diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e9796fd..fc99b5524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### Version 2.6.4 + +* Support automatic theme switching on Android 10 + ### Version 2.6.3 * Support for ?register and ?register;preauth XMPP uri parameters diff --git a/build.gradle b/build.gradle index 9a9bb4a4e..9705480b3 100644 --- a/build.gradle +++ b/build.gradle @@ -85,13 +85,13 @@ ext { } android { - compileSdkVersion 28 + compileSdkVersion 29 defaultConfig { minSdkVersion 16 targetSdkVersion 25 - versionCode 360 - versionName "2.6.3" + versionCode 362 + versionName "2.6.4" archivesBaseName += "-$versionName" applicationId "eu.sum7.conversations" resValue "string", "applicationId", applicationId diff --git a/fastlane/metadata/android/en-US/changelogs/349.txt b/fastlane/metadata/android/en-US/changelogs/349.txt new file mode 100644 index 000000000..b8f22adef --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/349.txt @@ -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 diff --git a/fastlane/metadata/android/en-US/changelogs/351.txt b/fastlane/metadata/android/en-US/changelogs/351.txt new file mode 100644 index 000000000..8fabff2f1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/351.txt @@ -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 diff --git a/fastlane/metadata/android/en-US/changelogs/353.txt b/fastlane/metadata/android/en-US/changelogs/353.txt new file mode 100644 index 000000000..63b829a80 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/353.txt @@ -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) diff --git a/fastlane/metadata/android/en-US/changelogs/360.txt b/fastlane/metadata/android/en-US/changelogs/360.txt new file mode 100644 index 000000000..87b92f033 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/360.txt @@ -0,0 +1 @@ +* Support for ?register and ?register;preauth XMPP uri parameters diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 000000000..783978e73 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -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 OMEMO or OpenPGP +* 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 +XEP’s. 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. diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png new file mode 100644 index 000000000..28128322f Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png new file mode 100644 index 000000000..a3ea7d690 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png new file mode 100644 index 000000000..76d112650 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png new file mode 100644 index 000000000..bf13cd25d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png new file mode 100644 index 000000000..44ce5f93b Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/06.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/06.png new file mode 100644 index 000000000..94840e6c6 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/06.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/07.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/07.png new file mode 100644 index 000000000..0d18d8ce6 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/07.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/01.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/01.png new file mode 100644 index 000000000..790cb6820 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/01.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/02.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/02.png new file mode 100644 index 000000000..a20c67f25 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/02.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/03.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/03.png new file mode 100644 index 000000000..03b513ea4 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/03.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/04.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/04.png new file mode 100644 index 000000000..f9391b38b Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/04.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 000000000..4b41cb27b --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +An encrypted, user friendly XMPP instant messaging client optimized for mobile \ No newline at end of file diff --git a/screenshots.xcf b/screenshots.xcf new file mode 100644 index 000000000..7e3aebc68 Binary files /dev/null and b/screenshots.xcf differ diff --git a/src/conversations/java/eu/siacs/conversations/ui/WelcomeActivity.java b/src/conversations/java/eu/siacs/conversations/ui/WelcomeActivity.java index df90dfb26..12695b6f7 100644 --- a/src/conversations/java/eu/siacs/conversations/ui/WelcomeActivity.java +++ b/src/conversations/java/eu/siacs/conversations/ui/WelcomeActivity.java @@ -52,12 +52,12 @@ public class WelcomeActivity extends XmppActivity { private boolean processXmppUri(final XmppUri xmppUri) { if (xmppUri.isValidJid()) { - final String preauth = xmppUri.getParamater("preauth"); + final String preauth = xmppUri.getParameter("preauth"); final Jid jid = xmppUri.getJid(); final Intent intent; if (xmppUri.isAction(XmppUri.ACTION_REGISTER)) { 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.putExtra(StartConversationActivity.EXTRA_INVITE_URI, xmppUri.toString()); } else { diff --git a/src/conversations/res/values-hu/strings.xml b/src/conversations/res/values-hu/strings.xml index 87b99b878..acc0984f0 100644 --- a/src/conversations/res/values-hu/strings.xml +++ b/src/conversations/res/values-hu/strings.xml @@ -5,4 +5,7 @@ Új fiók létrehozása 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. 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. + 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. + 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. + Az Ön kiszolgálómeghívása diff --git a/src/conversations/res/values-pl/strings.xml b/src/conversations/res/values-pl/strings.xml index 7f28014b4..3f03d437b 100644 --- a/src/conversations/res/values-pl/strings.xml +++ b/src/conversations/res/values-pl/strings.xml @@ -5,4 +5,7 @@ Stwórz nowe konto 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. 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. + 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. + 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. + Zaproszenie twojego serwera diff --git a/src/main/java/eu/siacs/conversations/Config.java b/src/main/java/eu/siacs/conversations/Config.java index 211590916..ff0bbb1b3 100644 --- a/src/main/java/eu/siacs/conversations/Config.java +++ b/src/main/java/eu/siacs/conversations/Config.java @@ -125,7 +125,7 @@ public final class Config { public static final long MAM_MAX_CATCHUP = MILLISECONDS_IN_DAY * 5; 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 EXPIRY_INTERVAL = 30 * 60 * 1000; // 30 minutes diff --git a/src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java b/src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java index 44336d4fc..018a86b5d 100644 --- a/src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java +++ b/src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java @@ -57,1512 +57,1511 @@ import rocks.xmpp.addr.Jid; public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { - public static final String PEP_PREFIX = "eu.siacs.conversations.axolotl"; - public static final String PEP_DEVICE_LIST = PEP_PREFIX + ".devicelist"; - public static final String PEP_DEVICE_LIST_NOTIFY = PEP_DEVICE_LIST + "+notify"; - public static final String PEP_BUNDLES = PEP_PREFIX + ".bundles"; - public static final String PEP_VERIFICATION = PEP_PREFIX + ".verification"; - public static final String PEP_OMEMO_WHITELISTED = PEP_PREFIX + ".whitelisted"; - - public static final String LOGPREFIX = "AxolotlService"; - - private static final int NUM_KEYS_TO_PUBLISH = 100; - private static final int publishTriesThreshold = 3; - - private final Account account; - private final XmppConnectionService mXmppConnectionService; - private final SQLiteAxolotlStore axolotlStore; - private final SessionMap sessions; - private final Map> deviceIds; - private final Map messageCache; - private final FetchStatusMap fetchStatusMap; - private final Map fetchDeviceListStatus = new HashMap<>(); - private final HashMap> fetchDeviceIdsMap = new HashMap<>(); - private final SerialSingleThreadExecutor executor; - private int numPublishTriesOnEmptyPep = 0; - private boolean pepBroken = false; - private final Set healingAttempts = new HashSet<>(); - private int lastDeviceListNotificationHash = 0; - private final HashSet cleanedOwnDeviceIds = new HashSet<>(); - private Set postponedSessions = new HashSet<>(); //sessions stored here will receive after mam catchup treatment - private Set postponedHealing = new HashSet<>(); //addresses stored here will need a healing notification after mam catchup - - private AtomicBoolean changeAccessMode = new AtomicBoolean(false); - - @Override - public void onAdvancedStreamFeaturesAvailable(Account account) { - if (Config.supportOmemo() - && account.getXmppConnection() != null - && account.getXmppConnection().getFeatures().pep()) { - publishBundlesIfNeeded(true, false); - } else { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": skipping OMEMO initialization"); - } - } - - private boolean hasErrorFetchingDeviceList(Jid jid) { - Boolean status = fetchDeviceListStatus.get(jid); - return status != null && !status; - } - - public boolean hasErrorFetchingDeviceList(List jids) { - for(Jid jid : jids) { - if (hasErrorFetchingDeviceList(jid)) { - return true; - } - } - return false; - } - - public boolean fetchMapHasErrors(List jids) { - for (Jid jid : jids) { - if (deviceIds.get(jid) != null) { - for (Integer foreignId : this.deviceIds.get(jid)) { - SignalProtocolAddress address = new SignalProtocolAddress(jid.toString(), foreignId); - if (fetchStatusMap.getAll(address.getName()).containsValue(FetchStatus.ERROR)) { - return true; - } - } - } - } - return false; - } - - public void preVerifyFingerprint(Contact contact, String fingerprint) { - axolotlStore.preVerifyFingerprint(contact.getAccount(), contact.getJid().asBareJid().toString(), fingerprint); - } - - public void preVerifyFingerprint(Account account, String fingerprint) { - axolotlStore.preVerifyFingerprint(account, account.getJid().asBareJid().toString(), fingerprint); - } - - public boolean hasVerifiedKeys(String name) { - for (XmppAxolotlSession session : this.sessions.getAll(name).values()) { - if (session.getTrust().isVerified()) { - return true; - } - } - return false; - } - - private static class AxolotlAddressMap { - protected Map> map; - protected final Object MAP_LOCK = new Object(); - - public AxolotlAddressMap() { - this.map = new HashMap<>(); - } - - public void put(SignalProtocolAddress address, T value) { - synchronized (MAP_LOCK) { - Map devices = map.get(address.getName()); - if (devices == null) { - devices = new HashMap<>(); - map.put(address.getName(), devices); - } - devices.put(address.getDeviceId(), value); - } - } - - public T get(SignalProtocolAddress address) { - synchronized (MAP_LOCK) { - Map devices = map.get(address.getName()); - if (devices == null) { - return null; - } - return devices.get(address.getDeviceId()); - } - } - - public Map getAll(String name) { - synchronized (MAP_LOCK) { - Map devices = map.get(name); - if (devices == null) { - return new HashMap<>(); - } - return devices; - } - } - - public boolean hasAny(SignalProtocolAddress address) { - synchronized (MAP_LOCK) { - Map devices = map.get(address.getName()); - return devices != null && !devices.isEmpty(); - } - } - - public void clear() { - map.clear(); - } - - } - - private static class SessionMap extends AxolotlAddressMap { - private final XmppConnectionService xmppConnectionService; - private final Account account; - - public SessionMap(XmppConnectionService service, SQLiteAxolotlStore store, Account account) { - super(); - this.xmppConnectionService = service; - this.account = account; - this.fillMap(store); - } - - public Set findCounterpartsForSourceId(Integer sid) { - Set candidates = new HashSet<>(); - synchronized (MAP_LOCK) { - for(Map.Entry> entry : map.entrySet()) { - String key = entry.getKey(); - if (entry.getValue().containsKey(sid)) { - candidates.add(Jid.of(key)); - } - } - } - return candidates; - } - - private void putDevicesForJid(String bareJid, List deviceIds, SQLiteAxolotlStore store) { - for (Integer deviceId : deviceIds) { - SignalProtocolAddress axolotlAddress = new SignalProtocolAddress(bareJid, deviceId); - IdentityKey identityKey = store.loadSession(axolotlAddress).getSessionState().getRemoteIdentityKey(); - if (Config.X509_VERIFICATION) { - X509Certificate certificate = store.getFingerprintCertificate(CryptoHelper.bytesToHex(identityKey.getPublicKey().serialize())); - if (certificate != null) { - Bundle information = CryptoHelper.extractCertificateInformation(certificate); - try { - final String cn = information.getString("subject_cn"); - final Jid jid = Jid.of(bareJid); - Log.d(Config.LOGTAG, "setting common name for " + jid + " to " + cn); - account.getRoster().getContact(jid).setCommonName(cn); - } catch (final IllegalArgumentException ignored) { - //ignored - } - } - } - this.put(axolotlAddress, new XmppAxolotlSession(account, store, axolotlAddress, identityKey)); - } - } - - private void fillMap(SQLiteAxolotlStore store) { - List deviceIds = store.getSubDeviceSessions(account.getJid().asBareJid().toString()); - putDevicesForJid(account.getJid().asBareJid().toString(), deviceIds, store); - for (String address : store.getKnownAddresses()) { - deviceIds = store.getSubDeviceSessions(address); - putDevicesForJid(address, deviceIds, store); - } - } - - @Override - public void put(SignalProtocolAddress address, XmppAxolotlSession value) { - super.put(address, value); - value.setNotFresh(); - } - - public void put(XmppAxolotlSession session) { - this.put(session.getRemoteAddress(), session); - } - } - - public enum FetchStatus { - PENDING, - SUCCESS, - SUCCESS_VERIFIED, - TIMEOUT, - SUCCESS_TRUSTED, - ERROR - } - - private static class FetchStatusMap extends AxolotlAddressMap { - - public void clearErrorFor(Jid jid) { - synchronized (MAP_LOCK) { - Map devices = this.map.get(jid.asBareJid().toString()); - if (devices == null) { - return; - } - for (Map.Entry entry : devices.entrySet()) { - if (entry.getValue() == FetchStatus.ERROR) { - Log.d(Config.LOGTAG, "resetting error for " + jid.asBareJid() + "(" + entry.getKey() + ")"); - entry.setValue(FetchStatus.TIMEOUT); - } - } - } - } - } - - public static String getLogprefix(Account account) { - return LOGPREFIX + " (" + account.getJid().asBareJid().toString() + "): "; - } - - public AxolotlService(Account account, XmppConnectionService connectionService) { - if (account == null || connectionService == null) { - throw new IllegalArgumentException("account and service cannot be null"); - } - if (Security.getProvider("BC") == null) { - Security.addProvider(new BouncyCastleProvider()); - } - this.mXmppConnectionService = connectionService; - this.account = account; - this.axolotlStore = new SQLiteAxolotlStore(this.account, this.mXmppConnectionService); - this.deviceIds = new HashMap<>(); - this.messageCache = new HashMap<>(); - this.sessions = new SessionMap(mXmppConnectionService, axolotlStore, account); - this.fetchStatusMap = new FetchStatusMap(); - this.executor = new SerialSingleThreadExecutor("Axolotl"); - } - - public String getOwnFingerprint() { - return CryptoHelper.bytesToHex(axolotlStore.getIdentityKeyPair().getPublicKey().serialize()); - } - - public Set getKeysWithTrust(FingerprintStatus status) { - return axolotlStore.getContactKeysWithTrust(account.getJid().asBareJid().toString(), status); - } - - public Set getKeysWithTrust(FingerprintStatus status, Jid jid) { - return axolotlStore.getContactKeysWithTrust(jid.asBareJid().toString(), status); - } - - public Set getKeysWithTrust(FingerprintStatus status, List jids) { - Set keys = new HashSet<>(); - for (Jid jid : jids) { - keys.addAll(axolotlStore.getContactKeysWithTrust(jid.toString(), status)); - } - return keys; - } - - public Set findCounterpartsBySourceId(int sid) { - return sessions.findCounterpartsForSourceId(sid); - } - - public long getNumTrustedKeys(Jid jid) { - return axolotlStore.getContactNumTrustedKeys(jid.asBareJid().toString()); - } - - public boolean anyTargetHasNoTrustedKeys(List jids) { - for (Jid jid : jids) { - if (axolotlStore.getContactNumTrustedKeys(jid.asBareJid().toString()) == 0) { - return true; - } - } - return false; - } - - private SignalProtocolAddress getAddressForJid(Jid jid) { - return new SignalProtocolAddress(jid.toString(), 0); - } - - public Collection findOwnSessions() { - SignalProtocolAddress ownAddress = getAddressForJid(account.getJid().asBareJid()); - ArrayList s = new ArrayList<>(this.sessions.getAll(ownAddress.getName()).values()); - Collections.sort(s); - return s; - } - - - public Collection findSessionsForContact(Contact contact) { - SignalProtocolAddress contactAddress = getAddressForJid(contact.getJid()); - ArrayList s = new ArrayList<>(this.sessions.getAll(contactAddress.getName()).values()); - Collections.sort(s); - return s; - } - - private Set findSessionsForConversation(Conversation conversation) { - if (conversation.getContact().isSelf()) { - //will be added in findOwnSessions() - return Collections.emptySet(); - } - HashSet sessions = new HashSet<>(); - for (Jid jid : conversation.getAcceptedCryptoTargets()) { - sessions.addAll(this.sessions.getAll(getAddressForJid(jid).getName()).values()); - } - return sessions; - } - - private boolean hasAny(Jid jid) { - return sessions.hasAny(getAddressForJid(jid)); - } - - public boolean isPepBroken() { - return this.pepBroken; - } - - public void resetBrokenness() { - this.pepBroken = false; - this.numPublishTriesOnEmptyPep = 0; - this.lastDeviceListNotificationHash = 0; - this.healingAttempts.clear(); - } - - public void clearErrorsInFetchStatusMap(Jid jid) { - fetchStatusMap.clearErrorFor(jid); - fetchDeviceListStatus.remove(jid); - } - - public void regenerateKeys(boolean wipeOther) { - axolotlStore.regenerate(); - sessions.clear(); - fetchStatusMap.clear(); - fetchDeviceIdsMap.clear(); - fetchDeviceListStatus.clear(); - publishBundlesIfNeeded(true, wipeOther); - } - - public void destroy() { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": destroying old axolotl service. no longer in use"); - mXmppConnectionService.databaseBackend.wipeAxolotlDb(account); - } - - public AxolotlService makeNew() { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": make new axolotl service"); - return new AxolotlService(this.account, this.mXmppConnectionService); - } - - public int getOwnDeviceId() { - return axolotlStore.getLocalRegistrationId(); - } - - public SignalProtocolAddress getOwnAxolotlAddress() { - return new SignalProtocolAddress(account.getJid().asBareJid().toString(), getOwnDeviceId()); - } - - public Set getOwnDeviceIds() { - return this.deviceIds.get(account.getJid().asBareJid()); - } - - public void registerDevices(final Jid jid, @NonNull final Set deviceIds) { - final int hash = deviceIds.hashCode(); - final boolean me = jid.asBareJid().equals(account.getJid().asBareJid()); - if (me) { - if (hash != 0 && hash == this.lastDeviceListNotificationHash) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring duplicate own device id list"); - return; - } - this.lastDeviceListNotificationHash = hash; - } - boolean needsPublishing = me && !deviceIds.contains(getOwnDeviceId()); - if (me) { - deviceIds.remove(getOwnDeviceId()); - } - Set expiredDevices = new HashSet<>(axolotlStore.getSubDeviceSessions(jid.asBareJid().toString())); - expiredDevices.removeAll(deviceIds); - for (Integer deviceId : expiredDevices) { - SignalProtocolAddress address = new SignalProtocolAddress(jid.asBareJid().toString(), deviceId); - XmppAxolotlSession session = sessions.get(address); - if (session != null && session.getFingerprint() != null) { - if (session.getTrust().isActive()) { - session.setTrust(session.getTrust().toInactive()); - } - } - } - Set newDevices = new HashSet<>(deviceIds); - for (Integer deviceId : newDevices) { - SignalProtocolAddress address = new SignalProtocolAddress(jid.asBareJid().toString(), deviceId); - XmppAxolotlSession session = sessions.get(address); - if (session != null && session.getFingerprint() != null) { - if (!session.getTrust().isActive()) { - Log.d(Config.LOGTAG, "reactivating device with fingerprint " + session.getFingerprint()); - session.setTrust(session.getTrust().toActive()); - } - } - } - if (me) { - if (Config.OMEMO_AUTO_EXPIRY != 0) { - needsPublishing |= deviceIds.removeAll(getExpiredDevices()); - } - needsPublishing |= this.changeAccessMode.get(); - for (Integer deviceId : deviceIds) { - SignalProtocolAddress ownDeviceAddress = new SignalProtocolAddress(jid.asBareJid().toString(), deviceId); - if (sessions.get(ownDeviceAddress) == null) { - FetchStatus status = fetchStatusMap.get(ownDeviceAddress); - if (status == null || status == FetchStatus.TIMEOUT) { - fetchStatusMap.put(ownDeviceAddress, FetchStatus.PENDING); - this.buildSessionFromPEP(ownDeviceAddress); - } - } - } - if (needsPublishing) { - publishOwnDeviceId(deviceIds); - } - } - final Set oldSet = this.deviceIds.get(jid); - final boolean changed = oldSet == null || oldSet.hashCode() != hash; - this.deviceIds.put(jid, deviceIds); - if (changed) { - mXmppConnectionService.updateConversationUi(); //update the lock icon - mXmppConnectionService.keyStatusUpdated(null); - if (me) { - mXmppConnectionService.updateAccountUi(); - } - } else { - Log.d(Config.LOGTAG,"skipped device list update because it hasn't changed"); - } - } - - public void wipeOtherPepDevices() { - if (pepBroken) { - Log.d(Config.LOGTAG, getLogprefix(account) + "wipeOtherPepDevices called, but PEP is broken. Ignoring... "); - return; - } - Set deviceIds = new HashSet<>(); - deviceIds.add(getOwnDeviceId()); - publishDeviceIdsAndRefineAccessModel(deviceIds); - } - - public void distrustFingerprint(final String fingerprint) { - final String fp = fingerprint.replaceAll("\\s", ""); - final FingerprintStatus fingerprintStatus = axolotlStore.getFingerprintStatus(fp); - axolotlStore.setFingerprintStatus(fp, fingerprintStatus.toUntrusted()); - } - - private void publishOwnDeviceIdIfNeeded() { - if (pepBroken) { - Log.d(Config.LOGTAG, getLogprefix(account) + "publishOwnDeviceIdIfNeeded called, but PEP is broken. Ignoring... "); - return; - } - IqPacket packet = mXmppConnectionService.getIqGenerator().retrieveDeviceIds(account.getJid().asBareJid()); - mXmppConnectionService.sendIqPacket(account, packet, new OnIqPacketReceived() { - @Override - public void onIqPacketReceived(Account account, IqPacket packet) { - if (packet.getType() == IqPacket.TYPE.TIMEOUT) { - Log.d(Config.LOGTAG, getLogprefix(account) + "Timeout received while retrieving own Device Ids."); - } else { - //TODO consider calling registerDevices only after item-not-found to account for broken PEPs - Element item = mXmppConnectionService.getIqParser().getItem(packet); - Set deviceIds = mXmppConnectionService.getIqParser().deviceIds(item); - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": retrieved own device list: " + deviceIds); - registerDevices(account.getJid().asBareJid(), deviceIds); - } - } - }); - } - - private Set getExpiredDevices() { - Set devices = new HashSet<>(); - for (XmppAxolotlSession session : findOwnSessions()) { - if (session.getTrust().isActive()) { - long diff = System.currentTimeMillis() - session.getTrust().getLastActivation(); - if (diff > Config.OMEMO_AUTO_EXPIRY) { - long lastMessageDiff = System.currentTimeMillis() - mXmppConnectionService.databaseBackend.getLastTimeFingerprintUsed(account, session.getFingerprint()); - long hours = Math.round(lastMessageDiff / (1000 * 60.0 * 60.0)); - if (lastMessageDiff > Config.OMEMO_AUTO_EXPIRY) { - devices.add(session.getRemoteAddress().getDeviceId()); - session.setTrust(session.getTrust().toInactive()); - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": added own device " + session.getFingerprint() + " to list of expired devices. Last message received " + hours + " hours ago"); - } else { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": own device " + session.getFingerprint() + " was active " + hours + " hours ago"); - } - } //TODO print last activation diff - } - } - return devices; - } - - private void publishOwnDeviceId(Set deviceIds) { - Set deviceIdsCopy = new HashSet<>(deviceIds); - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "publishing own device ids"); - if (deviceIdsCopy.isEmpty()) { - if (numPublishTriesOnEmptyPep >= publishTriesThreshold) { - Log.w(Config.LOGTAG, getLogprefix(account) + "Own device publish attempt threshold exceeded, aborting..."); - pepBroken = true; - return; - } else { - numPublishTriesOnEmptyPep++; - Log.w(Config.LOGTAG, getLogprefix(account) + "Own device list empty, attempting to publish (try " + numPublishTriesOnEmptyPep + ")"); - } - } else { - numPublishTriesOnEmptyPep = 0; - } - deviceIdsCopy.add(getOwnDeviceId()); - publishDeviceIdsAndRefineAccessModel(deviceIdsCopy); - } - - private void publishDeviceIdsAndRefineAccessModel(Set ids) { - publishDeviceIdsAndRefineAccessModel(ids, true); - } - - private void publishDeviceIdsAndRefineAccessModel(final Set ids, final boolean firstAttempt) { - final Bundle publishOptions = account.getXmppConnection().getFeatures().pepPublishOptions() ? PublishOptions.openAccess() : null; - IqPacket publish = mXmppConnectionService.getIqGenerator().publishDeviceIds(ids, publishOptions); - mXmppConnectionService.sendIqPacket(account, publish, new OnIqPacketReceived() { - @Override - public void onIqPacketReceived(Account account, IqPacket packet) { - final Element error = packet.getType() == IqPacket.TYPE.ERROR ? packet.findChild("error") : null; - final boolean preConditionNotMet = PublishOptions.preconditionNotMet(packet); - if (firstAttempt && preConditionNotMet) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": precondition wasn't met for device list. pushing node configuration"); - mXmppConnectionService.pushNodeConfiguration(account, AxolotlService.PEP_DEVICE_LIST, publishOptions, new XmppConnectionService.OnConfigurationPushed() { - @Override - public void onPushSucceeded() { - publishDeviceIdsAndRefineAccessModel(ids, false); - } - - @Override - public void onPushFailed() { - publishDeviceIdsAndRefineAccessModel(ids, false); - } - }); - } else { - if (AxolotlService.this.changeAccessMode.compareAndSet(true, false)) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": done changing access mode"); - account.setOption(Account.OPTION_REQUIRES_ACCESS_MODE_CHANGE, false); - mXmppConnectionService.databaseBackend.updateAccount(account); - } - if (packet.getType() == IqPacket.TYPE.ERROR) { - if (preConditionNotMet) { - Log.d(Config.LOGTAG,account.getJid().asBareJid()+": device list pre condition still not met on second attempt"); - } else if (error != null) { - pepBroken = true; - Log.d(Config.LOGTAG, getLogprefix(account) + "Error received while publishing own device id" + packet.findChild("error")); - } - - } - } - } - }); - } - - public void publishDeviceVerificationAndBundle(final SignedPreKeyRecord signedPreKeyRecord, - final Set preKeyRecords, - final boolean announceAfter, - final boolean wipe) { - try { - IdentityKey axolotlPublicKey = axolotlStore.getIdentityKeyPair().getPublicKey(); - PrivateKey x509PrivateKey = KeyChain.getPrivateKey(mXmppConnectionService, account.getPrivateKeyAlias()); - X509Certificate[] chain = KeyChain.getCertificateChain(mXmppConnectionService, account.getPrivateKeyAlias()); - Signature verifier = Signature.getInstance("sha256WithRSA"); - verifier.initSign(x509PrivateKey, mXmppConnectionService.getRNG()); - verifier.update(axolotlPublicKey.serialize()); - byte[] signature = verifier.sign(); - IqPacket packet = mXmppConnectionService.getIqGenerator().publishVerification(signature, chain, getOwnDeviceId()); - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + ": publish verification for device " + getOwnDeviceId()); - mXmppConnectionService.sendIqPacket(account, packet, new OnIqPacketReceived() { - @Override - public void onIqPacketReceived(final Account account, IqPacket packet) { - String node = AxolotlService.PEP_VERIFICATION + ":" + getOwnDeviceId(); - mXmppConnectionService.pushNodeConfiguration(account, node, PublishOptions.openAccess(), new XmppConnectionService.OnConfigurationPushed() { - @Override - public void onPushSucceeded() { - Log.d(Config.LOGTAG, getLogprefix(account) + "configured verification node to be world readable"); - publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe); - } - - @Override - public void onPushFailed() { - Log.d(Config.LOGTAG, getLogprefix(account) + "unable to set access model on verification node"); - publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe); - } - }); - } - }); - } catch (Exception e) { - e.printStackTrace(); - } - } - - public void publishBundlesIfNeeded(final boolean announce, final boolean wipe) { - if (pepBroken) { - Log.d(Config.LOGTAG, getLogprefix(account) + "publishBundlesIfNeeded called, but PEP is broken. Ignoring... "); - return; - } - - if (account.getXmppConnection().getFeatures().pepPublishOptions()) { - this.changeAccessMode.set(account.isOptionSet(Account.OPTION_REQUIRES_ACCESS_MODE_CHANGE)); - } else { - if (account.setOption(Account.OPTION_REQUIRES_ACCESS_MODE_CHANGE, true)) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": server doesn’t support publish-options. setting for later access mode change"); - mXmppConnectionService.databaseBackend.updateAccount(account); - } - } - if (this.changeAccessMode.get()) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": server gained publish-options capabilities. changing access model"); - } - IqPacket packet = mXmppConnectionService.getIqGenerator().retrieveBundlesForDevice(account.getJid().asBareJid(), getOwnDeviceId()); - mXmppConnectionService.sendIqPacket(account, packet, new OnIqPacketReceived() { - @Override - public void onIqPacketReceived(Account account, IqPacket packet) { - - if (packet.getType() == IqPacket.TYPE.TIMEOUT) { - return; //ignore timeout. do nothing - } - - if (packet.getType() == IqPacket.TYPE.ERROR) { - Element error = packet.findChild("error"); - if (error == null || !error.hasChild("item-not-found")) { - pepBroken = true; - Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "request for device bundles came back with something other than item-not-found" + packet); - return; - } - } - - PreKeyBundle bundle = mXmppConnectionService.getIqParser().bundle(packet); - Map keys = mXmppConnectionService.getIqParser().preKeyPublics(packet); - boolean flush = false; - if (bundle == null) { - Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Received invalid bundle:" + packet); - bundle = new PreKeyBundle(-1, -1, -1, null, -1, null, null, null); - flush = true; - } - if (keys == null) { - Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Received invalid prekeys:" + packet); - } - try { - boolean changed = false; - // Validate IdentityKey - IdentityKeyPair identityKeyPair = axolotlStore.getIdentityKeyPair(); - if (flush || !identityKeyPair.getPublicKey().equals(bundle.getIdentityKey())) { - Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding own IdentityKey " + identityKeyPair.getPublicKey() + " to PEP."); - changed = true; - } - - // Validate signedPreKeyRecord + ID - SignedPreKeyRecord signedPreKeyRecord; - int numSignedPreKeys = axolotlStore.getSignedPreKeysCount(); - try { - signedPreKeyRecord = axolotlStore.loadSignedPreKey(bundle.getSignedPreKeyId()); - if (flush - || !bundle.getSignedPreKey().equals(signedPreKeyRecord.getKeyPair().getPublicKey()) - || !Arrays.equals(bundle.getSignedPreKeySignature(), signedPreKeyRecord.getSignature())) { - Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding new signedPreKey with ID " + (numSignedPreKeys + 1) + " to PEP."); - signedPreKeyRecord = KeyHelper.generateSignedPreKey(identityKeyPair, numSignedPreKeys + 1); - axolotlStore.storeSignedPreKey(signedPreKeyRecord.getId(), signedPreKeyRecord); - changed = true; - } - } catch (InvalidKeyIdException e) { - Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding new signedPreKey with ID " + (numSignedPreKeys + 1) + " to PEP."); - signedPreKeyRecord = KeyHelper.generateSignedPreKey(identityKeyPair, numSignedPreKeys + 1); - axolotlStore.storeSignedPreKey(signedPreKeyRecord.getId(), signedPreKeyRecord); - changed = true; - } - - // Validate PreKeys - Set preKeyRecords = new HashSet<>(); - if (keys != null) { - for (Integer id : keys.keySet()) { - try { - PreKeyRecord preKeyRecord = axolotlStore.loadPreKey(id); - if (preKeyRecord.getKeyPair().getPublicKey().equals(keys.get(id))) { - preKeyRecords.add(preKeyRecord); - } - } catch (InvalidKeyIdException ignored) { - } - } - } - int newKeys = NUM_KEYS_TO_PUBLISH - preKeyRecords.size(); - if (newKeys > 0) { - List newRecords = KeyHelper.generatePreKeys( - axolotlStore.getCurrentPreKeyId() + 1, newKeys); - preKeyRecords.addAll(newRecords); - for (PreKeyRecord record : newRecords) { - axolotlStore.storePreKey(record.getId(), record); - } - changed = true; - Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding " + newKeys + " new preKeys to PEP."); - } - - - if (changed || changeAccessMode.get()) { - if (account.getPrivateKeyAlias() != null && Config.X509_VERIFICATION) { - mXmppConnectionService.publishDisplayName(account); - publishDeviceVerificationAndBundle(signedPreKeyRecord, preKeyRecords, announce, wipe); - } else { - publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announce, wipe); - } - } else { - Log.d(Config.LOGTAG, getLogprefix(account) + "Bundle " + getOwnDeviceId() + " in PEP was current"); - if (wipe) { - wipeOtherPepDevices(); - } else if (announce) { - Log.d(Config.LOGTAG, getLogprefix(account) + "Announcing device " + getOwnDeviceId()); - publishOwnDeviceIdIfNeeded(); - } - } - } catch (InvalidKeyException e) { - Log.e(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Failed to publish bundle " + getOwnDeviceId() + ", reason: " + e.getMessage()); - } - } - }); - } - - private void publishDeviceBundle(SignedPreKeyRecord signedPreKeyRecord, - Set preKeyRecords, - final boolean announceAfter, - final boolean wipe) { - publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe, true); - } - - private void publishDeviceBundle(final SignedPreKeyRecord signedPreKeyRecord, - final Set preKeyRecords, - final boolean announceAfter, - final boolean wipe, - final boolean firstAttempt) { - final Bundle publishOptions = account.getXmppConnection().getFeatures().pepPublishOptions() ? PublishOptions.openAccess() : null; - IqPacket publish = mXmppConnectionService.getIqGenerator().publishBundles( - signedPreKeyRecord, axolotlStore.getIdentityKeyPair().getPublicKey(), - preKeyRecords, getOwnDeviceId(), publishOptions); - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + ": Bundle " + getOwnDeviceId() + " in PEP not current. Publishing..."); - mXmppConnectionService.sendIqPacket(account, publish, new OnIqPacketReceived() { - @Override - public void onIqPacketReceived(final Account account, IqPacket packet) { - final boolean preconditionNotMet = PublishOptions.preconditionNotMet(packet); - if (firstAttempt && preconditionNotMet) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": precondition wasn't met for bundle. pushing node configuration"); - final String node = AxolotlService.PEP_BUNDLES + ":" + getOwnDeviceId(); - mXmppConnectionService.pushNodeConfiguration(account, node, publishOptions, new XmppConnectionService.OnConfigurationPushed() { - @Override - public void onPushSucceeded() { - publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe, false); - } - - @Override - public void onPushFailed() { - publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe, false); - } - }); - } else if (packet.getType() == IqPacket.TYPE.RESULT) { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Successfully published bundle. "); - if (wipe) { - wipeOtherPepDevices(); - } else if (announceAfter) { - Log.d(Config.LOGTAG, getLogprefix(account) + "Announcing device " + getOwnDeviceId()); - publishOwnDeviceIdIfNeeded(); - } - } else if (packet.getType() == IqPacket.TYPE.ERROR) { - if (preconditionNotMet) { - Log.d(Config.LOGTAG,getLogprefix(account) + "bundle precondition still not met after second attempt"); - } else { - Log.d(Config.LOGTAG, getLogprefix(account) + "Error received while publishing bundle: " + packet.toString()); - } - pepBroken = true; - } - } - }); - } - - public void deleteOmemoIdentity() { - final String node = AxolotlService.PEP_BUNDLES + ":" + getOwnDeviceId(); - final IqPacket deleteBundleNode = mXmppConnectionService.getIqGenerator().deleteNode(node); - mXmppConnectionService.sendIqPacket(account, deleteBundleNode, null); - final Set ownDeviceIds = getOwnDeviceIds(); - publishDeviceIdsAndRefineAccessModel(ownDeviceIds == null ? Collections.emptySet() : ownDeviceIds); - } - - public List getCryptoTargets(Conversation conversation) { - final List jids; - if (conversation.getMode() == Conversation.MODE_SINGLE) { - jids = new ArrayList<>(); - jids.add(conversation.getJid().asBareJid()); - } else { - jids = conversation.getMucOptions().getMembers(false); - } - return jids; - } - - public FingerprintStatus getFingerprintTrust(String fingerprint) { - return axolotlStore.getFingerprintStatus(fingerprint); - } - - public X509Certificate getFingerprintCertificate(String fingerprint) { - return axolotlStore.getFingerprintCertificate(fingerprint); - } - - public void setFingerprintTrust(String fingerprint, FingerprintStatus status) { - axolotlStore.setFingerprintStatus(fingerprint, status); - } - - private void verifySessionWithPEP(final XmppAxolotlSession session) { - Log.d(Config.LOGTAG, "trying to verify fresh session (" + session.getRemoteAddress().getName() + ") with pep"); - final SignalProtocolAddress address = session.getRemoteAddress(); - final IdentityKey identityKey = session.getIdentityKey(); - try { - IqPacket packet = mXmppConnectionService.getIqGenerator().retrieveVerificationForDevice(Jid.of(address.getName()), address.getDeviceId()); - mXmppConnectionService.sendIqPacket(account, packet, new OnIqPacketReceived() { - @Override - public void onIqPacketReceived(Account account, IqPacket packet) { - Pair verification = mXmppConnectionService.getIqParser().verification(packet); - if (verification != null) { - try { - Signature verifier = Signature.getInstance("sha256WithRSA"); - verifier.initVerify(verification.first[0]); - verifier.update(identityKey.serialize()); - if (verifier.verify(verification.second)) { - try { - mXmppConnectionService.getMemorizingTrustManager().getNonInteractive().checkClientTrusted(verification.first, "RSA"); - String fingerprint = session.getFingerprint(); - Log.d(Config.LOGTAG, "verified session with x.509 signature. fingerprint was: " + fingerprint); - setFingerprintTrust(fingerprint, FingerprintStatus.createActiveVerified(true)); - axolotlStore.setFingerprintCertificate(fingerprint, verification.first[0]); - fetchStatusMap.put(address, FetchStatus.SUCCESS_VERIFIED); - Bundle information = CryptoHelper.extractCertificateInformation(verification.first[0]); - try { - final String cn = information.getString("subject_cn"); - final Jid jid = Jid.of(address.getName()); - Log.d(Config.LOGTAG, "setting common name for " + jid + " to " + cn); - account.getRoster().getContact(jid).setCommonName(cn); - } catch (final IllegalArgumentException ignored) { - //ignored - } - finishBuildingSessionsFromPEP(address); - return; - } catch (Exception e) { - Log.d(Config.LOGTAG, "could not verify certificate"); - } - } - } catch (Exception e) { - Log.d(Config.LOGTAG, "error during verification " + e.getMessage()); - } - } else { - Log.d(Config.LOGTAG, "no verification found"); - } - fetchStatusMap.put(address, FetchStatus.SUCCESS); - finishBuildingSessionsFromPEP(address); - } - }); - } catch (IllegalArgumentException e) { - fetchStatusMap.put(address, FetchStatus.SUCCESS); - finishBuildingSessionsFromPEP(address); - } - } - - private final Set PREVIOUSLY_REMOVED_FROM_ANNOUNCEMENT = new HashSet<>(); - - private void finishBuildingSessionsFromPEP(final SignalProtocolAddress address) { - SignalProtocolAddress ownAddress = new SignalProtocolAddress(account.getJid().asBareJid().toString(), 0); - Map own = fetchStatusMap.getAll(ownAddress.getName()); - Map remote = fetchStatusMap.getAll(address.getName()); - if (!own.containsValue(FetchStatus.PENDING) && !remote.containsValue(FetchStatus.PENDING)) { - FetchStatus report = null; - if (own.containsValue(FetchStatus.SUCCESS) || remote.containsValue(FetchStatus.SUCCESS)) { - report = FetchStatus.SUCCESS; - } else if (own.containsValue(FetchStatus.SUCCESS_VERIFIED) || remote.containsValue(FetchStatus.SUCCESS_VERIFIED)) { - report = FetchStatus.SUCCESS_VERIFIED; - } else if (own.containsValue(FetchStatus.SUCCESS_TRUSTED) || remote.containsValue(FetchStatus.SUCCESS_TRUSTED)) { - report = FetchStatus.SUCCESS_TRUSTED; - } else if (own.containsValue(FetchStatus.ERROR) || remote.containsValue(FetchStatus.ERROR)) { - report = FetchStatus.ERROR; - } - mXmppConnectionService.keyStatusUpdated(report); - } - if (Config.REMOVE_BROKEN_DEVICES) { - Set ownDeviceIds = new HashSet<>(getOwnDeviceIds()); - boolean publish = false; - for (Map.Entry entry : own.entrySet()) { - int id = entry.getKey(); - if (entry.getValue() == FetchStatus.ERROR && PREVIOUSLY_REMOVED_FROM_ANNOUNCEMENT.add(id) && ownDeviceIds.remove(id)) { - publish = true; - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": error fetching own device with id " + id + ". removing from announcement"); - } - } - if (publish) { - publishOwnDeviceId(ownDeviceIds); - } - } - } - - public boolean hasEmptyDeviceList(Jid jid) { - return !hasAny(jid) && (!deviceIds.containsKey(jid) || deviceIds.get(jid).isEmpty()); - } - - public interface OnDeviceIdsFetched { - void fetched(Jid jid, Set deviceIds); - } - - public interface OnMultipleDeviceIdFetched { - void fetched(); - } - - public void fetchDeviceIds(final Jid jid) { - fetchDeviceIds(jid, null); - } - - private void fetchDeviceIds(final Jid jid, OnDeviceIdsFetched callback) { - IqPacket packet; - synchronized (this.fetchDeviceIdsMap) { - List callbacks = this.fetchDeviceIdsMap.get(jid); - if (callbacks != null) { - if (callback != null) { - callbacks.add(callback); - } - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": fetching device ids for " + jid + " already running. adding callback"); - packet = null; - } else { - callbacks = new ArrayList<>(); - if (callback != null) { - callbacks.add(callback); - } - this.fetchDeviceIdsMap.put(jid, callbacks); - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": fetching device ids for " + jid); - packet = mXmppConnectionService.getIqGenerator().retrieveDeviceIds(jid); - } - } - if (packet != null) { - mXmppConnectionService.sendIqPacket(account, packet, (account, response) -> { - if (response.getType() == IqPacket.TYPE.RESULT) { - fetchDeviceListStatus.put(jid, true); - Element item = mXmppConnectionService.getIqParser().getItem(response); - Set deviceIds = mXmppConnectionService.getIqParser().deviceIds(item); - registerDevices(jid, deviceIds); - final List callbacks; - synchronized (fetchDeviceIdsMap) { - callbacks = fetchDeviceIdsMap.remove(jid); - } - if (callbacks != null) { - for (OnDeviceIdsFetched c : callbacks) { - c.fetched(jid, deviceIds); - } - } - } else { - if (response.getType() == IqPacket.TYPE.TIMEOUT) { - fetchDeviceListStatus.remove(jid); - } else { - fetchDeviceListStatus.put(jid, false); - } - final List callbacks; - synchronized (fetchDeviceIdsMap) { - callbacks = fetchDeviceIdsMap.remove(jid); - } - if (callbacks != null) { - for (OnDeviceIdsFetched c : callbacks) { - c.fetched(jid, null); - } - } - } - }); - } - } - - private void fetchDeviceIds(List jids, final OnMultipleDeviceIdFetched callback) { - final ArrayList unfinishedJids = new ArrayList<>(jids); - synchronized (unfinishedJids) { - for (Jid jid : unfinishedJids) { - fetchDeviceIds(jid, (j, deviceIds) -> { - synchronized (unfinishedJids) { - unfinishedJids.remove(j); - if (unfinishedJids.size() == 0 && callback != null) { - callback.fetched(); - } - } - }); - } - } - } - - interface OnSessionBuildFromPep { - void onSessionBuildSuccessful(); - void onSessionBuildFailed(); - } - - private void buildSessionFromPEP(final SignalProtocolAddress address) { - buildSessionFromPEP(address, null); - } - - private void buildSessionFromPEP(final SignalProtocolAddress address, OnSessionBuildFromPep callback) { - Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Building new session for " + address.toString()); - if (address.equals(getOwnAxolotlAddress())) { - throw new AssertionError("We should NEVER build a session with ourselves. What happened here?!"); - } - - final Jid jid = Jid.of(address.getName()); - final boolean oneOfOurs = jid.asBareJid().equals(account.getJid().asBareJid()); - IqPacket bundlesPacket = mXmppConnectionService.getIqGenerator().retrieveBundlesForDevice(jid, address.getDeviceId()); - mXmppConnectionService.sendIqPacket(account, bundlesPacket, (account, packet) -> { - if (packet.getType() == IqPacket.TYPE.TIMEOUT) { - fetchStatusMap.put(address, FetchStatus.TIMEOUT); - } else if (packet.getType() == IqPacket.TYPE.RESULT) { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Received preKey IQ packet, processing..."); - final IqParser parser = mXmppConnectionService.getIqParser(); - final List preKeyBundleList = parser.preKeys(packet); - final PreKeyBundle bundle = parser.bundle(packet); - if (preKeyBundleList.isEmpty() || bundle == null) { - Log.e(Config.LOGTAG, AxolotlService.getLogprefix(account) + "preKey IQ packet invalid: " + packet); - fetchStatusMap.put(address, FetchStatus.ERROR); - finishBuildingSessionsFromPEP(address); - if (callback != null) { - callback.onSessionBuildFailed(); - } - return; - } - Random random = new Random(); - final PreKeyBundle preKey = preKeyBundleList.get(random.nextInt(preKeyBundleList.size())); - if (preKey == null) { - //should never happen - fetchStatusMap.put(address, FetchStatus.ERROR); - finishBuildingSessionsFromPEP(address); - if (callback != null) { - callback.onSessionBuildFailed(); - } - return; - } - - final PreKeyBundle preKeyBundle = new PreKeyBundle(0, address.getDeviceId(), - preKey.getPreKeyId(), preKey.getPreKey(), - bundle.getSignedPreKeyId(), bundle.getSignedPreKey(), - bundle.getSignedPreKeySignature(), bundle.getIdentityKey()); - - try { - SessionBuilder builder = new SessionBuilder(axolotlStore, address); - builder.process(preKeyBundle); - XmppAxolotlSession session = new XmppAxolotlSession(account, axolotlStore, address, bundle.getIdentityKey()); - sessions.put(address, session); - if (Config.X509_VERIFICATION) { - verifySessionWithPEP(session); //TODO; maybe inject callback in here too - } else { - FingerprintStatus status = getFingerprintTrust(CryptoHelper.bytesToHex(bundle.getIdentityKey().getPublicKey().serialize())); - FetchStatus fetchStatus; - if (status != null && status.isVerified()) { - fetchStatus = FetchStatus.SUCCESS_VERIFIED; - } else if (status != null && status.isTrusted()) { - fetchStatus = FetchStatus.SUCCESS_TRUSTED; - } else { - fetchStatus = FetchStatus.SUCCESS; - } - fetchStatusMap.put(address, fetchStatus); - finishBuildingSessionsFromPEP(address); - if (callback != null) { - callback.onSessionBuildSuccessful(); - } - } - } catch (UntrustedIdentityException | InvalidKeyException e) { - Log.e(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Error building session for " + address + ": " - + e.getClass().getName() + ", " + e.getMessage()); - fetchStatusMap.put(address, FetchStatus.ERROR); - finishBuildingSessionsFromPEP(address); - if (oneOfOurs && cleanedOwnDeviceIds.add(address.getDeviceId())) { - removeFromDeviceAnnouncement(address.getDeviceId()); - } - if (callback != null) { - callback.onSessionBuildFailed(); - } - } - } else { - fetchStatusMap.put(address, FetchStatus.ERROR); - Element error = packet.findChild("error"); - boolean itemNotFound = error != null && error.hasChild("item-not-found"); - Log.d(Config.LOGTAG, getLogprefix(account) + "Error received while building session:" + packet.findChild("error")); - finishBuildingSessionsFromPEP(address); - if (oneOfOurs && itemNotFound && cleanedOwnDeviceIds.add(address.getDeviceId())) { - removeFromDeviceAnnouncement(address.getDeviceId()); - } - if (callback != null) { - callback.onSessionBuildFailed(); - } - } - }); - } - - private void removeFromDeviceAnnouncement(Integer id) { - HashSet temp = new HashSet<>(getOwnDeviceIds()); - if (temp.remove(id)) { - Log.d(Config.LOGTAG,account.getJid().asBareJid()+" remove own device id "+id+" from announcement. devices left:"+temp); - publishOwnDeviceId(temp); - } - } - - public Set findDevicesWithoutSession(final Conversation conversation) { - Set addresses = new HashSet<>(); - for (Jid jid : getCryptoTargets(conversation)) { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Finding devices without session for " + jid); - final Set ids = deviceIds.get(jid); - if (ids != null && !ids.isEmpty()) { - for (Integer foreignId : ids) { - SignalProtocolAddress address = new SignalProtocolAddress(jid.toString(), foreignId); - if (sessions.get(address) == null) { - IdentityKey identityKey = axolotlStore.loadSession(address).getSessionState().getRemoteIdentityKey(); - if (identityKey != null) { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Already have session for " + address.toString() + ", adding to cache..."); - XmppAxolotlSession session = new XmppAxolotlSession(account, axolotlStore, address, identityKey); - sessions.put(address, session); - } else { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Found device " + jid + ":" + foreignId); - if (fetchStatusMap.get(address) != FetchStatus.ERROR) { - addresses.add(address); - } else { - Log.d(Config.LOGTAG, getLogprefix(account) + "skipping over " + address + " because it's broken"); - } - } - } - } - } else { - mXmppConnectionService.keyStatusUpdated(FetchStatus.ERROR); - Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Have no target devices in PEP!"); - } - } - Set ownIds = this.deviceIds.get(account.getJid().asBareJid()); - for (Integer ownId : (ownIds != null ? ownIds : new HashSet())) { - SignalProtocolAddress address = new SignalProtocolAddress(account.getJid().asBareJid().toString(), ownId); - if (sessions.get(address) == null) { - IdentityKey identityKey = axolotlStore.loadSession(address).getSessionState().getRemoteIdentityKey(); - if (identityKey != null) { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Already have session for " + address.toString() + ", adding to cache..."); - XmppAxolotlSession session = new XmppAxolotlSession(account, axolotlStore, address, identityKey); - sessions.put(address, session); - } else { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Found device " + account.getJid().asBareJid() + ":" + ownId); - if (fetchStatusMap.get(address) != FetchStatus.ERROR) { - addresses.add(address); - } else { - Log.d(Config.LOGTAG, getLogprefix(account) + "skipping over " + address + " because it's broken"); - } - } - } - } - - return addresses; - } - - public boolean createSessionsIfNeeded(final Conversation conversation) { - final List jidsWithEmptyDeviceList = getCryptoTargets(conversation); - for (Iterator iterator = jidsWithEmptyDeviceList.iterator(); iterator.hasNext(); ) { - final Jid jid = iterator.next(); - if (!hasEmptyDeviceList(jid)) { - iterator.remove(); - } - } - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": createSessionsIfNeeded() - jids with empty device list: " + jidsWithEmptyDeviceList); - if (jidsWithEmptyDeviceList.size() > 0) { - fetchDeviceIds(jidsWithEmptyDeviceList, () -> createSessionsIfNeededActual(conversation)); - return true; - } else { - return createSessionsIfNeededActual(conversation); - } - } - - private boolean createSessionsIfNeededActual(final Conversation conversation) { - Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Creating axolotl sessions if needed..."); - boolean newSessions = false; - Set addresses = findDevicesWithoutSession(conversation); - for (SignalProtocolAddress address : addresses) { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Processing device: " + address.toString()); - FetchStatus status = fetchStatusMap.get(address); - if (status == null || status == FetchStatus.TIMEOUT) { - fetchStatusMap.put(address, FetchStatus.PENDING); - this.buildSessionFromPEP(address); - newSessions = true; - } else if (status == FetchStatus.PENDING) { - newSessions = true; - } else { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Already fetching bundle for " + address.toString()); - } - } - - return newSessions; - } - - public boolean trustedSessionVerified(final Conversation conversation) { - final Set sessions = new HashSet<>(); - sessions.addAll(findSessionsForConversation(conversation)); - sessions.addAll(findOwnSessions()); - boolean verified = false; - for (XmppAxolotlSession session : sessions) { - if (session.getTrust().isTrustedAndActive()) { - if (session.getTrust().getTrust() == FingerprintStatus.Trust.VERIFIED_X509) { - verified = true; - } else { - return false; - } - } - } - return verified; - } - - public boolean hasPendingKeyFetches(List jids) { - SignalProtocolAddress ownAddress = new SignalProtocolAddress(account.getJid().asBareJid().toString(), 0); - if (fetchStatusMap.getAll(ownAddress.getName()).containsValue(FetchStatus.PENDING)) { - return true; - } - synchronized (this.fetchDeviceIdsMap) { - for (Jid jid : jids) { - SignalProtocolAddress foreignAddress = new SignalProtocolAddress(jid.asBareJid().toString(), 0); - if (fetchStatusMap.getAll(foreignAddress.getName()).containsValue(FetchStatus.PENDING) || this.fetchDeviceIdsMap.containsKey(jid)) { - return true; - } - } - } - return false; - } - - @Nullable - private boolean buildHeader(XmppAxolotlMessage axolotlMessage, Conversation c) { - Set remoteSessions = findSessionsForConversation(c); - final boolean acceptEmpty = (c.getMode() == Conversation.MODE_MULTI && c.getMucOptions().getUserCount() == 0) || c.getContact().isSelf(); - Collection ownSessions = findOwnSessions(); - if (remoteSessions.isEmpty() && !acceptEmpty) { - return false; - } - for (XmppAxolotlSession session : remoteSessions) { - axolotlMessage.addDevice(session); - } - for (XmppAxolotlSession session : ownSessions) { - axolotlMessage.addDevice(session); - } - - return true; - } - - //this is being used for private muc messages only - private boolean buildHeader(XmppAxolotlMessage axolotlMessage, Jid jid) { - if (jid == null) { - return false; - } - HashSet sessions = new HashSet<>(); - sessions.addAll(this.sessions.getAll(getAddressForJid(jid).getName()).values()); - if (sessions.isEmpty()) { - return false; - } - sessions.addAll(findOwnSessions()); - for(XmppAxolotlSession session : sessions) { - axolotlMessage.addDevice(session); - } - return true; - } - - @Nullable - public XmppAxolotlMessage encrypt(Message message) { - final XmppAxolotlMessage axolotlMessage = new XmppAxolotlMessage(account.getJid().asBareJid(), getOwnDeviceId()); - final String content; - if (message.hasFileOnRemoteHost()) { - content = message.getFileParams().url.toString(); - } else { - content = message.getBody(); - } - try { - axolotlMessage.encrypt(content); - } catch (CryptoFailedException e) { - Log.w(Config.LOGTAG, getLogprefix(account) + "Failed to encrypt message: " + e.getMessage()); - return null; - } - - final boolean success; - if (message.isPrivateMessage()) { - success = buildHeader(axolotlMessage, message.getTrueCounterpart()); - } else { - success = buildHeader(axolotlMessage, (Conversation) message.getConversation()); - } - return success ? axolotlMessage : null; - } - - public void preparePayloadMessage(final Message message, final boolean delay) { - executor.execute(new Runnable() { - @Override - public void run() { - XmppAxolotlMessage axolotlMessage = encrypt(message); - if (axolotlMessage == null) { - mXmppConnectionService.markMessage(message, Message.STATUS_SEND_FAILED); - //mXmppConnectionService.updateConversationUi(); - } else { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Generated message, caching: " + message.getUuid()); - messageCache.put(message.getUuid(), axolotlMessage); - mXmppConnectionService.resendMessage(message, delay); - } - } - }); - } - - public void prepareKeyTransportMessage(final Conversation conversation, final OnMessageCreatedCallback onMessageCreatedCallback) { - executor.execute(new Runnable() { - @Override - public void run() { - final XmppAxolotlMessage axolotlMessage = new XmppAxolotlMessage(account.getJid().asBareJid(), getOwnDeviceId()); - if (buildHeader(axolotlMessage, conversation)) { - onMessageCreatedCallback.run(axolotlMessage); - } else { - onMessageCreatedCallback.run(null); - } - } - }); - } - - public XmppAxolotlMessage fetchAxolotlMessageFromCache(Message message) { - XmppAxolotlMessage axolotlMessage = messageCache.get(message.getUuid()); - if (axolotlMessage != null) { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Cache hit: " + message.getUuid()); - messageCache.remove(message.getUuid()); - } else { - Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Cache miss: " + message.getUuid()); - } - return axolotlMessage; - } - - private XmppAxolotlSession recreateUncachedSession(SignalProtocolAddress address) { - IdentityKey identityKey = axolotlStore.loadSession(address).getSessionState().getRemoteIdentityKey(); - return (identityKey != null) - ? new XmppAxolotlSession(account, axolotlStore, address, identityKey) - : null; - } - - private XmppAxolotlSession getReceivingSession(XmppAxolotlMessage message) { - SignalProtocolAddress senderAddress = new SignalProtocolAddress(message.getFrom().toString(), message.getSenderDeviceId()); - return getReceivingSession(senderAddress); - - } - - private XmppAxolotlSession getReceivingSession(SignalProtocolAddress senderAddress) { - XmppAxolotlSession session = sessions.get(senderAddress); - if (session == null) { - //Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Account: " + account.getJid() + " No axolotl session found while parsing received message " + message); - session = recreateUncachedSession(senderAddress); - if (session == null) { - session = new XmppAxolotlSession(account, axolotlStore, senderAddress); - } - } - return session; - } - - public XmppAxolotlMessage.XmppAxolotlPlaintextMessage processReceivingPayloadMessage(XmppAxolotlMessage message, boolean postponePreKeyMessageHandling) throws NotEncryptedForThisDeviceException, BrokenSessionException { - XmppAxolotlMessage.XmppAxolotlPlaintextMessage plaintextMessage = null; - - XmppAxolotlSession session = getReceivingSession(message); - int ownDeviceId = getOwnDeviceId(); - try { - plaintextMessage = message.decrypt(session, ownDeviceId); - Integer preKeyId = session.getPreKeyIdAndReset(); - if (preKeyId != null) { - postPreKeyMessageHandling(session, postponePreKeyMessageHandling); - } - } catch (NotEncryptedForThisDeviceException e) { - if (account.getJid().asBareJid().equals(message.getFrom().asBareJid()) && message.getSenderDeviceId() == ownDeviceId) { - Log.w(Config.LOGTAG, getLogprefix(account) + "Reflected omemo message received"); - } else { - throw e; - } - } catch (final BrokenSessionException e) { - throw e; - } catch (CryptoFailedException e) { - Log.w(Config.LOGTAG, getLogprefix(account) + "Failed to decrypt message from " + message.getFrom(), e); - } - - if (session.isFresh() && plaintextMessage != null) { - putFreshSession(session); - } - - return plaintextMessage; - } - - public void reportBrokenSessionException(BrokenSessionException e, boolean postpone) { - Log.e(Config.LOGTAG,account.getJid().asBareJid()+": broken session with "+e.getSignalProtocolAddress().toString()+" detected", e); - if (postpone) { - postponedHealing.add(e.getSignalProtocolAddress()); - } else { - notifyRequiresHealing(e.getSignalProtocolAddress()); - } - } - - private void notifyRequiresHealing(final SignalProtocolAddress signalProtocolAddress) { - if (healingAttempts.add(signalProtocolAddress)) { - Log.d(Config.LOGTAG,account.getJid().asBareJid()+": attempt to heal "+signalProtocolAddress); - buildSessionFromPEP(signalProtocolAddress, new OnSessionBuildFromPep() { - @Override - public void onSessionBuildSuccessful() { - Log.d(Config.LOGTAG, "successfully build new session from pep after detecting broken session"); - completeSession(getReceivingSession(signalProtocolAddress)); - } - - @Override - public void onSessionBuildFailed() { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": unable to build new session from pep after detecting broken session"); - } - }); - } else { - Log.d(Config.LOGTAG,account.getJid().asBareJid()+": do not attempt to heal "+signalProtocolAddress+" again"); - } - } - - private void postPreKeyMessageHandling(final XmppAxolotlSession session, final boolean postpone) { - if (postpone) { - postponedSessions.add(session); - } else { - if (axolotlStore.flushPreKeys()) { - publishBundlesIfNeeded(false, false); - } else { - Log.d(Config.LOGTAG,account.getJid().asBareJid()+": nothing to flush. Not republishing key"); - } - if (trustedOrPreviouslyResponded(session)) { - completeSession(session); - } - } - } - - public void processPostponed() { - if (postponedSessions.size() > 0) { - if (axolotlStore.flushPreKeys()) { - publishBundlesIfNeeded(false, false); - } - } - final Iterator iterator = postponedSessions.iterator(); - while (iterator.hasNext()) { - final XmppAxolotlSession session = iterator.next(); - if (trustedOrPreviouslyResponded(session)) { - completeSession(session); - } - iterator.remove(); - } - final Iterator postponedHealingAttemptsIterator = postponedHealing.iterator(); - while (postponedHealingAttemptsIterator.hasNext()) { - notifyRequiresHealing(postponedHealingAttemptsIterator.next()); - postponedHealingAttemptsIterator.remove(); - } - } - - - private boolean trustedOrPreviouslyResponded(XmppAxolotlSession session) { - try { - return trustedOrPreviouslyResponded(Jid.of(session.getRemoteAddress().getName())); - } catch (IllegalArgumentException e) { - return false; - } - } - - public boolean trustedOrPreviouslyResponded(Jid jid) { - final Contact contact = account.getRoster().getContact(jid); - if (contact.showInRoster() || contact.isSelf()) { - return true; - } - final Conversation conversation = mXmppConnectionService.find(account, jid); - return conversation != null && conversation.sentMessagesCount() > 0; - } - - private void completeSession(XmppAxolotlSession session) { - final XmppAxolotlMessage axolotlMessage = new XmppAxolotlMessage(account.getJid().asBareJid(), getOwnDeviceId()); - axolotlMessage.addDevice(session, true); - try { - final Jid jid = Jid.of(session.getRemoteAddress().getName()); - MessagePacket packet = mXmppConnectionService.getMessageGenerator().generateKeyTransportMessage(jid, axolotlMessage); - mXmppConnectionService.sendMessagePacket(account, packet); - } catch (IllegalArgumentException e) { - throw new Error("Remote addresses are created from jid and should convert back to jid", e); - } - } - - - public XmppAxolotlMessage.XmppAxolotlKeyTransportMessage processReceivingKeyTransportMessage(XmppAxolotlMessage message, final boolean postponePreKeyMessageHandling) { - final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage; - final XmppAxolotlSession session = getReceivingSession(message); - try { - keyTransportMessage = message.getParameters(session, getOwnDeviceId()); - Integer preKeyId = session.getPreKeyIdAndReset(); - if (preKeyId != null) { - postPreKeyMessageHandling(session, postponePreKeyMessageHandling); - } - } catch (CryptoFailedException e) { - Log.d(Config.LOGTAG, "could not decrypt keyTransport message " + e.getMessage()); - return null; - } - - if (session.isFresh() && keyTransportMessage != null) { - putFreshSession(session); - } - - return keyTransportMessage; - } - - private void putFreshSession(XmppAxolotlSession session) { - sessions.put(session); - if (Config.X509_VERIFICATION) { - if (session.getIdentityKey() != null) { - verifySessionWithPEP(session); - } else { - Log.e(Config.LOGTAG, account.getJid().asBareJid() + ": identity key was empty after reloading for x509 verification"); - } - } - } + public static final String PEP_PREFIX = "eu.siacs.conversations.axolotl"; + public static final String PEP_DEVICE_LIST = PEP_PREFIX + ".devicelist"; + public static final String PEP_DEVICE_LIST_NOTIFY = PEP_DEVICE_LIST + "+notify"; + public static final String PEP_BUNDLES = PEP_PREFIX + ".bundles"; + public static final String PEP_VERIFICATION = PEP_PREFIX + ".verification"; + public static final String PEP_OMEMO_WHITELISTED = PEP_PREFIX + ".whitelisted"; + + public static final String LOGPREFIX = "AxolotlService"; + + private static final int NUM_KEYS_TO_PUBLISH = 100; + private static final int publishTriesThreshold = 3; + + private final Account account; + private final XmppConnectionService mXmppConnectionService; + private final SQLiteAxolotlStore axolotlStore; + private final SessionMap sessions; + private final Map> deviceIds; + private final Map messageCache; + private final FetchStatusMap fetchStatusMap; + private final Map fetchDeviceListStatus = new HashMap<>(); + private final HashMap> fetchDeviceIdsMap = new HashMap<>(); + private final SerialSingleThreadExecutor executor; + private final Set healingAttempts = new HashSet<>(); + private final HashSet cleanedOwnDeviceIds = new HashSet<>(); + private final Set PREVIOUSLY_REMOVED_FROM_ANNOUNCEMENT = new HashSet<>(); + private int numPublishTriesOnEmptyPep = 0; + private boolean pepBroken = false; + private int lastDeviceListNotificationHash = 0; + private Set postponedSessions = new HashSet<>(); //sessions stored here will receive after mam catchup treatment + private Set postponedHealing = new HashSet<>(); //addresses stored here will need a healing notification after mam catchup + private AtomicBoolean changeAccessMode = new AtomicBoolean(false); + + public AxolotlService(Account account, XmppConnectionService connectionService) { + if (account == null || connectionService == null) { + throw new IllegalArgumentException("account and service cannot be null"); + } + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + this.mXmppConnectionService = connectionService; + this.account = account; + this.axolotlStore = new SQLiteAxolotlStore(this.account, this.mXmppConnectionService); + this.deviceIds = new HashMap<>(); + this.messageCache = new HashMap<>(); + this.sessions = new SessionMap(mXmppConnectionService, axolotlStore, account); + this.fetchStatusMap = new FetchStatusMap(); + this.executor = new SerialSingleThreadExecutor("Axolotl"); + } + + public static String getLogprefix(Account account) { + return LOGPREFIX + " (" + account.getJid().asBareJid().toString() + "): "; + } + + @Override + public void onAdvancedStreamFeaturesAvailable(Account account) { + if (Config.supportOmemo() + && account.getXmppConnection() != null + && account.getXmppConnection().getFeatures().pep()) { + publishBundlesIfNeeded(true, false); + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": skipping OMEMO initialization"); + } + } + + private boolean hasErrorFetchingDeviceList(Jid jid) { + Boolean status = fetchDeviceListStatus.get(jid); + return status != null && !status; + } + + public boolean hasErrorFetchingDeviceList(List jids) { + for (Jid jid : jids) { + if (hasErrorFetchingDeviceList(jid)) { + return true; + } + } + return false; + } + + public boolean fetchMapHasErrors(List jids) { + for (Jid jid : jids) { + if (deviceIds.get(jid) != null) { + for (Integer foreignId : this.deviceIds.get(jid)) { + SignalProtocolAddress address = new SignalProtocolAddress(jid.toString(), foreignId); + if (fetchStatusMap.getAll(address.getName()).containsValue(FetchStatus.ERROR)) { + return true; + } + } + } + } + return false; + } + + public void preVerifyFingerprint(Contact contact, String fingerprint) { + axolotlStore.preVerifyFingerprint(contact.getAccount(), contact.getJid().asBareJid().toString(), fingerprint); + } + + public void preVerifyFingerprint(Account account, String fingerprint) { + axolotlStore.preVerifyFingerprint(account, account.getJid().asBareJid().toString(), fingerprint); + } + + public boolean hasVerifiedKeys(String name) { + for (XmppAxolotlSession session : this.sessions.getAll(name).values()) { + if (session.getTrust().isVerified()) { + return true; + } + } + return false; + } + + public String getOwnFingerprint() { + return CryptoHelper.bytesToHex(axolotlStore.getIdentityKeyPair().getPublicKey().serialize()); + } + + public Set getKeysWithTrust(FingerprintStatus status) { + return axolotlStore.getContactKeysWithTrust(account.getJid().asBareJid().toString(), status); + } + + public Set getKeysWithTrust(FingerprintStatus status, Jid jid) { + return axolotlStore.getContactKeysWithTrust(jid.asBareJid().toString(), status); + } + + public Set getKeysWithTrust(FingerprintStatus status, List jids) { + Set keys = new HashSet<>(); + for (Jid jid : jids) { + keys.addAll(axolotlStore.getContactKeysWithTrust(jid.toString(), status)); + } + return keys; + } + + public Set findCounterpartsBySourceId(int sid) { + return sessions.findCounterpartsForSourceId(sid); + } + + public long getNumTrustedKeys(Jid jid) { + return axolotlStore.getContactNumTrustedKeys(jid.asBareJid().toString()); + } + + public boolean anyTargetHasNoTrustedKeys(List jids) { + for (Jid jid : jids) { + if (axolotlStore.getContactNumTrustedKeys(jid.asBareJid().toString()) == 0) { + return true; + } + } + return false; + } + + private SignalProtocolAddress getAddressForJid(Jid jid) { + return new SignalProtocolAddress(jid.toString(), 0); + } + + public Collection findOwnSessions() { + SignalProtocolAddress ownAddress = getAddressForJid(account.getJid().asBareJid()); + ArrayList s = new ArrayList<>(this.sessions.getAll(ownAddress.getName()).values()); + Collections.sort(s); + return s; + } + + public Collection findSessionsForContact(Contact contact) { + SignalProtocolAddress contactAddress = getAddressForJid(contact.getJid()); + ArrayList s = new ArrayList<>(this.sessions.getAll(contactAddress.getName()).values()); + Collections.sort(s); + return s; + } + + private Set findSessionsForConversation(Conversation conversation) { + if (conversation.getContact().isSelf()) { + //will be added in findOwnSessions() + return Collections.emptySet(); + } + HashSet sessions = new HashSet<>(); + for (Jid jid : conversation.getAcceptedCryptoTargets()) { + sessions.addAll(this.sessions.getAll(getAddressForJid(jid).getName()).values()); + } + return sessions; + } + + private boolean hasAny(Jid jid) { + return sessions.hasAny(getAddressForJid(jid)); + } + + public boolean isPepBroken() { + return this.pepBroken; + } + + public void resetBrokenness() { + this.pepBroken = false; + this.numPublishTriesOnEmptyPep = 0; + this.lastDeviceListNotificationHash = 0; + this.healingAttempts.clear(); + } + + public void clearErrorsInFetchStatusMap(Jid jid) { + fetchStatusMap.clearErrorFor(jid); + fetchDeviceListStatus.remove(jid); + } + + public void regenerateKeys(boolean wipeOther) { + axolotlStore.regenerate(); + sessions.clear(); + fetchStatusMap.clear(); + fetchDeviceIdsMap.clear(); + fetchDeviceListStatus.clear(); + publishBundlesIfNeeded(true, wipeOther); + } + + public void destroy() { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": destroying old axolotl service. no longer in use"); + mXmppConnectionService.databaseBackend.wipeAxolotlDb(account); + } + + public AxolotlService makeNew() { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": make new axolotl service"); + return new AxolotlService(this.account, this.mXmppConnectionService); + } + + public int getOwnDeviceId() { + return axolotlStore.getLocalRegistrationId(); + } + + public SignalProtocolAddress getOwnAxolotlAddress() { + return new SignalProtocolAddress(account.getJid().asBareJid().toString(), getOwnDeviceId()); + } + + public Set getOwnDeviceIds() { + return this.deviceIds.get(account.getJid().asBareJid()); + } + + public void registerDevices(final Jid jid, @NonNull final Set deviceIds) { + final int hash = deviceIds.hashCode(); + final boolean me = jid.asBareJid().equals(account.getJid().asBareJid()); + if (me) { + if (hash != 0 && hash == this.lastDeviceListNotificationHash) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring duplicate own device id list"); + return; + } + this.lastDeviceListNotificationHash = hash; + } + boolean needsPublishing = me && !deviceIds.contains(getOwnDeviceId()); + if (me) { + deviceIds.remove(getOwnDeviceId()); + } + Set expiredDevices = new HashSet<>(axolotlStore.getSubDeviceSessions(jid.asBareJid().toString())); + expiredDevices.removeAll(deviceIds); + for (Integer deviceId : expiredDevices) { + SignalProtocolAddress address = new SignalProtocolAddress(jid.asBareJid().toString(), deviceId); + XmppAxolotlSession session = sessions.get(address); + if (session != null && session.getFingerprint() != null) { + if (session.getTrust().isActive()) { + session.setTrust(session.getTrust().toInactive()); + } + } + } + Set newDevices = new HashSet<>(deviceIds); + for (Integer deviceId : newDevices) { + SignalProtocolAddress address = new SignalProtocolAddress(jid.asBareJid().toString(), deviceId); + XmppAxolotlSession session = sessions.get(address); + if (session != null && session.getFingerprint() != null) { + if (!session.getTrust().isActive()) { + Log.d(Config.LOGTAG, "reactivating device with fingerprint " + session.getFingerprint()); + session.setTrust(session.getTrust().toActive()); + } + } + } + if (me) { + if (Config.OMEMO_AUTO_EXPIRY != 0) { + needsPublishing |= deviceIds.removeAll(getExpiredDevices()); + } + needsPublishing |= this.changeAccessMode.get(); + for (Integer deviceId : deviceIds) { + SignalProtocolAddress ownDeviceAddress = new SignalProtocolAddress(jid.asBareJid().toString(), deviceId); + if (sessions.get(ownDeviceAddress) == null) { + FetchStatus status = fetchStatusMap.get(ownDeviceAddress); + if (status == null || status == FetchStatus.TIMEOUT) { + fetchStatusMap.put(ownDeviceAddress, FetchStatus.PENDING); + this.buildSessionFromPEP(ownDeviceAddress); + } + } + } + if (needsPublishing) { + publishOwnDeviceId(deviceIds); + } + } + final Set oldSet = this.deviceIds.get(jid); + final boolean changed = oldSet == null || oldSet.hashCode() != hash; + this.deviceIds.put(jid, deviceIds); + if (changed) { + mXmppConnectionService.updateConversationUi(); //update the lock icon + mXmppConnectionService.keyStatusUpdated(null); + if (me) { + mXmppConnectionService.updateAccountUi(); + } + } else { + Log.d(Config.LOGTAG, "skipped device list update because it hasn't changed"); + } + } + + public void wipeOtherPepDevices() { + if (pepBroken) { + Log.d(Config.LOGTAG, getLogprefix(account) + "wipeOtherPepDevices called, but PEP is broken. Ignoring... "); + return; + } + Set deviceIds = new HashSet<>(); + deviceIds.add(getOwnDeviceId()); + publishDeviceIdsAndRefineAccessModel(deviceIds); + } + + public void distrustFingerprint(final String fingerprint) { + final String fp = fingerprint.replaceAll("\\s", ""); + final FingerprintStatus fingerprintStatus = axolotlStore.getFingerprintStatus(fp); + axolotlStore.setFingerprintStatus(fp, fingerprintStatus.toUntrusted()); + } + + private void publishOwnDeviceIdIfNeeded() { + if (pepBroken) { + Log.d(Config.LOGTAG, getLogprefix(account) + "publishOwnDeviceIdIfNeeded called, but PEP is broken. Ignoring... "); + return; + } + IqPacket packet = mXmppConnectionService.getIqGenerator().retrieveDeviceIds(account.getJid().asBareJid()); + mXmppConnectionService.sendIqPacket(account, packet, new OnIqPacketReceived() { + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + if (packet.getType() == IqPacket.TYPE.TIMEOUT) { + Log.d(Config.LOGTAG, getLogprefix(account) + "Timeout received while retrieving own Device Ids."); + } else { + //TODO consider calling registerDevices only after item-not-found to account for broken PEPs + Element item = mXmppConnectionService.getIqParser().getItem(packet); + Set deviceIds = mXmppConnectionService.getIqParser().deviceIds(item); + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": retrieved own device list: " + deviceIds); + registerDevices(account.getJid().asBareJid(), deviceIds); + } + } + }); + } + + private Set getExpiredDevices() { + Set devices = new HashSet<>(); + for (XmppAxolotlSession session : findOwnSessions()) { + if (session.getTrust().isActive()) { + long diff = System.currentTimeMillis() - session.getTrust().getLastActivation(); + if (diff > Config.OMEMO_AUTO_EXPIRY) { + long lastMessageDiff = System.currentTimeMillis() - mXmppConnectionService.databaseBackend.getLastTimeFingerprintUsed(account, session.getFingerprint()); + long hours = Math.round(lastMessageDiff / (1000 * 60.0 * 60.0)); + if (lastMessageDiff > Config.OMEMO_AUTO_EXPIRY) { + devices.add(session.getRemoteAddress().getDeviceId()); + session.setTrust(session.getTrust().toInactive()); + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": added own device " + session.getFingerprint() + " to list of expired devices. Last message received " + hours + " hours ago"); + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": own device " + session.getFingerprint() + " was active " + hours + " hours ago"); + } + } //TODO print last activation diff + } + } + return devices; + } + + private void publishOwnDeviceId(Set deviceIds) { + Set deviceIdsCopy = new HashSet<>(deviceIds); + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "publishing own device ids"); + if (deviceIdsCopy.isEmpty()) { + if (numPublishTriesOnEmptyPep >= publishTriesThreshold) { + Log.w(Config.LOGTAG, getLogprefix(account) + "Own device publish attempt threshold exceeded, aborting..."); + pepBroken = true; + return; + } else { + numPublishTriesOnEmptyPep++; + Log.w(Config.LOGTAG, getLogprefix(account) + "Own device list empty, attempting to publish (try " + numPublishTriesOnEmptyPep + ")"); + } + } else { + numPublishTriesOnEmptyPep = 0; + } + deviceIdsCopy.add(getOwnDeviceId()); + publishDeviceIdsAndRefineAccessModel(deviceIdsCopy); + } + + private void publishDeviceIdsAndRefineAccessModel(Set ids) { + publishDeviceIdsAndRefineAccessModel(ids, true); + } + + private void publishDeviceIdsAndRefineAccessModel(final Set ids, final boolean firstAttempt) { + final Bundle publishOptions = account.getXmppConnection().getFeatures().pepPublishOptions() ? PublishOptions.openAccess() : null; + IqPacket publish = mXmppConnectionService.getIqGenerator().publishDeviceIds(ids, publishOptions); + mXmppConnectionService.sendIqPacket(account, publish, new OnIqPacketReceived() { + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + final Element error = packet.getType() == IqPacket.TYPE.ERROR ? packet.findChild("error") : null; + final boolean preConditionNotMet = PublishOptions.preconditionNotMet(packet); + if (firstAttempt && preConditionNotMet) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": precondition wasn't met for device list. pushing node configuration"); + mXmppConnectionService.pushNodeConfiguration(account, AxolotlService.PEP_DEVICE_LIST, publishOptions, new XmppConnectionService.OnConfigurationPushed() { + @Override + public void onPushSucceeded() { + publishDeviceIdsAndRefineAccessModel(ids, false); + } + + @Override + public void onPushFailed() { + publishDeviceIdsAndRefineAccessModel(ids, false); + } + }); + } else { + if (AxolotlService.this.changeAccessMode.compareAndSet(true, false)) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": done changing access mode"); + account.setOption(Account.OPTION_REQUIRES_ACCESS_MODE_CHANGE, false); + mXmppConnectionService.databaseBackend.updateAccount(account); + } + if (packet.getType() == IqPacket.TYPE.ERROR) { + if (preConditionNotMet) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": device list pre condition still not met on second attempt"); + } else if (error != null) { + pepBroken = true; + Log.d(Config.LOGTAG, getLogprefix(account) + "Error received while publishing own device id" + packet.findChild("error")); + } + + } + } + } + }); + } + + public void publishDeviceVerificationAndBundle(final SignedPreKeyRecord signedPreKeyRecord, + final Set preKeyRecords, + final boolean announceAfter, + final boolean wipe) { + try { + IdentityKey axolotlPublicKey = axolotlStore.getIdentityKeyPair().getPublicKey(); + PrivateKey x509PrivateKey = KeyChain.getPrivateKey(mXmppConnectionService, account.getPrivateKeyAlias()); + X509Certificate[] chain = KeyChain.getCertificateChain(mXmppConnectionService, account.getPrivateKeyAlias()); + Signature verifier = Signature.getInstance("sha256WithRSA"); + verifier.initSign(x509PrivateKey, mXmppConnectionService.getRNG()); + verifier.update(axolotlPublicKey.serialize()); + byte[] signature = verifier.sign(); + IqPacket packet = mXmppConnectionService.getIqGenerator().publishVerification(signature, chain, getOwnDeviceId()); + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + ": publish verification for device " + getOwnDeviceId()); + mXmppConnectionService.sendIqPacket(account, packet, new OnIqPacketReceived() { + @Override + public void onIqPacketReceived(final Account account, IqPacket packet) { + String node = AxolotlService.PEP_VERIFICATION + ":" + getOwnDeviceId(); + mXmppConnectionService.pushNodeConfiguration(account, node, PublishOptions.openAccess(), new XmppConnectionService.OnConfigurationPushed() { + @Override + public void onPushSucceeded() { + Log.d(Config.LOGTAG, getLogprefix(account) + "configured verification node to be world readable"); + publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe); + } + + @Override + public void onPushFailed() { + Log.d(Config.LOGTAG, getLogprefix(account) + "unable to set access model on verification node"); + publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe); + } + }); + } + }); + } catch (Exception e) { + e.printStackTrace(); + } + } + + public void publishBundlesIfNeeded(final boolean announce, final boolean wipe) { + if (pepBroken) { + Log.d(Config.LOGTAG, getLogprefix(account) + "publishBundlesIfNeeded called, but PEP is broken. Ignoring... "); + return; + } + + if (account.getXmppConnection().getFeatures().pepPublishOptions()) { + this.changeAccessMode.set(account.isOptionSet(Account.OPTION_REQUIRES_ACCESS_MODE_CHANGE)); + } else { + if (account.setOption(Account.OPTION_REQUIRES_ACCESS_MODE_CHANGE, true)) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": server doesn’t support publish-options. setting for later access mode change"); + mXmppConnectionService.databaseBackend.updateAccount(account); + } + } + if (this.changeAccessMode.get()) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": server gained publish-options capabilities. changing access model"); + } + IqPacket packet = mXmppConnectionService.getIqGenerator().retrieveBundlesForDevice(account.getJid().asBareJid(), getOwnDeviceId()); + mXmppConnectionService.sendIqPacket(account, packet, new OnIqPacketReceived() { + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + + if (packet.getType() == IqPacket.TYPE.TIMEOUT) { + return; //ignore timeout. do nothing + } + + if (packet.getType() == IqPacket.TYPE.ERROR) { + Element error = packet.findChild("error"); + if (error == null || !error.hasChild("item-not-found")) { + pepBroken = true; + Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "request for device bundles came back with something other than item-not-found" + packet); + return; + } + } + + PreKeyBundle bundle = mXmppConnectionService.getIqParser().bundle(packet); + Map keys = mXmppConnectionService.getIqParser().preKeyPublics(packet); + boolean flush = false; + if (bundle == null) { + Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Received invalid bundle:" + packet); + bundle = new PreKeyBundle(-1, -1, -1, null, -1, null, null, null); + flush = true; + } + if (keys == null) { + Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Received invalid prekeys:" + packet); + } + try { + boolean changed = false; + // Validate IdentityKey + IdentityKeyPair identityKeyPair = axolotlStore.getIdentityKeyPair(); + if (flush || !identityKeyPair.getPublicKey().equals(bundle.getIdentityKey())) { + Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding own IdentityKey " + identityKeyPair.getPublicKey() + " to PEP."); + changed = true; + } + + // Validate signedPreKeyRecord + ID + SignedPreKeyRecord signedPreKeyRecord; + int numSignedPreKeys = axolotlStore.getSignedPreKeysCount(); + try { + signedPreKeyRecord = axolotlStore.loadSignedPreKey(bundle.getSignedPreKeyId()); + if (flush + || !bundle.getSignedPreKey().equals(signedPreKeyRecord.getKeyPair().getPublicKey()) + || !Arrays.equals(bundle.getSignedPreKeySignature(), signedPreKeyRecord.getSignature())) { + Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding new signedPreKey with ID " + (numSignedPreKeys + 1) + " to PEP."); + signedPreKeyRecord = KeyHelper.generateSignedPreKey(identityKeyPair, numSignedPreKeys + 1); + axolotlStore.storeSignedPreKey(signedPreKeyRecord.getId(), signedPreKeyRecord); + changed = true; + } + } catch (InvalidKeyIdException e) { + Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding new signedPreKey with ID " + (numSignedPreKeys + 1) + " to PEP."); + signedPreKeyRecord = KeyHelper.generateSignedPreKey(identityKeyPair, numSignedPreKeys + 1); + axolotlStore.storeSignedPreKey(signedPreKeyRecord.getId(), signedPreKeyRecord); + changed = true; + } + + // Validate PreKeys + Set preKeyRecords = new HashSet<>(); + if (keys != null) { + for (Integer id : keys.keySet()) { + try { + PreKeyRecord preKeyRecord = axolotlStore.loadPreKey(id); + if (preKeyRecord.getKeyPair().getPublicKey().equals(keys.get(id))) { + preKeyRecords.add(preKeyRecord); + } + } catch (InvalidKeyIdException ignored) { + } + } + } + int newKeys = NUM_KEYS_TO_PUBLISH - preKeyRecords.size(); + if (newKeys > 0) { + List newRecords = KeyHelper.generatePreKeys( + axolotlStore.getCurrentPreKeyId() + 1, newKeys); + preKeyRecords.addAll(newRecords); + for (PreKeyRecord record : newRecords) { + axolotlStore.storePreKey(record.getId(), record); + } + changed = true; + Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding " + newKeys + " new preKeys to PEP."); + } + + + if (changed || changeAccessMode.get()) { + if (account.getPrivateKeyAlias() != null && Config.X509_VERIFICATION) { + mXmppConnectionService.publishDisplayName(account); + publishDeviceVerificationAndBundle(signedPreKeyRecord, preKeyRecords, announce, wipe); + } else { + publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announce, wipe); + } + } else { + Log.d(Config.LOGTAG, getLogprefix(account) + "Bundle " + getOwnDeviceId() + " in PEP was current"); + if (wipe) { + wipeOtherPepDevices(); + } else if (announce) { + Log.d(Config.LOGTAG, getLogprefix(account) + "Announcing device " + getOwnDeviceId()); + publishOwnDeviceIdIfNeeded(); + } + } + } catch (InvalidKeyException e) { + Log.e(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Failed to publish bundle " + getOwnDeviceId() + ", reason: " + e.getMessage()); + } + } + }); + } + + private void publishDeviceBundle(SignedPreKeyRecord signedPreKeyRecord, + Set preKeyRecords, + final boolean announceAfter, + final boolean wipe) { + publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe, true); + } + + private void publishDeviceBundle(final SignedPreKeyRecord signedPreKeyRecord, + final Set preKeyRecords, + final boolean announceAfter, + final boolean wipe, + final boolean firstAttempt) { + final Bundle publishOptions = account.getXmppConnection().getFeatures().pepPublishOptions() ? PublishOptions.openAccess() : null; + IqPacket publish = mXmppConnectionService.getIqGenerator().publishBundles( + signedPreKeyRecord, axolotlStore.getIdentityKeyPair().getPublicKey(), + preKeyRecords, getOwnDeviceId(), publishOptions); + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + ": Bundle " + getOwnDeviceId() + " in PEP not current. Publishing..."); + mXmppConnectionService.sendIqPacket(account, publish, new OnIqPacketReceived() { + @Override + public void onIqPacketReceived(final Account account, IqPacket packet) { + final boolean preconditionNotMet = PublishOptions.preconditionNotMet(packet); + if (firstAttempt && preconditionNotMet) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": precondition wasn't met for bundle. pushing node configuration"); + final String node = AxolotlService.PEP_BUNDLES + ":" + getOwnDeviceId(); + mXmppConnectionService.pushNodeConfiguration(account, node, publishOptions, new XmppConnectionService.OnConfigurationPushed() { + @Override + public void onPushSucceeded() { + publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe, false); + } + + @Override + public void onPushFailed() { + publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe, false); + } + }); + } else if (packet.getType() == IqPacket.TYPE.RESULT) { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Successfully published bundle. "); + if (wipe) { + wipeOtherPepDevices(); + } else if (announceAfter) { + Log.d(Config.LOGTAG, getLogprefix(account) + "Announcing device " + getOwnDeviceId()); + publishOwnDeviceIdIfNeeded(); + } + } else if (packet.getType() == IqPacket.TYPE.ERROR) { + if (preconditionNotMet) { + Log.d(Config.LOGTAG, getLogprefix(account) + "bundle precondition still not met after second attempt"); + } else { + Log.d(Config.LOGTAG, getLogprefix(account) + "Error received while publishing bundle: " + packet.toString()); + } + pepBroken = true; + } + } + }); + } + + public void deleteOmemoIdentity() { + final String node = AxolotlService.PEP_BUNDLES + ":" + getOwnDeviceId(); + final IqPacket deleteBundleNode = mXmppConnectionService.getIqGenerator().deleteNode(node); + mXmppConnectionService.sendIqPacket(account, deleteBundleNode, null); + final Set ownDeviceIds = getOwnDeviceIds(); + publishDeviceIdsAndRefineAccessModel(ownDeviceIds == null ? Collections.emptySet() : ownDeviceIds); + } + + public List getCryptoTargets(Conversation conversation) { + final List jids; + if (conversation.getMode() == Conversation.MODE_SINGLE) { + jids = new ArrayList<>(); + jids.add(conversation.getJid().asBareJid()); + } else { + jids = conversation.getMucOptions().getMembers(false); + } + return jids; + } + + public FingerprintStatus getFingerprintTrust(String fingerprint) { + return axolotlStore.getFingerprintStatus(fingerprint); + } + + public X509Certificate getFingerprintCertificate(String fingerprint) { + return axolotlStore.getFingerprintCertificate(fingerprint); + } + + public void setFingerprintTrust(String fingerprint, FingerprintStatus status) { + axolotlStore.setFingerprintStatus(fingerprint, status); + } + + private void verifySessionWithPEP(final XmppAxolotlSession session) { + Log.d(Config.LOGTAG, "trying to verify fresh session (" + session.getRemoteAddress().getName() + ") with pep"); + final SignalProtocolAddress address = session.getRemoteAddress(); + final IdentityKey identityKey = session.getIdentityKey(); + try { + IqPacket packet = mXmppConnectionService.getIqGenerator().retrieveVerificationForDevice(Jid.of(address.getName()), address.getDeviceId()); + mXmppConnectionService.sendIqPacket(account, packet, new OnIqPacketReceived() { + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + Pair verification = mXmppConnectionService.getIqParser().verification(packet); + if (verification != null) { + try { + Signature verifier = Signature.getInstance("sha256WithRSA"); + verifier.initVerify(verification.first[0]); + verifier.update(identityKey.serialize()); + if (verifier.verify(verification.second)) { + try { + mXmppConnectionService.getMemorizingTrustManager().getNonInteractive().checkClientTrusted(verification.first, "RSA"); + String fingerprint = session.getFingerprint(); + Log.d(Config.LOGTAG, "verified session with x.509 signature. fingerprint was: " + fingerprint); + setFingerprintTrust(fingerprint, FingerprintStatus.createActiveVerified(true)); + axolotlStore.setFingerprintCertificate(fingerprint, verification.first[0]); + fetchStatusMap.put(address, FetchStatus.SUCCESS_VERIFIED); + Bundle information = CryptoHelper.extractCertificateInformation(verification.first[0]); + try { + final String cn = information.getString("subject_cn"); + final Jid jid = Jid.of(address.getName()); + Log.d(Config.LOGTAG, "setting common name for " + jid + " to " + cn); + account.getRoster().getContact(jid).setCommonName(cn); + } catch (final IllegalArgumentException ignored) { + //ignored + } + finishBuildingSessionsFromPEP(address); + return; + } catch (Exception e) { + Log.d(Config.LOGTAG, "could not verify certificate"); + } + } + } catch (Exception e) { + Log.d(Config.LOGTAG, "error during verification " + e.getMessage()); + } + } else { + Log.d(Config.LOGTAG, "no verification found"); + } + fetchStatusMap.put(address, FetchStatus.SUCCESS); + finishBuildingSessionsFromPEP(address); + } + }); + } catch (IllegalArgumentException e) { + fetchStatusMap.put(address, FetchStatus.SUCCESS); + finishBuildingSessionsFromPEP(address); + } + } + + private void finishBuildingSessionsFromPEP(final SignalProtocolAddress address) { + SignalProtocolAddress ownAddress = new SignalProtocolAddress(account.getJid().asBareJid().toString(), 0); + Map own = fetchStatusMap.getAll(ownAddress.getName()); + Map remote = fetchStatusMap.getAll(address.getName()); + if (!own.containsValue(FetchStatus.PENDING) && !remote.containsValue(FetchStatus.PENDING)) { + FetchStatus report = null; + if (own.containsValue(FetchStatus.SUCCESS) || remote.containsValue(FetchStatus.SUCCESS)) { + report = FetchStatus.SUCCESS; + } else if (own.containsValue(FetchStatus.SUCCESS_VERIFIED) || remote.containsValue(FetchStatus.SUCCESS_VERIFIED)) { + report = FetchStatus.SUCCESS_VERIFIED; + } else if (own.containsValue(FetchStatus.SUCCESS_TRUSTED) || remote.containsValue(FetchStatus.SUCCESS_TRUSTED)) { + report = FetchStatus.SUCCESS_TRUSTED; + } else if (own.containsValue(FetchStatus.ERROR) || remote.containsValue(FetchStatus.ERROR)) { + report = FetchStatus.ERROR; + } + mXmppConnectionService.keyStatusUpdated(report); + } + if (Config.REMOVE_BROKEN_DEVICES) { + Set ownDeviceIds = new HashSet<>(getOwnDeviceIds()); + boolean publish = false; + for (Map.Entry entry : own.entrySet()) { + int id = entry.getKey(); + if (entry.getValue() == FetchStatus.ERROR && PREVIOUSLY_REMOVED_FROM_ANNOUNCEMENT.add(id) && ownDeviceIds.remove(id)) { + publish = true; + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": error fetching own device with id " + id + ". removing from announcement"); + } + } + if (publish) { + publishOwnDeviceId(ownDeviceIds); + } + } + } + + public boolean hasEmptyDeviceList(Jid jid) { + return !hasAny(jid) && (!deviceIds.containsKey(jid) || deviceIds.get(jid).isEmpty()); + } + + public void fetchDeviceIds(final Jid jid) { + fetchDeviceIds(jid, null); + } + + private void fetchDeviceIds(final Jid jid, OnDeviceIdsFetched callback) { + IqPacket packet; + synchronized (this.fetchDeviceIdsMap) { + List callbacks = this.fetchDeviceIdsMap.get(jid); + if (callbacks != null) { + if (callback != null) { + callbacks.add(callback); + } + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": fetching device ids for " + jid + " already running. adding callback"); + packet = null; + } else { + callbacks = new ArrayList<>(); + if (callback != null) { + callbacks.add(callback); + } + this.fetchDeviceIdsMap.put(jid, callbacks); + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": fetching device ids for " + jid); + packet = mXmppConnectionService.getIqGenerator().retrieveDeviceIds(jid); + } + } + if (packet != null) { + mXmppConnectionService.sendIqPacket(account, packet, (account, response) -> { + if (response.getType() == IqPacket.TYPE.RESULT) { + fetchDeviceListStatus.put(jid, true); + Element item = mXmppConnectionService.getIqParser().getItem(response); + Set deviceIds = mXmppConnectionService.getIqParser().deviceIds(item); + registerDevices(jid, deviceIds); + final List callbacks; + synchronized (fetchDeviceIdsMap) { + callbacks = fetchDeviceIdsMap.remove(jid); + } + if (callbacks != null) { + for (OnDeviceIdsFetched c : callbacks) { + c.fetched(jid, deviceIds); + } + } + } else { + if (response.getType() == IqPacket.TYPE.TIMEOUT) { + fetchDeviceListStatus.remove(jid); + } else { + fetchDeviceListStatus.put(jid, false); + } + final List callbacks; + synchronized (fetchDeviceIdsMap) { + callbacks = fetchDeviceIdsMap.remove(jid); + } + if (callbacks != null) { + for (OnDeviceIdsFetched c : callbacks) { + c.fetched(jid, null); + } + } + } + }); + } + } + + private void fetchDeviceIds(List jids, final OnMultipleDeviceIdFetched callback) { + final ArrayList unfinishedJids = new ArrayList<>(jids); + synchronized (unfinishedJids) { + for (Jid jid : unfinishedJids) { + fetchDeviceIds(jid, (j, deviceIds) -> { + synchronized (unfinishedJids) { + unfinishedJids.remove(j); + if (unfinishedJids.size() == 0 && callback != null) { + callback.fetched(); + } + } + }); + } + } + } + + private void buildSessionFromPEP(final SignalProtocolAddress address) { + buildSessionFromPEP(address, null); + } + + private void buildSessionFromPEP(final SignalProtocolAddress address, OnSessionBuildFromPep callback) { + Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Building new session for " + address.toString()); + if (address.equals(getOwnAxolotlAddress())) { + throw new AssertionError("We should NEVER build a session with ourselves. What happened here?!"); + } + + final Jid jid = Jid.of(address.getName()); + final boolean oneOfOurs = jid.asBareJid().equals(account.getJid().asBareJid()); + IqPacket bundlesPacket = mXmppConnectionService.getIqGenerator().retrieveBundlesForDevice(jid, address.getDeviceId()); + mXmppConnectionService.sendIqPacket(account, bundlesPacket, (account, packet) -> { + if (packet.getType() == IqPacket.TYPE.TIMEOUT) { + fetchStatusMap.put(address, FetchStatus.TIMEOUT); + } else if (packet.getType() == IqPacket.TYPE.RESULT) { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Received preKey IQ packet, processing..."); + final IqParser parser = mXmppConnectionService.getIqParser(); + final List preKeyBundleList = parser.preKeys(packet); + final PreKeyBundle bundle = parser.bundle(packet); + if (preKeyBundleList.isEmpty() || bundle == null) { + Log.e(Config.LOGTAG, AxolotlService.getLogprefix(account) + "preKey IQ packet invalid: " + packet); + fetchStatusMap.put(address, FetchStatus.ERROR); + finishBuildingSessionsFromPEP(address); + if (callback != null) { + callback.onSessionBuildFailed(); + } + return; + } + Random random = new Random(); + final PreKeyBundle preKey = preKeyBundleList.get(random.nextInt(preKeyBundleList.size())); + if (preKey == null) { + //should never happen + fetchStatusMap.put(address, FetchStatus.ERROR); + finishBuildingSessionsFromPEP(address); + if (callback != null) { + callback.onSessionBuildFailed(); + } + return; + } + + final PreKeyBundle preKeyBundle = new PreKeyBundle(0, address.getDeviceId(), + preKey.getPreKeyId(), preKey.getPreKey(), + bundle.getSignedPreKeyId(), bundle.getSignedPreKey(), + bundle.getSignedPreKeySignature(), bundle.getIdentityKey()); + + try { + SessionBuilder builder = new SessionBuilder(axolotlStore, address); + builder.process(preKeyBundle); + XmppAxolotlSession session = new XmppAxolotlSession(account, axolotlStore, address, bundle.getIdentityKey()); + sessions.put(address, session); + if (Config.X509_VERIFICATION) { + verifySessionWithPEP(session); //TODO; maybe inject callback in here too + } else { + FingerprintStatus status = getFingerprintTrust(CryptoHelper.bytesToHex(bundle.getIdentityKey().getPublicKey().serialize())); + FetchStatus fetchStatus; + if (status != null && status.isVerified()) { + fetchStatus = FetchStatus.SUCCESS_VERIFIED; + } else if (status != null && status.isTrusted()) { + fetchStatus = FetchStatus.SUCCESS_TRUSTED; + } else { + fetchStatus = FetchStatus.SUCCESS; + } + fetchStatusMap.put(address, fetchStatus); + finishBuildingSessionsFromPEP(address); + if (callback != null) { + callback.onSessionBuildSuccessful(); + } + } + } catch (UntrustedIdentityException | InvalidKeyException e) { + Log.e(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Error building session for " + address + ": " + + e.getClass().getName() + ", " + e.getMessage()); + fetchStatusMap.put(address, FetchStatus.ERROR); + finishBuildingSessionsFromPEP(address); + if (oneOfOurs && cleanedOwnDeviceIds.add(address.getDeviceId())) { + removeFromDeviceAnnouncement(address.getDeviceId()); + } + if (callback != null) { + callback.onSessionBuildFailed(); + } + } + } else { + fetchStatusMap.put(address, FetchStatus.ERROR); + Element error = packet.findChild("error"); + boolean itemNotFound = error != null && error.hasChild("item-not-found"); + Log.d(Config.LOGTAG, getLogprefix(account) + "Error received while building session:" + packet.findChild("error")); + finishBuildingSessionsFromPEP(address); + if (oneOfOurs && itemNotFound && cleanedOwnDeviceIds.add(address.getDeviceId())) { + removeFromDeviceAnnouncement(address.getDeviceId()); + } + if (callback != null) { + callback.onSessionBuildFailed(); + } + } + }); + } + + private void removeFromDeviceAnnouncement(Integer id) { + HashSet temp = new HashSet<>(getOwnDeviceIds()); + if (temp.remove(id)) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + " remove own device id " + id + " from announcement. devices left:" + temp); + publishOwnDeviceId(temp); + } + } + + public Set findDevicesWithoutSession(final Conversation conversation) { + Set addresses = new HashSet<>(); + for (Jid jid : getCryptoTargets(conversation)) { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Finding devices without session for " + jid); + final Set ids = deviceIds.get(jid); + if (ids != null && !ids.isEmpty()) { + for (Integer foreignId : ids) { + SignalProtocolAddress address = new SignalProtocolAddress(jid.toString(), foreignId); + if (sessions.get(address) == null) { + IdentityKey identityKey = axolotlStore.loadSession(address).getSessionState().getRemoteIdentityKey(); + if (identityKey != null) { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Already have session for " + address.toString() + ", adding to cache..."); + XmppAxolotlSession session = new XmppAxolotlSession(account, axolotlStore, address, identityKey); + sessions.put(address, session); + } else { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Found device " + jid + ":" + foreignId); + if (fetchStatusMap.get(address) != FetchStatus.ERROR) { + addresses.add(address); + } else { + Log.d(Config.LOGTAG, getLogprefix(account) + "skipping over " + address + " because it's broken"); + } + } + } + } + } else { + mXmppConnectionService.keyStatusUpdated(FetchStatus.ERROR); + Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Have no target devices in PEP!"); + } + } + Set ownIds = this.deviceIds.get(account.getJid().asBareJid()); + for (Integer ownId : (ownIds != null ? ownIds : new HashSet())) { + SignalProtocolAddress address = new SignalProtocolAddress(account.getJid().asBareJid().toString(), ownId); + if (sessions.get(address) == null) { + IdentityKey identityKey = axolotlStore.loadSession(address).getSessionState().getRemoteIdentityKey(); + if (identityKey != null) { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Already have session for " + address.toString() + ", adding to cache..."); + XmppAxolotlSession session = new XmppAxolotlSession(account, axolotlStore, address, identityKey); + sessions.put(address, session); + } else { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Found device " + account.getJid().asBareJid() + ":" + ownId); + if (fetchStatusMap.get(address) != FetchStatus.ERROR) { + addresses.add(address); + } else { + Log.d(Config.LOGTAG, getLogprefix(account) + "skipping over " + address + " because it's broken"); + } + } + } + } + + return addresses; + } + + public boolean createSessionsIfNeeded(final Conversation conversation) { + final List jidsWithEmptyDeviceList = getCryptoTargets(conversation); + for (Iterator iterator = jidsWithEmptyDeviceList.iterator(); iterator.hasNext(); ) { + final Jid jid = iterator.next(); + if (!hasEmptyDeviceList(jid)) { + iterator.remove(); + } + } + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": createSessionsIfNeeded() - jids with empty device list: " + jidsWithEmptyDeviceList); + if (jidsWithEmptyDeviceList.size() > 0) { + fetchDeviceIds(jidsWithEmptyDeviceList, () -> createSessionsIfNeededActual(conversation)); + return true; + } else { + return createSessionsIfNeededActual(conversation); + } + } + + private boolean createSessionsIfNeededActual(final Conversation conversation) { + Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Creating axolotl sessions if needed..."); + boolean newSessions = false; + Set addresses = findDevicesWithoutSession(conversation); + for (SignalProtocolAddress address : addresses) { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Processing device: " + address.toString()); + FetchStatus status = fetchStatusMap.get(address); + if (status == null || status == FetchStatus.TIMEOUT) { + fetchStatusMap.put(address, FetchStatus.PENDING); + this.buildSessionFromPEP(address); + newSessions = true; + } else if (status == FetchStatus.PENDING) { + newSessions = true; + } else { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Already fetching bundle for " + address.toString()); + } + } + + return newSessions; + } + + public boolean trustedSessionVerified(final Conversation conversation) { + final Set sessions = new HashSet<>(); + sessions.addAll(findSessionsForConversation(conversation)); + sessions.addAll(findOwnSessions()); + boolean verified = false; + for (XmppAxolotlSession session : sessions) { + if (session.getTrust().isTrustedAndActive()) { + if (session.getTrust().getTrust() == FingerprintStatus.Trust.VERIFIED_X509) { + verified = true; + } else { + return false; + } + } + } + return verified; + } + + public boolean hasPendingKeyFetches(List jids) { + SignalProtocolAddress ownAddress = new SignalProtocolAddress(account.getJid().asBareJid().toString(), 0); + if (fetchStatusMap.getAll(ownAddress.getName()).containsValue(FetchStatus.PENDING)) { + return true; + } + synchronized (this.fetchDeviceIdsMap) { + for (Jid jid : jids) { + SignalProtocolAddress foreignAddress = new SignalProtocolAddress(jid.asBareJid().toString(), 0); + if (fetchStatusMap.getAll(foreignAddress.getName()).containsValue(FetchStatus.PENDING) || this.fetchDeviceIdsMap.containsKey(jid)) { + return true; + } + } + } + return false; + } + + @Nullable + private boolean buildHeader(XmppAxolotlMessage axolotlMessage, Conversation c) { + Set remoteSessions = findSessionsForConversation(c); + final boolean acceptEmpty = (c.getMode() == Conversation.MODE_MULTI && c.getMucOptions().getUserCount() == 0) || c.getContact().isSelf(); + Collection ownSessions = findOwnSessions(); + if (remoteSessions.isEmpty() && !acceptEmpty) { + return false; + } + for (XmppAxolotlSession session : remoteSessions) { + axolotlMessage.addDevice(session); + } + for (XmppAxolotlSession session : ownSessions) { + axolotlMessage.addDevice(session); + } + + return true; + } + + //this is being used for private muc messages only + private boolean buildHeader(XmppAxolotlMessage axolotlMessage, Jid jid) { + if (jid == null) { + return false; + } + HashSet sessions = new HashSet<>(); + sessions.addAll(this.sessions.getAll(getAddressForJid(jid).getName()).values()); + if (sessions.isEmpty()) { + return false; + } + sessions.addAll(findOwnSessions()); + for (XmppAxolotlSession session : sessions) { + axolotlMessage.addDevice(session); + } + return true; + } + + @Nullable + public XmppAxolotlMessage encrypt(Message message) { + final XmppAxolotlMessage axolotlMessage = new XmppAxolotlMessage(account.getJid().asBareJid(), getOwnDeviceId()); + final String content; + if (message.hasFileOnRemoteHost()) { + content = message.getFileParams().url.toString(); + } else { + content = message.getBody(); + } + try { + axolotlMessage.encrypt(content); + } catch (CryptoFailedException e) { + Log.w(Config.LOGTAG, getLogprefix(account) + "Failed to encrypt message: " + e.getMessage()); + return null; + } + + final boolean success; + if (message.isPrivateMessage()) { + success = buildHeader(axolotlMessage, message.getTrueCounterpart()); + } else { + success = buildHeader(axolotlMessage, (Conversation) message.getConversation()); + } + return success ? axolotlMessage : null; + } + + public void preparePayloadMessage(final Message message, final boolean delay) { + executor.execute(new Runnable() { + @Override + public void run() { + XmppAxolotlMessage axolotlMessage = encrypt(message); + if (axolotlMessage == null) { + mXmppConnectionService.markMessage(message, Message.STATUS_SEND_FAILED); + //mXmppConnectionService.updateConversationUi(); + } else { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Generated message, caching: " + message.getUuid()); + messageCache.put(message.getUuid(), axolotlMessage); + mXmppConnectionService.resendMessage(message, delay); + } + } + }); + } + + public void prepareKeyTransportMessage(final Conversation conversation, final OnMessageCreatedCallback onMessageCreatedCallback) { + executor.execute(new Runnable() { + @Override + public void run() { + final XmppAxolotlMessage axolotlMessage = new XmppAxolotlMessage(account.getJid().asBareJid(), getOwnDeviceId()); + if (buildHeader(axolotlMessage, conversation)) { + onMessageCreatedCallback.run(axolotlMessage); + } else { + onMessageCreatedCallback.run(null); + } + } + }); + } + + public XmppAxolotlMessage fetchAxolotlMessageFromCache(Message message) { + XmppAxolotlMessage axolotlMessage = messageCache.get(message.getUuid()); + if (axolotlMessage != null) { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Cache hit: " + message.getUuid()); + messageCache.remove(message.getUuid()); + } else { + Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Cache miss: " + message.getUuid()); + } + return axolotlMessage; + } + + private XmppAxolotlSession recreateUncachedSession(SignalProtocolAddress address) { + IdentityKey identityKey = axolotlStore.loadSession(address).getSessionState().getRemoteIdentityKey(); + return (identityKey != null) + ? new XmppAxolotlSession(account, axolotlStore, address, identityKey) + : null; + } + + private XmppAxolotlSession getReceivingSession(XmppAxolotlMessage message) { + SignalProtocolAddress senderAddress = new SignalProtocolAddress(message.getFrom().toString(), message.getSenderDeviceId()); + return getReceivingSession(senderAddress); + + } + + private XmppAxolotlSession getReceivingSession(SignalProtocolAddress senderAddress) { + XmppAxolotlSession session = sessions.get(senderAddress); + if (session == null) { + session = recreateUncachedSession(senderAddress); + if (session == null) { + session = new XmppAxolotlSession(account, axolotlStore, senderAddress); + } + } + return session; + } + + public XmppAxolotlMessage.XmppAxolotlPlaintextMessage processReceivingPayloadMessage(XmppAxolotlMessage message, boolean postponePreKeyMessageHandling) throws NotEncryptedForThisDeviceException, BrokenSessionException, OutdatedSenderException { + XmppAxolotlMessage.XmppAxolotlPlaintextMessage plaintextMessage = null; + + XmppAxolotlSession session = getReceivingSession(message); + int ownDeviceId = getOwnDeviceId(); + try { + plaintextMessage = message.decrypt(session, ownDeviceId); + Integer preKeyId = session.getPreKeyIdAndReset(); + if (preKeyId != null) { + postPreKeyMessageHandling(session, postponePreKeyMessageHandling); + } + } catch (NotEncryptedForThisDeviceException e) { + if (account.getJid().asBareJid().equals(message.getFrom().asBareJid()) && message.getSenderDeviceId() == ownDeviceId) { + Log.w(Config.LOGTAG, getLogprefix(account) + "Reflected omemo message received"); + } else { + throw e; + } + } catch (final BrokenSessionException e) { + throw e; + } catch (final OutdatedSenderException e) { + Log.e(Config.LOGTAG,account.getJid().asBareJid()+": "+e.getMessage()); + throw e; + } catch (CryptoFailedException e) { + Log.w(Config.LOGTAG, getLogprefix(account) + "Failed to decrypt message from " + message.getFrom(), e); + } + + if (session.isFresh() && plaintextMessage != null) { + putFreshSession(session); + } + + return plaintextMessage; + } + + public void reportBrokenSessionException(BrokenSessionException e, boolean postpone) { + Log.e(Config.LOGTAG, account.getJid().asBareJid() + ": broken session with " + e.getSignalProtocolAddress().toString() + " detected", e); + if (postpone) { + postponedHealing.add(e.getSignalProtocolAddress()); + } else { + notifyRequiresHealing(e.getSignalProtocolAddress()); + } + } + + private void notifyRequiresHealing(final SignalProtocolAddress signalProtocolAddress) { + if (healingAttempts.add(signalProtocolAddress)) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": attempt to heal " + signalProtocolAddress); + buildSessionFromPEP(signalProtocolAddress, new OnSessionBuildFromPep() { + @Override + public void onSessionBuildSuccessful() { + Log.d(Config.LOGTAG, "successfully build new session from pep after detecting broken session"); + completeSession(getReceivingSession(signalProtocolAddress)); + } + + @Override + public void onSessionBuildFailed() { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": unable to build new session from pep after detecting broken session"); + } + }); + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": do not attempt to heal " + signalProtocolAddress + " again"); + } + } + + private void postPreKeyMessageHandling(final XmppAxolotlSession session, final boolean postpone) { + if (postpone) { + postponedSessions.add(session); + } else { + if (axolotlStore.flushPreKeys()) { + publishBundlesIfNeeded(false, false); + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": nothing to flush. Not republishing key"); + } + if (trustedOrPreviouslyResponded(session)) { + completeSession(session); + } + } + } + + public void processPostponed() { + if (postponedSessions.size() > 0) { + if (axolotlStore.flushPreKeys()) { + publishBundlesIfNeeded(false, false); + } + } + final Iterator iterator = postponedSessions.iterator(); + while (iterator.hasNext()) { + final XmppAxolotlSession session = iterator.next(); + if (trustedOrPreviouslyResponded(session)) { + completeSession(session); + } + iterator.remove(); + } + final Iterator postponedHealingAttemptsIterator = postponedHealing.iterator(); + while (postponedHealingAttemptsIterator.hasNext()) { + notifyRequiresHealing(postponedHealingAttemptsIterator.next()); + postponedHealingAttemptsIterator.remove(); + } + } + + private boolean trustedOrPreviouslyResponded(XmppAxolotlSession session) { + try { + return trustedOrPreviouslyResponded(Jid.of(session.getRemoteAddress().getName())); + } catch (IllegalArgumentException e) { + return false; + } + } + + public boolean trustedOrPreviouslyResponded(Jid jid) { + final Contact contact = account.getRoster().getContact(jid); + if (contact.showInRoster() || contact.isSelf()) { + return true; + } + final Conversation conversation = mXmppConnectionService.find(account, jid); + return conversation != null && conversation.sentMessagesCount() > 0; + } + + private void completeSession(XmppAxolotlSession session) { + final XmppAxolotlMessage axolotlMessage = new XmppAxolotlMessage(account.getJid().asBareJid(), getOwnDeviceId()); + axolotlMessage.addDevice(session, true); + try { + final Jid jid = Jid.of(session.getRemoteAddress().getName()); + MessagePacket packet = mXmppConnectionService.getMessageGenerator().generateKeyTransportMessage(jid, axolotlMessage); + mXmppConnectionService.sendMessagePacket(account, packet); + } catch (IllegalArgumentException e) { + throw new Error("Remote addresses are created from jid and should convert back to jid", e); + } + } + + public XmppAxolotlMessage.XmppAxolotlKeyTransportMessage processReceivingKeyTransportMessage(XmppAxolotlMessage message, final boolean postponePreKeyMessageHandling) { + final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage; + final XmppAxolotlSession session = getReceivingSession(message); + try { + keyTransportMessage = message.getParameters(session, getOwnDeviceId()); + Integer preKeyId = session.getPreKeyIdAndReset(); + if (preKeyId != null) { + postPreKeyMessageHandling(session, postponePreKeyMessageHandling); + } + } catch (CryptoFailedException e) { + Log.d(Config.LOGTAG, "could not decrypt keyTransport message " + e.getMessage()); + return null; + } + + if (session.isFresh() && keyTransportMessage != null) { + putFreshSession(session); + } + + return keyTransportMessage; + } + + private void putFreshSession(XmppAxolotlSession session) { + sessions.put(session); + if (Config.X509_VERIFICATION) { + if (session.getIdentityKey() != null) { + verifySessionWithPEP(session); + } else { + Log.e(Config.LOGTAG, account.getJid().asBareJid() + ": identity key was empty after reloading for x509 verification"); + } + } + } + + public enum FetchStatus { + PENDING, + SUCCESS, + SUCCESS_VERIFIED, + TIMEOUT, + SUCCESS_TRUSTED, + ERROR + } + + public interface OnDeviceIdsFetched { + void fetched(Jid jid, Set deviceIds); + } + + + public interface OnMultipleDeviceIdFetched { + void fetched(); + } + + interface OnSessionBuildFromPep { + void onSessionBuildSuccessful(); + + void onSessionBuildFailed(); + } + + private static class AxolotlAddressMap { + protected final Object MAP_LOCK = new Object(); + protected Map> map; + + public AxolotlAddressMap() { + this.map = new HashMap<>(); + } + + public void put(SignalProtocolAddress address, T value) { + synchronized (MAP_LOCK) { + Map devices = map.get(address.getName()); + if (devices == null) { + devices = new HashMap<>(); + map.put(address.getName(), devices); + } + devices.put(address.getDeviceId(), value); + } + } + + public T get(SignalProtocolAddress address) { + synchronized (MAP_LOCK) { + Map devices = map.get(address.getName()); + if (devices == null) { + return null; + } + return devices.get(address.getDeviceId()); + } + } + + public Map getAll(String name) { + synchronized (MAP_LOCK) { + Map devices = map.get(name); + if (devices == null) { + return new HashMap<>(); + } + return devices; + } + } + + public boolean hasAny(SignalProtocolAddress address) { + synchronized (MAP_LOCK) { + Map devices = map.get(address.getName()); + return devices != null && !devices.isEmpty(); + } + } + + public void clear() { + map.clear(); + } + + } + + private static class SessionMap extends AxolotlAddressMap { + private final XmppConnectionService xmppConnectionService; + private final Account account; + + public SessionMap(XmppConnectionService service, SQLiteAxolotlStore store, Account account) { + super(); + this.xmppConnectionService = service; + this.account = account; + this.fillMap(store); + } + + public Set findCounterpartsForSourceId(Integer sid) { + Set candidates = new HashSet<>(); + synchronized (MAP_LOCK) { + for (Map.Entry> entry : map.entrySet()) { + String key = entry.getKey(); + if (entry.getValue().containsKey(sid)) { + candidates.add(Jid.of(key)); + } + } + } + return candidates; + } + + private void putDevicesForJid(String bareJid, List deviceIds, SQLiteAxolotlStore store) { + for (Integer deviceId : deviceIds) { + SignalProtocolAddress axolotlAddress = new SignalProtocolAddress(bareJid, deviceId); + IdentityKey identityKey = store.loadSession(axolotlAddress).getSessionState().getRemoteIdentityKey(); + if (Config.X509_VERIFICATION) { + X509Certificate certificate = store.getFingerprintCertificate(CryptoHelper.bytesToHex(identityKey.getPublicKey().serialize())); + if (certificate != null) { + Bundle information = CryptoHelper.extractCertificateInformation(certificate); + try { + final String cn = information.getString("subject_cn"); + final Jid jid = Jid.of(bareJid); + Log.d(Config.LOGTAG, "setting common name for " + jid + " to " + cn); + account.getRoster().getContact(jid).setCommonName(cn); + } catch (final IllegalArgumentException ignored) { + //ignored + } + } + } + this.put(axolotlAddress, new XmppAxolotlSession(account, store, axolotlAddress, identityKey)); + } + } + + private void fillMap(SQLiteAxolotlStore store) { + List deviceIds = store.getSubDeviceSessions(account.getJid().asBareJid().toString()); + putDevicesForJid(account.getJid().asBareJid().toString(), deviceIds, store); + for (String address : store.getKnownAddresses()) { + deviceIds = store.getSubDeviceSessions(address); + putDevicesForJid(address, deviceIds, store); + } + } + + @Override + public void put(SignalProtocolAddress address, XmppAxolotlSession value) { + super.put(address, value); + value.setNotFresh(); + } + + public void put(XmppAxolotlSession session) { + this.put(session.getRemoteAddress(), session); + } + } + + private static class FetchStatusMap extends AxolotlAddressMap { + + public void clearErrorFor(Jid jid) { + synchronized (MAP_LOCK) { + Map devices = this.map.get(jid.asBareJid().toString()); + if (devices == null) { + return; + } + for (Map.Entry entry : devices.entrySet()) { + if (entry.getValue() == FetchStatus.ERROR) { + Log.d(Config.LOGTAG, "resetting error for " + jid.asBareJid() + "(" + entry.getKey() + ")"); + entry.setValue(FetchStatus.TIMEOUT); + } + } + } + } + } } diff --git a/src/main/java/eu/siacs/conversations/crypto/axolotl/OutdatedSenderException.java b/src/main/java/eu/siacs/conversations/crypto/axolotl/OutdatedSenderException.java new file mode 100644 index 000000000..b684c678f --- /dev/null +++ b/src/main/java/eu/siacs/conversations/crypto/axolotl/OutdatedSenderException.java @@ -0,0 +1,8 @@ +package eu.siacs.conversations.crypto.axolotl; + +public class OutdatedSenderException extends CryptoFailedException { + + public OutdatedSenderException(final String msg) { + super(msg); + } +} diff --git a/src/main/java/eu/siacs/conversations/crypto/axolotl/XmppAxolotlMessage.java b/src/main/java/eu/siacs/conversations/crypto/axolotl/XmppAxolotlMessage.java index 5a85e1157..13082deac 100644 --- a/src/main/java/eu/siacs/conversations/crypto/axolotl/XmppAxolotlMessage.java +++ b/src/main/java/eu/siacs/conversations/crypto/axolotl/XmppAxolotlMessage.java @@ -27,294 +27,293 @@ import eu.siacs.conversations.xml.Element; import rocks.xmpp.addr.Jid; public class XmppAxolotlMessage { - public static final String CONTAINERTAG = "encrypted"; - private static final String HEADER = "header"; - private static final String SOURCEID = "sid"; - private static final String KEYTAG = "key"; - private static final String REMOTEID = "rid"; - private static final String IVTAG = "iv"; - private static final String PAYLOAD = "payload"; + public static final String CONTAINERTAG = "encrypted"; + private static final String HEADER = "header"; + private static final String SOURCEID = "sid"; + private static final String KEYTAG = "key"; + private static final String REMOTEID = "rid"; + private static final String IVTAG = "iv"; + private static final String PAYLOAD = "payload"; - private static final String KEYTYPE = "AES"; - private static final String CIPHERMODE = "AES/GCM/NoPadding"; - private static final String PROVIDER = "BC"; + private static final String KEYTYPE = "AES"; + private static final String CIPHERMODE = "AES/GCM/NoPadding"; + private static final String PROVIDER = "BC"; + private final List 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 byte[] ciphertext = null; - private byte[] authtagPlusInnerKey = null; - private byte[] iv = null; - private final List keys; - private final Jid from; - private final int sourceDeviceId; + 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 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 { - private final String plaintext; - private final String fingerprint; + XmppAxolotlMessage(Jid from, int sourceDeviceId) { + this.from = from; + this.sourceDeviceId = sourceDeviceId; + this.keys = new ArrayList<>(); + this.iv = generateIv(); + this.innerKey = generateKey(); + } - XmppAxolotlPlaintextMessage(String plaintext, String fingerprint) { - this.plaintext = plaintext; - this.fingerprint = fingerprint; - } + 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"); + } + } - public String getPlaintext() { - return plaintext; - } + 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; + } + + 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 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() { - return fingerprint; - } - } + public String getFingerprint() { + return fingerprint; + } + } - public static class XmppAxolotlKeyTransportMessage { - private final String fingerprint; - private final byte[] key; - private final byte[] iv; + public static class XmppAxolotlKeyTransportMessage { + private final String fingerprint; + private final byte[] key; + private final byte[] iv; - XmppAxolotlKeyTransportMessage(String fingerprint, byte[] key, byte[] iv) { - this.fingerprint = fingerprint; - this.key = key; - this.iv = iv; - } + XmppAxolotlKeyTransportMessage(String fingerprint, byte[] key, byte[] iv) { + this.fingerprint = fingerprint; + this.key = key; + this.iv = iv; + } - public String getFingerprint() { - return fingerprint; - } + public String getFingerprint() { + return fingerprint; + } - public byte[] getKey() { - return key; - } + public byte[] getKey() { + return key; + } - public byte[] getIv() { - 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 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 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; - } + public byte[] getIv() { + return iv; + } + } } diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index 2187617e3..ec90e294f 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -76,8 +76,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl private Jid nextCounterpart; private transient MucOptions mucOptions = null; private boolean messagesLeftOnServer = true; - private ChatState mOutgoingChatState = Config.DEFAULT_CHATSTATE; - private ChatState mIncomingChatState = Config.DEFAULT_CHATSTATE; + private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE; + private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE; private String mFirstMamReference = null; public Conversation(final String name, final Account account, final Jid contactJid, diff --git a/src/main/java/eu/siacs/conversations/entities/MucOptions.java b/src/main/java/eu/siacs/conversations/entities/MucOptions.java index 116906fef..74a3c538d 100644 --- a/src/main/java/eu/siacs/conversations/entities/MucOptions.java +++ b/src/main/java/eu/siacs/conversations/entities/MucOptions.java @@ -94,7 +94,7 @@ public class MucOptions { public void resetChatState() { synchronized (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 Avatar avatar; private MucOptions options; - private ChatState chatState = Config.DEFAULT_CHATSTATE; + private ChatState chatState = Config.DEFAULT_CHAT_STATE; public User(MucOptions options, Jid fullJid) { this.options = options; diff --git a/src/main/java/eu/siacs/conversations/http/HttpDownloadConnection.java b/src/main/java/eu/siacs/conversations/http/HttpDownloadConnection.java index 1d88d6ff4..317c2cead 100644 --- a/src/main/java/eu/siacs/conversations/http/HttpDownloadConnection.java +++ b/src/main/java/eu/siacs/conversations/http/HttpDownloadConnection.java @@ -31,493 +31,487 @@ import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.utils.FileWriterException; import eu.siacs.conversations.utils.WakeLockHelper; -import eu.siacs.conversations.xmpp.OnIqPacketReceived; import eu.siacs.conversations.xmpp.stanzas.IqPacket; import rocks.xmpp.addr.Jid; public class HttpDownloadConnection implements Transferable { - private HttpConnectionManager mHttpConnectionManager; - private XmppConnectionService mXmppConnectionService; + private final Message message; + 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; - private final Message message; - private DownloadableFile file; - private int mStatus = Transferable.STATUS_UNKNOWN; - private boolean acceptedAutomatically = false; - private int mProgress = 0; - private final boolean mUseTor; - private boolean canceled = false; - private Method method = Method.HTTP_UPLOAD; + HttpDownloadConnection(Message message, HttpConnectionManager manager) { + this.message = message; + this.mHttpConnectionManager = manager; + this.mXmppConnectionService = manager.getXmppConnectionService(); + this.mUseTor = mXmppConnectionService.useTorToConnect(); + } - HttpDownloadConnection(Message message, HttpConnectionManager manager) { - this.message = message; - this.mHttpConnectionManager = manager; - this.mXmppConnectionService = manager.getXmppConnectionService(); - this.mUseTor = mXmppConnectionService.useTorToConnect(); - } + @Override + public boolean start() { + if (mXmppConnectionService.hasInternetConnection()) { + if (this.mStatus == STATUS_OFFER_CHECK_FILESIZE) { + checkFileSize(true); + } else { + download(true); + } + return true; + } else { + return false; + } + } - @Override - public boolean start() { - if (mXmppConnectionService.hasInternetConnection()) { - if (this.mStatus == STATUS_OFFER_CHECK_FILESIZE) { - checkFileSize(true); - } else { - download(true); - } - return true; - } else { - return false; - } - } - - public void init(boolean interactive) { - if (message.isDeleted()) { - if (message.getType() == Message.TYPE_PRIVATE_FILE) { - message.setType(Message.TYPE_PRIVATE); - } else if (message.isFileOrImage()) { - message.setType(Message.TYPE_TEXT); - } - message.setDeleted(false); - mXmppConnectionService.updateMessage(message); - } - this.message.setTransferable(this); - try { - final Message.FileParams fileParams = message.getFileParams(); - if (message.hasFileOnRemoteHost()) { - mUrl = CryptoHelper.toHttpsUrl(fileParams.url); - } else if (message.isOOb() && fileParams.url != null && fileParams.size > 0) { - mUrl = fileParams.url; - } else { - mUrl = CryptoHelper.toHttpsUrl(new URL(message.getBody().split("\n")[0])); - } - final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.getPath()); - if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) { - this.message.setEncryption(Message.ENCRYPTION_PGP); - } else if (message.getEncryption() != Message.ENCRYPTION_OTR - && message.getEncryption() != Message.ENCRYPTION_AXOLOTL) { - this.message.setEncryption(Message.ENCRYPTION_NONE); - } - final String ext; - 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; - } + public void init(boolean interactive) { + if (message.isDeleted()) { + if (message.getType() == Message.TYPE_PRIVATE_FILE) { + message.setType(Message.TYPE_PRIVATE); + } else if (message.isFileOrImage()) { + message.setType(Message.TYPE_TEXT); + } + message.setOob(true); + message.setDeleted(false); + mXmppConnectionService.updateMessage(message); + } + this.message.setTransferable(this); + try { + final Message.FileParams fileParams = message.getFileParams(); + if (message.hasFileOnRemoteHost()) { + mUrl = CryptoHelper.toHttpsUrl(fileParams.url); + } else if (message.isOOb() && fileParams.url != null && fileParams.size > 0) { + mUrl = fileParams.url; + } else { + mUrl = CryptoHelper.toHttpsUrl(new URL(message.getBody().split("\n")[0])); + } + final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.getPath()); + if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) { + this.message.setEncryption(Message.ENCRYPTION_PGP); + } else if (message.getEncryption() != Message.ENCRYPTION_OTR + && message.getEncryption() != Message.ENCRYPTION_AXOLOTL) { + this.message.setEncryption(Message.ENCRYPTION_NONE); + } + final String ext; + if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) { + ext = extension.secondary; + } else { + ext = extension.main; + } + message.setRelativeFilePath(message.getUuid() + (ext != null ? ("." + ext) : "")); + final String reference = mUrl.getRef(); + if (reference != null && AesGcmURLStreamHandler.IV_KEY.matcher(reference).matches()) { + this.file = new DownloadableFile(mXmppConnectionService.getCacheDir().getAbsolutePath() + "/" + message.getUuid()); + this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference)); + Log.d(Config.LOGTAG, "create temporary OMEMO encrypted file: " + this.file.getAbsolutePath() + "(" + message.getMimeType() + ")"); + } else { + this.file = mXmppConnectionService.getFileBackend().getFile(message, false); + } - @Override - public void run() { - if (mUrl.getProtocol().equalsIgnoreCase(P1S3UrlStreamHandler.PROTOCOL_NAME)) { - retrieveUrl(); - } else { - check(); - } - } + 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 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 download(boolean interactive) { + new Thread(new FileDownloader(interactive)).start(); + } - 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 checkFileSize(boolean interactive) { + new Thread(new FileSizeChecker(interactive)).start(); + } - 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); - } - } + @Override + public void cancel() { + this.canceled = true; + mHttpConnectionManager.finishConnection(this); + message.setTransferable(null); + if (message.isFileOrImage()) { + message.setDeleted(true); + } + mHttpConnectionManager.updateConversationUi(true); + } - 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 void decryptFile() throws IOException { + final DownloadableFile outputFile = mXmppConnectionService.getFileBackend().getFile(message, true); - } + 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) { - this.interactive = interactive; - } + ByteStreams.copy(is, os); - @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(); - } - } + FileBackend.close(is); + FileBackend.close(os); - 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 (!file.delete()) { + Log.w(Config.LOGTAG, "unable to delete temporary OMEMO encrypted file " + file.getAbsolutePath()); + } + } - 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, "http download failed " + e.getMessage()); - throw e; - } finally { - FileBackend.close(os); - FileBackend.close(is); - if (connection != null) { - connection.disconnect(); - } - WakeLockHelper.release(wakeLock); - } - } + 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 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); - } + private void decryptIfNeeded() throws IOException { + if (file.getKey() != null && file.getIv() != null) { + decryptFile(); + } + } - } + 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); + } + + } } diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 84bee6408..ed7f6ad96 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -19,6 +19,7 @@ import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.axolotl.BrokenSessionException; 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.entities.Account; import eu.siacs.conversations.entities.Bookmark; @@ -140,6 +141,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } } catch (NotEncryptedForThisDeviceException e) { 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) { Message finishedMessage = new Message(conversation, plaintextMessage.getPlaintext(), Message.ENCRYPTION_AXOLOTL, status); diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java index 48f3d4343..57a129f70 100644 --- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java @@ -508,6 +508,8 @@ public class FileBackend { return getFile(message, true); } + + public DownloadableFile getFileForPath(String path) { return getFileForPath(path, MimeUtils.guessMimeTypeFromExtension(MimeUtils.extractRelevantExtension(path))); } diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index df2aaec19..4280e6f00 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -34,7 +34,6 @@ import android.provider.ContactsContract; import android.security.KeyChain; import android.support.annotation.BoolRes; import android.support.annotation.IntegerRes; -import android.support.annotation.NonNull; import android.support.v4.app.RemoteInput; import android.support.v4.content.ContextCompat; import android.text.TextUtils; @@ -1515,7 +1514,7 @@ public class XmppConnectionService extends Service { if (delay) { mMessageGenerator.addDelay(packet, message.getTimeSent()); } - if (conversation.setOutgoingChatState(Config.DEFAULT_CHATSTATE)) { + if (conversation.setOutgoingChatState(Config.DEFAULT_CHAT_STATE)) { if (this.sendChatStates()) { packet.addChild(ChatState.toElement(conversation.getOutgoingChatState())); } @@ -1855,6 +1854,9 @@ public class XmppConnectionService extends Service { for (Conversation conversation : getConversations()) { deleted |= conversation.markAsDeleted(uuids); } + for(final String uuid : uuids) { + evictPreview(uuid); + } if (deleted) { updateConversationUi(); } @@ -2514,7 +2516,7 @@ public class XmppConnectionService extends Service { if (conversation.getMode() == Conversation.MODE_MULTI) { conversation.getMucOptions().resetChatState(); } else { - conversation.setIncomingChatState(Config.DEFAULT_CHATSTATE); + conversation.setIncomingChatState(Config.DEFAULT_CHAT_STATE); } } for (Account account : getAccounts()) { @@ -4576,6 +4578,12 @@ public class XmppConnectionService extends Service { sendIqPacket(account, set, null); } + public void evictPreview(String uuid) { + if (mBitmapCache.remove(uuid) != null) { + Log.d(Config.LOGTAG,"deleted cached preview"); + } + } + public interface OnMamPreferencesFetched { void onPreferencesFetched(Element prefs); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 724855ee7..036e35633 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -1653,6 +1653,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke builder.setPositiveButton(R.string.confirm, (dialog, which) -> { if (activity.xmppConnectionService.getFileBackend().deleteFile(message)) { message.setDeleted(true); + activity.xmppConnectionService.evictPreview(message.getUuid()); activity.xmppConnectionService.updateMessage(message, false); activity.onConversationsListItemUpdated(); refresh(); @@ -1721,7 +1722,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } public void privateMessageWith(final Jid counterpart) { - if (conversation.setOutgoingChatState(Config.DEFAULT_CHATSTATE)) { + if (conversation.setOutgoingChatState(Config.DEFAULT_CHAT_STATE)) { activity.xmppConnectionService.sendChatState(conversation); } 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) { - 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(); if (status == Account.State.ONLINE && conversation.setOutgoingChatState(state)) { activity.xmppConnectionService.sendChatState(conversation); @@ -2619,7 +2620,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke return; } 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); } if (storeNextMessage()) { diff --git a/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java b/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java index d5a88ebcd..9a2e7d939 100644 --- a/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java @@ -88,7 +88,7 @@ public class UriHandlerActivity extends AppCompatActivity { final List accounts = DatabaseBackend.getInstance(this).getAccountJids(true); if (SignupUtils.isSupportTokenRegistry() && xmppUri.isValidJid()) { - final String preauth = xmppUri.getParamater("preauth"); + final String preauth = xmppUri.getParameter("preauth"); final Jid jid = xmppUri.getJid(); if (xmppUri.isAction(XmppUri.ACTION_REGISTER)) { if (jid.getEscapedLocal() != null && accounts.contains(jid.asBareJid())) { @@ -99,7 +99,7 @@ public class UriHandlerActivity extends AppCompatActivity { startActivity(intent); 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.putExtra(StartConversationActivity.EXTRA_INVITE_URI, xmppUri.toString()); startActivity(intent); diff --git a/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java b/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java index 32feb61b0..bd50ab0ae 100644 --- a/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java @@ -31,8 +31,10 @@ package eu.siacs.conversations.utils; import android.content.Context; import android.content.SharedPreferences; +import android.content.res.Configuration; import android.content.res.Resources; import android.content.res.TypedArray; +import android.os.Build; import android.preference.PreferenceManager; import android.support.annotation.StyleRes; import android.support.design.widget.Snackbar; @@ -45,10 +47,10 @@ import eu.siacs.conversations.ui.SettingsActivity; public class ThemeHelper { - public static int find(Context context) { + public static int find(final Context context) { final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); 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)); switch (fontSize) { case "medium": @@ -63,7 +65,7 @@ public class ThemeHelper { public static int findDialog(Context context) { final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); 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)); switch (fontSize) { 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) { switch (id) { case R.style.ConversationsTheme_Dark: diff --git a/src/main/java/eu/siacs/conversations/utils/XmppUri.java b/src/main/java/eu/siacs/conversations/utils/XmppUri.java index e17c2ff3b..2afa2954b 100644 --- a/src/main/java/eu/siacs/conversations/utils/XmppUri.java +++ b/src/main/java/eu/siacs/conversations/utils/XmppUri.java @@ -18,241 +18,240 @@ import rocks.xmpp.addr.Jid; public class XmppUri { - protected Uri uri; - protected String jid; - private List fingerprints = new ArrayList<>(); - private Map parameters = Collections.emptyMap(); - private boolean safeSource = true; + public static final String ACTION_JOIN = "join"; + public static final String ACTION_MESSAGE = "message"; + public static final String ACTION_REGISTER = "register"; + public static final String ACTION_ROSTER = "roster"; + private static final String OMEMO_URI_PARAM = "omemo-sid-"; + protected Uri uri; + protected String jid; + private List fingerprints = new ArrayList<>(); + private Map 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 static final String ACTION_MESSAGE = "message"; - public static final String ACTION_REGISTER = "register"; - public static final String ACTION_ROSTER = "roster"; + public XmppUri(Uri uri) { + parse(uri); + } - 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 XmppUri(Uri uri, boolean safeSource) { + this.safeSource = safeSource; + parse(uri); + } - public XmppUri(Uri uri) { - parse(uri); - } + private static Map parseParameters(final String query, final char seperator) { + final ImmutableMap.Builder 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) { - this.safeSource = safeSource; - parse(uri); - } + private static List parseFingerprints(Map parameters) { + ImmutableList.Builder builder = new ImmutableList.Builder<>(); + for (Map.Entry 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() { - return safeSource; - } + public static String getFingerprintUri(final String base, final List fingerprints, char separator) { + 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) { - if (uri == null) { - return; - } - this.uri = uri; - String scheme = uri.getScheme(); - String host = uri.getHost(); - List 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 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 String lameUrlDecode(String url) { + return url.replace("%23", "#").replace("%25", "%"); + } + public static String lameUrlEncode(String url) { + return url.replace("%", "%25").replace("#", "%23"); + } - private static Map parseParameters(final String query, final char seperator) { - final ImmutableMap.Builder 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 boolean isSafeSource() { + return safeSource; + } - @Override - @NonNull - public String toString() { - if (uri != null) { - return uri.toString(); - } - return ""; - } + protected void parse(final Uri uri) { + if (uri == null) { + return; + } + this.uri = uri; + String scheme = uri.getScheme(); + String host = uri.getHost(); + List 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 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 parseFingerprints(Map parameters) { - ImmutableList.Builder builder = new ImmutableList.Builder<>(); - for (Map.Entry 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 - } - } - } - return builder.build(); - } + @Override + @NonNull + public String toString() { + if (uri != null) { + return uri.toString(); + } + return ""; + } - public boolean isAction(final String action) { - return parameters.containsKey(action); - } + public boolean isAction(final String action) { + return parameters.containsKey(action); + } - public Jid getJid() { - try { - return this.jid == null ? null : Jid.of(this.jid); - } catch (IllegalArgumentException e) { - return null; - } - } + public Jid getJid() { + try { + return this.jid == null ? null : Jid.of(this.jid); + } catch (IllegalArgumentException e) { + return null; + } + } - public boolean isValidJid() { - if (jid == null) { - return false; - } - try { - Jid.of(jid); - return true; - } catch (IllegalArgumentException e) { - return false; - } - } + public boolean isValidJid() { + if (jid == null) { + return false; + } + try { + Jid.of(jid); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } - public String getBody() { - return parameters.get("body"); - } + public String getBody() { + return parameters.get("body"); + } - public String getName() { - return parameters.get("name"); - } + public String getName() { + return parameters.get("name"); + } - public String getParamater(String key) { - return this.parameters.get(key); - } + public String getParameter(String key) { + return this.parameters.get(key); + } - public List getFingerprints() { - return this.fingerprints; - } + public List getFingerprints() { + return this.fingerprints; + } - public boolean hasFingerprints() { - return fingerprints.size() > 0; - } + public boolean hasFingerprints() { + return fingerprints.size() > 0; + } - public enum FingerprintType { - OMEMO - } + public enum FingerprintType { + OMEMO + } - public static String getFingerprintUri(String base, List fingerprints, char separator) { - 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(); - } + public static class Fingerprint { + public final FingerprintType type; + public final String fingerprint; + final int deviceId; - public static class Fingerprint { - public final FingerprintType type; - public final String fingerprint; - final int deviceId; + public Fingerprint(FingerprintType type, String fingerprint, int deviceId) { + this.type = type; + this.fingerprint = fingerprint; + this.deviceId = deviceId; + } - public Fingerprint(FingerprintType type, String fingerprint, int deviceId) { - this.type = type; - this.fingerprint = fingerprint; - this.deviceId = 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"); - } + @NonNull + @Override + public String toString() { + return type.toString() + ": " + fingerprint + (deviceId != 0 ? " " + deviceId : ""); + } + } } diff --git a/src/main/res/values-hu/strings.xml b/src/main/res/values-hu/strings.xml index 1a485bd41..268d5640a 100644 --- a/src/main/res/values-hu/strings.xml +++ b/src/main/res/values-hu/strings.xml @@ -155,6 +155,7 @@ A felhasználónév már használatban van Regisztráció befejezve A kiszolgáló nem támogatja a regisztrációt + Érvénytelen regisztrációs token A TLS-egyeztetés sikertelen Irányelv megsértése Nem kompatibilis kiszolgáló @@ -879,4 +880,8 @@ Csatornafelderítés módszere Biztonsági mentés Névjegy + + %1$d résztvevő megtekintése + %1$d résztvevő megtekintése + diff --git a/src/main/res/values-pl/strings.xml b/src/main/res/values-pl/strings.xml index 736266dfc..162da6607 100644 --- a/src/main/res/values-pl/strings.xml +++ b/src/main/res/values-pl/strings.xml @@ -155,6 +155,7 @@ Nazwa jest już w użyciu Zarejestrowano pomyślnie Serwer nie umożliwia rejestracji + Nieprawidłowy żeton rejestracji Nie powiodła się negocjacja TLS Naruszenie zasad Serwer niekompatybilny diff --git a/src/main/res/values-v29/theme-settings.xml b/src/main/res/values-v29/theme-settings.xml new file mode 100644 index 000000000..5cd93386f --- /dev/null +++ b/src/main/res/values-v29/theme-settings.xml @@ -0,0 +1,16 @@ + + + + automatic + + @string/pref_theme_automatic + @string/pref_theme_light + @string/pref_theme_dark + + + automatic + light + dark + + + diff --git a/src/main/res/values/about.xml b/src/main/res/values/about.xml index 18b62de73..52c38ffd1 100644 --- a/src/main/res/values/about.xml +++ b/src/main/res/values/about.xml @@ -31,7 +31,7 @@ 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 it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or diff --git a/src/main/res/values/arrays.xml b/src/main/res/values/arrays.xml index 50ca38c6e..64865db75 100644 --- a/src/main/res/values/arrays.xml +++ b/src/main/res/values/arrays.xml @@ -1,14 +1,6 @@ - - @string/pref_theme_light - @string/pref_theme_dark - - - light - dark - @string/never 256 KiB diff --git a/src/main/res/values/defaults.xml b/src/main/res/values/defaults.xml index a4a1448cd..dd7b12aa0 100644 --- a/src/main/res/values/defaults.xml +++ b/src/main/res/values/defaults.xml @@ -15,7 +15,6 @@ 144 524288 auto - light recent true true diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index d00136e87..6f81d37df 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -559,6 +559,7 @@ Privacy Theme Select the color palette + Automatic Light theme Dark theme Unable to connect to OpenKeychain diff --git a/src/main/res/values/theme-settings.xml b/src/main/res/values/theme-settings.xml new file mode 100644 index 000000000..b770b1b26 --- /dev/null +++ b/src/main/res/values/theme-settings.xml @@ -0,0 +1,14 @@ + + + + light + + @string/pref_theme_light + @string/pref_theme_dark + + + light + dark + + +