diff --git a/.gitignore b/.gitignore index c689a5f59..e2091fa3d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ src/quicksyPlaystore/res/values/push.xml build/ captures/ signing.properties +signing.managed.properties # Ignore Gradle GUI config gradle-app.setting diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 000000000..affaf7110 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,7 @@ +steps: + build: + image: codeberg.org/freeyourgadget/android-fdroid-tools:latest + commands: + - ./gradlew clean + - ./gradlew assembleConversationsFreeDebug + - ./gradlew assembleQuicksyFreeDebug diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc4e0763..6016b798b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,80 @@ # Changelog +### Version 2.13.4 + +* Fix minor regressions introduced with 2.13.1 + +### Version 2.13.3 + +* Provide easier access to 'Privacy Policy' on Play Store version (Quicksy and Conversations) +* Remove address book integration on Play Store version of Conversations + +### Version 2.13.2 + +* minor bug fixes +* slight modifications in Quicksy onboard flow + +### Version 2.13.1 + +* Support P2P file transfer via WebRTC data channels +* Fix interoperability issues with Bind 2.0 on ejabberd +* Bundle Let’s Encrypt root certificates for Android <= 7 + +### Version 2.13.0 + +* Easier access to 'Show QR code' +* Support PEP Native Bookmarks +* Add support for SDP Offer / Answer Model (Used by SIP gateways) +* Raise target API to Android 14 + +### Version 2.12.12 + +* Support Private DNS (DNS over TLS) +* Support themed launcher icon +* Fix rare permission issue when sharing files on Android 11+ + +### Version 2.12.11 + +* Bump libwebrtc dependency to M117 and bump libvpx +* Go back to AAC for voice messages +* Support per app language settings + +### Version 2.12.10 + +* support per conversation notification settings +* use opus for voice messages on Android 10 + +### Version 2.12.9 + +* Introduce new backup file format + +### Version 2.12.8 + +* Disable opening backup files (.ceb) from file manager + +### Version 2.12.7 + +* Remove channel discovery feature from Google Play version + +### Version 2.12.6 + +* Fix 'q' falsely being recognized as cyrillic + +### Version 2.12.5 + +* Bump Target SDK to 33 again +* Fix issues on servers supporting SASL2 w/o inline Stream Management + +### Version 2.12.4 + +* Revert Target SDK bump (back to 32) to fix various issues on Android 13 + +### Version 2.12.3 + +* Improve support for new emojis +* Add ability to remove account from server +* Show timestamp for calls + ### Version 2.12.2 * Increase corner radius on profile pictures diff --git a/README.md b/README.md index 3ea9fad09..296a05433 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Snikket for Android is based on [Conversations](https://conversations.im/) by Da The official Conversations repository is available at: https://codeberg.org/iNPUTmice/Conversations -Copyright (c) 2014-2023 Daniel Gultsch and Snikket Community Interest Company. +Copyright (c) 2014-2024 Daniel Gultsch and Snikket Community Interest Company. Snikket and the Snikket logo are trademarks of Snikket Community Interest Company. diff --git a/build.gradle b/build.gradle index e4537e71a..71441273d 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.0' + classpath 'com.android.tools.build:gradle:8.2.0-rc03' } } @@ -15,7 +15,7 @@ apply plugin: 'com.android.application' repositories { google() mavenCentral() - jcenter() + maven { url='https://jitpack.io'} } configurations { @@ -31,25 +31,27 @@ configurations { } dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' + implementation 'androidx.viewpager:viewpager:1.0.0' - playstoreImplementation('com.google.firebase:firebase-messaging:23.1.1') { + playstoreImplementation('com.google.firebase:firebase-messaging:23.4.0') { exclude group: 'com.google.firebase', module: 'firebase-core' exclude group: 'com.google.firebase', module: 'firebase-analytics' exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' } conversationsPlaystoreImplementation("com.android.installreferrer:installreferrer:2.2") quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.0.1' - implementation 'org.sufficientlysecure:openpgp-api:10.0' - implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0' - implementation 'androidx.appcompat:appcompat:1.5.1' - implementation 'androidx.exifinterface:exifinterface:1.3.5' + implementation 'com.github.open-keychain.open-keychain:openpgp-api:v5.7.1' + implementation("com.github.CanHub:Android-Image-Cropper:2.0.0") + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.exifinterface:exifinterface:1.3.7' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'com.google.android.material:material:1.7.0' + implementation 'com.google.android.material:material:1.11.0' - implementation "androidx.emoji2:emoji2:1.2.0" - freeImplementation "androidx.emoji2:emoji2-bundled:1.2.0" + implementation "androidx.emoji2:emoji2:1.4.0" + freeImplementation "androidx.emoji2:emoji2-bundled:1.4.0" implementation 'org.bouncycastle:bcmail-jdk15on:1.64' //zxing stopped supporting Java 7 so we have to stick with 3.3.3 @@ -61,9 +63,13 @@ dependencies { implementation 'com.makeramen:roundedimageview:2.3.0' implementation "com.wefika:flowlayout:0.4.1" //noinspection GradleDependency - implementation 'com.otaliastudios:transcoder:0.9.1' + implementation('com.github.natario1:Transcoder:v0.9.1') { + exclude group: 'com.otaliastudios.opengl', module: 'egloo' + } + implementation 'com.github.natario1:Egloo:v0.4.0' implementation 'org.jxmpp:jxmpp-jid:1.0.3' + implementation 'org.jxmpp:jxmpp-stringprep-libidn:1.0.3' implementation 'org.osmdroid:osmdroid-android:6.1.11' implementation 'org.hsluv:hsluv:0.2' implementation 'org.conscrypt:conscrypt-android:2.5.2' @@ -72,11 +78,11 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:2.9.0" implementation "com.squareup.retrofit2:converter-gson:2.9.0" - implementation "com.squareup.okhttp3:okhttp:4.10.0" + implementation "com.squareup.okhttp3:okhttp:4.12.0" - implementation 'com.google.guava:guava:31.1-android' - quicksyImplementation 'io.michaelrocks:libphonenumber-android:8.12.49' - implementation 'im.conversations.webrtc:webrtc-android:104.0.0' + implementation 'com.google.guava:guava:32.1.3-android' + quicksyImplementation 'io.michaelrocks:libphonenumber-android:8.13.17' + implementation 'im.conversations.webrtc:webrtc-android:119.0.0' } ext { @@ -86,13 +92,13 @@ ext { android { namespace 'eu.siacs.conversations' - compileSdkVersion 33 + compileSdk 34 defaultConfig { minSdkVersion 21 - targetSdkVersion 33 - versionCode 42051 - versionName "2.12.2-2" + targetSdkVersion 34 + versionCode 42094 + versionName "2.13.4" archivesBaseName += "-$versionName" applicationId "org.snikket.android" resValue "string", "applicationId", applicationId @@ -105,6 +111,9 @@ android { abi { universalApk true enable true + reset() + //noinspection ChromeOsAbiSupport + include project.ext.abiCodes.keySet() as String[] } } @@ -117,11 +126,13 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } - flavorDimensions("mode", "distribution") + flavorDimensions += "mode" + flavorDimensions += "distribution" productFlavors { @@ -133,10 +144,12 @@ android { def appName = "Quicksy" resValue "string", "app_name", appName buildConfigField "String", "APP_NAME", "\"$appName\"" + buildConfigField "String", "PRIVACY_POLICY", "\"https://quicksy.im/privacy.htm\"" } conversations { dimension "mode" + buildConfigField "String", "PRIVACY_POLICY", "\"https://snikket.org/app/privacy/\"" } playstore { @@ -231,8 +244,11 @@ android { lint { disable 'MissingTranslation', 'InvalidPackage', 'AppCompatResource' } + buildFeatures { + buildConfig true + } - android.applicationVariants.all { variant -> + android.applicationVariants.configureEach { variant -> variant.outputs.each { output -> def baseAbiVersionCode = project.ext.abiCodes.get(output.getFilter(com.android.build.OutputFile.ABI)) if (baseAbiVersionCode != null) { diff --git a/conversations.doap b/conversations.doap index 7f5e654b2..4d492f9b3 100644 --- a/conversations.doap +++ b/conversations.doap @@ -91,13 +91,6 @@ 1.1 - - - - complete - 1.1 - - @@ -215,13 +208,6 @@ 2.0.1 - - - - complete - 2.0.1 - - @@ -389,7 +375,7 @@ complete - 0.2 + 0.3.1 @@ -399,6 +385,20 @@ 0.3.0 + + + + complete + 0.4.0 + + + + + + complete + 0.4.0 + + @@ -438,6 +438,13 @@ 0.2.1 + + + + complete + 1.1.3 + + @@ -453,6 +460,13 @@ 0.2.0 + + + + complete + 0.4.0 + + diff --git a/docs/user/migrating_to_new_device.md b/docs/user/migrating_to_new_device.md index 401a15386..e7d50a1ce 100644 --- a/docs/user/migrating_to_new_device.md +++ b/docs/user/migrating_to_new_device.md @@ -22,12 +22,11 @@ This tutorial explains how you can transfer your Conversations data from an old ## 3. Import the backup (new device) 1. Install Conversations on your new device. 2. Open Conversations for the first time. -3. Tap on "Use other server" -4. Tap on the three dot menu in the upper right corner and tap on "Import backup" -5. If your backup files are not listed, tap on the cloud symbol in the upper right corner to choose the files from the where you saved them. -6. Enter your account password to decrypt the backup. -7. Remember to activate your account (head back to "manage accounts", see step 1.2). -8. Check if chats work. +3. Tap on the three dot menu in the upper right corner and tap on "Import backup" +4. If your backup files are not listed, tap on the cloud symbol in the upper right corner to choose the files from where you saved them. +5. Enter your account password to decrypt the backup. +6. Remember to activate your account (head back to "manage accounts", see step 1.2). +7. Check if chats work. Once confirmed that the new device is running fine you can just uninstall the app from the old device. diff --git a/fastlane/metadata/android/de-DE/changelogs/42050.txt b/fastlane/metadata/android/de-DE/changelogs/42050.txt new file mode 100644 index 000000000..c15854142 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/42050.txt @@ -0,0 +1 @@ +* Vergrößerung des Eckenradius bei Profilbildern diff --git a/fastlane/metadata/android/de-DE/changelogs/42059.txt b/fastlane/metadata/android/de-DE/changelogs/42059.txt new file mode 100644 index 000000000..39abfa314 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/42059.txt @@ -0,0 +1,2 @@ +* Ziel-SDK wieder auf 33 erhöht +* Behebt Probleme auf Servern, die SASL2 ohne Inline Stream Management unterstützen diff --git a/fastlane/metadata/android/de-DE/changelogs/42060.txt b/fastlane/metadata/android/de-DE/changelogs/42060.txt new file mode 100644 index 000000000..535628636 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/42060.txt @@ -0,0 +1 @@ +* Fehlerhafte Erkennung von 'q' als kyrillisch behoben diff --git a/fastlane/metadata/android/de-DE/changelogs/42061.txt b/fastlane/metadata/android/de-DE/changelogs/42061.txt new file mode 100644 index 000000000..9673c61d8 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/42061.txt @@ -0,0 +1 @@ +* Channelsuchfunktion aus der Google Play-Version entfernt diff --git a/fastlane/metadata/android/de-DE/changelogs/42062.txt b/fastlane/metadata/android/de-DE/changelogs/42062.txt new file mode 100644 index 000000000..7b9eff49c --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/42062.txt @@ -0,0 +1 @@ +* Öffnen von Sicherungsdateien (.ceb) im Dateimanager deaktiviert diff --git a/fastlane/metadata/android/de-DE/changelogs/42065.txt b/fastlane/metadata/android/de-DE/changelogs/42065.txt new file mode 100644 index 000000000..79d8f5738 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/42065.txt @@ -0,0 +1 @@ +* Einführung eines neuen Formats für Sicherungsdateien diff --git a/fastlane/metadata/android/de-DE/changelogs/42068.txt b/fastlane/metadata/android/de-DE/changelogs/42068.txt new file mode 100644 index 000000000..8204a5fe5 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/42068.txt @@ -0,0 +1,2 @@ +* Unterstützung der Benachrichtigungseinstellungen pro Unterhaltung +* Verwendung von Opus für Sprachnachrichten unter Android 10 diff --git a/fastlane/metadata/android/de-DE/changelogs/42072.txt b/fastlane/metadata/android/de-DE/changelogs/42072.txt new file mode 100644 index 000000000..bc6504828 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/42072.txt @@ -0,0 +1,3 @@ +* Änderung der libwebrtc-Abhängigkeit auf M117 und Änderung von libvpx +* Rückkehr zu AAC für Sprachnachrichten +* Unterstützung von Spracheinstellungen innerhalb einer App diff --git a/fastlane/metadata/android/de-DE/changelogs/42074.txt b/fastlane/metadata/android/de-DE/changelogs/42074.txt new file mode 100644 index 000000000..ad343db66 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/42074.txt @@ -0,0 +1,3 @@ +* Unterstützung von Private DNS (DNS über TLS) +* Unterstützung für designbasiertes Startsymbol +* Behebt ein seltenes Berechtigungsproblem beim Teilen von Dateien unter Android 11+ diff --git a/fastlane/metadata/android/de-DE/changelogs/4207704.txt b/fastlane/metadata/android/de-DE/changelogs/4207704.txt new file mode 100644 index 000000000..61579ecd3 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/4207704.txt @@ -0,0 +1,3 @@ +* Unterstützung von Private DNS (DNS über TLS) +* Unterstützung von designbezogenem Startsymbol +* Behebt ein seltenes Berechtigungsproblem beim Teilen von Dateien unter Android 11+ diff --git a/fastlane/metadata/android/de-DE/changelogs/4208104.txt b/fastlane/metadata/android/de-DE/changelogs/4208104.txt new file mode 100644 index 000000000..9f9787c70 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* Leichterer Zugriff auf 'QR-Code anzeigen' +* Unterstützung von PEP Native Bookmarks +* Unterstützung für SDP Offer / Answer-Model (wird von SIP Gateways verwendet) +* Anhebung der Ziel-API auf Android 14 diff --git a/fastlane/metadata/android/de-DE/changelogs/4208804.txt b/fastlane/metadata/android/de-DE/changelogs/4208804.txt new file mode 100644 index 000000000..b3e0925f4 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/4208804.txt @@ -0,0 +1,3 @@ +* Unterstützung von P2P-Dateiübertragung über WebRTC-Datenkanäle +* Behebung von Interoperabilitätsproblemen mit Bind 2.0 auf ejabberd +* Bündelung von Let's Encrypt Root-Zertifikaten für Android <= 7 diff --git a/fastlane/metadata/android/de-DE/changelogs/4209004.txt b/fastlane/metadata/android/de-DE/changelogs/4209004.txt new file mode 100644 index 000000000..018d6e1eb --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/4209004.txt @@ -0,0 +1,2 @@ +* kleinere Fehlerbehebungen +* Geringfügige Änderungen beim Quicksy-Onboarding diff --git a/fastlane/metadata/android/de-DE/changelogs/4209204.txt b/fastlane/metadata/android/de-DE/changelogs/4209204.txt new file mode 100644 index 000000000..7658709d2 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/4209204.txt @@ -0,0 +1,2 @@ +* Einfacherer Zugang zu den Datenschutzbestimmungen in der Play Store-Version (Quicksy und Conversations) +* Entfernen der Adressbuchintegration in der Play Store-Version von Conversations diff --git a/fastlane/metadata/android/en-US/changelogs/42050.txt b/fastlane/metadata/android/en-US/changelogs/42050.txt new file mode 100644 index 000000000..abdee24a2 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/42050.txt @@ -0,0 +1 @@ +* Increase corner radius on profile pictures diff --git a/fastlane/metadata/android/en-US/changelogs/42059.txt b/fastlane/metadata/android/en-US/changelogs/42059.txt new file mode 100644 index 000000000..042b86fcb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/42059.txt @@ -0,0 +1,2 @@ +* Bump Target SDK to 33 again +* Fix issues on servers supporting SASL2 w/o inline Stream Management diff --git a/fastlane/metadata/android/en-US/changelogs/42060.txt b/fastlane/metadata/android/en-US/changelogs/42060.txt new file mode 100644 index 000000000..65c918e49 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/42060.txt @@ -0,0 +1 @@ +* Fix 'q' falsely being recognized as cyrillic diff --git a/fastlane/metadata/android/en-US/changelogs/42061.txt b/fastlane/metadata/android/en-US/changelogs/42061.txt new file mode 100644 index 000000000..0475d110d --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/42061.txt @@ -0,0 +1 @@ +* Remove channel discovery feature from Google Play version diff --git a/fastlane/metadata/android/en-US/changelogs/42062.txt b/fastlane/metadata/android/en-US/changelogs/42062.txt new file mode 100644 index 000000000..833c320e1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/42062.txt @@ -0,0 +1 @@ +* Disable opening backup files (.ceb) from file manager diff --git a/fastlane/metadata/android/en-US/changelogs/42065.txt b/fastlane/metadata/android/en-US/changelogs/42065.txt new file mode 100644 index 000000000..9b314f571 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/42065.txt @@ -0,0 +1 @@ +* Introduce new backup file format diff --git a/fastlane/metadata/android/en-US/changelogs/42068.txt b/fastlane/metadata/android/en-US/changelogs/42068.txt new file mode 100644 index 000000000..1ddfe9ea5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/42068.txt @@ -0,0 +1,2 @@ +* support per conversation notification settings +* use opus for voice messages on Android 10 diff --git a/fastlane/metadata/android/en-US/changelogs/42072.txt b/fastlane/metadata/android/en-US/changelogs/42072.txt new file mode 100644 index 000000000..6a84b9489 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/42072.txt @@ -0,0 +1,3 @@ +* Bump libwebrtc dependency to M117 and bump libvpx +* Go back to AAC for voice messages +* Support per app language settings diff --git a/fastlane/metadata/android/en-US/changelogs/4207704.txt b/fastlane/metadata/android/en-US/changelogs/4207704.txt new file mode 100644 index 000000000..6b19df3eb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4207704.txt @@ -0,0 +1,3 @@ +* Support Private DNS (DNS over TLS) +* Support themed launcher icon +* Fix rare permission issue when sharing files on Android 11+ diff --git a/fastlane/metadata/android/en-US/changelogs/4208104.txt b/fastlane/metadata/android/en-US/changelogs/4208104.txt new file mode 100644 index 000000000..a945cbcb3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* Easier access to 'Show QR code' +* Support PEP Native Bookmarks +* Add support for SDP Offer / Answer Model (Used by SIP gateways) +* Raise target API to Android 14 diff --git a/fastlane/metadata/android/en-US/changelogs/4208804.txt b/fastlane/metadata/android/en-US/changelogs/4208804.txt new file mode 100644 index 000000000..b6a7bb98f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4208804.txt @@ -0,0 +1,3 @@ +* Support P2P file transfer via WebRTC data channels +* Fix interoperability issues with Bind 2.0 on ejabberd +* Bundle Let’s Encrypt root certificates for Android <= 7 diff --git a/fastlane/metadata/android/en-US/changelogs/4209004.txt b/fastlane/metadata/android/en-US/changelogs/4209004.txt new file mode 100644 index 000000000..b062bedcb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4209004.txt @@ -0,0 +1,2 @@ +* minor bug fixes +* slight modifications in Quicksy onboard flow diff --git a/fastlane/metadata/android/en-US/changelogs/4209204.txt b/fastlane/metadata/android/en-US/changelogs/4209204.txt new file mode 100644 index 000000000..83a947d54 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4209204.txt @@ -0,0 +1,2 @@ +* Provide easier access to 'Privacy Policy' on Play Store version (Quicksy and Conversations) +* Remove address book integration on Play Store version of Conversations diff --git a/fastlane/metadata/android/en-US/changelogs/4209404.txt b/fastlane/metadata/android/en-US/changelogs/4209404.txt new file mode 100644 index 000000000..764f13c52 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4209404.txt @@ -0,0 +1 @@ +* Fix minor regressions introduced with 2.13.1 diff --git a/fastlane/metadata/android/es-ES/changelogs/349.txt b/fastlane/metadata/android/es-ES/changelogs/349.txt new file mode 100644 index 000000000..8f84c2432 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/349.txt @@ -0,0 +1,4 @@ +* Introducir configuración experta para realizar el descubrimiento de canales en el servidor local en lugar de search.jabber.network +* Habilitar las marcas de verificación de entrega por defecto y eliminar la configuración +* Habilitar «Enviar botón indica estado» por defecto y eliminar la configuración +* Mover los ajustes de copia de seguridad y servicio en primer plano a la pantalla principal diff --git a/fastlane/metadata/android/es-ES/changelogs/351.txt b/fastlane/metadata/android/es-ES/changelogs/351.txt new file mode 100644 index 000000000..a89b01aee --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/351.txt @@ -0,0 +1,3 @@ +* Corrección de la transferencia de archivos Jingle IBB +* Corrección de correcciones repetidas que llenaban la base de datos. +* Transición a Last Message Correction v1.1 diff --git a/fastlane/metadata/android/es-ES/changelogs/353.txt b/fastlane/metadata/android/es-ES/changelogs/353.txt new file mode 100644 index 000000000..e0f55a6b1 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/353.txt @@ -0,0 +1,4 @@ +* Permitir a los usuarios establecer su propio apodo +* reanudar la descarga de archivos encriptados OMEMO +* Los canales ahora usan '#' como símbolo en el avatar +* Quicksy utiliza «siempre» como cifrado OMEMO por defecto (oculta el icono del candado) diff --git a/fastlane/metadata/android/es-ES/changelogs/360.txt b/fastlane/metadata/android/es-ES/changelogs/360.txt new file mode 100644 index 000000000..169ae4b3e --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/360.txt @@ -0,0 +1 @@ +* Soporte para los parámetros URI de XMPP «?register» y «?register;preauth» diff --git a/fastlane/metadata/android/es-ES/changelogs/362.txt b/fastlane/metadata/android/es-ES/changelogs/362.txt new file mode 100644 index 000000000..bd88ea0f4 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/362.txt @@ -0,0 +1 @@ +* Soporte para el cambio automático de tema en Android 10 diff --git a/fastlane/metadata/android/es-ES/changelogs/364.txt b/fastlane/metadata/android/es-ES/changelogs/364.txt new file mode 100644 index 000000000..cc2be8a4d --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/364.txt @@ -0,0 +1,2 @@ +* Proporcionar vista previa de PDF en Android 5+ +* Utilizar IVs de 12 bytes para OMEMO diff --git a/fastlane/metadata/android/es-ES/changelogs/367.txt b/fastlane/metadata/android/es-ES/changelogs/367.txt new file mode 100644 index 000000000..e95c61e6b --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/367.txt @@ -0,0 +1,2 @@ +* Corregir la selección de avatar en algunos dispositivos Android 10 +* Corregir la transferencia de archivos más grandes diff --git a/fastlane/metadata/android/es-ES/changelogs/379.txt b/fastlane/metadata/android/es-ES/changelogs/379.txt new file mode 100644 index 000000000..74870d83f --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/379.txt @@ -0,0 +1 @@ +* Llamadas de audio/vídeo (requiere soporte de servidor en forma de servidores STUN y TURN detectables mediante XEP-0215) diff --git a/fastlane/metadata/android/es-ES/changelogs/381.txt b/fastlane/metadata/android/es-ES/changelogs/381.txt new file mode 100644 index 000000000..44512a40d --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/381.txt @@ -0,0 +1,2 @@ +* Respuesta audible (marcación, llamada iniciada, llamada finalizada) para llamadas de voz. +* Solucionado el problema de reintento de videollamada fallida diff --git a/fastlane/metadata/android/es-ES/changelogs/382.txt b/fastlane/metadata/android/es-ES/changelogs/382.txt new file mode 100644 index 000000000..8421f8e55 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/382.txt @@ -0,0 +1,2 @@ +* Añadir botón para cambiar de cámara durante la videollamada +* Corregidas las llamadas de voz en tablets diff --git a/fastlane/metadata/android/es-ES/changelogs/383.txt b/fastlane/metadata/android/es-ES/changelogs/383.txt new file mode 100644 index 000000000..189cd9c75 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/383.txt @@ -0,0 +1,3 @@ +* Mover el icono de llamada a la izquierda para mantener otros iconos de la barra de herramientas en un lugar coherente. +* Mostrar la duración de la llamada durante las llamadas de audio +* Desempate en las llamadas A/V (dos personas que se llaman al mismo tiempo) diff --git a/fastlane/metadata/android/es-ES/changelogs/387.txt b/fastlane/metadata/android/es-ES/changelogs/387.txt new file mode 100644 index 000000000..28af6206b --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/387.txt @@ -0,0 +1,2 @@ +* Reestructuración de la interfaz de inicio de sesión con certificado +* Añadir la posibilidad de anclar chats en la parte superior (añadir a favoritos) diff --git a/fastlane/metadata/android/es-ES/changelogs/388.txt b/fastlane/metadata/android/es-ES/changelogs/388.txt new file mode 100644 index 000000000..cd381e62a --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/388.txt @@ -0,0 +1,3 @@ +* Reducir el eco durante las llamadas en algunos dispositivos +* Arreglar el inicio de sesión cuando las contraseñas contienen caracteres especiales +* Reproducir tonos de marcado y ocupado en el altavoz durante las videollamadas diff --git a/fastlane/metadata/android/es-ES/changelogs/390.txt b/fastlane/metadata/android/es-ES/changelogs/390.txt new file mode 100644 index 000000000..7f1ba8d5e --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/390.txt @@ -0,0 +1 @@ +* Ofrecer la grabación de un mensaje de voz cuando la persona que llama está ocupada diff --git a/fastlane/metadata/android/es-ES/changelogs/393.txt b/fastlane/metadata/android/es-ES/changelogs/393.txt new file mode 100644 index 000000000..e6f08c87a --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/393.txt @@ -0,0 +1,3 @@ +* Mostrar botón de ayuda si falla la llamada A/V +* Corregidos algunos fallos molestos +* Corregidas conexiones Jingle (transferencia de archivos + llamadas) con JIDs sin ocultar diff --git a/fastlane/metadata/android/es-ES/changelogs/394.txt b/fastlane/metadata/android/es-ES/changelogs/394.txt new file mode 100644 index 000000000..72b50cdfb --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/394.txt @@ -0,0 +1,2 @@ +* Se ha corregido el problema de las notificaciones que no aparecían en determinadas circunstancias. +* Se han solucionado problemas de compatibilidad y bloqueos relacionados con las llamadas A/V diff --git a/fastlane/metadata/android/es-ES/changelogs/395.txt b/fastlane/metadata/android/es-ES/changelogs/395.txt new file mode 100644 index 000000000..7fd679913 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/395.txt @@ -0,0 +1,3 @@ +* Añadir 'Volver al chat' a la pantalla de llamada de audio +* Mejorar los atajos del teclado +* corrección de errores diff --git a/fastlane/metadata/android/es-ES/changelogs/397.txt b/fastlane/metadata/android/es-ES/changelogs/397.txt new file mode 100644 index 000000000..5bf18c91d --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/397.txt @@ -0,0 +1,3 @@ +* Gestión de archivos GPX +* Mejorar el rendimiento de la restauración de las copias de seguridad +* Corrección de errores diff --git a/fastlane/metadata/android/es-ES/changelogs/398.txt b/fastlane/metadata/android/es-ES/changelogs/398.txt new file mode 100644 index 000000000..b665a46bd --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/398.txt @@ -0,0 +1,4 @@ +* Buscar conversaciones individuales +* Notificar al usuario si falla la entrega del mensaje +* Recordar los nombres de usuario (nicks) de los usuarios de Quicksy en los reinicios +* Añadir el botón para iniciar Orbot (Tor) desde la notificación si es necesario diff --git a/fastlane/metadata/android/es-ES/changelogs/401.txt b/fastlane/metadata/android/es-ES/changelogs/401.txt new file mode 100644 index 000000000..f0818cbe9 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/401.txt @@ -0,0 +1,2 @@ +* búsqueda fija en Android <= 5 +* optimizar el consumo de la memoria diff --git a/fastlane/metadata/android/es-ES/changelogs/402.txt b/fastlane/metadata/android/es-ES/changelogs/402.txt new file mode 100644 index 000000000..079ced197 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/402.txt @@ -0,0 +1,3 @@ +* Ofrece la generación de invitaciones fáciles en los servidores compatibles +* Mostrar GIFs enviados desde Movim +* Almacenar avatares en caché diff --git a/fastlane/metadata/android/es-ES/changelogs/403.txt b/fastlane/metadata/android/es-ES/changelogs/403.txt new file mode 100644 index 000000000..3a4c6b698 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/403.txt @@ -0,0 +1,3 @@ +* Corregidos problemas de conectividad cuando diferentes cuentas utilizaban diferentes mecanismos SCRAM. +* Añadir soporte para SCRAM-SHA-512 +* Permitir la transferencia de archivos P2P (Jingle) con autocontacto diff --git a/fastlane/metadata/android/es-ES/changelogs/404.txt b/fastlane/metadata/android/es-ES/changelogs/404.txt new file mode 100644 index 000000000..c594e57cd --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/404.txt @@ -0,0 +1 @@ +* Pequeñas mejoras de estabilidad en las llamadas A/V diff --git a/fastlane/metadata/android/es-ES/changelogs/405.txt b/fastlane/metadata/android/es-ES/changelogs/405.txt new file mode 100644 index 000000000..a341b6579 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/405.txt @@ -0,0 +1 @@ +* Quicksy: Recibir automáticamente SMS de verificación diff --git a/fastlane/metadata/android/es-ES/changelogs/407.txt b/fastlane/metadata/android/es-ES/changelogs/407.txt new file mode 100644 index 000000000..3958fd6ef --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/407.txt @@ -0,0 +1,3 @@ +* Mostrar botón de llamada para contactos desconectados si previamente anunciaron soporte +* El botón Atrás ya no finaliza la llamada cuando está conectada. +* Corrección de errores diff --git a/fastlane/metadata/android/es-ES/changelogs/42000.txt b/fastlane/metadata/android/es-ES/changelogs/42000.txt new file mode 100644 index 000000000..8ad2c5472 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42000.txt @@ -0,0 +1,4 @@ +* Posibilidad de seleccionar el tono de la llamada entrante +* Corrección de la identificación de claves OpenPGP para OpenKeychain 5.6+. +* Verificación correcta de los certificados TLS punycode +* Mejora de la estabilidad del establecimiento de sesiones RTP (llamadas) diff --git a/fastlane/metadata/android/es-ES/changelogs/42006.txt b/fastlane/metadata/android/es-ES/changelogs/42006.txt new file mode 100644 index 000000000..eb1ea92f4 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42006.txt @@ -0,0 +1,2 @@ +* Verificar llamadas A/V con sesiones OMEMO preexistentes +* Mejorar la compatibilidad con implementaciones WebRTC no libwebrtc diff --git a/fastlane/metadata/android/es-ES/changelogs/42010.txt b/fastlane/metadata/android/es-ES/changelogs/42010.txt new file mode 100644 index 000000000..41e1148ba --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42010.txt @@ -0,0 +1,2 @@ +* Varias correcciones de errores en torno a la compatibilidad con Tor +* Mejora de la compatibilidad de llamadas con Dino diff --git a/fastlane/metadata/android/es-ES/changelogs/42012.txt b/fastlane/metadata/android/es-ES/changelogs/42012.txt new file mode 100644 index 000000000..814a09be7 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42012.txt @@ -0,0 +1 @@ +* Corrección de la carga/descarga HTTP para usuarios que no confían en las CA del sistema diff --git a/fastlane/metadata/android/es-ES/changelogs/42013.txt b/fastlane/metadata/android/es-ES/changelogs/42013.txt new file mode 100644 index 000000000..e31ca2cab --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42013.txt @@ -0,0 +1 @@ +* Solucionados los problemas de 'No Conectividad' en Android 7.1 diff --git a/fastlane/metadata/android/es-ES/changelogs/42014.txt b/fastlane/metadata/android/es-ES/changelogs/42014.txt new file mode 100644 index 000000000..a06654b64 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42014.txt @@ -0,0 +1,2 @@ +* Verificar siempre el nombre del dominio. No sobrescribir al usuario +* Soporta pre autenticación de roster diff --git a/fastlane/metadata/android/es-ES/changelogs/42015.txt b/fastlane/metadata/android/es-ES/changelogs/42015.txt new file mode 100644 index 000000000..ad8b35003 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42015.txt @@ -0,0 +1 @@ +* pequeñas mejoras en A/V diff --git a/fastlane/metadata/android/es-ES/changelogs/42018.txt b/fastlane/metadata/android/es-ES/changelogs/42018.txt new file mode 100644 index 000000000..25ec35a9b --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42018.txt @@ -0,0 +1,3 @@ +* Mostrar barras negras cuando el vídeo remoto no coincide con la relación del aspecto de la pantalla. +* Mejorar el rendimiento de la búsqueda +* Añadir configuración para evitar capturas de pantalla diff --git a/fastlane/metadata/android/es-ES/changelogs/42022.txt b/fastlane/metadata/android/es-ES/changelogs/42022.txt new file mode 100644 index 000000000..db5c79bc7 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42022.txt @@ -0,0 +1,2 @@ +* Corrección de un problema que impedía comprimir algunos vídeos. +* Corrección de un fallo poco frecuente al abrir una notificación diff --git a/fastlane/metadata/android/es-ES/changelogs/42023.txt b/fastlane/metadata/android/es-ES/changelogs/42023.txt new file mode 100644 index 000000000..0a1dd4ce3 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42023.txt @@ -0,0 +1,2 @@ +* Corrección de fallos en la representación de algunas citas +* Corrección del fallo en la pantalla de bienvenida diff --git a/fastlane/metadata/android/es-ES/changelogs/42037.txt b/fastlane/metadata/android/es-ES/changelogs/42037.txt new file mode 100644 index 000000000..b10095829 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42037.txt @@ -0,0 +1,11 @@ +Versión 2.10.9 +* Pedir permisos Bluetooth al hacer llamadas A/V (Puede rechazar esto si no utiliza auriculares Bluetooth). +* Corrección de error al llamar a Movim +* Corregir avatar incorrecto que se muestra para los chats de grupo +* Preguntar siempre por las optimizaciones de batería +* Establecer sólo local bandera en 'x cuentas conectadas' notificaciones +* Corrección de la interacción con Google Maps Share Location Plugin +* Eliminar nota a pie de página con respecto a la cuota del servidor +* Almacenar archivos en la ubicación adecuada para Android 11 +* Intento de reconectar llamada tras cambio de red +* Mostrar el JID de la persona que llama y el JID de la cuenta en la pantalla de llamada entrante diff --git a/fastlane/metadata/android/es-ES/changelogs/42038.txt b/fastlane/metadata/android/es-ES/changelogs/42038.txt new file mode 100644 index 000000000..0864d1d84 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42038.txt @@ -0,0 +1,2 @@ +* Corrección de errores menores +* Restaurar la capacidad de llamar a través de JMP y otros servicios (versión Playstore) diff --git a/fastlane/metadata/android/es-ES/changelogs/42041.txt b/fastlane/metadata/android/es-ES/changelogs/42041.txt new file mode 100644 index 000000000..03c4e761a --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42041.txt @@ -0,0 +1,5 @@ +* Implementación del perfil SASL extensible, Bind 2.0 y Fast para reconexiones más rápidas. +* Implementación de Channel Binding +* Añadir la posibilidad de cambiar de llamada de audio a videollamada +* Añadir la posibilidad de eliminar el propio avatar +* Notificación de llamadas perdidas diff --git a/fastlane/metadata/android/es-ES/changelogs/42042.txt b/fastlane/metadata/android/es-ES/changelogs/42042.txt new file mode 100644 index 000000000..288d6502a --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42042.txt @@ -0,0 +1,2 @@ +* Corrección del bucle de reenvío en servidores que sólo admiten sm:2 +* Mostrar "Cambiar a vídeo" sólo si la otra parte admite vídeo diff --git a/fastlane/metadata/android/es-ES/changelogs/42043.txt b/fastlane/metadata/android/es-ES/changelogs/42043.txt new file mode 100644 index 000000000..131ea2911 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42043.txt @@ -0,0 +1 @@ +* Corregida una regresión en la transferencia de archivos P2P diff --git a/fastlane/metadata/android/es-ES/changelogs/42044.txt b/fastlane/metadata/android/es-ES/changelogs/42044.txt new file mode 100644 index 000000000..1b98bb2aa --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42044.txt @@ -0,0 +1,3 @@ +* Corrección del reenvío de mensajes al utilizar SASL2 +* Corregir vídeo negro entre algunos dispositivos +* Arreglar fallo en contraseñas vacías diff --git a/fastlane/metadata/android/es-ES/changelogs/42046.txt b/fastlane/metadata/android/es-ES/changelogs/42046.txt new file mode 100644 index 000000000..f1576f520 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42046.txt @@ -0,0 +1 @@ +* Integrar el Distribuidor UnifiedPush para facilitar los mensajes push a otras aplicaciones habilitadas para UnifiedPush como Tusky y Fedilab diff --git a/fastlane/metadata/android/es-ES/changelogs/42047.txt b/fastlane/metadata/android/es-ES/changelogs/42047.txt new file mode 100644 index 000000000..8f3909b76 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42047.txt @@ -0,0 +1 @@ +* Corrección de fallos en el distribuidor de UnifiedPush diff --git a/fastlane/metadata/android/es-ES/changelogs/42050.txt b/fastlane/metadata/android/es-ES/changelogs/42050.txt new file mode 100644 index 000000000..fff8b62cb --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42050.txt @@ -0,0 +1 @@ +* Aumentar el radio de las esquinas en las fotos de perfil diff --git a/fastlane/metadata/android/es-ES/changelogs/42059.txt b/fastlane/metadata/android/es-ES/changelogs/42059.txt new file mode 100644 index 000000000..3a6ce0d00 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42059.txt @@ -0,0 +1,2 @@ +* Actualizar Target SDK a 33 de nuevo +* Corrección de problemas en servidores que soportan SASL2 sin Stream Management en línea diff --git a/fastlane/metadata/android/es-ES/changelogs/42060.txt b/fastlane/metadata/android/es-ES/changelogs/42060.txt new file mode 100644 index 000000000..624c64e92 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42060.txt @@ -0,0 +1 @@ +* Arreglar 'q' falsamente siendo reconocido como cirílico diff --git a/fastlane/metadata/android/es-ES/changelogs/42061.txt b/fastlane/metadata/android/es-ES/changelogs/42061.txt new file mode 100644 index 000000000..da7a92ab9 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42061.txt @@ -0,0 +1 @@ +* Eliminar la función de descubrimiento de canales de la versión de Google Play diff --git a/fastlane/metadata/android/es-ES/changelogs/42062.txt b/fastlane/metadata/android/es-ES/changelogs/42062.txt new file mode 100644 index 000000000..c1729884a --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42062.txt @@ -0,0 +1 @@ +* Desactivar la apertura de archivos de copia de seguridad (.ceb) desde el gestor de archivos diff --git a/fastlane/metadata/android/es-ES/changelogs/42065.txt b/fastlane/metadata/android/es-ES/changelogs/42065.txt new file mode 100644 index 000000000..eceb051e1 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42065.txt @@ -0,0 +1 @@ +* Introducir un nuevo formato de archivo de copia de seguridad diff --git a/fastlane/metadata/android/es-ES/changelogs/42068.txt b/fastlane/metadata/android/es-ES/changelogs/42068.txt new file mode 100644 index 000000000..b651cc178 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42068.txt @@ -0,0 +1,2 @@ +* soporte para los ajustes de la notificación de la conversación +* usar opus para mensajes de voz en Android 10 diff --git a/fastlane/metadata/android/es-ES/changelogs/42072.txt b/fastlane/metadata/android/es-ES/changelogs/42072.txt new file mode 100644 index 000000000..a4b7e971d --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/42072.txt @@ -0,0 +1,3 @@ +* Aumenta la dependencia de libwebrtc a M117 y aumenta la de libvpx. +* Volver a AAC para mensajes de voz +* Soporta ajustes de idioma por aplicación diff --git a/fastlane/metadata/android/es-ES/changelogs/4207704.txt b/fastlane/metadata/android/es-ES/changelogs/4207704.txt new file mode 100644 index 000000000..9396b343d --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/4207704.txt @@ -0,0 +1,3 @@ +* Soporta DNS Privado (DNS sobre TLS) +* Icono temático del lanzador +* Corrección de un problema de permisos poco frecuente al compartir archivos en Android 11+ diff --git a/fastlane/metadata/android/es-ES/changelogs/4208104.txt b/fastlane/metadata/android/es-ES/changelogs/4208104.txt new file mode 100644 index 000000000..dde4e4f05 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* Acceso más fácil a "Mostrar el código QR +* Soporte para marcadores nativos PEP +* Añadir soporte para SDP Oferta / Respuesta Modelo (Utilizado por pasarelas SIP) +* Aumento de la API de destino a Android 14 diff --git a/fastlane/metadata/android/es-ES/changelogs/4208804.txt b/fastlane/metadata/android/es-ES/changelogs/4208804.txt new file mode 100644 index 000000000..0a3ef27b1 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/4208804.txt @@ -0,0 +1,3 @@ +* Admite transferencia de archivos P2P a través del canal de datos WebRTC +* Solucionar problemas de interoperabilidad con Bind 2.0 en ejabberd +* Paquetes de certificado raíz Let's Encrypt para Android <= 7 diff --git a/fastlane/metadata/android/es-ES/changelogs/4209004.txt b/fastlane/metadata/android/es-ES/changelogs/4209004.txt new file mode 100644 index 000000000..700284fd2 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/4209004.txt @@ -0,0 +1,2 @@ +* Correcciones de errores menores +* ligeras modificaciones en el flujo interno de Quicksy diff --git a/fastlane/metadata/android/es-ES/changelogs/4209204.txt b/fastlane/metadata/android/es-ES/changelogs/4209204.txt new file mode 100644 index 000000000..097d42728 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/4209204.txt @@ -0,0 +1,2 @@ +* Facilitar el acceso a la "Política de privacidad" en la versión de Play Store (Quicksy y Conversations). +* Eliminar la integración de la libreta de direcciones en la versión Play Store de Conversations diff --git a/fastlane/metadata/android/gl-ES/changelogs/349.txt b/fastlane/metadata/android/gl-ES/changelogs/349.txt new file mode 100644 index 000000000..ce9204ef3 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/349.txt @@ -0,0 +1,4 @@ +* Introdución do axuste de experta para realizar o descubrimento de canle no servidor local e non buscar en search.jabber.network +* Activadas as marcas de comprobación de entrega por defecto e eliminación do axuste +* Activar por defecto 'O botón enviar indica estado' e eliminar o axuste +* Mover os axustes Copia de Apoio e Servizo en primeiro plano á pantalla principal diff --git a/fastlane/metadata/android/gl-ES/changelogs/351.txt b/fastlane/metadata/android/gl-ES/changelogs/351.txt new file mode 100644 index 000000000..8fabff2f1 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/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/gl-ES/changelogs/353.txt b/fastlane/metadata/android/gl-ES/changelogs/353.txt new file mode 100644 index 000000000..388f9ae91 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/353.txt @@ -0,0 +1,4 @@ +* permitir que as usuarias elixan o seu propio alcume +* retomar a descarga de ficheiros cifrados con OMEMO +* agora as Canles usan '#' como símbolo no avatar +* Quicksy establece 'sempre' para a cifraxe OMEMO por defecto (agocha a icona do cadeado) diff --git a/fastlane/metadata/android/gl-ES/changelogs/360.txt b/fastlane/metadata/android/gl-ES/changelogs/360.txt new file mode 100644 index 000000000..f31c6d51c --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/360.txt @@ -0,0 +1 @@ +* Soporte para os parámetros ?register e ?register;preauth da uri XMPP diff --git a/fastlane/metadata/android/gl-ES/changelogs/362.txt b/fastlane/metadata/android/gl-ES/changelogs/362.txt new file mode 100644 index 000000000..e357ba94c --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/362.txt @@ -0,0 +1 @@ +* Soporte para o cambio automático de decorado en Android 10 diff --git a/fastlane/metadata/android/gl-ES/changelogs/364.txt b/fastlane/metadata/android/gl-ES/changelogs/364.txt new file mode 100644 index 000000000..338355cbf --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/364.txt @@ -0,0 +1,2 @@ +* Proporciona vista previa dos PDF en Android 5+ +* Usa 12 byte IVs con OMEMO diff --git a/fastlane/metadata/android/gl-ES/changelogs/367.txt b/fastlane/metadata/android/gl-ES/changelogs/367.txt new file mode 100644 index 000000000..9b96da762 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/367.txt @@ -0,0 +1,2 @@ +* Arranxo da selección do avatar en dispositivos Android 10 +* Arranxo da transferencia de ficheiros moi grandes diff --git a/fastlane/metadata/android/gl-ES/changelogs/379.txt b/fastlane/metadata/android/gl-ES/changelogs/379.txt new file mode 100644 index 000000000..495c4da0c --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/379.txt @@ -0,0 +1 @@ +* Chamadas de Audio/Video (Require soporte no servidor para que os servidores STUN e TURN sexan accesibles vía XEP-0215) diff --git a/fastlane/metadata/android/gl-ES/changelogs/381.txt b/fastlane/metadata/android/gl-ES/changelogs/381.txt new file mode 100644 index 000000000..a2df5e828 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/381.txt @@ -0,0 +1,2 @@ +* Audible feedback (dialing, call started, call ended) for voice calls. +* Fixed issue with retrying failed video call diff --git a/fastlane/metadata/android/gl-ES/changelogs/382.txt b/fastlane/metadata/android/gl-ES/changelogs/382.txt new file mode 100644 index 000000000..64e23e14d --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/382.txt @@ -0,0 +1,2 @@ +* Add button to switch camera during video call +* Fixed voice calls on tablets diff --git a/fastlane/metadata/android/gl-ES/changelogs/383.txt b/fastlane/metadata/android/gl-ES/changelogs/383.txt new file mode 100644 index 000000000..19c9a0116 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/383.txt @@ -0,0 +1,3 @@ +* Move call icon to the left in order to keep other toolbar icons in a consistent place +* Show call duration during audio calls +* Tie breaking for A/V calls (the same two people calling each other at the same time) diff --git a/fastlane/metadata/android/gl-ES/changelogs/387.txt b/fastlane/metadata/android/gl-ES/changelogs/387.txt new file mode 100644 index 000000000..2710be0a5 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/387.txt @@ -0,0 +1,2 @@ +* Rework Login with certificate UI +* Add ability to pin chats on top (add to favorites) diff --git a/fastlane/metadata/android/gl-ES/changelogs/388.txt b/fastlane/metadata/android/gl-ES/changelogs/388.txt new file mode 100644 index 000000000..6a4909652 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/388.txt @@ -0,0 +1,3 @@ +* Reduce echo during calls on some devices +* Fix login when passwords contains special characters +* Play dial and busy tones on speaker during video calls diff --git a/fastlane/metadata/android/gl-ES/changelogs/390.txt b/fastlane/metadata/android/gl-ES/changelogs/390.txt new file mode 100644 index 000000000..56ed78885 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/390.txt @@ -0,0 +1 @@ +* Offer to record voice message when callee is busy diff --git a/fastlane/metadata/android/gl-ES/changelogs/393.txt b/fastlane/metadata/android/gl-ES/changelogs/393.txt new file mode 100644 index 000000000..82250ee87 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/393.txt @@ -0,0 +1,3 @@ +* Show help button if A/V call fails +* Fixed some annoying crashes +* Fixed Jingle connections (file transfer + calls) with bare JIDs diff --git a/fastlane/metadata/android/gl-ES/changelogs/394.txt b/fastlane/metadata/android/gl-ES/changelogs/394.txt new file mode 100644 index 000000000..b04adbd56 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/394.txt @@ -0,0 +1,2 @@ +* Fixed notifications not showing up under certain conditions +* Fixed compatibility issues and crashes related to A/V calls diff --git a/fastlane/metadata/android/gl-ES/changelogs/395.txt b/fastlane/metadata/android/gl-ES/changelogs/395.txt new file mode 100644 index 000000000..76a654338 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/395.txt @@ -0,0 +1,3 @@ +* add 'Return to chat' to audio call screen +* Improve keyboard shortcuts +* bug fixes diff --git a/fastlane/metadata/android/gl-ES/changelogs/397.txt b/fastlane/metadata/android/gl-ES/changelogs/397.txt new file mode 100644 index 000000000..207b36708 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/397.txt @@ -0,0 +1,3 @@ +* Handle GPX files +* Improve performance for backup restore +* bug fixes diff --git a/fastlane/metadata/android/gl-ES/changelogs/398.txt b/fastlane/metadata/android/gl-ES/changelogs/398.txt new file mode 100644 index 000000000..95280ea88 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/398.txt @@ -0,0 +1,4 @@ +* Search individual conversations +* Notify user if message delivery fails +* Remember display names (nicks) from Quicksy users across restarts +* Add button to start Orbot (Tor) from notification if necessary diff --git a/fastlane/metadata/android/gl-ES/changelogs/401.txt b/fastlane/metadata/android/gl-ES/changelogs/401.txt new file mode 100644 index 000000000..907063eb6 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/401.txt @@ -0,0 +1,2 @@ +* fixed search on Android <= 5 +* optimize memory consumption diff --git a/fastlane/metadata/android/gl-ES/changelogs/402.txt b/fastlane/metadata/android/gl-ES/changelogs/402.txt new file mode 100644 index 000000000..53f461756 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/402.txt @@ -0,0 +1,3 @@ +* Offer Easy Invite generation on supporting servers +* Display GIFs send from Movim +* store avatars in cache diff --git a/fastlane/metadata/android/gl-ES/changelogs/403.txt b/fastlane/metadata/android/gl-ES/changelogs/403.txt new file mode 100644 index 000000000..99d62ca48 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/403.txt @@ -0,0 +1,3 @@ +* Fixed connectivity issues when different accounts used different SCRAM mechanisms +* Add support for SCRAM-SHA-512 +* Allow P2P (Jingle) file transfer with self contact diff --git a/fastlane/metadata/android/gl-ES/changelogs/404.txt b/fastlane/metadata/android/gl-ES/changelogs/404.txt new file mode 100644 index 000000000..d4f2e7b6d --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/404.txt @@ -0,0 +1 @@ +* minor stability improvements for A/V calls diff --git a/fastlane/metadata/android/gl-ES/changelogs/405.txt b/fastlane/metadata/android/gl-ES/changelogs/405.txt new file mode 100644 index 000000000..e858b6cd1 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/405.txt @@ -0,0 +1 @@ +* Quicksy: Automatically receive verification SMS diff --git a/fastlane/metadata/android/gl-ES/changelogs/407.txt b/fastlane/metadata/android/gl-ES/changelogs/407.txt new file mode 100644 index 000000000..e746bc7d7 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/407.txt @@ -0,0 +1,3 @@ +* Show call button for offline contacts if they previously announced support +* Back button no longer ends call when call is connected +* bug fixes diff --git a/fastlane/metadata/android/gl-ES/changelogs/42000.txt b/fastlane/metadata/android/gl-ES/changelogs/42000.txt new file mode 100644 index 000000000..1ecfe204d --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42000.txt @@ -0,0 +1,4 @@ +* Ability to select incoming call ringtone +* Fix OpenPGP key id discovery for OpenKeychain 5.6+ +* Properly verify punycode TLS certificates +* Improve stability of RTP session establishment (calling) diff --git a/fastlane/metadata/android/gl-ES/changelogs/42006.txt b/fastlane/metadata/android/gl-ES/changelogs/42006.txt new file mode 100644 index 000000000..91e2b904f --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42006.txt @@ -0,0 +1,2 @@ +* Verify A/V calls with preexisting OMEMO sessions +* Improve compatibility with non libwebrtc WebRTC implementations diff --git a/fastlane/metadata/android/gl-ES/changelogs/42010.txt b/fastlane/metadata/android/gl-ES/changelogs/42010.txt new file mode 100644 index 000000000..3a1c234c1 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42010.txt @@ -0,0 +1,2 @@ +* Various bug fixes around Tor support +* Improve call compatibility with Dino diff --git a/fastlane/metadata/android/gl-ES/changelogs/42012.txt b/fastlane/metadata/android/gl-ES/changelogs/42012.txt new file mode 100644 index 000000000..967fae964 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42012.txt @@ -0,0 +1 @@ +* fix HTTP up/download for users that don’t trust system CAs diff --git a/fastlane/metadata/android/gl-ES/changelogs/42013.txt b/fastlane/metadata/android/gl-ES/changelogs/42013.txt new file mode 100644 index 000000000..8749f0a0f --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42013.txt @@ -0,0 +1 @@ +* Fixed 'No Connectivity' issues on Android 7.1 diff --git a/fastlane/metadata/android/gl-ES/changelogs/42014.txt b/fastlane/metadata/android/gl-ES/changelogs/42014.txt new file mode 100644 index 000000000..8ae96511e --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42014.txt @@ -0,0 +1,2 @@ +* Always verify domain name. No user overwrite +* Support roster pre authentication diff --git a/fastlane/metadata/android/gl-ES/changelogs/42015.txt b/fastlane/metadata/android/gl-ES/changelogs/42015.txt new file mode 100644 index 000000000..1980efb2a --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42015.txt @@ -0,0 +1 @@ +* minor A/V improvements diff --git a/fastlane/metadata/android/gl-ES/changelogs/42018.txt b/fastlane/metadata/android/gl-ES/changelogs/42018.txt new file mode 100644 index 000000000..8f4d66caa --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42018.txt @@ -0,0 +1,3 @@ +* Show black bars when remote video does not match aspect ratio of screen +* Improve search performance +* Add setting to prevent screenshots diff --git a/fastlane/metadata/android/gl-ES/changelogs/42022.txt b/fastlane/metadata/android/gl-ES/changelogs/42022.txt new file mode 100644 index 000000000..eaaa190fa --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42022.txt @@ -0,0 +1,2 @@ +* Fix issue with some videos not being compressed +* Fix rare crash when opening notification diff --git a/fastlane/metadata/android/gl-ES/changelogs/42023.txt b/fastlane/metadata/android/gl-ES/changelogs/42023.txt new file mode 100644 index 000000000..ed3c25380 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42023.txt @@ -0,0 +1,2 @@ +* Fix crash when rendering some quotes +* Fix crash in welcome screen diff --git a/fastlane/metadata/android/gl-ES/changelogs/42037.txt b/fastlane/metadata/android/gl-ES/changelogs/42037.txt new file mode 100644 index 000000000..375905aa8 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42037.txt @@ -0,0 +1,11 @@ +Version 2.10.9 +* Ask for Bluetooth permissions when making A/V calls (You can reject this if you don’t use Bluetooth headsets) +* Fix bug when calling Movim +* Fix wrong avatar being shown for group chats +* Always ask for battery optimizations opt-out +* Set local only flag on 'x connected accounts' notifications +* Fix interaction with Google Maps Share Location Plugin +* Remove footnote with regards to server fee +* Store files in location appropriate for Android 11 +* Attempt to reconnect call after network switch +* Show caller JID and account JID in incoming call screen diff --git a/fastlane/metadata/android/gl-ES/changelogs/42038.txt b/fastlane/metadata/android/gl-ES/changelogs/42038.txt new file mode 100644 index 000000000..71373032f --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42038.txt @@ -0,0 +1,2 @@ +* Arranxos menores +* Restablecida a posibilidade de chamar vía JMP e outros servizos (versión Playstore) diff --git a/fastlane/metadata/android/gl-ES/changelogs/42041.txt b/fastlane/metadata/android/gl-ES/changelogs/42041.txt new file mode 100644 index 000000000..37079b4f7 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42041.txt @@ -0,0 +1,5 @@ +* Implementamos Extensible SASL Profile, Bind 2.0 e Fast para reconectar máis rápidamente +* Implementamos Channel Binding +* Engadimos a posibilidade de pasar de chamada de audio a chamada de vídeo +* Podes eliminar o teu propio avatar +* Engadida notificación de chamada perdida diff --git a/fastlane/metadata/android/gl-ES/changelogs/42042.txt b/fastlane/metadata/android/gl-ES/changelogs/42042.txt new file mode 100644 index 000000000..520578662 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42042.txt @@ -0,0 +1,2 @@ +* Arranxo do reenvío contínuo en servidores que só teñen soporte sm:2 +* Mostrar 'Cambiar a vídeo' só se a outra parte tamén soporta chamada de vídeo diff --git a/fastlane/metadata/android/gl-ES/changelogs/42043.txt b/fastlane/metadata/android/gl-ES/changelogs/42043.txt new file mode 100644 index 000000000..91937ba45 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42043.txt @@ -0,0 +1 @@ +* Arranxo da regresión na transferencia de ficheiros con P2P diff --git a/fastlane/metadata/android/gl-ES/changelogs/42044.txt b/fastlane/metadata/android/gl-ES/changelogs/42044.txt new file mode 100644 index 000000000..b6df29794 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42044.txt @@ -0,0 +1,3 @@ +* Arranxo do reenvío usando SASL2 +* Arranxo dos vídeos en negro nalgúns dispositivos +* Arranxo do fallo ao usar un contrasinal baleiro diff --git a/fastlane/metadata/android/gl-ES/changelogs/42046.txt b/fastlane/metadata/android/gl-ES/changelogs/42046.txt new file mode 100644 index 000000000..ec1c1ca76 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42046.txt @@ -0,0 +1 @@ +* Integrar UnifiedPush Distributor para facilitar a entrega de mensaxes push a outras apps con UnifiedPush activado como Tusky e Fedilab diff --git a/fastlane/metadata/android/gl-ES/changelogs/42047.txt b/fastlane/metadata/android/gl-ES/changelogs/42047.txt new file mode 100644 index 000000000..359f0e958 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42047.txt @@ -0,0 +1 @@ +* Arranxar o fallo en UnifiedPush Distributor diff --git a/fastlane/metadata/android/gl-ES/changelogs/42050.txt b/fastlane/metadata/android/gl-ES/changelogs/42050.txt new file mode 100644 index 000000000..40a774b21 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42050.txt @@ -0,0 +1 @@ +* Aumentar o radio dos cantos nas imaxes de perfil diff --git a/fastlane/metadata/android/gl-ES/changelogs/42059.txt b/fastlane/metadata/android/gl-ES/changelogs/42059.txt new file mode 100644 index 000000000..44356c7e7 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42059.txt @@ -0,0 +1,2 @@ +* Establecer o Target SDK ao 33 de novo +* Arranxar problemas cos servidores con soporte SASL2 sen Stream Management en liña diff --git a/fastlane/metadata/android/gl-ES/changelogs/42060.txt b/fastlane/metadata/android/gl-ES/changelogs/42060.txt new file mode 100644 index 000000000..b79b6c0a7 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42060.txt @@ -0,0 +1 @@ +* Arranxa o problema de considerar o 'q' como cirílico diff --git a/fastlane/metadata/android/gl-ES/changelogs/42061.txt b/fastlane/metadata/android/gl-ES/changelogs/42061.txt new file mode 100644 index 000000000..3173f43f4 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42061.txt @@ -0,0 +1 @@ +* Retira, da versión Google Play, a ferramenta de descubrimento de canles diff --git a/fastlane/metadata/android/gl-ES/changelogs/42062.txt b/fastlane/metadata/android/gl-ES/changelogs/42062.txt new file mode 100644 index 000000000..4d9358f7d --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42062.txt @@ -0,0 +1 @@ +* Desactiva a apertura de ficheiros de copia de apoio (.ceb) desde o xestor de ficheiros diff --git a/fastlane/metadata/android/gl-ES/changelogs/42065.txt b/fastlane/metadata/android/gl-ES/changelogs/42065.txt new file mode 100644 index 000000000..ce952a317 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42065.txt @@ -0,0 +1 @@ +* Presenta o novo formato para as copias de apoio diff --git a/fastlane/metadata/android/gl-ES/changelogs/42068.txt b/fastlane/metadata/android/gl-ES/changelogs/42068.txt new file mode 100644 index 000000000..49d898d51 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42068.txt @@ -0,0 +1,2 @@ +* soporte para os axustes das notificacións por conversa +* usar opus para as mensaxes de voz en Android 10 diff --git a/fastlane/metadata/android/gl-ES/changelogs/42072.txt b/fastlane/metadata/android/gl-ES/changelogs/42072.txt new file mode 100644 index 000000000..534c20ed1 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42072.txt @@ -0,0 +1,3 @@ +*Subir a dependencia libwebrtc a M117 e tamén libvpx +* Volver a AAC para as mensaxes de voz +* Soporte para indicar na app os axustes do idioma diff --git a/fastlane/metadata/android/gl-ES/changelogs/42074.txt b/fastlane/metadata/android/gl-ES/changelogs/42074.txt new file mode 100644 index 000000000..6729e251d --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/42074.txt @@ -0,0 +1,3 @@ +* Soport para DNS Privado (DNS sobre TLS) +* Soporte para personalizar a icona de inicio +* Arranxamos un raro problema de permisos ao compartir ficheiros en Android 11+ diff --git a/fastlane/metadata/android/gl-ES/changelogs/4207704.txt b/fastlane/metadata/android/gl-ES/changelogs/4207704.txt new file mode 100644 index 000000000..f09552438 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/4207704.txt @@ -0,0 +1,3 @@ +* Soporte para Private DNS (DNS sobre TLS) +* Soporte para decorar a icona no iniciador +* Arranxo dun problema pouco común de permisos ao compartir ficheiros en Android 11+ diff --git a/fastlane/metadata/android/gl-ES/changelogs/4208104.txt b/fastlane/metadata/android/gl-ES/changelogs/4208104.txt new file mode 100644 index 000000000..ff912c254 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* Acceso mais rápido a 'Mostrar código QR' +* Soporte para PEP Marcadores Nativos +* Engadido soporte para SDP Offer / Answer Model (usado por pasarelas SIP) +* Establecida a API de Android 14 como obxectivo diff --git a/fastlane/metadata/android/gl-ES/changelogs/4208804.txt b/fastlane/metadata/android/gl-ES/changelogs/4208804.txt new file mode 100644 index 000000000..03aa6966c --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/4208804.txt @@ -0,0 +1,3 @@ +* Soporte para a transferencia de ficheiros P2P a través de canles de datos WebRTC +* Arranxo dos problemas de interoperabilidade con Bind 2.0 en ejabberd +* Paquete de certificados raiz Let's Encrypt para Android <=7 diff --git a/fastlane/metadata/android/gl-ES/changelogs/4209004.txt b/fastlane/metadata/android/gl-ES/changelogs/4209004.txt new file mode 100644 index 000000000..d1f8b3e26 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/4209004.txt @@ -0,0 +1,2 @@ +* arranxos menores +* pequenos cambios no primeiro incio de Quicksy diff --git a/fastlane/metadata/android/gl-ES/changelogs/4209204.txt b/fastlane/metadata/android/gl-ES/changelogs/4209204.txt new file mode 100644 index 000000000..4b7430123 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/4209204.txt @@ -0,0 +1,2 @@ +* Acceso máis doado á 'Política de Privacidade' na versión da Play Store (Quicksy e Conversations) +* Retirada a integración coa libreta de enderezo na versión de Conversations da Play Store diff --git a/fastlane/metadata/android/it-IT/changelogs/349.txt b/fastlane/metadata/android/it-IT/changelogs/349.txt new file mode 100644 index 000000000..170eda951 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/349.txt @@ -0,0 +1,4 @@ +* Introdotta l'impostazione per esperti per eseguire la ricerca dei canali sul server locale invece che su search.jabber.network +* Attivati i segni di spunta per la consegna in modo predefinito e rimossa l'impostazione +* Attivato "Il pulsante di invio indica lo stato" in modo predefinito e rimossa l'impostazione +* Spostate le impostazioni del servizio di backup e di primo piano nella schermata principale diff --git a/fastlane/metadata/android/it-IT/changelogs/351.txt b/fastlane/metadata/android/it-IT/changelogs/351.txt new file mode 100644 index 000000000..3c3b94459 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/351.txt @@ -0,0 +1,3 @@ +* Correzioni per il trasferimento di file Jingle IBB +* Risolte le correzioni ripetute che riempivano il database +* Transizione a Last Message Correction v1.1 diff --git a/fastlane/metadata/android/it-IT/changelogs/353.txt b/fastlane/metadata/android/it-IT/changelogs/353.txt new file mode 100644 index 000000000..dc3a5160d --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/353.txt @@ -0,0 +1,4 @@ +* Consente agli utenti di impostare il proprio nick name +* Riprende il download dei file criptati OMEMO +* I canali ora usano '#' come simbolo nell'avatar. +* Quicksy imposta "sempre" come crittografia OMEMO in modo predefinito (nasconde l'icona del lucchetto) diff --git a/fastlane/metadata/android/it-IT/changelogs/362.txt b/fastlane/metadata/android/it-IT/changelogs/362.txt new file mode 100644 index 000000000..c276d02c2 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/362.txt @@ -0,0 +1 @@ +* Supporto del cambio automatico dei temi su Android 10 diff --git a/fastlane/metadata/android/it-IT/changelogs/364.txt b/fastlane/metadata/android/it-IT/changelogs/364.txt new file mode 100644 index 000000000..7b461a350 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/364.txt @@ -0,0 +1,2 @@ +* Fornisce l'anteprima PDF su Android 5+ +* Utilizzo di IVs a 12 byte per OMEMO diff --git a/fastlane/metadata/android/it-IT/changelogs/367.txt b/fastlane/metadata/android/it-IT/changelogs/367.txt new file mode 100644 index 000000000..89239a58f --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/367.txt @@ -0,0 +1,2 @@ +* Corretta la selezione dell'avatar su alcuni dispositivi Android 10 +* Corretto il trasferimento di file più grandi diff --git a/fastlane/metadata/android/it-IT/changelogs/379.txt b/fastlane/metadata/android/it-IT/changelogs/379.txt new file mode 100644 index 000000000..fe27f8ec6 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/379.txt @@ -0,0 +1 @@ +* Chiamate audio/video (richiede il supporto di server sotto forma di server STUN e TURN rilevabili tramite XEP-0215) diff --git a/fastlane/metadata/android/it-IT/changelogs/381.txt b/fastlane/metadata/android/it-IT/changelogs/381.txt new file mode 100644 index 000000000..39bd79c54 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/381.txt @@ -0,0 +1,2 @@ +* Feedback acustico (composizione, inizio e fine chiamata) per le chiamate vocali. +* Risolto un problema con la ripetizione di una videochiamata fallita diff --git a/fastlane/metadata/android/it-IT/changelogs/382.txt b/fastlane/metadata/android/it-IT/changelogs/382.txt new file mode 100644 index 000000000..8a8bd6863 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/382.txt @@ -0,0 +1,2 @@ +* Aggiunto un pulsante per cambiare telecamera durante la videochiamata +* Corrette le chiamate vocali sui tablet diff --git a/fastlane/metadata/android/it-IT/changelogs/388.txt b/fastlane/metadata/android/it-IT/changelogs/388.txt new file mode 100644 index 000000000..20b0c18fa --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/388.txt @@ -0,0 +1,3 @@ +* Riduzione dell'eco durante le chiamate su alcuni dispositivi +* Corretto l'accesso quando le password contengono caratteri speciali +* Riproduzione dei toni di chiamata e di occupato sull'altoparlante durante le videochiamate diff --git a/fastlane/metadata/android/it-IT/changelogs/390.txt b/fastlane/metadata/android/it-IT/changelogs/390.txt new file mode 100644 index 000000000..35b00a97d --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/390.txt @@ -0,0 +1 @@ +* Offerta di registrazione del messaggio vocale quando il chiamante è occupato diff --git a/fastlane/metadata/android/it-IT/changelogs/393.txt b/fastlane/metadata/android/it-IT/changelogs/393.txt new file mode 100644 index 000000000..3238e8910 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/393.txt @@ -0,0 +1,3 @@ +* Mostra il pulsante di aiuto se la chiamata A/V fallisce +* Risolti alcuni fastidiosi arresti anomali +* Corrette le connessioni Jingle (trasferimento di file + chiamate) con JID nudi diff --git a/fastlane/metadata/android/it-IT/changelogs/394.txt b/fastlane/metadata/android/it-IT/changelogs/394.txt new file mode 100644 index 000000000..d94866c6d --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/394.txt @@ -0,0 +1,2 @@ +* Corrette le notifiche che non vengono visualizzate in determinate condizioni +* Corretti i problemi di compatibilità e gli arresti anomali relativi alle chiamate A/V diff --git a/fastlane/metadata/android/it-IT/changelogs/395.txt b/fastlane/metadata/android/it-IT/changelogs/395.txt new file mode 100644 index 000000000..264021fd3 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/395.txt @@ -0,0 +1,3 @@ +* Aggiunta la funzione "Torna alla chat" alla schermata delle chiamate audio +* Migliorate le scorciatoie da tastiera +* Correzioni di errori diff --git a/fastlane/metadata/android/it-IT/changelogs/397.txt b/fastlane/metadata/android/it-IT/changelogs/397.txt new file mode 100644 index 000000000..295266e01 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/397.txt @@ -0,0 +1,3 @@ +* Gestisce i file GPX +* Migliorate le prestazioni per il ripristino dei backup +* Correzioni di errori diff --git a/fastlane/metadata/android/it-IT/changelogs/403.txt b/fastlane/metadata/android/it-IT/changelogs/403.txt new file mode 100644 index 000000000..c0ee371d8 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/403.txt @@ -0,0 +1,3 @@ +* Corretti i problemi di connettività quando profili diversi usavano meccanismi SCRAM diversi +* Aggiunto il supporto per SCRAM-SHA-512 +* Consente il trasferimento di file P2P (Jingle) con l'auto contatto diff --git a/fastlane/metadata/android/it-IT/changelogs/404.txt b/fastlane/metadata/android/it-IT/changelogs/404.txt new file mode 100644 index 000000000..6346ddb12 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/404.txt @@ -0,0 +1 @@ +* Miglioramenti di stabilità minori per le chiamate A/V diff --git a/fastlane/metadata/android/it-IT/changelogs/405.txt b/fastlane/metadata/android/it-IT/changelogs/405.txt new file mode 100644 index 000000000..7325ea2a9 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/405.txt @@ -0,0 +1 @@ +* Quicksy: ricevi automaticamente SMS di verifica diff --git a/fastlane/metadata/android/it-IT/changelogs/407.txt b/fastlane/metadata/android/it-IT/changelogs/407.txt new file mode 100644 index 000000000..2ba4dd4fd --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/407.txt @@ -0,0 +1,3 @@ +* Mostra il pulsante di chiamata per i contatti offline se hanno precedentemente annunciato il supporto +* Il pulsante Indietro non termina più la chiamata quando questa è connessa +* Correzioni di errori diff --git a/fastlane/metadata/android/it-IT/changelogs/42000.txt b/fastlane/metadata/android/it-IT/changelogs/42000.txt new file mode 100644 index 000000000..5697c2ed2 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42000.txt @@ -0,0 +1,4 @@ +* Possibilità di selezionare la suoneria delle chiamate in arrivo +* Correzione del rilevamento dell'id della chiave OpenPGP per OpenKeychain 5.6+ +* Verifica corretta dei certificati TLS con codice punycode +* Miglioramento della stabilità della creazione di sessioni RTP (chiamate) diff --git a/fastlane/metadata/android/it-IT/changelogs/42006.txt b/fastlane/metadata/android/it-IT/changelogs/42006.txt new file mode 100644 index 000000000..dda32ef6a --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42006.txt @@ -0,0 +1,2 @@ +* Verifica delle chiamate A/V con le sessioni OMEMO preesistenti +* Miglioramento della compatibilità con le implementazioni WebRTC non libwebrtc diff --git a/fastlane/metadata/android/it-IT/changelogs/42010.txt b/fastlane/metadata/android/it-IT/changelogs/42010.txt new file mode 100644 index 000000000..2140a3a44 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42010.txt @@ -0,0 +1,2 @@ +* Correzione di vari errori relativi al supporto di Tor +* Migliorata la compatibilità delle chiamate con Dino diff --git a/fastlane/metadata/android/it-IT/changelogs/42012.txt b/fastlane/metadata/android/it-IT/changelogs/42012.txt new file mode 100644 index 000000000..9da7d48df --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42012.txt @@ -0,0 +1 @@ +* Corretto l'up/download HTTP per gli utenti che non si fidano delle CA di sistema diff --git a/fastlane/metadata/android/it-IT/changelogs/42013.txt b/fastlane/metadata/android/it-IT/changelogs/42013.txt new file mode 100644 index 000000000..f58811956 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42013.txt @@ -0,0 +1 @@ +* Risolti i problemi di "assenza di connettività" su Android 7.1 diff --git a/fastlane/metadata/android/it-IT/changelogs/42014.txt b/fastlane/metadata/android/it-IT/changelogs/42014.txt new file mode 100644 index 000000000..95c2355ff --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42014.txt @@ -0,0 +1,2 @@ +* Verifica sempre il nome del dominio. Nessuna sovrascrittura dell'utente +* Supporto della pre-autenticazione del roster diff --git a/fastlane/metadata/android/it-IT/changelogs/42015.txt b/fastlane/metadata/android/it-IT/changelogs/42015.txt new file mode 100644 index 000000000..b296f82ca --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42015.txt @@ -0,0 +1 @@ +* Miglioramenti A/V minori diff --git a/fastlane/metadata/android/it-IT/changelogs/42018.txt b/fastlane/metadata/android/it-IT/changelogs/42018.txt new file mode 100644 index 000000000..a6eac7a8f --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42018.txt @@ -0,0 +1,3 @@ +* Mostra barre nere quando il video remoto non corrisponde alle proporzioni dello schermo +* Migliorate le prestazioni della ricerca +* Aggiunta un'impostazione per impedire gli screenshot diff --git a/fastlane/metadata/android/it-IT/changelogs/42022.txt b/fastlane/metadata/android/it-IT/changelogs/42022.txt new file mode 100644 index 000000000..1ff223875 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42022.txt @@ -0,0 +1,2 @@ +* Corretto il problema di alcuni video che non vengono compressi +* Corretti rari arresti anomali all'apertura delle notifiche diff --git a/fastlane/metadata/android/it-IT/changelogs/42047.txt b/fastlane/metadata/android/it-IT/changelogs/42047.txt index f0601e6c0..abc0c9e6c 100644 --- a/fastlane/metadata/android/it-IT/changelogs/42047.txt +++ b/fastlane/metadata/android/it-IT/changelogs/42047.txt @@ -1 +1 @@ -* Correzione dell'arresto anomalo del distributore UnifiedPush +* Corretto l'arresto anomalo del distributore UnifiedPush diff --git a/fastlane/metadata/android/it-IT/changelogs/42050.txt b/fastlane/metadata/android/it-IT/changelogs/42050.txt new file mode 100644 index 000000000..071ad6d75 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42050.txt @@ -0,0 +1 @@ +* Aumenta il raggio degli angoli nelle immagini del profilo diff --git a/fastlane/metadata/android/it-IT/changelogs/42059.txt b/fastlane/metadata/android/it-IT/changelogs/42059.txt new file mode 100644 index 000000000..405597cd0 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42059.txt @@ -0,0 +1,2 @@ +* Aggiornato l'SDK di destinazione di nuovo alla versione 33 +* Corretti problemi sui server che supportano SASL2 senza gestione inline dei flussi diff --git a/fastlane/metadata/android/it-IT/changelogs/42060.txt b/fastlane/metadata/android/it-IT/changelogs/42060.txt new file mode 100644 index 000000000..14a8a6a1f --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42060.txt @@ -0,0 +1 @@ +* Corretta la 'q' che viene erroneamente riconosciuta come cirillico diff --git a/fastlane/metadata/android/it-IT/changelogs/42061.txt b/fastlane/metadata/android/it-IT/changelogs/42061.txt new file mode 100644 index 000000000..00af956c5 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42061.txt @@ -0,0 +1 @@ +* Rimossa la funzione di scoperta dei canali dalla versione di Google Play diff --git a/fastlane/metadata/android/it-IT/changelogs/42062.txt b/fastlane/metadata/android/it-IT/changelogs/42062.txt new file mode 100644 index 000000000..c2d024121 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42062.txt @@ -0,0 +1 @@ +* Disattiva l'apertura dei file di backup (.ceb) dal file manager diff --git a/fastlane/metadata/android/it-IT/changelogs/42065.txt b/fastlane/metadata/android/it-IT/changelogs/42065.txt new file mode 100644 index 000000000..ae3fca640 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42065.txt @@ -0,0 +1 @@ +* Introdotto un nuovo formato di file di backup diff --git a/fastlane/metadata/android/it-IT/changelogs/42068.txt b/fastlane/metadata/android/it-IT/changelogs/42068.txt new file mode 100644 index 000000000..801dbb015 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42068.txt @@ -0,0 +1,2 @@ +* supporta impostazioni di notifica per singola conversazione +* usa opus per i messaggi vocali su Android 10 diff --git a/fastlane/metadata/android/it-IT/changelogs/42072.txt b/fastlane/metadata/android/it-IT/changelogs/42072.txt new file mode 100644 index 000000000..c497edc32 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42072.txt @@ -0,0 +1,3 @@ +* Aggiornata la dipendenza libwebrtc a M117 e libvpx +* Ritorno a AAC per i messaggi vocali +* Supporta impostazioni di lingua per app diff --git a/fastlane/metadata/android/it-IT/changelogs/42074.txt b/fastlane/metadata/android/it-IT/changelogs/42074.txt new file mode 100644 index 000000000..675715118 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/42074.txt @@ -0,0 +1,3 @@ +* Supporto per DNS Privato (DNS over TLS) +* Supporto per icona del launcher a tema +* Risolto un raro problema di autorizzazione durante la condivisione di file su Android 11+ diff --git a/fastlane/metadata/android/it-IT/changelogs/4207704.txt b/fastlane/metadata/android/it-IT/changelogs/4207704.txt new file mode 100644 index 000000000..675715118 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/4207704.txt @@ -0,0 +1,3 @@ +* Supporto per DNS Privato (DNS over TLS) +* Supporto per icona del launcher a tema +* Risolto un raro problema di autorizzazione durante la condivisione di file su Android 11+ diff --git a/fastlane/metadata/android/it-IT/changelogs/4208104.txt b/fastlane/metadata/android/it-IT/changelogs/4208104.txt new file mode 100644 index 000000000..00be75ede --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* Accesso più facile a 'Mostra codice QR' +* Supporto per PEP Native Bookmarks +* Aggiunto supporto per il modello Offerta / Risposta SDP (usato dai gateway SIP) +* Aumentata l'API di destinazione ad Android 14 diff --git a/fastlane/metadata/android/it-IT/changelogs/4208804.txt b/fastlane/metadata/android/it-IT/changelogs/4208804.txt new file mode 100644 index 000000000..7139e8caa --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/4208804.txt @@ -0,0 +1,3 @@ +* Supporto per trasferimenti P2P di file via canali di dati WebRTC +* Corretti problemi di interoperabilità con Bind 2.0 su ejabberd +* Integra certificati root di Let’s Encrypt su Android <= 7 diff --git a/fastlane/metadata/android/it-IT/changelogs/4209004.txt b/fastlane/metadata/android/it-IT/changelogs/4209004.txt new file mode 100644 index 000000000..6186e8210 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/4209004.txt @@ -0,0 +1,2 @@ +* correzioni minori +* piccole modifiche nel flusso di configurazione di Quicksy diff --git a/fastlane/metadata/android/ro/changelogs/349.txt b/fastlane/metadata/android/ro/changelogs/349.txt new file mode 100644 index 000000000..0f1ea5501 --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/349.txt @@ -0,0 +1,4 @@ +* Introducerea setărilor pentru experți pentru a efectua descoperirea canalelor pe serverul local în loc de search.jabber.network +* Activarea marcajelor de verificare a livrării în mod implicit și eliminarea setării +* Activarea "Butonul de trimitere indică starea" în mod implicit și eliminarea setării +*Mutarea setărilor Serviciului de rezervă și ale Serviciului de prim-plan în ecranul principal diff --git a/fastlane/metadata/android/ro/changelogs/351.txt b/fastlane/metadata/android/ro/changelogs/351.txt new file mode 100644 index 000000000..629d86109 --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/351.txt @@ -0,0 +1,3 @@ +* reparații pentru transferul de fișiere Jingle IBB +* reparații pentru corecțiile repetate care umplu baza de date +* schimbarea la Corectarea Ultimului Mesaj v1.1 diff --git a/fastlane/metadata/android/ro/changelogs/353.txt b/fastlane/metadata/android/ro/changelogs/353.txt new file mode 100644 index 000000000..1c788dab6 --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/353.txt @@ -0,0 +1,4 @@ +* utilizatorii pot să își seteze propria poreclă +* continuarea descărcării de fișiere criptate OMEMO +* Canalele folosesc '#' ca simbol în avatar +* Quicksy folosește 'mereu' ca și criptare implicită OMEMO (ascunde iconița lacăt) diff --git a/fastlane/metadata/android/ro/changelogs/360.txt b/fastlane/metadata/android/ro/changelogs/360.txt new file mode 100644 index 000000000..7949e0d14 --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/360.txt @@ -0,0 +1 @@ +* Suport pentru parametrii uri ?register și ?register;preauth XMPP diff --git a/fastlane/metadata/android/ro/changelogs/362.txt b/fastlane/metadata/android/ro/changelogs/362.txt new file mode 100644 index 000000000..46a802c73 --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/362.txt @@ -0,0 +1 @@ +* Suport pentru comutarea automată a temei pe Android 10 diff --git a/fastlane/metadata/android/ro/changelogs/364.txt b/fastlane/metadata/android/ro/changelogs/364.txt new file mode 100644 index 000000000..95ea86302 --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/364.txt @@ -0,0 +1,2 @@ +* Furnizarea de previzualizări PDF pe Android 5+ +* Folosirea IV-urilor de 12 biți pentru OMEMO diff --git a/fastlane/metadata/android/ro/changelogs/367.txt b/fastlane/metadata/android/ro/changelogs/367.txt new file mode 100644 index 000000000..9575f811d --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/367.txt @@ -0,0 +1,2 @@ +* Repararea selecției de avatar pe unele dispozitive ce rulează Android 10 +* Repararea transferului de fișiere pentru fișiere mari diff --git a/fastlane/metadata/android/ro/changelogs/379.txt b/fastlane/metadata/android/ro/changelogs/379.txt new file mode 100644 index 000000000..7bbc1c965 --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/379.txt @@ -0,0 +1 @@ +* Apeluri Audio/Video (Necesită suport pe server în formă de servere STUN și TURN descoperibile prin XEP-0125) diff --git a/fastlane/metadata/android/ro/changelogs/381.txt b/fastlane/metadata/android/ro/changelogs/381.txt new file mode 100644 index 000000000..9d8e76ba3 --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/381.txt @@ -0,0 +1,2 @@ +* Feedback auditoriu (apelare, apel început, apel terminat) pentru apeluri vocale +* Problemă rezolvată cu reîncercarea apelului video eșuat diff --git a/fastlane/metadata/android/ro/changelogs/382.txt b/fastlane/metadata/android/ro/changelogs/382.txt new file mode 100644 index 000000000..f9eb35219 --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/382.txt @@ -0,0 +1,2 @@ +* Adăugarea butonului pentru a schimba camera în timpul apelului video +* Repararea apelurilor voce pe tablete diff --git a/fastlane/metadata/android/ro/changelogs/383.txt b/fastlane/metadata/android/ro/changelogs/383.txt new file mode 100644 index 000000000..ee79d607e --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/383.txt @@ -0,0 +1,3 @@ +* Mutarea iconiței de apel către stânga pentru a ține celelalte iconițe din bara de instrumente într-un loc consistent +* Afișarea durației apelurilor în timpul apelurilor audio +* Ruperea egalității pentru apeluri audio/video (aceleași două persoane care se sună între ele în același timp) diff --git a/fastlane/metadata/android/ro/changelogs/387.txt b/fastlane/metadata/android/ro/changelogs/387.txt new file mode 100644 index 000000000..4faeea6b1 --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/387.txt @@ -0,0 +1,2 @@ +* Refacerea logării cu UI pentru certificate +* Adăugarea abilității de a fixa conversații sus (adăugarea la favorite) diff --git a/fastlane/metadata/android/ro/changelogs/388.txt b/fastlane/metadata/android/ro/changelogs/388.txt new file mode 100644 index 000000000..0fbd0d92e --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/388.txt @@ -0,0 +1,3 @@ +* Reducerea ecoului în timpul apelurilor pe unele dispozitive +* Repararea logării când parolele conțin caractere speciale +* Redarea tonurilor de apel și ocupat pe difuzor în timpul apelurilor video diff --git a/fastlane/metadata/android/ro/changelogs/390.txt b/fastlane/metadata/android/ro/changelogs/390.txt new file mode 100644 index 000000000..8ae2da5cb --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/390.txt @@ -0,0 +1 @@ +* Oferirea de a înregistra mesaj vocal când persoana apelată este ocupată diff --git a/fastlane/metadata/android/ro/changelogs/393.txt b/fastlane/metadata/android/ro/changelogs/393.txt new file mode 100644 index 000000000..712b8e420 --- /dev/null +++ b/fastlane/metadata/android/ro/changelogs/393.txt @@ -0,0 +1,3 @@ +* Afișarea butonului de ajutor dacă apelul audio/video eșuează +* Repararea unor crash-uri enervante +* Repararea conexiunilor Jingle (transfer fișiere + apeluri) cu JID-uri goale diff --git a/fastlane/metadata/android/sv-SE/changelogs/360.txt b/fastlane/metadata/android/sv-SE/changelogs/360.txt new file mode 100644 index 000000000..ac91103f8 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/360.txt @@ -0,0 +1 @@ +* Stöd för XMPP-uri-parametrarna ?register och ?register;preauth diff --git a/fastlane/metadata/android/sv-SE/changelogs/362.txt b/fastlane/metadata/android/sv-SE/changelogs/362.txt new file mode 100644 index 000000000..04a164c57 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/362.txt @@ -0,0 +1 @@ +* Stöd för automatisk tema-byte på Android 10 diff --git a/fastlane/metadata/android/sv-SE/changelogs/364.txt b/fastlane/metadata/android/sv-SE/changelogs/364.txt new file mode 100644 index 000000000..64803c6cc --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/364.txt @@ -0,0 +1,2 @@ +* PDF-förhandsvisning på Android 5+ +* Använd 12-bitars IV:s för OMEMO diff --git a/fastlane/metadata/android/sv-SE/changelogs/367.txt b/fastlane/metadata/android/sv-SE/changelogs/367.txt new file mode 100644 index 000000000..56be092a2 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/367.txt @@ -0,0 +1,2 @@ +* Fix för avatar-val på vissa Android 10-enheter +* Fix för överföring av större filer diff --git a/fastlane/metadata/android/sv-SE/changelogs/379.txt b/fastlane/metadata/android/sv-SE/changelogs/379.txt new file mode 100644 index 000000000..2b471fc71 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/379.txt @@ -0,0 +1 @@ +* Ljud- och bildsamtal (Kräver serverstöd i form av STUN- och TURN-servrar som kan hittas via XEP-0215) diff --git a/fastlane/metadata/android/sv-SE/changelogs/381.txt b/fastlane/metadata/android/sv-SE/changelogs/381.txt new file mode 100644 index 000000000..e34071679 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/381.txt @@ -0,0 +1,2 @@ +* Ljudsignaler för ljudsamtal (uppringning, samtal startat och samtal avslutat) +* Fix för problem med återuppringning vid misslyckat videosamtal diff --git a/fastlane/metadata/android/sv-SE/changelogs/382.txt b/fastlane/metadata/android/sv-SE/changelogs/382.txt new file mode 100644 index 000000000..949e8e9d3 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/382.txt @@ -0,0 +1,2 @@ +* Knapp för att byta kamera under videosamtal +* Fix för röstsamtal på plattor diff --git a/fastlane/metadata/android/uk/changelogs/349.txt b/fastlane/metadata/android/uk/changelogs/349.txt new file mode 100644 index 000000000..03f0b8764 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/349.txt @@ -0,0 +1,4 @@ +* Додано Експертні налаштування для пошуку каналів на локальному сервері замість search.jabber.network +* Позначки про доставку увімкнено за замовчуванням, а налаштування видалено +* «Кнопка надсилання показує стан» увімкнено за замовчуванням, а налаштування видалено +* Налаштування резервного копіювання і процесу на передньому плані перенесено на основний екран diff --git a/fastlane/metadata/android/uk/changelogs/351.txt b/fastlane/metadata/android/uk/changelogs/351.txt new file mode 100644 index 000000000..4b92092b9 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/351.txt @@ -0,0 +1,3 @@ +* Виправлення обміну файлами Jingle IBB +* Повторювані виправлення правопису більше не заповнюють базу даних +* Перехід на Last Message Correction v1.1 diff --git a/fastlane/metadata/android/uk/changelogs/353.txt b/fastlane/metadata/android/uk/changelogs/353.txt new file mode 100644 index 000000000..e0ab4b1da --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/353.txt @@ -0,0 +1,4 @@ +* Користувачі можуть встановлювати своє прізвисько (нікнейм) +* Відновлювати завантаження файлів, зашифрованих OMEMO +* Канали тепер позначаються символом «#» на піктограмі +* Quicksy за замовчуванням використовує «завжди» для шифрування OMEMO (приховує значок замка) diff --git a/fastlane/metadata/android/uk/changelogs/360.txt b/fastlane/metadata/android/uk/changelogs/360.txt new file mode 100644 index 000000000..6685cdb3a --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/360.txt @@ -0,0 +1 @@ +* Підтримка параметрів XMPP URI ?register та ?register;preauth diff --git a/fastlane/metadata/android/uk/changelogs/362.txt b/fastlane/metadata/android/uk/changelogs/362.txt new file mode 100644 index 000000000..742bbaab6 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/362.txt @@ -0,0 +1 @@ +* Підтримка автоматичного перемикання теми на Android 10 diff --git a/fastlane/metadata/android/uk/changelogs/364.txt b/fastlane/metadata/android/uk/changelogs/364.txt new file mode 100644 index 000000000..acf1518ac --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/364.txt @@ -0,0 +1,2 @@ +* Попередній перегляд PDF на Android 5 і новіших +* Використання 12-байтових IV для OMEMO diff --git a/fastlane/metadata/android/uk/changelogs/367.txt b/fastlane/metadata/android/uk/changelogs/367.txt new file mode 100644 index 000000000..4e697be13 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/367.txt @@ -0,0 +1,2 @@ +* Виправлено вибір піктограми користувача на деяких пристроях з Android 10 +* Виправлення обміну файлами для великих файлів diff --git a/fastlane/metadata/android/uk/changelogs/379.txt b/fastlane/metadata/android/uk/changelogs/379.txt new file mode 100644 index 000000000..0143a133a --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/379.txt @@ -0,0 +1 @@ +* Голосові та відеовиклики (необхідна підтримка сервера у вигляді серверів STUN і TURN, доступних для виявлення через XEP-0215) diff --git a/fastlane/metadata/android/uk/changelogs/381.txt b/fastlane/metadata/android/uk/changelogs/381.txt new file mode 100644 index 000000000..c59e5669c --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/381.txt @@ -0,0 +1,2 @@ +* Зворотний зв'язок (звуки «набір номера», «початок дзвінка», «завершення дзвінка») для голосових викликів +* Виправлено проблему з повторною спробою невдалого відеовиклику diff --git a/fastlane/metadata/android/uk/changelogs/382.txt b/fastlane/metadata/android/uk/changelogs/382.txt new file mode 100644 index 000000000..49a02dfbb --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/382.txt @@ -0,0 +1,2 @@ +* Додано кнопку перемикання камери під час відеовиклику +* Виправлення для голосових дзвінків на планшетах diff --git a/fastlane/metadata/android/uk/changelogs/383.txt b/fastlane/metadata/android/uk/changelogs/383.txt new file mode 100644 index 000000000..66d879d4b --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/383.txt @@ -0,0 +1,3 @@ +* Значок дзвінка переміщено ліворуч, щоб інші значки панелі інструментів залишалися на відповідних місцях +* Показувати тривалість розмови під час голосових викликів +* Визначення переваги в голосових та відеовикликах (двоє людей телефонують один одному одночасно) diff --git a/fastlane/metadata/android/uk/changelogs/387.txt b/fastlane/metadata/android/uk/changelogs/387.txt new file mode 100644 index 000000000..a26a7642f --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/387.txt @@ -0,0 +1,2 @@ +* Перероблено інтерфейс входу з сертифікатом +* Додано можливість закріплювати чати (додати до вибраного) diff --git a/fastlane/metadata/android/uk/changelogs/388.txt b/fastlane/metadata/android/uk/changelogs/388.txt new file mode 100644 index 000000000..6e5f7899a --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/388.txt @@ -0,0 +1,3 @@ +* Зменшено відлуння під час викликів на деяких пристроях +* Виправлено вхід з паролями, що містять спеціальні символи +* Сигнали набору номера та зайнятості відтворюються через динамік під час відеовикликів diff --git a/fastlane/metadata/android/uk/changelogs/390.txt b/fastlane/metadata/android/uk/changelogs/390.txt new file mode 100644 index 000000000..f12a56fda --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/390.txt @@ -0,0 +1 @@ +* Можливість записати голосове повідомлення, коли абонент зайнятий diff --git a/fastlane/metadata/android/uk/changelogs/393.txt b/fastlane/metadata/android/uk/changelogs/393.txt new file mode 100644 index 000000000..c4bd20e66 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/393.txt @@ -0,0 +1,3 @@ +* Показувати кнопку «Довідка» у випадку невдалого голосового чи відеовиклику +* Виправлено деякі неприємні збої +* Виправлено з'єднання Jingle (обмін файлами + дзвінки) з JID'ами без ресурсу diff --git a/fastlane/metadata/android/uk/changelogs/394.txt b/fastlane/metadata/android/uk/changelogs/394.txt new file mode 100644 index 000000000..374c95fec --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/394.txt @@ -0,0 +1,2 @@ +* Виправлено сповіщення, які не з'являлися за певних умов +* Виправлення проблем сумісності та збоїв, пов’язаних з голосовими та відеовикликами diff --git a/fastlane/metadata/android/uk/changelogs/395.txt b/fastlane/metadata/android/uk/changelogs/395.txt new file mode 100644 index 000000000..890b5c473 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/395.txt @@ -0,0 +1,3 @@ +* Додано «Повернутися до чату» на екрані звукового виклику +* Удосконалено комбінації клавіш +* Виправлення помилок diff --git a/fastlane/metadata/android/uk/changelogs/397.txt b/fastlane/metadata/android/uk/changelogs/397.txt new file mode 100644 index 000000000..0c9af0508 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/397.txt @@ -0,0 +1,3 @@ +* Обробляти файли GPX +* Покращення продуктивності при відновленні резервної копії +* Виправлення помилок diff --git a/fastlane/metadata/android/uk/changelogs/398.txt b/fastlane/metadata/android/uk/changelogs/398.txt new file mode 100644 index 000000000..837e85eac --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/398.txt @@ -0,0 +1,4 @@ +* Пошук в окремих розмовах +* Сповіщення про невдале надсилання повідомлень +* Імена (нікнейми) користувачів Quicksy зберігаються після перезапуску застосунку +* Додано кнопку для запуску Orbot (Tor) із сповіщення, якщо це необхідно diff --git a/fastlane/metadata/android/uk/changelogs/401.txt b/fastlane/metadata/android/uk/changelogs/401.txt new file mode 100644 index 000000000..dbce88932 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/401.txt @@ -0,0 +1,2 @@ +* Виправлено пошук на версіях Android до 5-ї включно +* Оптимізація використання пам'яті diff --git a/fastlane/metadata/android/uk/changelogs/402.txt b/fastlane/metadata/android/uk/changelogs/402.txt new file mode 100644 index 000000000..1f2ec0fbd --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/402.txt @@ -0,0 +1,3 @@ +* Просте створення запрошень на серверах з підтримкою запрошень +* Перегляд файлів GIF, отриманих з Movim +* Піктограми користувачів зберігаються у кеші diff --git a/fastlane/metadata/android/uk/changelogs/403.txt b/fastlane/metadata/android/uk/changelogs/403.txt new file mode 100644 index 000000000..75ec2df34 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/403.txt @@ -0,0 +1,3 @@ +* Виправлено проблеми з підключенням, коли різні облікові записи використовували різні механізми SCRAM +* Додано підтримку SCRAM-SHA-512 +* Дозволено обмін файлами P2P (Jingle) із власним контактом diff --git a/fastlane/metadata/android/uk/changelogs/404.txt b/fastlane/metadata/android/uk/changelogs/404.txt new file mode 100644 index 000000000..130dce1c6 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/404.txt @@ -0,0 +1 @@ +* Незначні покращення стабільності для голосових та відеовикликів diff --git a/fastlane/metadata/android/uk/changelogs/405.txt b/fastlane/metadata/android/uk/changelogs/405.txt new file mode 100644 index 000000000..215a7e262 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/405.txt @@ -0,0 +1 @@ +* Quicksy: Автоматично отримувати SMS підтвердження diff --git a/fastlane/metadata/android/uk/changelogs/407.txt b/fastlane/metadata/android/uk/changelogs/407.txt new file mode 100644 index 000000000..abc18bf99 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/407.txt @@ -0,0 +1,3 @@ +* Показувати кнопку виклику для контактів поза мережею, якщо вони раніше оголосили про підтримку дзвінків +* Кнопка «Назад» більше не завершує дзвінок під час виклику +* Виправлення помилок diff --git a/fastlane/metadata/android/uk/changelogs/42000.txt b/fastlane/metadata/android/uk/changelogs/42000.txt new file mode 100644 index 000000000..7657ede43 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42000.txt @@ -0,0 +1,4 @@ +* Можливість вибирати мелодію для вхідних викликів +* Виправлено виявлення ідентифікатора ключа OpenPGP для OpenKeychain 5.6+ +* Коректна перевірка сертифікатів punycode TLS +* Покращення стабільності встановлення сесії RTP (дзвінки) diff --git a/fastlane/metadata/android/uk/changelogs/42006.txt b/fastlane/metadata/android/uk/changelogs/42006.txt new file mode 100644 index 000000000..6077b0312 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42006.txt @@ -0,0 +1,2 @@ +* Перевіряти голосові та відеовиклики за допомогою вже існуючих сесій OMEMO +* Покращено сумісність із реалізаціями WebRTC без libwebrtc diff --git a/fastlane/metadata/android/uk/changelogs/42010.txt b/fastlane/metadata/android/uk/changelogs/42010.txt new file mode 100644 index 000000000..0c25752b4 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42010.txt @@ -0,0 +1,2 @@ +* Виправлення різноманітних помилок у підтримці Tor +* Покращення сумісності дзвінків із Dino diff --git a/fastlane/metadata/android/uk/changelogs/42012.txt b/fastlane/metadata/android/uk/changelogs/42012.txt new file mode 100644 index 000000000..654b6af7f --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42012.txt @@ -0,0 +1 @@ +* Виправлено передачу/завантаження через HTTP для користувачів, які не довіряють системним ЦС diff --git a/fastlane/metadata/android/uk/changelogs/42013.txt b/fastlane/metadata/android/uk/changelogs/42013.txt new file mode 100644 index 000000000..f1a182eb3 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42013.txt @@ -0,0 +1 @@ +* Виправлено проблеми з повідомленням про відсутність з'єднання на Android 7.1 diff --git a/fastlane/metadata/android/uk/changelogs/42014.txt b/fastlane/metadata/android/uk/changelogs/42014.txt new file mode 100644 index 000000000..39b9354ce --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42014.txt @@ -0,0 +1,2 @@ +* Завжди перевіряти ім'я домену. Без перезапису користувачем +* Підтримка попередньої автентифікації списку контактів diff --git a/fastlane/metadata/android/uk/changelogs/42015.txt b/fastlane/metadata/android/uk/changelogs/42015.txt new file mode 100644 index 000000000..e80184657 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42015.txt @@ -0,0 +1 @@ +* Незначні покращення голосових та відеовикликів diff --git a/fastlane/metadata/android/uk/changelogs/42018.txt b/fastlane/metadata/android/uk/changelogs/42018.txt new file mode 100644 index 000000000..01ffffc78 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42018.txt @@ -0,0 +1,3 @@ +* Показувати чорні смуги, коли віддалене відео не відповідає пропорціям екрана +* Покращення ефективності пошуку +* Додано налаштування для заборони знімків екрана diff --git a/fastlane/metadata/android/uk/changelogs/42022.txt b/fastlane/metadata/android/uk/changelogs/42022.txt new file mode 100644 index 000000000..4a5bfc7bb --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42022.txt @@ -0,0 +1,2 @@ +* Виправлено проблему коли деякі відео не стискалися +* Виправлено рідкісний збій під час відкриття сповіщення diff --git a/fastlane/metadata/android/uk/changelogs/42023.txt b/fastlane/metadata/android/uk/changelogs/42023.txt new file mode 100644 index 000000000..e85b7c0ad --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42023.txt @@ -0,0 +1,2 @@ +* Виправлено збій при відтворенні деяких лапок +* Виправлено збій на екрані привітання diff --git a/fastlane/metadata/android/uk/changelogs/42037.txt b/fastlane/metadata/android/uk/changelogs/42037.txt new file mode 100644 index 000000000..723563a95 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42037.txt @@ -0,0 +1,11 @@ +Версія 2.10.9 +* Запитувати дозволи Bluetooth для голосових та відеовикликів (можна відхилити, якщо не використовуєте гарнітуру Bluetooth) +* Виправлено помилку під час виклику Movim +* Виправлено відображення неправильної піктограми для групових чатів +* Завжди запитувати про вимкнення оптимізації батареї +* Установлено прапорець «лише локально» для сповіщень «x облікових записів у мережі» +* Виправлено взаємодію з плагіном Google Maps Share Location +* Видалено примітку щодо плати за сервер +* Зберігати файли в місці, яке підходить для Android 11 +* Пробувати повторно підключити виклик після перемикання мережі +* Показувати JID абонента та JID облікового запису на екрані вхідного виклику diff --git a/fastlane/metadata/android/uk/changelogs/42038.txt b/fastlane/metadata/android/uk/changelogs/42038.txt new file mode 100644 index 000000000..df23d2664 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42038.txt @@ -0,0 +1,2 @@ +* Незначні виправлення помилок +* Відновлено можливість викликів через JMP та інші служби (версія Playstore) diff --git a/fastlane/metadata/android/uk/changelogs/42041.txt b/fastlane/metadata/android/uk/changelogs/42041.txt new file mode 100644 index 000000000..f0c963522 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42041.txt @@ -0,0 +1,5 @@ +* Реалізація Extensible SASL Profile, Bind 2.0 і Fast для швидшого повторного з'єднання +* Реалізація Channel Binding +* Додано можливість перемикатися з голосового на відеовиклик +* Додано можливість видаляти свою піктограму користувача +* Додано сповіщення про пропущені виклики diff --git a/fastlane/metadata/android/uk/changelogs/42042.txt b/fastlane/metadata/android/uk/changelogs/42042.txt new file mode 100644 index 000000000..771ad5b10 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42042.txt @@ -0,0 +1,2 @@ +* Виправлено циклічне повторне надсилання на сервери, які підтримують лише sm:2 +* Показувати «Перемкнути на відео» тільки якщо інша сторона підтримує відео diff --git a/fastlane/metadata/android/uk/changelogs/42043.txt b/fastlane/metadata/android/uk/changelogs/42043.txt new file mode 100644 index 000000000..a92ebf0fc --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42043.txt @@ -0,0 +1 @@ +* Виправлено регресивну помилку в обміні файлами P2P diff --git a/fastlane/metadata/android/uk/changelogs/42044.txt b/fastlane/metadata/android/uk/changelogs/42044.txt new file mode 100644 index 000000000..8facdb3bf --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42044.txt @@ -0,0 +1,3 @@ +* Виправлено повторне надсилання повідомлень при використанні SASL2 +* Виправлення чорного відео між деякими пристроями +* Виправлено збій з порожніми паролями diff --git a/fastlane/metadata/android/uk/changelogs/42046.txt b/fastlane/metadata/android/uk/changelogs/42046.txt new file mode 100644 index 000000000..36f6ac725 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42046.txt @@ -0,0 +1 @@ +* Інтегрований дистриб'ютор UnifiedPush для надсилання push-повідомлень іншим застосункам, які підтримують UnifiedPush, як-от Tusky і Fedilab diff --git a/fastlane/metadata/android/uk/changelogs/42047.txt b/fastlane/metadata/android/uk/changelogs/42047.txt new file mode 100644 index 000000000..5cf6316f1 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42047.txt @@ -0,0 +1 @@ +* Виправлено збій у дистриб'юторі UnifiedPush diff --git a/fastlane/metadata/android/uk/changelogs/42050.txt b/fastlane/metadata/android/uk/changelogs/42050.txt new file mode 100644 index 000000000..cc1ed9ce3 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42050.txt @@ -0,0 +1 @@ +* Збільшено радіус заокруглення кутів зображення профілю diff --git a/fastlane/metadata/android/uk/changelogs/42059.txt b/fastlane/metadata/android/uk/changelogs/42059.txt new file mode 100644 index 000000000..06af124bb --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42059.txt @@ -0,0 +1,2 @@ +* Цільовий SDK знову підвищено до 33 +* Виправлення проблем із серверами, які підтримують SASL2 без вбудованого керування потоком diff --git a/fastlane/metadata/android/uk/changelogs/42060.txt b/fastlane/metadata/android/uk/changelogs/42060.txt new file mode 100644 index 000000000..20fde53a4 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42060.txt @@ -0,0 +1 @@ +* Виправлено помилкове розпізнавання літери «q» як кириличної diff --git a/fastlane/metadata/android/uk/changelogs/42061.txt b/fastlane/metadata/android/uk/changelogs/42061.txt new file mode 100644 index 000000000..2b4441c6e --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42061.txt @@ -0,0 +1 @@ +* Видалено функцію пошуку каналів із версії Google Play diff --git a/fastlane/metadata/android/uk/changelogs/42062.txt b/fastlane/metadata/android/uk/changelogs/42062.txt new file mode 100644 index 000000000..f5768cc61 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42062.txt @@ -0,0 +1 @@ +* Вимкнено відкривання файлів резервних копій (.ceb) із файлового менеджера diff --git a/fastlane/metadata/android/uk/changelogs/42065.txt b/fastlane/metadata/android/uk/changelogs/42065.txt new file mode 100644 index 000000000..4a8122be7 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42065.txt @@ -0,0 +1 @@ +* Запроваджено новий формат файлу резервної копії diff --git a/fastlane/metadata/android/uk/changelogs/42068.txt b/fastlane/metadata/android/uk/changelogs/42068.txt new file mode 100644 index 000000000..eabdf5366 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42068.txt @@ -0,0 +1,2 @@ +* Підтримка налаштування сповіщень окремо для кожної розмови +* Використання Opus для голосових повідомлень на Android 10 diff --git a/fastlane/metadata/android/uk/changelogs/42072.txt b/fastlane/metadata/android/uk/changelogs/42072.txt new file mode 100644 index 000000000..b8ff7f7c7 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/42072.txt @@ -0,0 +1,3 @@ +* Підвищено залежність libwebrtc до M117 і оновлено libvpx +* Повернення до AAC для голосових повідомлень +* Підтримка своїх налаштувань мови в додатку diff --git a/fastlane/metadata/android/uk/changelogs/4207704.txt b/fastlane/metadata/android/uk/changelogs/4207704.txt new file mode 100644 index 000000000..db6654491 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/4207704.txt @@ -0,0 +1,3 @@ +* Підтримка приватної DNS (DNS over TLS) +* Підтримка тематичного значка додатка в лаунчері +* Виправлено рідкісну проблему з дозволами під час обміну файлами на Android 11 і новіших diff --git a/fastlane/metadata/android/uk/changelogs/4208104.txt b/fastlane/metadata/android/uk/changelogs/4208104.txt new file mode 100644 index 000000000..9235074b1 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* Простіший доступ до «Показати QR-код» +* Підтримка закладок PEP Native Bookmarks +* Додано підтримку моделі SDP пропозиція/відповідь (Використовується шлюзами SIP) +* Підвищено цільовий API до Android 14 diff --git a/fastlane/metadata/android/uk/changelogs/4208804.txt b/fastlane/metadata/android/uk/changelogs/4208804.txt new file mode 100644 index 000000000..6aa707982 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/4208804.txt @@ -0,0 +1,3 @@ +* Підтримка передачі файлів P2P через канали даних WebRTC +* Виправлено проблеми сумісності з Bind 2.0 на ejabberd +* Пакет кореневих сертифікатів Let's Encrypt для версій Android до 7-ї включно diff --git a/fastlane/metadata/android/uk/changelogs/4209004.txt b/fastlane/metadata/android/uk/changelogs/4209004.txt new file mode 100644 index 000000000..2863414a6 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/4209004.txt @@ -0,0 +1,2 @@ +* Незначні виправлення помилок +* Деякі зміни у процесі підключення до Quicksy diff --git a/fastlane/metadata/android/uk/changelogs/4209204.txt b/fastlane/metadata/android/uk/changelogs/4209204.txt new file mode 100644 index 000000000..652ea89d8 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/4209204.txt @@ -0,0 +1,2 @@ +* Простіший доступ до «Політики конфіденційності» у версії Play Store (Quicksy та Conversations) +* Видалено інтеграцію адресної книги у версії Conversations для Play Store diff --git a/fastlane/metadata/android/zh-CN/changelogs/349.txt b/fastlane/metadata/android/zh-CN/changelogs/349.txt new file mode 100644 index 000000000..689259e2b --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/349.txt @@ -0,0 +1,4 @@ +* 引入专家设置在本地服务器上执行频道发现而不是 search.jabber.network +* 默认启用传递复选标记并移除设置 +* 默认启用“发送按钮指示状态”并移除设置 +* 将备份和前台服务设置移至主屏幕 diff --git a/fastlane/metadata/android/zh-CN/changelogs/351.txt b/fastlane/metadata/android/zh-CN/changelogs/351.txt new file mode 100644 index 000000000..c3ff6bc46 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/351.txt @@ -0,0 +1,3 @@ +* 修复了 Jingle IBB 文件传输问题 +* 修复了重复更正填满数据库的问题 +* 切换到最后消息更正 v1.1 diff --git a/fastlane/metadata/android/zh-CN/changelogs/353.txt b/fastlane/metadata/android/zh-CN/changelogs/353.txt new file mode 100644 index 000000000..75d7425d1 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/353.txt @@ -0,0 +1,4 @@ +* 让用户设置自己的昵称 +* 恢复 OMEMO 加密文件的下载 +* 频道现在使用“#”作为头像中的符号 +* Quicksy 使用“始终”作为 OMEMO 加密默认值(隐藏锁定图标) diff --git a/fastlane/metadata/android/zh-CN/changelogs/360.txt b/fastlane/metadata/android/zh-CN/changelogs/360.txt new file mode 100644 index 000000000..fc2e905de --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/360.txt @@ -0,0 +1 @@ +* 支持 ?register 和 ?register;preauth XMPP uri 参数 diff --git a/fastlane/metadata/android/zh-CN/changelogs/362.txt b/fastlane/metadata/android/zh-CN/changelogs/362.txt new file mode 100644 index 000000000..27249c94f --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/362.txt @@ -0,0 +1 @@ +* 支持在 Android 10 上自动切换主题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/364.txt b/fastlane/metadata/android/zh-CN/changelogs/364.txt new file mode 100644 index 000000000..2b4d64fcb --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/364.txt @@ -0,0 +1,2 @@ +* 在 Android 5 以上版本上提供 PDF 预览 +* 为 OMEMO 使用 12 byte IVs diff --git a/fastlane/metadata/android/zh-CN/changelogs/367.txt b/fastlane/metadata/android/zh-CN/changelogs/367.txt new file mode 100644 index 000000000..59ac67d50 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/367.txt @@ -0,0 +1,2 @@ +* 修复部分 Android 10 设备上的头像选择问题 +* 修复较大文件的文件传输 diff --git a/fastlane/metadata/android/zh-CN/changelogs/379.txt b/fastlane/metadata/android/zh-CN/changelogs/379.txt new file mode 100644 index 000000000..9bd6e9e93 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/379.txt @@ -0,0 +1 @@ +* 音频/视频通话(需要通过 XEP-0215 发现的 STUN 和 TURN 服务器形式的服务器支持) diff --git a/fastlane/metadata/android/zh-CN/changelogs/381.txt b/fastlane/metadata/android/zh-CN/changelogs/381.txt new file mode 100644 index 000000000..b8db10afd --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/381.txt @@ -0,0 +1,2 @@ +* 语音通话的声音反馈(拨号、通话开始、通话结束)。 +* 修复了重试失败视频通话的问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/382.txt b/fastlane/metadata/android/zh-CN/changelogs/382.txt new file mode 100644 index 000000000..334e75d43 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/382.txt @@ -0,0 +1,2 @@ +* 添加视频通话时切换摄像头的按钮 +* 修复了平板电脑上的语音通话问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/383.txt b/fastlane/metadata/android/zh-CN/changelogs/383.txt new file mode 100644 index 000000000..31ee7aa89 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/383.txt @@ -0,0 +1,3 @@ +* 将通话图标移至左侧,以保持其他工具栏图标在一致的位置 +* 音频通话时显示通话时长 +* 音频/视频通话打破僵局(两个人同时打电话给对方) diff --git a/fastlane/metadata/android/zh-CN/changelogs/387.txt b/fastlane/metadata/android/zh-CN/changelogs/387.txt new file mode 100644 index 000000000..e8c9f29a3 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/387.txt @@ -0,0 +1,2 @@ +* 重新设计使用证书登录的用户界面 +* 添加将聊天固定在顶部的功能(添加到收藏夹) diff --git a/fastlane/metadata/android/zh-CN/changelogs/388.txt b/fastlane/metadata/android/zh-CN/changelogs/388.txt new file mode 100644 index 000000000..8b8536bbe --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/388.txt @@ -0,0 +1,3 @@ +* 在某些设备上通话时减少回声 +* 修复密码包含特殊字符时的登录问题 +* 视频通话期间扬声器上播放拨号音和忙音 diff --git a/fastlane/metadata/android/zh-CN/changelogs/390.txt b/fastlane/metadata/android/zh-CN/changelogs/390.txt new file mode 100644 index 000000000..ee78f954d --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/390.txt @@ -0,0 +1 @@ +* 在接听者忙时提供录制语音消息服务 diff --git a/fastlane/metadata/android/zh-CN/changelogs/393.txt b/fastlane/metadata/android/zh-CN/changelogs/393.txt new file mode 100644 index 000000000..1d903fe6c --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/393.txt @@ -0,0 +1,3 @@ +* 如果音频/视频通话失败则显示帮助按钮 +* 修复了一些恼人的崩溃问题 +* 修复了带有纯 JID 的 Jingle 连接(文件传输 + 通话) diff --git a/fastlane/metadata/android/zh-CN/changelogs/394.txt b/fastlane/metadata/android/zh-CN/changelogs/394.txt new file mode 100644 index 000000000..df4f52a34 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/394.txt @@ -0,0 +1,2 @@ +* 修复了某些情况下不显示通知的问题 +* 修复了与音频/视频通话相关的兼容性问题和崩溃 diff --git a/fastlane/metadata/android/zh-CN/changelogs/395.txt b/fastlane/metadata/android/zh-CN/changelogs/395.txt new file mode 100644 index 000000000..010d78c4d --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/395.txt @@ -0,0 +1,3 @@ +* 在音频通话屏幕中添加“返回聊天” +* 改进键盘快捷键 +* bug 修复 diff --git a/fastlane/metadata/android/zh-CN/changelogs/397.txt b/fastlane/metadata/android/zh-CN/changelogs/397.txt new file mode 100644 index 000000000..96c3815c3 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/397.txt @@ -0,0 +1,3 @@ +* 处理 GPX 文件 +* 提高备份恢复性能 +* bug 修复 diff --git a/fastlane/metadata/android/zh-CN/changelogs/398.txt b/fastlane/metadata/android/zh-CN/changelogs/398.txt new file mode 100644 index 000000000..3985836d3 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/398.txt @@ -0,0 +1,4 @@ +* 搜索个人对话 +* 消息传递失败时通知用户 +* 重启时记住 Quicksy 用户的显示名称(昵称) +* 如有必要,添加按钮以从通知中启动 Orbot(Tor) diff --git a/fastlane/metadata/android/zh-CN/changelogs/401.txt b/fastlane/metadata/android/zh-CN/changelogs/401.txt new file mode 100644 index 000000000..df75923de --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/401.txt @@ -0,0 +1,2 @@ +* 修复了 Android <= 5 上的搜索 +* 优化内存消耗 diff --git a/fastlane/metadata/android/zh-CN/changelogs/402.txt b/fastlane/metadata/android/zh-CN/changelogs/402.txt new file mode 100644 index 000000000..7bcabf3b9 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/402.txt @@ -0,0 +1,3 @@ +* 在支持的服务器上提供简易邀请生成功能 +* 显示从 Movim 发送的 GIF +* 在缓存中存储头像 diff --git a/fastlane/metadata/android/zh-CN/changelogs/403.txt b/fastlane/metadata/android/zh-CN/changelogs/403.txt new file mode 100644 index 000000000..52a13575a --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/403.txt @@ -0,0 +1,3 @@ +* 修复了不同账号使用不同 SCRAM 机制时的连接问题 +* 添加对 SCRAM-SHA-512 的支持 +* 允许通过自联系进行 P2P(Jingle)文件传输 diff --git a/fastlane/metadata/android/zh-CN/changelogs/404.txt b/fastlane/metadata/android/zh-CN/changelogs/404.txt new file mode 100644 index 000000000..4619eb7cc --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/404.txt @@ -0,0 +1 @@ +* 对音频/视频通话的稳定性略有改善 diff --git a/fastlane/metadata/android/zh-CN/changelogs/405.txt b/fastlane/metadata/android/zh-CN/changelogs/405.txt new file mode 100644 index 000000000..a0fa53f8b --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/405.txt @@ -0,0 +1 @@ +* Quicksy:自动接收验证短信 diff --git a/fastlane/metadata/android/zh-CN/changelogs/407.txt b/fastlane/metadata/android/zh-CN/changelogs/407.txt new file mode 100644 index 000000000..33e0919ad --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/407.txt @@ -0,0 +1,3 @@ +* 如果离线联系人之前已宣布支持,则显示呼叫按钮 +* 通话接通后,后退按钮不再结束通话 +* bug 修复 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42000.txt b/fastlane/metadata/android/zh-CN/changelogs/42000.txt new file mode 100644 index 000000000..8aedfaed8 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42000.txt @@ -0,0 +1,4 @@ +* 能够选择来电铃声 +* 修复 OpenKeychain 5.6+ 的 OpenPGP 密钥 ID 发现问题 +* 正确验证 punycode TLS 证书 +* 提高 RTP 会话建立(呼叫)的稳定性 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42006.txt b/fastlane/metadata/android/zh-CN/changelogs/42006.txt new file mode 100644 index 000000000..d9f60038f --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42006.txt @@ -0,0 +1,2 @@ +* 使用预先存在的 OMEMO 会话验证音频/视频通话 +* 提高与非 libwebrtc WebRTC 实现的兼容性 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42010.txt b/fastlane/metadata/android/zh-CN/changelogs/42010.txt new file mode 100644 index 000000000..09fddc261 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42010.txt @@ -0,0 +1,2 @@ +* 修复了有关 Tor 支持的各种错误 +* 改进与 Dino 的通话兼容性 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42012.txt b/fastlane/metadata/android/zh-CN/changelogs/42012.txt new file mode 100644 index 000000000..9c15d9ceb --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42012.txt @@ -0,0 +1 @@ +* 修复不信任系统证书颁发机构的用户的 HTTP 上传/下载问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42013.txt b/fastlane/metadata/android/zh-CN/changelogs/42013.txt new file mode 100644 index 000000000..801cf42fb --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42013.txt @@ -0,0 +1 @@ +* 修复了 Android 7.1 上的“无连接”问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42014.txt b/fastlane/metadata/android/zh-CN/changelogs/42014.txt new file mode 100644 index 000000000..2784135e0 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42014.txt @@ -0,0 +1,2 @@ +* 始终验证域名。没有用户覆盖 +* 支持花名册预验证 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42015.txt b/fastlane/metadata/android/zh-CN/changelogs/42015.txt new file mode 100644 index 000000000..b9ce380d6 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42015.txt @@ -0,0 +1 @@ +* 在音频和视频方面略有改进 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42018.txt b/fastlane/metadata/android/zh-CN/changelogs/42018.txt new file mode 100644 index 000000000..974bd0292 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42018.txt @@ -0,0 +1,3 @@ +* 当远程视频与屏幕宽高比不匹配时显示黑条 +* 提高搜索性能 +* 添加防止截图的设置 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42022.txt b/fastlane/metadata/android/zh-CN/changelogs/42022.txt new file mode 100644 index 000000000..08c03bff7 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42022.txt @@ -0,0 +1,2 @@ +* 修复某些视频无法压缩的问题 +* 修复打开通知时罕见的崩溃问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42023.txt b/fastlane/metadata/android/zh-CN/changelogs/42023.txt new file mode 100644 index 000000000..e24d2373f --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42023.txt @@ -0,0 +1,2 @@ +* 修复渲染某些引用时的崩溃问题 +* 修复欢迎屏幕崩溃问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42037.txt b/fastlane/metadata/android/zh-CN/changelogs/42037.txt new file mode 100644 index 000000000..bde374945 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42037.txt @@ -0,0 +1,11 @@ +版本2.10.9 +* 进行音视频通话时请求蓝牙权限(如果您不使用蓝牙耳机可以拒绝) +* 修复呼叫 Movim 时的错误 +* 修复群组聊天的显示错误头像的问题 +* 始终要求选择退出电池优化 +* 在“x 个已连接账号”通知上设置仅本地标志 +* 修复与 Google 地图分享位置插件的交互 +* 移除有关服务器费用的脚注 +* 将文件存储在适合 Android 11 的位置 +* 网络切换后尝试重新连接通话 +* 在来电屏幕中显示来电者JID和帐户JID diff --git a/fastlane/metadata/android/zh-CN/changelogs/42038.txt b/fastlane/metadata/android/zh-CN/changelogs/42038.txt new file mode 100644 index 000000000..99c2ed862 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42038.txt @@ -0,0 +1,2 @@ +* 修正了一些小错误 +* 恢复通过 JMP 和其他服务呼叫的能力(Playstore 版本) diff --git a/fastlane/metadata/android/zh-CN/changelogs/42041.txt b/fastlane/metadata/android/zh-CN/changelogs/42041.txt new file mode 100644 index 000000000..b809c9854 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42041.txt @@ -0,0 +1,5 @@ +* 实施可扩展 SASL Profile、Bind 2.0 和 Fast,以加快重新连接速度 +* 实现频道绑定 +* 增加从音频通话切换到视频通话的功能 +* 增加删除自己头像的功能 +* 增加未接来电通知功能 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42042.txt b/fastlane/metadata/android/zh-CN/changelogs/42042.txt new file mode 100644 index 000000000..2c5975c38 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42042.txt @@ -0,0 +1,2 @@ +* 修复仅支持 sm:2 的服务器上的重发循环 +* 仅当对方支持视频时才显示“切换到视频” diff --git a/fastlane/metadata/android/zh-CN/changelogs/42043.txt b/fastlane/metadata/android/zh-CN/changelogs/42043.txt new file mode 100644 index 000000000..d830a7f32 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42043.txt @@ -0,0 +1 @@ +* 修复了 P2P 文件传输中的缺陷 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42044.txt b/fastlane/metadata/android/zh-CN/changelogs/42044.txt new file mode 100644 index 000000000..f0e54c458 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42044.txt @@ -0,0 +1,3 @@ +* 修复使用 SASL2 时重新发送消息的问题 +* 修复部分设备之间的黑屏问题 +* 修复空密码崩溃问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42046.txt b/fastlane/metadata/android/zh-CN/changelogs/42046.txt new file mode 100644 index 000000000..a4a5e4c1a --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42046.txt @@ -0,0 +1 @@ +* 集成 UnifiedPush 分发程序,以便将消息推送到其他支持 UnifiedPush 的应用程序,例如 Tusky 和 Fedilab diff --git a/fastlane/metadata/android/zh-CN/changelogs/42047.txt b/fastlane/metadata/android/zh-CN/changelogs/42047.txt new file mode 100644 index 000000000..c67da1843 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42047.txt @@ -0,0 +1 @@ +* 修复 UnifiedPush 分发程序中的崩溃问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42050.txt b/fastlane/metadata/android/zh-CN/changelogs/42050.txt new file mode 100644 index 000000000..28c946e81 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42050.txt @@ -0,0 +1 @@ +* 增加个人资料图片的圆角半径 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42059.txt b/fastlane/metadata/android/zh-CN/changelogs/42059.txt new file mode 100644 index 000000000..faeaa2ff1 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42059.txt @@ -0,0 +1,2 @@ +* 将 Target SDK 再次提升至 33 +* 修复支持 SASL2 且不支持内联流管理的服务器上的问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42060.txt b/fastlane/metadata/android/zh-CN/changelogs/42060.txt new file mode 100644 index 000000000..72a78b5cb --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42060.txt @@ -0,0 +1 @@ +* 修复“q”被错误识别为西里尔字母的问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42061.txt b/fastlane/metadata/android/zh-CN/changelogs/42061.txt new file mode 100644 index 000000000..4cf6ebea1 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42061.txt @@ -0,0 +1 @@ +* 从 Google Play 版本中移除频道发现功能 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42062.txt b/fastlane/metadata/android/zh-CN/changelogs/42062.txt new file mode 100644 index 000000000..e15f64892 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42062.txt @@ -0,0 +1 @@ +* 禁止从文件管理器打开备份文件(.ceb) diff --git a/fastlane/metadata/android/zh-CN/changelogs/42065.txt b/fastlane/metadata/android/zh-CN/changelogs/42065.txt new file mode 100644 index 000000000..419c47a53 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42065.txt @@ -0,0 +1 @@ +* 引入新的备份文件格式 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42068.txt b/fastlane/metadata/android/zh-CN/changelogs/42068.txt new file mode 100644 index 000000000..3c7020ba0 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42068.txt @@ -0,0 +1,2 @@ +* 支持每个对话通知设置 +* 在 Android 10 上使用 opus 发送语音消息 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42072.txt b/fastlane/metadata/android/zh-CN/changelogs/42072.txt new file mode 100644 index 000000000..481587e68 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42072.txt @@ -0,0 +1,3 @@ +* 将 libwebrtc 依赖项提升到 M117 并提升 libvpx +* 回到 AAC 语音消息 +* 支持每个应用程序语言设置 diff --git a/fastlane/metadata/android/zh-CN/changelogs/42074.txt b/fastlane/metadata/android/zh-CN/changelogs/42074.txt new file mode 100644 index 000000000..12acff938 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/42074.txt @@ -0,0 +1,3 @@ +* 支持私人 DNS(DNS over TLS) +* 支持主题启动器图标 +* 修复在 Android 11+ 分享文件时罕见的权限问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/4207704.txt b/fastlane/metadata/android/zh-CN/changelogs/4207704.txt new file mode 100644 index 000000000..4c33d6b99 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/4207704.txt @@ -0,0 +1,3 @@ +* 支持私人 DNS(DNS over TLS) +* 支持主题启动器图标 +* 修复在 Android 11+ 上分享文件时罕见的权限问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/4208104.txt b/fastlane/metadata/android/zh-CN/changelogs/4208104.txt new file mode 100644 index 000000000..7e5f80a13 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* 更容易访问“显示二维码” +* 支持 PEP Native Bookmarks +* 添加对 SDP 请求/响应模型的支持(由 SIP 网关使用) +* 将目标 API 提升到 Android 14 diff --git a/fastlane/metadata/android/zh-CN/changelogs/4208804.txt b/fastlane/metadata/android/zh-CN/changelogs/4208804.txt new file mode 100644 index 000000000..b35be34f5 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/4208804.txt @@ -0,0 +1,3 @@ +* 支持通过 WebRTC 数据通道进行 P2P 文件传输 +* 修复 ejabberd 上 Bind 2.0 的互操作性问题 +* 捆绑适用于 Android <= 7 的 Let’s Encrypt 根证书 diff --git a/fastlane/metadata/android/zh-CN/changelogs/4209004.txt b/fastlane/metadata/android/zh-CN/changelogs/4209004.txt new file mode 100644 index 000000000..da11d968f --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/4209004.txt @@ -0,0 +1,2 @@ +* 修正了一些小错误 +* Quicksy 板载流程略有修改 diff --git a/fastlane/metadata/android/zh-CN/changelogs/4209204.txt b/fastlane/metadata/android/zh-CN/changelogs/4209204.txt new file mode 100644 index 000000000..37c544ff2 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/4209204.txt @@ -0,0 +1,2 @@ +* 在 Play 商店版本(Quicksy 和 Conversations)上提供对“隐私政策”的更轻松访问 +* 移除 Play 商店版本的 Conversations 上的通讯录集成 diff --git a/fastlane/metadata/android/zh-TW/changelogs/349.txt b/fastlane/metadata/android/zh-TW/changelogs/349.txt new file mode 100644 index 000000000..af5136714 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/349.txt @@ -0,0 +1,4 @@ +* 引入專家設置,以在本地伺服器上執行通道發現,而非 search.jabber.network +* 默認啟用傳送檢查標記,並刪除相應設置 +* 默認啟用「發送按鈕顯示狀態」,並刪除相應設置 +* 將備份和前景服務設置移至主畫面 diff --git a/fastlane/metadata/android/zh-TW/changelogs/351.txt b/fastlane/metadata/android/zh-TW/changelogs/351.txt new file mode 100644 index 000000000..bf292b34a --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/351.txt @@ -0,0 +1,3 @@ +* 修復 Jingle IBB 檔案傳輸問題 +* 修復重複更正填充數據庫的問題 +* 切換至 Last Message Correction 版本 1.1 diff --git a/fastlane/metadata/android/zh-TW/changelogs/353.txt b/fastlane/metadata/android/zh-TW/changelogs/353.txt new file mode 100644 index 000000000..5aeb2bb42 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/353.txt @@ -0,0 +1,4 @@ +* 允許用戶設置自己的暱稱 +* 恢復下載 OMEMO 加密文件 +* 頻道頭像中現在使用 '#' 符號 +* Quicksy 將「總是」用作 OMEMO 加密的默認值(隱藏鎖定圖標) diff --git a/fastlane/metadata/android/zh-TW/changelogs/360.txt b/fastlane/metadata/android/zh-TW/changelogs/360.txt new file mode 100644 index 000000000..6fff27fea --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/360.txt @@ -0,0 +1 @@ +* 支援 ?register 和 ?register;preauth XMPP URI 參數 diff --git a/fastlane/metadata/android/zh-TW/changelogs/362.txt b/fastlane/metadata/android/zh-TW/changelogs/362.txt new file mode 100644 index 000000000..7919d5550 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/362.txt @@ -0,0 +1 @@ +在 Android 10 上支援自動主題切換 diff --git a/fastlane/metadata/android/zh-TW/changelogs/364.txt b/fastlane/metadata/android/zh-TW/changelogs/364.txt new file mode 100644 index 000000000..b46a5c3e2 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/364.txt @@ -0,0 +1,2 @@ +* 在 Android 5+ 上提供 PDF 預覽 +* 在 OMEMO 中使用 12 字節的 IV diff --git a/fastlane/metadata/android/zh-TW/changelogs/367.txt b/fastlane/metadata/android/zh-TW/changelogs/367.txt new file mode 100644 index 000000000..6ace96e8b --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/367.txt @@ -0,0 +1,2 @@ +* 修復在某些 Android 10 設備上的頭像選擇問題 +* 修復傳輸較大文件的問題 diff --git a/fastlane/metadata/android/zh-TW/changelogs/379.txt b/fastlane/metadata/android/zh-TW/changelogs/379.txt new file mode 100644 index 000000000..aae54ffb2 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/379.txt @@ -0,0 +1 @@ +* 音訊/視訊通話(需要伺服器支援,以 STUN 和 TURN 伺服器的形式通過 XEP-0215 可發現) diff --git a/fastlane/metadata/android/zh-TW/changelogs/381.txt b/fastlane/metadata/android/zh-TW/changelogs/381.txt new file mode 100644 index 000000000..c714a43f2 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/381.txt @@ -0,0 +1,2 @@ +* 語音通話的聲音回饋(撥號、通話開始、通話結束) +* 修復重試失敗的視訊通話問題 diff --git a/fastlane/metadata/android/zh-TW/changelogs/382.txt b/fastlane/metadata/android/zh-TW/changelogs/382.txt new file mode 100644 index 000000000..b026a4707 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/382.txt @@ -0,0 +1,2 @@ +* 新增在視訊通話中切換攝像頭的按鈕 +* 修復平板電腦上的語音通話問題 diff --git a/fastlane/metadata/android/zh-TW/changelogs/383.txt b/fastlane/metadata/android/zh-TW/changelogs/383.txt new file mode 100644 index 000000000..027684eed --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/383.txt @@ -0,0 +1,3 @@ +* 將通話圖標移至左側,以保持其他工具欄圖標的一致位置 +* 在語音通話期間顯示通話持續時間 +* 視訊/音訊通話的分開處理(同時互打電話的兩人的處理) diff --git a/fastlane/metadata/android/zh-TW/changelogs/387.txt b/fastlane/metadata/android/zh-TW/changelogs/387.txt new file mode 100644 index 000000000..38f22df14 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/387.txt @@ -0,0 +1,2 @@ +* 重做憑證登錄的使用者介面 +* 新增置頂聊天的功能(加入最愛) diff --git a/fastlane/metadata/android/zh-TW/changelogs/388.txt b/fastlane/metadata/android/zh-TW/changelogs/388.txt new file mode 100644 index 000000000..3ea17c53f --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/388.txt @@ -0,0 +1,3 @@ +* 在某些設備上減少通話中的回音 +* 修復當密碼包含特殊字符時的登錄問題 +* 在視訊通話期間在揚聲器上播放撥號和忙音 diff --git a/fastlane/metadata/android/zh-TW/changelogs/390.txt b/fastlane/metadata/android/zh-TW/changelogs/390.txt new file mode 100644 index 000000000..8452f3854 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/390.txt @@ -0,0 +1 @@ +* 在被呼叫方忙線時提供錄製語音訊息的選項 diff --git a/fastlane/metadata/android/zh-TW/changelogs/393.txt b/fastlane/metadata/android/zh-TW/changelogs/393.txt new file mode 100644 index 000000000..855909e6e --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/393.txt @@ -0,0 +1,3 @@ +* 如果音訊/視訊通話失敗,顯示求助按鈕 +* 修復一些令人困擾的崩潰問題 +* 修復使用裸 JIDs 的 Jingle 連接(檔案傳輸 + 通話) diff --git a/fastlane/metadata/android/zh-TW/changelogs/394.txt b/fastlane/metadata/android/zh-TW/changelogs/394.txt new file mode 100644 index 000000000..4e2764249 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/394.txt @@ -0,0 +1,2 @@ +* 修復在某些情況下通知未顯示的問題 +* 修復與音訊/視訊通話相關的相容性問題和崩潰 diff --git a/fastlane/metadata/android/zh-TW/changelogs/395.txt b/fastlane/metadata/android/zh-TW/changelogs/395.txt new file mode 100644 index 000000000..09ebd7faa --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/395.txt @@ -0,0 +1,3 @@ +* 在音訊通話畫面中新增「返回聊天」選項 +* 改進鍵盤快捷鍵 +* 修復錯誤 diff --git a/fastlane/metadata/android/zh-TW/changelogs/397.txt b/fastlane/metadata/android/zh-TW/changelogs/397.txt new file mode 100644 index 000000000..0575a7378 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/397.txt @@ -0,0 +1,3 @@ +* 處理 GPX 檔案 +* 改善備份與還原的效能 +* 錯誤修正 diff --git a/fastlane/metadata/android/zh-TW/changelogs/398.txt b/fastlane/metadata/android/zh-TW/changelogs/398.txt new file mode 100644 index 000000000..a728131a9 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/398.txt @@ -0,0 +1,4 @@ +* 搜尋個別對話 +* 如果消息未能傳遞,通知使用者 +* 在重新啟動後保留來自 Quicksy 使用者的顯示名稱(暱稱) +* 如果需要,新增從通知啟動 Orbot(洋蔥路由器)的按鈕 diff --git a/fastlane/metadata/android/zh-TW/changelogs/401.txt b/fastlane/metadata/android/zh-TW/changelogs/401.txt new file mode 100644 index 000000000..b24fec2cb --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/401.txt @@ -0,0 +1,2 @@ +* 修復在 Android <= 5 上的搜尋問題 +* 優化內存消耗 diff --git a/fastlane/metadata/android/zh-TW/changelogs/402.txt b/fastlane/metadata/android/zh-TW/changelogs/402.txt new file mode 100644 index 000000000..1897128a2 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/402.txt @@ -0,0 +1,3 @@ +* 在支援的伺服器上提供簡易邀請生成 +* 顯示從 Movim 發送的 GIF +* 將頭像存儲在快取中 diff --git a/fastlane/metadata/android/zh-TW/changelogs/403.txt b/fastlane/metadata/android/zh-TW/changelogs/403.txt new file mode 100644 index 000000000..e3e5e57f1 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/403.txt @@ -0,0 +1,3 @@ +* 修復使用不同 SCRAM 機制的不同帳戶時的連接問題 +* 新增對 SCRAM-SHA-512 的支援 +* 允許自己與自己進行 P2P(Jingle)文件傳輸 diff --git a/fastlane/metadata/android/zh-TW/changelogs/404.txt b/fastlane/metadata/android/zh-TW/changelogs/404.txt new file mode 100644 index 000000000..60d555681 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/404.txt @@ -0,0 +1 @@ +* 對音訊/視訊通話進行了小幅度的穩定性改進 diff --git a/fastlane/metadata/android/zh-TW/changelogs/405.txt b/fastlane/metadata/android/zh-TW/changelogs/405.txt new file mode 100644 index 000000000..189b5a041 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/405.txt @@ -0,0 +1 @@ +* Quicksy:自動接收驗證簡訊 diff --git a/fastlane/metadata/android/zh-TW/changelogs/407.txt b/fastlane/metadata/android/zh-TW/changelogs/407.txt new file mode 100644 index 000000000..bcc3aca98 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/407.txt @@ -0,0 +1,3 @@ +* 如果離線的聯絡人之前宣告支援,則顯示通話按鈕 +* 當通話已連接時,返回按鈕不再結束通話 +* 錯誤修復 diff --git a/fastlane/metadata/android/zh-TW/changelogs/42000.txt b/fastlane/metadata/android/zh-TW/changelogs/42000.txt new file mode 100644 index 000000000..3e4923271 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/42000.txt @@ -0,0 +1,4 @@ +* 可選擇來電鈴聲 +* 修復 開放金鑰匙圈 5.6+ 的 開放PGP 金鑰 ID 查找問題 +* 正確驗證 punycode TLS 證書 +* 改進 RTP 會話建立的穩定性(呼叫) diff --git a/fastlane/metadata/android/zh-TW/changelogs/42006.txt b/fastlane/metadata/android/zh-TW/changelogs/42006.txt new file mode 100644 index 000000000..d435a9bfb --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/42006.txt @@ -0,0 +1,2 @@ +* 使用預先存在的 OMEMO 會話驗證音訊/視訊通話 +* 改進與非 libwebrtc WebRTC 實現的相容性 diff --git a/fastlane/metadata/android/zh-TW/changelogs/42010.txt b/fastlane/metadata/android/zh-TW/changelogs/42010.txt new file mode 100644 index 000000000..2ff727219 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/42010.txt @@ -0,0 +1,2 @@ +* 針對 Tor 支援進行了各種錯誤修復 +* 改進與 Dino 的通話相容性 diff --git a/fastlane/metadata/android/zh-TW/changelogs/42012.txt b/fastlane/metadata/android/zh-TW/changelogs/42012.txt new file mode 100644 index 000000000..5e5b341fb --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/42012.txt @@ -0,0 +1 @@ +* 修復對不信任系統 CA 的用戶的 HTTP 上傳/下載問題 diff --git a/fastlane/metadata/android/zh-TW/changelogs/42013.txt b/fastlane/metadata/android/zh-TW/changelogs/42013.txt new file mode 100644 index 000000000..51d6c51b6 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/42013.txt @@ -0,0 +1 @@ +* 修復在 Android 7.1 上的「無連線」問題 diff --git a/fastlane/metadata/android/zh-TW/changelogs/42014.txt b/fastlane/metadata/android/zh-TW/changelogs/42014.txt new file mode 100644 index 000000000..f2106a3bd --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/42014.txt @@ -0,0 +1,2 @@ +* 總是驗證域名。不允許使用者覆寫 +* 支援花名冊預先驗證 diff --git a/fastlane/metadata/android/zh-TW/changelogs/42047.txt b/fastlane/metadata/android/zh-TW/changelogs/42047.txt new file mode 100644 index 000000000..a63fcf276 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/42047.txt @@ -0,0 +1 @@ +* 修正 UnifiedPush 散發者當機 diff --git a/fastlane/metadata/android/zh-TW/changelogs/42050.txt b/fastlane/metadata/android/zh-TW/changelogs/42050.txt new file mode 100644 index 000000000..5e10020c6 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/42050.txt @@ -0,0 +1 @@ +* 增加設定檔圖片的圓角半徑 diff --git a/gradle.properties b/gradle.properties index 431e485f2..b0323e6c6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,5 @@ android.useAndroidX=true android.enableJetifier=true +android.nonTransitiveRClass=true +android.nonFinalResIds=false org.gradle.jvmargs=-Xmx4096m diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9d41003b2..a2c87f5ef 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip diff --git a/proguard-rules.pro b/proguard-rules.pro index 7e4d7d31d..03044d525 100644 --- a/proguard-rules.pro +++ b/proguard-rules.pro @@ -64,7 +64,21 @@ -dontwarn retrofit2.KotlinExtensions -dontwarn retrofit2.KotlinExtensions$* + # With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy # and replaces all potential values with null. Explicitly keeping the interfaces prevents this. -if interface * { @retrofit2.http.* ; } -keep,allowobfuscation interface <1> + +# Keep inherited services. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface * extends <1> + +# With R8 full mode generic signatures are stripped for classes that are not +# kept. Suspend functions are wrapped in continuations where the type argument +# is used. +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +# R8 full mode strips generic signatures from return types if not kept. +-if interface * { @retrofit2.http.* public *** *(...); } +-keep,allowoptimization,allowshrinking,allowobfuscation class <3> diff --git a/src/conversations/AndroidManifest.xml b/src/conversations/AndroidManifest.xml index e1f8b6658..3817c0f3e 100644 --- a/src/conversations/AndroidManifest.xml +++ b/src/conversations/AndroidManifest.xml @@ -25,57 +25,8 @@ android:launchMode="singleTask" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:launchMode="singleTask" /> diff --git a/src/conversations/fastlane/metadata/android/da-DK/short_description.txt b/src/conversations/fastlane/metadata/android/da-DK/short_description.txt new file mode 100644 index 000000000..166643398 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/da-DK/short_description.txt @@ -0,0 +1 @@ +Krypteret, brugervenlig XMPP instant messenger til din mobile enhed diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/src/conversations/fastlane/metadata/android/de-DE/full_description.txt similarity index 100% rename from fastlane/metadata/android/de-DE/full_description.txt rename to src/conversations/fastlane/metadata/android/de-DE/full_description.txt diff --git a/fastlane/metadata/android/de-DE/short_description.txt b/src/conversations/fastlane/metadata/android/de-DE/short_description.txt similarity index 100% rename from fastlane/metadata/android/de-DE/short_description.txt rename to src/conversations/fastlane/metadata/android/de-DE/short_description.txt diff --git a/fastlane/metadata/android/en-US/full_description.txt b/src/conversations/fastlane/metadata/android/en-US/full_description.txt similarity index 100% rename from fastlane/metadata/android/en-US/full_description.txt rename to src/conversations/fastlane/metadata/android/en-US/full_description.txt diff --git a/src/conversations/fastlane/metadata/android/en-US/images/icon.png b/src/conversations/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 000000000..046dbfb3a Binary files /dev/null and b/src/conversations/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/src/conversations/fastlane/metadata/android/en-US/short_description.txt similarity index 100% rename from fastlane/metadata/android/en-US/short_description.txt rename to src/conversations/fastlane/metadata/android/en-US/short_description.txt diff --git a/src/conversations/fastlane/metadata/android/eo/short_description.txt b/src/conversations/fastlane/metadata/android/eo/short_description.txt new file mode 100644 index 000000000..514cca5bd --- /dev/null +++ b/src/conversations/fastlane/metadata/android/eo/short_description.txt @@ -0,0 +1 @@ +Ĉifrita, facile uzebla XMPP tujmesaĝilo por via poŝtelefono diff --git a/src/conversations/fastlane/metadata/android/es-ES/full_description.txt b/src/conversations/fastlane/metadata/android/es-ES/full_description.txt new file mode 100644 index 000000000..853549547 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/es-ES/full_description.txt @@ -0,0 +1,39 @@ +Fácil de usar, fiable y con poca batería. Con soporte integrado para imágenes, chats de grupo y cifrado e2e. + +Principios de diseño: + +* Ser lo más bonito y fácil de usar posible sin sacrificar la seguridad ni la privacidad. +* Basarse en protocolos existentes y bien establecidos. +* No requerir una cuenta de Google o, específicamente, Google Cloud Messaging (GCM). +* Requerir el menor número de permisos posible + +Características: + +* Cifrado de extremo a extremo con OMEMO o OpenPGP. +* Envío y recepción de imágenes +* Llamadas de audio y vídeo cifradas (DTLS-SRTP) +* Interfaz de usuario intuitiva que sigue las directrices de diseño de Android +* Imágenes / Avatares para tus contactos +* Sincronización con el cliente de escritorio +* Conferencias (con soporte para marcadores) +* Integración de la libreta de direcciones +* Múltiples cuentas / bandeja de entrada unificada +* Muy bajo impacto en la duración de la batería + +Conversations hace que sea muy fácil crear una cuenta en el servidor gratuito conversations.im. Sin embargo, Conversations también funciona con cualquier otro servidor XMPP. Muchos servidores XMPP están gestionados por voluntarios y son gratuitos. + +Características de XMPP: + +Conversations funciona con todos los servidores XMPP existentes. Sin embargo, XMPP es un protocolo extensible. Estas extensiones también están estandarizadas en los llamados XEP. Conversations soporta un par de ellas para mejorar la experiencia general del usuario. Existe la posibilidad de que su actual servidor XMPP no soporte estas extensiones. Por lo tanto, para sacar el máximo provecho de Conversaciones deberías considerar o bien cambiar a un servidor XMPP que lo haga o - mejor aún - ejecutar tu propio servidor XMPP para ti y tus amigos. + +Estos XEPs son (por el momento): + +* XEP-0065: SOCKS5 Bytestreams (o mod_proxy65). Se utilizará para transferir archivos si ambas partes están detrás de un cortafuegos (NAT). +* XEP-0163: Protocolo de Evento Personal para avatares +* XEP-0191: El comando de bloqueo te permite hacer una lista negra de spammers o bloquear contactos sin eliminarlos de tu lista. +* XEP-0198: Stream Management permite a XMPP sobrevivir a pequeños cortes de red y cambios de la conexión TCP subyacente. +* XEP-0280: Message Carbons que sincroniza automáticamente los mensajes que envías a tu cliente de escritorio y por lo tanto te permite cambiar sin problemas de tu cliente móvil a tu cliente de escritorio y viceversa en una sola conversación. +* XEP-0237: Versionado de listas, principalmente para ahorrar ancho de banda en conexiones móviles deficientes. +* XEP-0313: Gestión de Archivo de Mensajes sincroniza el historial de mensajes con el servidor. Ponerse al día con los mensajes que fueron enviados mientras Conversaciones estaba fuera de línea. +* XEP-0352: Indicación del Estado del Cliente permite al servidor saber si Conversaciones está o no en segundo plano. Permite al servidor ahorrar ancho de banda reteniendo paquetes sin importancia. +* XEP-0363: Carga de Archivos HTTP permite compartir archivos en conferencias y con contactos sin conexión. Requiere un componente adicional en su servidor. diff --git a/src/conversations/fastlane/metadata/android/es-ES/short_description.txt b/src/conversations/fastlane/metadata/android/es-ES/short_description.txt new file mode 100644 index 000000000..7ed047672 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/es-ES/short_description.txt @@ -0,0 +1 @@ +Mensajería instantánea XMPP cifrada y fácil de usar para tu dispositivo móvil diff --git a/src/conversations/fastlane/metadata/android/fi-FI/full_description.txt b/src/conversations/fastlane/metadata/android/fi-FI/full_description.txt new file mode 100644 index 000000000..8071c92d0 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/fi-FI/full_description.txt @@ -0,0 +1,39 @@ +Helppokäyttöinen, luotettava ja vähän akkua käyttävä. Sisäänrakennettu tuki kuville, ryhmille ja päästä päähän -salaukselle. + +Periaatteet: + +* Ole niin kaunis ja helppokäyttöinen kuin on mahdolista turvallisuudesta ja ykstyisyydestä tinkimättä +* Käytä valmiita ja vakiintuneita protokollia +* Älä riipu Google-tunnuksesta äläkä Google Cloud Messaging -palvelusta (GCM) +* Vaadi niin vähän käyttöoikeuksia kuin mahdollista + +Ominaisuudet: + +* Päästä päähän -salaus joko OMEMO:lla tai OpenPGP:llä +* Lähetä ja vastaanota kuvia +* Salatut ääni- ja videopuhelut (DTLS-SRTP) +* Intuitiivinen käyttöliittymä joka noudattaa Androidin muotoilukieltä +* Profiilikuvat yhteystiedoille +* Synkronoi työpöytäversion kanssa +* Konferenssit (kirjanmerkkituella) +* Osoitekirjaintegrointi +* Useampi tili yhdessä näkymässä +* Todella pieni akun kulutus + +Conversations:lla on helppo luoda tili conversations.im-palvelimella. Silti Conversations toimii myös minkä tahansa muun XMPP-palvelimen kanssa. Monia XMPP-palvelimia ylläpidetään ilmaiseksi vapaaehtoisvoimin. + +XMPP-ominaisuudet: + +Conversations toimii kaikkien XMPP-palvelinten kanssa. XMPP on kuitenkin laajennettava protokolla. Nämä laajennukset on standardoitu niin kutsuttuina XEP:inä. Conversations tukee muutamaa näistä tehdäkseen käyttäjäkokemuksesta paremman. On mahdollista että nykyinen XMPP-palvelimesi ei tue kaikkia näitä laajennoksia. Siispä saadaksesi kaiken ilon irti Conversationsista kannattaa harkita joko sellaiseen palvelimeen, joka tukee näitä, vaihtamista tai oman XMPP-palvelimen ylläpitämistä itsellesi ja kavereillesi. + +XEP:t ovat tällä hetkellä: + +* XEP-0065: SOCKS5 Bytestreams (tai mod_proxy65). Käytetään tiedostojen siirtoon jos molemmat osapuolet ovat palomuurin tai NAT:n takana. +* XEP-0163: Personal Eventing Protocol profiilikuville +* XEP-0191: Blocking command lets you blacklist spammers or block contacts without removing them from your roster. +* XEP-0198: Stream Management mahdollistaa XMPP:n selviämisen pienestä verkon pätkimisestä ja TCP-yhteyden muutoksista. +* XEP-0280: Kopiot lähettämistäsi viesteistä muille laitteillesi. Mahdolistaa laitteiden vaihdon kesken keskustelun täysin saumoitta. +* XEP-0237: Roster Versioning säästää dataa heikoila yhteyksillä +* 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/src/conversations/fastlane/metadata/android/fi-FI/short_description.txt b/src/conversations/fastlane/metadata/android/fi-FI/short_description.txt new file mode 100644 index 000000000..2713b6efd --- /dev/null +++ b/src/conversations/fastlane/metadata/android/fi-FI/short_description.txt @@ -0,0 +1 @@ +Salattu ja helppokäyttöinen XMPP-pikaviestin mobiililaitteellesi diff --git a/src/conversations/fastlane/metadata/android/gl-ES/full_description.txt b/src/conversations/fastlane/metadata/android/gl-ES/full_description.txt new file mode 100644 index 000000000..9bdcf9042 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/gl-ES/full_description.txt @@ -0,0 +1,40 @@ +Fácil de usar, fiable, baixo consumo de batería. Con soporte para imaxes, conversas en grupo e cifraxe e2e. + +Principios do deseño: + +* Ser tan fermosa e doada de usar como sexa posible sen sacrificar a seguridade ou privacidade +* Apoiarse en protocolos existentes e ben establecidos +* Non precisar dunha Conta de Google ou concretamente Google Cloud Messaging (GCM) +* Solicitar os mínimos permisos posibles + +Características: + +* Cifraxe extremo-a-extremo, ben con OMEMO ou con OpenPGP +* Enviar e recibir imaxes +* Chamadas de audio e vídeo cifradas (DTLS-SRTP) +* Interface intuitiva seguindo as recomendacións Android Design +* Imaxes/Avatares para os Contactos +* Sicronizada co cliente de escritorio +* Conferencias (con soporte para marcadores) +* Integración coa Libreta de enderezos +* Varias contas cunha lista de conversas unificada +* Consumo de enerxía moi baixo + +Con Conversations é moi doado crear unha conta no servidor gratuíto conversations.im. Con todo, Conversations funcionará igualmente con calquera outro servidor XMPP. Existen moitos servidores XMPP xestionados por voluntarios e gratuítos. + +Características de XMPP: + +Conversations funciona con calquera sevidor XMPP, mais XMPP é un protocolo extensible. Estas extensións tamén están estadarizadas nos chamados XEP's. +Conversations da soporte a un par delas que axudan a mellorar a experiencia de uso da aplicación. Pode acontecer que o teu servidor XMPP actual non dé soporte para estas extensións. Por tanto para obter o mellor resultado ao usar Conversations debes ter considerar usar un servidor XMPP que si o faga - ou incluso mellor - xestionar o teu propio servidor para as túas amizades. + +Estes XEPs son - neste intre: + +* XEP-0065: SOCKS5 Bytestreams (ou mod_proxy65). Usado para a transferencia de ficheiros se as dúas partes están detrás dun cortalumes (NAT). +* XEP-0163: Personal Eventing Protocol para os avatares +* XEP-0191: O bloqueo de ordes permiteche bloquear spammer ou contactos sen eliminalos das túas listaxes. +* XEP-0198: Stream Management permite que XMPP sobreviva a caídas da rede e cambios na conexión TCP. +* XEP-0280: Message Carbons permite sincronizar automáticamente as mensaxes co teu cliente de escritorio e por tanto cambiar dun a outro sen perder mensaxes da conversa. +* XEP-0237: Roster Versioning fundamentalmente para aforrar datos en conexións móbiles +* XEP-0313: Message Archive Management sincroniza o historial de mensaxes co servidor. Para obter as mensaxes recibidas cando Conversations non teña conexión. +* XEP-0352: Client State Indication permítelle ao servidor saber se Conversations está a funcionar en segundo plano. Permítelle ao servidor aforrar ancho de banda retendo paquetes de datos de pouca importancia. +* XEP-0363: HTTP File Upload permíteche compartir ficheiros en salas de conferencia e con contactos que non están conectados. Require un compoñente adicional no teu servidor. diff --git a/src/conversations/fastlane/metadata/android/gl-ES/short_description.txt b/src/conversations/fastlane/metadata/android/gl-ES/short_description.txt new file mode 100644 index 000000000..79c77166e --- /dev/null +++ b/src/conversations/fastlane/metadata/android/gl-ES/short_description.txt @@ -0,0 +1 @@ +Mensaxería instantánea XMPP cifrada e fácil de usar para o teu dispositivo móbil diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/src/conversations/fastlane/metadata/android/it-IT/full_description.txt similarity index 100% rename from fastlane/metadata/android/it-IT/full_description.txt rename to src/conversations/fastlane/metadata/android/it-IT/full_description.txt diff --git a/fastlane/metadata/android/it-IT/short_description.txt b/src/conversations/fastlane/metadata/android/it-IT/short_description.txt similarity index 100% rename from fastlane/metadata/android/it-IT/short_description.txt rename to src/conversations/fastlane/metadata/android/it-IT/short_description.txt diff --git a/fastlane/metadata/android/pl-PL/full_description.txt b/src/conversations/fastlane/metadata/android/pl-PL/full_description.txt similarity index 100% rename from fastlane/metadata/android/pl-PL/full_description.txt rename to src/conversations/fastlane/metadata/android/pl-PL/full_description.txt diff --git a/fastlane/metadata/android/pl-PL/short_description.txt b/src/conversations/fastlane/metadata/android/pl-PL/short_description.txt similarity index 100% rename from fastlane/metadata/android/pl-PL/short_description.txt rename to src/conversations/fastlane/metadata/android/pl-PL/short_description.txt diff --git a/src/conversations/fastlane/metadata/android/ro/full_description.txt b/src/conversations/fastlane/metadata/android/ro/full_description.txt new file mode 100644 index 000000000..2d4ae419d --- /dev/null +++ b/src/conversations/fastlane/metadata/android/ro/full_description.txt @@ -0,0 +1,38 @@ +Ușor de utilizat, fiabil, prietenos cu bateria. Cu suport încorporat pentru imagini, discuții de grup și criptare E2E. + +Principii de proiectare: + +* Să fie cât mai frumos și mai ușor de utilizat posibil, fără a sacrifica securitatea sau confidențialitatea. +* Să se bazeze pe protocoale existente și bine stabilite +* Nu necesită un cont Google sau în mod specific Google Cloud Messaging (GCM). +* Să necesite cât mai puține permisiuni posibil + +Caracteristici: + +* Criptare de la un capăt-la-altul (E2E) cu OMEMO sau OpenPGP +* Trimiterea și primirea de imagini +* Apeluri audio și video criptate (DTLS-SRTP) +* Interfață intuitivă care respectă liniile directoare Android Design +* Imagini / Avataruri pentru contactele dvs. +* Se sincronizează cu clientul desktop +* Conferințe (cu suport pentru marcaje) +* Integrare cu lista de contacte +* Conturi multiple / căsuță de mesaje unificată +* Impact foarte redus asupra duratei de viață a bateriei + +Conversations face foarte ușoară crearea unui cont pe serverul gratuit conversations.im. Cu toate acestea, Conversations va funcționa și cu orice alt server XMPP. O mulțime de servere XMPP sunt administrate de voluntari și sunt gratuite. + +Caracteristici XMPP: + +Conversations funcționează cu orice server XMPP existent. Cu toate acestea, XMPP este un protocol extensibil. Aceste extensii sunt, de asemenea, standardizate în așa-numitele XEP-uri. Conversations suportă câteva dintre acestea pentru a îmbunătăți experiența generală a utilizatorului. Există o șansă ca serverul XMPP actual să nu suporte aceste extensii. Prin urmare, pentru a profita la maximum de Conversations, ar trebui să luați în considerare fie trecerea la un server XMPP care să suporte aceste extensii, fie - și mai bine - să rulați propriul server XMPP pentru dumneavoastră și prietenii dumneavoastră. + +Aceste XEP-uri sunt - deocamdată: +* XEP-0065: SOCKS5 Bytestreams (sau mod_proxy65). Va fi utilizat pentru a transfera fișiere dacă ambele părți se află în spatele unui firewall (NAT). +* XEP-0163: Protocol de evenimente personale pentru avatare. +* XEP-0191: Comanda de blocare vă permite să puneți pe lista neagră spamerii sau să blocați contactele fără a le elimina din listă. +* XEP-0198: Stream Management permite XMPP să supraviețuiască unor mici întreruperi de rețea și schimbărilor conexiunii TCP de bază. +* XEP-0280: Message Carbons, care sincronizează automat mesajele pe care le trimiteți în clientul desktop și vă permite astfel să treceți fără probleme de la clientul mobil la clientul desktop și înapoi în cadrul unei singure conversații. +* XEP-0237: Roster Versioning în principal pentru a economisi lățimea de bandă în cazul conexiunilor mobile slabe +* XEP-0313: Gestionarea arhivei de mesaje sincronizează istoricul mesajelor cu serverul. Recuperați mesajele care au fost trimise în timp ce Conversations era deconectat. +* XEP-0352: Client State Indication permite serverului să știe dacă Conversations este sau nu în fundal. Permite serverului să economisească lățimea de bandă prin reținerea pachetelor neimportante. +* XEP-0363: HTTP File Upload vă permite să partajați fișiere în cadrul conferințelor și cu contactele deconectate. Necesită o componentă suplimentară pe serverul dumneavoastră. diff --git a/fastlane/metadata/android/ro/short_description.txt b/src/conversations/fastlane/metadata/android/ro/short_description.txt similarity index 100% rename from fastlane/metadata/android/ro/short_description.txt rename to src/conversations/fastlane/metadata/android/ro/short_description.txt diff --git a/fastlane/metadata/android/sq/full_description.txt b/src/conversations/fastlane/metadata/android/sq/full_description.txt similarity index 100% rename from fastlane/metadata/android/sq/full_description.txt rename to src/conversations/fastlane/metadata/android/sq/full_description.txt diff --git a/fastlane/metadata/android/sq/short_description.txt b/src/conversations/fastlane/metadata/android/sq/short_description.txt similarity index 100% rename from fastlane/metadata/android/sq/short_description.txt rename to src/conversations/fastlane/metadata/android/sq/short_description.txt diff --git a/src/conversations/fastlane/metadata/android/sv-SE/full_description.txt b/src/conversations/fastlane/metadata/android/sv-SE/full_description.txt new file mode 100644 index 000000000..c02bd4912 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/sv-SE/full_description.txt @@ -0,0 +1,39 @@ +Lättanvänd, pålitlig och batterivänlig. Med inbyggt stöd för bilder, gruppchatt och totalsträckskryptering (end-to-end-kryptering). + +Designprinciper: + +* Vara så snygg och lättanvänd som möjligt utan att offra säkerhet eller personlig integritet +* Bygga på väletablerade existerande protokoll +* Inte kräva ett Google-konto eller specifikt Google Cloud Messaging (GCM) +* Kräva så få behörigheter som möjligt + +Funktioner: + +* Totalsträckskryptering (end-to-end-kryptering) med antingen OMEMO eller OpenPGP +* Skicka och ta emot bilder +* Krypterade ljud- och bildsamtal (DTLS-SRTP) +* Intuitivt användargränssnitt som följder Androids designriktlinjer +* Bilder eller avatarer för dina kontakter +* Synkroniserar med din skrivbordsklient +* Konferenser (med stöd för bokmärken) +* Integration med adressboken +* Stöd för flera konton, med delan inkorg +* Väldigt liten påverkan på batteriets livstid + +Med Conversations kan du lätt skapa ett konto på den fria servern conversations.im. Men Conversations fungerar med vilken annan XMPP-server som helst. Många XMPP-servrar drivs av volontärer och är gratis att använda. + +XMPP-funktioner: + +Conversations fungerar med alla XMPP-servrar. Men XMPP är ett utbyggbart protokoll. Dessa tillägg är också standardiserade i så kallade XEP’s. Conversations stödjer vissa av dessa tillägg för att göra den övergripande användarupplevelsen bättre. Det kan hända att din XMPP-server inte har stöd för dessa tillägg. För att få ut det mesta av Conversations bör du överväga att antingen byta till en XMPP-server som har stöd, eller - ännu bättre - kör din egen XMPP-server för dig och dina vänner! + +De XEP-tillägg som stöds är: + +* XEP-0065: SOCKS5 Bytestreams (or mod_proxy65). Används för filöverföring om båda parter är bakom en brandvägg (NAT). +* XEP-0163: Personal Eventing Protocol för avatarer +* XEP-0191: Blocking command låter dig svartlista spammare eller blocka kontakter utan att ta bort dem +* XEP-0198: Stream Management låter XMPP att klara av mindre nätverksavbrott och förändringar i den underliggande TCP-anslutningen +* XEP-0280: Message Carbons som automatiskt synkar meddelanden till din skrivbordsklient och på så viss gör det möjligt att växla sömlöst från din mobil till skrivbordsklient och tillbaka inom en och samma konversation +* XEP-0237: Roster Versioning för att spara bandbredd vid dåliga mobilanslutningar +* XEP-0313: Message Archive Management synkronisera meddelandehistorik med server. Läs meddelanden som sänts medan Conversations var off line. +* XEP-0352: Client State Indication låter servern veta om Conversations är körs i bakgrunden eller inte. Det gör att servern kan spara bandbredd genom att inte skicka oviktiga paket. +* XEP-0363: HTTP File Upload låter dig dela filer i konferenser med offline-kontakter. Det kräver ett tillägg på din server. diff --git a/src/conversations/fastlane/metadata/android/sv-SE/short_description.txt b/src/conversations/fastlane/metadata/android/sv-SE/short_description.txt new file mode 100644 index 000000000..0177c6fe2 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/sv-SE/short_description.txt @@ -0,0 +1 @@ +Krypterad lättanvänd XMPP-meddelandeapp för din mobil diff --git a/src/conversations/fastlane/metadata/android/uk/full_description.txt b/src/conversations/fastlane/metadata/android/uk/full_description.txt new file mode 100644 index 000000000..39971ed55 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/uk/full_description.txt @@ -0,0 +1,39 @@ +Надійний, простий у використанні, ощадливо витрачає заряд акумулятора. Має вбудовану підтримку зображень, групових чатів і наскрізного шифрування. + +Принципи проєктування: + +* Бути максимально красивим та простим у використанні, не жертвуючи безпекою чи конфіденційністю +* Покладатися на існуючі, добре встановлені протоколи +* Не вимагати облікового запису Google, зокрема Google Cloud Messaging (GCM) +* Вимагати якомога менше дозволів + +Функції: + +* Наскрізне шифрування (від відправника до одержувача) за допомогою OMEMO або OpenPGP +* Надсилання та отримання зображень +* Зашифровані голосові та відеодзвінки (DTLS-SRTP) +* Інтуїтивно зрозумілий інтерфейс користувача, який відповідає вказівкам Android Design +* Зображення / Аватари для Ваших контактів +* Синхронізація з настільним клієнтом +* Конференції (з підтримкою закладок) +* Інтеграція адресної книги +* Кілька облікових записів / єдина папка вхідних +* Дуже низький вплив на термін служби акумулятора + +Conversations дозволяє легко створити обліковий запис на безкоштовному сервері conversations.im. Однак Conversations працюватиме також із будь-яким іншим XMPP-сервером. Чимало серверів XMPP обслуговуються волонтерами і є безкоштовними. + +Функції XMPP: + +Conversations працює з будь-яким сервером XMPP. Проте XMPP — розширюваний протокол. Розширення також стандартизовані в так званих XEP. Conversations підтримує кілька з них, щоб покращити загальний досвід користування. Може виявитися, що Ваш поточний сервер XMPP не підтримує цих розширень. Тому, щоб отримати максимум від Conversations, розгляньте перехід на XMPP-сервер з підтримкою цих розширень або — ще краще — запускайте власний сервер XMPP для себе і своїх друзів. + +На даний час підтримуються такі XEP: + +* XEP-0065: SOCKS5 Bytestreams (або mod_proxy65). Використовується для передачі файлів, якщо обидві сторони знаходяться за брандмауером (NAT). +* XEP-0163: персональний протокол подій для аватарів +* XEP-0191: команда блокування дозволяє Вам заносити спамерів у чорний список або блокувати контакти, не видаляючи їх зі свого списку. +* XEP-0198: керування потоками дозволяє XMPP витримувати невеликі перебої в мережі та зміни основного TCP-з'єднання. +* XEP-0280: Message Carbons, який автоматично синхронізує повідомлення, які Ви надсилаєте, на настільний клієнт і, таким чином, дозволяє плавно переключатися з мобільного клієнта на клієнт для настільного ПК і назад протягом однієї розмови. +* XEP-0237: версія списку в основному для економії пропускної здатності при поганих мобільних з'єднаннях +* XEP-0313: керування архівом повідомлень синхронізує історію повідомлень із сервером. Дізнавайтеся про повідомлення, надіслані, поки Conversations був офлайн. +* XEP-0352: індикація стану клієнта повідомляє серверу, чи працює Conversations у фоновому режимі. Дозволяє серверу заощаджувати пропускну здатність, утримуючи неважливі пакети. +* XEP-0363: завантаження файлів HTTP дозволяє обмінюватися файлами в конференціях і з офлайн-контактами. Потрібен додатковий компонент на Вашому сервері. diff --git a/src/conversations/fastlane/metadata/android/uk/short_description.txt b/src/conversations/fastlane/metadata/android/uk/short_description.txt new file mode 100644 index 000000000..300b89277 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/uk/short_description.txt @@ -0,0 +1 @@ +Простий у використанні XMPP-клієнт з підтримкою шифрування для Вашого телефона diff --git a/src/conversations/fastlane/metadata/android/zh-CN/full_description.txt b/src/conversations/fastlane/metadata/android/zh-CN/full_description.txt new file mode 100644 index 000000000..14b7bc66b --- /dev/null +++ b/src/conversations/fastlane/metadata/android/zh-CN/full_description.txt @@ -0,0 +1,39 @@ +易于使用、性能可靠、电池友好。内置支持图片、群聊和 e2e 加密功能。 + +设计原则: + +* 在不牺牲安全性和隐私性的前提下,尽可能美观易用 +* 依赖现有的、完善的协议 +* 不需要 Google 账号或特定的 Google 云通讯服务(GCM) +* 要求尽可能少的权限 + +特点: + +* 使用 OMEMOOpenPGP 进行端对端加密 +* 发送和接收图片 +* 加密音视频通话(DTLS-SRTP) +* 直观的用户界面,遵循 Android 设计准则 +* 为您的联系人添加图片/头像 +* 与桌面客户端同步 +* 群聊(支持书签功能) +* 通讯录集成 +* 多账号/统一消息栏 +* 对电池寿命的影响非常小 + +Conversations 使在免费的 conversations.im 服务器上创建账号变得非常简单。不过,Conversations 也适用于任何其他 XMPP 服务器。许多 XMPP 服务器都是由志愿者免费运行的。 + +XMPP 功能: + +Conversations 适用于所有 XMPP 服务器。然而,XMPP 是一种可扩展的协议。这些扩展在所谓的 XEP 中也是标准化的。Conversations 支持其中的一些扩展,以使整体用户体验更好。有一种可能是您当前的 XMPP 服务器不支持这些扩展。因此,要想充分使用 Conversations 的功能,您应该考虑切换到支持这些扩展的 XMPP 服务器,甚至有更好的方式,或者为您和您的朋友运行自己的 XMPP 服务器。 + +到目前为止,这些 XEP 是: + +* XEP-0065:SOCKS5 字节流(或 mod_proxy65)。如果双方都在防火墙(NAT)后面,将用于传输文件。 +* XEP-0163:个人事件协议(头像) +* XEP-0191:屏蔽命令可让您将垃圾消息发送者列入黑名单或屏蔽的联系人中,而不会将其从花名册中删除。 +* XEP-0198:流管理允许 XMPP 在小规模网络中断和底层 TCP 连接发生变化时继续运行。 +* XEP-0280:消息抄送,可自动将您发送的消息同步到桌面客户端,因此您可以在一次对话中从手机客户端无缝切换到桌面客户端,然后再返回。 +* XEP-0237:花名册版本控制主要是为了在移动连接不佳的情况下节省带宽 +* XEP-0313:消息存档管理与服务器同步消息历史记录。补发 Conversations 离线时发送的消息。 +* XEP-0352:客户端状态指示让服务器知道 Conversations 是否在后台。允许服务器保留不重要的数据包,从而节省带宽。 +* XEP-0363:通过 HTTP 文件上传功能,您可以在群聊中与离线联系人分享文件。需要在服务器上安装额外组件。 diff --git a/src/conversations/fastlane/metadata/android/zh-CN/short_description.txt b/src/conversations/fastlane/metadata/android/zh-CN/short_description.txt new file mode 100644 index 000000000..913d97480 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/zh-CN/short_description.txt @@ -0,0 +1 @@ +加密、易于使用的 XMPP 即时通讯软件,适用于您的移动设备 diff --git a/src/conversations/fastlane/metadata/android/zh-TW/full_description.txt b/src/conversations/fastlane/metadata/android/zh-TW/full_description.txt new file mode 100644 index 000000000..0e9114c98 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/zh-TW/full_description.txt @@ -0,0 +1,39 @@ +易於使用、可靠、省電,且帶有內建圖像支援、群組聊天和端對端加密的 XMPP 用戶端。 + +設計原則: + +* 在不犧牲安全或隱私權的前提下,盡可能地保持美觀性和易用性 +* 仰賴現存的、已建立的通訊協定 +* 不需要 Google 帳戶或特別的 Google 雲端訊息 (GCM) +* 需要少量可能的權限 + +功能: + +* OMEMOOpenPGP 端對端加密 +* 傳送並接收圖像 +* 加密的音訊和視訊通話 (DTLS-SRTP) +* 依循 Android 設計指南的直覺化 UI +* 為您的聯絡人顯示圖片/ 頭像 +* 與桌面用戶端同步 +* 會議 (書籤支援) +* 通訊錄整合 +* 多個帳戶/整合收件匣 +* 對電池壽命的極低影響 + +Conversations 使在免費的 conversations.im 伺服器上建立一個帳戶變得極為輕易。然而 Conversations 也可在其他 XMPP 伺服器上運作,很多 XMPP 伺服器是由志工驅動的,並且完全免費。 + +XMPP 功能: + +Conversations 可以在所有 XMPP 伺服器上運作。然而,XMPP 是一個可以擴充的通訊協定,這些擴充功能在所謂的 XEP 中也是標準化的。Conversations 支援其中的幾個,已使使用者體驗更佳。有可能您目前的 XMPP 伺服器並不支援這些擴充功能,因此,為了最大限度的發揮 Conversations 的作用,您應該考慮切換到一個支援這些擴充功能的 XMPP 伺服器,或者甚至更好——為您和您的朋友驅動您自己的 XMPP 伺服器。 + +如下 XEP - 截止目前: + +* XEP-0065:SOCKS5 位元資料流 (或 mod_proxy65),將被用於傳輸檔案,如果雙方都在防火牆之後 (NAT)。 +* XEP-0163:用於虛擬化身的私人活動通訊協定 +* XEP-0191:封鎖命令可讓您將濫發垃圾郵件者列入黑名單,或封鎖聯絡人而不把他們從名冊中移除。 +* XEP-0198:串流管理允許 XMPP 在小型網路中斷和基礎 TCP 連線的變更中生存。 +* XEP-0280:訊息副本,自動將您傳送的訊息同步至桌面用戶端,從而允許您在一次會話中從您的行動用戶端無縫切換到您的桌面用戶端。 +* XEP-0237:名冊版本管理,主要是為了節省行動連線不佳時的頻寬。 +* XEP-0313:訊息封存管理將訊息記錄與伺服器同步,隨時掌握離線傳送的訊息。 +* XEP-0352:用戶端狀態指示可讓伺服器知道 Conversations 是否在背景,允許伺服器透過扣留不必要的封裝來節省頻寬。 +* XEP-0363:HTTP 檔案上傳允許您在會議中或與離線聯絡人分享檔案,需要在您的伺服器上有一個額外的元件。 diff --git a/src/conversations/fastlane/metadata/android/zh-TW/short_description.txt b/src/conversations/fastlane/metadata/android/zh-TW/short_description.txt new file mode 100644 index 000000000..06e14ddbe --- /dev/null +++ b/src/conversations/fastlane/metadata/android/zh-TW/short_description.txt @@ -0,0 +1 @@ +可加密、易於使用的 XMPP 即時訊息,為您的行動裝置設計 diff --git a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java index c118d7375..377070941 100644 --- a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java +++ b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java @@ -6,6 +6,7 @@ import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; +import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.database.Cursor; @@ -22,6 +23,21 @@ import androidx.core.app.NotificationManagerCompat; import com.google.common.base.Charsets; import com.google.common.base.Stopwatch; import com.google.common.io.CountingInputStream; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.persistance.DatabaseBackend; +import eu.siacs.conversations.persistance.FileBackend; +import eu.siacs.conversations.ui.ManageAccountActivity; +import eu.siacs.conversations.utils.BackupFileHeader; +import eu.siacs.conversations.utils.SerialSingleThreadExecutor; +import eu.siacs.conversations.xmpp.Jid; import org.bouncycastle.crypto.engines.AESEngine; import org.bouncycastle.crypto.io.CipherInputStream; @@ -40,50 +56,47 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; import java.util.zip.GZIPInputStream; import java.util.zip.ZipException; import javax.crypto.BadPaddingException; -import eu.siacs.conversations.Config; -import eu.siacs.conversations.R; -import eu.siacs.conversations.persistance.DatabaseBackend; -import eu.siacs.conversations.persistance.FileBackend; -import eu.siacs.conversations.ui.ManageAccountActivity; -import eu.siacs.conversations.utils.BackupFileHeader; -import eu.siacs.conversations.utils.SerialSingleThreadExecutor; -import eu.siacs.conversations.xmpp.Jid; - public class ImportBackupService extends Service { private static final int NOTIFICATION_ID = 21; private static final AtomicBoolean running = new AtomicBoolean(false); private final ImportBackupServiceBinder binder = new ImportBackupServiceBinder(); - private final SerialSingleThreadExecutor executor = new SerialSingleThreadExecutor(getClass().getSimpleName()); - private final Set mOnBackupProcessedListeners = Collections.newSetFromMap(new WeakHashMap<>()); + private final SerialSingleThreadExecutor executor = + new SerialSingleThreadExecutor(getClass().getSimpleName()); + private final Set mOnBackupProcessedListeners = + Collections.newSetFromMap(new WeakHashMap<>()); private DatabaseBackend mDatabaseBackend; private NotificationManager notificationManager; - private static int count(String input, char c) { - int count = 0; - for (char aChar : input.toCharArray()) { - if (aChar == c) { - ++count; - } - } - return count; - } + private static final Collection TABLE_ALLOW_LIST = + Arrays.asList( + Account.TABLENAME, + Conversation.TABLENAME, + Message.TABLENAME, + SQLiteAxolotlStore.PREKEY_TABLENAME, + SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, + SQLiteAxolotlStore.SESSION_TABLENAME, + SQLiteAxolotlStore.IDENTITIES_TABLENAME); + private static final Pattern COLUMN_PATTERN = Pattern.compile("^[a-zA-Z_]+$"); @Override public void onCreate() { mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext()); - notificationManager = (android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager = + (android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); } @Override @@ -105,16 +118,17 @@ public class ImportBackupService extends Service { return START_NOT_STICKY; } if (running.compareAndSet(false, true)) { - executor.execute(() -> { - startForegroundService(); - final boolean success = importBackup(uri, password); - stopForeground(true); - running.set(false); - if (success) { - notifySuccess(); - } - stopSelf(); - }); + executor.execute( + () -> { + startForegroundService(); + final boolean success = importBackup(uri, password); + stopForeground(true); + running.set(false); + if (success) { + notifySuccess(); + } + stopSelf(); + }); } else { Log.d(Config.LOGTAG, "backup already running"); } @@ -126,42 +140,62 @@ public class ImportBackupService extends Service { } public void loadBackupFiles(final OnBackupFilesLoaded onBackupFilesLoaded) { - executor.execute(() -> { - final List accounts = mDatabaseBackend.getAccountJids(false); - final ArrayList backupFiles = new ArrayList<>(); - final Set apps = new HashSet<>(Arrays.asList("Conversations", "Quicksy", getString(R.string.app_name))); - final List directories = new ArrayList<>(); - for (final String app : apps) { - directories.add(FileBackend.getLegacyBackupDirectory(app)); - } - directories.add(FileBackend.getBackupDirectory(this)); - for (final File directory : directories) { - if (!directory.exists() || !directory.isDirectory()) { - Log.d(Config.LOGTAG, "directory not found: " + directory.getAbsolutePath()); - continue; - } - final File[] files = directory.listFiles(); - if (files == null) { - continue; - } - for (final File file : files) { - if (file.isFile() && file.getName().endsWith(".ceb")) { - try { - final BackupFile backupFile = BackupFile.read(file); - if (accounts.contains(backupFile.getHeader().getJid())) { - Log.d(Config.LOGTAG, "skipping backup for " + backupFile.getHeader().getJid()); - } else { - backupFiles.add(backupFile); + executor.execute( + () -> { + final List accounts = mDatabaseBackend.getAccountJids(false); + final ArrayList backupFiles = new ArrayList<>(); + final Set apps = + new HashSet<>( + Arrays.asList( + "Conversations", + "Quicksy", + getString(R.string.app_name))); + final List directories = new ArrayList<>(); + for (final String app : apps) { + directories.add(FileBackend.getLegacyBackupDirectory(app)); + } + directories.add(FileBackend.getBackupDirectory(this)); + for (final File directory : directories) { + if (!directory.exists() || !directory.isDirectory()) { + Log.d( + Config.LOGTAG, + "directory not found: " + directory.getAbsolutePath()); + continue; + } + final File[] files = directory.listFiles(); + if (files == null) { + continue; + } + Log.d(Config.LOGTAG, "looking for backups in " + directory); + for (final File file : files) { + if (file.isFile() && file.getName().endsWith(".ceb")) { + try { + final BackupFile backupFile = BackupFile.read(file); + if (accounts.contains(backupFile.getHeader().getJid())) { + Log.d( + Config.LOGTAG, + "skipping backup for " + + backupFile.getHeader().getJid()); + } else { + backupFiles.add(backupFile); + } + } catch (final IOException + | IllegalArgumentException + | BackupFileHeader.OutdatedBackupFileVersion e) { + Log.d(Config.LOGTAG, "unable to read backup file ", e); + } } - } catch (IOException | IllegalArgumentException e) { - Log.d(Config.LOGTAG, "unable to read backup file ", e); } } - } - } - Collections.sort(backupFiles, (a, b) -> a.header.getJid().toString().compareTo(b.header.getJid().toString())); - onBackupFilesLoaded.onBackupFilesLoaded(backupFiles); - }); + Collections.sort( + backupFiles, + (a, b) -> + a.header + .getJid() + .toString() + .compareTo(b.header.getJid().toString())); + onBackupFilesLoaded.onBackupFilesLoaded(backupFiles); + }); } private void startForegroundService() { @@ -180,14 +214,16 @@ public class ImportBackupService extends Service { } final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); try { - notificationManager.notify(NOTIFICATION_ID, createImportBackupNotification(max, progress)); + notificationManager.notify( + NOTIFICATION_ID, createImportBackupNotification(max, progress)); } catch (final RuntimeException e) { Log.d(Config.LOGTAG, "unable to make notification", e); } } private Notification createImportBackupNotification(final int max, final int progress) { - NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup"); + NotificationCompat.Builder mBuilder = + new NotificationCompat.Builder(getBaseContext(), "backup"); mBuilder.setContentTitle(getString(R.string.restoring_backup)) .setSmallIcon(R.drawable.ic_unarchive_white_24dp) .setProgress(max, progress, max == 1 && progress == 0); @@ -212,7 +248,9 @@ public class ImportBackupService extends Service { fileSize = 0; } else { returnCursor.moveToFirst(); - fileSize = returnCursor.getLong(returnCursor.getColumnIndex(OpenableColumns.SIZE)); + fileSize = + returnCursor.getLong( + returnCursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); returnCursor.close(); } inputStream = getContentResolver().openInputStream(uri); @@ -242,40 +280,46 @@ public class ImportBackupService extends Service { final byte[] key = ExportBackupService.getKey(password, backupFileHeader.getSalt()); final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine()); - cipher.init(false, new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv())); - final CipherInputStream cipherInputStream = new CipherInputStream(countingInputStream, cipher); + cipher.init( + false, + new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv())); + final CipherInputStream cipherInputStream = + new CipherInputStream(countingInputStream, cipher); final GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream); - final BufferedReader reader = new BufferedReader(new InputStreamReader(gzipInputStream, Charsets.UTF_8)); + final BufferedReader reader = + new BufferedReader(new InputStreamReader(gzipInputStream, Charsets.UTF_8)); + final JsonReader jsonReader = new JsonReader(reader); + if (jsonReader.peek() == JsonToken.BEGIN_ARRAY) { + jsonReader.beginArray(); + } else { + throw new IllegalStateException("Backup file did not begin with array"); + } db.beginTransaction(); - String line; - StringBuilder multiLineQuery = null; - while ((line = reader.readLine()) != null) { - int count = count(line, '\''); - if (multiLineQuery != null) { - multiLineQuery.append('\n'); - multiLineQuery.append(line); - if (count % 2 == 1) { - db.execSQL(multiLineQuery.toString()); - multiLineQuery = null; - updateImportBackupNotification(fileSize, countingInputStream.getCount()); - } - } else { - if (count % 2 == 0) { - db.execSQL(line); - updateImportBackupNotification(fileSize, countingInputStream.getCount()); - } else { - multiLineQuery = new StringBuilder(line); - } + while (jsonReader.hasNext()) { + if (jsonReader.peek() == JsonToken.BEGIN_OBJECT) { + importRow(db, jsonReader, backupFileHeader.getJid(), password); + } else if (jsonReader.peek() == JsonToken.END_ARRAY) { + jsonReader.endArray(); + continue; } + updateImportBackupNotification(fileSize, countingInputStream.getCount()); } db.setTransactionSuccessful(); db.endTransaction(); final Jid jid = backupFileHeader.getJid(); - final Cursor countCursor = db.rawQuery("select count(messages.uuid) from messages join conversations on conversations.uuid=messages.conversationUuid join accounts on conversations.accountUuid=accounts.uuid where accounts.username=? and accounts.server=?", new String[]{jid.getEscapedLocal(), jid.getDomain().toEscapedString()}); + final Cursor countCursor = + db.rawQuery( + "select count(messages.uuid) from messages join conversations on conversations.uuid=messages.conversationUuid join accounts on conversations.accountUuid=accounts.uuid where accounts.username=? and accounts.server=?", + new String[] { + jid.getEscapedLocal(), jid.getDomain().toEscapedString() + }); countCursor.moveToFirst(); final int count = countCursor.getInt(0); - Log.d(Config.LOGTAG, String.format("restored %d messages in %s", count, stopwatch.stop().toString())); + Log.d( + Config.LOGTAG, + String.format( + "restored %d messages in %s", count, stopwatch.stop().toString())); countCursor.close(); stopBackgroundService(); synchronized (mOnBackupProcessedListeners) { @@ -286,7 +330,8 @@ public class ImportBackupService extends Service { return true; } catch (final Exception e) { final Throwable throwable = e.getCause(); - final boolean reasonWasCrypto = throwable instanceof BadPaddingException || e instanceof ZipException; + final boolean reasonWasCrypto = + throwable instanceof BadPaddingException || e instanceof ZipException; synchronized (mOnBackupProcessedListeners) { for (OnBackupProcessed l : mOnBackupProcessedListeners) { if (reasonWasCrypto) { @@ -301,14 +346,75 @@ public class ImportBackupService extends Service { } } + private void importRow( + final SQLiteDatabase db, + final JsonReader jsonReader, + final Jid account, + final String passphrase) + throws IOException { + jsonReader.beginObject(); + final String firstParameter = jsonReader.nextName(); + if (!firstParameter.equals("table")) { + throw new IllegalStateException("Expected key 'table'"); + } + final String table = jsonReader.nextString(); + if (!TABLE_ALLOW_LIST.contains(table)) { + throw new IOException(String.format("%s is not recognized for import", table)); + } + final ContentValues contentValues = new ContentValues(); + final String secondParameter = jsonReader.nextName(); + if (!secondParameter.equals("values")) { + throw new IllegalStateException("Expected key 'values'"); + } + jsonReader.beginObject(); + while (jsonReader.peek() != JsonToken.END_OBJECT) { + final String name = jsonReader.nextName(); + if (COLUMN_PATTERN.matcher(name).matches()) { + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); + contentValues.putNull(name); + } else if (jsonReader.peek() == JsonToken.NUMBER) { + contentValues.put(name, jsonReader.nextLong()); + } else { + contentValues.put(name, jsonReader.nextString()); + } + } else { + throw new IOException(String.format("Unexpected column name %s", name)); + } + } + jsonReader.endObject(); + jsonReader.endObject(); + if (Account.TABLENAME.equals(table)) { + final Jid jid = + Jid.of( + contentValues.getAsString(Account.USERNAME), + contentValues.getAsString(Account.SERVER), + null); + final String password = contentValues.getAsString(Account.PASSWORD); + if (jid.equals(account) && passphrase.equals(password)) { + Log.d(Config.LOGTAG, "jid and password from backup header had matching row"); + } else { + throw new IOException("jid or password in table did not match backup"); + } + } + db.insert(table, null, contentValues); + } + private void notifySuccess() { - NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup"); + NotificationCompat.Builder mBuilder = + new NotificationCompat.Builder(getBaseContext(), "backup"); mBuilder.setContentTitle(getString(R.string.notification_restored_backup_title)) .setContentText(getString(R.string.notification_restored_backup_subtitle)) .setAutoCancel(true) - .setContentIntent(PendingIntent.getActivity(this, 145, new Intent(this, ManageAccountActivity.class), s() - ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT - : PendingIntent.FLAG_UPDATE_CURRENT)) + .setContentIntent( + PendingIntent.getActivity( + this, + 145, + new Intent(this, ManageAccountActivity.class), + s() + ? PendingIntent.FLAG_IMMUTABLE + | PendingIntent.FLAG_UPDATE_CURRENT + : PendingIntent.FLAG_UPDATE_CURRENT)) .setSmallIcon(R.drawable.ic_unarchive_white_24dp); notificationManager.notify(NOTIFICATION_ID, mBuilder.build()); } @@ -391,4 +497,4 @@ public class ImportBackupService extends Service { return ImportBackupService.this; } } -} \ No newline at end of file +} diff --git a/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java b/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java index 6e4815159..ed998677b 100644 --- a/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java +++ b/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java @@ -30,6 +30,7 @@ import eu.siacs.conversations.databinding.DialogEnterPasswordBinding; import eu.siacs.conversations.services.ImportBackupService; import eu.siacs.conversations.ui.adapter.BackupFileAdapter; import eu.siacs.conversations.ui.util.SettingsUtils; +import eu.siacs.conversations.utils.BackupFileHeader; import eu.siacs.conversations.utils.ThemeHelper; public class ImportBackupActivity extends ActionBarActivity implements ServiceConnection, ImportBackupService.OnBackupFilesLoaded, BackupFileAdapter.OnItemClickedListener, ImportBackupService.OnBackupProcessed { @@ -131,6 +132,8 @@ public class ImportBackupActivity extends ActionBarActivity implements ServiceCo try { final ImportBackupService.BackupFile backupFile = ImportBackupService.BackupFile.read(this, uri); showEnterPasswordDialog(backupFile, finishOnCancel); + } catch (final BackupFileHeader.OutdatedBackupFileVersion e) { + Snackbar.make(binding.coordinator, R.string.outdated_backup_file_format, Snackbar.LENGTH_LONG).show(); } catch (final IOException | IllegalArgumentException e) { Log.d(Config.LOGTAG, "unable to open backup file " + uri, e); Snackbar.make(binding.coordinator, R.string.not_a_backup_file, Snackbar.LENGTH_LONG).show(); diff --git a/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java b/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java index 6aecf4b26..4446acefe 100644 --- a/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java +++ b/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java @@ -12,6 +12,8 @@ import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.AdapterView.AdapterContextMenuInfo; +import android.widget.Button; +import android.widget.CheckBox; import android.widget.ListView; import android.widget.Toast; @@ -196,6 +198,12 @@ public class ManageAccountActivity extends XmppActivity implements OnAccountUpda } } + @Override + protected void deleteAccount(final Account account) { + super.deleteAccount(account); + this.selectedAccount = null; + } + @Override public boolean onOptionsItemSelected(MenuItem item) { if (MenuDoubleTabUtil.shouldIgnoreTap()) { @@ -351,6 +359,7 @@ public class ManageAccountActivity extends XmppActivity implements OnAccountUpda private void enableAccount(Account account) { account.setOption(Account.OPTION_DISABLED, false); + account.setOption(Account.OPTION_SOFT_DISABLED, false); final XmppConnection connection = account.getXmppConnection(); if (connection != null) { connection.resetEverything(); @@ -368,22 +377,6 @@ public class ManageAccountActivity extends XmppActivity implements OnAccountUpda } } - private void deleteAccount(final Account account) { - final AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setTitle(getString(R.string.mgmt_account_are_you_sure)); - builder.setIconAttribute(android.R.attr.alertDialogIcon); - builder.setMessage(getString(R.string.mgmt_account_delete_confirm_text)); - builder.setPositiveButton(getString(R.string.delete), - (dialog, which) -> { - xmppConnectionService.deleteAccount(account); - selectedAccount = null; - if (xmppConnectionService.getAccounts().size() == 0 && Config.MAGIC_CREATE_DOMAIN != null) { - WelcomeActivity.launch(this); - } - }); - builder.setNegativeButton(getString(R.string.cancel), null); - builder.create().show(); - } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { diff --git a/src/conversations/res/drawable/ic_launcher_foreground.xml b/src/conversations/res/drawable/ic_launcher_foreground.xml index 07a7ee7eb..5851e5f2c 100644 --- a/src/conversations/res/drawable/ic_launcher_foreground.xml +++ b/src/conversations/res/drawable/ic_launcher_foreground.xml @@ -1,24 +1,13 @@ - - - + android:width="108dp" + android:height="108dp" + android:viewportWidth="1146.7721" + android:viewportHeight="1146.7721"> + + diff --git a/src/conversations/res/drawable/ic_launcher_monochrome.xml b/src/conversations/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 000000000..a2057b73c --- /dev/null +++ b/src/conversations/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/src/conversations/res/layout/dialog_enter_password.xml b/src/conversations/res/layout/dialog_enter_password.xml index 40f3ba34d..29b50d4ef 100644 --- a/src/conversations/res/layout/dialog_enter_password.xml +++ b/src/conversations/res/layout/dialog_enter_password.xml @@ -22,6 +22,13 @@ android:text="@string/restore_warning" android:textAppearance="@style/TextAppearance.Conversations.Body1"/> + + - XMPP সার্ভার নির্বাচন করুন - conversations.im-ই ব্যবহার করা যাক - নতুন অ্যকাউন্ট তৈরী করা যাক - আপনার কি একটা XMPP অ্যকাউন্ট ইতিমধ্যে করা আছে? সেরকমটা হতেই পারে যদি এর আগে আপনি কোনো অন্য XMPP প্রোগ্রাম বা অ্যাপ ব্যবহার করে থাকেন। এই মুহুর্তে আরেকটা অ্যকাউন্ট তৈরী করা সম্ভব না।‌\nHint: মাঝে মাঝে ইমেল অ্যকাউন্ট খুললেও এরকম অ্যকাউন্ট নিজে থেকেই তৈরী হয়ে যায়। - XMPP কোনো একটি নির্দিষ্ট সংস্থার উপরে নির্ভরশীল নয়। এই অ্যপটি আপনি যেকোনো সংস্থার XMPP সার্ভারের সাথে ব্যবহার করতে পারেন।\nমনে রাখবেন, সুধুমাত্র আপনার সুবিধার্থেই conversations.im -এ আপনার জন্যে একটি অ্যকাউন্ট তৈরী করে দেওয়া হয়েছে। Conversations অ্যপটি এই সার্ভারের সাথে সবথেকে বেশী কার্যকারী। + আপনার XMPP প্রোভাইডার নির্বাচন করুন + conversations.im ব্যবহার করুন + নতুন অ্যকাউন্ট তৈরী করুন + আপনার কি কোনও XMPP অ্যকাউন্ট ইতিমধ্যে করা আছে? সেরকমটা হতেই পারে যদি এর আগে আপনি কোনো অন্য XMPP প্রোগ্রাম বা অ্যাপ ব্যবহার করে থাকেন। যদি না করে থাকেন, তাহলে আপনি এখন একটি XMPP অ্যাকাউন্ট বানাতে পারেন। +\nসূত্র: মাঝে মধ্যে কিছু ইমেল প্রোভাইডাররা XMPP অ্যাকাউন্ট দেয়। + XMPP কোনো একটি নির্দিষ্ট সংস্থার উপরে নির্ভরশীল নয়। এই অ্যপটি আপনি যেকোনো সংস্থার XMPP সার্ভারের সাথে ব্যবহার করতে পারেন। +\nআপনার সুবিধার্থে conversations.im-এ আপনার জন্যে একটি অ্যকাউন্ট তৈরী করে দেওয়া সুবিধাজনক করা হয়েছে। Conversations অ্যপটি এই সার্ভারের সাথে সবথেকে বেশী কার্যকারী। আপনাকে %1$s-এ আমন্ত্রিত করা হয়েছে। অ্যকাউন্ট তৈরী করার সময় আপনাকে সাহায্য করা হবে।\n%1$s ব্যবহার করলেও, অন্য সেবা-প্রদানকারী সংস্থার ব্যবহারকারীদের সাথে আপনি কথা বলতে পারবেন, আপনার সম্পূর্ণ XMPP অ্যড্রেস তাদেরকে বলে দিয়ে। আপনাকে %1$s-এ নিমন্ত্রণ করা হয়েছে। একটি username-ও আপনার জন্যে নির্দিষ্ট করে রাখা হয়েছে। অ্যকাউন্ট তৈরী করার সময় আপনাকে সাহায্য করা হবে।\nঅন্য XMPP সেবা প্রদানকারী সংস্থার ব্যবহারকারীদের সাথে আপনিও কথা বলতে পারবেন, আপনার সম্পূর্ণ XMPP অ্যড্রেস তাদেরকে বলে দিয়ে। আপনার নিমন্ত্রণপত্র, সার্ভার থেকে diff --git a/src/conversations/res/values-da-rDK/strings.xml b/src/conversations/res/values-da-rDK/strings.xml index fb5992a1b..f79a92078 100644 --- a/src/conversations/res/values-da-rDK/strings.xml +++ b/src/conversations/res/values-da-rDK/strings.xml @@ -1,6 +1,6 @@ - Vælg din XMPP-udbyder + Vælg din XMPP udbyder Brug conversations.im Opret ny konto Har du allerede en XMPP-konto? Dette kan være tilfældet, hvis du allerede bruger en anden XMPP-klient eller har brugt Conversations før. Hvis ikke, kan du lige nu oprette en ny XMPP-konto.\nTip: Nogle e-mail-udbydere leverer også XMPP-konti. @@ -12,5 +12,5 @@ Tryk på deleknappen for at sende din kontakt en invitation til %1$s. Hvis din kontakt er i nærheden, kan de også skanne koden nedenfor for at acceptere din invitation. Deltag med %1$s og chat med mig: %2$s - Del invitation med... + Del invitation med… \ No newline at end of file diff --git a/src/conversations/res/values-el/strings.xml b/src/conversations/res/values-el/strings.xml index bb7bcadf0..c64e3d68e 100644 --- a/src/conversations/res/values-el/strings.xml +++ b/src/conversations/res/values-el/strings.xml @@ -12,5 +12,5 @@ Πατήστε το πλήκτρο διαμοιρασμού για να στείλετε στην επαφή σας μια πρόσκληση στο %1$s. Αν η επαφή σας βρίσκεται κοντά σας, μπορεί επίσης να σαρώσει τον κωδικό παρακάτω για να αποδεχτεί την πρόσκλησή σας. Μπείτε στο %1$s και συνομιλήστε μαζί μου: %2$s - Διαμοιρασμός πρόσκλησης με... + Διαμοιρασμός πρόσκλησης με… \ No newline at end of file diff --git a/src/conversations/res/values-eo/strings.xml b/src/conversations/res/values-eo/strings.xml new file mode 100644 index 000000000..d5fc7d80e --- /dev/null +++ b/src/conversations/res/values-eo/strings.xml @@ -0,0 +1,20 @@ + + + Uzi conversations.im + Aliĝu al %1$s kaj babilu kun mi: %2$s + Vi estis invitita al %1$s. Ni gvidos vin tra la procezo de kreado de konto. +\nElektante %1$s kiel provizanton vi povos komuniki kun uzantoj de aliaj provizantoj donante al ili vian plenan XMPP-adreson. + Ĉu vi jam havas XMPP-konton\? Ĉi tio povus esti la kazo se vi jam uzas alian XMPP-klienton aŭ antaŭe uzis Conversations. Se ne, vi povas krei novan XMPP-konton nun. +\nKonsileto: Iuj retpoŝtaj provizantoj ankaŭ provizas XMPP-kontojn. + Se via kontakto estas proksime, ili ankaŭ povas skani la suban kodon por akcepti vian inviton. + Elekti vian XMPP-provizanton + Kunhavigi inviton kun… + XMPP estas provizanta sendependa tujmesaĝa reto. Vi povas uzi ĉi tiun klienton per kia ajn XMPP-servilo, kiun vi elektas. +\nTamen por via komforto ni faciligis krei konton ĉe conversations.im; provizanto speciale taŭga por la uzo kun Conversations. + Vi estis invitita al %1$s. Uzantnomo jam estas elektita por vi. Ni gvidos vin tra la procezo de kreado de konto. +\nVi povos komuniki kun uzantoj de aliaj provizantoj donante al ili vian plenan XMPP-adreson. + Nedece formatita provizokodo + Premu la kunhavigi butonon por sendi al via kontakto inviton al %1$s. + Via servila invito + Krei novan konton + \ No newline at end of file diff --git a/src/conversations/res/values-fa-rIR/strings.xml b/src/conversations/res/values-fa-rIR/strings.xml index 0f3362506..dcf72204c 100644 --- a/src/conversations/res/values-fa-rIR/strings.xml +++ b/src/conversations/res/values-fa-rIR/strings.xml @@ -3,4 +3,18 @@ لطفا سرویس دهنده پیام خود را انتخاب نمائید. برای مثال artalk.im از Conversations.im استفاده کنید حساب کاربری جدیدی بسازید - \ No newline at end of file + عضو %1$s شو و با من گفتگو کن: %2$s + شما به %1$s دعوت شده‌اید. ما شما را در ساخت حسابتان راهنمایی می‌کنیم. +\nوقتی شما %1$s را به عنوان سرویس‌دهندهٔ خود برگزینید، خواهید توانست با کاربران بقیهٔ سرویس‌دهنده‌ها نیز ارتباط داشته باشید. کافی است نشانی کامل XMPP خود را به آن‌ها بدهید. + آیا خودتان حساب XMPP دارید؟ اگر جایی دیگری با همین برنامه یا برنامه‌های مشابه کار می‌کنید باید داشته باشید. اگر حساب XMPP ندارید، همین‌جا می‌توانید یکی بسازید. +\nنکته: برخی از سرویس‌دهنده‌های ایمیل به شما حساب XMPP هم ارائه می‌دهند. + اگر مخاطب شما نزدیکتان است، می‌تواند برای پذیرش دعوت‌نامه کد زیر را اسکن کند. + هم‌رسانی دعوت‌نامه با… + شبکهٔ XMPP مستقل از سرویس‌دهنده‌هایش است. بنابراین شما می‌توانید این برنامه را با هر سرویس‌دهنده‌ای که می‌خواهید به کار ببرید. +\nولی برای راحتی شما، ما امکان ساخت حساب روی سرور conversations.im را می‌دهیم؛ سرویس‌دهنده‌ای که برای کار با این برنامه بهینه شده است. + شما به %1$s دعوت شده‌اید و یک نام کاربری برایتان برگزیده شده است. ما شما را در ساخت حسابتان راهنمایی می‌کنیم. +\nشما خواهید توانست با کاربران بقیهٔ سرویس‌دهنده‌ها نیز ارتباط داشته باشید. کافی است نشانی کامل XMPP خود را به آن‌ها بدهید. + کد مورد نظر اشتباه ساخته شده است + روی گزینهٔ هم‌رسانی بزنید تا مخاطب خود را به %1$s دعوت کنید. + دعوت‌نامهٔ سرور شما + \ No newline at end of file diff --git a/src/conversations/res/values-fr/strings.xml b/src/conversations/res/values-fr/strings.xml index 47badf219..f0c96726f 100644 --- a/src/conversations/res/values-fr/strings.xml +++ b/src/conversations/res/values-fr/strings.xml @@ -9,8 +9,8 @@ Vous avez été invité à %1$s. Un nom d’utilisateur a déjà été choisi pour vous. Nous allons vous guider à travers le processus de création d’un compte.\nVous pourrez communiquer avec les utilisateurs des autres fournisseurs en leur donnant votre adresse XMPP complète. Votre invitation au serveur Code de provisionnement mal formaté - Appuyez sur le bouton partager pour envoyer à votre contact une invitation pour %1$s - Si vos contacts sont à votre côté, ils peuvent aussi scanner le code ci dessous pour accepter votre invitation + Appuyez sur le bouton partager pour envoyer à votre contact une invitation pour %1$s. + Si vos contacts sont à proximité, ils peuvent aussi scanner le code ci-dessous pour accepter votre invitation. Rejoignez %1$set discutez avec moi : %2$s - Partager une invitation avec ... + Partager une invitation avec … \ No newline at end of file diff --git a/src/conversations/res/values-nl/strings.xml b/src/conversations/res/values-nl/strings.xml index f04a6b2de..bc7dbc2fa 100644 --- a/src/conversations/res/values-nl/strings.xml +++ b/src/conversations/res/values-nl/strings.xml @@ -11,4 +11,5 @@ Tik op de delen knop om een uitnodiging te versturen naar %1$s Als je contactpersoon in de buurt is, kan deze ook onderstaande code scannen om de uitnodiging te aanvaarden. Deel de uitnodiging met ... + Vergezel %1$s en chat met mij: %2$s \ No newline at end of file diff --git a/src/conversations/res/values-sk/strings.xml b/src/conversations/res/values-sk/strings.xml index ed58bbefb..e280344c4 100644 --- a/src/conversations/res/values-sk/strings.xml +++ b/src/conversations/res/values-sk/strings.xml @@ -10,5 +10,5 @@ Ťuknite na tlačidlo zdieľať na odoslanie pozvánky do %1$s vášmu kontaktu. Ak je váš kontakt blízko, na prijatie vašej pozvánky si môže nasnímať kód nižšie. Pripojte sa k %1$sa rozprávajte sa so mnou: %2$s - Zdieľať pozvánku s... + Zdieľať pozvánku s… \ No newline at end of file diff --git a/src/conversations/res/values-tr-rTR/strings.xml b/src/conversations/res/values-tr-rTR/strings.xml index 6fb383cf7..415bc89e0 100644 --- a/src/conversations/res/values-tr-rTR/strings.xml +++ b/src/conversations/res/values-tr-rTR/strings.xml @@ -11,6 +11,6 @@ Yanlış ayarlanmış düzenleme kodu Kişinize, %1$s grubuna davet etmek için Paylaş düğmesine basın. Kişiniz yakınınızda ise, aşağıdaki kodu tarayak daveti kabul edebilirler. - %1$s grubuna katıl ve benimle sohpet et: %2$s - Daveti şununla paylaş... + %1$s grubuna katıl ve benimle sohbet et: %2$s + Daveti şununla paylaş… \ No newline at end of file diff --git a/src/conversations/res/values-uk/strings.xml b/src/conversations/res/values-uk/strings.xml index 3b855ab5c..f9e37cea7 100644 --- a/src/conversations/res/values-uk/strings.xml +++ b/src/conversations/res/values-uk/strings.xml @@ -3,10 +3,18 @@ Виберіть постачальника послуг обміну повідомленнями XMPP Скористатися conversations.im Створити новий обліковий запис - Вже маєте обліковий запис XMPP? Можливо, користуєтеся іншою програмою XMPP або користувалися цією програмою раніше. Якщо ні, можете створити новий обліковий запис XMPP просто зараз.\nЗверніть увагу, що деякі постачальники електронної пошти у той же час надають облікові записи XMPP. - XMPP — це мережа обміну повідомленнями, незалежна від постачальників. Можете використовувати цю програму з будь-яким XMPP сервером, який оберете.\nПроте, для зручності, ми спростили створення облікового запису на conversations.im — у постачальника, який спеціально налаштований на роботу з цією програмою. - Вас запросили до %1$s. Ми проведемо вас крок за кроком, щоб створити обліковий запис.\nОбираючи %1$s в якості свого постачальника, ви зможете спілкуватися з користувачами інших постачальників, для цього повідомте їм свою повну адресу XMPP. - Вас запросили до %1$s. Для вас створено ім\'я користувача. Ми проведемо вас крок за кроком, щоб створити обліковий запис.\nВи зможете спілкуватися з користувачами інших постачальників, для цього повідомите їм свою повну адресу XMPP. + Уже маєте обліковий запис XMPP\? Можливо, користуєтеся іншою програмою XMPP або користувалися Conversations раніше. Якщо ні, можете створити новий обліковий запис XMPP просто зараз. +\nЗверніть увагу, що деякі постачальники електронної пошти у той же час надають облікові записи XMPP. + XMPP — це мережа обміну повідомленнями, незалежна від постачальників. Можете використовувати цю програму з будь-яким XMPP-сервером, який оберете. +\nПроте для зручності ми спростили створення облікового запису на conversations.im — у постачальника, спеціально налаштованого на роботу з Conversations. + Вас запросили до %1$s. Ми проведемо Вас крок за кроком, щоб створити обліковий запис. +\nОбравши %1$s в якості свого постачальника, Ви зможете спілкуватися з користувачами інших постачальників, для цього повідомте їм свою повну адресу XMPP. + Вас запросили до %1$s. Для Вас створено ім\'я користувача. Ми проведемо Вас крок за кроком, щоб створити обліковий запис. +\nВи зможете спілкуватися з користувачами інших постачальників, для цього повідомте їм свою повну адресу XMPP. Ваше запрошення до сервера Неправильно відформатований код забезпечення - \ No newline at end of file + Якщо контакт поблизу, він також може прийняти запрошення, відсканувавши код нижче. + Приєднуйтеся до %1$s і спілкуйтеся зі мною: %2$s + Запросити… + Натисніть «Поділитися», щоб надіслати Вашому контакту запрошення до %1$s. + \ No newline at end of file diff --git a/src/conversations/res/values-vi/strings.xml b/src/conversations/res/values-vi/strings.xml index f80ceacf8..851ad0927 100644 --- a/src/conversations/res/values-vi/strings.xml +++ b/src/conversations/res/values-vi/strings.xml @@ -1,16 +1,20 @@ Chọn nhà cung cấp XMPP của bạn - Sử dụng conversations.im + Sử dụng “conversations.im” Tạo tài khoản mới - Bạn đã có tài khoản XMPP chưa? Điều này có thể đúng nếu bạn đang dùng một ứng dụng khách cho XMPP khác hoặc đã sử dụng Conversations trước đó. Nếu không, bạn có thể tạo tài khoản XMPP mới ngay bây giờ.\nGợi ý: Một số nhà cung cấp email cũng cung cấp tài khoản XMPP. - XMPP là một mạng nhắn tin ngay lập tức không phụ thuộc vào nhà cung cấp. Bạn có thể sử dụng ứng dụng khách này với bất kỳ máy chủ XMPP nào mà bạn chọn.\nTuy nhiên, vì sự thuận tiện của bạn, chúng tôi đã làm cho việc tạo tài khoản trên conversations.im được dễ dàng; một nhà cung cấp đặc biệt phù hợp với việc sử dụng Conversations. - Bạn đã được mời vào %1$s. Chúng tôi sẽ hướng dẫn bạn trong quá trình tạo tài khoản.\nKhi chọn %1$s là nhà cung cấp, bạn sẽ có thể giao tiếp với những người dùng của các nhà cung cấp khác bằng cách đưa cho họ địa chỉ XMPP đầy đủ của bạn. - Bạn đã được mời vào %1$s. Một tên người dùng đã được chọn sẵn cho bạn. Chúng tôi sẽ hướng dẫn bạn trong quá trình tạo tài khoản.\nBạn sẽ có thể giao tiếp với những người dùng của các nhà cung cấp khác bằng cách đưa cho họ địa chỉ XMPP đầy đủ của bạn. + Bạn đã có sẵn một tài khoản XMPP chưa\? Nếu bạn đang dùng một ứng dụng XMPP khác dành cho máy khách (client) hoặc đã sử dụng Conversations trước đó. Nếu chưa có, bạn có thể tạo tài khoản XMPP mới ngay bây giờ. +\nGợi ý: Một số nhà cung cấp dịch vụ email cũng cung cấp tài khoản XMPP. + XMPP là một dịch vụ mạng tin nhắn không phụ thuộc vào nhà cung cấp nào. Bạn có thể sử dụng ứng dụng máy khách này với bất kỳ máy chủ XMPP nào mà bạn chọn. +\nĐể thuận tiện hơn cho bạn, chúng tôi đã đơn giản hóa khâu tạo tài khoản trên conversations.im – một nhà cung cấp đặc biệt phù hợp cho việc sử dụng Conversations. + Bạn đã được mời vào “ %1$s”. Chúng tôi sẽ hướng dẫn bạn xuyên suốt quá trình tạo tài khoản. +\nKhi chọn “%1$s” làm nhà cung cấp, bạn sẽ có thể liên lạc với những người dùng của các nhà cung cấp khác bằng cách đưa cho họ địa chỉ XMPP đầy đủ của bạn. + Bạn đã được mời vào “%1$s.” Một tên người dùng đã được chọn sẵn cho bạn. Chúng tôi sẽ hướng dẫn bạn xuyên suốt quá trình tạo tài khoản. +\nBạn sẽ có thể với những người dùng của các nhà cung cấp khác bằng cách đưa cho họ địa chỉ XMPP đầy đủ của bạn. Lời mời vào máy chủ của bạn - Mã cung cấp không được định dạng đúng - Nhấn nút chia sẻ để gửi lời mời vào %1$s đến liên hệ của bạn. - Nếu liên hệ của bạn ở gần đây, họ cũng có thể quét mã ở dưới để chấp nhận lời mời của bạn. - Hãy tham gia vào %1$s và trò chuyện với tôi: %2$s - Chia sẻ lời mời với... + Mã cung cấp sai định dạng + Nhấn nút chia sẻ để gửi đến liên hệ của bạn một lời mời vào “%1$s”. + Nếu liên hệ của bạn đang ở gần bên bạn, họ có thể quét mã ở dưới để chấp nhận lời mời của bạn. + Hãy tham gia vào “%1$s” và trò chuyện với tôi: %2$s + Chia sẻ lời mời với… \ No newline at end of file diff --git a/src/conversations/res/values-zh-rCN/strings.xml b/src/conversations/res/values-zh-rCN/strings.xml index 961973b8c..34cf35734 100644 --- a/src/conversations/res/values-zh-rCN/strings.xml +++ b/src/conversations/res/values-zh-rCN/strings.xml @@ -2,15 +2,19 @@ 选择您的 XMPP 提供者 使用 conversations.im - 创建新账户 - 您有 XMPP 账户吗?如果您之前使用过其他的 XMPP 客户端,那么您已经拥有这种账户了。如果没有的话,您现在可以创建一个。\n提示:有些电子邮件服务也提供XMPP账户。 - XMPP 是独立于提供者的即时消息网络。您可以将此客户端与任意 XMPP 服务器一同使用。\n不过,您可以很容易地在 conversations.im 上创建账户;它是特别适合与“Conversations”一起使用的提供者。 - 您已受邀加入 %1$s。我们将指导您完成创建帐户的过程。\n使用 %1$s 作为提供者时,您可以通过您的完整 XMPP 地址与其他提供者的用户进行交流。 - 您已受邀加入 %1$s。已为您选择了一个用户名。我们将指导您完成创建帐户的过程。\n您可以使用完整的 XMPP 地址来与其他提供者的用户进行交流。 - 你的服务器邀请 - 格式不正确的配置代码 - 点击分享按钮向您的联系人发送加入 %1$s 的邀请。 - 如果你的联系人在附近,他们也可以扫描下面的代码来接受你的邀请。 + 创建新账号 + 您已经有 XMPP 账号了吗?如果您之前使用过 Conversations 或其他 XMPP 客户端,那么您已经有账号了。如果没有,您可以立即创建一个。 +\n提示:一些电子邮件服务也提供 XMPP 账号。 + XMPP 是独立于提供者的即时通讯网络。您选择的任何 XMPP 服务器都可以使用此客户端。 +\n不过,您可以轻松地在 conversations.im 上创建账号;特别适合与 Conversations 使用的提供者。 + 您已受邀加入 %1$s。我们将指导您创建账号。 +\n当选择 %1$s 作为提供者时,向其他 XMPP 用户提供您的完整地址,就能和对方交流。 + 您已受邀加入 %1$s。已为您选择了用户名。我们将指导您创建账号。 +\n向其他 XMPP 用户提供您的完整地址,就能和对方交流。 + 您的服务器邀请 + 配置代码格式不正确 + 轻击分享按钮,向您的联系人发送加入 %1$s 的邀请。 + 如果您的联系人在附近,对方也可以扫描下方二维码接受邀请。 加入 %1$s 和我聊天:%2$s - 分享邀请… + 分享邀请至… \ No newline at end of file diff --git a/src/conversations/res/values-zh-rTW/strings.xml b/src/conversations/res/values-zh-rTW/strings.xml index 8f1828bf6..de09fe867 100644 --- a/src/conversations/res/values-zh-rTW/strings.xml +++ b/src/conversations/res/values-zh-rTW/strings.xml @@ -3,14 +3,18 @@ 挑選您的 XMPP 提供者 使用 conversations.im 建立新帳戶 - 您已經擁有一個 XMPP 賬戶嗎?如果您之前使用過其他 XMPP 客戶端或 Conversations 的話,那麼您已經擁有 XMPP 賬戶了。如果沒有賬戶的話,您可以現在建立一個。\n提示:有些電子郵件服務供應商也會提供 XMPP 賬戶。 - XMPP 是提供者無關的即時訊息網絡。 任何你選擇的 XMPP 伺服器都可在此客戶端上使用。\n不過,我們令它在 Coversations.im 中建立帳戶變得更方便; Conversations.im 是特別適合 Conversations 的提供者 - 你已受邀參加 %1$s 。 我們將指導你完成建立帳戶的過程。選擇 %1$s 作爲提供者後,你可以將你完整的 XMPP 地址交給使用其他提供者的用戶,你便能與他們交流。 - 您已被邀請參加 %1$s 。 我們已經爲你選擇了一個用戶名。 我們將指導你完成建立帳戶的過程。\n將你完整的 XMPP 地址交給使用其他提供者的用戶後,你便能與他們交流。 + 您已經擁有一個 XMPP 帳戶了嗎?如果您之前使用過其他 XMPP 用戶端或 Conversations 的話,那麼您已經擁有 XMPP 帳戶了。若沒有,您現在就建立一個新的 XMPP 帳戶。 +\n提示:部分電子郵件提供者也會提供 XMPP 帳戶。 + XMPP 是提供者無關的即時訊息網路。任何您選擇的 XMPP 伺服器都可在此用戶端上使用。 +\n不過,我們令它在 Coversations.im 中建立帳戶變得更方便;conversations.im 是特別適合 Conversations 的提供者。 + 你已受邀參加 %1$s 。我們將指引您完成建立帳戶的過程。 +\n選擇 %1$s 作為提供者後,您可以將您完整的 XMPP 位址交給使用其他提供者的使用者,以便能與他們進行交流。 + 你已受邀參加 %1$s 。我們已經為您挑選了一個使用者名稱。我們將指引您完成建立帳戶的過程。 +\n您可以將您完整的 XMPP 位址交給使用其他提供者的使用者,以便能與他們進行交流。 您的伺服器邀請 - 配置代碼格式不正確 - 輕觸分享按鍵向您的聯絡人發送加入 %1$s 的邀請。 - 如果你的聯絡人就在附近,他們也可以掃描下面的代碼來接受你的邀請。 - 加入 %1$s 和我聊天:%2$s - 分享邀請到... + 佈建代碼格式不正確 + 輕觸分享按鍵以向您的聯絡人傳送加入 %1$s 的邀請。 + 如果您的聯絡人就在附近,他們也可以掃描下面的代碼以接受您的邀請。 + 加入 %1$s 與我聊天:%2$s + 分享邀請至… \ No newline at end of file diff --git a/src/free/AndroidManifest.xml b/src/free/AndroidManifest.xml new file mode 100644 index 000000000..b127401a9 --- /dev/null +++ b/src/free/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 984bdc507..c01009862 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -5,7 +5,6 @@ - @@ -18,7 +17,6 @@ - + + + + + + + + + + + - + @@ -75,30 +87,60 @@ + tools:targetApi="tiramisu"> - + + + + + + + + + + + + + + android:exported="false"> - + @@ -112,10 +154,12 @@ + + - + @@ -143,7 +187,6 @@ + android:exported="true"> @@ -258,7 +300,6 @@ @@ -296,7 +337,7 @@ android:value="eu.siacs.conversations.ui.SettingsActivity" /> @@ -304,17 +345,6 @@ android:name=".ui.MediaBrowserActivity" android:label="@string/media_browser" /> - - - - - - - - QUERY_CACHE = + new LruCache<>(1024); + private final Context context; + private final NetworkDataSource networkDataSource = new NetworkDataSource(); + private boolean askForDnssec = false; + + public AndroidDNSClient(final Context context) { + super(); + this.setDataSource(networkDataSource); + this.context = context; + } + + private static String getPrivateDnsServerName(final LinkProperties linkProperties) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return linkProperties.getPrivateDnsServerName(); + } else { + return null; + } + } + + private static boolean isPrivateDnsActive(final LinkProperties linkProperties) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return linkProperties.isPrivateDnsActive(); + } else { + return false; + } + } + + @Override + protected DNSMessage.Builder newQuestion(final DNSMessage.Builder message) { + message.setRecursionDesired(true); + message.getEdnsBuilder() + .setUdpPayloadSize(networkDataSource.getUdpPayloadSize()) + .setDnssecOk(askForDnssec); + return message; + } + + @Override + protected DNSMessage query(final DNSMessage.Builder queryBuilder) throws IOException { + final DNSMessage question = newQuestion(queryBuilder).build(); + for (final DNSServer dnsServer : getDNSServers()) { + final QuestionServerTuple cacheKey = new QuestionServerTuple(dnsServer, question); + final DNSMessage cachedResponse = queryCache(cacheKey); + if (cachedResponse != null) { + return cachedResponse; + } + final DNSMessage response = this.networkDataSource.query(question, dnsServer); + if (response == null) { + continue; + } + switch (response.responseCode) { + case NO_ERROR: + case NX_DOMAIN: + break; + default: + continue; + } + cacheQuery(cacheKey, response); + return response; + } + return null; + } + + public boolean isAskForDnssec() { + return askForDnssec; + } + + public void setAskForDnssec(boolean askForDnssec) { + this.askForDnssec = askForDnssec; + } + + private List getDNSServers() { + final ImmutableList.Builder dnsServerBuilder = new ImmutableList.Builder<>(); + final ConnectivityManager connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + final Network[] networks = getActiveNetworks(connectivityManager); + for (final Network network : networks) { + final LinkProperties linkProperties = connectivityManager.getLinkProperties(network); + if (linkProperties == null) { + continue; + } + final String privateDnsServerName = getPrivateDnsServerName(linkProperties); + if (Strings.isNullOrEmpty(privateDnsServerName)) { + final boolean isPrivateDns = isPrivateDnsActive(linkProperties); + for (final InetAddress dnsServer : linkProperties.getDnsServers()) { + if (isPrivateDns) { + dnsServerBuilder.add(new DNSServer(dnsServer, Transport.TLS)); + } else { + dnsServerBuilder.add(new DNSServer(dnsServer)); + } + } + } else { + dnsServerBuilder.add(new DNSServer(privateDnsServerName, Transport.TLS)); + } + } + return dnsServerBuilder.build(); + } + + private Network[] getActiveNetworks(final ConnectivityManager connectivityManager) { + if (connectivityManager == null) { + return new Network[0]; + } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + final Network activeNetwork = connectivityManager.getActiveNetwork(); + if (activeNetwork != null) { + return new Network[] {activeNetwork}; + } + } + return connectivityManager.getAllNetworks(); + } + + private DNSMessage queryCache(final QuestionServerTuple key) { + final DNSMessage cachedResponse; + synchronized (QUERY_CACHE) { + cachedResponse = QUERY_CACHE.get(key); + if (cachedResponse == null) { + return null; + } + final long expiresIn = expiresIn(cachedResponse); + if (expiresIn < 0) { + QUERY_CACHE.remove(key); + return null; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Log.d( + Config.LOGTAG, + "DNS query came from cache. expires in " + Duration.ofMillis(expiresIn)); + } + } + return cachedResponse; + } + + private void cacheQuery(final QuestionServerTuple key, final DNSMessage response) { + if (response.receiveTimestamp <= 0) { + return; + } + synchronized (QUERY_CACHE) { + QUERY_CACHE.put(key, response); + } + } + + private static long ttl(final DNSMessage dnsMessage) { + final List> answerSection = dnsMessage.answerSection; + if (answerSection == null || answerSection.isEmpty()) { + final List> authoritySection = dnsMessage.authoritySection; + if (authoritySection == null || authoritySection.isEmpty()) { + return 0; + } else { + return Collections.min(Collections2.transform(authoritySection, d -> d.ttl)); + } + + } else { + return Collections.min(Collections2.transform(answerSection, d -> d.ttl)); + } + } + + private static long expiresAt(final DNSMessage dnsMessage) { + return dnsMessage.receiveTimestamp + (Math.min(DNS_MAX_TTL, ttl(dnsMessage)) * 1000L); + } + + private static long expiresIn(final DNSMessage dnsMessage) { + return expiresAt(dnsMessage) - System.currentTimeMillis(); + } + + private static class QuestionServerTuple { + private final DNSServer dnsServer; + private final DNSMessage question; + + private QuestionServerTuple(final DNSServer dnsServer, final DNSMessage question) { + this.dnsServer = dnsServer; + this.question = question.asNormalizedVersion(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + QuestionServerTuple that = (QuestionServerTuple) o; + return Objects.equal(dnsServer, that.dnsServer) + && Objects.equal(question, that.question); + } + + @Override + public int hashCode() { + return Objects.hashCode(dnsServer, question); + } + } +} diff --git a/src/main/java/de/gultsch/minidns/DNSServer.java b/src/main/java/de/gultsch/minidns/DNSServer.java new file mode 100644 index 000000000..7486ec2c6 --- /dev/null +++ b/src/main/java/de/gultsch/minidns/DNSServer.java @@ -0,0 +1,104 @@ +package de.gultsch.minidns; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Objects; +import com.google.common.base.Preconditions; +import com.google.common.collect.Iterables; + +import java.net.InetAddress; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; + +public final class DNSServer { + + public final InetAddress inetAddress; + public final String hostname; + public final int port; + public final List transports; + + public DNSServer(InetAddress inetAddress, Integer port, Transport transport) { + this.inetAddress = inetAddress; + this.port = port == null ? 0 : port; + this.transports = Collections.singletonList(transport); + this.hostname = null; + } + + public DNSServer(final String hostname, final Integer port, final Transport transport) { + Preconditions.checkArgument( + Arrays.asList(Transport.HTTPS, Transport.TLS).contains(transport), + "hostname validation only works with TLS based transports"); + this.hostname = hostname; + this.port = port == null ? 0 : port; + this.transports = Collections.singletonList(transport); + this.inetAddress = null; + } + + public DNSServer(final String hostname, final Transport transport) { + this(hostname, Transport.DEFAULT_PORTS.get(transport), transport); + } + + public DNSServer(InetAddress inetAddress, Transport transport) { + this(inetAddress, Transport.DEFAULT_PORTS.get(transport), transport); + } + + public DNSServer(final InetAddress inetAddress) { + this(inetAddress, 53, Arrays.asList(Transport.UDP, Transport.TCP)); + } + + public DNSServer(final InetAddress inetAddress, int port, List transports) { + this(inetAddress, null, port, transports); + } + + private DNSServer( + final InetAddress inetAddress, + final String hostname, + final int port, + final List transports) { + this.inetAddress = inetAddress; + this.hostname = hostname; + this.port = port; + this.transports = transports; + } + + public Transport uniqueTransport() { + return Iterables.getOnlyElement(this.transports); + } + + public DNSServer asUniqueTransport(final Transport transport) { + Preconditions.checkArgument( + this.transports.contains(transport), + "This DNS server does not have transport ", + transport); + return new DNSServer(inetAddress, hostname, port, Collections.singletonList(transport)); + } + + @Override + @Nonnull + public String toString() { + return MoreObjects.toStringHelper(this) + .add("inetAddress", inetAddress) + .add("hostname", hostname) + .add("port", port) + .add("transports", transports) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DNSServer dnsServer = (DNSServer) o; + return port == dnsServer.port + && Objects.equal(inetAddress, dnsServer.inetAddress) + && Objects.equal(hostname, dnsServer.hostname) + && Objects.equal(transports, dnsServer.transports); + } + + @Override + public int hashCode() { + return Objects.hashCode(inetAddress, hostname, port, transports); + } +} diff --git a/src/main/java/de/gultsch/minidns/DNSSocket.java b/src/main/java/de/gultsch/minidns/DNSSocket.java new file mode 100644 index 000000000..e3d86b80c --- /dev/null +++ b/src/main/java/de/gultsch/minidns/DNSSocket.java @@ -0,0 +1,200 @@ +package de.gultsch.minidns; + +import android.util.Log; + +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; + +import de.measite.minidns.DNSMessage; + +import eu.siacs.conversations.Config; + +import org.conscrypt.OkHostnameVerifier; + +import java.io.Closeable; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +final class DNSSocket implements Closeable { + + public static final int QUERY_TIMEOUT = 5_000; + + private final Semaphore semaphore = new Semaphore(1); + private final Map> inFlightQueries = new HashMap<>(); + private final Socket socket; + private final DataInputStream dataInputStream; + private final DataOutputStream dataOutputStream; + + private DNSSocket( + final Socket socket, + final DataInputStream dataInputStream, + final DataOutputStream dataOutputStream) { + this.socket = socket; + this.dataInputStream = dataInputStream; + this.dataOutputStream = dataOutputStream; + new Thread(this::readDNSMessages).start(); + } + + private void readDNSMessages() { + try { + while (socket.isConnected()) { + final DNSMessage response = readDNSMessage(); + final SettableFuture future; + synchronized (inFlightQueries) { + future = inFlightQueries.remove(response.id); + } + if (future != null) { + future.set(response); + } else { + Log.e(Config.LOGTAG, "no in flight query found for response id " + response.id); + } + } + evictInFlightQueries(new EOFException()); + } catch (final IOException e) { + evictInFlightQueries(e); + } + } + + private void evictInFlightQueries(final Exception e) { + synchronized (inFlightQueries) { + final Iterator>> iterator = + inFlightQueries.entrySet().iterator(); + while (iterator.hasNext()) { + final Map.Entry> entry = iterator.next(); + entry.getValue().setException(e); + iterator.remove(); + } + } + } + + private static DNSSocket of(final Socket socket) throws IOException { + final DataInputStream dataInputStream = new DataInputStream(socket.getInputStream()); + final DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream()); + return new DNSSocket(socket, dataInputStream, dataOutputStream); + } + + public static DNSSocket connect(final DNSServer dnsServer) throws IOException { + switch (dnsServer.uniqueTransport()) { + case TCP: + return connectTcpSocket(dnsServer); + case TLS: + return connectTlsSocket(dnsServer); + default: + throw new IllegalStateException("This is not a socket based transport"); + } + } + + private static DNSSocket connectTcpSocket(final DNSServer dnsServer) throws IOException { + Preconditions.checkArgument(dnsServer.uniqueTransport() == Transport.TCP); + final SocketAddress socketAddress = + new InetSocketAddress(dnsServer.inetAddress, dnsServer.port); + final Socket socket = new Socket(); + socket.connect(socketAddress, QUERY_TIMEOUT / 2); + socket.setSoTimeout(QUERY_TIMEOUT); + return DNSSocket.of(socket); + } + + private static DNSSocket connectTlsSocket(final DNSServer dnsServer) throws IOException { + Preconditions.checkArgument(dnsServer.uniqueTransport() == Transport.TLS); + final SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault(); + final SSLSocket sslSocket = (SSLSocket) factory.createSocket(); + if (Strings.isNullOrEmpty(dnsServer.hostname)) { + final SocketAddress socketAddress = + new InetSocketAddress(dnsServer.inetAddress, dnsServer.port); + sslSocket.connect(socketAddress, QUERY_TIMEOUT / 2); + sslSocket.setSoTimeout(QUERY_TIMEOUT); + sslSocket.startHandshake(); + } else { + final SocketAddress socketAddress = + new InetSocketAddress(dnsServer.hostname, dnsServer.port); + sslSocket.connect(socketAddress, QUERY_TIMEOUT / 2); + sslSocket.setSoTimeout(QUERY_TIMEOUT); + sslSocket.startHandshake(); + final SSLSession session = sslSocket.getSession(); + final Certificate[] peerCertificates = session.getPeerCertificates(); + if (peerCertificates.length == 0 || !(peerCertificates[0] instanceof X509Certificate)) { + throw new IOException("Peer did not provide X509 certificates"); + } + final X509Certificate certificate = (X509Certificate) peerCertificates[0]; + if (!OkHostnameVerifier.strictInstance().verify(dnsServer.hostname, certificate)) { + throw new SSLPeerUnverifiedException("Peer did not provide valid certificates"); + } + } + return DNSSocket.of(sslSocket); + } + + public DNSMessage query(final DNSMessage query) throws IOException, InterruptedException { + try { + return queryAsync(query).get(QUERY_TIMEOUT, TimeUnit.MILLISECONDS); + } catch (final ExecutionException e) { + final Throwable cause = e.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } else { + throw new IOException(e); + } + } catch (final TimeoutException e) { + throw new IOException(e); + } + } + + public ListenableFuture queryAsync(final DNSMessage query) + throws InterruptedException, IOException { + final SettableFuture responseFuture = SettableFuture.create(); + synchronized (this.inFlightQueries) { + this.inFlightQueries.put(query.id, responseFuture); + } + this.semaphore.acquire(); + try { + query.writeTo(this.dataOutputStream); + this.dataOutputStream.flush(); + } finally { + this.semaphore.release(); + } + return responseFuture; + } + + private DNSMessage readDNSMessage() throws IOException { + final int length = this.dataInputStream.readUnsignedShort(); + byte[] data = new byte[length]; + int read = 0; + while (read < length) { + read += this.dataInputStream.read(data, read, length - read); + } + return NetworkDataSource.readDNSMessage(data); + } + + @Override + public void close() throws IOException { + this.socket.close(); + } + + public void closeQuietly() { + try { + this.socket.close(); + } catch (final IOException ignored) { + + } + } +} diff --git a/src/main/java/de/gultsch/minidns/NetworkDataSource.java b/src/main/java/de/gultsch/minidns/NetworkDataSource.java new file mode 100644 index 000000000..67a8f8c33 --- /dev/null +++ b/src/main/java/de/gultsch/minidns/NetworkDataSource.java @@ -0,0 +1,169 @@ +package de.gultsch.minidns; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.common.base.Throwables; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.cache.RemovalListener; +import com.google.common.collect.ImmutableList; + +import de.measite.minidns.DNSMessage; +import de.measite.minidns.MiniDNSException; +import de.measite.minidns.source.DNSDataSource; +import de.measite.minidns.util.MultipleIoException; + +import eu.siacs.conversations.Config; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +public class NetworkDataSource extends DNSDataSource { + + private static final LoadingCache socketCache = + CacheBuilder.newBuilder() + .removalListener( + (RemovalListener) + notification -> { + final DNSServer dnsServer = notification.getKey(); + final DNSSocket dnsSocket = notification.getValue(); + if (dnsSocket == null) { + return; + } + Log.d(Config.LOGTAG, "closing connection to " + dnsServer); + dnsSocket.closeQuietly(); + }) + .expireAfterAccess(5, TimeUnit.MINUTES) + .build( + new CacheLoader() { + @Override + @NonNull + public DNSSocket load(@NonNull final DNSServer dnsServer) + throws Exception { + Log.d(Config.LOGTAG, "establishing connection to " + dnsServer); + return DNSSocket.connect(dnsServer); + } + }); + + private static List transportsForPort(final int port) { + final ImmutableList.Builder transportBuilder = new ImmutableList.Builder<>(); + for (final Map.Entry entry : Transport.DEFAULT_PORTS.entrySet()) { + if (entry.getValue().equals(port)) { + transportBuilder.add(entry.getKey()); + } + } + return transportBuilder.build(); + } + + @Override + public DNSMessage query(final DNSMessage message, final InetAddress address, final int port) + throws IOException { + final List transports = transportsForPort(port); + Log.w( + Config.LOGTAG, + "using legacy DataSource interface. guessing transports " + + transports + + " from port"); + if (transports.isEmpty()) { + throw new IOException(String.format("No transports found for port %d", port)); + } + return query(message, new DNSServer(address, port, transports)); + } + + public DNSMessage query(final DNSMessage message, final DNSServer dnsServer) + throws IOException { + Log.d(Config.LOGTAG, "using " + dnsServer); + final List ioExceptions = new ArrayList<>(); + for (final Transport transport : dnsServer.transports) { + try { + final DNSMessage response = + queryWithUniqueTransport(message, dnsServer.asUniqueTransport(transport)); + if (response != null && !response.truncated) { + return response; + } + } catch (final IOException e) { + ioExceptions.add(e); + } catch (final InterruptedException e) { + throw new IOException(e); + } + } + MultipleIoException.throwIfRequired(ioExceptions); + return null; + } + + private DNSMessage queryWithUniqueTransport(final DNSMessage message, final DNSServer dnsServer) + throws IOException, InterruptedException { + final Transport transport = dnsServer.uniqueTransport(); + switch (transport) { + case UDP: + return queryUdp(message, dnsServer.inetAddress, dnsServer.port); + case TCP: + case TLS: + return queryDnsSocket(message, dnsServer); + default: + throw new IOException( + String.format("Transport %s has not been implemented", transport)); + } + } + + protected DNSMessage queryUdp( + final DNSMessage message, final InetAddress address, final int port) + throws IOException { + final DatagramPacket request = message.asDatagram(address, port); + final byte[] buffer = new byte[udpPayloadSize]; + try (final DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(timeout); + socket.send(request); + final DatagramPacket response = new DatagramPacket(buffer, buffer.length); + socket.receive(response); + final DNSMessage dnsMessage = readDNSMessage(response.getData()); + if (dnsMessage.id != message.id) { + throw new MiniDNSException.IdMismatch(message, dnsMessage); + } + return dnsMessage; + } + } + + protected DNSMessage queryDnsSocket(final DNSMessage message, final DNSServer dnsServer) + throws IOException, InterruptedException { + final DNSSocket cachedDnsSocket = socketCache.getIfPresent(dnsServer); + if (cachedDnsSocket != null) { + try { + return cachedDnsSocket.query(message); + } catch (final IOException e) { + Log.d( + Config.LOGTAG, + "IOException occurred at cached socket. invalidating and falling through to new socket creation"); + socketCache.invalidate(dnsServer); + } + } + try { + return socketCache.get(dnsServer).query(message); + } catch (final ExecutionException e) { + final Throwable cause = e.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } else { + throw new IOException(cause); + } + } + } + + public static DNSMessage readDNSMessage(final byte[] bytes) throws IOException { + try { + return new DNSMessage(bytes); + } catch (final IllegalArgumentException e) { + throw new IOException(Throwables.getRootCause(e)); + } + } +} diff --git a/src/main/java/de/gultsch/minidns/Transport.java b/src/main/java/de/gultsch/minidns/Transport.java new file mode 100644 index 000000000..3aabfacaa --- /dev/null +++ b/src/main/java/de/gultsch/minidns/Transport.java @@ -0,0 +1,23 @@ +package de.gultsch.minidns; + +import com.google.common.collect.ImmutableMap; + +import java.util.Map; + +public enum Transport { + UDP, + TCP, + TLS, + HTTPS; + + public static final Map DEFAULT_PORTS; + + static { + final ImmutableMap.Builder builder = new ImmutableMap.Builder<>(); + builder.put(Transport.UDP, 53); + builder.put(Transport.TCP, 53); + builder.put(Transport.TLS, 853); + builder.put(Transport.HTTPS, 443); + DEFAULT_PORTS = builder.build(); + } +} diff --git a/src/main/java/eu/siacs/conversations/Config.java b/src/main/java/eu/siacs/conversations/Config.java index 4f98732ff..41fdb9b42 100644 --- a/src/main/java/eu/siacs/conversations/Config.java +++ b/src/main/java/eu/siacs/conversations/Config.java @@ -41,6 +41,8 @@ public final class Config { public static final String LOGTAG = BuildConfig.APP_NAME.toLowerCase(Locale.US); + public static final boolean QUICK_LOG = false; + public static final Jid BUG_REPORTS = Jid.of("bugs@snikket.org"); public static final Uri HELP = Uri.parse("https://snikket.org/faq/?ref=app"); @@ -87,6 +89,8 @@ public final class Config { public static final boolean XEP_0392 = true; //enables XEP-0392 v0.6.0 + + // media file formats. Homogenous Android or Conversations only deployments can switch to opus and webp public static final int AVATAR_SIZE = 192; public static final Bitmap.CompressFormat AVATAR_FORMAT = Bitmap.CompressFormat.JPEG; public static final int AVATAR_CHAR_LIMIT = 9400; @@ -95,6 +99,8 @@ public final class Config { public static final Bitmap.CompressFormat IMAGE_FORMAT = Bitmap.CompressFormat.JPEG; public static final int IMAGE_QUALITY = 75; + public static final boolean USE_OPUS_VOICE_MESSAGES = false; + public static final int MESSAGE_MERGE_WINDOW = 20; public static final int PAGE_SIZE = 50; @@ -115,10 +121,7 @@ public final class Config { public static final boolean OMEMO_PADDING = false; public static final boolean PUT_AUTH_TAG_INTO_KEY = true; public static final boolean AUTOMATICALLY_COMPLETE_SESSIONS = true; - - public static final boolean USE_BOOKMARKS2 = false; - - public static final boolean DISABLE_PROXY_LOOKUP = false; //useful to debug ibb + public static final boolean DISABLE_PROXY_LOOKUP = false; //disables STUN/TURN and Proxy65 look up (useful to debug IBB fallback) public static final boolean USE_DIRECT_JINGLE_CANDIDATES = true; public static final boolean DISABLE_HTTP_UPLOAD = false; public static final boolean EXTENDED_SM_LOGGING = false; // log stanza counts diff --git a/src/main/java/eu/siacs/conversations/android/JabberIdContact.java b/src/main/java/eu/siacs/conversations/android/JabberIdContact.java index 0b701d27a..32d5a53e7 100644 --- a/src/main/java/eu/siacs/conversations/android/JabberIdContact.java +++ b/src/main/java/eu/siacs/conversations/android/JabberIdContact.java @@ -8,28 +8,39 @@ import android.os.Build; import android.provider.ContactsContract; import android.util.Log; +import eu.siacs.conversations.Config; +import eu.siacs.conversations.services.QuickConversationsService; +import eu.siacs.conversations.xmpp.Jid; + import java.util.Collections; import java.util.HashMap; import java.util.Map; -import eu.siacs.conversations.Config; -import eu.siacs.conversations.xmpp.Jid; - public class JabberIdContact extends AbstractPhoneContact { - private static final String[] PROJECTION = new String[]{ContactsContract.Data._ID, - ContactsContract.Data.DISPLAY_NAME, - ContactsContract.Data.PHOTO_URI, - ContactsContract.Data.LOOKUP_KEY, - ContactsContract.CommonDataKinds.Im.DATA - }; - private static final String SELECTION = ContactsContract.Data.MIMETYPE + "=? AND (" + ContactsContract.CommonDataKinds.Im.PROTOCOL + "=? or (" + ContactsContract.CommonDataKinds.Im.PROTOCOL + "=? and lower(" + ContactsContract.CommonDataKinds.Im.CUSTOM_PROTOCOL + ")=?))"; + private static final String[] PROJECTION = + new String[] { + ContactsContract.Data._ID, + ContactsContract.Data.DISPLAY_NAME, + ContactsContract.Data.PHOTO_URI, + ContactsContract.Data.LOOKUP_KEY, + ContactsContract.CommonDataKinds.Im.DATA + }; + private static final String SELECTION = + ContactsContract.Data.MIMETYPE + + "=? AND (" + + ContactsContract.CommonDataKinds.Im.PROTOCOL + + "=? or (" + + ContactsContract.CommonDataKinds.Im.PROTOCOL + + "=? and lower(" + + ContactsContract.CommonDataKinds.Im.CUSTOM_PROTOCOL + + ")=?))"; private static final String[] SELECTION_ARGS = { - ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE, - String.valueOf(ContactsContract.CommonDataKinds.Im.PROTOCOL_JABBER), - String.valueOf(ContactsContract.CommonDataKinds.Im.PROTOCOL_CUSTOM), - "xmpp" + ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE, + String.valueOf(ContactsContract.CommonDataKinds.Im.PROTOCOL_JABBER), + String.valueOf(ContactsContract.CommonDataKinds.Im.PROTOCOL_CUSTOM), + "xmpp" }; private final Jid jid; @@ -37,8 +48,12 @@ public class JabberIdContact extends AbstractPhoneContact { private JabberIdContact(Cursor cursor) throws IllegalArgumentException { super(cursor); try { - this.jid = Jid.of(cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Im.DATA))); - } catch (IllegalArgumentException | NullPointerException e) { + this.jid = + Jid.of( + cursor.getString( + cursor.getColumnIndexOrThrow( + ContactsContract.CommonDataKinds.Im.DATA))); + } catch (final IllegalArgumentException | NullPointerException e) { throw new IllegalArgumentException(e); } } @@ -47,11 +62,21 @@ public class JabberIdContact extends AbstractPhoneContact { return jid; } - public static Map load(Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) { + public static Map load(final Context context) { + if (!QuickConversationsService.isContactListIntegration(context) + || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && context.checkSelfPermission(Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED)) { return Collections.emptyMap(); } - try (final Cursor cursor = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, PROJECTION, SELECTION, SELECTION_ARGS, null)) { + try (final Cursor cursor = + context.getContentResolver() + .query( + ContactsContract.Data.CONTENT_URI, + PROJECTION, + SELECTION, + SELECTION_ARGS, + null)) { if (cursor == null) { return Collections.emptyMap(); } diff --git a/src/main/java/eu/siacs/conversations/crypto/BundledTrustManager.java b/src/main/java/eu/siacs/conversations/crypto/BundledTrustManager.java new file mode 100644 index 000000000..9eb20cc30 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/crypto/BundledTrustManager.java @@ -0,0 +1,65 @@ +package eu.siacs.conversations.crypto; + +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.net.ssl.X509TrustManager; + +public class BundledTrustManager implements X509TrustManager { + + private final X509TrustManager delegate; + + private BundledTrustManager(final KeyStore keyStore) + throws NoSuchAlgorithmException, KeyStoreException { + this.delegate = TrustManagers.createTrustManager(keyStore); + } + + public static Builder builder() throws KeyStoreException { + return new Builder(); + } + + @Override + public void checkClientTrusted(final X509Certificate[] chain, final String authType) + throws CertificateException { + this.delegate.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(final X509Certificate[] chain, final String authType) + throws CertificateException { + this.delegate.checkServerTrusted(chain, authType); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return this.delegate.getAcceptedIssuers(); + } + + public static class Builder { + + private KeyStore keyStore; + + private Builder() {} + + public Builder loadKeyStore(final InputStream inputStream, final String password) + throws CertificateException, IOException, NoSuchAlgorithmException, + KeyStoreException { + if (this.keyStore != null) { + throw new IllegalStateException("KeyStore has already been loaded"); + } + final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(inputStream, password.toCharArray()); + this.keyStore = keyStore; + return this; + } + + public BundledTrustManager build() throws NoSuchAlgorithmException, KeyStoreException { + return new BundledTrustManager(keyStore); + } + } +} diff --git a/src/main/java/eu/siacs/conversations/crypto/CombiningTrustManager.java b/src/main/java/eu/siacs/conversations/crypto/CombiningTrustManager.java new file mode 100644 index 000000000..0f3c0e044 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/crypto/CombiningTrustManager.java @@ -0,0 +1,96 @@ +package eu.siacs.conversations.crypto; + +import android.util.Log; + +import com.google.common.collect.ImmutableList; + +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; + +import javax.net.ssl.X509TrustManager; + +import eu.siacs.conversations.Config; + +public class CombiningTrustManager implements X509TrustManager { + + private final List trustManagers; + + private CombiningTrustManager(final List trustManagers) { + this.trustManagers = trustManagers; + } + + @Override + public void checkClientTrusted(final X509Certificate[] chain, final String authType) + throws CertificateException { + for (final Iterator iterator = this.trustManagers.iterator(); + iterator.hasNext(); ) { + final X509TrustManager trustManager = iterator.next(); + try { + trustManager.checkClientTrusted(chain, authType); + } catch (final CertificateException certificateException) { + if (iterator.hasNext()) { + continue; + } + throw certificateException; + } + } + } + + @Override + public void checkServerTrusted(final X509Certificate[] chain, final String authType) + throws CertificateException { + Log.d( + Config.LOGTAG, + CombiningTrustManager.class.getSimpleName() + + " is configured with " + + this.trustManagers.size() + + " TrustManagers"); + int i = 0; + for (final Iterator iterator = this.trustManagers.iterator(); + iterator.hasNext(); ) { + final X509TrustManager trustManager = iterator.next(); + try { + trustManager.checkServerTrusted(chain, authType); + Log.d( + Config.LOGTAG, + "certificate check passed on " + trustManager.getClass().getName()+". chain length was "+chain.length); + return; + } catch (final CertificateException certificateException) { + Log.d( + Config.LOGTAG, + "failed to verify in [" + i + "]/" + trustManager.getClass().getName(), + certificateException); + if (iterator.hasNext()) { + continue; + } + throw certificateException; + } finally { + ++i; + } + } + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + final ImmutableList.Builder certificates = ImmutableList.builder(); + for (final X509TrustManager trustManager : this.trustManagers) { + for (final X509Certificate certificate : trustManager.getAcceptedIssuers()) { + certificates.add(certificate); + } + } + return certificates.build().toArray(new X509Certificate[0]); + } + + public static X509TrustManager combineWithDefault(final X509TrustManager... trustManagers) + throws NoSuchAlgorithmException, KeyStoreException { + final ImmutableList.Builder builder = ImmutableList.builder(); + builder.addAll(Arrays.asList(trustManagers)); + builder.add(TrustManagers.createDefaultTrustManager()); + return new CombiningTrustManager(builder.build()); + } +} diff --git a/src/main/java/eu/siacs/conversations/crypto/PgpDecryptionService.java b/src/main/java/eu/siacs/conversations/crypto/PgpDecryptionService.java index db84e0cf4..68447e552 100644 --- a/src/main/java/eu/siacs/conversations/crypto/PgpDecryptionService.java +++ b/src/main/java/eu/siacs/conversations/crypto/PgpDecryptionService.java @@ -156,7 +156,8 @@ public class PgpDecryptionService { && manager.getAutoAcceptFileSize() > 0) { manager.createNewDownloadConnection(message); } - } catch (IOException e) { + } catch (final IOException e) { + Log.d(Config.LOGTAG,"decryption failed", e); message.setEncryption(Message.ENCRYPTION_DECRYPTION_FAILED); } mXmppConnectionService.updateMessage(message); @@ -170,6 +171,7 @@ public class PgpDecryptionService { } break; case OpenPgpApi.RESULT_CODE_ERROR: + Log.d(Config.LOGTAG,"decryption failed (api error)"); message.setEncryption(Message.ENCRYPTION_DECRYPTION_FAILED); mXmppConnectionService.updateMessage(message); break; diff --git a/src/main/java/eu/siacs/conversations/crypto/PgpEngine.java b/src/main/java/eu/siacs/conversations/crypto/PgpEngine.java index 9652ad3eb..d3588a995 100644 --- a/src/main/java/eu/siacs/conversations/crypto/PgpEngine.java +++ b/src/main/java/eu/siacs/conversations/crypto/PgpEngine.java @@ -285,7 +285,9 @@ public class PgpEngine { Intent params = new Intent(); params.setAction(OpenPgpApi.ACTION_GET_KEY); params.putExtra(OpenPgpApi.EXTRA_KEY_ID, pgpKeyId); - Intent result = api.executeApi(params, null, null); + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + final ByteArrayInputStream inputStream = new ByteArrayInputStream(new byte[0]); + Intent result = api.executeApi(params, inputStream, outputStream); return (PendingIntent) result.getParcelableExtra(OpenPgpApi.RESULT_INTENT); } } diff --git a/src/main/java/eu/siacs/conversations/crypto/TrustManagers.java b/src/main/java/eu/siacs/conversations/crypto/TrustManagers.java new file mode 100644 index 000000000..11fe182dd --- /dev/null +++ b/src/main/java/eu/siacs/conversations/crypto/TrustManagers.java @@ -0,0 +1,38 @@ +package eu.siacs.conversations.crypto; + +import androidx.annotation.Nullable; + +import com.google.common.collect.Iterables; + +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +public final class TrustManagers { + + private TrustManagers() { + throw new IllegalStateException("Do not instantiate me"); + } + + public static X509TrustManager createTrustManager(@Nullable final KeyStore keyStore) + throws NoSuchAlgorithmException, KeyStoreException { + final TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keyStore); + return Iterables.getOnlyElement( + Iterables.filter( + Arrays.asList(trustManagerFactory.getTrustManagers()), + X509TrustManager.class)); + } + + public static X509TrustManager createDefaultTrustManager() + throws NoSuchAlgorithmException, KeyStoreException { + return createTrustManager(null); + } + + +} 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 05ffdbdca..3721f4cfe 100644 --- a/src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java +++ b/src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java @@ -62,11 +62,13 @@ import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.OnAdvancedStreamFeaturesLoaded; import eu.siacs.conversations.xmpp.OnIqPacketReceived; +import eu.siacs.conversations.xmpp.jingle.DescriptionTransport; import eu.siacs.conversations.xmpp.jingle.OmemoVerification; import eu.siacs.conversations.xmpp.jingle.OmemoVerifiedRtpContentMap; import eu.siacs.conversations.xmpp.jingle.RtpContentMap; import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; import eu.siacs.conversations.xmpp.pep.PublishOptions; import eu.siacs.conversations.xmpp.stanzas.IqPacket; import eu.siacs.conversations.xmpp.stanzas.MessagePacket; @@ -736,8 +738,12 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { return axolotlStore.getFingerprintCertificate(fingerprint); } - public void setFingerprintTrust(String fingerprint, FingerprintStatus status) { + public void setFingerprintTrust(final String fingerprint, final FingerprintStatus status) { axolotlStore.setFingerprintStatus(fingerprint, status); + // TODO we decided to call this after a fingerprint gets toggled to update the 'your contact + // is using unverified devices text'; however this means the entire screen gets redrawn + // after a toggle which might be annoying or cause other weird UI glitches + mXmppConnectionService.updateAccountUi(); } private ListenableFuture verifySessionWithPEP(final XmppAxolotlSession session) { @@ -1258,12 +1264,12 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { if (Config.REQUIRE_RTP_VERIFICATION) { requireVerification(session); } - final ImmutableMap.Builder descriptionTransportBuilder = new ImmutableMap.Builder<>(); + final ImmutableMap.Builder> descriptionTransportBuilder = new ImmutableMap.Builder<>(); final OmemoVerification omemoVerification = new OmemoVerification(); omemoVerification.setDeviceId(session.getRemoteAddress().getDeviceId()); omemoVerification.setSessionFingerprint(session.getFingerprint()); - for (final Map.Entry content : rtpContentMap.contents.entrySet()) { - final RtpContentMap.DescriptionTransport descriptionTransport = content.getValue(); + for (final Map.Entry> content : rtpContentMap.contents.entrySet()) { + final DescriptionTransport descriptionTransport = content.getValue(); final OmemoVerifiedIceUdpTransportInfo encryptedTransportInfo; try { encryptedTransportInfo = encrypt(descriptionTransport.transport, session); @@ -1272,7 +1278,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { } descriptionTransportBuilder.put( content.getKey(), - new RtpContentMap.DescriptionTransport(descriptionTransport.senders, descriptionTransport.description, encryptedTransportInfo) + new DescriptionTransport<>(descriptionTransport.senders, descriptionTransport.description, encryptedTransportInfo) ); } return Futures.immediateFuture( @@ -1292,11 +1298,11 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { } public ListenableFuture> decrypt(OmemoVerifiedRtpContentMap omemoVerifiedRtpContentMap, final Jid from) { - final ImmutableMap.Builder descriptionTransportBuilder = new ImmutableMap.Builder<>(); + final ImmutableMap.Builder> descriptionTransportBuilder = new ImmutableMap.Builder<>(); final OmemoVerification omemoVerification = new OmemoVerification(); final ImmutableList.Builder> pepVerificationFutures = new ImmutableList.Builder<>(); - for (final Map.Entry content : omemoVerifiedRtpContentMap.contents.entrySet()) { - final RtpContentMap.DescriptionTransport descriptionTransport = content.getValue(); + for (final Map.Entry> content : omemoVerifiedRtpContentMap.contents.entrySet()) { + final DescriptionTransport descriptionTransport = content.getValue(); final OmemoVerifiedPayload decryptedTransport; try { decryptedTransport = decrypt((OmemoVerifiedIceUdpTransportInfo) descriptionTransport.transport, from, pepVerificationFutures); @@ -1306,7 +1312,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { omemoVerification.setOrEnsureEqual(decryptedTransport); descriptionTransportBuilder.put( content.getKey(), - new RtpContentMap.DescriptionTransport(descriptionTransport.senders, descriptionTransport.description, decryptedTransport.payload) + new DescriptionTransport<>(descriptionTransport.senders, descriptionTransport.description, decryptedTransport.payload) ); } processPostponed(); @@ -1372,18 +1378,15 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { )); } - 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 ListenableFuture prepareKeyTransportMessage(final Conversation conversation) { + return Futures.submit(()->{ + final XmppAxolotlMessage axolotlMessage = new XmppAxolotlMessage(account.getJid().asBareJid(), getOwnDeviceId()); + if (buildHeader(axolotlMessage, conversation)) { + return axolotlMessage; + } else { + throw new IllegalStateException("No session to decrypt to"); } - }); + },executor); } public XmppAxolotlMessage fetchAxolotlMessageFromCache(Message message) { diff --git a/src/main/java/eu/siacs/conversations/crypto/axolotl/FingerprintStatus.java b/src/main/java/eu/siacs/conversations/crypto/axolotl/FingerprintStatus.java index 2f1856d09..dffde90a1 100644 --- a/src/main/java/eu/siacs/conversations/crypto/axolotl/FingerprintStatus.java +++ b/src/main/java/eu/siacs/conversations/crypto/axolotl/FingerprintStatus.java @@ -97,6 +97,10 @@ public class FingerprintStatus implements Comparable { return trust == Trust.TRUSTED || isVerified(); } + public boolean isUnverified() { + return trust == Trust.TRUSTED; + } + public boolean isVerified() { return trust == Trust.VERIFIED || trust == Trust.VERIFIED_X509; } diff --git a/src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBinding.java b/src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBinding.java index 216f3d7f8..2eb5e39fb 100644 --- a/src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBinding.java +++ b/src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBinding.java @@ -117,4 +117,14 @@ public enum ChannelBinding { throw new AssertionError("Missing short name for " + channelBinding); } } + + public static int priority(final ChannelBinding channelBinding) { + if (Arrays.asList(TLS_EXPORTER,TLS_UNIQUE).contains(channelBinding)) { + return 2; + } else if (channelBinding == ChannelBinding.TLS_SERVER_END_POINT) { + return 1; + } else { + return 0; + } + } } diff --git a/src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBindingMechanism.java b/src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBindingMechanism.java index b94210a60..7343eb86e 100644 --- a/src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBindingMechanism.java +++ b/src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBindingMechanism.java @@ -97,4 +97,13 @@ public interface ChannelBindingMechanism { messageDigest.update(encodedCertificate); return messageDigest.digest(); } + + static int getPriority(final SaslMechanism mechanism) { + if (mechanism instanceof ChannelBindingMechanism) { + final ChannelBindingMechanism channelBindingMechanism = (ChannelBindingMechanism) mechanism; + return ChannelBinding.priority(channelBindingMechanism.getChannelBinding()); + } else { + return 0; + } + } } diff --git a/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1Plus.java b/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1Plus.java index 2ca27570f..4490d7621 100644 --- a/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1Plus.java +++ b/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1Plus.java @@ -27,7 +27,7 @@ public class ScramSha1Plus extends ScramPlusMechanism { @Override public int getPriority() { - return 35; // higher than SCRAM-SHA512 (30) + return 35 + ChannelBinding.priority(this.channelBinding); // higher than SCRAM-SHA512 (30) } @Override diff --git a/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256Plus.java b/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256Plus.java index 4db33a2fa..eafc86fbc 100644 --- a/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256Plus.java +++ b/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256Plus.java @@ -27,7 +27,7 @@ public class ScramSha256Plus extends ScramPlusMechanism { @Override public int getPriority() { - return 40; + return 40 + ChannelBinding.priority(this.channelBinding); } @Override diff --git a/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512Plus.java b/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512Plus.java index 5d8461973..d110e7708 100644 --- a/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512Plus.java +++ b/src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512Plus.java @@ -27,7 +27,7 @@ public class ScramSha512Plus extends ScramPlusMechanism { @Override public int getPriority() { - return 45; + return 45 + ChannelBinding.priority(this.channelBinding); } @Override diff --git a/src/main/java/eu/siacs/conversations/entities/Account.java b/src/main/java/eu/siacs/conversations/entities/Account.java index bfbe817cb..3146abe46 100644 --- a/src/main/java/eu/siacs/conversations/entities/Account.java +++ b/src/main/java/eu/siacs/conversations/entities/Account.java @@ -31,7 +31,6 @@ import eu.siacs.conversations.crypto.sasl.HashedToken; import eu.siacs.conversations.crypto.sasl.HashedTokenSha256; import eu.siacs.conversations.crypto.sasl.HashedTokenSha512; import eu.siacs.conversations.crypto.sasl.SaslMechanism; -import eu.siacs.conversations.crypto.sasl.ScramPlusMechanism; import eu.siacs.conversations.services.AvatarService; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.utils.UIHelper; @@ -71,6 +70,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable public static final int OPTION_UNVERIFIED = 8; public static final int OPTION_FIXED_USERNAME = 9; public static final int OPTION_QUICKSTART_AVAILABLE = 10; + public static final int OPTION_SOFT_DISABLED = 11; private static final String KEY_PGP_SIGNATURE = "pgp_signature"; private static final String KEY_PGP_ID = "pgp_id"; @@ -249,11 +249,18 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable return !isOptionSet(Account.OPTION_DISABLED); } + public boolean isConnectionEnabled() { + return !isOptionSet(Account.OPTION_DISABLED) && !isOptionSet(Account.OPTION_SOFT_DISABLED); + } + public boolean isOptionSet(final int option) { return ((options & (1 << option)) != 0); } public boolean setOption(final int option, final boolean value) { + if (value && (option == OPTION_DISABLED || option == OPTION_SOFT_DISABLED)) { + this.setStatus(State.OFFLINE); + } final int before = this.options; if (value) { this.options |= 1 << option; @@ -323,11 +330,17 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable public State getStatus() { if (isOptionSet(OPTION_DISABLED)) { return State.DISABLED; + } else if (isOptionSet(OPTION_SOFT_DISABLED)) { + return State.LOGGED_OUT; } else { return this.status; } } + public boolean unauthorized() { + return this.status == State.UNAUTHORIZED || this.lastErrorStatus == State.UNAUTHORIZED; + } + public State getLastErrorStatus() { return this.lastErrorStatus; } @@ -762,6 +775,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable public enum State { DISABLED(false, false), + LOGGED_OUT(false,false), OFFLINE(false), CONNECTING(false), ONLINE(false), @@ -787,6 +801,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable BIND_FAILURE, HOST_UNKNOWN, STREAM_ERROR, + SEE_OTHER_HOST, STREAM_OPENING_ERROR, POLICY_VIOLATION, PAYMENT_REQUIRED, @@ -820,6 +835,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable switch (this) { case DISABLED: return R.string.account_status_disabled; + case LOGGED_OUT: + return R.string.account_state_logged_out; case ONLINE: return R.string.account_status_online; case CONNECTING: @@ -874,6 +891,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable return R.string.account_status_stream_opening_error; case PAYMENT_REQUIRED: return R.string.payment_required; + case SEE_OTHER_HOST: + return R.string.reconnect_on_other_host; case MISSING_INTERNET_PERMISSION: return R.string.missing_internet_permission; case TEMPORARY_AUTH_FAILURE: diff --git a/src/main/java/eu/siacs/conversations/entities/Bookmark.java b/src/main/java/eu/siacs/conversations/entities/Bookmark.java index 7ded4f842..24107ec8f 100644 --- a/src/main/java/eu/siacs/conversations/entities/Bookmark.java +++ b/src/main/java/eu/siacs/conversations/entities/Bookmark.java @@ -25,6 +25,7 @@ public class Bookmark extends Element implements ListItem { private final Account account; private WeakReference conversation; private Jid jid; + protected Element extensions = new Element("extensions", Namespace.BOOKMARKS2); public Bookmark(final Account account, final Jid jid) { super("conference"); @@ -101,9 +102,18 @@ public class Bookmark extends Element implements ListItem { bookmark.setBookmarkName(conference.getAttribute("name")); bookmark.setAutojoin(conference.getAttributeAsBoolean("autojoin")); bookmark.setNick(conference.findChildContent("nick")); + bookmark.setPassword(conference.findChildContent("password")); + final Element extensions = conference.findChild("extensions", Namespace.BOOKMARKS2); + if (extensions != null) { + bookmark.extensions = extensions; + } return bookmark; } + public Element getExtensions() { + return extensions; + } + public void setAutojoin(boolean autojoin) { if (autojoin) { this.setAttribute("autojoin", "true"); diff --git a/src/main/java/eu/siacs/conversations/entities/MucOptions.java b/src/main/java/eu/siacs/conversations/entities/MucOptions.java index cc1c358de..c408d147f 100644 --- a/src/main/java/eu/siacs/conversations/entities/MucOptions.java +++ b/src/main/java/eu/siacs/conversations/entities/MucOptions.java @@ -170,7 +170,9 @@ public class MucOptions { } public boolean participantsCanChangeSubject() { - final Field field = getRoomInfoForm().getFieldByName("muc#roominfo_changesubject"); + final Field configField = getRoomInfoForm().getFieldByName("muc#roomconfig_changesubject"); + final Field infoField = getRoomInfoForm().getFieldByName("muc#roominfo_changesubject"); + final Field field = configField != null ? configField : infoField; return field != null && "1".equals(field.getValue()); } diff --git a/src/main/java/eu/siacs/conversations/entities/Presences.java b/src/main/java/eu/siacs/conversations/entities/Presences.java index 04d378cc2..59480b0ce 100644 --- a/src/main/java/eu/siacs/conversations/entities/Presences.java +++ b/src/main/java/eu/siacs/conversations/entities/Presences.java @@ -134,20 +134,6 @@ public class Presences { return true; } - public boolean anySupport(final String namespace) { - synchronized (this.presences) { - if (this.presences.size() == 0) { - return true; - } - for (Presence presence : this.presences.values()) { - ServiceDiscoveryResult disco = presence.getServiceDiscoveryResult(); - if (disco != null && disco.getFeatures().contains(namespace)) { - return true; - } - } - } - return false; - } public Pair, Map> toTypeAndNameMap() { Map typeMap = new HashMap<>(); diff --git a/src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java b/src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java index 8eccbe141..8c63a2670 100644 --- a/src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java +++ b/src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java @@ -17,6 +17,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.List; import eu.siacs.conversations.xml.Element; @@ -100,9 +101,9 @@ public class ServiceDiscoveryResult { public ServiceDiscoveryResult(Cursor cursor) throws JSONException { this( - cursor.getString(cursor.getColumnIndex(HASH)), - Base64.decode(cursor.getString(cursor.getColumnIndex(VER)), Base64.DEFAULT), - new JSONObject(cursor.getString(cursor.getColumnIndex(RESULT))) + cursor.getString(cursor.getColumnIndexOrThrow(HASH)), + Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(VER)), Base64.DEFAULT), + new JSONObject(cursor.getString(cursor.getColumnIndexOrThrow(RESULT))) ); } @@ -209,24 +210,23 @@ public class ServiceDiscoveryResult { .append("<"); } - List features = this.getFeatures(); + final List features = this.getFeatures(); Collections.sort(features); - - for (String feature : features) { + for (final String feature : features) { s.append(clean(feature)).append("<"); } - Collections.sort(forms, (lhs, rhs) -> lhs.getFormType().compareTo(rhs.getFormType())); - - for (Data form : forms) { + Collections.sort(forms, Comparator.comparing(Data::getFormType)); + for (final Data form : forms) { s.append(clean(form.getFormType())).append("<"); - List fields = form.getFields(); - Collections.sort(fields, (lhs, rhs) -> Strings.nullToEmpty(lhs.getFieldName()).compareTo(Strings.nullToEmpty(rhs.getFieldName()))); - for (Field field : fields) { + final List fields = form.getFields(); + Collections.sort( + fields, Comparator.comparing(lhs -> Strings.nullToEmpty(lhs.getFieldName()))); + for (final Field field : fields) { s.append(Strings.nullToEmpty(field.getFieldName())).append("<"); - List values = field.getValues(); - Collections.sort(values); - for (String value : values) { + final List values = field.getValues(); + Collections.sort(values, Comparator.comparing(ServiceDiscoveryResult::blankNull)); + for (final String value : values) { s.append(blankNull(value)).append("<"); } } diff --git a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java index 706b50043..42fc3c00f 100644 --- a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java @@ -27,11 +27,7 @@ public abstract class AbstractGenerator { private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); private final String[] FEATURES = { Namespace.JINGLE, - - //Jingle File Transfer - FileTransferDescription.Version.FT_3.getNamespace(), - FileTransferDescription.Version.FT_4.getNamespace(), - FileTransferDescription.Version.FT_5.getNamespace(), + Namespace.JINGLE_APPS_FILE_TRANSFER, Namespace.JINGLE_TRANSPORTS_S5B, Namespace.JINGLE_TRANSPORTS_IBB, Namespace.JINGLE_ENCRYPTED_TRANSPORT, @@ -124,6 +120,7 @@ public abstract class AbstractGenerator { if (!mXmppConnectionService.useTorToConnect() && !account.isOnion()) { features.addAll(Arrays.asList(PRIVACY_SENSITIVE)); features.addAll(Arrays.asList(VOIP_NAMESPACES)); + features.add(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL); } if (mXmppConnectionService.broadcastLastActivity()) { features.add(Namespace.IDLE); diff --git a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java index 52a19eaa4..c9fa7f6a6 100644 --- a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java @@ -247,6 +247,7 @@ public class IqGenerator extends AbstractGenerator { public Element publishBookmarkItem(final Bookmark bookmark) { final String name = bookmark.getBookmarkName(); final String nick = bookmark.getNick(); + final String password = bookmark.getPassword(); final boolean autojoin = bookmark.autojoin(); final Element conference = new Element("conference", Namespace.BOOKMARKS2); if (name != null) { @@ -255,7 +256,11 @@ public class IqGenerator extends AbstractGenerator { if (nick != null) { conference.addChild("nick").setContent(nick); } + if (password != null) { + conference.addChild("password").setContent(password); + } conference.setAttribute("autojoin",String.valueOf(autojoin)); + conference.addChild(bookmark.getExtensions()); return conference; } @@ -339,12 +344,18 @@ public class IqGenerator extends AbstractGenerator { return iq; } - public IqPacket generateSetBlockRequest(final Jid jid, boolean reportSpam) { + public IqPacket generateSetBlockRequest(final Jid jid, final boolean reportSpam, final String serverMsgId) { final IqPacket iq = new IqPacket(IqPacket.TYPE.SET); final Element block = iq.addChild("block", Namespace.BLOCKING); final Element item = block.addChild("item").setAttribute("jid", jid); if (reportSpam) { - item.addChild("report", "urn:xmpp:reporting:0").addChild("spam"); + final Element report = item.addChild("report", Namespace.REPORTING); + report.setAttribute("reason", Namespace.REPORTING_REASON_SPAM); + if (serverMsgId != null) { + final Element stanzaId = report.addChild("stanza-id", Namespace.STANZA_IDS); + stanzaId.setAttribute("by", jid); + stanzaId.setAttribute("id", serverMsgId); + } } Log.d(Config.LOGTAG, iq.toString()); return iq; diff --git a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java index 4b055e158..4e99ab086 100644 --- a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java @@ -194,8 +194,8 @@ public class MessageGenerator extends AbstractGenerator { return packet; } - public MessagePacket invite(Conversation conversation, Jid contact) { - MessagePacket packet = new MessagePacket(); + public MessagePacket invite(final Conversation conversation, final Jid contact) { + final MessagePacket packet = new MessagePacket(); packet.setTo(conversation.getJid().asBareJid()); packet.setFrom(conversation.getAccount().getJid()); Element x = new Element("x"); diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 46355354a..bf3cba178 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -52,7 +52,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm:ss", Locale.ENGLISH); - private static final List JINGLE_MESSAGE_ELEMENT_NAMES = Arrays.asList("accept", "propose", "proceed", "reject", "retract"); + private static final List JINGLE_MESSAGE_ELEMENT_NAMES = + Arrays.asList("accept", "propose", "proceed", "reject", "retract", "ringing"); public MessageParser(XmppConnectionService service) { super(service); @@ -169,14 +170,19 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece return null; } - private Invite extractInvite(Element message) { + private Invite extractInvite(final Element message) { final Element mucUser = message.findChild("x", Namespace.MUC_USER); if (mucUser != null) { - Element invite = mucUser.findChild("invite"); + final Element invite = mucUser.findChild("invite"); if (invite != null) { - String password = mucUser.findChildContent("password"); - Jid from = InvalidJid.getNullForInvalid(invite.getAttributeAsJid("from")); - Jid room = InvalidJid.getNullForInvalid(message.getAttributeAsJid("from")); + final String password = mucUser.findChildContent("password"); + final Jid from = InvalidJid.getNullForInvalid(invite.getAttributeAsJid("from")); + final Jid to = InvalidJid.getNullForInvalid(invite.getAttributeAsJid("to")); + if (to != null && from == null) { + Log.d(Config.LOGTAG,"do not parse outgoing mediated invite "+message); + return null; + } + final Jid room = InvalidJid.getNullForInvalid(message.getAttributeAsJid("from")); if (room == null) { return null; } @@ -453,8 +459,10 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece final Invite invite = extractInvite(packet); if (invite != null) { - if (isTypeGroupChat) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring invite to " + invite.jid + " because type=groupchat"); + if (invite.jid.asBareJid().equals(account.getJid().asBareJid())) { + Log.d(Config.LOGTAG,account.getJid().asBareJid()+": ignore invite to "+invite.jid+" because it matches account"); + } else if (isTypeGroupChat) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring invite to " + invite.jid + " because it was received as group chat"); } else if (invite.direct && (mucUserElement != null || invite.inviter == null || mXmppConnectionService.isMuc(account, invite.inviter))) { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring direct invite to " + invite.jid + " because it was received in MUC"); } else { @@ -782,7 +790,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } if (isTypeGroupChat) { - if (packet.hasChild("subject")) { //TODO usually we would want to check for lack of body; however some servers do set a body :( + if (packet.hasChild("subject") && !packet.hasChild("thread")) { // We already know it has no body per above if (conversation != null && conversation.getMode() == Conversation.MODE_MULTI) { conversation.setHasMessagesLeftOnServer(conversation.countMessages() > 0); final LocalizedContent subject = packet.findInternationalizedChildContentInDefaultNamespace("subject"); @@ -848,9 +856,22 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece if (serverMsgId == null) { serverMsgId = extractStanzaId(account, packet); } - mXmppConnectionService.getJingleConnectionManager().deliverMessage(account, packet.getTo(), packet.getFrom(), child, remoteMsgId, serverMsgId, timestamp); - if (!account.getJid().asBareJid().equals(from.asBareJid()) && remoteMsgId != null) { - processMessageReceipts(account, packet, remoteMsgId, query); + mXmppConnectionService + .getJingleConnectionManager() + .deliverMessage( + account, + packet.getTo(), + packet.getFrom(), + child, + remoteMsgId, + serverMsgId, + timestamp); + final Contact contact = account.getRoster().getContact(from); + if (mXmppConnectionService.confirmMessages() + && !contact.isSelf() + && remoteMsgId != null + && contact.showInContactList()) { + processMessageReceipts(account, packet, remoteMsgId, null); } } else if (query.isCatchup()) { if ("propose".equals(action)) { @@ -1083,22 +1104,26 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece this.inviter = inviter; } - public boolean execute(Account account) { - if (jid != null) { - Conversation conversation = mXmppConnectionService.findOrCreateConversation(account, jid, true, false); - if (conversation.getMucOptions().online()) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received invite to " + jid + " but muc is considered to be online"); - mXmppConnectionService.mucSelfPingAndRejoin(conversation); - } else { - conversation.getMucOptions().setPassword(password); - mXmppConnectionService.databaseBackend.updateConversation(conversation); - final Contact contact = inviter != null ? account.getRoster().getContactFromContactList(inviter) : null; - mXmppConnectionService.joinMuc(conversation, contact != null && contact.mutualPresenceSubscription()); - mXmppConnectionService.updateConversationUi(); - } - return true; + public boolean execute(final Account account) { + if (this.jid == null) { + return false; } - return false; + final Contact contact = this.inviter != null ? account.getRoster().getContact(this.inviter) : null; + if (contact != null && contact.isBlocked()) { + Log.d(Config.LOGTAG,account.getJid().asBareJid()+": ignore invite from "+contact.getJid()+" because contact is blocked"); + return false; + } + final Conversation conversation = mXmppConnectionService.findOrCreateConversation(account, jid, true, false); + if (conversation.getMucOptions().online()) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received invite to " + jid + " but muc is considered to be online"); + mXmppConnectionService.mucSelfPingAndRejoin(conversation); + } else { + conversation.getMucOptions().setPassword(password); + mXmppConnectionService.databaseBackend.updateConversation(conversation); + mXmppConnectionService.joinMuc(conversation, contact != null && contact.showInContactList()); + mXmppConnectionService.updateConversationUi(); + } + return true; } } } diff --git a/src/main/java/eu/siacs/conversations/parser/PresenceParser.java b/src/main/java/eu/siacs/conversations/parser/PresenceParser.java index 8ad582b17..584b8e704 100644 --- a/src/main/java/eu/siacs/conversations/parser/PresenceParser.java +++ b/src/main/java/eu/siacs/conversations/parser/PresenceParser.java @@ -2,11 +2,6 @@ package eu.siacs.conversations.parser; import android.util.Log; -import org.openintents.openpgp.util.OpenPgpUtils; - -import java.util.ArrayList; -import java.util.List; - import eu.siacs.conversations.Config; import eu.siacs.conversations.crypto.PgpEngine; import eu.siacs.conversations.crypto.axolotl.AxolotlService; @@ -28,123 +23,159 @@ import eu.siacs.conversations.xmpp.OnPresencePacketReceived; import eu.siacs.conversations.xmpp.pep.Avatar; import eu.siacs.conversations.xmpp.stanzas.PresencePacket; -public class PresenceParser extends AbstractParser implements - OnPresencePacketReceived { +import org.openintents.openpgp.util.OpenPgpUtils; - public PresenceParser(XmppConnectionService service) { - super(service); - } +import java.util.ArrayList; +import java.util.List; - public void parseConferencePresence(PresencePacket packet, Account account) { - final Conversation conversation = packet.getFrom() == null ? null : mXmppConnectionService.find(account, packet.getFrom().asBareJid()); - if (conversation != null) { - final MucOptions mucOptions = conversation.getMucOptions(); - boolean before = mucOptions.online(); - int count = mucOptions.getUserCount(); - final List tileUserBefore = mucOptions.getUsers(5); - processConferencePresence(packet, conversation); - final List tileUserAfter = mucOptions.getUsers(5); - if (!tileUserAfter.equals(tileUserBefore)) { - mXmppConnectionService.getAvatarService().clear(mucOptions); - } - if (before != mucOptions.online() || (mucOptions.online() && count != mucOptions.getUserCount())) { - mXmppConnectionService.updateConversationUi(); - } else if (mucOptions.online()) { - mXmppConnectionService.updateMucRosterUi(); - } - } - } +public class PresenceParser extends AbstractParser implements OnPresencePacketReceived { - private void processConferencePresence(PresencePacket packet, Conversation conversation) { - final Account account = conversation.getAccount(); - final MucOptions mucOptions = conversation.getMucOptions(); - final Jid jid = conversation.getAccount().getJid(); - final Jid from = packet.getFrom(); - if (!from.isBareJid()) { - final String type = packet.getAttribute("type"); - final Element x = packet.findChild("x", Namespace.MUC_USER); - Avatar avatar = Avatar.parsePresence(packet.findChild("x", "vcard-temp:x:update")); - final List codes = getStatusCodes(x); - if (type == null) { - if (x != null) { - Element item = x.findChild("item"); - if (item != null && !from.isBareJid()) { - mucOptions.setError(MucOptions.Error.NONE); - MucOptions.User user = parseItem(conversation, item, from); - if (codes.contains(MucOptions.STATUS_CODE_SELF_PRESENCE) || (codes.contains(MucOptions.STATUS_CODE_ROOM_CREATED) && jid.equals(InvalidJid.getNullForInvalid(item.getAttributeAsJid("jid"))))) { - if (mucOptions.setOnline()) { - mXmppConnectionService.getAvatarService().clear(mucOptions); - } - if (mucOptions.setSelf(user)) { - Log.d(Config.LOGTAG,"role or affiliation changed"); - mXmppConnectionService.databaseBackend.updateConversation(conversation); - } + public PresenceParser(XmppConnectionService service) { + super(service); + } - mXmppConnectionService.persistSelfNick(user); - invokeRenameListener(mucOptions, true); - } - boolean isNew = mucOptions.updateUser(user); - final AxolotlService axolotlService = conversation.getAccount().getAxolotlService(); - Contact contact = user.getContact(); - if (isNew - && user.getRealJid() != null - && mucOptions.isPrivateAndNonAnonymous() - && (contact == null || !contact.mutualPresenceSubscription()) - && axolotlService.hasEmptyDeviceList(user.getRealJid())) { - axolotlService.fetchDeviceIds(user.getRealJid()); - } - if (codes.contains(MucOptions.STATUS_CODE_ROOM_CREATED) && mucOptions.autoPushConfiguration()) { - Log.d(Config.LOGTAG,account.getJid().asBareJid() - +": room '" - +mucOptions.getConversation().getJid().asBareJid() - +"' created. pushing default configuration"); - mXmppConnectionService.pushConferenceConfiguration(mucOptions.getConversation(), - IqGenerator.defaultChannelConfiguration(), - null); - } - if (mXmppConnectionService.getPgpEngine() != null) { - Element signed = packet.findChild("x", "jabber:x:signed"); - if (signed != null) { - Element status = packet.findChild("status"); - String msg = status == null ? "" : status.getContent(); - long keyId = mXmppConnectionService.getPgpEngine().fetchKeyId(mucOptions.getAccount(), msg, signed.getContent()); - if (keyId != 0) { - user.setPgpKeyId(keyId); - } - } - } - if (avatar != null) { - avatar.owner = from; - if (mXmppConnectionService.getFileBackend().isAvatarCached(avatar)) { - if (user.setAvatar(avatar)) { - mXmppConnectionService.getAvatarService().clear(user); - } - if (user.getRealJid() != null) { - final Contact c = conversation.getAccount().getRoster().getContact(user.getRealJid()); - c.setAvatar(avatar); - mXmppConnectionService.syncRoster(conversation.getAccount()); - mXmppConnectionService.getAvatarService().clear(c); - mXmppConnectionService.updateRosterUi(); - } - } else if (mXmppConnectionService.isDataSaverDisabled()) { - mXmppConnectionService.fetchAvatar(mucOptions.getAccount(), avatar); - } - } - } - } - } else if (type.equals("unavailable")) { - final boolean fullJidMatches = from.equals(mucOptions.getSelf().getFullJid()); - if (x.hasChild("destroy") && fullJidMatches) { - Element destroy = x.findChild("destroy"); - final Jid alternate = destroy == null ? null : InvalidJid.getNullForInvalid(destroy.getAttributeAsJid("jid")); - mucOptions.setError(MucOptions.Error.DESTROYED); - if (alternate != null) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": muc destroyed. alternate location " + alternate); - } - } else if (codes.contains(MucOptions.STATUS_CODE_SHUTDOWN) && fullJidMatches) { - mucOptions.setError(MucOptions.Error.SHUTDOWN); - } else if (codes.contains(MucOptions.STATUS_CODE_SELF_PRESENCE)) { - if (codes.contains(MucOptions.STATUS_CODE_TECHNICAL_REASONS)) { + public void parseConferencePresence(PresencePacket packet, Account account) { + final Conversation conversation = + packet.getFrom() == null + ? null + : mXmppConnectionService.find(account, packet.getFrom().asBareJid()); + if (conversation != null) { + final MucOptions mucOptions = conversation.getMucOptions(); + boolean before = mucOptions.online(); + int count = mucOptions.getUserCount(); + final List tileUserBefore = mucOptions.getUsers(5); + processConferencePresence(packet, conversation); + final List tileUserAfter = mucOptions.getUsers(5); + if (!tileUserAfter.equals(tileUserBefore)) { + mXmppConnectionService.getAvatarService().clear(mucOptions); + } + if (before != mucOptions.online() + || (mucOptions.online() && count != mucOptions.getUserCount())) { + mXmppConnectionService.updateConversationUi(); + } else if (mucOptions.online()) { + mXmppConnectionService.updateMucRosterUi(); + } + } + } + + private void processConferencePresence(PresencePacket packet, Conversation conversation) { + final Account account = conversation.getAccount(); + final MucOptions mucOptions = conversation.getMucOptions(); + final Jid jid = conversation.getAccount().getJid(); + final Jid from = packet.getFrom(); + if (!from.isBareJid()) { + final String type = packet.getAttribute("type"); + final Element x = packet.findChild("x", Namespace.MUC_USER); + Avatar avatar = Avatar.parsePresence(packet.findChild("x", "vcard-temp:x:update")); + final List codes = getStatusCodes(x); + if (type == null) { + if (x != null) { + Element item = x.findChild("item"); + if (item != null && !from.isBareJid()) { + mucOptions.setError(MucOptions.Error.NONE); + MucOptions.User user = parseItem(conversation, item, from); + if (codes.contains(MucOptions.STATUS_CODE_SELF_PRESENCE) + || (codes.contains(MucOptions.STATUS_CODE_ROOM_CREATED) + && jid.equals( + InvalidJid.getNullForInvalid( + item.getAttributeAsJid("jid"))))) { + if (mucOptions.setOnline()) { + mXmppConnectionService.getAvatarService().clear(mucOptions); + } + if (mucOptions.setSelf(user)) { + Log.d(Config.LOGTAG, "role or affiliation changed"); + mXmppConnectionService.databaseBackend.updateConversation( + conversation); + } + + mXmppConnectionService.persistSelfNick(user); + invokeRenameListener(mucOptions, true); + } + boolean isNew = mucOptions.updateUser(user); + final AxolotlService axolotlService = + conversation.getAccount().getAxolotlService(); + Contact contact = user.getContact(); + if (isNew + && user.getRealJid() != null + && mucOptions.isPrivateAndNonAnonymous() + && (contact == null || !contact.mutualPresenceSubscription()) + && axolotlService.hasEmptyDeviceList(user.getRealJid())) { + axolotlService.fetchDeviceIds(user.getRealJid()); + } + if (codes.contains(MucOptions.STATUS_CODE_ROOM_CREATED) + && mucOptions.autoPushConfiguration()) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": room '" + + mucOptions.getConversation().getJid().asBareJid() + + "' created. pushing default configuration"); + mXmppConnectionService.pushConferenceConfiguration( + mucOptions.getConversation(), + IqGenerator.defaultChannelConfiguration(), + null); + } + if (mXmppConnectionService.getPgpEngine() != null) { + Element signed = packet.findChild("x", "jabber:x:signed"); + if (signed != null) { + Element status = packet.findChild("status"); + String msg = status == null ? "" : status.getContent(); + long keyId = + mXmppConnectionService + .getPgpEngine() + .fetchKeyId( + mucOptions.getAccount(), + msg, + signed.getContent()); + if (keyId != 0) { + user.setPgpKeyId(keyId); + } + } + } + if (avatar != null) { + avatar.owner = from; + if (mXmppConnectionService.getFileBackend().isAvatarCached(avatar)) { + if (user.setAvatar(avatar)) { + mXmppConnectionService.getAvatarService().clear(user); + } + if (user.getRealJid() != null) { + final Contact c = + conversation + .getAccount() + .getRoster() + .getContact(user.getRealJid()); + c.setAvatar(avatar); + mXmppConnectionService.syncRoster(conversation.getAccount()); + mXmppConnectionService.getAvatarService().clear(c); + mXmppConnectionService.updateRosterUi(); + } + } else if (mXmppConnectionService.isDataSaverDisabled()) { + mXmppConnectionService.fetchAvatar(mucOptions.getAccount(), avatar); + } + } + } + } + } else if (type.equals("unavailable")) { + final boolean fullJidMatches = from.equals(mucOptions.getSelf().getFullJid()); + if (x.hasChild("destroy") && fullJidMatches) { + Element destroy = x.findChild("destroy"); + final Jid alternate = + destroy == null + ? null + : InvalidJid.getNullForInvalid( + destroy.getAttributeAsJid("jid")); + mucOptions.setError(MucOptions.Error.DESTROYED); + if (alternate != null) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": muc destroyed. alternate location " + + alternate); + } + } else if (codes.contains(MucOptions.STATUS_CODE_SHUTDOWN) && fullJidMatches) { + mucOptions.setError(MucOptions.Error.SHUTDOWN); + } else if (codes.contains(MucOptions.STATUS_CODE_SELF_PRESENCE)) { + if (codes.contains(MucOptions.STATUS_CODE_TECHNICAL_REASONS)) { final boolean wasOnline = mucOptions.online(); mucOptions.setError(MucOptions.Error.TECHNICAL_PROBLEMS); Log.d( @@ -157,238 +188,259 @@ public class PresenceParser extends AbstractParser implements if (wasOnline) { mXmppConnectionService.mucSelfPingAndRejoin(conversation); } - } else if (codes.contains(MucOptions.STATUS_CODE_KICKED)) { - mucOptions.setError(MucOptions.Error.KICKED); - } else if (codes.contains(MucOptions.STATUS_CODE_BANNED)) { - mucOptions.setError(MucOptions.Error.BANNED); - } else if (codes.contains(MucOptions.STATUS_CODE_LOST_MEMBERSHIP)) { - mucOptions.setError(MucOptions.Error.MEMBERS_ONLY); - } else if (codes.contains(MucOptions.STATUS_CODE_AFFILIATION_CHANGE)) { - mucOptions.setError(MucOptions.Error.MEMBERS_ONLY); - } else if (codes.contains(MucOptions.STATUS_CODE_SHUTDOWN)) { - mucOptions.setError(MucOptions.Error.SHUTDOWN); - } else if (!codes.contains(MucOptions.STATUS_CODE_CHANGED_NICK)) { - mucOptions.setError(MucOptions.Error.UNKNOWN); - Log.d(Config.LOGTAG, "unknown error in conference: " + packet); - } - } else if (!from.isBareJid()){ - Element item = x.findChild("item"); - if (item != null) { - mucOptions.updateUser(parseItem(conversation, item, from)); - } - MucOptions.User user = mucOptions.deleteUser(from); - if (user != null) { - mXmppConnectionService.getAvatarService().clear(user); - } - } - } else if (type.equals("error")) { - final Element error = packet.findChild("error"); - if (error == null) { - return; - } - if (error.hasChild("conflict")) { - if (mucOptions.online()) { - invokeRenameListener(mucOptions, false); - } else { - mucOptions.setError(MucOptions.Error.NICK_IN_USE); - } - } else if (error.hasChild("not-authorized")) { - mucOptions.setError(MucOptions.Error.PASSWORD_REQUIRED); - } else if (error.hasChild("forbidden")) { - mucOptions.setError(MucOptions.Error.BANNED); - } else if (error.hasChild("registration-required")) { - mucOptions.setError(MucOptions.Error.MEMBERS_ONLY); - } else if (error.hasChild("resource-constraint")) { - mucOptions.setError(MucOptions.Error.RESOURCE_CONSTRAINT); - } else if (error.hasChild("remote-server-timeout")) { - mucOptions.setError(MucOptions.Error.REMOTE_SERVER_TIMEOUT); - } else if (error.hasChild("gone")) { - final String gone = error.findChildContent("gone"); - final Jid alternate; - if (gone != null) { - final XmppUri xmppUri = new XmppUri(gone); - if (xmppUri.isValidJid()) { - alternate = xmppUri.getJid(); - } else { - alternate = null; - } - } else { - alternate = null; - } - mucOptions.setError(MucOptions.Error.DESTROYED); - if (alternate != null) { - Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": muc destroyed. alternate location " + alternate); - } - } else { - final String text = error.findChildContent("text"); - if (text != null && text.contains("attribute 'to'")) { - if (mucOptions.online()) { - invokeRenameListener(mucOptions, false); - } else { - mucOptions.setError(MucOptions.Error.INVALID_NICK); - } - } else { - mucOptions.setError(MucOptions.Error.UNKNOWN); - Log.d(Config.LOGTAG, "unknown error in conference: " + packet); - } - } - } - } - } + } else if (codes.contains(MucOptions.STATUS_CODE_KICKED)) { + mucOptions.setError(MucOptions.Error.KICKED); + } else if (codes.contains(MucOptions.STATUS_CODE_BANNED)) { + mucOptions.setError(MucOptions.Error.BANNED); + } else if (codes.contains(MucOptions.STATUS_CODE_LOST_MEMBERSHIP)) { + mucOptions.setError(MucOptions.Error.MEMBERS_ONLY); + } else if (codes.contains(MucOptions.STATUS_CODE_AFFILIATION_CHANGE)) { + mucOptions.setError(MucOptions.Error.MEMBERS_ONLY); + } else if (codes.contains(MucOptions.STATUS_CODE_SHUTDOWN)) { + mucOptions.setError(MucOptions.Error.SHUTDOWN); + } else if (!codes.contains(MucOptions.STATUS_CODE_CHANGED_NICK)) { + mucOptions.setError(MucOptions.Error.UNKNOWN); + Log.d(Config.LOGTAG, "unknown error in conference: " + packet); + } + } else if (!from.isBareJid()) { + Element item = x.findChild("item"); + if (item != null) { + mucOptions.updateUser(parseItem(conversation, item, from)); + } + MucOptions.User user = mucOptions.deleteUser(from); + if (user != null) { + mXmppConnectionService.getAvatarService().clear(user); + } + } + } else if (type.equals("error")) { + final Element error = packet.findChild("error"); + if (error == null) { + return; + } + if (error.hasChild("conflict")) { + if (mucOptions.online()) { + invokeRenameListener(mucOptions, false); + } else { + mucOptions.setError(MucOptions.Error.NICK_IN_USE); + } + } else if (error.hasChild("not-authorized")) { + mucOptions.setError(MucOptions.Error.PASSWORD_REQUIRED); + } else if (error.hasChild("forbidden")) { + mucOptions.setError(MucOptions.Error.BANNED); + } else if (error.hasChild("registration-required")) { + mucOptions.setError(MucOptions.Error.MEMBERS_ONLY); + } else if (error.hasChild("resource-constraint")) { + mucOptions.setError(MucOptions.Error.RESOURCE_CONSTRAINT); + } else if (error.hasChild("remote-server-timeout")) { + mucOptions.setError(MucOptions.Error.REMOTE_SERVER_TIMEOUT); + } else if (error.hasChild("gone")) { + final String gone = error.findChildContent("gone"); + final Jid alternate; + if (gone != null) { + final XmppUri xmppUri = new XmppUri(gone); + if (xmppUri.isValidJid()) { + alternate = xmppUri.getJid(); + } else { + alternate = null; + } + } else { + alternate = null; + } + mucOptions.setError(MucOptions.Error.DESTROYED); + if (alternate != null) { + Log.d( + Config.LOGTAG, + conversation.getAccount().getJid().asBareJid() + + ": muc destroyed. alternate location " + + alternate); + } + } else { + final String text = error.findChildContent("text"); + if (text != null && text.contains("attribute 'to'")) { + if (mucOptions.online()) { + invokeRenameListener(mucOptions, false); + } else { + mucOptions.setError(MucOptions.Error.INVALID_NICK); + } + } else { + mucOptions.setError(MucOptions.Error.UNKNOWN); + Log.d(Config.LOGTAG, "unknown error in conference: " + packet); + } + } + } + } + } - private static void invokeRenameListener(final MucOptions options, boolean success) { - if (options.onRenameListener != null) { - if (success) { - options.onRenameListener.onSuccess(); - } else { - options.onRenameListener.onFailure(); - } - options.onRenameListener = null; - } - } + private static void invokeRenameListener(final MucOptions options, boolean success) { + if (options.onRenameListener != null) { + if (success) { + options.onRenameListener.onSuccess(); + } else { + options.onRenameListener.onFailure(); + } + options.onRenameListener = null; + } + } - private static List getStatusCodes(Element x) { - List codes = new ArrayList<>(); - if (x != null) { - for (Element child : x.getChildren()) { - if (child.getName().equals("status")) { - String code = child.getAttribute("code"); - if (code != null) { - codes.add(code); - } - } - } - } - return codes; - } + private static List getStatusCodes(Element x) { + List codes = new ArrayList<>(); + if (x != null) { + for (Element child : x.getChildren()) { + if (child.getName().equals("status")) { + String code = child.getAttribute("code"); + if (code != null) { + codes.add(code); + } + } + } + } + return codes; + } - private void parseContactPresence(final PresencePacket packet, final Account account) { - final PresenceGenerator mPresenceGenerator = mXmppConnectionService.getPresenceGenerator(); - final Jid from = packet.getFrom(); - if (from == null || from.equals(account.getJid())) { - return; - } - final String type = packet.getAttribute("type"); - final Contact contact = account.getRoster().getContact(from); - if (type == null) { - final String resource = from.isBareJid() ? "" : from.getResource(); - Avatar avatar = Avatar.parsePresence(packet.findChild("x", "vcard-temp:x:update")); - if (avatar != null && (!contact.isSelf() || account.getAvatar() == null)) { - avatar.owner = from.asBareJid(); - if (mXmppConnectionService.getFileBackend().isAvatarCached(avatar)) { - if (avatar.owner.equals(account.getJid().asBareJid())) { - account.setAvatar(avatar.getFilename()); - mXmppConnectionService.databaseBackend.updateAccount(account); - mXmppConnectionService.getAvatarService().clear(account); - mXmppConnectionService.updateConversationUi(); - mXmppConnectionService.updateAccountUi(); - } else { - contact.setAvatar(avatar); - mXmppConnectionService.syncRoster(account); - mXmppConnectionService.getAvatarService().clear(contact); - mXmppConnectionService.updateConversationUi(); - mXmppConnectionService.updateRosterUi(); - } - } else if (mXmppConnectionService.isDataSaverDisabled()){ - mXmppConnectionService.fetchAvatar(account, avatar); - } - } + private void parseContactPresence(final PresencePacket packet, final Account account) { + final PresenceGenerator mPresenceGenerator = mXmppConnectionService.getPresenceGenerator(); + final Jid from = packet.getFrom(); + if (from == null || from.equals(account.getJid())) { + return; + } + final String type = packet.getAttribute("type"); + final Contact contact = account.getRoster().getContact(from); + if (type == null) { + final String resource = from.isBareJid() ? "" : from.getResource(); + final Avatar avatar = + Avatar.parsePresence(packet.findChild("x", "vcard-temp:x:update")); + if (avatar != null && (!contact.isSelf() || account.getAvatar() == null)) { + avatar.owner = from.asBareJid(); + if (mXmppConnectionService.getFileBackend().isAvatarCached(avatar)) { + if (avatar.owner.equals(account.getJid().asBareJid())) { + account.setAvatar(avatar.getFilename()); + mXmppConnectionService.databaseBackend.updateAccount(account); + mXmppConnectionService.getAvatarService().clear(account); + mXmppConnectionService.updateConversationUi(); + mXmppConnectionService.updateAccountUi(); + } else { + contact.setAvatar(avatar); + mXmppConnectionService.syncRoster(account); + mXmppConnectionService.getAvatarService().clear(contact); + mXmppConnectionService.updateConversationUi(); + mXmppConnectionService.updateRosterUi(); + } + } else if (mXmppConnectionService.isDataSaverDisabled()) { + mXmppConnectionService.fetchAvatar(account, avatar); + } + } - if (mXmppConnectionService.isMuc(account, from)) { - return; - } + if (mXmppConnectionService.isMuc(account, from)) { + return; + } - int sizeBefore = contact.getPresences().size(); + final int sizeBefore = contact.getPresences().size(); - final String show = packet.findChildContent("show"); - final Element caps = packet.findChild("c", "http://jabber.org/protocol/caps"); - final String message = packet.findChildContent("status"); - final Presence presence = Presence.parse(show, caps, message); - contact.updatePresence(resource, presence); - if (presence.hasCaps()) { - mXmppConnectionService.fetchCaps(account, from, presence); - } + final String show = packet.findChildContent("show"); + final Element caps = packet.findChild("c", "http://jabber.org/protocol/caps"); + final String message = packet.findChildContent("status"); + final Presence presence = Presence.parse(show, caps, message); + contact.updatePresence(resource, presence); + if (presence.hasCaps()) { + mXmppConnectionService.fetchCaps(account, from, presence); + } - final Element idle = packet.findChild("idle", Namespace.IDLE); - if (idle != null) { - try { - final String since = idle.getAttribute("since"); - contact.setLastseen(AbstractParser.parseTimestamp(since)); - contact.flagInactive(); - } catch (Throwable throwable) { - if (contact.setLastseen(AbstractParser.parseTimestamp(packet))) { - contact.flagActive(); - } - } - } else { - if (contact.setLastseen(AbstractParser.parseTimestamp(packet))) { - contact.flagActive(); - } - } + final Element idle = packet.findChild("idle", Namespace.IDLE); + if (idle != null) { + try { + final String since = idle.getAttribute("since"); + contact.setLastseen(AbstractParser.parseTimestamp(since)); + contact.flagInactive(); + } catch (Throwable throwable) { + if (contact.setLastseen(AbstractParser.parseTimestamp(packet))) { + contact.flagActive(); + } + } + } else { + if (contact.setLastseen(AbstractParser.parseTimestamp(packet))) { + contact.flagActive(); + } + } - PgpEngine pgp = mXmppConnectionService.getPgpEngine(); - Element x = packet.findChild("x", "jabber:x:signed"); - if (pgp != null && x != null) { - final String status = packet.findChildContent("status"); - final long keyId = pgp.fetchKeyId(account, status, x.getContent()); - if (keyId != 0 && contact.setPgpKeyId(keyId)) { - Log.d(Config.LOGTAG,account.getJid().asBareJid()+": found OpenPGP key id for "+contact.getJid()+" "+OpenPgpUtils.convertKeyIdToHex(keyId)); - mXmppConnectionService.syncRoster(account); - } - } - boolean online = sizeBefore < contact.getPresences().size(); - mXmppConnectionService.onContactStatusChanged.onContactStatusChanged(contact, online); - } else if (type.equals("unavailable")) { - if (contact.setLastseen(AbstractParser.parseTimestamp(packet,0L,true))) { - contact.flagInactive(); - } - if (from.isBareJid()) { - contact.clearPresences(); - } else { - contact.removePresence(from.getResource()); - } - if (contact.getShownStatus() == Presence.Status.OFFLINE) { - contact.flagInactive(); - } - mXmppConnectionService.onContactStatusChanged.onContactStatusChanged(contact, false); - } else if (type.equals("subscribe")) { - if (contact.setPresenceName(packet.findChildContent("nick", Namespace.NICK))) { - mXmppConnectionService.syncRoster(account); - mXmppConnectionService.getAvatarService().clear(contact); - } - if (contact.getOption(Contact.Options.PREEMPTIVE_GRANT)) { - mXmppConnectionService.sendPresencePacket(account, - mPresenceGenerator.sendPresenceUpdatesTo(contact)); - } else { - contact.setOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST); - final Conversation conversation = mXmppConnectionService.findOrCreateConversation( - account, contact.getJid().asBareJid(), false, false); - final String statusMessage = packet.findChildContent("status"); - if (statusMessage != null - && !statusMessage.isEmpty() - && conversation.countMessages() == 0) { - conversation.add(new Message( - conversation, - statusMessage, - Message.ENCRYPTION_NONE, - Message.STATUS_RECEIVED - )); - } - } - } - mXmppConnectionService.updateRosterUi(); - } + final PgpEngine pgp = mXmppConnectionService.getPgpEngine(); + final Element x = packet.findChild("x", "jabber:x:signed"); + if (pgp != null && x != null) { + final String status = packet.findChildContent("status"); + final long keyId = pgp.fetchKeyId(account, status, x.getContent()); + if (keyId != 0 && contact.setPgpKeyId(keyId)) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": found OpenPGP key id for " + + contact.getJid() + + " " + + OpenPgpUtils.convertKeyIdToHex(keyId)); + mXmppConnectionService.syncRoster(account); + } + } + boolean online = sizeBefore < contact.getPresences().size(); + mXmppConnectionService.onContactStatusChanged.onContactStatusChanged(contact, online); + } else if (type.equals("unavailable")) { + if (contact.setLastseen(AbstractParser.parseTimestamp(packet, 0L, true))) { + contact.flagInactive(); + } + if (from.isBareJid()) { + contact.clearPresences(); + } else { + contact.removePresence(from.getResource()); + } + if (contact.getShownStatus() == Presence.Status.OFFLINE) { + contact.flagInactive(); + } + mXmppConnectionService.onContactStatusChanged.onContactStatusChanged(contact, false); + } else if (type.equals("subscribe")) { + if (contact.isBlocked()) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": ignoring 'subscribe' presence from blocked " + + from); + return; + } + if (contact.setPresenceName(packet.findChildContent("nick", Namespace.NICK))) { + mXmppConnectionService.syncRoster(account); + mXmppConnectionService.getAvatarService().clear(contact); + } + if (contact.getOption(Contact.Options.PREEMPTIVE_GRANT)) { + mXmppConnectionService.sendPresencePacket( + account, mPresenceGenerator.sendPresenceUpdatesTo(contact)); + } else { + contact.setOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST); + final Conversation conversation = + mXmppConnectionService.findOrCreateConversation( + account, contact.getJid().asBareJid(), false, false); + final String statusMessage = packet.findChildContent("status"); + if (statusMessage != null + && !statusMessage.isEmpty() + && conversation.countMessages() == 0) { + conversation.add( + new Message( + conversation, + statusMessage, + Message.ENCRYPTION_NONE, + Message.STATUS_RECEIVED)); + } + } + } + mXmppConnectionService.updateRosterUi(); + } - @Override - public void onPresencePacketReceived(Account account, PresencePacket packet) { - if (packet.hasChild("x", Namespace.MUC_USER)) { - this.parseConferencePresence(packet, account); - } else if (packet.hasChild("x", "http://jabber.org/protocol/muc")) { - this.parseConferencePresence(packet, account); - } else if ("error".equals(packet.getAttribute("type")) && mXmppConnectionService.isMuc(account, packet.getFrom())) { - this.parseConferencePresence(packet, account); - } else { - this.parseContactPresence(packet, account); - } - } + @Override + public void onPresencePacketReceived(Account account, PresencePacket packet) { + if (packet.hasChild("x", Namespace.MUC_USER)) { + this.parseConferencePresence(packet, account); + } else if (packet.hasChild("x", "http://jabber.org/protocol/muc")) { + this.parseConferencePresence(packet, account); + } else if ("error".equals(packet.getAttribute("type")) + && mXmppConnectionService.isMuc(account, packet.getFrom())) { + this.parseConferencePresence(packet, account); + } else { + this.parseContactPresence(packet, account); + } + } } diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java index 3c193219e..d8050646a 100644 --- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java @@ -648,8 +648,13 @@ public class FileBackend { } } - public String getOriginalPath(Uri uri) { - return FileUtils.getPath(mXmppConnectionService, uri); + public String getOriginalPath(final Uri uri) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // On Android 11+ we don’t have access to the original file + return null; + } else { + return FileUtils.getPath(mXmppConnectionService, uri); + } } private void copyFileToPrivateStorage(File file, Uri uri) throws FileCopyException { diff --git a/src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java b/src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java index f36506bd1..fd7b27db4 100644 --- a/src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java +++ b/src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java @@ -16,6 +16,7 @@ import com.google.common.collect.ImmutableList; import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; import java.util.List; import eu.siacs.conversations.Config; @@ -129,6 +130,23 @@ public class UnifiedPushDatabase extends SQLiteOpenHelper { return null; } + public List deletePushTargets() { + final SQLiteDatabase sqLiteDatabase = getReadableDatabase(); + final ImmutableList.Builder builder = new ImmutableList.Builder<>(); + try (final Cursor cursor = sqLiteDatabase.query("push",new String[]{"application","instance"},null,null,null,null,null)) { + if (cursor != null && cursor.moveToFirst()) { + builder.add(new PushTarget( + cursor.getString(cursor.getColumnIndexOrThrow("application")), + cursor.getString(cursor.getColumnIndexOrThrow("instance")))); + } + } catch (final Exception e) { + Log.d(Config.LOGTAG,"unable to retrieve push targets",e); + return builder.build(); + } + sqLiteDatabase.delete("push",null,null); + return builder.build(); + } + public boolean hasEndpoints(final UnifiedPushBroker.Transport transport) { final SQLiteDatabase sqLiteDatabase = getReadableDatabase(); try (final Cursor cursor = diff --git a/src/main/java/eu/siacs/conversations/services/AbstractQuickConversationsService.java b/src/main/java/eu/siacs/conversations/services/AbstractQuickConversationsService.java index d05e40ae8..8f6c3c1f2 100644 --- a/src/main/java/eu/siacs/conversations/services/AbstractQuickConversationsService.java +++ b/src/main/java/eu/siacs/conversations/services/AbstractQuickConversationsService.java @@ -1,13 +1,22 @@ package eu.siacs.conversations.services; +import android.Manifest; +import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; + +import com.google.common.collect.Iterables; import eu.siacs.conversations.BuildConfig; +import java.util.Arrays; + public abstract class AbstractQuickConversationsService { + public static final String SMS_RETRIEVED_ACTION = + "com.google.android.gms.auth.api.phone.SMS_RETRIEVED"; - public static final String SMS_RETRIEVED_ACTION = "com.google.android.gms.auth.api.phone.SMS_RETRIEVED"; + private static Boolean declaredReadContacts = null; protected final XmppConnectionService service; @@ -25,6 +34,41 @@ public abstract class AbstractQuickConversationsService { return "conversations".equals(BuildConfig.FLAVOR_mode); } + public static boolean isPlayStoreFlavor() { + return "playstore".equals(BuildConfig.FLAVOR_distribution); + } + + public static boolean isContactListIntegration(final Context context) { + if ("quicksy".equals(BuildConfig.FLAVOR_mode)) { + return true; + } + final var readContacts = AbstractQuickConversationsService.declaredReadContacts; + if (readContacts != null) { + return Boolean.TRUE.equals(readContacts); + } + AbstractQuickConversationsService.declaredReadContacts = hasDeclaredReadContacts(context); + return AbstractQuickConversationsService.declaredReadContacts; + } + + private static boolean hasDeclaredReadContacts(final Context context) { + final String[] permissions; + try { + permissions = + context.getPackageManager() + .getPackageInfo( + context.getPackageName(), PackageManager.GET_PERMISSIONS) + .requestedPermissions; + } catch (final PackageManager.NameNotFoundException e) { + return false; + } + return Iterables.any( + Arrays.asList(permissions), p -> p.equals(Manifest.permission.READ_CONTACTS)); + } + + public static boolean isQuicksyPlayStore() { + return isQuicksy() && isPlayStoreFlavor(); + } + public abstract void signalAccountStateChange(); public abstract boolean isSynchronizing(); diff --git a/src/main/java/eu/siacs/conversations/services/AvatarService.java b/src/main/java/eu/siacs/conversations/services/AvatarService.java index 7baee23f9..e9e827c56 100644 --- a/src/main/java/eu/siacs/conversations/services/AvatarService.java +++ b/src/main/java/eu/siacs/conversations/services/AvatarService.java @@ -124,6 +124,17 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded { return avatar; } + public Bitmap getRoundedShortcut(final MucOptions mucOptions) { + final DisplayMetrics metrics = mXmppConnectionService.getResources().getDisplayMetrics(); + final int size = Math.round(metrics.density * 48); + final Bitmap bitmap = get(mucOptions, size, false); + final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(output); + final Paint paint = new Paint(); + drawAvatar(bitmap, canvas, paint); + return output; + } + public Bitmap getRoundedShortcut(final Contact contact) { return getRoundedShortcut(contact, false); } @@ -147,7 +158,7 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded { return output; } - private void drawAvatar(Bitmap bitmap, Canvas canvas, Paint paint) { + private static void drawAvatar(Bitmap bitmap, Canvas canvas, Paint paint) { final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); paint.setAntiAlias(true); canvas.drawARGB(0, 0, 0, 0); diff --git a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java b/src/main/java/eu/siacs/conversations/services/ExportBackupService.java index 9826ecbc2..1462b5614 100644 --- a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java +++ b/src/main/java/eu/siacs/conversations/services/ExportBackupService.java @@ -19,18 +19,25 @@ import androidx.core.app.NotificationCompat; import com.google.common.base.CharMatcher; import com.google.common.base.Strings; +import com.google.gson.stream.JsonWriter; import java.io.DataOutputStream; import java.io.File; import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; +import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.concurrent.atomic.AtomicBoolean; import java.util.zip.GZIPOutputStream; @@ -54,6 +61,8 @@ import eu.siacs.conversations.utils.Compatibility; public class ExportBackupService extends Service { + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + public static final String KEYTYPE = "AES"; public static final String CIPHERMODE = "AES/GCM/NoPadding"; public static final String PROVIDER = "BC"; @@ -61,16 +70,16 @@ public class ExportBackupService extends Service { public static final String MIME_TYPE = "application/vnd.conversations.backup"; private static final int NOTIFICATION_ID = 19; - private static final int PAGE_SIZE = 20; private static final AtomicBoolean RUNNING = new AtomicBoolean(false); private DatabaseBackend mDatabaseBackend; private List mAccounts; private NotificationManager notificationManager; - private static List getPossibleFileOpenIntents(final Context context, final String path) { + private static List getPossibleFileOpenIntents( + final Context context, final String path) { - //http://www.openintents.org/action/android-intent-action-view/file-directory - //do not use 'vnd.android.document/directory' since this will trigger system file manager + // http://www.openintents.org/action/android-intent-action-view/file-directory + // do not use 'vnd.android.document/directory' since this will trigger system file manager final Intent openIntent = new Intent(Intent.ACTION_VIEW); openIntent.addCategory(Intent.CATEGORY_DEFAULT); if (Compatibility.runsAndTargetsTwentyFour(context)) { @@ -83,134 +92,95 @@ public class ExportBackupService extends Service { final Intent amazeIntent = new Intent(Intent.ACTION_VIEW); amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder"); - //will open a file manager at root and user can navigate themselves + // will open a file manager at root and user can navigate themselves final Intent systemFallBack = new Intent(Intent.ACTION_VIEW); systemFallBack.addCategory(Intent.CATEGORY_DEFAULT); - systemFallBack.setData(Uri.parse("content://com.android.externalstorage.documents/root/primary")); + systemFallBack.setData( + Uri.parse("content://com.android.externalstorage.documents/root/primary")); return Arrays.asList(openIntent, amazeIntent, systemFallBack); } - private static void accountExport(final SQLiteDatabase db, final String uuid, final PrintWriter writer) { - final StringBuilder builder = new StringBuilder(); - final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null); + private static void accountExport( + final SQLiteDatabase db, final String uuid, final JsonWriter writer) + throws IOException { + final Cursor accountCursor = + db.query( + Account.TABLENAME, + null, + Account.UUID + "=?", + new String[] {uuid}, + null, + null, + null); while (accountCursor != null && accountCursor.moveToNext()) { - builder.append("INSERT INTO ").append(Account.TABLENAME).append("("); + writer.beginObject(); + writer.name("table"); + writer.value(Account.TABLENAME); + writer.name("values"); + writer.beginObject(); for (int i = 0; i < accountCursor.getColumnCount(); ++i) { - if (i != 0) { - builder.append(','); - } - builder.append(accountCursor.getColumnName(i)); - } - builder.append(") VALUES("); - for (int i = 0; i < accountCursor.getColumnCount(); ++i) { - if (i != 0) { - builder.append(','); - } + final String name = accountCursor.getColumnName(i); + writer.name(name); final String value = accountCursor.getString(i); if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) { - builder.append("NULL"); - } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i)) && value.matches("\\d+")) { + writer.nullValue(); + } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i)) + && value.matches("\\d+")) { int intValue = Integer.parseInt(value); intValue |= 1 << Account.OPTION_DISABLED; - builder.append(intValue); + writer.value(intValue); } else { - appendEscapedSQLString(builder, value); + writer.value(value); } } - builder.append(")"); - builder.append(';'); - builder.append('\n'); + writer.endObject(); + writer.endObject(); } if (accountCursor != null) { accountCursor.close(); } - writer.append(builder.toString()); } - private static void appendEscapedSQLString(final StringBuilder sb, final String sqlString) { - DatabaseUtils.appendEscapedSQLString(sb, CharMatcher.is('\u0000').removeFrom(sqlString)); - } - - private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) { - final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null); + private static void simpleExport( + final SQLiteDatabase db, + final String table, + final String column, + final String uuid, + final JsonWriter writer) + throws IOException { + final Cursor cursor = + db.query(table, null, column + "=?", new String[] {uuid}, null, null, null); while (cursor != null && cursor.moveToNext()) { - writer.write(cursorToString(table, cursor, PAGE_SIZE)); + writer.beginObject(); + writer.name("table"); + writer.value(table); + writer.name("values"); + writer.beginObject(); + for (int i = 0; i < cursor.getColumnCount(); ++i) { + final String name = cursor.getColumnName(i); + writer.name(name); + final String value = cursor.getString(i); + writer.value(value); + } + writer.endObject(); + writer.endObject(); } if (cursor != null) { cursor.close(); } } - public static byte[] getKey(final String password, final byte[] salt) throws InvalidKeySpecException { + public static byte[] getKey(final String password, final byte[] salt) + throws InvalidKeySpecException { final SecretKeyFactory factory; try { factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } - return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)).getEncoded(); - } - - private static String cursorToString(final String table, final Cursor cursor, final int max) { - return cursorToString(table, cursor, max, false); - } - - private static String cursorToString(final String table, final Cursor cursor, int max, boolean ignore) { - final boolean identities = SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(table); - StringBuilder builder = new StringBuilder(); - builder.append("INSERT "); - if (ignore) { - builder.append("OR IGNORE "); - } - builder.append("INTO ").append(table).append("("); - int skipColumn = -1; - for (int i = 0; i < cursor.getColumnCount(); ++i) { - final String name = cursor.getColumnName(i); - if (identities && SQLiteAxolotlStore.TRUSTED.equals(name)) { - skipColumn = i; - continue; - } - if (i != 0) { - builder.append(','); - } - builder.append(name); - } - builder.append(") VALUES"); - for (int i = 0; i < max; ++i) { - if (i != 0) { - builder.append(','); - } - appendValues(cursor, builder, skipColumn); - if (i < max - 1 && !cursor.moveToNext()) { - break; - } - } - builder.append(';'); - builder.append('\n'); - return builder.toString(); - } - - private static void appendValues(final Cursor cursor, final StringBuilder builder, final int skipColumn) { - builder.append("("); - for (int i = 0; i < cursor.getColumnCount(); ++i) { - if (i == skipColumn) { - continue; - } - if (i != 0) { - builder.append(','); - } - final String value = cursor.getString(i); - if (value == null) { - builder.append("NULL"); - } else if (value.matches("[0-9]+")) { - builder.append(value); - } else { - appendEscapedSQLString(builder, value); - } - } - builder.append(")"); - + return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)) + .getEncoded(); } @Override @@ -223,49 +193,69 @@ public class ExportBackupService extends Service { @Override public int onStartCommand(Intent intent, int flags, int startId) { if (RUNNING.compareAndSet(false, true)) { - new Thread(() -> { - boolean success; - List files; - try { - files = export(); - success = true; - } catch (final Exception e) { - Log.d(Config.LOGTAG, "unable to create backup", e); - success = false; - files = Collections.emptyList(); - } - stopForeground(true); - RUNNING.set(false); - if (success) { - notifySuccess(files); - } - stopSelf(); - }).start(); + new Thread( + () -> { + boolean success; + List files; + try { + files = export(); + success = true; + } catch (final Exception e) { + Log.d(Config.LOGTAG, "unable to create backup", e); + success = false; + files = Collections.emptyList(); + } + stopForeground(true); + RUNNING.set(false); + if (success) { + notifySuccess(files); + } + stopSelf(); + }) + .start(); return START_STICKY; } else { - Log.d(Config.LOGTAG, "ExportBackupService. ignoring start command because already running"); + Log.d( + Config.LOGTAG, + "ExportBackupService. ignoring start command because already running"); } return START_NOT_STICKY; } - private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) { - Cursor cursor = db.rawQuery("select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid}); + private void messageExport( + final SQLiteDatabase db, + final String uuid, + final JsonWriter writer, + final Progress progress) + throws IOException { + Cursor cursor = + db.rawQuery( + "select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", + new String[] {uuid}); int size = cursor != null ? cursor.getCount() : 0; Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid); int i = 0; int p = 0; while (cursor != null && cursor.moveToNext()) { - writer.write(cursorToString(Message.TABLENAME, cursor, PAGE_SIZE, false)); - if (i + PAGE_SIZE > size) { - i = size; - } else { - i += PAGE_SIZE; + writer.beginObject(); + writer.name("table"); + writer.value(Message.TABLENAME); + writer.name("values"); + writer.beginObject(); + for (int j = 0; j < cursor.getColumnCount(); ++j) { + final String name = cursor.getColumnName(j); + writer.name(name); + final String value = cursor.getString(j); + writer.value(value); } + writer.endObject(); + writer.endObject(); final int percentage = i * 100 / size; if (p < percentage) { p = percentage; notificationManager.notify(NOTIFICATION_ID, progress.build(p)); } + i++; } if (cursor != null) { cursor.close(); @@ -273,7 +263,8 @@ public class ExportBackupService extends Service { } private List export() throws Exception { - NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup"); + NotificationCompat.Builder mBuilder = + new NotificationCompat.Builder(getBaseContext(), "backup"); mBuilder.setContentTitle(getString(R.string.notification_create_backup_title)) .setSmallIcon(R.drawable.ic_archive_white_24dp) .setProgress(1, 0, false); @@ -286,17 +277,38 @@ public class ExportBackupService extends Service { for (final Account account : this.mAccounts) { final String password = account.getPassword(); if (Strings.nullToEmpty(password).trim().isEmpty()) { - Log.d(Config.LOGTAG, String.format("skipping backup for %s because password is empty. unable to encrypt", account.getJid().asBareJid())); + Log.d( + Config.LOGTAG, + String.format( + "skipping backup for %s because password is empty. unable to encrypt", + account.getJid().asBareJid())); continue; } - Log.d(Config.LOGTAG, String.format("exporting data for account %s (%s)", account.getJid().asBareJid(), account.getUuid())); + Log.d( + Config.LOGTAG, + String.format( + "exporting data for account %s (%s)", + account.getJid().asBareJid(), account.getUuid())); final byte[] IV = new byte[12]; final byte[] salt = new byte[16]; secureRandom.nextBytes(IV); secureRandom.nextBytes(salt); - final BackupFileHeader backupFileHeader = new BackupFileHeader(getString(R.string.app_name), account.getJid(), System.currentTimeMillis(), IV, salt); + final BackupFileHeader backupFileHeader = + new BackupFileHeader( + getString(R.string.app_name), + account.getJid(), + System.currentTimeMillis(), + IV, + salt); final Progress progress = new Progress(mBuilder, max, count); - final File file = new File(FileBackend.getBackupDirectory(this), account.getJid().asBareJid().toEscapedString() + ".ceb"); + final String filename = + String.format( + "%s.%s.ceb", + account.getJid().asBareJid().toEscapedString(), + DATE_FORMAT.format(new Date())); + final File file = + new File( + FileBackend.getBackupDirectory(this), filename); files.add(file); final File directory = file.getParentFile(); if (directory != null && directory.mkdirs()) { @@ -307,25 +319,38 @@ public class ExportBackupService extends Service { backupFileHeader.write(dataOutputStream); dataOutputStream.flush(); - final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER); + final Cipher cipher = + Compatibility.twentyEight() + ? Cipher.getInstance(CIPHERMODE) + : Cipher.getInstance(CIPHERMODE, PROVIDER); final byte[] key = getKey(password, salt); SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE); IvParameterSpec ivSpec = new IvParameterSpec(IV); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); - CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher); + CipherOutputStream cipherOutputStream = + new CipherOutputStream(fileOutputStream, cipher); - GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream); - PrintWriter writer = new PrintWriter(gzipOutputStream); - SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase(); + final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream); + final JsonWriter jsonWriter = + new JsonWriter( + new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8)); + jsonWriter.beginArray(); + final SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase(); final String uuid = account.getUuid(); - accountExport(db, uuid, writer); - simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer); - messageExport(db, uuid, writer, progress); - for (String table : Arrays.asList(SQLiteAxolotlStore.PREKEY_TABLENAME, SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, SQLiteAxolotlStore.SESSION_TABLENAME, SQLiteAxolotlStore.IDENTITIES_TABLENAME)) { - simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, writer); + accountExport(db, uuid, jsonWriter); + simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter); + messageExport(db, uuid, jsonWriter, progress); + for (final String table : + Arrays.asList( + SQLiteAxolotlStore.PREKEY_TABLENAME, + SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, + SQLiteAxolotlStore.SESSION_TABLENAME, + SQLiteAxolotlStore.IDENTITIES_TABLENAME)) { + simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, jsonWriter); } - writer.flush(); - writer.close(); + jsonWriter.endArray(); + jsonWriter.flush(); + jsonWriter.close(); mediaScannerScanFile(file); Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile()); count++; @@ -346,9 +371,15 @@ public class ExportBackupService extends Service { for (final Intent intent : getPossibleFileOpenIntents(this, path)) { if (intent.resolveActivityInfo(getPackageManager(), 0) != null) { - openFolderIntent = PendingIntent.getActivity(this, 189, intent, s() - ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT - : PendingIntent.FLAG_UPDATE_CURRENT); + openFolderIntent = + PendingIntent.getActivity( + this, + 189, + intent, + s() + ? PendingIntent.FLAG_IMMUTABLE + | PendingIntent.FLAG_UPDATE_CURRENT + : PendingIntent.FLAG_UPDATE_CURRENT); break; } } @@ -363,22 +394,39 @@ public class ExportBackupService extends Service { intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setType(MIME_TYPE); - final Intent chooser = Intent.createChooser(intent, getString(R.string.share_backup_files)); - shareFilesIntent = PendingIntent.getActivity(this, 190, chooser, s() - ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT - : PendingIntent.FLAG_UPDATE_CURRENT); + final Intent chooser = + Intent.createChooser(intent, getString(R.string.share_backup_files)); + shareFilesIntent = + PendingIntent.getActivity( + this, + 190, + chooser, + s() + ? PendingIntent.FLAG_IMMUTABLE + | PendingIntent.FLAG_UPDATE_CURRENT + : PendingIntent.FLAG_UPDATE_CURRENT); } - NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup"); + NotificationCompat.Builder mBuilder = + new NotificationCompat.Builder(getBaseContext(), "backup"); mBuilder.setContentTitle(getString(R.string.notification_backup_created_title)) .setContentText(getString(R.string.notification_backup_created_subtitle, path)) - .setStyle(new NotificationCompat.BigTextStyle().bigText(getString(R.string.notification_backup_created_subtitle, FileBackend.getBackupDirectory(this).getAbsolutePath()))) + .setStyle( + new NotificationCompat.BigTextStyle() + .bigText( + getString( + R.string.notification_backup_created_subtitle, + FileBackend.getBackupDirectory(this) + .getAbsolutePath()))) .setAutoCancel(true) .setContentIntent(openFolderIntent) .setSmallIcon(R.drawable.ic_archive_white_24dp); if (shareFilesIntent != null) { - mBuilder.addAction(R.drawable.ic_share_white_24dp, getString(R.string.share_backup_files), shareFilesIntent); + mBuilder.addAction( + R.drawable.ic_share_white_24dp, + getString(R.string.share_backup_files), + shareFilesIntent); } notificationManager.notify(NOTIFICATION_ID, mBuilder.build()); diff --git a/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java b/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java index 520348943..81cfb951f 100644 --- a/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java +++ b/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java @@ -33,6 +33,7 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; +import android.os.Build; import android.os.Handler; import android.preference.PreferenceManager; import android.util.Base64; @@ -43,9 +44,21 @@ import androidx.appcompat.app.AppCompatActivity; import com.google.common.base.Charsets; import com.google.common.base.Joiner; +import com.google.common.base.Preconditions; import com.google.common.io.ByteStreams; import com.google.common.io.CharStreams; +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.crypto.BundledTrustManager; +import eu.siacs.conversations.crypto.CombiningTrustManager; +import eu.siacs.conversations.crypto.TrustManagers; +import eu.siacs.conversations.crypto.XmppDomainVerifier; +import eu.siacs.conversations.entities.MTMDecision; +import eu.siacs.conversations.http.HttpConnectionManager; +import eu.siacs.conversations.persistance.FileBackend; +import eu.siacs.conversations.ui.MemorizingActivity; + import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -78,39 +91,40 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; -import eu.siacs.conversations.Config; -import eu.siacs.conversations.R; -import eu.siacs.conversations.crypto.XmppDomainVerifier; -import eu.siacs.conversations.entities.MTMDecision; -import eu.siacs.conversations.http.HttpConnectionManager; -import eu.siacs.conversations.persistance.FileBackend; -import eu.siacs.conversations.ui.MemorizingActivity; - /** - * A X509 trust manager implementation which asks the user about invalid - * certificates and memorizes their decision. - *

- * The certificate validity is checked using the system default X509 - * TrustManager, creating a query Dialog if the check fails. - *

- * WARNING: This only works if a dedicated thread is used for - * opening sockets! + * A X509 trust manager implementation which asks the user about invalid certificates and memorizes + * their decision. + * + *

The certificate validity is checked using the system default X509 TrustManager, creating a + * query Dialog if the check fails. + * + *

WARNING: This only works if a dedicated thread is used for opening sockets! */ public class MemorizingTrustManager { - private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + private static final SimpleDateFormat DATE_FORMAT = + new SimpleDateFormat("yyyy-MM-dd", Locale.US); - final static String DECISION_INTENT = "de.duenndns.ssl.DECISION"; - public final static String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId"; - public final static String DECISION_INTENT_CERT = DECISION_INTENT + ".cert"; - public final static String DECISION_TITLE_ID = DECISION_INTENT + ".titleId"; - final static String NO_TRUST_ANCHOR = "Trust anchor for certification path not found."; - private static final Pattern PATTERN_IPV4 = Pattern.compile("\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); - private static final Pattern PATTERN_IPV6_HEX4DECCOMPRESSED = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); - private static final Pattern PATTERN_IPV6_6HEX4DEC = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); - private static final Pattern PATTERN_IPV6_HEXCOMPRESSED = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z"); - private static final Pattern PATTERN_IPV6 = Pattern.compile("\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z"); - private final static Logger LOGGER = Logger.getLogger(MemorizingTrustManager.class.getName()); + static final String DECISION_INTENT = "de.duenndns.ssl.DECISION"; + public static final String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId"; + public static final String DECISION_INTENT_CERT = DECISION_INTENT + ".cert"; + public static final String DECISION_TITLE_ID = DECISION_INTENT + ".titleId"; + static final String NO_TRUST_ANCHOR = "Trust anchor for certification path not found."; + private static final Pattern PATTERN_IPV4 = + Pattern.compile( + "\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); + private static final Pattern PATTERN_IPV6_HEX4DECCOMPRESSED = + Pattern.compile( + "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); + private static final Pattern PATTERN_IPV6_6HEX4DEC = + Pattern.compile( + "\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); + private static final Pattern PATTERN_IPV6_HEXCOMPRESSED = + Pattern.compile( + "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z"); + private static final Pattern PATTERN_IPV6 = + Pattern.compile("\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z"); + private static final Logger LOGGER = Logger.getLogger(MemorizingTrustManager.class.getName()); static String KEYSTORE_DIR = "KeyStore"; static String KEYSTORE_FILE = "KeyStore.bks"; private static int decisionId = 0; @@ -126,54 +140,76 @@ public class MemorizingTrustManager { private String poshCacheDir; /** - * Creates an instance of the MemorizingTrustManager class that falls back to a custom TrustManager. - *

- * You need to supply the application context. This has to be one of: - * - Application - * - Activity - * - Service - *

- * The context is used for file management, to display the dialog / - * notification and for obtaining translated strings. + * Creates an instance of the MemorizingTrustManager class that falls back to a custom + * TrustManager. * - * @param m Context for the application. - * @param defaultTrustManager Delegate trust management to this TM. If null, the user must accept every certificate. + *

You need to supply the application context. This has to be one of: - Application - + * Activity - Service + * + *

The context is used for file management, to display the dialog / notification and for + * obtaining translated strings. + * + * @param context Context for the application. + * @param defaultTrustManager Delegate trust management to this TM. If null, the user must + * accept every certificate. */ - public MemorizingTrustManager(Context m, X509TrustManager defaultTrustManager) { - init(m); + public MemorizingTrustManager( + final Context context, final X509TrustManager defaultTrustManager) { + init(context); this.appTrustManager = getTrustManager(appKeyStore); this.defaultTrustManager = defaultTrustManager; } /** * Creates an instance of the MemorizingTrustManager class using the system X509TrustManager. - *

- * You need to supply the application context. This has to be one of: - * - Application - * - Activity - * - Service - *

- * The context is used for file management, to display the dialog / - * notification and for obtaining translated strings. * - * @param m Context for the application. + *

You need to supply the application context. This has to be one of: - Application - + * Activity - Service + * + *

The context is used for file management, to display the dialog / notification and for + * obtaining translated strings. + * + * @param context Context for the application. */ - public MemorizingTrustManager(Context m) { - init(m); + public MemorizingTrustManager(final Context context) { + init(context); this.appTrustManager = getTrustManager(appKeyStore); - this.defaultTrustManager = getTrustManager(null); + try { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) { + this.defaultTrustManager = defaultWithBundledLetsEncrypt(context); + } else { + this.defaultTrustManager = TrustManagers.createDefaultTrustManager(); + } + } catch (final NoSuchAlgorithmException + | KeyStoreException + | CertificateException + | IOException e) { + throw new RuntimeException(e); + } + } + + private static X509TrustManager defaultWithBundledLetsEncrypt(final Context context) + throws NoSuchAlgorithmException, KeyStoreException, CertificateException, IOException { + final BundledTrustManager bundleTrustManager = + BundledTrustManager.builder() + .loadKeyStore( + context.getResources().openRawResource(R.raw.letsencrypt), + "letsencrypt") + .build(); + return CombiningTrustManager.combineWithDefault(bundleTrustManager); } private static boolean isIp(final String server) { - return server != null && ( - PATTERN_IPV4.matcher(server).matches() + return server != null + && (PATTERN_IPV4.matcher(server).matches() || PATTERN_IPV6.matcher(server).matches() || PATTERN_IPV6_6HEX4DEC.matcher(server).matches() || PATTERN_IPV6_HEX4DECCOMPRESSED.matcher(server).matches() || PATTERN_IPV6_HEXCOMPRESSED.matcher(server).matches()); } - private static String getBase64Hash(X509Certificate certificate, String digest) throws CertificateEncodingException { + private static String getBase64Hash(X509Certificate certificate, String digest) + throws CertificateEncodingException { MessageDigest md; try { md = MessageDigest.getInstance(digest); @@ -188,8 +224,7 @@ public class MemorizingTrustManager { StringBuffer si = new StringBuffer(); for (int i = 0; i < data.length; i++) { si.append(String.format("%02x", data[i])); - if (i < data.length - 1) - si.append(":"); + if (i < data.length - 1) si.append(":"); } return si.toString(); } @@ -220,20 +255,22 @@ public class MemorizingTrustManager { } } - void init(final Context m) { - master = m; - masterHandler = new Handler(m.getMainLooper()); - notificationManager = (NotificationManager) master.getSystemService(Context.NOTIFICATION_SERVICE); + void init(final Context context) { + master = context; + masterHandler = new Handler(context.getMainLooper()); + notificationManager = + (NotificationManager) master.getSystemService(Context.NOTIFICATION_SERVICE); Application app; - if (m instanceof Application) { - app = (Application) m; - } else if (m instanceof Service) { - app = ((Service) m).getApplication(); - } else if (m instanceof AppCompatActivity) { - app = ((AppCompatActivity) m).getApplication(); + if (context instanceof Application) { + app = (Application) context; + } else if (context instanceof Service) { + app = ((Service) context).getApplication(); + } else if (context instanceof AppCompatActivity) { + app = ((AppCompatActivity) context).getApplication(); } else - throw new ClassCastException("MemorizingTrustManager context must be either Activity or Service!"); + throw new ClassCastException( + "MemorizingTrustManager context must be either Activity or Service!"); File dir = app.getDir(KEYSTORE_DIR, Context.MODE_PRIVATE); keyStoreFile = new File(dir + File.separator + KEYSTORE_FILE); @@ -260,12 +297,9 @@ public class MemorizingTrustManager { /** * Removes the given certificate from MTMs key store. * - *

- * WARNING: this does not immediately invalidate the certificate. It is - * well possible that (a) data is transmitted over still existing connections or - * (b) new connections are created using TLS renegotiation, without a new cert - * check. - *

+ *

WARNING: this does not immediately invalidate the certificate. It is well possible + * that (a) data is transmitted over still existing connections or (b) new connections are + * created using TLS renegotiation, without a new cert check. * * @param alias the certificate's alias as returned by {@link #getCertificates()}. * @throws KeyStoreException if the certificate could not be deleted. @@ -275,20 +309,21 @@ public class MemorizingTrustManager { keyStoreUpdated(); } - X509TrustManager getTrustManager(KeyStore ks) { + private X509TrustManager getTrustManager(final KeyStore keyStore) { + Preconditions.checkNotNull(keyStore); try { TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509"); - tmf.init(ks); + tmf.init(keyStore); for (TrustManager t : tmf.getTrustManagers()) { if (t instanceof X509TrustManager) { return (X509TrustManager) t; } } - } catch (Exception e) { + } catch (final Exception e) { // Here, we are covering up errors. It might be more useful // however to throw them out of the constructor so the // embedding app knows something went wrong. - LOGGER.log(Level.SEVERE, "getTrustManager(" + ks + ")", e); + LOGGER.log(Level.SEVERE, "getTrustManager(" + keyStore + ")", e); } return null; } @@ -361,45 +396,60 @@ public class MemorizingTrustManager { } } - - private void checkCertTrusted(X509Certificate[] chain, String authType, String domain, boolean isServer, boolean interactive) + private void checkCertTrusted( + X509Certificate[] chain, + String authType, + String domain, + boolean isServer, + boolean interactive) throws CertificateException { - LOGGER.log(Level.FINE, "checkCertTrusted(" + chain + ", " + authType + ", " + isServer + ")"); + LOGGER.log( + Level.FINE, "checkCertTrusted(" + chain + ", " + authType + ", " + isServer + ")"); try { LOGGER.log(Level.FINE, "checkCertTrusted: trying appTrustManager"); - if (isServer) - appTrustManager.checkServerTrusted(chain, authType); - else - appTrustManager.checkClientTrusted(chain, authType); + if (isServer) appTrustManager.checkServerTrusted(chain, authType); + else appTrustManager.checkClientTrusted(chain, authType); } catch (final CertificateException ae) { LOGGER.log(Level.FINER, "checkCertTrusted: appTrustManager failed", ae); if (isCertKnown(chain[0])) { - LOGGER.log(Level.INFO, "checkCertTrusted: accepting cert already stored in keystore"); + LOGGER.log( + Level.INFO, "checkCertTrusted: accepting cert already stored in keystore"); return; } try { - if (defaultTrustManager == null) - throw ae; + if (defaultTrustManager == null) throw ae; LOGGER.log(Level.FINE, "checkCertTrusted: trying defaultTrustManager"); - if (isServer) - defaultTrustManager.checkServerTrusted(chain, authType); - else - defaultTrustManager.checkClientTrusted(chain, authType); + if (isServer) defaultTrustManager.checkServerTrusted(chain, authType); + else defaultTrustManager.checkClientTrusted(chain, authType); } catch (final CertificateException e) { - final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(master); - final boolean trustSystemCAs = !preferences.getBoolean("dont_trust_system_cas", false); - if (domain != null && isServer && trustSystemCAs && !isIp(domain) && !domain.endsWith(".onion")) { + final SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(master); + final boolean trustSystemCAs = + !preferences.getBoolean("dont_trust_system_cas", false); + if (domain != null + && isServer + && trustSystemCAs + && !isIp(domain) + && !domain.endsWith(".onion")) { final String hash = getBase64Hash(chain[0], "SHA-256"); final List fingerprints = getPoshFingerprints(domain); if (hash != null && fingerprints.size() > 0) { if (fingerprints.contains(hash)) { - Log.d(Config.LOGTAG, "trusted cert fingerprint of " + domain + " via posh"); + Log.d( + Config.LOGTAG, + "trusted cert fingerprint of " + domain + " via posh"); return; } else { - Log.d(Config.LOGTAG, "fingerprint " + hash + " not found in " + fingerprints); + Log.d( + Config.LOGTAG, + "fingerprint " + hash + " not found in " + fingerprints); } if (getPoshCacheFile(domain).delete()) { - Log.d(Config.LOGTAG, "deleted posh file for " + domain + " after not being able to verify"); + Log.d( + Config.LOGTAG, + "deleted posh file for " + + domain + + " after not being able to verify"); } } } @@ -422,17 +472,25 @@ public class MemorizingTrustManager { } private List getPoshFingerprintsFromServer(String domain) { - return getPoshFingerprintsFromServer(domain, "https://" + domain + "/.well-known/posh/xmpp-client.json", -1, true); + return getPoshFingerprintsFromServer( + domain, "https://" + domain + "/.well-known/posh/xmpp-client.json", -1, true); } - private List getPoshFingerprintsFromServer(String domain, String url, int maxTtl, boolean followUrl) { + private List getPoshFingerprintsFromServer( + String domain, String url, int maxTtl, boolean followUrl) { Log.d(Config.LOGTAG, "downloading json for " + domain + " from " + url); final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(master); - final boolean useTor = QuickConversationsService.isConversations() && preferences.getBoolean("use_tor", master.getResources().getBoolean(R.bool.use_tor)); + final boolean useTor = + QuickConversationsService.isConversations() + && preferences.getBoolean( + "use_tor", master.getResources().getBoolean(R.bool.use_tor)); try { final List results = new ArrayList<>(); final InputStream inputStream = HttpConnectionManager.open(url, useTor); - final String body = CharStreams.toString(new InputStreamReader(ByteStreams.limit(inputStream,10_000), Charsets.UTF_8)); + final String body = + CharStreams.toString( + new InputStreamReader( + ByteStreams.limit(inputStream, 10_000), Charsets.UTF_8)); final JSONObject jsonObject = new JSONObject(body); int expires = jsonObject.getInt("expires"); if (expires <= 0) { @@ -459,7 +517,7 @@ public class MemorizingTrustManager { writeFingerprintsToCache(domain, results, 1000L * expires + System.currentTimeMillis()); return results; } catch (final Exception e) { - Log.d(Config.LOGTAG, "error fetching posh",e); + Log.d(Config.LOGTAG, "error fetching posh", e); return new ArrayList<>(); } } @@ -489,7 +547,8 @@ public class MemorizingTrustManager { final File file = getPoshCacheFile(domain); try { final InputStream inputStream = new FileInputStream(file); - final String json = CharStreams.toString(new InputStreamReader(inputStream, Charsets.UTF_8)); + final String json = + CharStreams.toString(new InputStreamReader(inputStream, Charsets.UTF_8)); final JSONObject jsonObject = new JSONObject(json); long expires = jsonObject.getLong("expires"); long expiresIn = expires - System.currentTimeMillis(); @@ -514,7 +573,9 @@ public class MemorizingTrustManager { } private X509Certificate[] getAcceptedIssuers() { - return defaultTrustManager == null ? new X509Certificate[0] : defaultTrustManager.getAcceptedIssuers(); + return defaultTrustManager == null + ? new X509Certificate[0] + : defaultTrustManager.getAcceptedIssuers(); } private int createDecisionId(MTMDecision d) { @@ -527,7 +588,8 @@ public class MemorizingTrustManager { return myId; } - private void certDetails(final StringBuffer si, final X509Certificate c, final boolean showValidFor) { + private void certDetails( + final StringBuffer si, final X509Certificate c, final boolean showValidFor) { si.append("\n"); if (showValidFor) { @@ -564,8 +626,7 @@ public class MemorizingTrustManager { // not found", so we use string comparison. if (NO_TRUST_ANCHOR.equals(e.getMessage())) { si.append(master.getString(R.string.mtm_trust_anchor)); - } else - si.append(e.getLocalizedMessage()); + } else si.append(e.getLocalizedMessage()); si.append("\n"); } si.append("\n"); @@ -573,7 +634,7 @@ public class MemorizingTrustManager { si.append("\n\n"); si.append(master.getString(R.string.mtm_cert_details)); si.append('\n'); - for(int i = 0; i < chain.length; ++i) { + for (int i = 0; i < chain.length; ++i) { certDetails(si, chain[i], i == 0); } return si.toString(); @@ -593,24 +654,25 @@ public class MemorizingTrustManager { MTMDecision choice = new MTMDecision(); final int myId = createDecisionId(choice); - masterHandler.post(new Runnable() { - public void run() { - Intent ni = new Intent(master, MemorizingActivity.class); - ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - ni.setData(Uri.parse(MemorizingTrustManager.class.getName() + "/" + myId)); - ni.putExtra(DECISION_INTENT_ID, myId); - ni.putExtra(DECISION_INTENT_CERT, message); - ni.putExtra(DECISION_TITLE_ID, titleId); + masterHandler.post( + new Runnable() { + public void run() { + Intent ni = new Intent(master, MemorizingActivity.class); + ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ni.setData(Uri.parse(MemorizingTrustManager.class.getName() + "/" + myId)); + ni.putExtra(DECISION_INTENT_ID, myId); + ni.putExtra(DECISION_INTENT_CERT, message); + ni.putExtra(DECISION_TITLE_ID, titleId); - // we try to directly start the activity and fall back to - // making a notification - try { - getUI().startActivity(ni); - } catch (Exception e) { - LOGGER.log(Level.FINE, "startActivity(MemorizingActivity)", e); - } - } - }); + // we try to directly start the activity and fall back to + // making a notification + try { + getUI().startActivity(ni); + } catch (Exception e) { + LOGGER.log(Level.FINE, "startActivity(MemorizingActivity)", e); + } + } + }); LOGGER.log(Level.FINE, "openDecisions: " + openDecisions + ", waiting on " + myId); try { @@ -661,7 +723,8 @@ public class MemorizingTrustManager { } @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, false, false); } @@ -675,7 +738,6 @@ public class MemorizingTrustManager { public X509Certificate[] getAcceptedIssuers() { return MemorizingTrustManager.this.getAcceptedIssuers(); } - } private class InteractiveMemorizingTrustManager implements X509TrustManager { @@ -686,7 +748,8 @@ public class MemorizingTrustManager { } @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, false, true); } diff --git a/src/main/java/eu/siacs/conversations/services/NotificationService.java b/src/main/java/eu/siacs/conversations/services/NotificationService.java index ce302c92e..aae2a968d 100644 --- a/src/main/java/eu/siacs/conversations/services/NotificationService.java +++ b/src/main/java/eu/siacs/conversations/services/NotificationService.java @@ -10,6 +10,7 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; +import android.content.pm.ShortcutManager; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Typeface; @@ -21,6 +22,7 @@ import android.os.Build; import android.os.SystemClock; import android.os.Vibrator; import android.preference.PreferenceManager; +import android.provider.Settings; import android.text.SpannableString; import android.text.style.StyleSpan; import android.util.DisplayMetrics; @@ -34,10 +36,12 @@ import androidx.core.app.NotificationManagerCompat; import androidx.core.app.Person; import androidx.core.app.RemoteInput; import androidx.core.content.ContextCompat; +import androidx.core.content.pm.ShortcutInfoCompat; import androidx.core.graphics.drawable.IconCompat; import com.google.common.base.Joiner; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; import java.io.File; @@ -111,6 +115,7 @@ public class NotificationService { private long mLastNotification; private static final String INCOMING_CALLS_NOTIFICATION_CHANNEL = "incoming_calls_channel"; + private static final String MESSAGES_NOTIFICATION_CHANNEL = "messages"; private Ringtone currentlyPlayingRingtone = null; private ScheduledFuture vibrationFuture; @@ -240,7 +245,7 @@ public class NotificationService { final NotificationChannel messagesChannel = new NotificationChannel( - "messages", + MESSAGES_NOTIFICATION_CHANNEL, c.getString(R.string.messages_channel_name), NotificationManager.IMPORTANCE_HIGH); messagesChannel.setShowBadge(true); @@ -640,6 +645,7 @@ public class NotificationService { createCallAction( id.sessionId, XmppConnectionService.ACTION_END_CALL, 104)) .build()); + builder.setLocalOnly(true); return builder.build(); } @@ -770,6 +776,25 @@ public class NotificationService { } } + public void clearMissedCall(final Message message) { + synchronized (mMissedCalls) { + final Iterator> iterator = mMissedCalls.entrySet().iterator(); + while (iterator.hasNext()) { + final Map.Entry entry = iterator.next(); + final Conversational conversational = entry.getKey(); + final MissedCallsInfo missedCallsInfo = entry.getValue(); + if (conversational.getUuid().equals(message.getConversation().getUuid())) { + if (missedCallsInfo.removeMissedCall()) { + cancel(conversational.getUuid(), MISSED_CALL_NOTIFICATION_ID); + Log.d(Config.LOGTAG,conversational.getAccount().getJid().asBareJid()+": dismissed missed call because call was picked up on other device"); + iterator.remove(); + } + } + } + updateMissedCallNotifications(null); + } + } + public void clearMissedCalls() { synchronized (mMissedCalls) { for (final Conversational conversation : mMissedCalls.keySet()) { @@ -1088,7 +1113,7 @@ public class NotificationService { final Builder mBuilder = new NotificationCompat.Builder( mXmppConnectionService, - quietHours ? "quiet_hours" : (notify ? "messages" : "silent_messages")); + quietHours ? "quiet_hours" : (notify ? MESSAGES_NOTIFICATION_CHANNEL : "silent_messages")); final NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle(); style.setBigContentTitle( mXmppConnectionService @@ -1154,147 +1179,161 @@ public class NotificationService { private Builder buildSingleConversations( final ArrayList messages, final boolean notify, final boolean quietHours) { - final Builder mBuilder = - new NotificationCompat.Builder( - mXmppConnectionService, - quietHours ? "quiet_hours" : (notify ? "messages" : "silent_messages")); - if (messages.size() >= 1) { - final Conversation conversation = (Conversation) messages.get(0).getConversation(); - mBuilder.setLargeIcon( + final var channel = quietHours ? "quiet_hours" : (notify ? MESSAGES_NOTIFICATION_CHANNEL : "silent_messages"); + final Builder notificationBuilder = + new NotificationCompat.Builder(mXmppConnectionService, channel); + if (messages.isEmpty()) { + return notificationBuilder; + } + final Conversation conversation = (Conversation) messages.get(0).getConversation(); + notificationBuilder.setLargeIcon( + mXmppConnectionService + .getAvatarService() + .get( + conversation, + AvatarService.getSystemUiAvatarSize(mXmppConnectionService))); + notificationBuilder.setContentTitle(conversation.getName()); + if (Config.HIDE_MESSAGE_TEXT_IN_NOTIFICATION) { + int count = messages.size(); + notificationBuilder.setContentText( mXmppConnectionService - .getAvatarService() - .get( - conversation, - AvatarService.getSystemUiAvatarSize(mXmppConnectionService))); - mBuilder.setContentTitle(conversation.getName()); - if (Config.HIDE_MESSAGE_TEXT_IN_NOTIFICATION) { - int count = messages.size(); - mBuilder.setContentText( - mXmppConnectionService - .getResources() - .getQuantityString(R.plurals.x_messages, count, count)); + .getResources() + .getQuantityString(R.plurals.x_messages, count, count)); + } else { + final Message message; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P + && (message = getImage(messages)) != null) { + modifyForImage(notificationBuilder, message, messages); } else { - Message message; - // TODO starting with Android 9 we might want to put images in MessageStyle - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P - && (message = getImage(messages)) != null) { - modifyForImage(mBuilder, message, messages); - } else { - modifyForTextOnly(mBuilder, messages); - } - RemoteInput remoteInput = - new RemoteInput.Builder("text_reply") - .setLabel( - UIHelper.getMessageHint( - mXmppConnectionService, conversation)) - .build(); - PendingIntent markAsReadPendingIntent = createReadPendingIntent(conversation); - NotificationCompat.Action markReadAction = - new NotificationCompat.Action.Builder( - R.drawable.ic_drafts_white_24dp, - mXmppConnectionService.getString(R.string.mark_as_read), - markAsReadPendingIntent) - .setSemanticAction( - NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) - .setShowsUserInterface(false) - .build(); - final String replyLabel = mXmppConnectionService.getString(R.string.reply); - final String lastMessageUuid = Iterables.getLast(messages).getUuid(); - final NotificationCompat.Action replyAction = - new NotificationCompat.Action.Builder( - R.drawable.ic_send_text_offline, - replyLabel, - createReplyIntent(conversation, lastMessageUuid, false)) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) - .setShowsUserInterface(false) - .addRemoteInput(remoteInput) - .build(); - final NotificationCompat.Action wearReplyAction = - new NotificationCompat.Action.Builder( - R.drawable.ic_wear_reply, - replyLabel, - createReplyIntent(conversation, lastMessageUuid, true)) - .addRemoteInput(remoteInput) - .build(); - mBuilder.extend( - new NotificationCompat.WearableExtender().addAction(wearReplyAction)); - int addedActionsCount = 1; - mBuilder.addAction(markReadAction); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - mBuilder.addAction(replyAction); - ++addedActionsCount; - } + modifyForTextOnly(notificationBuilder, messages); + } + RemoteInput remoteInput = + new RemoteInput.Builder("text_reply") + .setLabel(UIHelper.getMessageHint(mXmppConnectionService, conversation)) + .build(); + PendingIntent markAsReadPendingIntent = createReadPendingIntent(conversation); + NotificationCompat.Action markReadAction = + new NotificationCompat.Action.Builder( + R.drawable.ic_drafts_white_24dp, + mXmppConnectionService.getString(R.string.mark_as_read), + markAsReadPendingIntent) + .setSemanticAction( + NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .setShowsUserInterface(false) + .build(); + final String replyLabel = mXmppConnectionService.getString(R.string.reply); + final String lastMessageUuid = Iterables.getLast(messages).getUuid(); + final NotificationCompat.Action replyAction = + new NotificationCompat.Action.Builder( + R.drawable.ic_send_text_offline, + replyLabel, + createReplyIntent(conversation, lastMessageUuid, false)) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .setShowsUserInterface(false) + .addRemoteInput(remoteInput) + .build(); + final NotificationCompat.Action wearReplyAction = + new NotificationCompat.Action.Builder( + R.drawable.ic_wear_reply, + replyLabel, + createReplyIntent(conversation, lastMessageUuid, true)) + .addRemoteInput(remoteInput) + .build(); + notificationBuilder.extend( + new NotificationCompat.WearableExtender().addAction(wearReplyAction)); + int addedActionsCount = 1; + notificationBuilder.addAction(markReadAction); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + notificationBuilder.addAction(replyAction); + ++addedActionsCount; + } - if (displaySnoozeAction(messages)) { - String label = mXmppConnectionService.getString(R.string.snooze); - PendingIntent pendingSnoozeIntent = createSnoozeIntent(conversation); - NotificationCompat.Action snoozeAction = - new NotificationCompat.Action.Builder( - R.drawable.ic_notifications_paused_white_24dp, - label, - pendingSnoozeIntent) - .build(); - mBuilder.addAction(snoozeAction); - ++addedActionsCount; - } - if (addedActionsCount < 3) { - final Message firstLocationMessage = getFirstLocationMessage(messages); - if (firstLocationMessage != null) { - final PendingIntent pendingShowLocationIntent = - createShowLocationIntent(firstLocationMessage); - if (pendingShowLocationIntent != null) { - final String label = - mXmppConnectionService - .getResources() - .getString(R.string.show_location); - NotificationCompat.Action locationAction = - new NotificationCompat.Action.Builder( - R.drawable.ic_room_white_24dp, - label, - pendingShowLocationIntent) - .build(); - mBuilder.addAction(locationAction); - ++addedActionsCount; - } - } - } - if (addedActionsCount < 3) { - Message firstDownloadableMessage = getFirstDownloadableMessage(messages); - if (firstDownloadableMessage != null) { - String label = + if (displaySnoozeAction(messages)) { + String label = mXmppConnectionService.getString(R.string.snooze); + PendingIntent pendingSnoozeIntent = createSnoozeIntent(conversation); + NotificationCompat.Action snoozeAction = + new NotificationCompat.Action.Builder( + R.drawable.ic_notifications_paused_white_24dp, + label, + pendingSnoozeIntent) + .build(); + notificationBuilder.addAction(snoozeAction); + ++addedActionsCount; + } + if (addedActionsCount < 3) { + final Message firstLocationMessage = getFirstLocationMessage(messages); + if (firstLocationMessage != null) { + final PendingIntent pendingShowLocationIntent = + createShowLocationIntent(firstLocationMessage); + if (pendingShowLocationIntent != null) { + final String label = mXmppConnectionService .getResources() - .getString( - R.string.download_x_file, - UIHelper.getFileDescriptionString( - mXmppConnectionService, - firstDownloadableMessage)); - PendingIntent pendingDownloadIntent = - createDownloadIntent(firstDownloadableMessage); - NotificationCompat.Action downloadAction = + .getString(R.string.show_location); + NotificationCompat.Action locationAction = new NotificationCompat.Action.Builder( - R.drawable.ic_file_download_white_24dp, + R.drawable.ic_room_white_24dp, label, - pendingDownloadIntent) + pendingShowLocationIntent) .build(); - mBuilder.addAction(downloadAction); + notificationBuilder.addAction(locationAction); ++addedActionsCount; } } } - if (conversation.getMode() == Conversation.MODE_SINGLE) { - Contact contact = conversation.getContact(); - Uri systemAccount = contact.getSystemAccount(); - if (systemAccount != null) { - mBuilder.addPerson(systemAccount.toString()); + if (addedActionsCount < 3) { + Message firstDownloadableMessage = getFirstDownloadableMessage(messages); + if (firstDownloadableMessage != null) { + String label = + mXmppConnectionService + .getResources() + .getString( + R.string.download_x_file, + UIHelper.getFileDescriptionString( + mXmppConnectionService, + firstDownloadableMessage)); + PendingIntent pendingDownloadIntent = + createDownloadIntent(firstDownloadableMessage); + NotificationCompat.Action downloadAction = + new NotificationCompat.Action.Builder( + R.drawable.ic_file_download_white_24dp, + label, + pendingDownloadIntent) + .build(); + notificationBuilder.addAction(downloadAction); + ++addedActionsCount; } } - mBuilder.setWhen(conversation.getLatestMessage().getTimeSent()); - mBuilder.setSmallIcon(R.drawable.ic_notification); - mBuilder.setDeleteIntent(createDeleteIntent(conversation)); - mBuilder.setContentIntent(createContentIntent(conversation)); } - return mBuilder; + final ShortcutInfoCompat info; + if (conversation.getMode() == Conversation.MODE_SINGLE) { + final Contact contact = conversation.getContact(); + final Uri systemAccount = contact.getSystemAccount(); + if (systemAccount != null) { + notificationBuilder.addPerson(systemAccount.toString()); + } + info = mXmppConnectionService.getShortcutService().getShortcutInfoCompat(contact); + } else { + info = + mXmppConnectionService + .getShortcutService() + .getShortcutInfoCompat(conversation.getMucOptions()); + } + notificationBuilder.setWhen(conversation.getLatestMessage().getTimeSent()); + notificationBuilder.setSmallIcon(R.drawable.ic_notification); + notificationBuilder.setDeleteIntent(createDeleteIntent(conversation)); + notificationBuilder.setContentIntent(createContentIntent(conversation)); + if (channel.equals(MESSAGES_NOTIFICATION_CHANNEL)) { + // when do not want 'customized' notifications for silent notifications in their + // respective channels + notificationBuilder.setShortcutInfo(info); + if (Build.VERSION.SDK_INT >= 30) { + mXmppConnectionService + .getSystemService(ShortcutManager.class) + .pushDynamicShortcut(info.toShortcutInfo()); + } + } + return notificationBuilder; } private void modifyForImage( @@ -1626,12 +1665,25 @@ public class NotificationService { } private PendingIntent createCallAction(String sessionId, final String action, int requestCode) { - final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class); + return pendingServiceIntent(mXmppConnectionService, action, requestCode, ImmutableMap.of(RtpSessionActivity.EXTRA_SESSION_ID, sessionId)); + } + + private PendingIntent createSnoozeIntent(final Conversation conversation) { + return pendingServiceIntent(mXmppConnectionService, XmppConnectionService.ACTION_SNOOZE, generateRequestCode(conversation,22),ImmutableMap.of("uuid",conversation.getUuid())); + } + + private static PendingIntent pendingServiceIntent(final Context context, final String action, final int requestCode) { + return pendingServiceIntent(context, action, requestCode, ImmutableMap.of()); + } + + private static PendingIntent pendingServiceIntent(final Context context, final String action, final int requestCode, final Map extras) { + final Intent intent = new Intent(context, XmppConnectionService.class); intent.setAction(action); - intent.setPackage(mXmppConnectionService.getPackageName()); - intent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, sessionId); + for(final Map.Entry entry : extras.entrySet()) { + intent.putExtra(entry.getKey(), entry.getValue()); + } return PendingIntent.getService( - mXmppConnectionService, + context, requestCode, intent, s() @@ -1639,44 +1691,6 @@ public class NotificationService { : PendingIntent.FLAG_UPDATE_CURRENT); } - private PendingIntent createSnoozeIntent(Conversation conversation) { - final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class); - intent.setAction(XmppConnectionService.ACTION_SNOOZE); - intent.putExtra("uuid", conversation.getUuid()); - intent.setPackage(mXmppConnectionService.getPackageName()); - return PendingIntent.getService( - mXmppConnectionService, - generateRequestCode(conversation, 22), - intent, - s() - ? PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT - : PendingIntent.FLAG_UPDATE_CURRENT); - } - - private PendingIntent createTryAgainIntent() { - final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class); - intent.setAction(XmppConnectionService.ACTION_TRY_AGAIN); - return PendingIntent.getService( - mXmppConnectionService, - 45, - intent, - s() - ? PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT - : PendingIntent.FLAG_UPDATE_CURRENT); - } - - private PendingIntent createDismissErrorIntent() { - final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class); - intent.setAction(XmppConnectionService.ACTION_DISMISS_ERROR_NOTIFICATIONS); - return PendingIntent.getService( - mXmppConnectionService, - 69, - intent, - s() - ? PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT - : PendingIntent.FLAG_UPDATE_CURRENT); - } - private boolean wasHighlightedOrPrivate(final Message message) { if (message.getConversation() instanceof Conversation) { Conversation conversation = (Conversation) message.getConversation(); @@ -1721,17 +1735,15 @@ public class NotificationService { final Notification.Builder mBuilder = new Notification.Builder(mXmppConnectionService); mBuilder.setContentTitle(mXmppConnectionService.getString(R.string.app_name)); final List accounts = mXmppConnectionService.getAccounts(); - int enabled = 0; - int connected = 0; - if (accounts != null) { - for (Account account : accounts) { - if (account.isOnlineAndConnected()) { - connected++; - enabled++; - } else if (account.isEnabled()) { - enabled++; - } - } + final int enabled; + final int connected; + if (accounts == null) { + enabled = 0; + connected = 0; + } else { + enabled = Iterables.size(Iterables.filter(accounts, Account::isEnabled)); + connected = + Iterables.size(Iterables.filter(accounts, Account::isOnlineAndConnected)); } mBuilder.setContentText( mXmppConnectionService.getString(R.string.connected_accounts, connected, enabled)); @@ -1749,11 +1761,36 @@ public class NotificationService { if (Compatibility.runsTwentySix()) { mBuilder.setChannelId("foreground"); + mBuilder.addAction( + R.drawable.ic_logout_white_24dp, + mXmppConnectionService.getString(R.string.log_out), + pendingServiceIntent( + mXmppConnectionService, + XmppConnectionService.ACTION_TEMPORARILY_DISABLE, + 87)); + mBuilder.addAction( + R.drawable.ic_notifications_off_white_24dp, + mXmppConnectionService.getString(R.string.hide_notification), + pendingNotificationSettingsIntent(mXmppConnectionService)); } return mBuilder.build(); } + @RequiresApi(api = Build.VERSION_CODES.O) + private static PendingIntent pendingNotificationSettingsIntent(final Context context) { + final Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()); + intent.putExtra(Settings.EXTRA_CHANNEL_ID, "foreground"); + return PendingIntent.getActivity( + context, + 89, + intent, + s() + ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + : PendingIntent.FLAG_UPDATE_CURRENT); + } + private PendingIntent createOpenConversationsIntent() { try { return PendingIntent.getActivity( @@ -1804,7 +1841,7 @@ public class NotificationService { mBuilder.addAction( R.drawable.ic_autorenew_white_24dp, mXmppConnectionService.getString(R.string.try_again), - createTryAgainIntent()); + pendingServiceIntent(mXmppConnectionService, XmppConnectionService.ACTION_TRY_AGAIN, 45)); if (torNotAvailable) { if (TorServiceUtils.isOrbotInstalled(mXmppConnectionService)) { mBuilder.addAction( @@ -1832,7 +1869,7 @@ public class NotificationService { : PendingIntent.FLAG_UPDATE_CURRENT)); } } - mBuilder.setDeleteIntent(createDismissErrorIntent()); + mBuilder.setDeleteIntent(pendingServiceIntent(mXmppConnectionService,XmppConnectionService.ACTION_DISMISS_ERROR_NOTIFICATIONS, 69)); mBuilder.setVisibility(Notification.VISIBILITY_PRIVATE); mBuilder.setSmallIcon(R.drawable.ic_warning_white_24dp); mBuilder.setLocalOnly(true); @@ -1927,6 +1964,11 @@ public class NotificationService { lastTime = time; } + public boolean removeMissedCall() { + --numberOfCalls; + return numberOfCalls <= 0; + } + public int getNumberOfCalls() { return numberOfCalls; } diff --git a/src/main/java/eu/siacs/conversations/services/ShortcutService.java b/src/main/java/eu/siacs/conversations/services/ShortcutService.java index 6b7106f74..c6ae77b63 100644 --- a/src/main/java/eu/siacs/conversations/services/ShortcutService.java +++ b/src/main/java/eu/siacs/conversations/services/ShortcutService.java @@ -11,6 +11,9 @@ import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.graphics.drawable.IconCompat; import java.util.ArrayList; import java.util.HashMap; @@ -19,6 +22,7 @@ import java.util.List; import eu.siacs.conversations.Config; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.MucOptions; import eu.siacs.conversations.ui.StartConversationActivity; import eu.siacs.conversations.utils.ReplacingSerialSingleThreadExecutor; import eu.siacs.conversations.xmpp.Jid; @@ -88,13 +92,45 @@ public class ShortcutService { } } - @TargetApi(Build.VERSION_CODES.N_MR1) - private ShortcutInfo getShortcutInfo(Contact contact) { - return new ShortcutInfo.Builder(xmppConnectionService, getShortcutId(contact)) + public ShortcutInfoCompat getShortcutInfoCompat(final Contact contact) { + final ShortcutInfoCompat.Builder builder = + new ShortcutInfoCompat.Builder(xmppConnectionService, getShortcutId(contact)) .setShortLabel(contact.getDisplayName()) .setIntent(getShortcutIntent(contact)) - .setIcon(Icon.createWithBitmap(xmppConnectionService.getAvatarService().getRoundedShortcut(contact))) - .build(); + .setIsConversation(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + builder.setIcon( + IconCompat.createFromIcon( + xmppConnectionService, + Icon.createWithBitmap( + xmppConnectionService + .getAvatarService() + .getRoundedShortcut(contact)))); + } + return builder.build(); + } + + public ShortcutInfoCompat getShortcutInfoCompat(final MucOptions mucOptions) { + final ShortcutInfoCompat.Builder builder = + new ShortcutInfoCompat.Builder(xmppConnectionService, getShortcutId(mucOptions)) + .setShortLabel(mucOptions.getConversation().getName()) + .setIntent(getShortcutIntent(mucOptions)) + .setIsConversation(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + builder.setIcon( + IconCompat.createFromIcon( + xmppConnectionService, + Icon.createWithBitmap( + xmppConnectionService + .getAvatarService() + .getRoundedShortcut(mucOptions)))); + } + return builder.build(); + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + private ShortcutInfo getShortcutInfo(final Contact contact) { + return getShortcutInfoCompat(contact).toShortcutInfo(); } private static boolean contactsChanged(List needles, List haystack) { @@ -120,12 +156,40 @@ public class ShortcutService { return contact.getAccount().getJid().asBareJid().toEscapedString()+"#"+contact.getJid().asBareJid().toEscapedString(); } - private Intent getShortcutIntent(Contact contact) { + private static String getShortcutId(final MucOptions mucOptions) { + final Account account = mucOptions.getAccount(); + final Jid jid = mucOptions.getConversation().getJid(); + return account.getJid().asBareJid().toEscapedString() + + "#" + + jid.asBareJid().toEscapedString(); + } + + private Intent getShortcutIntent(final MucOptions mucOptions) { + final Account account = mucOptions.getAccount(); + return getShortcutIntent( + account, + Uri.parse( + String.format( + "xmpp:%s?join", + mucOptions + .getConversation() + .getJid() + .asBareJid() + .toEscapedString()))); + } + + private Intent getShortcutIntent(final Contact contact) { + return getShortcutIntent( + contact.getAccount(), + Uri.parse("xmpp:" + contact.getJid().asBareJid().toEscapedString())); + } + + private Intent getShortcutIntent(final Account account, final Uri uri) { Intent intent = new Intent(xmppConnectionService, StartConversationActivity.class); intent.setAction(Intent.ACTION_VIEW); - intent.setData(Uri.parse("xmpp:"+contact.getJid().asBareJid().toEscapedString())); - intent.putExtra("account",contact.getAccount().getJid().asBareJid().toString()); - intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP| Intent.FLAG_ACTIVITY_SINGLE_TOP); + intent.setData(uri); + intent.putExtra("account", account.getJid().asBareJid().toString()); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); return intent; } diff --git a/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java b/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java index 7d2d90dd5..bfa1785f1 100644 --- a/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java +++ b/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java @@ -1,15 +1,28 @@ package eu.siacs.conversations.services; +import android.app.PendingIntent; import android.content.ComponentName; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.os.Message; +import android.os.Messenger; +import android.os.RemoteException; import android.preference.PreferenceManager; import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.google.common.base.Optional; import com.google.common.base.Strings; import com.google.common.collect.Iterables; import com.google.common.io.BaseEncoding; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; + import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; @@ -62,7 +75,7 @@ public class UnifiedPushBroker { Log.d( Config.LOGTAG, account.getJid().asBareJid() + ": trigger endpoint renewal on bind"); - renewUnifiedEndpoint(transportOptional.get()); + renewUnifiedEndpoint(transportOptional.get(), null); } } } @@ -73,22 +86,44 @@ public class UnifiedPushBroker { service.sendPresencePacket(account, presence); } - public Optional renewUnifiedPushEndpoints() { + public void renewUnifiedPushEndpoints() { + renewUnifiedPushEndpoints(null); + } + + public Optional renewUnifiedPushEndpoints(@Nullable final PushTargetMessenger pushTargetMessenger) { final Optional transportOptional = getTransport(); if (transportOptional.isPresent()) { final Transport transport = transportOptional.get(); if (transport.account.isEnabled()) { - renewUnifiedEndpoint(transportOptional.get()); + renewUnifiedEndpoint(transportOptional.get(), pushTargetMessenger); } else { + if (pushTargetMessenger != null && pushTargetMessenger.messenger != null) { + sendRegistrationDelayed(pushTargetMessenger.messenger,"account is disabled"); + } Log.d(Config.LOGTAG, "skipping UnifiedPush endpoint renewal. Account is disabled"); } } else { + if (pushTargetMessenger != null && pushTargetMessenger.messenger != null) { + sendRegistrationDelayed(pushTargetMessenger.messenger,"no transport selected"); + } Log.d(Config.LOGTAG, "skipping UnifiedPush endpoint renewal. No transport selected"); } return transportOptional; } - private void renewUnifiedEndpoint(final Transport transport) { + private void sendRegistrationDelayed(final Messenger messenger, final String error) { + final Intent intent = new Intent(UnifiedPushDistributor.ACTION_REGISTRATION_DELAYED); + intent.putExtra(UnifiedPushDistributor.EXTRA_MESSAGE, error); + final var message = new Message(); + message.obj = intent; + try { + messenger.send(message); + } catch (final RemoteException e) { + Log.d(Config.LOGTAG,"unable to tell messenger of delayed registration",e); + } + } + + private void renewUnifiedEndpoint(final Transport transport, final PushTargetMessenger pushTargetMessenger) { final Account account = transport.account; final UnifiedPushDatabase unifiedPushDatabase = UnifiedPushDatabase.getInstance(service); final List renewals = @@ -105,6 +140,7 @@ public class UnifiedPushBroker { Log.d( Config.LOGTAG, account.getJid().asBareJid() + ": try to renew UnifiedPush " + renewal); + UnifiedPushDistributor.quickLog(service,String.format("%s: try to renew UnifiedPush %s", account.getJid(), renewal.toString())); final String hashedApplication = UnifiedPushDistributor.hash(account.getUuid(), renewal.application); final String hashedInstance = @@ -114,16 +150,23 @@ public class UnifiedPushBroker { final Element register = registration.addChild("register", Namespace.UNIFIED_PUSH); register.setAttribute("application", hashedApplication); register.setAttribute("instance", hashedInstance); + final Messenger messenger; + if (pushTargetMessenger != null && renewal.equals(pushTargetMessenger.pushTarget)) { + messenger = pushTargetMessenger.messenger; + } else { + messenger = null; + } this.service.sendIqPacket( account, registration, - (a, response) -> processRegistration(transport, renewal, response)); + (a, response) -> processRegistration(transport, renewal, messenger, response)); } } private void processRegistration( final Transport transport, final UnifiedPushDatabase.PushTarget renewal, + final Messenger messenger, final IqPacket response) { if (response.getType() == IqPacket.TYPE.RESULT) { final Element registered = response.findChild("registered", Namespace.UNIFIED_PUSH); @@ -142,7 +185,7 @@ public class UnifiedPushBroker { Log.d(Config.LOGTAG, "could not parse expiration", e); return; } - renewUnifiedPushEndpoint(transport, renewal, endpoint, expiration); + renewUnifiedPushEndpoint(transport, renewal, messenger, endpoint, expiration); } else { Log.d(Config.LOGTAG, "could not register UP endpoint " + response.getErrorCondition()); } @@ -151,6 +194,7 @@ public class UnifiedPushBroker { private void renewUnifiedPushEndpoint( final Transport transport, final UnifiedPushDatabase.PushTarget renewal, + final Messenger messenger, final String endpoint, final long expiration) { Log.d(Config.LOGTAG, "registered endpoint " + endpoint + " expiration=" + expiration); @@ -171,15 +215,42 @@ public class UnifiedPushBroker { + renewal.instance + " was updated to " + endpoint); - broadcastEndpoint( - renewal.instance, - new UnifiedPushDatabase.ApplicationEndpoint(renewal.application, endpoint)); + UnifiedPushDistributor.quickLog( + service, + "endpoint for " + + renewal.application + + "/" + + renewal.instance + + " was updated to " + + endpoint); + final UnifiedPushDatabase.ApplicationEndpoint applicationEndpoint = + new UnifiedPushDatabase.ApplicationEndpoint(renewal.application, endpoint); + sendEndpoint(messenger, renewal.instance, applicationEndpoint); + } + } + + private void sendEndpoint(final Messenger messenger, String instance, final UnifiedPushDatabase.ApplicationEndpoint applicationEndpoint) { + if (messenger != null) { + Log.d(Config.LOGTAG,"using messenger instead of broadcast to communicate endpoint to "+applicationEndpoint.application); + final Message message = new Message(); + message.obj = endpointIntent(instance, applicationEndpoint); + try { + messenger.send(message); + } catch (final RemoteException e) { + Log.d(Config.LOGTAG,"messenger failed. falling back to broadcast"); + broadcastEndpoint(instance, applicationEndpoint); + } + } else { + broadcastEndpoint(instance, applicationEndpoint); } } public boolean reconfigurePushDistributor() { final boolean enabled = getTransport().isPresent(); setUnifiedPushDistributorEnabled(enabled); + if (!enabled) { + unregisterCurrentPushTargets(); + } return enabled; } @@ -202,6 +273,43 @@ public class UnifiedPushBroker { } } + private void unregisterCurrentPushTargets() { + final var future = deletePushTargets(); + Futures.addCallback( + future, + new FutureCallback<>() { + @Override + public void onSuccess( + final List pushTargets) { + broadcastUnregistered(pushTargets); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + Log.d( + Config.LOGTAG, + "could not delete endpoints after UnifiedPushDistributor was disabled"); + } + }, + MoreExecutors.directExecutor()); + } + + private ListenableFuture> deletePushTargets() { + return Futures.submit(() -> UnifiedPushDatabase.getInstance(service).deletePushTargets(),SCHEDULER); + } + + private void broadcastUnregistered(final List pushTargets) { + for(final UnifiedPushDatabase.PushTarget pushTarget : pushTargets) { + Log.d(Config.LOGTAG,"sending unregistered to "+pushTarget); + broadcastUnregistered(pushTarget); + } + } + + private void broadcastUnregistered(final UnifiedPushDatabase.PushTarget pushTarget) { + final var intent = unregisteredIntent(pushTarget); + service.sendBroadcast(intent); + } + public boolean processPushMessage( final Account account, final Jid transport, final Element push) { final String instance = push.getAttribute("instance"); @@ -296,20 +404,50 @@ public class UnifiedPushBroker { updateIntent.putExtra("token", target.instance); updateIntent.putExtra("bytesMessage", payload); updateIntent.putExtra("message", new String(payload, StandardCharsets.UTF_8)); + final var distributorVerificationIntent = new Intent(); + distributorVerificationIntent.setPackage(service.getPackageName()); + final var pendingIntent = + PendingIntent.getBroadcast( + service, 0, distributorVerificationIntent, PendingIntent.FLAG_IMMUTABLE); + updateIntent.putExtra("distributor", pendingIntent); service.sendBroadcast(updateIntent); } private void broadcastEndpoint( final String instance, final UnifiedPushDatabase.ApplicationEndpoint endpoint) { Log.d(Config.LOGTAG, "broadcasting endpoint to " + endpoint.application); - final Intent updateIntent = new Intent(UnifiedPushDistributor.ACTION_NEW_ENDPOINT); - updateIntent.setPackage(endpoint.application); - updateIntent.putExtra("token", instance); - updateIntent.putExtra("endpoint", endpoint.endpoint); + final Intent updateIntent = endpointIntent(instance, endpoint); service.sendBroadcast(updateIntent); } - public void rebroadcastEndpoint(final String instance, final Transport transport) { + private Intent endpointIntent(final String instance, final UnifiedPushDatabase.ApplicationEndpoint endpoint) { + final Intent intent = new Intent(UnifiedPushDistributor.ACTION_NEW_ENDPOINT); + intent.setPackage(endpoint.application); + intent.putExtra("token", instance); + intent.putExtra("endpoint", endpoint.endpoint); + final var distributorVerificationIntent = new Intent(); + distributorVerificationIntent.setPackage(service.getPackageName()); + final var pendingIntent = + PendingIntent.getBroadcast( + service, 0, distributorVerificationIntent, PendingIntent.FLAG_IMMUTABLE); + intent.putExtra("distributor", pendingIntent); + return intent; + } + + private Intent unregisteredIntent(final UnifiedPushDatabase.PushTarget pushTarget) { + final Intent intent = new Intent(UnifiedPushDistributor.ACTION_UNREGISTERED); + intent.setPackage(pushTarget.application); + intent.putExtra("token", pushTarget.instance); + final var distributorVerificationIntent = new Intent(); + distributorVerificationIntent.setPackage(service.getPackageName()); + final var pendingIntent = + PendingIntent.getBroadcast( + service, 0, distributorVerificationIntent, PendingIntent.FLAG_IMMUTABLE); + intent.putExtra("distributor", pendingIntent); + return intent; + } + + public void rebroadcastEndpoint(final Messenger messenger, final String instance, final Transport transport) { final UnifiedPushDatabase unifiedPushDatabase = UnifiedPushDatabase.getInstance(service); final UnifiedPushDatabase.ApplicationEndpoint endpoint = unifiedPushDatabase.getEndpoint( @@ -317,7 +455,7 @@ public class UnifiedPushBroker { transport.transport.toEscapedString(), instance); if (endpoint != null) { - broadcastEndpoint(instance, endpoint); + sendEndpoint(messenger, instance, endpoint); } } @@ -330,4 +468,14 @@ public class UnifiedPushBroker { this.transport = transport; } } + + public static class PushTargetMessenger { + private final UnifiedPushDatabase.PushTarget pushTarget; + public final Messenger messenger; + + public PushTargetMessenger(UnifiedPushDatabase.PushTarget pushTarget, Messenger messenger) { + this.pushTarget = pushTarget; + this.messenger = messenger; + } + } } diff --git a/src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java b/src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java index 64c16dbcd..b47a61a53 100644 --- a/src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java +++ b/src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java @@ -1,10 +1,15 @@ package eu.siacs.conversations.services; +import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.pm.ResolveInfo; import android.net.Uri; +import android.os.Message; +import android.os.Messenger; +import android.os.Parcelable; +import android.os.RemoteException; import android.util.Log; import com.google.common.base.Charsets; @@ -24,16 +29,30 @@ import eu.siacs.conversations.utils.Compatibility; public class UnifiedPushDistributor extends BroadcastReceiver { + // distributor actions (these are actios used for connector->distributor broadcasts) + // we, the distributor, have a broadcast receiver listening for those actions + public static final String ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER"; public static final String ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER"; + + + // connector actions (these are actions used for distributor->connector broadcasts) + public static final String ACTION_UNREGISTERED = "org.unifiedpush.android.connector.UNREGISTERED"; public static final String ACTION_BYTE_MESSAGE = "org.unifiedpush.android.distributor.feature.BYTES_MESSAGE"; public static final String ACTION_REGISTRATION_FAILED = "org.unifiedpush.android.connector.REGISTRATION_FAILED"; + + // this action is only used in 'messenger' communication to tell the app that a registration is + // probably fine but can not be processed right now; for example due to spotty internet + public static final String ACTION_REGISTRATION_DELAYED = + "org.unifiedpush.android.connector.REGISTRATION_DELAYED"; public static final String ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE"; public static final String ACTION_NEW_ENDPOINT = "org.unifiedpush.android.connector.NEW_ENDPOINT"; + public static final String EXTRA_MESSAGE = "message"; + public static final String PREFERENCE_ACCOUNT = "up_push_account"; public static final String PREFERENCE_PUSH_SERVER = "up_push_server"; @@ -46,22 +65,24 @@ public class UnifiedPushDistributor extends BroadcastReceiver { return; } final String action = intent.getAction(); - final String application = intent.getStringExtra("application"); + final String application; + final Parcelable appVerification = intent.getParcelableExtra("app"); + if (appVerification instanceof PendingIntent pendingIntent) { + application = pendingIntent.getIntentSender().getCreatorPackage(); + Log.d(Config.LOGTAG,"received application name via pending intent "+ application); + } else { + application = intent.getStringExtra("application"); + } + final Parcelable messenger = intent.getParcelableExtra("messenger"); final String instance = intent.getStringExtra("token"); final List features = intent.getStringArrayListExtra("features"); switch (Strings.nullToEmpty(action)) { - case ACTION_REGISTER: - register(context, application, instance, features); - break; - case ACTION_UNREGISTER: - unregister(context, instance); - break; - case Intent.ACTION_PACKAGE_FULLY_REMOVED: - unregisterApplication(context, intent.getData()); - break; - default: - Log.d(Config.LOGTAG, "UnifiedPushDistributor received unknown action " + action); - break; + case ACTION_REGISTER -> register(context, application, instance, features, messenger); + case ACTION_UNREGISTER -> unregister(context, instance); + case Intent.ACTION_PACKAGE_FULLY_REMOVED -> + unregisterApplication(context, intent.getData()); + default -> + Log.d(Config.LOGTAG, "UnifiedPushDistributor received unknown action " + action); } } @@ -69,7 +90,8 @@ public class UnifiedPushDistributor extends BroadcastReceiver { final Context context, final String application, final String instance, - final Collection features) { + final Collection features, + final Parcelable messenger) { if (Strings.isNullOrEmpty(application) || Strings.isNullOrEmpty(instance)) { Log.w(Config.LOGTAG, "ignoring invalid UnifiedPush registration"); return; @@ -89,18 +111,37 @@ public class UnifiedPushDistributor extends BroadcastReceiver { Log.d( Config.LOGTAG, "successfully created UnifiedPush entry. waking up XmppConnectionService"); + quickLog(context, String.format("successfully registered %s (token = %s) for UnifiedPushed", application, instance)); final Intent serviceIntent = new Intent(context, XmppConnectionService.class); serviceIntent.setAction(XmppConnectionService.ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS); serviceIntent.putExtra("instance", instance); + serviceIntent.putExtra("application", application); + if (messenger instanceof Messenger) { + serviceIntent.putExtra("messenger", messenger); + } Compatibility.startService(context, serviceIntent); } else { Log.d(Config.LOGTAG, "not successful. sending error message back to application"); final Intent registrationFailed = new Intent(ACTION_REGISTRATION_FAILED); + registrationFailed.putExtra(EXTRA_MESSAGE, "instance already exits"); registrationFailed.setPackage(application); registrationFailed.putExtra("token", instance); - context.sendBroadcast(registrationFailed); + if (messenger instanceof Messenger m) { + final var message = new Message(); + message.obj = registrationFailed; + try { + m.send(message); + } catch (final RemoteException e) { + context.sendBroadcast(registrationFailed); + } + } else { + context.sendBroadcast(registrationFailed); + } } } else { + if (messenger instanceof Messenger m) { + sendRegistrationFailed(m,"Your application is not registered to receive messages"); + } Log.d( Config.LOGTAG, "ignoring invalid UnifiedPush registration. Unknown application " @@ -108,6 +149,18 @@ public class UnifiedPushDistributor extends BroadcastReceiver { } } + private void sendRegistrationFailed(final Messenger messenger, final String error) { + final Intent intent = new Intent(ACTION_REGISTRATION_FAILED); + intent.putExtra(EXTRA_MESSAGE, error); + final var message = new Message(); + message.obj = intent; + try { + messenger.send(message); + } catch (final RemoteException e) { + Log.d(Config.LOGTAG,"unable to tell messenger of failed registration",e); + } + } + private List getBroadcastReceivers(final Context context, final String application) { final Intent messageIntent = new Intent(ACTION_MESSAGE); messageIntent.setPackage(application); @@ -124,7 +177,9 @@ public class UnifiedPushDistributor extends BroadcastReceiver { } final UnifiedPushDatabase unifiedPushDatabase = UnifiedPushDatabase.getInstance(context); if (unifiedPushDatabase.deleteInstance(instance)) { + quickLog(context, String.format("successfully unregistered token %s from UnifiedPushed (application requested unregister)", instance)); Log.d(Config.LOGTAG, "successfully removed " + instance + " from UnifiedPush"); + // TODO send UNREGISTERED broadcast back to app?! } } @@ -137,6 +192,7 @@ public class UnifiedPushDistributor extends BroadcastReceiver { Log.d(Config.LOGTAG, "app " + application + " has been removed from the system"); final UnifiedPushDatabase database = UnifiedPushDatabase.getInstance(context); if (database.deleteApplication(application)) { + quickLog(context, String.format("successfully removed %s from UnifiedPushed (ACTION_PACKAGE_FULLY_REMOVED)", application)); Log.d(Config.LOGTAG, "successfully removed " + application + " from UnifiedPush"); } } @@ -149,4 +205,11 @@ public class UnifiedPushDistributor extends BroadcastReceiver { .hashString(Joiner.on('\0').join(components), Charsets.UTF_8) .asBytes()); } + + public static void quickLog(final Context context, final String message) { + final Intent intent = new Intent(context, XmppConnectionService.class); + intent.setAction(XmppConnectionService.ACTION_QUICK_LOG); + intent.putExtra("message", message); + context.startService(intent); + } } diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index d3722053f..584237156 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -19,6 +19,7 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; import android.database.ContentObserver; import android.graphics.Bitmap; import android.media.AudioManager; @@ -32,6 +33,7 @@ import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.os.IBinder; +import android.os.Messenger; import android.os.PowerManager; import android.os.PowerManager.WakeLock; import android.os.SystemClock; @@ -51,12 +53,14 @@ import androidx.annotation.IntegerRes; import androidx.annotation.NonNull; import androidx.core.app.RemoteInput; import androidx.core.content.ContextCompat; +import androidx.core.util.Consumer; import com.google.common.base.Objects; import com.google.common.base.Optional; import com.google.common.base.Strings; import org.conscrypt.Conscrypt; +import org.jxmpp.stringprep.libidn.LibIdnXmppStringprep; import org.openintents.openpgp.IOpenPgpService2; import org.openintents.openpgp.util.OpenPgpApi; import org.openintents.openpgp.util.OpenPgpServiceConnection; @@ -65,7 +69,6 @@ import java.io.File; import java.security.Security; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.text.ParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -82,6 +85,8 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -121,6 +126,7 @@ import eu.siacs.conversations.persistance.DatabaseBackend; import eu.siacs.conversations.persistance.FileBackend; import eu.siacs.conversations.persistance.UnifiedPushDatabase; import eu.siacs.conversations.ui.ChooseAccountForProfilePictureActivity; +import eu.siacs.conversations.ui.ConversationsActivity; import eu.siacs.conversations.ui.RtpSessionActivity; import eu.siacs.conversations.ui.SettingsActivity; import eu.siacs.conversations.ui.UiCallback; @@ -182,7 +188,11 @@ public class XmppConnectionService extends Service { public static final String ACTION_CLEAR_MISSED_CALL_NOTIFICATION = "clear_missed_call_notification"; public static final String ACTION_DISMISS_ERROR_NOTIFICATIONS = "dismiss_error"; public static final String ACTION_TRY_AGAIN = "try_again"; + + public static final String ACTION_TEMPORARILY_DISABLE = "temporarily_disable"; + public static final String ACTION_PING = "ping"; public static final String ACTION_IDLE_PING = "idle_ping"; + public static final String ACTION_INTERNAL_PING = "internal_ping"; public static final String ACTION_FCM_TOKEN_REFRESH = "fcm_token_refresh"; public static final String ACTION_FCM_MESSAGE_RECEIVED = "fcm_message_received"; public static final String ACTION_DISMISS_CALL = "dismiss_call"; @@ -190,12 +200,15 @@ public class XmppConnectionService extends Service { public static final String ACTION_PROVISION_ACCOUNT = "provision_account"; private static final String ACTION_POST_CONNECTIVITY_CHANGE = "eu.siacs.conversations.POST_CONNECTIVITY_CHANGE"; public static final String ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS = "eu.siacs.conversations.UNIFIED_PUSH_RENEW"; + public static final String ACTION_QUICK_LOG = "eu.siacs.conversations.QUICK_LOG"; private static final String SETTING_LAST_ACTIVITY_TS = "last_activity_timestamp"; public final CountDownLatch restoredFromDatabaseLatch = new CountDownLatch(1); private final static Executor FILE_OBSERVER_EXECUTOR = Executors.newSingleThreadExecutor(); private final static Executor FILE_ATTACHMENT_EXECUTOR = Executors.newSingleThreadExecutor(); + + private final ScheduledExecutorService internalPingExecutor = Executors.newSingleThreadScheduledExecutor(); private final static SerialSingleThreadExecutor VIDEO_COMPRESSION_EXECUTOR = new SerialSingleThreadExecutor("VideoCompression"); private final SerialSingleThreadExecutor mDatabaseWriterExecutor = new SerialSingleThreadExecutor("DatabaseWriter"); private final SerialSingleThreadExecutor mDatabaseReaderExecutor = new SerialSingleThreadExecutor("DatabaseReader"); @@ -450,9 +463,9 @@ public class XmppConnectionService extends Service { joinMuc(conversation); } scheduleWakeUpCall(Config.PING_MAX_INTERVAL, account.getUuid().hashCode()); - } else if (account.getStatus() == Account.State.OFFLINE || account.getStatus() == Account.State.DISABLED) { + } else if (account.getStatus() == Account.State.OFFLINE || account.getStatus() == Account.State.DISABLED || account.getStatus() == Account.State.LOGGED_OUT) { resetSendingToWaiting(account); - if (account.isEnabled() && isInLowPingTimeoutMode(account)) { + if (account.isConnectionEnabled() && isInLowPingTimeoutMode(account)) { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": went into offline state during low ping mode. reconnecting now"); reconnectAccount(account, true, false); } else { @@ -465,15 +478,24 @@ public class XmppConnectionService extends Service { } else if (account.getStatus() != Account.State.CONNECTING && account.getStatus() != Account.State.NO_INTERNET) { resetSendingToWaiting(account); if (connection != null && account.getStatus().isAttemptReconnect()) { - final int next = connection.getTimeToNextAttempt(); + final boolean aggressive = account.getStatus() == Account.State.SEE_OTHER_HOST + || hasJingleRtpConnection(account); + final int next = connection.getTimeToNextAttempt(aggressive); final boolean lowPingTimeoutMode = isInLowPingTimeoutMode(account); if (next <= 0) { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": error connecting account. reconnecting now. lowPingTimeout=" + lowPingTimeoutMode); reconnectAccount(account, true, false); } else { final int attempt = connection.getAttempt() + 1; - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": error connecting account. try again in " + next + "s for the " + attempt + " time. lowPingTimeout=" + lowPingTimeoutMode); + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": error connecting account. try again in " + next + "s for the " + attempt + " time. lowPingTimeout=" + lowPingTimeoutMode+", aggressive="+aggressive); scheduleWakeUpCall(next, account.getUuid().hashCode()); + if (aggressive) { + internalPingExecutor.schedule( + XmppConnectionService.this::manageAccountConnectionStatesInternal, + (next * 1000L) + 50, + TimeUnit.MILLISECONDS + ); + } } } } @@ -485,6 +507,7 @@ public class XmppConnectionService extends Service { private WakeLock wakeLock; private LruCache mBitmapCache; private final BroadcastReceiver mInternalEventReceiver = new InternalEventReceiver(); + private final BroadcastReceiver mInternalRestrictedEventReceiver = new RestrictedEventReceiver(Arrays.asList(TorServiceUtils.ACTION_STATUS)); private final BroadcastReceiver mInternalScreenEventReceiver = new InternalEventReceiver(); private static String generateFetchKey(Account account, final Avatar avatar) { @@ -647,223 +670,293 @@ public class XmppConnectionService extends Service { } @Override - public int onStartCommand(Intent intent, int flags, int startId) { - final String action = intent == null ? null : intent.getAction(); + public int onStartCommand(final Intent intent, int flags, int startId) { + final String action = Strings.nullToEmpty(intent == null ? null : intent.getAction()); final boolean needsForegroundService = intent != null && intent.getBooleanExtra(EventReceiver.EXTRA_NEEDS_FOREGROUND_SERVICE, false); if (needsForegroundService) { Log.d(Config.LOGTAG, "toggle forced foreground service after receiving event (action=" + action + ")"); toggleForegroundService(true); } - String pushedAccountHash = null; - boolean interactive = false; - if (action != null) { - final String uuid = intent.getStringExtra("uuid"); - switch (action) { - case QuickConversationsService.SMS_RETRIEVED_ACTION: - mQuickConversationsService.handleSmsReceived(intent); - break; - case ConnectivityManager.CONNECTIVITY_ACTION: - if (hasInternetConnection()) { - if (Config.POST_CONNECTIVITY_CHANGE_PING_INTERVAL > 0) { - schedulePostConnectivityChange(); - } - if (Config.RESET_ATTEMPT_COUNT_ON_NETWORK_CHANGE) { - resetAllAttemptCounts(true, false); - } - Resolver.clearCache(); + final String uuid = intent == null ? null : intent.getStringExtra("uuid"); + switch (action) { + case QuickConversationsService.SMS_RETRIEVED_ACTION: + mQuickConversationsService.handleSmsReceived(intent); + break; + case ConnectivityManager.CONNECTIVITY_ACTION: + if (hasInternetConnection()) { + if (Config.POST_CONNECTIVITY_CHANGE_PING_INTERVAL > 0) { + schedulePostConnectivityChange(); } - break; - case Intent.ACTION_SHUTDOWN: - logoutAndSave(true); - return START_NOT_STICKY; - case ACTION_CLEAR_MESSAGE_NOTIFICATION: - mNotificationExecutor.execute(() -> { - try { - final Conversation c = findConversationByUuid(uuid); - if (c != null) { - mNotificationService.clearMessages(c); - } else { - mNotificationService.clearMessages(); - } - restoredFromDatabaseLatch.await(); - - } catch (InterruptedException e) { - Log.d(Config.LOGTAG, "unable to process clear message notification"); - } - }); - break; - case ACTION_CLEAR_MISSED_CALL_NOTIFICATION: - mNotificationExecutor.execute(() -> { - try { - final Conversation c = findConversationByUuid(uuid); - if (c != null) { - mNotificationService.clearMissedCalls(c); - } else { - mNotificationService.clearMissedCalls(); - } - restoredFromDatabaseLatch.await(); - - } catch (InterruptedException e) { - Log.d(Config.LOGTAG, "unable to process clear missed call notification"); - } - }); - break; - case ACTION_DISMISS_CALL: { - final String sessionId = intent.getStringExtra(RtpSessionActivity.EXTRA_SESSION_ID); - Log.d(Config.LOGTAG, "received intent to dismiss call with session id " + sessionId); - mJingleConnectionManager.rejectRtpSession(sessionId); - break; - } - case TorServiceUtils.ACTION_STATUS: - final String status = intent.getStringExtra(TorServiceUtils.EXTRA_STATUS); - //TODO port and host are in 'extras' - but this may not be a reliable source? - if ("ON".equals(status)) { - handleOrbotStartedEvent(); - return START_STICKY; + if (Config.RESET_ATTEMPT_COUNT_ON_NETWORK_CHANGE) { + resetAllAttemptCounts(true, false); } - break; - case ACTION_END_CALL: { - final String sessionId = intent.getStringExtra(RtpSessionActivity.EXTRA_SESSION_ID); - Log.d(Config.LOGTAG, "received intent to end call with session id " + sessionId); - mJingleConnectionManager.endRtpSession(sessionId); + Resolver.clearCache(); } break; - case ACTION_PROVISION_ACCOUNT: { - final String address = intent.getStringExtra("address"); - final String password = intent.getStringExtra("password"); - if (QuickConversationsService.isQuicksy() || Strings.isNullOrEmpty(address) || Strings.isNullOrEmpty(password)) { - break; - } - provisionAccount(address, password); - break; - } - case ACTION_DISMISS_ERROR_NOTIFICATIONS: - dismissErrorNotifications(); - break; - case ACTION_TRY_AGAIN: - resetAllAttemptCounts(false, true); - interactive = true; - break; - case ACTION_REPLY_TO_CONVERSATION: - Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); - if (remoteInput == null) { - break; - } - final CharSequence body = remoteInput.getCharSequence("text_reply"); - final boolean dismissNotification = intent.getBooleanExtra("dismiss_notification", false); - final String lastMessageUuid = intent.getStringExtra("last_message_uuid"); - if (body == null || body.length() <= 0) { - break; - } - mNotificationExecutor.execute(() -> { - try { - restoredFromDatabaseLatch.await(); - final Conversation c = findConversationByUuid(uuid); - if (c != null) { - directReply(c, body.toString(), lastMessageUuid, dismissNotification); - } - } catch (InterruptedException e) { - Log.d(Config.LOGTAG, "unable to process direct reply"); - } - }); - break; - case ACTION_MARK_AS_READ: - mNotificationExecutor.execute(() -> { + case Intent.ACTION_SHUTDOWN: + logoutAndSave(true); + return START_NOT_STICKY; + case ACTION_CLEAR_MESSAGE_NOTIFICATION: + mNotificationExecutor.execute(() -> { + try { final Conversation c = findConversationByUuid(uuid); - if (c == null) { - Log.d(Config.LOGTAG, "received mark read intent for unknown conversation (" + uuid + ")"); - return; - } - try { - restoredFromDatabaseLatch.await(); - sendReadMarker(c, null); - } catch (InterruptedException e) { - Log.d(Config.LOGTAG, "unable to process notification read marker for conversation " + c.getName()); + if (c != null) { + mNotificationService.clearMessages(c); + } else { + mNotificationService.clearMessages(); } + restoredFromDatabaseLatch.await(); - }); - break; - case ACTION_SNOOZE: - mNotificationExecutor.execute(() -> { + } catch (InterruptedException e) { + Log.d(Config.LOGTAG, "unable to process clear message notification"); + } + }); + break; + case ACTION_CLEAR_MISSED_CALL_NOTIFICATION: + mNotificationExecutor.execute(() -> { + try { final Conversation c = findConversationByUuid(uuid); - if (c == null) { - Log.d(Config.LOGTAG, "received snooze intent for unknown conversation (" + uuid + ")"); - return; + if (c != null) { + mNotificationService.clearMissedCalls(c); + } else { + mNotificationService.clearMissedCalls(); } - c.setMutedTill(System.currentTimeMillis() + 30 * 60 * 1000); - mNotificationService.clearMessages(c); - updateConversation(c); - }); - case AudioManager.RINGER_MODE_CHANGED_ACTION: - case NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED: - if (dndOnSilentMode()) { - refreshAllPresences(); + restoredFromDatabaseLatch.await(); + + } catch (InterruptedException e) { + Log.d(Config.LOGTAG, "unable to process clear missed call notification"); } + }); + break; + case ACTION_DISMISS_CALL: { + if (intent == null) { break; - case Intent.ACTION_SCREEN_ON: - deactivateGracePeriod(); - case Intent.ACTION_USER_PRESENT: - case Intent.ACTION_SCREEN_OFF: - if (awayWhenScreenLocked()) { - refreshAllPresences(); - } - break; - case ACTION_FCM_TOKEN_REFRESH: - refreshAllFcmTokens(); - break; - case ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS: - final String instance = intent.getStringExtra("instance"); - final Optional transport = renewUnifiedPushEndpoints(); - if (instance != null && transport.isPresent()) { - unifiedPushBroker.rebroadcastEndpoint(instance, transport.get()); - } - break; - case ACTION_IDLE_PING: - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - scheduleNextIdlePing(); - } - break; - case ACTION_FCM_MESSAGE_RECEIVED: - pushedAccountHash = intent.getStringExtra("account"); - Log.d(Config.LOGTAG, "push message arrived in service. account=" + pushedAccountHash); - break; - case Intent.ACTION_SEND: - Uri uri = intent.getData(); - if (uri != null) { - Log.d(Config.LOGTAG, "received uri permission for " + uri); - } - return START_STICKY; - } - } - synchronized (this) { - WakeLockHelper.acquire(wakeLock); - boolean pingNow = ConnectivityManager.CONNECTIVITY_ACTION.equals(action) || (Config.POST_CONNECTIVITY_CHANGE_PING_INTERVAL > 0 && ACTION_POST_CONNECTIVITY_CHANGE.equals(action)); - final HashSet pingCandidates = new HashSet<>(); - final String androidId = PhoneHelper.getAndroidId(this); - for (Account account : accounts) { - final boolean pushWasMeantForThisAccount = CryptoHelper.getAccountFingerprint(account, androidId).equals(pushedAccountHash); - pingNow |= processAccountState(account, - interactive, - "ui".equals(action), - pushWasMeantForThisAccount, - pingCandidates); - } - if (pingNow) { - for (Account account : pingCandidates) { - final boolean lowTimeout = isInLowPingTimeoutMode(account); - account.getXmppConnection().sendPing(); - Log.d(Config.LOGTAG, account.getJid().asBareJid() + " send ping (action=" + action + ",lowTimeout=" + lowTimeout + ")"); - scheduleWakeUpCall(lowTimeout ? Config.LOW_PING_TIMEOUT : Config.PING_TIMEOUT, account.getUuid().hashCode()); } + final String sessionId = intent.getStringExtra(RtpSessionActivity.EXTRA_SESSION_ID); + Log.d(Config.LOGTAG, "received intent to dismiss call with session id " + sessionId); + mJingleConnectionManager.rejectRtpSession(sessionId); + break; } - WakeLockHelper.release(wakeLock); + case TorServiceUtils.ACTION_STATUS: + final String status = intent == null ? null : intent.getStringExtra(TorServiceUtils.EXTRA_STATUS); + //TODO port and host are in 'extras' - but this may not be a reliable source? + if ("ON".equals(status)) { + handleOrbotStartedEvent(); + return START_STICKY; + } + break; + case ACTION_END_CALL: { + if (intent == null) { + break; + } + final String sessionId = intent.getStringExtra(RtpSessionActivity.EXTRA_SESSION_ID); + Log.d(Config.LOGTAG, "received intent to end call with session id " + sessionId); + mJingleConnectionManager.endRtpSession(sessionId); + } + break; + case ACTION_PROVISION_ACCOUNT: { + if (intent == null) { + break; + } + final String address = intent.getStringExtra("address"); + final String password = intent.getStringExtra("password"); + if (QuickConversationsService.isQuicksy() || Strings.isNullOrEmpty(address) || Strings.isNullOrEmpty(password)) { + break; + } + provisionAccount(address, password); + break; + } + case ACTION_DISMISS_ERROR_NOTIFICATIONS: + dismissErrorNotifications(); + break; + case ACTION_TRY_AGAIN: + resetAllAttemptCounts(false, true); + break; + case ACTION_REPLY_TO_CONVERSATION: + final Bundle remoteInput = intent == null ? null : RemoteInput.getResultsFromIntent(intent); + if (remoteInput == null) { + break; + } + final CharSequence body = remoteInput.getCharSequence("text_reply"); + final boolean dismissNotification = intent.getBooleanExtra("dismiss_notification", false); + final String lastMessageUuid = intent.getStringExtra("last_message_uuid"); + if (body == null || body.length() <= 0) { + break; + } + mNotificationExecutor.execute(() -> { + try { + restoredFromDatabaseLatch.await(); + final Conversation c = findConversationByUuid(uuid); + if (c != null) { + directReply(c, body.toString(), lastMessageUuid, dismissNotification); + } + } catch (InterruptedException e) { + Log.d(Config.LOGTAG, "unable to process direct reply"); + } + }); + break; + case ACTION_MARK_AS_READ: + mNotificationExecutor.execute(() -> { + final Conversation c = findConversationByUuid(uuid); + if (c == null) { + Log.d(Config.LOGTAG, "received mark read intent for unknown conversation (" + uuid + ")"); + return; + } + try { + restoredFromDatabaseLatch.await(); + sendReadMarker(c, null); + } catch (InterruptedException e) { + Log.d(Config.LOGTAG, "unable to process notification read marker for conversation " + c.getName()); + } + + }); + break; + case ACTION_SNOOZE: + mNotificationExecutor.execute(() -> { + final Conversation c = findConversationByUuid(uuid); + if (c == null) { + Log.d(Config.LOGTAG, "received snooze intent for unknown conversation (" + uuid + ")"); + return; + } + c.setMutedTill(System.currentTimeMillis() + 30 * 60 * 1000); + mNotificationService.clearMessages(c); + updateConversation(c); + }); + case AudioManager.RINGER_MODE_CHANGED_ACTION: + case NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED: + if (dndOnSilentMode()) { + refreshAllPresences(); + } + break; + case Intent.ACTION_SCREEN_ON: + deactivateGracePeriod(); + case Intent.ACTION_USER_PRESENT: + case Intent.ACTION_SCREEN_OFF: + if (awayWhenScreenLocked()) { + refreshAllPresences(); + } + break; + case ACTION_FCM_TOKEN_REFRESH: + refreshAllFcmTokens(); + break; + case ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS: + if (intent == null) { + break; + } + final String instance = intent.getStringExtra("instance"); + final String application = intent.getStringExtra("application"); + final Messenger messenger = intent.getParcelableExtra("messenger"); + final UnifiedPushBroker.PushTargetMessenger pushTargetMessenger; + if (messenger != null && application != null && instance != null) { + pushTargetMessenger = new UnifiedPushBroker.PushTargetMessenger(new UnifiedPushDatabase.PushTarget(application, instance),messenger); + Log.d(Config.LOGTAG,"found push target messenger"); + } else { + pushTargetMessenger = null; + } + final Optional transport = renewUnifiedPushEndpoints(pushTargetMessenger); + if (instance != null && transport.isPresent()) { + unifiedPushBroker.rebroadcastEndpoint(messenger, instance, transport.get()); + } + break; + case ACTION_IDLE_PING: + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + scheduleNextIdlePing(); + } + break; + case ACTION_FCM_MESSAGE_RECEIVED: + Log.d(Config.LOGTAG, "push message arrived in service. account"); + break; + case ACTION_QUICK_LOG: + final String message = intent == null ? null : intent.getStringExtra("message"); + if (message != null && Config.QUICK_LOG) { + quickLog(message); + } + break; + case Intent.ACTION_SEND: + final Uri uri = intent == null ? null : intent.getData(); + if (uri != null) { + Log.d(Config.LOGTAG, "received uri permission for " + uri); + } + return START_STICKY; + case ACTION_TEMPORARILY_DISABLE: + toggleSoftDisabled(true); + if (checkListeners()) { + stopSelf(); + } + return START_NOT_STICKY; } + manageAccountConnectionStates(action, intent == null ? null : intent.getExtras()); if (SystemClock.elapsedRealtime() - mLastExpiryRun.get() >= Config.EXPIRY_INTERVAL) { expireOldMessages(); } return START_STICKY; } + private void quickLog(final String message) { + if (Strings.isNullOrEmpty(message)) { + return; + } + final Account account = AccountUtils.getFirstEnabled(this); + if (account == null) { + return; + } + final Conversation conversation = + findOrCreateConversation(account, Config.BUG_REPORTS, false, true); + final Message report = new Message(conversation, message, Message.ENCRYPTION_NONE); + report.setStatus(Message.STATUS_RECEIVED); + conversation.add(report); + databaseBackend.createMessage(report); + updateConversationUi(); + } + + private void manageAccountConnectionStatesInternal() { + manageAccountConnectionStates(ACTION_INTERNAL_PING, null); + } + + private synchronized void manageAccountConnectionStates( + final String action, final Bundle extras) { + final String pushedAccountHash = extras == null ? null : extras.getString("account"); + final boolean interactive = java.util.Objects.equals(ACTION_TRY_AGAIN, action); + WakeLockHelper.acquire(wakeLock); + boolean pingNow = + ConnectivityManager.CONNECTIVITY_ACTION.equals(action) + || (Config.POST_CONNECTIVITY_CHANGE_PING_INTERVAL > 0 + && ACTION_POST_CONNECTIVITY_CHANGE.equals(action)); + final HashSet pingCandidates = new HashSet<>(); + final String androidId = pushedAccountHash == null ? null : PhoneHelper.getAndroidId(this); + for (final Account account : accounts) { + final boolean pushWasMeantForThisAccount = + androidId != null + && CryptoHelper.getAccountFingerprint(account, androidId) + .equals(pushedAccountHash); + pingNow |= + processAccountState( + account, + interactive, + "ui".equals(action), + pushWasMeantForThisAccount, + pingCandidates); + } + if (pingNow) { + for (final Account account : pingCandidates) { + final boolean lowTimeout = isInLowPingTimeoutMode(account); + account.getXmppConnection().sendPing(); + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + " send ping (action=" + + action + + ",lowTimeout=" + + lowTimeout + + ")"); + scheduleWakeUpCall( + lowTimeout ? Config.LOW_PING_TIMEOUT : Config.PING_TIMEOUT, + account.getUuid().hashCode()); + } + } + WakeLockHelper.release(wakeLock); + } + private void handleOrbotStartedEvent() { for (final Account account : accounts) { if (account.getStatus() == Account.State.TOR_NOT_AVAILABLE) { @@ -873,78 +966,85 @@ public class XmppConnectionService extends Service { } private boolean processAccountState(Account account, boolean interactive, boolean isUiAction, boolean isAccountPushed, HashSet pingCandidates) { - boolean pingNow = false; - if (account.getStatus().isAttemptReconnect()) { - if (!hasInternetConnection()) { - account.setStatus(Account.State.NO_INTERNET); - if (statusListener != null) { - statusListener.onStatusChanged(account); - } - } else { - if (account.getStatus() == Account.State.NO_INTERNET) { - account.setStatus(Account.State.OFFLINE); - if (statusListener != null) { - statusListener.onStatusChanged(account); - } - } - if (account.getStatus() == Account.State.ONLINE) { - synchronized (mLowPingTimeoutMode) { - long lastReceived = account.getXmppConnection().getLastPacketReceived(); - long lastSent = account.getXmppConnection().getLastPingSent(); - long pingInterval = isUiAction ? Config.PING_MIN_INTERVAL * 1000 : Config.PING_MAX_INTERVAL * 1000; - long msToNextPing = (Math.max(lastReceived, lastSent) + pingInterval) - SystemClock.elapsedRealtime(); - int pingTimeout = mLowPingTimeoutMode.contains(account.getJid().asBareJid()) ? Config.LOW_PING_TIMEOUT * 1000 : Config.PING_TIMEOUT * 1000; - long pingTimeoutIn = (lastSent + pingTimeout) - SystemClock.elapsedRealtime(); - if (lastSent > lastReceived) { - if (pingTimeoutIn < 0) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ping timeout"); - this.reconnectAccount(account, true, interactive); - } else { - int secs = (int) (pingTimeoutIn / 1000); - this.scheduleWakeUpCall(secs, account.getUuid().hashCode()); - } + if (!account.getStatus().isAttemptReconnect()) { + return false; + } + if (!hasInternetConnection()) { + account.setStatus(Account.State.NO_INTERNET); + statusListener.onStatusChanged(account); + } else { + if (account.getStatus() == Account.State.NO_INTERNET) { + account.setStatus(Account.State.OFFLINE); + statusListener.onStatusChanged(account); + } + if (account.getStatus() == Account.State.ONLINE) { + synchronized (mLowPingTimeoutMode) { + long lastReceived = account.getXmppConnection().getLastPacketReceived(); + long lastSent = account.getXmppConnection().getLastPingSent(); + long pingInterval = isUiAction ? Config.PING_MIN_INTERVAL * 1000 : Config.PING_MAX_INTERVAL * 1000; + long msToNextPing = (Math.max(lastReceived, lastSent) + pingInterval) - SystemClock.elapsedRealtime(); + int pingTimeout = mLowPingTimeoutMode.contains(account.getJid().asBareJid()) ? Config.LOW_PING_TIMEOUT * 1000 : Config.PING_TIMEOUT * 1000; + long pingTimeoutIn = (lastSent + pingTimeout) - SystemClock.elapsedRealtime(); + if (lastSent > lastReceived) { + if (pingTimeoutIn < 0) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ping timeout"); + this.reconnectAccount(account, true, interactive); } else { - pingCandidates.add(account); - if (isAccountPushed) { - pingNow = true; - if (mLowPingTimeoutMode.add(account.getJid().asBareJid())) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": entering low ping timeout mode"); - } - } else if (msToNextPing <= 0) { - pingNow = true; - } else { - this.scheduleWakeUpCall((int) (msToNextPing / 1000), account.getUuid().hashCode()); - if (mLowPingTimeoutMode.remove(account.getJid().asBareJid())) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": leaving low ping timeout mode"); - } + int secs = (int) (pingTimeoutIn / 1000); + this.scheduleWakeUpCall(secs, account.getUuid().hashCode()); + } + } else { + pingCandidates.add(account); + if (isAccountPushed) { + if (mLowPingTimeoutMode.add(account.getJid().asBareJid())) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": entering low ping timeout mode"); + } + return true; + } else if (msToNextPing <= 0) { + return true; + } else { + this.scheduleWakeUpCall((int) (msToNextPing / 1000), account.getUuid().hashCode()); + if (mLowPingTimeoutMode.remove(account.getJid().asBareJid())) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": leaving low ping timeout mode"); } } } - } else if (account.getStatus() == Account.State.OFFLINE) { + } + } else if (account.getStatus() == Account.State.OFFLINE) { + reconnectAccount(account, true, interactive); + } else if (account.getStatus() == Account.State.CONNECTING) { + long secondsSinceLastConnect = (SystemClock.elapsedRealtime() - account.getXmppConnection().getLastConnect()) / 1000; + long secondsSinceLastDisco = (SystemClock.elapsedRealtime() - account.getXmppConnection().getLastDiscoStarted()) / 1000; + long discoTimeout = Config.CONNECT_DISCO_TIMEOUT - secondsSinceLastDisco; + long timeout = Config.CONNECT_TIMEOUT - secondsSinceLastConnect; + if (timeout < 0) { + Log.d(Config.LOGTAG, account.getJid() + ": time out during connect reconnecting (secondsSinceLast=" + secondsSinceLastConnect + ")"); + account.getXmppConnection().resetAttemptCount(false); reconnectAccount(account, true, interactive); - } else if (account.getStatus() == Account.State.CONNECTING) { - long secondsSinceLastConnect = (SystemClock.elapsedRealtime() - account.getXmppConnection().getLastConnect()) / 1000; - long secondsSinceLastDisco = (SystemClock.elapsedRealtime() - account.getXmppConnection().getLastDiscoStarted()) / 1000; - long discoTimeout = Config.CONNECT_DISCO_TIMEOUT - secondsSinceLastDisco; - long timeout = Config.CONNECT_TIMEOUT - secondsSinceLastConnect; - if (timeout < 0) { - Log.d(Config.LOGTAG, account.getJid() + ": time out during connect reconnecting (secondsSinceLast=" + secondsSinceLastConnect + ")"); - account.getXmppConnection().resetAttemptCount(false); - reconnectAccount(account, true, interactive); - } else if (discoTimeout < 0) { - account.getXmppConnection().sendDiscoTimeout(); - scheduleWakeUpCall((int) Math.min(timeout, discoTimeout), account.getUuid().hashCode()); - } else { - scheduleWakeUpCall((int) Math.min(timeout, discoTimeout), account.getUuid().hashCode()); - } + } else if (discoTimeout < 0) { + account.getXmppConnection().sendDiscoTimeout(); + scheduleWakeUpCall((int) Math.min(timeout, discoTimeout), account.getUuid().hashCode()); } else { - if (account.getXmppConnection().getTimeToNextAttempt() <= 0) { - reconnectAccount(account, true, interactive); - } + scheduleWakeUpCall((int) Math.min(timeout, discoTimeout), account.getUuid().hashCode()); + } + } else { + final boolean aggressive = account.getStatus() == Account.State.SEE_OTHER_HOST || hasJingleRtpConnection(account); + if (account.getXmppConnection().getTimeToNextAttempt(aggressive) <= 0) { + reconnectAccount(account, true, interactive); + } + } + } + return false; + } + + private void toggleSoftDisabled(final boolean softDisabled) { + for(final Account account : this.accounts) { + if (account.isEnabled()) { + if (account.setOption(Account.OPTION_SOFT_DISABLED, softDisabled)) { + updateAccount(account); } } } - return pingNow; } public boolean processUnifiedPushMessage(final Account account, final Jid transport, final Element push) { @@ -1144,6 +1244,7 @@ public class XmppConnectionService extends Service { @SuppressLint("TrulyRandom") @Override public void onCreate() { + LibIdnXmppStringprep.setup(); if (Compatibility.runsTwentySix()) { mNotificationService.initializeChannels(); } @@ -1189,7 +1290,11 @@ public class XmppConnectionService extends Service { restoreFromDatabase(); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) { + if (QuickConversationsService.isContactListIntegration(this) + && (Build.VERSION.SDK_INT < Build.VERSION_CODES.M + || ContextCompat.checkSelfPermission( + this, Manifest.permission.READ_CONTACTS) + == PackageManager.PERMISSION_GRANTED)) { startContactObserver(); } FILE_OBSERVER_EXECUTOR.execute(fileBackend::deleteHistoricAvatarPath); @@ -1201,7 +1306,7 @@ public class XmppConnectionService extends Service { if (Config.supportOpenPgp()) { this.pgpServiceConnection = new OpenPgpServiceConnection(this, "org.sufficientlysecure.keychain", new OpenPgpServiceConnection.OnBound() { @Override - public void onBound(IOpenPgpService2 service) { + public void onBound(final IOpenPgpService2 service) { for (Account account : accounts) { final PgpDecryptionService pgp = account.getPgpDecryptionService(); if (pgp != null) { @@ -1211,7 +1316,8 @@ public class XmppConnectionService extends Service { } @Override - public void onError(Exception e) { + public void onError(final Exception exception) { + Log.e(Config.LOGTAG,"could not bind to OpenKeyChain", exception); } }); this.pgpServiceConnection.bindToService(); @@ -1223,19 +1329,30 @@ public class XmppConnectionService extends Service { toggleForegroundService(); updateUnreadCountBadge(); toggleScreenEventReceiver(); - final IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(TorServiceUtils.ACTION_STATUS); + final IntentFilter systemBroadcastFilter = new IntentFilter(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { scheduleNextIdlePing(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); + systemBroadcastFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); } - intentFilter.addAction(NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED); + systemBroadcastFilter.addAction(NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED); } - registerReceiver(this.mInternalEventReceiver, intentFilter); + ContextCompat.registerReceiver( + this, + this.mInternalEventReceiver, + systemBroadcastFilter, + ContextCompat.RECEIVER_NOT_EXPORTED); + final IntentFilter exportedBroadcastFilter = new IntentFilter(); + exportedBroadcastFilter.addAction(TorServiceUtils.ACTION_STATUS); + ContextCompat.registerReceiver( + this, + this.mInternalRestrictedEventReceiver, + exportedBroadcastFilter, + ContextCompat.RECEIVER_EXPORTED); mForceDuringOnCreate.set(false); toggleForegroundService(); setupPhoneStateListener(); + internalPingExecutor.scheduleAtFixedRate(this::manageAccountConnectionStatesInternal,10,10,TimeUnit.SECONDS); } @@ -1302,12 +1419,14 @@ public class XmppConnectionService extends Service { public void onDestroy() { try { unregisterReceiver(this.mInternalEventReceiver); + unregisterReceiver(this.mInternalRestrictedEventReceiver); unregisterReceiver(this.mInternalScreenEventReceiver); } catch (final IllegalArgumentException e) { //ignored } destroyed = false; fileObserver.stopWatching(); + internalPingExecutor.shutdown(); super.onDestroy(); } @@ -1347,7 +1466,7 @@ public class XmppConnectionService extends Service { toggleForegroundService(false); } - private void toggleForegroundService(boolean force) { + private void toggleForegroundService(final boolean force) { final boolean status; final OngoingCall ongoing = ongoingCall.get(); if (force || mForceDuringOnCreate.get() || mForceForegroundService.get() || ongoing != null || (Compatibility.keepForegroundService(this) && hasEnabledAccounts())) { @@ -1356,12 +1475,12 @@ public class XmppConnectionService extends Service { if (ongoing != null) { notification = this.mNotificationService.getOngoingCallNotification(ongoing); id = NotificationService.ONGOING_CALL_NOTIFICATION_ID; - startForeground(id, notification); + startForegroundOrCatch(id, notification, true); mNotificationService.cancel(NotificationService.FOREGROUND_NOTIFICATION_ID); } else { notification = this.mNotificationService.createForegroundNotification(); id = NotificationService.FOREGROUND_NOTIFICATION_ID; - startForeground(id, notification); + startForegroundOrCatch(id, notification, false); } if (!mForceForegroundService.get()) { @@ -1381,6 +1500,38 @@ public class XmppConnectionService extends Service { Log.d(Config.LOGTAG, "ForegroundService: " + (status ? "on" : "off")); } + private void startForegroundOrCatch( + final int id, final Notification notification, final boolean requireMicrophone) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + final int foregroundServiceType; + if (requireMicrophone + && ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED) { + foregroundServiceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE; + Log.d(Config.LOGTAG, "defaulting to microphone foreground service type"); + } else if (getSystemService(PowerManager.class) + .isIgnoringBatteryOptimizations(getPackageName())) { + foregroundServiceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED; + } else if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED) { + foregroundServiceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE; + } else if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED) { + foregroundServiceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA; + } else { + foregroundServiceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE; + Log.w(Config.LOGTAG, "falling back to special use foreground service type"); + } + startForeground(id, notification, foregroundServiceType); + } else { + startForeground(id, notification); + } + } catch (final IllegalStateException | SecurityException e) { + Log.e(Config.LOGTAG, "Could not start foreground service", e); + } + } + public boolean foregroundNotificationNeedsUpdatingWhenErrorStateChanges() { return !mForceForegroundService.get() && ongoingCall.get() == null && Compatibility.keepForegroundService(this) && hasEnabledAccounts(); } @@ -1398,7 +1549,7 @@ public class XmppConnectionService extends Service { private void logoutAndSave(boolean stop) { int activeAccounts = 0; for (final Account account : accounts) { - if (account.getStatus() != Account.State.DISABLED) { + if (account.isConnectionEnabled()) { databaseBackend.writeRoster(account.getRoster()); activeAccounts++; } @@ -1434,25 +1585,18 @@ public class XmppConnectionService extends Service { } } - public void scheduleWakeUpCall(int seconds, int requestCode) { + public void scheduleWakeUpCall(final int seconds, final int requestCode) { final long timeToWake = SystemClock.elapsedRealtime() + (seconds < 0 ? 1 : seconds + 1) * 1000L; final AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); if (alarmManager == null) { return; } final Intent intent = new Intent(this, EventReceiver.class); - intent.setAction("ping"); + intent.setAction(ACTION_PING); try { - final PendingIntent pendingIntent; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - pendingIntent = - PendingIntent.getBroadcast( - this, requestCode, intent, PendingIntent.FLAG_IMMUTABLE); - } else { - pendingIntent = - PendingIntent.getBroadcast( - this, requestCode, intent, 0); - } + final PendingIntent pendingIntent = + PendingIntent.getBroadcast( + this, requestCode, intent, PendingIntent.FLAG_IMMUTABLE); alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, timeToWake, pendingIntent); } catch (RuntimeException e) { Log.e(Config.LOGTAG, "unable to schedule alarm for ping", e); @@ -1846,6 +1990,7 @@ public class XmppConnectionService extends Service { if (connection == null) { Log.d(Config.LOGTAG, account.getJid().asBareJid()+": no connection. ignoring bookmark creation"); } else if (connection.getFeatures().bookmarks2()) { + Log.d(Config.LOGTAG,account.getJid().asBareJid() + ": pushing bookmark via Bookmarks 2"); final Element item = mIqGenerator.publishBookmarkItem(bookmark); pushNodeAndEnforcePublishOptions(account, Namespace.BOOKMARKS2, item, bookmark.getJid().asBareJid().toEscapedString(), PublishOptions.persistentWhitelistAccessMaxItems()); } else if (connection.getFeatures().bookmarksConversion()) { @@ -1859,7 +2004,8 @@ public class XmppConnectionService extends Service { account.removeBookmark(bookmark); final XmppConnection connection = account.getXmppConnection(); if (connection.getFeatures().bookmarks2()) { - IqPacket request = mIqGenerator.deleteItem(Namespace.BOOKMARKS2, bookmark.getJid().asBareJid().toEscapedString()); + final IqPacket request = mIqGenerator.deleteItem(Namespace.BOOKMARKS2, bookmark.getJid().asBareJid().toEscapedString()); + Log.d(Config.LOGTAG,account.getJid().asBareJid() + ": removing bookmark via Bookmarks 2"); sendIqPacket(account, request, (a, response) -> { if (response.getType() == IqPacket.TYPE.ERROR) { Log.d(Config.LOGTAG, a.getJid().asBareJid() + ": unable to delete bookmark " + response.getErrorCondition()); @@ -2361,8 +2507,12 @@ public class XmppConnectionService extends Service { return this.unifiedPushBroker.reconfigurePushDistributor(); } + private Optional renewUnifiedPushEndpoints(final UnifiedPushBroker.PushTargetMessenger pushTargetMessenger) { + return this.unifiedPushBroker.renewUnifiedPushEndpoints(pushTargetMessenger); + } + public Optional renewUnifiedPushEndpoints() { - return this.unifiedPushBroker.renewUnifiedPushEndpoints(); + return this.unifiedPushBroker.renewUnifiedPushEndpoints(null); } private void provisionAccount(final String address, final String password) { @@ -2473,6 +2623,20 @@ public class XmppConnectionService extends Service { }); } + public void unregisterAccount(final Account account, final Consumer callback) { + final IqPacket iqPacket = new IqPacket(IqPacket.TYPE.SET); + final Element query = iqPacket.addChild("query",Namespace.REGISTER); + query.addChild("remove"); + sendIqPacket(account, iqPacket, (a, response) -> { + if (response.getType() == IqPacket.TYPE.RESULT) { + deleteAccount(a); + callback.accept(true); + } else { + callback.accept(false); + } + }); + } + public void deleteAccount(final Account account) { final boolean connected = account.getStatus() == Account.State.ONLINE; synchronized (this.conversations) { @@ -2739,6 +2903,7 @@ public class XmppConnectionService extends Service { } private void switchToForeground() { + toggleSoftDisabled(false); final boolean broadcastLastActivity = broadcastLastActivity(); for (Conversation conversation : getConversations()) { if (conversation.getMode() == Conversation.MODE_MULTI) { @@ -3119,8 +3284,8 @@ public class XmppConnectionService extends Service { if (this.accounts == null) { return false; } - for (Account account : this.accounts) { - if (account.isEnabled()) { + for (final Account account : this.accounts) { + if (account.isConnectionEnabled()) { return true; } } @@ -3518,23 +3683,23 @@ public class XmppConnectionService extends Service { }); } - private void disconnect(Account account, boolean force) { - if ((account.getStatus() == Account.State.ONLINE) - || (account.getStatus() == Account.State.DISABLED)) { - final XmppConnection connection = account.getXmppConnection(); - if (!force) { - List conversations = getConversations(); - for (Conversation conversation : conversations) { - if (conversation.getAccount() == account) { - if (conversation.getMode() == Conversation.MODE_MULTI) { - leaveMuc(conversation, true); - } + private void disconnect(final Account account, boolean force) { + final XmppConnection connection = account.getXmppConnection(); + if (connection == null) { + return; + } + if (!force) { + final List conversations = getConversations(); + for (Conversation conversation : conversations) { + if (conversation.getAccount() == account) { + if (conversation.getMode() == Conversation.MODE_MULTI) { + leaveMuc(conversation, true); } } - sendOfflinePresence(account); } - connection.disconnect(force); + sendOfflinePresence(account); } + connection.disconnect(force); } @Override @@ -4018,13 +4183,18 @@ public class XmppConnectionService extends Service { private void reconnectAccount(final Account account, final boolean force, final boolean interactive) { synchronized (account) { - XmppConnection connection = account.getXmppConnection(); - if (connection == null) { + final XmppConnection existingConnection = account.getXmppConnection(); + final XmppConnection connection; + if (existingConnection != null) { + connection = existingConnection; + } else if (account.isConnectionEnabled()) { connection = createConnection(account); account.setXmppConnection(connection); + } else { + return; } - boolean hasInternet = hasInternetConnection(); - if (account.isEnabled() && hasInternet) { + final boolean hasInternet = hasInternetConnection(); + if (account.isConnectionEnabled() && hasInternet) { if (!force) { disconnect(account, false); } @@ -4512,7 +4682,7 @@ public class XmppConnectionService extends Service { public void refreshAllPresences() { boolean includeIdleTimestamp = checkListeners() && broadcastLastActivity(); for (Account account : getAccounts()) { - if (account.isEnabled()) { + if (account.isConnectionEnabled()) { sendPresence(account, includeIdleTimestamp); } } @@ -4553,6 +4723,10 @@ public class XmppConnectionService extends Service { return this.mJingleConnectionManager; } + private boolean hasJingleRtpConnection(final Account account) { + return this.mJingleConnectionManager.hasJingleRtpConnection(account); + } + public MessageArchiveService getMessageArchiveService() { return this.mMessageArchiveService; } @@ -4635,10 +4809,10 @@ public class XmppConnectionService extends Service { mDatabaseWriterExecutor.execute(runnable); } - public boolean sendBlockRequest(final Blockable blockable, boolean reportSpam) { + public boolean sendBlockRequest(final Blockable blockable, final boolean reportSpam, final String serverMsgId) { if (blockable != null && blockable.getBlockedJid() != null) { final Jid jid = blockable.getBlockedJid(); - this.sendIqPacket(blockable.getAccount(), getIqGenerator().generateSetBlockRequest(jid, reportSpam), (a, response) -> { + this.sendIqPacket(blockable.getAccount(), getIqGenerator().generateSetBlockRequest(jid, reportSpam, serverMsgId), (a, response) -> { if (response.getType() == IqPacket.TYPE.RESULT) { a.getBlocklist().add(jid); updateBlocklistUi(OnUpdateBlocklist.Status.BLOCKED); @@ -4984,11 +5158,30 @@ public class XmppConnectionService extends Service { private class InternalEventReceiver extends BroadcastReceiver { @Override - public void onReceive(Context context, Intent intent) { + public void onReceive(final Context context, final Intent intent) { onStartCommand(intent, 0, 0); } } + private class RestrictedEventReceiver extends BroadcastReceiver { + + private final Collection allowedActions; + + private RestrictedEventReceiver(final Collection allowedActions) { + this.allowedActions = allowedActions; + } + + @Override + public void onReceive(final Context context, final Intent intent) { + final String action = intent == null ? null : intent.getAction(); + if (allowedActions.contains(action)) { + onStartCommand(intent,0,0); + } else { + Log.e(Config.LOGTAG,"restricting broadcast of event "+action); + } + } + } + public static class OngoingCall { public final AbstractJingleConnection.Id id; public final Set media; @@ -5013,4 +5206,18 @@ public class XmppConnectionService extends Service { return Objects.hashCode(id, media, reconnecting); } } + + public static void toggleForegroundService(final XmppConnectionService service) { + if (service == null) { + return; + } + service.toggleForegroundService(); + } + + public static void toggleForegroundService(final ConversationsActivity activity) { + if (activity == null) { + return; + } + toggleForegroundService(activity.xmppConnectionService); + } } diff --git a/src/main/java/eu/siacs/conversations/ui/BlockContactDialog.java b/src/main/java/eu/siacs/conversations/ui/BlockContactDialog.java index 6f4d77c51..986aeb563 100644 --- a/src/main/java/eu/siacs/conversations/ui/BlockContactDialog.java +++ b/src/main/java/eu/siacs/conversations/ui/BlockContactDialog.java @@ -14,13 +14,27 @@ import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.ui.util.JidDialog; public final class BlockContactDialog { + public static void show(final XmppActivity xmppActivity, final Blockable blockable) { + show(xmppActivity, blockable, null); + } + public static void show(final XmppActivity xmppActivity, final Blockable blockable, final String serverMsgId) { final AlertDialog.Builder builder = new AlertDialog.Builder(xmppActivity); final boolean isBlocked = blockable.isBlocked(); builder.setNegativeButton(R.string.cancel, null); DialogBlockContactBinding binding = DataBindingUtil.inflate(xmppActivity.getLayoutInflater(), R.layout.dialog_block_contact, null, false); final boolean reporting = blockable.getAccount().getXmppConnection().getFeatures().spamReporting(); - binding.reportSpam.setVisibility(!isBlocked && reporting ? View.VISIBLE : View.GONE); + if (reporting && !isBlocked) { + binding.reportSpam.setVisibility(View.VISIBLE); + if (serverMsgId != null) { + binding.reportSpam.setChecked(true); + binding.reportSpam.setEnabled(false); + } else { + binding.reportSpam.setEnabled(true); + } + } else { + binding.reportSpam.setVisibility(View.GONE); + } builder.setView(binding.getRoot()); final String value; @@ -34,8 +48,18 @@ public final class BlockContactDialog { value =blockable.getJid().getDomain().toEscapedString(); res = isBlocked ? R.string.unblock_domain_text : R.string.block_domain_text; } else { - int resBlockAction = blockable instanceof Conversation && ((Conversation) blockable).isWithStranger() ? R.string.block_stranger : R.string.action_block_contact; - builder.setTitle(isBlocked ? R.string.action_unblock_contact : resBlockAction); + if (isBlocked) { + builder.setTitle(R.string.action_unblock_contact); + } else if (serverMsgId != null) { + builder.setTitle(R.string.report_spam_and_block); + } else { + final int resBlockAction = + blockable instanceof Conversation + && ((Conversation) blockable).isWithStranger() + ? R.string.block_stranger + : R.string.action_block_contact; + builder.setTitle(resBlockAction); + } value = blockable.getJid().asBareJid().toEscapedString(); res = isBlocked ? R.string.unblock_contact_text : R.string.block_contact_text; } @@ -45,7 +69,7 @@ public final class BlockContactDialog { xmppActivity.xmppConnectionService.sendUnblockRequest(blockable); } else { boolean toastShown = false; - if (xmppActivity.xmppConnectionService.sendBlockRequest(blockable, binding.reportSpam.isChecked())) { + if (xmppActivity.xmppConnectionService.sendBlockRequest(blockable, binding.reportSpam.isChecked(), serverMsgId)) { Toast.makeText(xmppActivity, R.string.corresponding_conversations_closed, Toast.LENGTH_SHORT).show(); toastShown = true; } diff --git a/src/main/java/eu/siacs/conversations/ui/BlocklistActivity.java b/src/main/java/eu/siacs/conversations/ui/BlocklistActivity.java index 55cf9ba03..17a9853b2 100644 --- a/src/main/java/eu/siacs/conversations/ui/BlocklistActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/BlocklistActivity.java @@ -87,7 +87,7 @@ public class BlocklistActivity extends AbstractSearchableListItemActivity implem dialog.setOnEnterJidDialogPositiveListener((accountJid, contactJid) -> { Blockable blockable = new RawBlockable(account, contactJid); - if (xmppConnectionService.sendBlockRequest(blockable, false)) { + if (xmppConnectionService.sendBlockRequest(blockable, false, null)) { Toast.makeText(BlocklistActivity.this, R.string.corresponding_conversations_closed, Toast.LENGTH_SHORT).show(); } return true; diff --git a/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java b/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java index 9da5c7546..246e39855 100644 --- a/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java @@ -270,7 +270,7 @@ public class ChooseContactActivity extends AbstractSearchableListItemActivity im return; } for (final Account account : xmppConnectionService.getAccounts()) { - if (account.getStatus() != Account.State.DISABLED) { + if (account.isEnabled()) { for (final Contact contact : account.getRoster().getContacts()) { if (contact.showInContactList() && !filterContacts.contains(contact.getJid().asBareJid().toString()) @@ -362,7 +362,7 @@ public class ChooseContactActivity extends AbstractSearchableListItemActivity im filterContacts(); this.mActivatedAccounts.clear(); for (Account account : xmppConnectionService.getAccounts()) { - if (account.getStatus() != Account.State.DISABLED) { + if (account.isEnabled()) { if (Config.DOMAIN_LOCK != null) { this.mActivatedAccounts.add(account.getJid().getEscapedLocal()); } else { @@ -382,6 +382,7 @@ public class ChooseContactActivity extends AbstractSearchableListItemActivity im @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); ScanActivity.onRequestPermissionResult(this, requestCode, grantResults); } diff --git a/src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java index fb716044c..f5fb9c7f5 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java @@ -137,8 +137,9 @@ public class ConferenceDetailsActivity extends XmppActivity implements OnConvers builder.setMultiChoiceItems(configuration.names, values, (dialog, which, isChecked) -> values[which] = isChecked); builder.setNegativeButton(R.string.cancel, null); builder.setPositiveButton(R.string.confirm, (dialog, which) -> { - Bundle options = configuration.toBundle(values); + final Bundle options = configuration.toBundle(values); options.putString("muc#roomconfig_persistentroom", "1"); + options.putString("{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites", options.getString("muc#roomconfig_allowinvites")); xmppConnectionService.pushConferenceConfiguration(mConversation, options, ConferenceDetailsActivity.this); diff --git a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java index 3b234c19e..394331452 100644 --- a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java @@ -46,6 +46,7 @@ import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.ListItem; import eu.siacs.conversations.services.AbstractQuickConversationsService; +import eu.siacs.conversations.services.QuickConversationsService; import eu.siacs.conversations.services.XmppConnectionService.OnAccountUpdate; import eu.siacs.conversations.services.XmppConnectionService.OnRosterUpdate; import eu.siacs.conversations.ui.adapter.MediaAdapter; @@ -119,13 +120,13 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp private void checkContactPermissionAndShowAddDialog() { if (hasContactsPermission()) { showAddToPhoneBookDialog(); - } else if (Config.CONTACTS_INTEGRATION && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + } else if (QuickConversationsService.isContactListIntegration(this) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { requestPermissions(new String[]{Manifest.permission.READ_CONTACTS}, REQUEST_SYNC_CONTACTS); } } private boolean hasContactsPermission() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (QuickConversationsService.isContactListIntegration(this) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return checkSelfPermission(Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED; } else { return true; @@ -455,6 +456,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp } boolean skippedInactive = false; boolean showsInactive = false; + boolean showUnverifiedWarning = false; for (final XmppAxolotlSession session : sessions) { final FingerprintStatus trust = session.getTrust(); hasKeys |= !trust.isCompromised(); @@ -470,7 +472,11 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp boolean highlight = session.getFingerprint().equals(messageFingerprint); addFingerprintRow(binding.detailsContactKeys, session, highlight); } + if (trust.isUnverified()) { + showUnverifiedWarning = true; + } } + binding.unverifiedWarning.setVisibility(showUnverifiedWarning ? View.VISIBLE : View.GONE); if (showsInactive || skippedInactive) { binding.showInactiveDevices.setText(showsInactive ? R.string.hide_inactive_devices : R.string.show_inactive_devices); binding.showInactiveDevices.setVisibility(View.VISIBLE); @@ -480,7 +486,8 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp } else { binding.showInactiveDevices.setVisibility(View.GONE); } - binding.scanButton.setVisibility(hasKeys && isCameraFeatureAvailable() ? View.VISIBLE : View.GONE); + final boolean isCameraFeatureAvailable = isCameraFeatureAvailable(); + binding.scanButton.setVisibility(hasKeys && isCameraFeatureAvailable ? View.VISIBLE : View.GONE); if (hasKeys) { binding.scanButton.setOnClickListener((v) -> ScanActivity.scan(this)); } @@ -517,18 +524,30 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp } } - private void onBadgeClick(View view) { - final Uri systemAccount = contact.getSystemAccount(); - if (systemAccount == null) { - checkContactPermissionAndShowAddDialog(); - } else { - final Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setData(systemAccount); - try { - startActivity(intent); - } catch (final ActivityNotFoundException e) { - Toast.makeText(this, R.string.no_application_found_to_view_contact, Toast.LENGTH_SHORT).show(); + private void onBadgeClick(final View view) { + if (QuickConversationsService.isContactListIntegration(this)) { + final Uri systemAccount = contact.getSystemAccount(); + if (systemAccount == null) { + checkContactPermissionAndShowAddDialog(); + } else { + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(systemAccount); + try { + startActivity(intent); + } catch (final ActivityNotFoundException e) { + Toast.makeText( + this, + R.string.no_application_found_to_view_contact, + Toast.LENGTH_SHORT) + .show(); + } } + } else { + Toast.makeText( + this, + R.string.contact_list_integration_not_available, + Toast.LENGTH_SHORT) + .show(); } } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 0f7e9073c..d19bc4a55 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -4,6 +4,8 @@ import static eu.siacs.conversations.ui.XmppActivity.EXTRA_ACCOUNT; import static eu.siacs.conversations.ui.XmppActivity.REQUEST_INVITE_TO_CONVERSATION; import static eu.siacs.conversations.ui.util.SoftKeyboardUtils.hideSoftKeyboard; import static eu.siacs.conversations.utils.PermissionUtils.allGranted; +import static eu.siacs.conversations.utils.PermissionUtils.audioGranted; +import static eu.siacs.conversations.utils.PermissionUtils.cameraGranted; import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied; import static eu.siacs.conversations.utils.PermissionUtils.writeGranted; @@ -64,19 +66,6 @@ import androidx.databinding.DataBindingUtil; import com.google.common.base.Optional; import com.google.common.collect.ImmutableList; -import org.jetbrains.annotations.NotNull; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicBoolean; - import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.axolotl.AxolotlService; @@ -123,13 +112,11 @@ import eu.siacs.conversations.utils.Compatibility; import eu.siacs.conversations.utils.GeoHelper; import eu.siacs.conversations.utils.MessageUtils; import eu.siacs.conversations.utils.NickValidityChecker; -import eu.siacs.conversations.utils.Patterns; import eu.siacs.conversations.utils.PermissionUtils; import eu.siacs.conversations.utils.QuickLoader; import eu.siacs.conversations.utils.StylingHelper; import eu.siacs.conversations.utils.TimeFrameUtils; import eu.siacs.conversations.utils.UIHelper; -import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.XmppConnection; import eu.siacs.conversations.xmpp.chatstate.ChatState; @@ -140,6 +127,19 @@ import eu.siacs.conversations.xmpp.jingle.Media; import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession; import eu.siacs.conversations.xmpp.jingle.RtpCapability; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + public class ConversationFragment extends XmppFragment implements EditMessage.KeyboardListener, MessageAdapter.OnContactPictureLongClicked, @@ -418,6 +418,7 @@ public class ConversationFragment extends XmppFragment public void onClick(View v) { final Account account = conversation == null ? null : conversation.getAccount(); if (account != null) { + account.setOption(Account.OPTION_SOFT_DISABLED, false); account.setOption(Account.OPTION_DISABLED, false); activity.xmppConnectionService.updateAccount(account); } @@ -480,7 +481,8 @@ public class ConversationFragment extends XmppFragment null, 0, 0, - 0); + 0, + Compatibility.pgpStartIntentSenderOptions()); } catch (SendIntentException e) { Toast.makeText( getActivity(), @@ -1306,6 +1308,7 @@ public class ConversationFragment extends XmppFragment || t instanceof HttpDownloadConnection); activity.getMenuInflater().inflate(R.menu.message_context, menu); menu.setHeaderTitle(R.string.message_options); + final MenuItem reportAndBlock = menu.findItem(R.id.action_report_and_block); MenuItem openWith = menu.findItem(R.id.open_with); MenuItem copyMessage = menu.findItem(R.id.copy_message); MenuItem copyLink = menu.findItem(R.id.copy_link); @@ -1324,6 +1327,17 @@ public class ConversationFragment extends XmppFragment m.getStatus() == Message.STATUS_SEND_FAILED && m.getErrorMessage() != null && !Message.ERROR_MESSAGE_CANCELLED.equals(m.getErrorMessage()); + final Conversational conversational = m.getConversation(); + if (m.getStatus() == Message.STATUS_RECEIVED && conversational instanceof Conversation c) { + final XmppConnection connection = c.getAccount().getXmppConnection(); + if (c.isWithStranger() + && m.getServerMsgId() != null + && !c.isBlocked() + && connection != null + && connection.getFeatures().spamReporting()) { + reportAndBlock.setVisible(true); + } + } if (!m.isFileOrImage() && !encrypted && !m.isGeoUri() @@ -1447,6 +1461,9 @@ public class ConversationFragment extends XmppFragment case R.id.open_with: openWith(selectedMessage); return true; + case R.id.action_report_and_block: + reportMessage(selectedMessage); + return true; default: return super.onContextItemSelected(item); } @@ -1610,8 +1627,12 @@ public class ConversationFragment extends XmppFragment .show(); return; } + final Account account = conversation.getAccount(); + if (account.setOption(Account.OPTION_SOFT_DISABLED, false)) { + activity.xmppConnectionService.updateAccount(account); + } final Contact contact = conversation.getContact(); - if (contact.getPresences().anySupport(Namespace.JINGLE_MESSAGE)) { + if (RtpCapability.jmiSupport(contact)) { triggerRtpSession(contact.getAccount(), contact.getJid().asBareJid(), action); } else { final RtpCapability.Capability capability; @@ -1866,6 +1887,9 @@ public class ConversationFragment extends XmppFragment } refresh(); } + if (cameraGranted(grantResults, permissions) || audioGranted(grantResults, permissions)) { + XmppConnectionService.toggleForegroundService(activity); + } } public void startDownloadable(Message message) { @@ -1965,8 +1989,7 @@ public class ConversationFragment extends XmppFragment if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { final List missingPermissions = new ArrayList<>(); for (String permission : permissions) { - if (Config.ONLY_INTERNAL_STORAGE - && permission.equals(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || Config.ONLY_INTERNAL_STORAGE) && permission.equals(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { continue; } if (activity.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { @@ -2106,6 +2129,10 @@ public class ConversationFragment extends XmppFragment } } + private void reportMessage(final Message message) { + BlockContactDialog.show(activity, conversation.getContact(), message.getServerMsgId()); + } + private void showErrorMessage(final Message message) { AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()); builder.setTitle(R.string.error_message); @@ -2663,6 +2690,8 @@ public class ConversationFragment extends XmppFragment R.string.this_account_is_disabled, R.string.enable, this.mEnableAccountListener); + } else if (account.getStatus() == Account.State.LOGGED_OUT) { + showSnackbar(R.string.this_account_is_logged_out,R.string.log_in,this.mEnableAccountListener); } else if (conversation.isBlocked()) { showSnackbar(R.string.contact_blocked, R.string.unblock, this.mUnblockClickListener); } else if (contact != null @@ -3391,7 +3420,7 @@ public class ConversationFragment extends XmppFragment try { getActivity() .startIntentSenderForResult( - pendingIntent.getIntentSender(), requestCode, null, 0, 0, 0); + pendingIntent.getIntentSender(), requestCode, null, 0, 0, 0, Compatibility.pgpStartIntentSenderOptions()); } catch (final SendIntentException ignored) { } } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index c31c3464b..94801c688 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -32,6 +32,7 @@ package eu.siacs.conversations.ui; import static eu.siacs.conversations.ui.ConversationFragment.REQUEST_DECRYPT_PGP; +import android.Manifest; import android.annotation.SuppressLint; import android.app.Activity; import android.app.Fragment; @@ -42,6 +43,7 @@ import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.provider.Settings; import android.util.Log; @@ -54,12 +56,14 @@ import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; +import androidx.core.app.ActivityCompat; import androidx.databinding.DataBindingUtil; import org.openintents.openpgp.util.OpenPgpApi; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import eu.siacs.conversations.Config; @@ -207,7 +211,9 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio if (ExceptionHelper.checkForCrash(this)) { return; } - openBatteryOptimizationDialogIfNeeded(); + if (openBatteryOptimizationDialogIfNeeded()) { + return; + } } } @@ -220,16 +226,16 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio getPreferences().edit().putBoolean(getBatteryOptimizationPreferenceKey(), false).apply(); } - private void openBatteryOptimizationDialogIfNeeded() { + private boolean openBatteryOptimizationDialogIfNeeded() { if (isOptimizingBattery() && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M && getPreferences().getBoolean(getBatteryOptimizationPreferenceKey(), true)) { - AlertDialog.Builder builder = new AlertDialog.Builder(this); + final AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setTitle(R.string.battery_optimizations_enabled); builder.setMessage(getString(R.string.battery_optimizations_enabled_dialog, getString(R.string.app_name))); builder.setPositiveButton(R.string.next, (dialog, which) -> { - Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); - Uri uri = Uri.parse("package:" + getPackageName()); + final Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + final Uri uri = Uri.parse("package:" + getPackageName()); intent.setData(uri); try { startActivityForResult(intent, REQUEST_BATTERY_OP); @@ -241,6 +247,14 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio final AlertDialog dialog = builder.create(); dialog.setCanceledOnTouchOutside(false); dialog.show(); + return true; + } + return false; + } + + private void requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQUEST_POST_NOTIFICATION); } } @@ -299,12 +313,17 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio } } - private void handleActivityResult(ActivityResult activityResult) { + private void handleActivityResult(final ActivityResult activityResult) { if (activityResult.resultCode == Activity.RESULT_OK) { handlePositiveActivityResult(activityResult.requestCode, activityResult.data); } else { handleNegativeActivityResult(activityResult.requestCode); } + if (activityResult.requestCode == REQUEST_BATTERY_OP) { + // the result code is always 0 even when battery permission were granted + requestNotificationPermissionIfNeeded(); + XmppConnectionService.toggleForegroundService(xmppConnectionService); + } } private void handleNegativeActivityResult(int requestCode) { diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java index eebd94df5..b673260ad 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java @@ -57,12 +57,14 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicReference; +import eu.siacs.conversations.BuildConfig; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.databinding.FragmentConversationsOverviewBinding; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Conversational; +import eu.siacs.conversations.services.QuickConversationsService; import eu.siacs.conversations.ui.adapter.ConversationAdapter; import eu.siacs.conversations.ui.interfaces.OnConversationArchived; import eu.siacs.conversations.ui.interfaces.OnConversationSelected; @@ -304,13 +306,19 @@ public class ConversationsOverviewFragment extends XmppFragment { return binding.getRoot(); } - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { - menuInflater.inflate(R.menu.fragment_conversations_overview, menu); - AccountUtils.showHideMenuItems(menu); - final MenuItem easyOnboardInvite = menu.findItem(R.id.action_easy_invite); - easyOnboardInvite.setVisible(EasyOnboardingInvite.anyHasSupport(activity == null ? null : activity.xmppConnectionService)); - } + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + menuInflater.inflate(R.menu.fragment_conversations_overview, menu); + AccountUtils.showHideMenuItems(menu); + final MenuItem easyOnboardInvite = menu.findItem(R.id.action_easy_invite); + easyOnboardInvite.setVisible( + EasyOnboardingInvite.anyHasSupport( + activity == null ? null : activity.xmppConnectionService)); + final MenuItem privacyPolicyMenuItem = menu.findItem(R.id.action_privacy_policy); + privacyPolicyMenuItem.setVisible( + BuildConfig.PRIVACY_POLICY != null + && QuickConversationsService.isPlayStoreFlavor()); + } @Override public void onBackendConnected() { diff --git a/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java b/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java index c0c43dda7..741cbcce5 100644 --- a/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java @@ -49,6 +49,7 @@ import java.util.concurrent.atomic.AtomicInteger; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.axolotl.AxolotlService; +import eu.siacs.conversations.crypto.axolotl.FingerprintStatus; import eu.siacs.conversations.crypto.axolotl.XmppAxolotlSession; import eu.siacs.conversations.databinding.ActivityEditAccountBinding; import eu.siacs.conversations.databinding.DialogPresenceBinding; @@ -66,6 +67,7 @@ import eu.siacs.conversations.ui.util.AvatarWorkerTask; import eu.siacs.conversations.ui.util.MenuDoubleTabUtil; import eu.siacs.conversations.ui.util.PendingItem; import eu.siacs.conversations.ui.util.SoftKeyboardUtils; +import eu.siacs.conversations.utils.Compatibility; import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.utils.Resolver; import eu.siacs.conversations.utils.SignupUtils; @@ -149,7 +151,8 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat if (mInitMode && mAccount != null) { mAccount.setOption(Account.OPTION_DISABLED, false); } - if (mAccount != null && mAccount.getStatus() == Account.State.DISABLED && !accountInfoEdited) { + if (mAccount != null && Arrays.asList(Account.State.DISABLED, Account.State.LOGGED_OUT).contains(mAccount.getStatus()) && !accountInfoEdited) { + mAccount.setOption(Account.OPTION_SOFT_DISABLED, false); mAccount.setOption(Account.OPTION_DISABLED, false); if (!xmppConnectionService.updateAccount(mAccount)) { Toast.makeText(EditAccountActivity.this, R.string.unable_to_update_account, Toast.LENGTH_SHORT).show(); @@ -471,6 +474,10 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat if (requestCode == REQUEST_BATTERY_OP || requestCode == REQUEST_DATA_SAVER) { updateAccountInformation(mAccount == null); } + if (requestCode == REQUEST_BATTERY_OP) { + // the result code is always 0 even when battery permission were granted + XmppConnectionService.toggleForegroundService(xmppConnectionService); + } if (requestCode == REQUEST_CHANGE_STATUS) { PresenceTemplate template = mPendingPresenceTemplate.pop(); if (template != null && resultCode == Activity.RESULT_OK) { @@ -624,6 +631,7 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat this.binding.accountRegisterNew.setVisibility(View.GONE); } this.binding.actionEditYourName.setOnClickListener(this::onEditYourNameClicked); + this.binding.scanButton.setOnClickListener((v) -> ScanActivity.scan(this)); } private void onEditYourNameClicked(View view) { @@ -648,6 +656,7 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat final MenuItem showBlocklist = menu.findItem(R.id.action_show_block_list); final MenuItem showMoreInfo = menu.findItem(R.id.action_server_info_show_more); final MenuItem changePassword = menu.findItem(R.id.action_change_password_on_server); + final MenuItem deleteAccount = menu.findItem(R.id.action_delete_account); final MenuItem renewCertificate = menu.findItem(R.id.action_renew_certificate); final MenuItem mamPrefs = menu.findItem(R.id.action_mam_prefs); final MenuItem changePresence = menu.findItem(R.id.action_change_presence); @@ -663,6 +672,7 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat if (!mAccount.getXmppConnection().getFeatures().register()) { changePassword.setVisible(false); + deleteAccount.setVisible(false); } mamPrefs.setVisible(mAccount.getXmppConnection().getFeatures().mam()); changePresence.setVisible(!mInitMode); @@ -670,6 +680,7 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat showBlocklist.setVisible(false); showMoreInfo.setVisible(false); changePassword.setVisible(false); + deleteAccount.setVisible(false); mamPrefs.setVisible(false); changePresence.setVisible(false); } @@ -875,6 +886,9 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat case R.id.action_change_password_on_server: gotoChangePassword(null); break; + case R.id.action_delete_account: + deleteAccount(); + break; case R.id.action_mam_prefs: editMamPrefs(); break; @@ -888,6 +902,12 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat return super.onOptionsItemSelected(item); } + private void deleteAccount() { + this.deleteAccount(mAccount,()->{ + finish(); + }); + } + private boolean inNeedOfSaslAccept() { return mAccount != null && mAccount.getLastErrorStatus() == Account.State.DOWNGRADE_ATTACK && mAccount.getPinnedMechanismPriority() >= 0 && !accountInfoEdited(); } @@ -965,7 +985,7 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat public void userInputRequired(PendingIntent pi, String object) { mPendingPresenceTemplate.push(template); try { - startIntentSenderForResult(pi.getIntentSender(), REQUEST_CHANGE_STATUS, null, 0, 0, 0); + startIntentSenderForResult(pi.getIntentSender(), REQUEST_CHANGE_STATUS, null, 0, 0, 0, Compatibility.pgpStartIntentSenderOptions()); } catch (final IntentSender.SendIntentException ignored) { } } @@ -1013,7 +1033,8 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat final boolean togglePassword = mAccount.isOptionSet(Account.OPTION_MAGIC_CREATE) || !mAccount.isOptionSet(Account.OPTION_LOGGED_IN_SUCCESSFULLY); - final boolean editPassword = !mAccount.isOptionSet(Account.OPTION_MAGIC_CREATE) || (!mAccount.isOptionSet(Account.OPTION_LOGGED_IN_SUCCESSFULLY) && QuickConversationsService.isConversations()) || mAccount.getLastErrorStatus() == Account.State.UNAUTHORIZED; + final boolean neverLoggedIn = !mAccount.isOptionSet(Account.OPTION_LOGGED_IN_SUCCESSFULLY) && QuickConversationsService.isConversations(); + final boolean editPassword = mAccount.unauthorized() || neverLoggedIn; this.binding.accountPasswordLayout.setPasswordVisibilityToggleEnabled(togglePassword); @@ -1141,19 +1162,24 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat this.binding.ownFingerprintDesc.setText(R.string.omemo_fingerprint); } this.binding.axolotlFingerprint.setText(CryptoHelper.prettifyFingerprint(ownAxolotlFingerprint.substring(2))); - this.binding.actionCopyAxolotlToClipboard.setVisibility(View.VISIBLE); - this.binding.actionCopyAxolotlToClipboard.setOnClickListener(v -> copyOmemoFingerprint(ownAxolotlFingerprint)); + this.binding.showQrCodeButton.setVisibility(View.VISIBLE); + this.binding.showQrCodeButton.setOnClickListener(v -> showQrCode()); } else { this.binding.axolotlFingerprintBox.setVisibility(View.GONE); } boolean hasKeys = false; + boolean showUnverifiedWarning = false; binding.otherDeviceKeys.removeAllViews(); - for (XmppAxolotlSession session : mAccount.getAxolotlService().findOwnSessions()) { - if (!session.getTrust().isCompromised()) { + for (final XmppAxolotlSession session : mAccount.getAxolotlService().findOwnSessions()) { + final FingerprintStatus trust = session.getTrust(); + if (!trust.isCompromised()) { boolean highlight = session.getFingerprint().equals(messageFingerprint); addFingerprintRow(binding.otherDeviceKeys, session, highlight); hasKeys = true; } + if (trust.isUnverified()) { + showUnverifiedWarning = true; + } } if (hasKeys && Config.supportOmemo()) { //TODO: either the button should be visible if we print an active device or the device list should be fed with reactived devices this.binding.otherDeviceKeysCard.setVisibility(View.VISIBLE); @@ -1163,6 +1189,8 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat } else { binding.clearDevices.setVisibility(View.VISIBLE); } + binding.unverifiedWarning.setVisibility(showUnverifiedWarning ? View.VISIBLE : View.GONE); + binding.scanButton.setVisibility(showUnverifiedWarning ? View.VISIBLE : View.GONE); } else { this.binding.otherDeviceKeysCard.setVisibility(View.GONE); } diff --git a/src/main/java/eu/siacs/conversations/ui/MucUsersActivity.java b/src/main/java/eu/siacs/conversations/ui/MucUsersActivity.java index 2f05be90f..e759ee18e 100644 --- a/src/main/java/eu/siacs/conversations/ui/MucUsersActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/MucUsersActivity.java @@ -13,8 +13,13 @@ import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.databinding.DataBindingUtil; +import com.google.common.collect.Collections2; +import com.google.common.collect.Lists; +import com.google.common.collect.Ordering; + import java.util.ArrayList; import java.util.Collections; import java.util.Locale; @@ -56,30 +61,38 @@ public class MucUsersActivity extends XmppActivity implements XmppConnectionServ private void loadAndSubmitUsers() { if (mConversation != null) { allUsers = mConversation.getMucOptions().getUsers(); - Collections.sort(allUsers); submitFilteredList(mSearchEditText != null ? mSearchEditText.getText().toString() : null); } } - private void submitFilteredList(String search) { + private void submitFilteredList(final String search) { if (TextUtils.isEmpty(search)) { - userAdapter.submitList(allUsers); + userAdapter.submitList(Ordering.natural().immutableSortedCopy(allUsers)); } else { final String needle = search.toLowerCase(Locale.getDefault()); - ArrayList filtered = new ArrayList<>(); - for(MucOptions.User user : allUsers) { - final String name = user.getName(); - final Contact contact = user.getContact(); - if (name != null && name.toLowerCase(Locale.getDefault()).contains(needle) || contact != null && contact.getDisplayName().toLowerCase(Locale.getDefault()).contains(needle)) { - filtered.add(user); - } - } - userAdapter.submitList(filtered); + userAdapter.submitList( + Ordering.natural() + .immutableSortedCopy( + Collections2.filter( + this.allUsers, + user -> { + final String name = user.getName(); + final Contact contact = user.getContact(); + return name != null + && name.toLowerCase( + Locale.getDefault()) + .contains(needle) + || contact != null + && contact.getDisplayName() + .toLowerCase( + Locale.getDefault()) + .contains(needle); + }))); } } @Override - public boolean onContextItemSelected(MenuItem item) { + public boolean onContextItemSelected(@NonNull MenuItem item) { if (!MucDetailsContextMenuHelper.onContextItemSelected(item, userAdapter.getSelectedUser(), this)) { return super.onContextItemSelected(item); } diff --git a/src/main/java/eu/siacs/conversations/ui/OmemoActivity.java b/src/main/java/eu/siacs/conversations/ui/OmemoActivity.java index 89fdae333..44af0d0b2 100644 --- a/src/main/java/eu/siacs/conversations/ui/OmemoActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/OmemoActivity.java @@ -205,6 +205,7 @@ public abstract class OmemoActivity extends XmppActivity { @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); ScanActivity.onRequestPermissionResult(this, requestCode, grantResults); } } diff --git a/src/main/java/eu/siacs/conversations/ui/PublishGroupChatProfilePictureActivity.java b/src/main/java/eu/siacs/conversations/ui/PublishGroupChatProfilePictureActivity.java index 81b0ae15c..658567aa6 100644 --- a/src/main/java/eu/siacs/conversations/ui/PublishGroupChatProfilePictureActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/PublishGroupChatProfilePictureActivity.java @@ -40,8 +40,6 @@ import android.widget.Toast; import androidx.annotation.StringRes; import androidx.databinding.DataBindingUtil; -import com.theartofdev.edmodo.cropper.CropImage; - import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.databinding.ActivityPublishProfilePictureBinding; @@ -51,6 +49,8 @@ import eu.siacs.conversations.ui.util.PendingItem; import static eu.siacs.conversations.ui.PublishProfilePictureActivity.REQUEST_CHOOSE_PICTURE; +import com.canhub.cropper.CropImage; + public class PublishGroupChatProfilePictureActivity extends XmppActivity implements OnAvatarPublication { private final PendingItem pendingConversationUuid = new PendingItem<>(); private ActivityPublishProfilePictureBinding binding; diff --git a/src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java b/src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java index 16607b81e..b6822b301 100644 --- a/src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java @@ -19,7 +19,7 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.StringRes; -import com.theartofdev.edmodo.cropper.CropImage; +import com.canhub.cropper.CropImage; import java.util.concurrent.atomic.AtomicBoolean; diff --git a/src/main/java/eu/siacs/conversations/ui/RecordingActivity.java b/src/main/java/eu/siacs/conversations/ui/RecordingActivity.java index ad8684b72..d3b3a96b2 100644 --- a/src/main/java/eu/siacs/conversations/ui/RecordingActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/RecordingActivity.java @@ -17,6 +17,8 @@ import android.widget.Toast; import androidx.databinding.DataBindingUtil; +import com.google.common.collect.ImmutableSet; + import java.io.File; import java.lang.ref.WeakReference; import java.text.SimpleDateFormat; @@ -25,6 +27,7 @@ import java.util.Locale; import java.util.Objects; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.Set; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; @@ -95,14 +98,40 @@ public class RecordingActivity extends Activity implements View.OnClickListener } } + private static final Set AAC_SENSITIVE_DEVICES = + new ImmutableSet.Builder() + .add("FP4") // Fairphone 4 https://codeberg.org/monocles/monocles_chat/issues/133 + .add("ONEPLUS A6000") // OnePlus 6 https://github.com/iNPUTmice/Conversations/issues/4329 + .add("ONEPLUS A6003") // OnePlus 6 https://github.com/iNPUTmice/Conversations/issues/4329 + .add("ONEPLUS A6010") // OnePlus 6T https://codeberg.org/monocles/monocles_chat/issues/133 + .add("ONEPLUS A6013") // OnePlus 6T https://codeberg.org/monocles/monocles_chat/issues/133 + .add("Pixel 4a") // Pixel 4a https://github.com/iNPUTmice/Conversations/issues/4223 + .build(); + private boolean startRecording() { mRecorder = new MediaRecorder(); mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); - mRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); - mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); - mRecorder.setAudioEncodingBitRate(96000); - mRecorder.setAudioSamplingRate(22050); - setupOutputFile(); + final int outputFormat; + if (Config.USE_OPUS_VOICE_MESSAGES && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + outputFormat = MediaRecorder.OutputFormat.OGG; + mRecorder.setOutputFormat(outputFormat); + mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS); + mRecorder.setAudioEncodingBitRate(32_000); + } else { + outputFormat = MediaRecorder.OutputFormat.MPEG_4; + mRecorder.setOutputFormat(outputFormat); + if (AAC_SENSITIVE_DEVICES.contains(Build.MODEL)) { + // Changing these three settings for AAC sensitive devices might lead to sporadically truncated (cut-off) voice messages. + mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.HE_AAC); + mRecorder.setAudioSamplingRate(24_000); + mRecorder.setAudioEncodingBitRate(28_000); + } else { + mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); + mRecorder.setAudioSamplingRate(22_050); + mRecorder.setAudioEncodingBitRate(64_000); + } + } + setupOutputFile(outputFormat); mRecorder.setOutputFile(mOutputFile.getAbsolutePath()); try { @@ -110,10 +139,10 @@ public class RecordingActivity extends Activity implements View.OnClickListener mRecorder.start(); mStartTime = SystemClock.elapsedRealtime(); mHandler.postDelayed(mTickExecutor, 100); - Log.d("Voice Recorder", "started recording to " + mOutputFile.getAbsolutePath()); + Log.d(Config.LOGTAG, "started recording to " + mOutputFile.getAbsolutePath()); return true; } catch (Exception e) { - Log.e("Voice Recorder", "prepare() failed " + e.getMessage()); + Log.e(Config.LOGTAG, "prepare() failed ", e); return false; } } @@ -175,9 +204,18 @@ public class RecordingActivity extends Activity implements View.OnClickListener } } - private File generateOutputFilename() { + private File generateOutputFilename(final int outputFormat) { final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US); - final String filename = "RECORDING_" + dateFormat.format(new Date()) + ".m4a"; + final String extension; + if (outputFormat == MediaRecorder.OutputFormat.MPEG_4) { + extension = "m4a"; + } else if (outputFormat == MediaRecorder.OutputFormat.OGG) { + extension = "oga"; + } else { + throw new IllegalStateException("Unrecognized output format"); + } + final String filename = + String.format("RECORDING_%s.%s", dateFormat.format(new Date()), extension); final File parentDirectory; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { parentDirectory = @@ -190,8 +228,8 @@ public class RecordingActivity extends Activity implements View.OnClickListener return new File(conversationsDirectory, filename); } - private void setupOutputFile() { - mOutputFile = generateOutputFilename(); + private void setupOutputFile(final int outputFormat) { + mOutputFile = generateOutputFilename(outputFormat); final File parentDirectory = mOutputFile.getParentFile(); if (Objects.requireNonNull(parentDirectory).mkdirs()) { Log.d(Config.LOGTAG, "created " + parentDirectory.getAbsolutePath()); diff --git a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java index 75079c585..fee4d9a1c 100644 --- a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java @@ -1,8 +1,9 @@ package eu.siacs.conversations.ui; -import static java.util.Arrays.asList; import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied; +import static java.util.Arrays.asList; + import android.Manifest; import android.annotation.SuppressLint; import android.app.Activity; @@ -10,7 +11,9 @@ import android.app.PictureInPictureParams; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; +import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; +import android.opengl.GLException; import android.os.Build; import android.os.Bundle; import android.os.Handler; @@ -39,7 +42,6 @@ import com.google.common.collect.ImmutableSet; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; -import org.jetbrains.annotations.NotNull; import org.webrtc.RendererCommon; import org.webrtc.SurfaceViewRenderer; import org.webrtc.VideoTrack; @@ -63,15 +65,27 @@ import eu.siacs.conversations.ui.util.MainThreadExecutor; import eu.siacs.conversations.ui.util.Rationals; import eu.siacs.conversations.utils.PermissionUtils; import eu.siacs.conversations.utils.TimeFrameUtils; -import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection; import eu.siacs.conversations.xmpp.jingle.ContentAddition; import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager; import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection; import eu.siacs.conversations.xmpp.jingle.Media; +import eu.siacs.conversations.xmpp.jingle.RtpCapability; import eu.siacs.conversations.xmpp.jingle.RtpEndUserState; +import org.jetbrains.annotations.NotNull; + +import org.webrtc.RendererCommon; +import org.webrtc.SurfaceViewRenderer; +import org.webrtc.VideoTrack; + +import java.lang.ref.WeakReference; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + public class RtpSessionActivity extends XmppActivity implements XmppConnectionService.OnJingleRtpConnectionUpdate, eu.siacs.conversations.ui.widget.SurfaceViewRenderer.OnAspectRatioChanged { @@ -106,9 +120,7 @@ public class RtpSessionActivity extends XmppActivity RtpEndUserState.RECONNECTING, RtpEndUserState.INCOMING_CONTENT_ADD); private static final List STATES_CONSIDERED_CONNECTED = - Arrays.asList( - RtpEndUserState.CONNECTED, - RtpEndUserState.RECONNECTING); + Arrays.asList(RtpEndUserState.CONNECTED, RtpEndUserState.RECONNECTING); private static final List STATES_SHOWING_PIP_PLACEHOLDER = Arrays.asList( RtpEndUserState.ACCEPTING_CALL, @@ -277,21 +289,22 @@ public class RtpSessionActivity extends XmppActivity } public boolean onOptionsItemSelected(final MenuItem item) { - switch (item.getItemId()) { - case R.id.action_help: - launchHelpInBrowser(); - return true; - case R.id.action_goto_chat: - switchToConversation(); - return true; - case R.id.action_switch_to_video: - requestPermissionAndSwitchToVideo(); - return true; - case R.id.action_dialpad: - toggleDialpadVisibility(); - break; + final var itemItem = item.getItemId(); + if (itemItem == R.id.action_help) { + launchHelpInBrowser(); + return true; + } else if (itemItem == R.id.action_goto_chat) { + switchToConversation(); + return true; + } else if (itemItem == R.id.action_switch_to_video) { + requestPermissionAndSwitchToVideo(); + return true; + } else if (itemItem == R.id.action_dialpad) { + toggleDialpadVisibility(); + return true; + } else { + return super.onOptionsItemSelected(item); } - return super.onOptionsItemSelected(item); } private void launchHelpInBrowser() { @@ -368,8 +381,9 @@ public class RtpSessionActivity extends XmppActivity } private void acceptContentAdd(final ContentAddition contentAddition) { - if (contentAddition == null || contentAddition.direction != ContentAddition.Direction.INCOMING) { - Log.d(Config.LOGTAG,"ignore press on content-accept button"); + if (contentAddition == null + || contentAddition.direction != ContentAddition.Direction.INCOMING) { + Log.d(Config.LOGTAG, "ignore press on content-accept button"); return; } requestPermissionAndAcceptContentAdd(contentAddition); @@ -378,7 +392,11 @@ public class RtpSessionActivity extends XmppActivity private void requestPermissionAndAcceptContentAdd(final ContentAddition contentAddition) { final List permissions = permissions(contentAddition.media()); if (PermissionUtils.hasPermission(this, permissions, REQUEST_ACCEPT_CONTENT)) { - requireRtpConnection().acceptContentAdd(contentAddition.summary); + try { + requireRtpConnection().acceptContentAdd(contentAddition.summary); + } catch (final IllegalStateException e) { + Toast.makeText(this, e.getMessage(), Toast.LENGTH_SHORT).show(); + } } } @@ -457,7 +475,7 @@ public class RtpSessionActivity extends XmppActivity private void putScreenInCallMode(final Set media) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - if (!media.contains(Media.VIDEO)) { + if (Media.audioOnly(media)) { final JingleRtpConnection rtpConnection = rtpConnectionReference != null ? rtpConnectionReference.get() : null; final AppRTCAudioManager audioManager = @@ -468,6 +486,15 @@ public class RtpSessionActivity extends XmppActivity acquireProximityWakeLock(); } } + lockOrientation(media); + } + + private void lockOrientation(final Set media) { + if (Media.audioOnly(media)) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + } else { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + } } @SuppressLint("WakelockTimeout") @@ -721,8 +748,10 @@ public class RtpSessionActivity extends XmppActivity private boolean isConnected() { final JingleRtpConnection connection = this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null; - final RtpEndUserState endUserState = connection == null ? null : connection.getEndUserState(); - return STATES_CONSIDERED_CONNECTED.contains(endUserState) || endUserState == RtpEndUserState.INCOMING_CONTENT_ADD; + final RtpEndUserState endUserState = + connection == null ? null : connection.getEndUserState(); + return STATES_CONSIDERED_CONNECTED.contains(endUserState) + || endUserState == RtpEndUserState.INCOMING_CONTENT_ADD; } private boolean switchToPictureInPicture() { @@ -870,8 +899,12 @@ public class RtpSessionActivity extends XmppActivity surfaceViewRenderer.setVisibility(View.VISIBLE); try { surfaceViewRenderer.init(requireRtpConnection().getEglBaseContext(), null); - } catch (final IllegalStateException e) { - // Log.d(Config.LOGTAG, "SurfaceViewRenderer was already initialized"); + } catch (final IllegalStateException ignored) { + // SurfaceViewRenderer was already initialized + } catch (final RuntimeException e) { + if (Throwables.getRootCause(e) instanceof GLException glException) { + Log.w(Config.LOGTAG, "could not set up hardware renderer", glException); + } } surfaceViewRenderer.setEnableHardwareScaler(true); } @@ -880,68 +913,43 @@ public class RtpSessionActivity extends XmppActivity updateStateDisplay(state, Collections.emptySet(), null); } - private void updateStateDisplay(final RtpEndUserState state, final Set media, final ContentAddition contentAddition) { + private void updateStateDisplay( + final RtpEndUserState state, + final Set media, + final ContentAddition contentAddition) { switch (state) { - case INCOMING_CALL: + case INCOMING_CALL -> { Preconditions.checkArgument(media.size() > 0, "Media must not be empty"); if (media.contains(Media.VIDEO)) { setTitle(R.string.rtp_state_incoming_video_call); } else { setTitle(R.string.rtp_state_incoming_call); } - break; - case INCOMING_CONTENT_ADD: + } + case INCOMING_CONTENT_ADD -> { if (contentAddition != null && contentAddition.media().contains(Media.VIDEO)) { setTitle(R.string.rtp_state_content_add_video); } else { setTitle(R.string.rtp_state_content_add); } - break; - case CONNECTING: - setTitle(R.string.rtp_state_connecting); - break; - case CONNECTED: - setTitle(R.string.rtp_state_connected); - break; - case RECONNECTING: - setTitle(R.string.rtp_state_reconnecting); - break; - case ACCEPTING_CALL: - setTitle(R.string.rtp_state_accepting_call); - break; - case ENDING_CALL: - setTitle(R.string.rtp_state_ending_call); - break; - case FINDING_DEVICE: - setTitle(R.string.rtp_state_finding_device); - break; - case RINGING: - setTitle(R.string.rtp_state_ringing); - break; - case DECLINED_OR_BUSY: - setTitle(R.string.rtp_state_declined_or_busy); - break; - case CONNECTIVITY_ERROR: - setTitle(R.string.rtp_state_connectivity_error); - break; - case CONNECTIVITY_LOST_ERROR: - setTitle(R.string.rtp_state_connectivity_lost_error); - break; - case RETRACTED: - setTitle(R.string.rtp_state_retracted); - break; - case APPLICATION_ERROR: - setTitle(R.string.rtp_state_application_failure); - break; - case SECURITY_ERROR: - setTitle(R.string.rtp_state_security_error); - break; - case ENDED: - throw new IllegalStateException( - "Activity should have called finishAndReleaseWakeLock();"); - default: - throw new IllegalStateException( - String.format("State %s has not been handled in UI", state)); + } + case CONNECTING -> setTitle(R.string.rtp_state_connecting); + case CONNECTED -> setTitle(R.string.rtp_state_connected); + case RECONNECTING -> setTitle(R.string.rtp_state_reconnecting); + case ACCEPTING_CALL -> setTitle(R.string.rtp_state_accepting_call); + case ENDING_CALL -> setTitle(R.string.rtp_state_ending_call); + case FINDING_DEVICE -> setTitle(R.string.rtp_state_finding_device); + case RINGING -> setTitle(R.string.rtp_state_ringing); + case DECLINED_OR_BUSY -> setTitle(R.string.rtp_state_declined_or_busy); + case CONNECTIVITY_ERROR -> setTitle(R.string.rtp_state_connectivity_error); + case CONNECTIVITY_LOST_ERROR -> setTitle(R.string.rtp_state_connectivity_lost_error); + case RETRACTED -> setTitle(R.string.rtp_state_retracted); + case APPLICATION_ERROR -> setTitle(R.string.rtp_state_application_failure); + case SECURITY_ERROR -> setTitle(R.string.rtp_state_security_error); + case ENDED -> throw new IllegalStateException( + "Activity should have called finishAndReleaseWakeLock();"); + default -> throw new IllegalStateException( + String.format("State %s has not been handled in UI", state)); } } @@ -997,7 +1005,10 @@ public class RtpSessionActivity extends XmppActivity } @SuppressLint("RestrictedApi") - private void updateButtonConfiguration(final RtpEndUserState state, final Set media, final ContentAddition contentAddition) { + private void updateButtonConfiguration( + final RtpEndUserState state, + final Set media, + final ContentAddition contentAddition) { if (state == RtpEndUserState.ENDING_CALL || isPictureInPicture()) { this.binding.rejectCall.setVisibility(View.INVISIBLE); this.binding.endCall.setVisibility(View.INVISIBLE); @@ -1013,7 +1024,8 @@ public class RtpSessionActivity extends XmppActivity this.binding.acceptCall.setImageResource(R.drawable.ic_call_white_48dp); this.binding.acceptCall.setVisibility(View.VISIBLE); } else if (state == RtpEndUserState.INCOMING_CONTENT_ADD) { - this.binding.rejectCall.setContentDescription(getString(R.string.reject_switch_to_video)); + this.binding.rejectCall.setContentDescription( + getString(R.string.reject_switch_to_video)); this.binding.rejectCall.setOnClickListener(this::rejectContentAdd); this.binding.rejectCall.setImageResource(R.drawable.ic_clear_white_48dp); this.binding.rejectCall.setVisibility(View.VISIBLE); @@ -1105,7 +1117,7 @@ public class RtpSessionActivity extends XmppActivity private void updateInCallButtonConfigurationSpeaker( final AppRTCAudioManager.AudioDevice selectedAudioDevice, final int numberOfChoices) { switch (selectedAudioDevice) { - case EARPIECE: + case EARPIECE -> { this.binding.inCallActionRight.setImageResource( R.drawable.ic_volume_off_black_24dp); if (numberOfChoices >= 2) { @@ -1114,13 +1126,13 @@ public class RtpSessionActivity extends XmppActivity this.binding.inCallActionRight.setOnClickListener(null); this.binding.inCallActionRight.setClickable(false); } - break; - case WIRED_HEADSET: + } + case WIRED_HEADSET -> { this.binding.inCallActionRight.setImageResource(R.drawable.ic_headset_black_24dp); this.binding.inCallActionRight.setOnClickListener(null); this.binding.inCallActionRight.setClickable(false); - break; - case SPEAKER_PHONE: + } + case SPEAKER_PHONE -> { this.binding.inCallActionRight.setImageResource(R.drawable.ic_volume_up_black_24dp); if (numberOfChoices >= 2) { this.binding.inCallActionRight.setOnClickListener(this::switchToEarpiece); @@ -1128,13 +1140,13 @@ public class RtpSessionActivity extends XmppActivity this.binding.inCallActionRight.setOnClickListener(null); this.binding.inCallActionRight.setClickable(false); } - break; - case BLUETOOTH: + } + case BLUETOOTH -> { this.binding.inCallActionRight.setImageResource( R.drawable.ic_bluetooth_audio_black_24dp); this.binding.inCallActionRight.setOnClickListener(null); this.binding.inCallActionRight.setClickable(false); - break; + } } this.binding.inCallActionRight.setVisibility(View.VISIBLE); } @@ -1163,10 +1175,10 @@ public class RtpSessionActivity extends XmppActivity private void switchCamera(final View view) { Futures.addCallback( requireRtpConnection().switchCamera(), - new FutureCallback() { + new FutureCallback<>() { @Override public void onSuccess(@Nullable Boolean isFrontCamera) { - binding.localVideo.setMirror(isFrontCamera); + binding.localVideo.setMirror(Boolean.TRUE.equals(isFrontCamera)); } @Override @@ -1430,6 +1442,7 @@ public class RtpSessionActivity extends XmppActivity final AbstractJingleConnection.Id id = requireRtpConnection().getId(); final boolean verified = requireRtpConnection().isVerified(); final Set media = getMedia(); + lockOrientation(media); final ContentAddition contentAddition = getPendingContentAddition(); final Contact contact = getWith(); if (account == id.account && id.with.equals(with) && id.sessionId.equals(sessionId)) { @@ -1531,10 +1544,7 @@ public class RtpSessionActivity extends XmppActivity final Account account, Jid with, final RtpEndUserState state, final Set media) { final Intent intent = new Intent(Intent.ACTION_VIEW); intent.putExtra(EXTRA_ACCOUNT, account.getJid().toEscapedString()); - if (account.getRoster() - .getContact(with) - .getPresences() - .anySupport(Namespace.JINGLE_MESSAGE)) { + if (RtpCapability.jmiSupport(account.getRoster().getContact(with))) { intent.putExtra(EXTRA_WITH, with.asBareJid().toEscapedString()); } else { intent.putExtra(EXTRA_WITH, with.toEscapedString()); diff --git a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java index 2b2f8110a..b9eaf32af 100644 --- a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java @@ -28,6 +28,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import java.io.File; +import java.net.URI; +import java.net.URISyntaxException; import java.security.KeyStoreException; import java.util.ArrayList; import java.util.Arrays; @@ -47,6 +49,7 @@ import eu.siacs.conversations.ui.util.SettingsUtils; import eu.siacs.conversations.ui.util.StyledAttributes; import eu.siacs.conversations.utils.GeoHelper; import eu.siacs.conversations.utils.TimeFrameUtils; +import eu.siacs.conversations.xmpp.InvalidJid; import eu.siacs.conversations.xmpp.Jid; public class SettingsActivity extends XmppActivity implements OnSharedPreferenceChangeListener { @@ -131,6 +134,7 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference changeOmemoSettingSummary(); if (QuickConversationsService.isQuicksy() + || QuickConversationsService.isPlayStoreFlavor() || Strings.isNullOrEmpty(Config.CHANNEL_DISCOVERY)) { final PreferenceCategory groupChats = (PreferenceCategory) mSettingsFragment.findPreference("group_chats"); @@ -505,12 +509,41 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference } else if (name.equals(PREVENT_SCREENSHOTS)) { SettingsUtils.applyScreenshotPreventionSetting(this); } else if (UnifiedPushDistributor.PREFERENCES.contains(name)) { + final String pushServerPreference = + Strings.nullToEmpty(preferences.getString( + UnifiedPushDistributor.PREFERENCE_PUSH_SERVER, + getString(R.string.default_push_server))).trim(); + if (isJidInvalid(pushServerPreference) || isHttpUri(pushServerPreference)) { + Toast.makeText(this,R.string.invalid_jid,Toast.LENGTH_LONG).show(); + } if (xmppConnectionService.reconfigurePushDistributor()) { xmppConnectionService.renewUnifiedPushEndpoints(); } } } + private static boolean isJidInvalid(final String input) { + if (Strings.isNullOrEmpty(input)) { + return true; + } + try { + Jid.ofEscaped(input); + return false; + } catch (final IllegalArgumentException e) { + return true; + } + } + + private static boolean isHttpUri(final String input) { + final URI uri; + try { + uri = new URI(input); + } catch (final URISyntaxException e) { + return false; + } + return Arrays.asList("http","https").contains(uri.getScheme()); + } + @Override public void onResume() { super.onResume(); diff --git a/src/main/java/eu/siacs/conversations/ui/ShortcutActivity.java b/src/main/java/eu/siacs/conversations/ui/ShortcutActivity.java index 33164c95c..ff935beae 100644 --- a/src/main/java/eu/siacs/conversations/ui/ShortcutActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ShortcutActivity.java @@ -61,7 +61,7 @@ public class ShortcutActivity extends AbstractSearchableListItemActivity { return; } for (final Account account : xmppConnectionService.getAccounts()) { - if (account.getStatus() != Account.State.DISABLED) { + if (account.isEnabled()) { for (final Contact contact : account.getRoster().getContacts()) { if (contact.showInContactList() && contact.match(this, needle)) { diff --git a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java index 7a2ffc0d8..8b94dd440 100644 --- a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java @@ -6,12 +6,14 @@ import android.app.Dialog; import android.app.PendingIntent; import android.content.ActivityNotFoundException; import android.content.Context; +import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.preference.PreferenceManager; import android.text.Editable; import android.text.Html; import android.text.TextWatcher; @@ -62,6 +64,7 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; +import eu.siacs.conversations.BuildConfig; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.databinding.ActivityStartConversationBinding; @@ -90,6 +93,8 @@ import eu.siacs.conversations.xmpp.XmppConnection; public class StartConversationActivity extends XmppActivity implements XmppConnectionService.OnConversationUpdate, OnRosterUpdate, OnUpdateBlocklist, CreatePrivateGroupChatDialog.CreateConferenceDialogListener, JoinConferenceDialog.JoinConferenceDialogListener, SwipeRefreshLayout.OnRefreshListener, CreatePublicChannelDialog.CreatePublicChannelDialogListener { + private static final String PREF_KEY_CONTACT_INTEGRATION_CONSENT = "contact_list_integration_consent"; + public static final String EXTRA_INVITE_URI = "eu.siacs.conversations.invite_uri"; private final int REQUEST_SYNC_CONTACTS = 0x28cf; @@ -323,7 +328,11 @@ public class StartConversationActivity extends XmppActivity implements XmppConne } switch (actionItem.getId()) { case R.id.discover_public_channels: - startActivity(new Intent(this, ChannelDiscoveryActivity.class)); + if (QuickConversationsService.isPlayStoreFlavor()) { + throw new IllegalStateException("Channel discovery is not available on Google Play flavor"); + } else { + startActivity(new Intent(this, ChannelDiscoveryActivity.class)); + } break; case R.id.join_public_channel: showJoinConferenceDialog(prefilled); @@ -349,6 +358,9 @@ public class StartConversationActivity extends XmppActivity implements XmppConne final Menu menu = popupMenu.getMenu(); for (int i = 0; i < menu.size(); i++) { final MenuItem menuItem = menu.getItem(i); + if (QuickConversationsService.isPlayStoreFlavor() && menuItem.getItemId() == R.id.discover_public_channels) { + continue; + } final SpeedDialActionItem actionItem = new SpeedDialActionItem.Builder(menuItem.getItemId(), menuItem.getIcon()) .setLabel(menuItem.getTitle() != null ? menuItem.getTitle().toString() : null) .setFabImageTintColor(ContextCompat.getColor(this, R.color.white)) @@ -645,17 +657,21 @@ public class StartConversationActivity extends XmppActivity implements XmppConne mSearchEditText.setHint(R.string.search_contacts); mSearchEditText.setContentDescription(getString(R.string.search_contacts)); } else { - mSearchEditText.setHint(R.string.search_bookmarks); - mSearchEditText.setContentDescription(getString(R.string.search_bookmarks)); + mSearchEditText.setHint(R.string.search_group_chats); + mSearchEditText.setContentDescription(getString(R.string.search_group_chats)); } } @Override - public boolean onCreateOptionsMenu(Menu menu) { + public boolean onCreateOptionsMenu(final Menu menu) { getMenuInflater().inflate(R.menu.start_conversation, menu); AccountUtils.showHideMenuItems(menu); - MenuItem menuHideOffline = menu.findItem(R.id.action_hide_offline); - MenuItem qrCodeScanMenuItem = menu.findItem(R.id.action_scan_qr_code); + final MenuItem menuHideOffline = menu.findItem(R.id.action_hide_offline); + final MenuItem qrCodeScanMenuItem = menu.findItem(R.id.action_scan_qr_code); + final MenuItem privacyPolicyMenuItem = menu.findItem(R.id.action_privacy_policy); + privacyPolicyMenuItem.setVisible( + BuildConfig.PRIVACY_POLICY != null + && QuickConversationsService.isPlayStoreFlavor()); qrCodeScanMenuItem.setVisible(isCameraFeatureAvailable()); if (QuickConversationsService.isQuicksy()) { menuHideOffline.setVisible(false); @@ -749,47 +765,96 @@ public class StartConversationActivity extends XmppActivity implements XmppConne } private void askForContactsPermissions() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) { + if (QuickConversationsService.isContactListIntegration(this) + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (checkSelfPermission(Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED) { if (mRequestedContactsPermission.compareAndSet(false, true)) { - if (QuickConversationsService.isQuicksy() || shouldShowRequestPermissionRationale(Manifest.permission.READ_CONTACTS)) { + final String consent = + PreferenceManager.getDefaultSharedPreferences(getApplicationContext()) + .getString(PREF_KEY_CONTACT_INTEGRATION_CONSENT, null); + final boolean requiresConsent = + (QuickConversationsService.isQuicksy() + || QuickConversationsService.isPlayStoreFlavor()) + && !"agreed".equals(consent); + if (requiresConsent && "declined".equals(consent)) { + Log.d(Config.LOGTAG,"not asking for contacts permission because consent has been declined"); + return; + } + if (requiresConsent + || shouldShowRequestPermissionRationale( + Manifest.permission.READ_CONTACTS)) { final AlertDialog.Builder builder = new AlertDialog.Builder(this); final AtomicBoolean requestPermission = new AtomicBoolean(false); - builder.setTitle(R.string.sync_with_contacts); if (QuickConversationsService.isQuicksy()) { - builder.setMessage(Html.fromHtml(getString(R.string.sync_with_contacts_quicksy))); + builder.setTitle(R.string.quicksy_wants_your_consent); + builder.setMessage( + Html.fromHtml( + getString(R.string.sync_with_contacts_quicksy_static))); } else { - builder.setMessage(getString(R.string.sync_with_contacts_long, getString(R.string.app_name))); + builder.setTitle(R.string.sync_with_contacts); + builder.setMessage( + getString( + R.string.sync_with_contacts_long, + getString(R.string.app_name))); } @StringRes int confirmButtonText; - if (QuickConversationsService.isConversations()) { - confirmButtonText = R.string.next; + if (requiresConsent) { + confirmButtonText = R.string.agree_and_continue; } else { - confirmButtonText = R.string.confirm; + confirmButtonText = R.string.next; } - builder.setPositiveButton(confirmButtonText, (dialog, which) -> { - if (requestPermission.compareAndSet(false, true)) { - requestPermissions(new String[]{Manifest.permission.READ_CONTACTS}, REQUEST_SYNC_CONTACTS); - } - }); - builder.setOnDismissListener(dialog -> { - if (QuickConversationsService.isConversations() && requestPermission.compareAndSet(false, true)) { - requestPermissions(new String[]{Manifest.permission.READ_CONTACTS}, REQUEST_SYNC_CONTACTS); - - } - }); - builder.setCancelable(QuickConversationsService.isQuicksy()); + builder.setPositiveButton( + confirmButtonText, + (dialog, which) -> { + if (requiresConsent) { + PreferenceManager.getDefaultSharedPreferences( + getApplicationContext()) + .edit() + .putString( + PREF_KEY_CONTACT_INTEGRATION_CONSENT, "agreed") + .apply(); + } + if (requestPermission.compareAndSet(false, true)) { + requestPermissions( + new String[] {Manifest.permission.READ_CONTACTS}, + REQUEST_SYNC_CONTACTS); + } + }); + if (requiresConsent) { + builder.setNegativeButton(R.string.decline, (dialog, which) -> PreferenceManager.getDefaultSharedPreferences( + getApplicationContext()) + .edit() + .putString( + PREF_KEY_CONTACT_INTEGRATION_CONSENT, "declined") + .apply()); + } else { + builder.setOnDismissListener( + dialog -> { + if (requestPermission.compareAndSet(false, true)) { + requestPermissions( + new String[] { + Manifest.permission.READ_CONTACTS + }, + REQUEST_SYNC_CONTACTS); + } + }); + } + builder.setCancelable(requiresConsent); final AlertDialog dialog = builder.create(); - dialog.setCanceledOnTouchOutside(QuickConversationsService.isQuicksy()); - dialog.setOnShowListener(dialogInterface -> { - final TextView tv = dialog.findViewById(android.R.id.message); - if (tv != null) { - tv.setMovementMethod(LinkMovementMethod.getInstance()); - } - }); + dialog.setCanceledOnTouchOutside(requiresConsent); + dialog.setOnShowListener( + dialogInterface -> { + final TextView tv = dialog.findViewById(android.R.id.message); + if (tv != null) { + tv.setMovementMethod(LinkMovementMethod.getInstance()); + } + }); dialog.show(); } else { - requestPermissions(new String[]{Manifest.permission.READ_CONTACTS}, REQUEST_SYNC_CONTACTS); + requestPermissions( + new String[] {Manifest.permission.READ_CONTACTS}, + REQUEST_SYNC_CONTACTS); } } } @@ -798,6 +863,7 @@ public class StartConversationActivity extends XmppActivity implements XmppConne @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (grantResults.length > 0) if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { ScanActivity.onRequestPermissionResult(this, requestCode, grantResults); @@ -824,8 +890,10 @@ public class StartConversationActivity extends XmppActivity implements XmppConne @Override protected void onBackendConnected() { - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || checkSelfPermission(Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) { + if (QuickConversationsService.isContactListIntegration(this) + && (Build.VERSION.SDK_INT < Build.VERSION_CODES.M + || checkSelfPermission(Manifest.permission.READ_CONTACTS) + == PackageManager.PERMISSION_GRANTED)) { xmppConnectionService.getQuickConversationsService().considerSyncBackground(false); } if (mPostponedActivityResult != null) { @@ -961,8 +1029,8 @@ public class StartConversationActivity extends XmppActivity implements XmppConne protected void filterContacts(String needle) { this.contacts.clear(); final List accounts = xmppConnectionService.getAccounts(); - for (Account account : accounts) { - if (account.getStatus() != Account.State.DISABLED) { + for (final Account account : accounts) { + if (account.isEnabled()) { for (Contact contact : account.getRoster().getContacts()) { Presence.Status s = contact.getShownStatus(); if (contact.showInContactList() && contact.match(this, needle) @@ -981,7 +1049,7 @@ public class StartConversationActivity extends XmppActivity implements XmppConne protected void filterConferences(String needle) { this.conferences.clear(); for (final Account account : xmppConnectionService.getAccounts()) { - if (account.getStatus() != Account.State.DISABLED) { + if (account.isEnabled()) { for (final Bookmark bookmark : account.getBookmarks()) { if (bookmark.match(this, needle)) { this.conferences.add(bookmark); @@ -1297,7 +1365,7 @@ public class StartConversationActivity extends XmppActivity implements XmppConne case 0: return getResources().getString(R.string.contacts); case 1: - return getResources().getString(R.string.bookmarks); + return getResources().getString(R.string.group_chats); default: return super.getPageTitle(position); } diff --git a/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java b/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java index 1e0cf41d3..de284264d 100644 --- a/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java @@ -18,13 +18,6 @@ import androidx.databinding.DataBindingUtil; import com.google.common.base.Strings; -import org.jetbrains.annotations.NotNull; - -import java.io.IOException; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.databinding.ActivityUriHandlerBinding; @@ -35,12 +28,20 @@ import eu.siacs.conversations.utils.ProvisioningUtils; import eu.siacs.conversations.utils.SignupUtils; import eu.siacs.conversations.utils.XmppUri; import eu.siacs.conversations.xmpp.Jid; + import okhttp3.Call; import okhttp3.Callback; import okhttp3.HttpUrl; import okhttp3.Request; import okhttp3.Response; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + public class UriHandlerActivity extends AppCompatActivity { public static final String ACTION_SCAN_QR_CODE = "scan_qr_code"; @@ -58,7 +59,9 @@ public class UriHandlerActivity extends AppCompatActivity { } public static void scan(final Activity activity, final boolean provisioning) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M + || ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED) { final Intent intent = new Intent(activity, UriHandlerActivity.class); intent.setAction(UriHandlerActivity.ACTION_SCAN_QR_CODE); if (provisioning) { @@ -68,14 +71,17 @@ public class UriHandlerActivity extends AppCompatActivity { activity.startActivity(intent); } else { activity.requestPermissions( - new String[]{Manifest.permission.CAMERA}, - provisioning ? REQUEST_CAMERA_PERMISSIONS_TO_SCAN_AND_PROVISION : REQUEST_CAMERA_PERMISSIONS_TO_SCAN - ); + new String[] {Manifest.permission.CAMERA}, + provisioning + ? REQUEST_CAMERA_PERMISSIONS_TO_SCAN_AND_PROVISION + : REQUEST_CAMERA_PERMISSIONS_TO_SCAN); } } - public static void onRequestPermissionResult(Activity activity, int requestCode, int[] grantResults) { - if (requestCode != REQUEST_CAMERA_PERMISSIONS_TO_SCAN && requestCode != REQUEST_CAMERA_PERMISSIONS_TO_SCAN_AND_PROVISION) { + public static void onRequestPermissionResult( + Activity activity, int requestCode, int[] grantResults) { + if (requestCode != REQUEST_CAMERA_PERMISSIONS_TO_SCAN + && requestCode != REQUEST_CAMERA_PERMISSIONS_TO_SCAN_AND_PROVISION) { return; } if (grantResults.length > 0) { @@ -86,7 +92,11 @@ public class UriHandlerActivity extends AppCompatActivity { scan(activity); } } else { - Toast.makeText(activity, R.string.qr_code_scanner_needs_access_to_camera, Toast.LENGTH_SHORT).show(); + Toast.makeText( + activity, + R.string.qr_code_scanner_needs_access_to_camera, + Toast.LENGTH_SHORT) + .show(); } } } @@ -116,7 +126,7 @@ public class UriHandlerActivity extends AppCompatActivity { private boolean handleUri(final Uri uri, final boolean scanned) { final Intent intent; final XmppUri xmppUri = new XmppUri(uri); - final List accounts = DatabaseBackend.getInstance(this).getAccountJids(true); + final List accounts = DatabaseBackend.getInstance(this).getAccountJids(false); if (SignupUtils.isSupportTokenRegistry() && xmppUri.isValidJid()) { final String preAuth = xmppUri.getParameter(XmppUri.PARAMETER_PRE_AUTH); @@ -130,7 +140,12 @@ public class UriHandlerActivity extends AppCompatActivity { startActivity(intent); return true; } - if (accounts.size() == 0 && xmppUri.isAction(XmppUri.ACTION_ROSTER) && "y".equals(xmppUri.getParameter(XmppUri.PARAMETER_IBR))) { + if (accounts.size() == 0 + && xmppUri.isAction(XmppUri.ACTION_ROSTER) + && "y" + .equalsIgnoreCase( + Strings.nullToEmpty(xmppUri.getParameter(XmppUri.PARAMETER_IBR)) + .trim())) { intent = SignupUtils.getTokenRegistrationIntent(this, jid.getDomain(), preAuth); intent.putExtra(StartConversationActivity.EXTRA_INVITE_URI, xmppUri.toString()); startActivity(intent); @@ -197,29 +212,28 @@ public class UriHandlerActivity extends AppCompatActivity { private void checkForLinkHeader(final HttpUrl url) { Log.d(Config.LOGTAG, "checking for link header on " + url); - this.call = HttpConnectionManager.OK_HTTP_CLIENT.newCall(new Request.Builder() - .url(url) - .head() - .build()); - this.call.enqueue(new Callback() { - @Override - public void onFailure(@NotNull Call call, @NotNull IOException e) { - Log.d(Config.LOGTAG, "unable to check HTTP url", e); - showError(R.string.no_xmpp_adddress_found); - } - - @Override - public void onResponse(@NotNull Call call, @NotNull Response response) { - if (response.isSuccessful()) { - final String linkHeader = response.header("Link"); - if (linkHeader != null && processLinkHeader(linkHeader)) { - return; + this.call = + HttpConnectionManager.OK_HTTP_CLIENT.newCall( + new Request.Builder().url(url).head().build()); + this.call.enqueue( + new Callback() { + @Override + public void onFailure(@NotNull Call call, @NotNull IOException e) { + Log.d(Config.LOGTAG, "unable to check HTTP url", e); + showError(R.string.no_xmpp_adddress_found); } - } - showError(R.string.no_xmpp_adddress_found); - } - }); + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) { + if (response.isSuccessful()) { + final String linkHeader = response.header("Link"); + if (linkHeader != null && processLinkHeader(linkHeader)) { + return; + } + } + showError(R.string.no_xmpp_adddress_found); + } + }); } private boolean processLinkHeader(final String header) { @@ -256,7 +270,8 @@ public class UriHandlerActivity extends AppCompatActivity { } switch (action) { case Intent.ACTION_MAIN: - binding.progress.setVisibility(call != null && !call.isCanceled() ? View.VISIBLE : View.INVISIBLE); + binding.progress.setVisibility( + call != null && !call.isCanceled() ? View.VISIBLE : View.INVISIBLE); break; case Intent.ACTION_VIEW: case Intent.ACTION_SENDTO: @@ -280,7 +295,8 @@ public class UriHandlerActivity extends AppCompatActivity { private boolean allowProvisioning() { final Intent launchIntent = getIntent(); - return launchIntent != null && launchIntent.getBooleanExtra(EXTRA_ALLOW_PROVISIONING, false); + return launchIntent != null + && launchIntent.getBooleanExtra(EXTRA_ALLOW_PROVISIONING, false); } @Override @@ -303,13 +319,17 @@ public class UriHandlerActivity extends AppCompatActivity { showError(R.string.no_xmpp_adddress_found); } return; - } else if (QuickConversationsService.isConversations() && looksLikeJsonObject(result) && allowProvisioning) { + } else if (QuickConversationsService.isConversations() + && looksLikeJsonObject(result) + && allowProvisioning) { ProvisioningUtils.provision(this, result); finish(); return; } final Uri uri = Uri.parse(result.trim()); - if (allowProvisioning && "https".equalsIgnoreCase(uri.getScheme()) && !XmppUri.INVITE_DOMAIN.equalsIgnoreCase(uri.getHost())) { + if (allowProvisioning + && "https".equalsIgnoreCase(uri.getScheme()) + && !XmppUri.INVITE_DOMAIN.equalsIgnoreCase(uri.getHost())) { final HttpUrl httpUrl = HttpUrl.parse(uri.toString()); if (httpUrl != null) { checkForLinkHeader(httpUrl); diff --git a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java index 644bd7ec5..9190697a8 100644 --- a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java @@ -41,12 +41,13 @@ import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.widget.Button; +import android.widget.CheckBox; import android.widget.ImageView; import android.widget.Toast; import androidx.annotation.BoolRes; import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog.Builder; @@ -55,12 +56,7 @@ import androidx.databinding.DataBindingUtil; import com.google.common.base.Strings; -import java.io.IOException; -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.RejectedExecutionException; - +import eu.siacs.conversations.BuildConfig; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.PgpEngine; @@ -78,16 +74,23 @@ import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.services.XmppConnectionService.XmppConnectionBinder; import eu.siacs.conversations.ui.util.MenuDoubleTabUtil; import eu.siacs.conversations.ui.util.PresenceSelector; +import eu.siacs.conversations.ui.util.SettingsUtils; import eu.siacs.conversations.ui.util.SoftKeyboardUtils; import eu.siacs.conversations.utils.AccountUtils; import eu.siacs.conversations.utils.Compatibility; import eu.siacs.conversations.utils.ExceptionHelper; -import eu.siacs.conversations.ui.util.SettingsUtils; +import eu.siacs.conversations.utils.SignupUtils; import eu.siacs.conversations.utils.ThemeHelper; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.OnKeyStatusUpdated; import eu.siacs.conversations.xmpp.OnUpdateBlocklist; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.RejectedExecutionException; + public abstract class XmppActivity extends ActionBarActivity { public static final String EXTRA_ACCOUNT = "account"; @@ -95,6 +98,7 @@ public abstract class XmppActivity extends ActionBarActivity { protected static final int REQUEST_INVITE_TO_CONVERSATION = 0x0102; protected static final int REQUEST_CHOOSE_PGP_ID = 0x0103; protected static final int REQUEST_BATTERY_OP = 0x49ff; + protected static final int REQUEST_POST_NOTIFICATION = 0x50ff; public XmppConnectionService xmppConnectionService; public boolean xmppConnectionServiceBound = false; @@ -289,6 +293,68 @@ public abstract class XmppActivity extends ActionBarActivity { builder.create().show(); } + protected void deleteAccount(final Account account) { + this.deleteAccount(account, null); + } + + protected void deleteAccount(final Account account, final Runnable postDelete) { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + final View dialogView = getLayoutInflater().inflate(R.layout.dialog_delete_account, null); + final CheckBox deleteFromServer = + dialogView.findViewById(R.id.delete_from_server); + builder.setView(dialogView); + builder.setTitle(R.string.mgmt_account_delete); + builder.setPositiveButton(getString(R.string.delete),null); + builder.setNegativeButton(getString(R.string.cancel), null); + final AlertDialog dialog = builder.create(); + dialog.setOnShowListener(dialogInterface->{ + final Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + button.setOnClickListener(v -> { + final boolean unregister = deleteFromServer.isChecked(); + if (unregister) { + if (account.isOnlineAndConnected()) { + deleteFromServer.setEnabled(false); + button.setText(R.string.please_wait); + button.setEnabled(false); + xmppConnectionService.unregisterAccount(account, result -> { + runOnUiThread(()->{ + if (result) { + dialog.dismiss(); + if (postDelete != null) { + postDelete.run(); + } + if (xmppConnectionService.getAccounts().size() == 0 && Config.MAGIC_CREATE_DOMAIN != null) { + final Intent intent = SignupUtils.getSignUpIntent(this); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + } + } else { + deleteFromServer.setEnabled(true); + button.setText(R.string.delete); + button.setEnabled(true); + Toast.makeText(this,R.string.could_not_delete_account_from_server,Toast.LENGTH_LONG).show(); + } + }); + }); + } else { + Toast.makeText(this,R.string.not_connected_try_again,Toast.LENGTH_LONG).show(); + } + } else { + xmppConnectionService.deleteAccount(account); + dialog.dismiss(); + if (xmppConnectionService.getAccounts().size() == 0 && Config.MAGIC_CREATE_DOMAIN != null) { + final Intent intent = SignupUtils.getSignUpIntent(this); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + } else if (postDelete != null) { + postDelete.run(); + } + } + }); + }); + dialog.show(); + } + abstract void onBackendConnected(); protected void registerListeners() { @@ -357,6 +423,9 @@ public abstract class XmppActivity extends ActionBarActivity { case R.id.action_settings: startActivity(new Intent(this, SettingsActivity.class)); break; + case R.id.action_privacy_policy: + openPrivacyPolicy(); + break; case R.id.action_accounts: AccountUtils.launchManageAccounts(this); break; @@ -373,6 +442,20 @@ public abstract class XmppActivity extends ActionBarActivity { return super.onOptionsItemSelected(item); } + private void openPrivacyPolicy() { + if (BuildConfig.PRIVACY_POLICY == null) { + return; + } + final var viewPolicyIntent = new Intent(Intent.ACTION_VIEW); + viewPolicyIntent.setData(Uri.parse(BuildConfig.PRIVACY_POLICY)); + try { + startActivity(viewPolicyIntent); + } catch (final ActivityNotFoundException e) { + Toast.makeText(this, R.string.no_application_found_to_open_link, Toast.LENGTH_SHORT) + .show(); + } + } + public void selectPresence(final Conversation conversation, final PresenceSelector.OnPresenceSelected listener) { final Contact contact = conversation.getContact(); if (contact.showInRoster() || contact.isSelf()) { @@ -578,9 +661,9 @@ public abstract class XmppActivity extends ActionBarActivity { xmppConnectionService.getPgpEngine().generateSignature(intent, account, status, new UiCallback() { @Override - public void userInputRequired(PendingIntent pi, String signature) { + public void userInputRequired(final PendingIntent pi, final String signature) { try { - startIntentSenderForResult(pi.getIntentSender(), REQUEST_ANNOUNCE_PGP, null, 0, 0, 0); + startIntentSenderForResult(pi.getIntentSender(), REQUEST_ANNOUNCE_PGP, null, 0, 0, 0,Compatibility.pgpStartIntentSenderOptions()); } catch (final SendIntentException ignored) { } } @@ -641,7 +724,7 @@ public abstract class XmppActivity extends ActionBarActivity { public void userInputRequired(PendingIntent pi, Account object) { try { startIntentSenderForResult(pi.getIntentSender(), - REQUEST_CHOOSE_PGP_ID, null, 0, 0, 0); + REQUEST_CHOOSE_PGP_ID, null, 0, 0, 0, Compatibility.pgpStartIntentSenderOptions()); } catch (final SendIntentException ignored) { } } @@ -746,7 +829,7 @@ public abstract class XmppActivity extends ActionBarActivity { } protected boolean hasStoragePermission(int requestCode) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode); return false; @@ -815,8 +898,9 @@ public abstract class XmppActivity extends ActionBarActivity { try { startIntentSenderForResult( pgp.getIntentForKey(keyId).getIntentSender(), 0, null, 0, - 0, 0); - } catch (Throwable e) { + 0, 0, Compatibility.pgpStartIntentSenderOptions()); + } catch (final Throwable e) { + Log.d(Config.LOGTAG,"could not launch OpenKeyChain", e); Toast.makeText(XmppActivity.this, R.string.openpgp_error, Toast.LENGTH_SHORT).show(); } } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java index b070a63bb..038f255ea 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java @@ -59,6 +59,7 @@ public class AccountAdapter extends ArrayAdapter { viewHolder.binding.accountStatus.setTextColor(StyledAttributes.getColor(activity, R.attr.TextColorOnline)); break; case DISABLED: + case LOGGED_OUT: case CONNECTING: viewHolder.binding.accountStatus.setTextColor(StyledAttributes.getColor(activity, android.R.attr.textColorSecondary)); break; diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/MediaAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/MediaAdapter.java index 2733e7b8b..2683876c7 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/MediaAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/MediaAdapter.java @@ -74,6 +74,8 @@ public class MediaAdapter extends RecyclerView.Adapter { final long duration = rtpSessionStatus.duration; if (received) { if (duration > 0) { - viewHolder.status_message.setText(activity.getString(R.string.incoming_call_duration, TimeFrameUtils.resolve(activity, duration))); + viewHolder.status_message.setText(activity.getString(R.string.incoming_call_duration_timestamp, TimeFrameUtils.resolve(activity, duration), UIHelper.readableTimeDifferenceFull(activity, message.getTimeSent()))); } else if (rtpSessionStatus.successful) { viewHolder.status_message.setText(R.string.incoming_call); } else { @@ -704,9 +705,9 @@ public class MessageAdapter extends ArrayAdapter { } } else { if (duration > 0) { - viewHolder.status_message.setText(activity.getString(R.string.outgoing_call_duration, TimeFrameUtils.resolve(activity, duration))); + viewHolder.status_message.setText(activity.getString(R.string.outgoing_call_duration_timestamp, TimeFrameUtils.resolve(activity, duration), UIHelper.readableTimeDifferenceFull(activity, message.getTimeSent()))); } else { - viewHolder.status_message.setText(R.string.outgoing_call); + viewHolder.status_message.setText(activity.getString(R.string.outgoing_call_timestamp, UIHelper.readableTimeDifferenceFull(activity, message.getTimeSent()))); } } viewHolder.indicatorReceived.setImageResource(RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful, isDarkTheme)); @@ -873,7 +874,7 @@ public class MessageAdapter extends ArrayAdapter { } public void openDownloadable(Message message) { - if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ConversationFragment.registerPendingMessage(activity, message); ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, ConversationsActivity.REQUEST_OPEN_MESSAGE); return; diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java index 41bfb24a1..de3216908 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java @@ -25,6 +25,7 @@ import eu.siacs.conversations.ui.ConferenceDetailsActivity; import eu.siacs.conversations.ui.XmppActivity; import eu.siacs.conversations.ui.util.AvatarWorkerTask; import eu.siacs.conversations.ui.util.MucDetailsContextMenuHelper; +import eu.siacs.conversations.utils.Compatibility; import eu.siacs.conversations.xmpp.Jid; public class UserAdapter extends ListAdapter implements View.OnCreateContextMenuListener { @@ -104,7 +105,7 @@ public class UserAdapter extends ListAdapter(playPause)); ActivityCompat.requestPermissions(messageAdapter.getActivity(), new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, ConversationsActivity.REQUEST_PLAY_PAUSE); return; diff --git a/src/main/java/eu/siacs/conversations/ui/text/FixedURLSpan.java b/src/main/java/eu/siacs/conversations/ui/text/FixedURLSpan.java index 684fd8094..a5fba62a7 100644 --- a/src/main/java/eu/siacs/conversations/ui/text/FixedURLSpan.java +++ b/src/main/java/eu/siacs/conversations/ui/text/FixedURLSpan.java @@ -75,9 +75,7 @@ public class FixedURLSpan extends URLSpan { } } final Intent intent = new Intent(Intent.ACTION_VIEW, uri); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); - } + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); //intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()); try { context.startActivity(intent); diff --git a/src/main/java/eu/siacs/conversations/ui/widget/SwipeRefreshListFragment.java b/src/main/java/eu/siacs/conversations/ui/widget/SwipeRefreshListFragment.java index b1ef6165a..5f5d6a8cf 100644 --- a/src/main/java/eu/siacs/conversations/ui/widget/SwipeRefreshListFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/widget/SwipeRefreshListFragment.java @@ -26,7 +26,6 @@ import android.widget.ListView; import androidx.fragment.app.ListFragment; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import eu.siacs.conversations.R; import eu.siacs.conversations.ui.util.StyledAttributes; /** @@ -57,7 +56,7 @@ public class SwipeRefreshListFragment extends ListFragment { final Context context = getActivity(); if (context != null) { - mSwipeRefreshLayout.setColorSchemeColors(StyledAttributes.getColor(context, R.attr.colorAccent)); + mSwipeRefreshLayout.setColorSchemeColors(StyledAttributes.getColor(context, androidx.appcompat.R.attr.colorAccent)); } if (onRefreshListener != null) { diff --git a/src/main/java/eu/siacs/conversations/utils/AccountUtils.java b/src/main/java/eu/siacs/conversations/utils/AccountUtils.java index b8f4855d0..7b7397123 100644 --- a/src/main/java/eu/siacs/conversations/utils/AccountUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/AccountUtils.java @@ -6,6 +6,10 @@ import android.view.Menu; import android.view.MenuItem; import android.widget.Toast; +import com.google.common.primitives.Bytes; +import com.google.common.primitives.Longs; + +import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -24,10 +28,9 @@ public class AccountUtils { MANAGE_ACCOUNT_ACTIVITY = getManageAccountActivityClass(); } - public static boolean hasEnabledAccounts(final XmppConnectionService service) { final List accounts = service.getAccounts(); - for(Account account : accounts) { + for (Account account : accounts) { if (account.isOptionSet(Account.OPTION_DISABLED)) { return false; } @@ -42,20 +45,22 @@ public class AccountUtils { } catch (final IllegalArgumentException e) { return account.getUuid(); } - final UUID publicDeviceId = getUuid(uuid.getLeastSignificantBits(), uuid.getLeastSignificantBits()); - return publicDeviceId.toString(); - } - - protected static UUID getUuid(final long msb, final long lsb) { - final long msb0 = (msb & 0xffffffffffff0fffL) | 4; // set version - final long lsb0 = (lsb & 0x3fffffffffffffffL) | 0x8000000000000000L; // set variant - return new UUID(msb0, lsb0); + final byte[] bytes = + Bytes.concat( + Longs.toByteArray(uuid.getLeastSignificantBits()), + Longs.toByteArray(uuid.getLeastSignificantBits())); + bytes[6] &= 0x0f; /* clear version */ + bytes[6] |= 0x40; /* set to version 4 */ + bytes[8] &= 0x3f; /* clear variant */ + bytes[8] |= 0x80; /* set to IETF variant */ + final ByteBuffer byteBuffer = ByteBuffer.wrap(bytes); + return new UUID(byteBuffer.getLong(), byteBuffer.getLong()).toString(); } public static List getEnabledAccounts(final XmppConnectionService service) { - ArrayList accounts = new ArrayList<>(); - for (Account account : service.getAccounts()) { - if (account.getStatus() != Account.State.DISABLED) { + final ArrayList accounts = new ArrayList<>(); + for (final Account account : service.getAccounts()) { + if (account.isEnabled()) { if (Config.DOMAIN_LOCK != null) { accounts.add(account.getJid().getEscapedLocal()); } else { @@ -68,7 +73,7 @@ public class AccountUtils { public static Account getFirstEnabled(XmppConnectionService service) { final List accounts = service.getAccounts(); - for(Account account : accounts) { + for (Account account : accounts) { if (!account.isOptionSet(Account.OPTION_DISABLED)) { return account; } @@ -78,7 +83,7 @@ public class AccountUtils { public static Account getFirst(XmppConnectionService service) { final List accounts = service.getAccounts(); - for(Account account : accounts) { + for (Account account : accounts) { return account; } return null; diff --git a/src/main/java/eu/siacs/conversations/utils/BackupFileHeader.java b/src/main/java/eu/siacs/conversations/utils/BackupFileHeader.java index 54387a8ee..3b536c27a 100644 --- a/src/main/java/eu/siacs/conversations/utils/BackupFileHeader.java +++ b/src/main/java/eu/siacs/conversations/utils/BackupFileHeader.java @@ -1,5 +1,7 @@ package eu.siacs.conversations.utils; +import androidx.annotation.NonNull; + import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; @@ -8,7 +10,7 @@ import eu.siacs.conversations.xmpp.Jid; public class BackupFileHeader { - private static final int VERSION = 1; + private static final int VERSION = 2; private final String app; private final Jid jid; @@ -17,6 +19,7 @@ public class BackupFileHeader { private final byte[] salt; + @NonNull @Override public String toString() { return "BackupFileHeader{" + @@ -47,17 +50,19 @@ public class BackupFileHeader { public static BackupFileHeader read(DataInputStream inputStream) throws IOException { final int version = inputStream.readInt(); - if (version > VERSION) { - throw new IllegalArgumentException("Backup File version was " + version + " but app only supports up to version " + VERSION); - } - String app = inputStream.readUTF(); - String jid = inputStream.readUTF(); + final String app = inputStream.readUTF(); + final String jid = inputStream.readUTF(); long timestamp = inputStream.readLong(); - byte[] iv = new byte[12]; + final byte[] iv = new byte[12]; inputStream.readFully(iv); - byte[] salt = new byte[16]; + final byte[] salt = new byte[16]; inputStream.readFully(salt); - + if (version < VERSION) { + throw new OutdatedBackupFileVersion(); + } + if (version != VERSION) { + throw new IllegalArgumentException("Backup File version was " + version + " but app only supports version " + VERSION); + } return new BackupFileHeader(app, Jid.of(jid), timestamp, iv, salt); } @@ -81,4 +86,8 @@ public class BackupFileHeader { public long getTimestamp() { return timestamp; } + + public static class OutdatedBackupFileVersion extends RuntimeException { + + } } diff --git a/src/main/java/eu/siacs/conversations/utils/Checksum.java b/src/main/java/eu/siacs/conversations/utils/Checksum.java deleted file mode 100644 index 407cb1944..000000000 --- a/src/main/java/eu/siacs/conversations/utils/Checksum.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2018, Daniel Gultsch All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, - * are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation and/or - * other materials provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors - * may be used to endorse or promote products derived from this software without - * specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package eu.siacs.conversations.utils; - -import android.util.Base64; - -import java.io.IOException; -import java.io.InputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -public class Checksum { - - public static String md5(InputStream inputStream) throws IOException { - byte[] buffer = new byte[4096]; - MessageDigest messageDigest; - try { - messageDigest = MessageDigest.getInstance("MD5"); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - - int count; - do { - count = inputStream.read(buffer); - if (count > 0) { - messageDigest.update(buffer, 0, count); - } - } while (count != -1); - inputStream.close(); - return Base64.encodeToString(messageDigest.digest(), Base64.NO_WRAP); - } -} diff --git a/src/main/java/eu/siacs/conversations/utils/Compatibility.java b/src/main/java/eu/siacs/conversations/utils/Compatibility.java index b1145794d..23c935a2b 100644 --- a/src/main/java/eu/siacs/conversations/utils/Compatibility.java +++ b/src/main/java/eu/siacs/conversations/utils/Compatibility.java @@ -3,6 +3,7 @@ package eu.siacs.conversations.utils; import static eu.siacs.conversations.services.EventReceiver.EXTRA_NEEDS_FOREGROUND_SERVICE; import android.annotation.SuppressLint; +import android.app.ActivityOptions; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; @@ -10,6 +11,7 @@ import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.net.ConnectivityManager; import android.os.Build; +import android.os.Bundle; import android.preference.Preference; import android.preference.PreferenceCategory; import android.preference.PreferenceManager; @@ -20,15 +22,15 @@ import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.core.content.ContextCompat; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.ui.SettingsActivity; import eu.siacs.conversations.ui.SettingsFragment; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + public class Compatibility { private static final List UNUSED_SETTINGS_POST_TWENTYSIX = @@ -40,8 +42,9 @@ public class Compatibility { private static final List UNUSED_SETTINGS_PRE_TWENTYSIX = Collections.singletonList("message_notification_settings"); - public static boolean hasStoragePermission(Context context) { + public static boolean hasStoragePermission(final Context context) { return Build.VERSION.SDK_INT < Build.VERSION_CODES.M + || Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission( context, android.Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; @@ -177,4 +180,15 @@ public class Compatibility { return ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED; } } + + public static Bundle pgpStartIntentSenderOptions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return ActivityOptions.makeBasic() + .setPendingIntentBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED) + .toBundle(); + } else { + return null; + } + } } diff --git a/src/main/java/eu/siacs/conversations/utils/Emoticons.java b/src/main/java/eu/siacs/conversations/utils/Emoticons.java index 266d9d081..8f2001ce3 100644 --- a/src/main/java/eu/siacs/conversations/utils/Emoticons.java +++ b/src/main/java/eu/siacs/conversations/utils/Emoticons.java @@ -43,8 +43,8 @@ public class Emoticons { private static final UnicodeRange MISC_SYMBOLS_AND_PICTOGRAPHS = new UnicodeRange(0x1F300, 0x1F5FF); private static final UnicodeRange SUPPLEMENTAL_SYMBOLS = new UnicodeRange(0x1F900, 0x1F9FF); - private static final UnicodeRange EMOTICONS = new UnicodeRange(0x1F600, 0x1F64F); - private static final UnicodeRange TRANSPORT_SYMBOLS = new UnicodeRange(0x1F680, 0x1F6FF); + private static final UnicodeRange EMOTICONS = new UnicodeRange(0x1F600, 0x1FAF6); + //private static final UnicodeRange TRANSPORT_SYMBOLS = new UnicodeRange(0x1F680, 0x1F6FF); private static final UnicodeRange MISC_SYMBOLS = new UnicodeRange(0x2600, 0x26FF); private static final UnicodeRange DINGBATS = new UnicodeRange(0x2700, 0x27BF); private static final UnicodeRange ENCLOSED_ALPHANUMERIC_SUPPLEMENT = new UnicodeRange(0x1F100, 0x1F1FF); @@ -69,7 +69,7 @@ public class Emoticons { MISC_SYMBOLS_AND_PICTOGRAPHS, SUPPLEMENTAL_SYMBOLS, EMOTICONS, - TRANSPORT_SYMBOLS, + //TRANSPORT_SYMBOLS, MISC_SYMBOLS, DINGBATS, ENCLOSED_ALPHANUMERIC_SUPPLEMENT, diff --git a/src/main/java/eu/siacs/conversations/utils/FileUtils.java b/src/main/java/eu/siacs/conversations/utils/FileUtils.java index 43e3da118..e439ab030 100644 --- a/src/main/java/eu/siacs/conversations/utils/FileUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/FileUtils.java @@ -34,10 +34,8 @@ public class FileUtils { return null; } - final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; - // DocumentProvider - if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { + if (DocumentsContract.isDocumentUri(context, uri)) { // ExternalStorageProvider if (isExternalStorageDocument(uri)) { final String docId = DocumentsContract.getDocumentId(uri); diff --git a/src/main/java/eu/siacs/conversations/utils/IP.java b/src/main/java/eu/siacs/conversations/utils/IP.java index a7182e207..948f7537a 100644 --- a/src/main/java/eu/siacs/conversations/utils/IP.java +++ b/src/main/java/eu/siacs/conversations/utils/IP.java @@ -1,5 +1,7 @@ package eu.siacs.conversations.utils; +import com.google.common.net.InetAddresses; + import java.util.regex.Pattern; public class IP { @@ -27,4 +29,14 @@ public class IP { } } + public static String unwrapIPv6(final String host) { + if (host.length() > 2 && host.charAt(0) == '[' && host.charAt(host.length() - 1) == ']') { + final String ip = host.substring(1,host.length() -1); + if (InetAddresses.isInetAddress(ip)) { + return ip; + } + } + return host; + } + } diff --git a/src/main/java/eu/siacs/conversations/utils/IrregularUnicodeDetector.java b/src/main/java/eu/siacs/conversations/utils/IrregularUnicodeDetector.java index d3aade355..04573ef4c 100644 --- a/src/main/java/eu/siacs/conversations/utils/IrregularUnicodeDetector.java +++ b/src/main/java/eu/siacs/conversations/utils/IrregularUnicodeDetector.java @@ -60,7 +60,7 @@ public class IrregularUnicodeDetector { private static final Map NORMALIZATION_MAP; private static final LruCache CACHE = new LruCache<>(4096); - private static final List AMBIGUOUS_CYRILLIC = Arrays.asList("а","г","е","ѕ","і","q","о","р","с","у"); + private static final List AMBIGUOUS_CYRILLIC = Arrays.asList("а","г","е","ѕ","і","ј","ķ","ԛ","о","р","с","у","х"); static { Map temp = new HashMap<>(); diff --git a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java index d112a9224..adf3841d3 100644 --- a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java @@ -28,6 +28,8 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -66,6 +68,7 @@ public final class MimeUtils { // by guessExtensionFromMimeType. add("application/andrew-inset", "ez"); add("application/dsptype", "tsp"); + add("application/json", "json"); add("application/epub+zip", "epub"); add("application/gpx+xml", "gpx"); add("application/hta", "hta"); @@ -260,7 +263,8 @@ public final class MimeUtils { add("audio/mpeg", "mpga"); add("audio/mpeg", "mpega"); add("audio/mpeg", "mp2"); - add("audio/mpeg", "m4a"); + add("audio/mp4", "m4a"); + add("audio/x-m4b", "m4b"); add("audio/mpegurl", "m3u"); add("audio/ogg", "oga"); add("audio/opus", "opus"); @@ -413,6 +417,9 @@ public final class MimeUtils { applyOverrides(); } + // mime types that are more reliant by path + private static final Collection PATH_PRECEDENCE_MIME_TYPE = Arrays.asList("audio/x-m4b"); + private static void add(String mimeType, String extension) { // If we have an existing x -> y mapping, we do not want to // override it with another mapping x -> y2. @@ -537,44 +544,49 @@ public final class MimeUtils { } public static String guessMimeTypeFromUriAndMime(final Context context, final Uri uri, final String mime) { - Log.d(Config.LOGTAG, "guessMimeTypeFromUriAndMime " + uri + " and mime=" + mime); - if (mime == null || mime.equals("application/octet-stream")) { - final String guess = guessMimeTypeFromUri(context, uri); - if (guess != null) { - return guess; - } else { - return mime; - } + Log.d(Config.LOGTAG, "guessMimeTypeFromUriAndMime(" + uri + "," + mime+")"); + final String mimeFromUri = guessMimeTypeFromUri(context, uri); + Log.d(Config.LOGTAG,"mimeFromUri:"+mimeFromUri); + if (PATH_PRECEDENCE_MIME_TYPE.contains(mimeFromUri)) { + return mimeFromUri; + } else if (mime == null || mime.equals("application/octet-stream")) { + return mimeFromUri; + } else { + return mime; } - return guessMimeTypeFromUri(context, uri); } - public static String guessMimeTypeFromUri(Context context, Uri uri) { - // try the content resolver - String mimeType; + public static String guessMimeTypeFromUri(final Context context, final Uri uri) { + final String mimeTypeContentResolver = guessFromContentResolver(context, uri); + final String mimeTypeFromQueryParameter = uri.getQueryParameter("mimeType"); + final String name = "content".equals(uri.getScheme()) ? getDisplayName(context, uri) : null; + final String mimeTypeFromName = Strings.isNullOrEmpty(name) ? null : guessFromPath(name); + final String path = uri.getPath(); + final String mimeTypeFromPath = Strings.isNullOrEmpty(path) ? null : guessFromPath(path); + if (PATH_PRECEDENCE_MIME_TYPE.contains(mimeTypeFromName)) { + return mimeTypeFromName; + } + if (PATH_PRECEDENCE_MIME_TYPE.contains(mimeTypeFromPath)) { + return mimeTypeFromPath; + } + if (mimeTypeContentResolver != null && !"application/octet-stream".equals(mimeTypeContentResolver)) { + return mimeTypeContentResolver; + } + if (mimeTypeFromName != null) { + return mimeTypeFromName; + } + if (mimeTypeFromQueryParameter != null) { + return mimeTypeFromQueryParameter; + } + return mimeTypeFromPath; + } + + private static String guessFromContentResolver(final Context context, final Uri uri) { try { - mimeType = context.getContentResolver().getType(uri); - } catch (final Throwable throwable) { - mimeType = null; + return context.getContentResolver().getType(uri); + } catch (final Throwable e) { + return null; } - // try the extension - if (mimeType == null || mimeType.equals("application/octet-stream")) { - final String path = uri.getPath(); - if (path != null) { - mimeType = guessFromPath(path); - } - } - if (mimeType == null && "content".equals(uri.getScheme())) { - final String name = getDisplayName(context, uri); - if (name != null) { - mimeType = guessFromPath(name); - } - } - // sometimes this works (as with the commit content api) - if (mimeType == null) { - mimeType = uri.getQueryParameter("mimeType"); - } - return mimeType; } private static String getDisplayName(final Context context, final Uri uri) { diff --git a/src/main/java/eu/siacs/conversations/utils/PermissionUtils.java b/src/main/java/eu/siacs/conversations/utils/PermissionUtils.java index 004676156..21d7f42a1 100644 --- a/src/main/java/eu/siacs/conversations/utils/PermissionUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/PermissionUtils.java @@ -24,9 +24,23 @@ public class PermissionUtils { return true; } - public static boolean writeGranted(int[] grantResults, String[] permission) { + public static boolean writeGranted(final int[] grantResults, final String[] permissions) { + return permissionGranted( + Manifest.permission.WRITE_EXTERNAL_STORAGE, grantResults, permissions); + } + + public static boolean audioGranted(final int[] grantResults, final String[] permissions) { + return permissionGranted(Manifest.permission.RECORD_AUDIO, grantResults, permissions); + } + + public static boolean cameraGranted(final int[] grantResults, final String[] permissions) { + return permissionGranted(Manifest.permission.CAMERA, grantResults, permissions); + } + + private static boolean permissionGranted( + final String permission, final int[] grantResults, final String[] permissions) { for (int i = 0; i < grantResults.length; ++i) { - if (Manifest.permission.WRITE_EXTERNAL_STORAGE.equals(permission[i])) { + if (permission.equals(permissions[i])) { return grantResults[i] == PackageManager.PERMISSION_GRANTED; } } diff --git a/src/main/java/eu/siacs/conversations/utils/PhoneHelper.java b/src/main/java/eu/siacs/conversations/utils/PhoneHelper.java index 9ff492578..98924a262 100644 --- a/src/main/java/eu/siacs/conversations/utils/PhoneHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/PhoneHelper.java @@ -10,34 +10,37 @@ import android.os.Build; import android.provider.ContactsContract.Profile; import android.provider.Settings; +import com.google.common.base.Strings; + +import eu.siacs.conversations.services.QuickConversationsService; + public class PhoneHelper { @SuppressLint("HardwareIds") - public static String getAndroidId(Context context) { + public static String getAndroidId(final Context context) { return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); } - public static Uri getProfilePictureUri(Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - && context.checkSelfPermission(Manifest.permission.READ_CONTACTS) - != PackageManager.PERMISSION_GRANTED) { + public static Uri getProfilePictureUri(final Context context) { + if (!QuickConversationsService.isContactListIntegration(context) + || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && context.checkSelfPermission(Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED)) { return null; } final String[] projection = new String[] {Profile._ID, Profile.PHOTO_URI}; - final Cursor cursor; - try { - cursor = - context.getContentResolver() - .query(Profile.CONTENT_URI, projection, null, null, null); - } catch (Throwable e) { - return null; + try (final Cursor cursor = + context.getContentResolver() + .query(Profile.CONTENT_URI, projection, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + final var photoUri = cursor.getString(1); + if (Strings.isNullOrEmpty(photoUri)) { + return null; + } + return Uri.parse(photoUri); + } } - if (cursor == null) { - return null; - } - final String uri = cursor.moveToFirst() ? cursor.getString(1) : null; - cursor.close(); - return uri == null ? null : Uri.parse(uri); + return null; } public static boolean isEmulator() { diff --git a/src/main/java/eu/siacs/conversations/utils/Resolver.java b/src/main/java/eu/siacs/conversations/utils/Resolver.java index 463d6eb73..6746f3ea9 100644 --- a/src/main/java/eu/siacs/conversations/utils/Resolver.java +++ b/src/main/java/eu/siacs/conversations/utils/Resolver.java @@ -6,6 +6,11 @@ import android.util.Log; import androidx.annotation.NonNull; +import com.google.common.base.Strings; +import com.google.common.base.Throwables; +import com.google.common.net.InetAddresses; +import com.google.common.primitives.Ints; + import java.io.IOException; import java.lang.reflect.Field; import java.net.Inet4Address; @@ -15,6 +20,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import de.gultsch.minidns.AndroidDNSClient; import de.measite.minidns.AbstractDNSClient; import de.measite.minidns.DNSCache; import de.measite.minidns.DNSClient; @@ -112,7 +118,7 @@ public class Resolver { return port == 443 || port == 5223; } - public static List resolve(String domain) { + public static List resolve(final String domain) { final List ipResults = fromIpAddress(domain); if (ipResults.size() > 0) { return ipResults; @@ -126,8 +132,10 @@ public class Resolver { synchronized (results) { results.addAll(list); } - } catch (Throwable throwable) { - Log.d(Config.LOGTAG, Resolver.class.getSimpleName() + ": error resolving SRV record (direct TLS)", throwable); + } catch (final Throwable throwable) { + if (!(Throwables.getRootCause(throwable) instanceof InterruptedException)) { + Log.d(Config.LOGTAG, Resolver.class.getSimpleName() + ": error resolving SRV record (direct TLS)", throwable); + } } }); threads[1] = new Thread(() -> { @@ -136,8 +144,10 @@ public class Resolver { synchronized (results) { results.addAll(list); } - } catch (Throwable throwable) { - Log.d(Config.LOGTAG, Resolver.class.getSimpleName() + ": error resolving SRV record (STARTTLS)", throwable); + } catch (final Throwable throwable) { + if (!(Throwables.getRootCause(throwable) instanceof InterruptedException)) { + Log.d(Config.LOGTAG, Resolver.class.getSimpleName() + ": error resolving SRV record (STARTTLS)", throwable); + } } }); threads[2] = new Thread(() -> { @@ -156,15 +166,15 @@ public class Resolver { threads[2].interrupt(); synchronized (results) { Collections.sort(results); - Log.d(Config.LOGTAG, Resolver.class.getSimpleName() + ": " + results.toString()); - return new ArrayList<>(results); + Log.d(Config.LOGTAG, Resolver.class.getSimpleName() + ": " + results); + return results; } } else { threads[2].join(); synchronized (fallbackResults) { Collections.sort(fallbackResults); - Log.d(Config.LOGTAG, Resolver.class.getSimpleName() + ": " + fallbackResults.toString()); - return new ArrayList<>(fallbackResults); + Log.d(Config.LOGTAG, Resolver.class.getSimpleName() + ": " + fallbackResults); + return fallbackResults; } } } catch (InterruptedException e) { @@ -247,7 +257,7 @@ public class Resolver { } private static List resolveNoSrvRecords(DNSName dnsName, boolean withCnames) { - List results = new ArrayList<>(); + final List results = new ArrayList<>(); try { for (A a : resolveWithFallback(dnsName, A.class, false).getAnswersOrEmptySet()) { results.add(Result.createDefault(dnsName, a.getInetAddress())); @@ -260,8 +270,10 @@ public class Resolver { results.addAll(resolveNoSrvRecords(cname.name, false)); } } - } catch (Throwable throwable) { - Log.d(Config.LOGTAG, Resolver.class.getSimpleName() + "error resolving fallback records", throwable); + } catch (final Throwable throwable) { + if (!(Throwables.getRootCause(throwable) instanceof InterruptedException)) { + Log.d(Config.LOGTAG, Resolver.class.getSimpleName() + "error resolving fallback records", throwable); + } } results.add(Result.createDefault(dnsName)); return results; @@ -274,7 +286,9 @@ public class Resolver { private static ResolverResult resolveWithFallback(DNSName dnsName, Class type, boolean validateHostname) throws IOException { final Question question = new Question(dnsName, Record.TYPE.getType(type)); if (!validateHostname) { - return ResolverApi.INSTANCE.resolve(question); + final AndroidDNSClient androidDNSClient = new AndroidDNSClient(SERVICE); + final ResolverApi resolverApi = new ResolverApi(androidDNSClient); + return resolverApi.resolve(question); } try { return DnssecResolverApi.INSTANCE.resolveDnssecReliable(question); @@ -435,6 +449,65 @@ public class Resolver { contentValues.put(AUTHENTICATED, authenticated ? 1 : 0); return contentValues; } + + public Result seeOtherHost(final String seeOtherHost) { + final String hostname = seeOtherHost.trim(); + if (hostname.isEmpty()) { + return null; + } + final Result result = new Result(); + result.directTls = this.directTls; + final int portSegmentStart = hostname.lastIndexOf(':'); + if (hostname.charAt(hostname.length() - 1) != ']' + && portSegmentStart >= 0 + && hostname.length() >= portSegmentStart + 1) { + final String hostPart = hostname.substring(0, portSegmentStart); + final String portPart = hostname.substring(portSegmentStart + 1); + final Integer port = Ints.tryParse(portPart); + if (port == null || Strings.isNullOrEmpty(hostPart)) { + return null; + } + final String host = eu.siacs.conversations.utils.IP.unwrapIPv6(hostPart); + result.port = port; + if (InetAddresses.isInetAddress(host)) { + final InetAddress inetAddress; + try { + inetAddress = InetAddresses.forString(host); + } catch (final IllegalArgumentException e) { + return null; + } + result.ip = inetAddress; + } else { + if (hostPart.trim().isEmpty()) { + return null; + } + try { + result.hostname = DNSName.from(hostPart.trim()); + } catch (final Exception e) { + return null; + } + } + } else { + final String host = eu.siacs.conversations.utils.IP.unwrapIPv6(hostname); + if (InetAddresses.isInetAddress(host)) { + final InetAddress inetAddress; + try { + inetAddress = InetAddresses.forString(host); + } catch (final IllegalArgumentException e) { + return null; + } + result.ip = inetAddress; + } else { + try { + result.hostname = DNSName.from(hostname); + } catch (final Exception e) { + return null; + } + } + result.port = port; + } + return result; + } } } diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java index b70bfc558..da72ba9bd 100644 --- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -481,6 +481,8 @@ public class UIHelper { return context.getString(R.string.file); } else if (MimeUtils.AMBIGUOUS_CONTAINER_FORMATS.contains(mime)) { return context.getString(R.string.multimedia_file); + } else if (mime.equals("audio/x-m4b")) { + return context.getString(R.string.audiobook); } else if (mime.startsWith("audio/")) { return context.getString(R.string.audio); } else if (mime.startsWith("video/")) { diff --git a/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java b/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java index 57a2f3dba..635afd145 100644 --- a/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java +++ b/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java @@ -29,7 +29,7 @@ public class LocalizedContent { final String childLanguage = child.getAttribute("xml:lang"); final String lang = childLanguage == null ? parentLanguage : childLanguage; final String content = child.getContent(); - if (content != null && (namespace == null || "jabber:client".equals(namespace))) { + if (content != null && (namespace == null || Namespace.JABBER_CLIENT.equals(namespace))) { if (contents.put(lang, content) != null) { //anything that has multiple contents for the same language is invalid return null; diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java index b614251bd..85714c765 100644 --- a/src/main/java/eu/siacs/conversations/xml/Namespace.java +++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java @@ -2,6 +2,7 @@ package eu.siacs.conversations.xml; public final class Namespace { public static final String STREAMS = "http://etherx.jabber.org/streams"; + public static final String JABBER_CLIENT = "jabber:client"; public static final String DISCO_ITEMS = "http://jabber.org/protocol/disco#items"; public static final String DISCO_INFO = "http://jabber.org/protocol/disco#info"; public static final String EXTERNAL_SERVICE_DISCOVERY = "urn:xmpp:extdisco:2"; @@ -46,7 +47,11 @@ public final class Namespace { public static final String JINGLE_TRANSPORTS_S5B = "urn:xmpp:jingle:transports:s5b:1"; public static final String JINGLE_TRANSPORTS_IBB = "urn:xmpp:jingle:transports:ibb:1"; public static final String JINGLE_TRANSPORT_ICE_UDP = "urn:xmpp:jingle:transports:ice-udp:1"; + public static final String JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL = "urn:xmpp:jingle:transports:webrtc-datachannel:1"; + public static final String JINGLE_TRANSPORT = "urn:xmpp:jingle:transports:dtls-sctp:1"; public static final String JINGLE_APPS_RTP = "urn:xmpp:jingle:apps:rtp:1"; + + public static final String JINGLE_APPS_FILE_TRANSFER = "urn:xmpp:jingle:apps:file-transfer:5"; public static final String JINGLE_APPS_DTLS = "urn:xmpp:jingle:apps:dtls:0"; public static final String JINGLE_APPS_GROUPING = "urn:xmpp:jingle:apps:grouping:0"; public static final String JINGLE_FEATURE_AUDIO = "urn:xmpp:jingle:apps:rtp:audio"; @@ -59,11 +64,16 @@ public final class Namespace { public static final String PUSH = "urn:xmpp:push:0"; public static final String COMMANDS = "http://jabber.org/protocol/commands"; public static final String MUC_USER = "http://jabber.org/protocol/muc#user"; - public static final String BOOKMARKS2 = "urn:xmpp:bookmarks:0"; + public static final String BOOKMARKS2 = "urn:xmpp:bookmarks:1"; public static final String BOOKMARKS2_COMPAT = BOOKMARKS2 + "#compat"; public static final String INVITE = "urn:xmpp:invite"; public static final String PARS = "urn:xmpp:pars:0"; public static final String EASY_ONBOARDING_INVITE = "urn:xmpp:invite#invite"; public static final String OMEMO_DTLS_SRTP_VERIFICATION = "http://gultsch.de/xmpp/drafts/omemo/dlts-srtp-verification"; + public static final String JINGLE_TRANSPORT_ICE_OPTION = "http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option"; public static final String UNIFIED_PUSH = "http://gultsch.de/xmpp/drafts/unified-push"; + public static final String REPORTING = "urn:xmpp:reporting:1"; + public static final String REPORTING_REASON_SPAM = "urn:xmpp:reporting:spam"; + public static final String SDP_OFFER_ANSWER = "urn:ietf:rfc:3264"; + public static final String HASHES = "urn:xmpp:hashes:2"; } diff --git a/src/main/java/eu/siacs/conversations/xml/Tag.java b/src/main/java/eu/siacs/conversations/xml/Tag.java index db2b11172..d8d98348e 100644 --- a/src/main/java/eu/siacs/conversations/xml/Tag.java +++ b/src/main/java/eu/siacs/conversations/xml/Tag.java @@ -43,6 +43,10 @@ public class Tag { return name; } + public String identifier() { + return String.format("%s#%s", name, this.attributes.get("xmlns")); + } + public String getAttribute(final String attrName) { return this.attributes.get(attrName); } diff --git a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java b/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java index 010cb76c7..9141e1c70 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java @@ -16,55 +16,18 @@ import android.util.SparseArray; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.common.base.MoreObjects; import com.google.common.base.Optional; +import com.google.common.base.Preconditions; import com.google.common.base.Strings; - -import org.xmlpull.v1.XmlPullParserException; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.ConnectException; -import java.net.IDN; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.Socket; -import java.net.UnknownHostException; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.security.Principal; -import java.security.PrivateKey; -import java.security.cert.X509Certificate; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Hashtable; -import java.util.Iterator; -import java.util.List; -import java.util.Map.Entry; -import java.util.Set; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.regex.Matcher; - -import javax.net.ssl.KeyManager; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLPeerUnverifiedException; -import javax.net.ssl.SSLSocket; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.X509KeyManager; -import javax.net.ssl.X509TrustManager; +import com.google.common.collect.ImmutableList; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.XmppDomainVerifier; import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.sasl.ChannelBinding; +import eu.siacs.conversations.crypto.sasl.ChannelBindingMechanism; import eu.siacs.conversations.crypto.sasl.HashedToken; import eu.siacs.conversations.crypto.sasl.SaslMechanism; import eu.siacs.conversations.entities.Account; @@ -106,8 +69,50 @@ import eu.siacs.conversations.xmpp.stanzas.streammgmt.AckPacket; import eu.siacs.conversations.xmpp.stanzas.streammgmt.EnablePacket; import eu.siacs.conversations.xmpp.stanzas.streammgmt.RequestPacket; import eu.siacs.conversations.xmpp.stanzas.streammgmt.ResumePacket; + import okhttp3.HttpUrl; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.ConnectException; +import java.net.IDN; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.UnknownHostException; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Hashtable; +import java.util.Iterator; +import java.util.List; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509KeyManager; +import javax.net.ssl.X509TrustManager; + public class XmppConnection implements Runnable { private static final int PACKET_IQ = 0; @@ -161,7 +166,8 @@ public class XmppConnection implements Runnable { private boolean quickStartInProgress = false; private boolean isBound = false; private Element streamFeatures; - private String streamId = null; + private Element boundStreamFeatures; + private StreamId streamId = null; private int stanzasReceived = 0; private int stanzasSent = 0; private int stanzasSentBeforeAuthentication; @@ -184,10 +190,12 @@ public class XmppConnection implements Runnable { private OnStatusChanged statusListener = null; private OnBindListener bindListener = null; private OnMessageAcknowledged acknowledgedListener = null; - private SaslMechanism saslMechanism; + private LoginInfo loginInfo; private HashedToken.Mechanism hashTokenRequest; private HttpUrl redirectionUrl = null; private String verifiedHostname = null; + private Resolver.Result currentResolverResult; + private Resolver.Result seeOtherHostResolverResult; private volatile Thread mThread; private CountDownLatch mStreamCountDownLatch; @@ -229,10 +237,11 @@ public class XmppConnection implements Runnable { return; } if (account.getStatus() != nextStatus) { - if ((nextStatus == Account.State.OFFLINE) - && (account.getStatus() != Account.State.CONNECTING) - && (account.getStatus() != Account.State.ONLINE) - && (account.getStatus() != Account.State.DISABLED)) { + if (nextStatus == Account.State.OFFLINE + && account.getStatus() != Account.State.CONNECTING + && account.getStatus() != Account.State.ONLINE + && account.getStatus() != Account.State.DISABLED + && account.getStatus() != Account.State.LOGGED_OUT) { return; } if (nextStatus == Account.State.ONLINE) { @@ -275,12 +284,14 @@ public class XmppConnection implements Runnable { mXmppConnectionService.resetSendingToWaiting(account); } Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": connecting"); - features.encryptionEnabled = false; + this.loginInfo = null; + this.features.encryptionEnabled = false; this.inSmacksSession = false; this.quickStartInProgress = false; this.isBound = false; this.attempt++; - this.verifiedHostname = null; // will be set if user entered hostname is being used or hostname was verified + this.verifiedHostname = + null; // will be set if user entered hostname is being used or hostname was verified // with dnssec try { Socket localSocket; @@ -327,12 +338,13 @@ public class XmppConnection implements Runnable { } } else { final String domain = account.getServer(); - final List results; + final List results = new ArrayList<>(); final boolean hardcoded = extended && !account.getHostname().isEmpty(); if (hardcoded) { - results = Resolver.fromHardCoded(account.getHostname(), account.getPort()); + results.addAll( + Resolver.fromHardCoded(account.getHostname(), account.getPort())); } else { - results = Resolver.resolve(domain); + results.addAll(Resolver.resolve(domain)); } if (Thread.currentThread().isInterrupted()) { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": Thread was interrupted"); @@ -359,7 +371,24 @@ public class XmppConnection implements Runnable { + storedBackupResult); } } - for (Iterator iterator = results.iterator(); + final StreamId streamId = this.streamId; + final Resolver.Result resumeLocation = streamId == null ? null : streamId.location; + if (resumeLocation != null) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": injected resume location on position 0"); + results.add(0, resumeLocation); + } + final Resolver.Result seeOtherHost = this.seeOtherHostResolverResult; + if (seeOtherHost != null) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": injected see-other-host on position 0"); + results.add(0, seeOtherHost); + } + for (final Iterator iterator = results.iterator(); iterator.hasNext(); ) { final Resolver.Result result = iterator.next(); if (Thread.currentThread().isInterrupted()) { @@ -373,7 +402,6 @@ public class XmppConnection implements Runnable { features.encryptionEnabled = result.isDirectTls(); verifiedHostname = result.isAuthenticated() ? result.getHostname().toString() : null; - Log.d(Config.LOGTAG, "verified hostname " + verifiedHostname); final InetSocketAddress addr; if (result.getIp() != null) { addr = new InetSocketAddress(result.getIp(), result.getPort()); @@ -421,6 +449,8 @@ public class XmppConnection implements Runnable { mXmppConnectionService.databaseBackend.saveResolverResult( domain, result); } + this.currentResolverResult = result; + this.seeOtherHostResolverResult = null; break; // successfully connected to server that speaks xmpp } else { FileBackend.close(localSocket); @@ -497,8 +527,7 @@ public class XmppConnection implements Runnable { tagReader.setInputStream(socket.getInputStream()); tagWriter.beginDocument(); final boolean quickStart; - if (socket instanceof SSLSocket) { - final SSLSocket sslSocket = (SSLSocket) socket; + if (socket instanceof SSLSocket sslSocket) { SSLSockets.log(account, sslSocket); quickStart = establishStream(SSLSockets.version(sslSocket)); } else { @@ -508,7 +537,16 @@ public class XmppConnection implements Runnable { if (Thread.currentThread().isInterrupted()) { throw new InterruptedException(); } - final boolean success = tag != null && tag.isStart("stream", Namespace.STREAMS); + if (tag == null) { + return false; + } + final boolean success = tag.isStart("stream", Namespace.STREAMS); + if (success) { + final var from = tag.getAttribute("from"); + if (from == null || !from.equals(account.getServer())) { + throw new StateChangingException(Account.State.HOST_UNKNOWN); + } + } if (success && quickStart) { this.quickStartInProgress = true; } @@ -565,14 +603,18 @@ public class XmppConnection implements Runnable { processStreamFeatures(nextTag); } else if (nextTag.isStart("proceed", Namespace.TLS)) { switchOverToTls(); + } else if (nextTag.isStart("failure", Namespace.TLS)) { + throw new StateChangingException(Account.State.TLS_ERROR); + } else if (account.isOptionSet(Account.OPTION_REGISTER) + && nextTag.isStart("iq", Namespace.JABBER_CLIENT)) { + processIq(nextTag); + } else if (!isSecure() || this.loginInfo == null) { + throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); } else if (nextTag.isStart("success")) { final Element success = tagReader.readElement(nextTag); if (processSuccess(success)) { break; } - - } else if (nextTag.isStart("failure", Namespace.TLS)) { - throw new StateChangingException(Account.State.TLS_ERROR); } else if (nextTag.isStart("failure")) { final Element failure = tagReader.readElement(nextTag); processFailure(failure); @@ -580,23 +622,34 @@ public class XmppConnection implements Runnable { // two step sasl2 - we don’t support this yet throw new StateChangingException(Account.State.INCOMPATIBLE_CLIENT); } else if (nextTag.isStart("challenge")) { - if (isSecure() && this.saslMechanism != null) { - final Element challenge = tagReader.readElement(nextTag); - processChallenge(challenge); - } else { - Log.d( - Config.LOGTAG, - account.getJid().asBareJid() - + ": received 'challenge on an unsecure connection"); - throw new StateChangingException(Account.State.INCOMPATIBLE_CLIENT); - } + final Element challenge = tagReader.readElement(nextTag); + processChallenge(challenge); + } else if (!LoginInfo.isSuccess(this.loginInfo)) { + throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); + } else if (this.streamId != null + && nextTag.isStart("resumed", Namespace.STREAM_MANAGEMENT)) { + final Element resumed = tagReader.readElement(nextTag); + processResumed(resumed); + } else if (nextTag.isStart("failed", Namespace.STREAM_MANAGEMENT)) { + final Element failed = tagReader.readElement(nextTag); + processFailed(failed, true); + } else if (nextTag.isStart("iq", Namespace.JABBER_CLIENT)) { + processIq(nextTag); + } else if (!isBound) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": server sent unexpected" + + nextTag.identifier()); + throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); + } else if (nextTag.isStart("message", Namespace.JABBER_CLIENT)) { + processMessage(nextTag); + } else if (nextTag.isStart("presence", Namespace.JABBER_CLIENT)) { + processPresence(nextTag); } else if (nextTag.isStart("enabled", Namespace.STREAM_MANAGEMENT)) { final Element enabled = tagReader.readElement(nextTag); processEnabled(enabled); - } else if (nextTag.isStart("resumed")) { - final Element resumed = tagReader.readElement(nextTag); - processResumed(resumed); - } else if (nextTag.isStart("r")) { + } else if (nextTag.isStart("r", Namespace.STREAM_MANAGEMENT)) { tagReader.readElement(nextTag); if (Config.EXTENDED_SM_LOGGING) { Log.d( @@ -607,7 +660,7 @@ public class XmppConnection implements Runnable { } final AckPacket ack = new AckPacket(this.stanzasReceived); tagWriter.writeStanzaAsync(ack); - } else if (nextTag.isStart("a")) { + } else if (nextTag.isStart("a", Namespace.STREAM_MANAGEMENT)) { boolean accountUiNeedsRefresh = false; synchronized (NotificationService.CATCHUP_LOCK) { if (mWaitingForSmCatchup.compareAndSet(true, false)) { @@ -650,15 +703,13 @@ public class XmppConnection implements Runnable { if (acknowledgedMessages) { mXmppConnectionService.updateConversationUi(); } - } else if (nextTag.isStart("failed")) { - final Element failed = tagReader.readElement(nextTag); - processFailed(failed, true); - } else if (nextTag.isStart("iq")) { - processIq(nextTag); - } else if (nextTag.isStart("message")) { - processMessage(nextTag); - } else if (nextTag.isStart("presence")) { - processPresence(nextTag); + } else { + Log.e( + Config.LOGTAG, + account.getJid().asBareJid() + + ": Encountered unknown stream element" + + nextTag.identifier()); + throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); } nextTag = tagReader.readTag(); } @@ -682,8 +733,14 @@ public class XmppConnection implements Runnable { } else { throw new AssertionError("Missing implementation for " + version); } + final LoginInfo currentLoginInfo = this.loginInfo; + if (currentLoginInfo == null || LoginInfo.isSuccess(currentLoginInfo)) { + throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); + } try { - response.setContent(saslMechanism.getResponse(challenge.getContent(), sslSocketOrNull(socket))); + response.setContent( + currentLoginInfo.saslMechanism.getResponse( + challenge.getContent(), sslSocketOrNull(socket))); } catch (final SaslMechanism.AuthenticationException e) { // TODO: Send auth abort tag. Log.e(Config.LOGTAG, e.toString()); @@ -700,8 +757,9 @@ public class XmppConnection implements Runnable { } catch (final IllegalArgumentException e) { throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); } - final SaslMechanism currentSaslMechanism = this.saslMechanism; - if (currentSaslMechanism == null) { + final LoginInfo currentLoginInfo = this.loginInfo; + final SaslMechanism currentSaslMechanism = LoginInfo.mechanism(currentLoginInfo); + if (currentLoginInfo == null || currentSaslMechanism == null) { throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); } final String challenge; @@ -713,9 +771,9 @@ public class XmppConnection implements Runnable { throw new AssertionError("Missing implementation for " + version); } try { - currentSaslMechanism.getResponse(challenge, sslSocketOrNull(socket)); + currentLoginInfo.success(challenge, sslSocketOrNull(socket)); } catch (final SaslMechanism.AuthenticationException e) { - Log.e(Config.LOGTAG, String.valueOf(e)); + Log.e(Config.LOGTAG, account.getJid().asBareJid() + ": authentication failure ", e); throw new StateChangingException(Account.State.UNAUTHORIZED); } Log.d( @@ -774,8 +832,14 @@ public class XmppConnection implements Runnable { + ": server sent bound and resumed in SASL2 success"); throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); } - final boolean processNopStreamFeatures; if (resumed != null && streamId != null) { + if (this.boundStreamFeatures != null) { + this.streamFeatures = this.boundStreamFeatures; + Log.d( + Config.LOGTAG, + "putting previous stream features back in place: " + + XmlHelper.printElementNames(this.boundStreamFeatures)); + } processResumed(resumed); } else if (failed != null) { processFailed(failed, false); // wait for new stream features @@ -783,6 +847,8 @@ public class XmppConnection implements Runnable { if (bound != null) { clearIqCallbacks(); this.isBound = true; + processNopStreamFeatures(); + this.boundStreamFeatures = this.streamFeatures; final Element streamManagementEnabled = bound.findChild("enabled", Namespace.STREAM_MANAGEMENT); final Element carbonsEnabled = bound.findChild("enabled", Namespace.CARBONS); @@ -792,19 +858,28 @@ public class XmppConnection implements Runnable { processEnabled(streamManagementEnabled); waitForDisco = true; } else { - //if we did not enable stream management in bind do it now + // if we did not enable stream management in bind do it now waitForDisco = enableStreamManagement(); } + final boolean negotiatedCarbons; if (carbonsEnabled != null) { + negotiatedCarbons = true; Log.d( Config.LOGTAG, - account.getJid().asBareJid() + ": successfully enabled carbons"); + account.getJid().asBareJid() + + ": successfully enabled carbons (via Bind 2.0)"); features.carbonsEnabled = true; + } else if (loginInfo.inlineBindFeatures.contains(Namespace.CARBONS)) { + negotiatedCarbons = true; + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": successfully enabled carbons (via Bind 2.0/implicit)"); + features.carbonsEnabled = true; + } else { + negotiatedCarbons = false; } - sendPostBindInitialization(waitForDisco, carbonsEnabled != null); - processNopStreamFeatures = true; - } else { - processNopStreamFeatures = false; + sendPostBindInitialization(waitForDisco, negotiatedCarbons); } final HashedToken.Mechanism tokenMechanism; if (SaslMechanism.hashedToken(currentSaslMechanism)) { @@ -815,10 +890,24 @@ public class XmppConnection implements Runnable { tokenMechanism = null; } if (tokenMechanism != null && !Strings.isNullOrEmpty(token)) { - this.account.setFastToken(tokenMechanism, token); - Log.d( - Config.LOGTAG, - account.getJid().asBareJid() + ": storing hashed token " + tokenMechanism); + if (ChannelBinding.priority(tokenMechanism.channelBinding) + >= ChannelBindingMechanism.getPriority(currentSaslMechanism)) { + this.account.setFastToken(tokenMechanism, token); + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": storing hashed token " + + tokenMechanism); + } else { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": not accepting hashed token " + + tokenMechanism.name() + + " for log in mechanism " + + currentSaslMechanism.getMechanism()); + this.account.resetFastToken(); + } } else if (this.hashTokenRequest != null) { Log.w( Config.LOGTAG, @@ -826,10 +915,6 @@ public class XmppConnection implements Runnable { + ": no response to our hashed token request " + this.hashTokenRequest); } - // a successful resume will not send stream features - if (processNopStreamFeatures) { - processNopStreamFeatures(); - } } mXmppConnectionService.databaseBackend.updateAccount(account); this.quickStartInProgress = false; @@ -907,7 +992,7 @@ public class XmppConnection implements Runnable { } Log.d(Config.LOGTAG, failure.toString()); Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": login failure " + version); - if (SaslMechanism.hashedToken(this.saslMechanism)) { + if (SaslMechanism.hashedToken(LoginInfo.mechanism(this.loginInfo))) { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resetting token"); account.resetFastToken(); mXmppConnectionService.databaseBackend.updateAccount(account); @@ -933,7 +1018,7 @@ public class XmppConnection implements Runnable { } } } - if (SaslMechanism.hashedToken(this.saslMechanism)) { + if (SaslMechanism.hashedToken(LoginInfo.mechanism(this.loginInfo))) { Log.d( Config.LOGTAG, account.getJid().asBareJid() @@ -953,18 +1038,29 @@ public class XmppConnection implements Runnable { } private void processEnabled(final Element enabled) { - final String streamId; + final String id; if (enabled.getAttributeAsBoolean("resume")) { - streamId = enabled.getAttribute("id"); - Log.d( - Config.LOGTAG, - account.getJid().asBareJid().toString() - + ": stream management enabled (resumable)"); + id = enabled.getAttribute("id"); + } else { + id = null; + } + final String locationAttribute = enabled.getAttribute("location"); + final Resolver.Result currentResolverResult = this.currentResolverResult; + final Resolver.Result location; + if (Strings.isNullOrEmpty(locationAttribute) || currentResolverResult == null) { + location = null; + } else { + location = currentResolverResult.seeOtherHost(locationAttribute); + } + final StreamId streamId = id == null ? null : new StreamId(id, location); + if (streamId == null) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": stream management enabled"); } else { Log.d( Config.LOGTAG, - account.getJid().asBareJid().toString() + ": stream management enabled"); - streamId = null; + account.getJid().asBareJid() + + ": stream management enabled. resume at: " + + streamId.location); } this.streamId = streamId; this.stanzasReceived = 0; @@ -1010,8 +1106,7 @@ public class XmppConnection implements Runnable { Config.LOGTAG, account.getJid().asBareJid() + ": resending " + failedStanzas.size() + " stanzas"); for (final AbstractAcknowledgeableStanza packet : failedStanzas) { - if (packet instanceof MessagePacket) { - MessagePacket message = (MessagePacket) packet; + if (packet instanceof MessagePacket message) { mXmppConnectionService.markMessage( account, message.getTo().asBareJid(), @@ -1074,8 +1169,7 @@ public class XmppConnection implements Runnable { + mStanzaQueue.keyAt(i)); } final AbstractAcknowledgeableStanza stanza = mStanzaQueue.valueAt(i); - if (stanza instanceof MessagePacket && acknowledgedListener != null) { - final MessagePacket packet = (MessagePacket) stanza; + if (stanza instanceof MessagePacket packet && acknowledgedListener != null) { final String id = packet.getId(); final Jid to = packet.getTo(); if (id != null && to != null) { @@ -1092,20 +1186,13 @@ public class XmppConnection implements Runnable { private @NonNull Element processPacket(final Tag currentTag, final int packetType) throws IOException { - final Element element; - switch (packetType) { - case PACKET_IQ: - element = new IqPacket(); - break; - case PACKET_MESSAGE: - element = new MessagePacket(); - break; - case PACKET_PRESENCE: - element = new PresencePacket(); - break; - default: - throw new AssertionError("Should never encounter invalid type"); - } + final Element element = + switch (packetType) { + case PACKET_IQ -> new IqPacket(); + case PACKET_MESSAGE -> new MessagePacket(); + case PACKET_PRESENCE -> new PresencePacket(); + default -> throw new AssertionError("Should never encounter invalid type"); + }; element.setAttributes(currentTag.getAttributes()); Tag nextTag = tagReader.readTag(); if (nextTag == null) { @@ -1159,52 +1246,74 @@ public class XmppConnection implements Runnable { + "'"); return; } - if (packet instanceof JinglePacket) { + if (Thread.currentThread().isInterrupted()) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + "Not processing iq. Thread was interrupted"); + return; + } + if (packet instanceof JinglePacket jinglePacket && isBound) { if (this.jingleListener != null) { - this.jingleListener.onJinglePacketReceived(account, (JinglePacket) packet); + this.jingleListener.onJinglePacketReceived(account, jinglePacket); } } else { - OnIqPacketReceived callback = null; - synchronized (this.packetCallbacks) { - final Pair packetCallbackDuple = - packetCallbacks.get(packet.getId()); - if (packetCallbackDuple != null) { - // Packets to the server should have responses from the server - if (packetCallbackDuple.first.toServer(account)) { - if (packet.fromServer(account)) { - callback = packetCallbackDuple.second; - packetCallbacks.remove(packet.getId()); - } else { - Log.e( - Config.LOGTAG, - account.getJid().asBareJid().toString() - + ": ignoring spoofed iq packet"); - } - } else { - if (packet.getFrom() != null - && packet.getFrom().equals(packetCallbackDuple.first.getTo())) { - callback = packetCallbackDuple.second; - packetCallbacks.remove(packet.getId()); - } else { - Log.e( - Config.LOGTAG, - account.getJid().asBareJid().toString() - + ": ignoring spoofed iq packet"); - } - } - } else if (packet.getType() == IqPacket.TYPE.GET - || packet.getType() == IqPacket.TYPE.SET) { - callback = this.unregisteredIqListener; - } + final OnIqPacketReceived callback = getIqPacketReceivedCallback(packet); + if (callback == null) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid().toString() + + ": no callback registered for IQ from " + + packet.getFrom()); + return; } - if (callback != null) { - try { - callback.onIqPacketReceived(account, packet); - } catch (StateChangingError error) { - throw new StateChangingException(error.state); + try { + callback.onIqPacketReceived(account, packet); + } catch (final StateChangingError error) { + throw new StateChangingException(error.state); + } + } + } + + private OnIqPacketReceived getIqPacketReceivedCallback(final IqPacket stanza) + throws StateChangingException { + final boolean isRequest = + stanza.getType() == IqPacket.TYPE.GET || stanza.getType() == IqPacket.TYPE.SET; + if (isRequest) { + if (isBound) { + return this.unregisteredIqListener; + } else { + throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); + } + } else { + synchronized (this.packetCallbacks) { + final var pair = packetCallbacks.get(stanza.getId()); + if (pair == null) { + return null; + } + if (pair.first.toServer(account)) { + if (stanza.fromServer(account)) { + packetCallbacks.remove(stanza.getId()); + return pair.second; + } else { + Log.e( + Config.LOGTAG, + account.getJid().asBareJid().toString() + + ": ignoring spoofed iq packet"); + } + } else { + if (stanza.getFrom() != null && stanza.getFrom().equals(pair.first.getTo())) { + packetCallbacks.remove(stanza.getId()); + return pair.second; + } else { + Log.e( + Config.LOGTAG, + account.getJid().asBareJid().toString() + + ": ignoring spoofed iq packet"); + } } } } + return null; } private void processMessage(final Tag currentTag) throws IOException { @@ -1219,11 +1328,18 @@ public class XmppConnection implements Runnable { + "'"); return; } + if (Thread.currentThread().isInterrupted()) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + "Not processing message. Thread was interrupted"); + return; + } this.messageListener.onMessagePacketReceived(account, packet); } private void processPresence(final Tag currentTag) throws IOException { - PresencePacket packet = (PresencePacket) processPacket(currentTag, PACKET_PRESENCE); + final PresencePacket packet = (PresencePacket) processPacket(currentTag, PACKET_PRESENCE); if (!packet.valid()) { Log.e( Config.LOGTAG, @@ -1234,6 +1350,13 @@ public class XmppConnection implements Runnable { + "'"); return; } + if (Thread.currentThread().isInterrupted()) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + "Not processing presence. Thread was interrupted"); + return; + } this.presenceListener.onPresencePacketReceived(account, packet); } @@ -1247,8 +1370,9 @@ public class XmppConnection implements Runnable { tagReader.readTag(); final Socket socket = this.socket; final SSLSocket sslSocket = upgradeSocketToTls(socket); - tagReader.setInputStream(sslSocket.getInputStream()); - tagWriter.setOutputStream(sslSocket.getOutputStream()); + this.socket = sslSocket; + this.tagReader.setInputStream(sslSocket.getInputStream()); + this.tagWriter.setOutputStream(sslSocket.getOutputStream()); Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": TLS connection established"); final boolean quickStart; try { @@ -1314,7 +1438,7 @@ public class XmppConnection implements Runnable { account.getJid().asBareJid() + ": quick start in progress. ignoring features: " + XmlHelper.printElementNames(this.streamFeatures)); - if (SaslMechanism.hashedToken(this.saslMechanism)) { + if (SaslMechanism.hashedToken(LoginInfo.mechanism(this.loginInfo))) { return; } if (isFastTokenAvailable( @@ -1363,6 +1487,8 @@ public class XmppConnection implements Runnable { && isSecure) { authenticate(SaslMechanism.Version.SASL); } else if (this.streamFeatures.hasChild("sm", Namespace.STREAM_MANAGEMENT) + && isSecure + && LoginInfo.isSuccess(loginInfo) && streamId != null && !inSmacksSession) { if (Config.EXTENDED_SM_LOGGING) { @@ -1372,12 +1498,14 @@ public class XmppConnection implements Runnable { + ": resuming after stanza #" + stanzasReceived); } - final ResumePacket resume = new ResumePacket(this.streamId, stanzasReceived); + final ResumePacket resume = new ResumePacket(this.streamId.id, stanzasReceived); this.mSmCatchupMessageCounter.set(0); this.mWaitingForSmCatchup.set(true); this.tagWriter.writeStanzaAsync(resume); } else if (needsBinding) { - if (this.streamFeatures.hasChild("bind", Namespace.BIND) && isSecure) { + if (this.streamFeatures.hasChild("bind", Namespace.BIND) + && isSecure + && LoginInfo.isSuccess(loginInfo)) { sendBindRequest(); } else { Log.d( @@ -1398,7 +1526,8 @@ public class XmppConnection implements Runnable { private void authenticate() throws IOException { final boolean isSecure = isSecure(); - if (isSecure && this.streamFeatures.hasChild("authentication", Namespace.SASL_2)) {authenticate(SaslMechanism.Version.SASL_2); + if (isSecure && this.streamFeatures.hasChild("authentication", Namespace.SASL_2)) { + authenticate(SaslMechanism.Version.SASL_2); } else if (isSecure && this.streamFeatures.hasChild("mechanisms", Namespace.SASL)) { authenticate(SaslMechanism.Version.SASL); } else { @@ -1422,11 +1551,13 @@ public class XmppConnection implements Runnable { this.streamFeatures.findChild("sasl-channel-binding", Namespace.CHANNEL_BINDING); final Collection channelBindings = ChannelBinding.of(cbElement); final SaslMechanism.Factory factory = new SaslMechanism.Factory(account); - final SaslMechanism saslMechanism = factory.of(mechanisms, channelBindings, version, SSLSockets.version(this.socket)); - this.saslMechanism = validate(saslMechanism, mechanisms); + final SaslMechanism saslMechanism = + factory.of(mechanisms, channelBindings, version, SSLSockets.version(this.socket)); + this.validate(saslMechanism, mechanisms); final boolean quickStartAvailable; - final String firstMessage = this.saslMechanism.getClientFirstMessage(sslSocketOrNull(this.socket)); - final boolean usingFast = SaslMechanism.hashedToken(this.saslMechanism); + final String firstMessage = + saslMechanism.getClientFirstMessage(sslSocketOrNull(this.socket)); + final boolean usingFast = SaslMechanism.hashedToken(saslMechanism); final Element authenticate; if (version == SaslMechanism.Version.SASL) { authenticate = new Element("auth", Namespace.SASL); @@ -1434,6 +1565,7 @@ public class XmppConnection implements Runnable { authenticate.setContent(firstMessage); } quickStartAvailable = false; + this.loginInfo = new LoginInfo(saslMechanism, version, Collections.emptyList()); } else if (version == SaslMechanism.Version.SASL_2) { final Element inline = authElement.findChild("inline", Namespace.SASL_2); final boolean sm = inline != null && inline.hasChild("sm", Namespace.STREAM_MANAGEMENT); @@ -1441,7 +1573,8 @@ public class XmppConnection implements Runnable { if (usingFast) { hashTokenRequest = null; } else { - final Element fast = inline == null ? null : inline.findChild("fast", Namespace.FAST); + final Element fast = + inline == null ? null : inline.findChild("fast", Namespace.FAST); final Collection fastMechanisms = SaslMechanism.mechanisms(fast); hashTokenRequest = HashedToken.Mechanism.best(fastMechanisms, SSLSockets.version(this.socket)); @@ -1462,8 +1595,11 @@ public class XmppConnection implements Runnable { return; } } + this.loginInfo = new LoginInfo(saslMechanism, version, bindFeatures); this.hashTokenRequest = hashTokenRequest; - authenticate = generateAuthenticationRequest(firstMessage, usingFast, hashTokenRequest, bindFeatures, sm); + authenticate = + generateAuthenticationRequest( + firstMessage, usingFast, hashTokenRequest, bindFeatures, sm); } else { throw new AssertionError("Missing implementation for " + version); } @@ -1478,8 +1614,8 @@ public class XmppConnection implements Runnable { + ": Authenticating with " + version + "/" - + this.saslMechanism.getMechanism()); - authenticate.setAttribute("mechanism", this.saslMechanism.getMechanism()); + + LoginInfo.mechanism(this.loginInfo).getMechanism()); + authenticate.setAttribute("mechanism", LoginInfo.mechanism(this.loginInfo).getMechanism()); synchronized (this.mStanzaQueue) { this.stanzasSentBeforeAuthentication = this.stanzasSent; tagWriter.writeElement(authenticate); @@ -1491,8 +1627,9 @@ public class XmppConnection implements Runnable { return inline != null && inline.hasChild("fast", Namespace.FAST); } - @NonNull - private SaslMechanism validate(final @Nullable SaslMechanism saslMechanism, Collection mechanisms) throws StateChangingException { + private void validate( + final @Nullable SaslMechanism saslMechanism, Collection mechanisms) + throws StateChangingException { if (saslMechanism == null) { Log.d( Config.LOGTAG, @@ -1502,7 +1639,7 @@ public class XmppConnection implements Runnable { throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); } if (SaslMechanism.hashedToken(saslMechanism)) { - return saslMechanism; + return; } final int pinnedMechanism = account.getPinnedMechanismPriority(); if (pinnedMechanism > saslMechanism.getPriority()) { @@ -1517,11 +1654,12 @@ public class XmppConnection implements Runnable { + "). Possible downgrade attack?"); throw new StateChangingException(Account.State.DOWNGRADE_ATTACK); } - return saslMechanism; } - private Element generateAuthenticationRequest(final String firstMessage, final boolean usingFast) { - return generateAuthenticationRequest(firstMessage, usingFast, null, Bind2.QUICKSTART_FEATURES, true); + private Element generateAuthenticationRequest( + final String firstMessage, final boolean usingFast) { + return generateAuthenticationRequest( + firstMessage, usingFast, null, Bind2.QUICKSTART_FEATURES, true); } private Element generateAuthenticationRequest( @@ -1544,11 +1682,14 @@ public class XmppConnection implements Runnable { .addChild("device") .setContent(String.format("%s %s", Build.MANUFACTURER, Build.MODEL)); } - if (bind != null) { + // do not include bind if 'inlineStreamManagement' is missing and we have a streamId + // (because we would rather just do a normal SM/resume) + final boolean mayAttemptBind = streamId == null || inlineStreamManagement; + if (bind != null && mayAttemptBind) { authenticate.addChild(generateBindRequest(bind)); } if (inlineStreamManagement && streamId != null) { - final ResumePacket resume = new ResumePacket(this.streamId, stanzasReceived); + final ResumePacket resume = new ResumePacket(this.streamId.id, stanzasReceived); this.mSmCatchupMessageCounter.set(0); this.mWaitingForSmCatchup.set(true); authenticate.addChild(resume); @@ -1711,8 +1852,10 @@ public class XmppConnection implements Runnable { resetAttemptCount(true); resetStreamId(); clearIqCallbacks(); - this.stanzasSent = 0; - mStanzaQueue.clear(); + synchronized (this.mStanzaQueue) { + this.stanzasSent = 0; + this.mStanzaQueue.clear(); + } this.redirectionUrl = null; synchronized (this.disco) { disco.clear(); @@ -1720,7 +1863,7 @@ public class XmppConnection implements Runnable { synchronized (this.commands) { this.commands.clear(); } - this.saslMechanism = null; + this.loginInfo = null; } private void sendBindRequest() { @@ -2164,6 +2307,29 @@ public class XmppConnection implements Runnable { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": policy violation. " + text); failPendingMessages(text); throw new StateChangingException(Account.State.POLICY_VIOLATION); + } else if (streamError.hasChild("see-other-host")) { + final String seeOtherHost = streamError.findChildContent("see-other-host"); + final Resolver.Result currentResolverResult = this.currentResolverResult; + if (Strings.isNullOrEmpty(seeOtherHost) || currentResolverResult == null) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + ": stream error " + streamError); + throw new StateChangingException(Account.State.STREAM_ERROR); + } + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": see other host: " + + seeOtherHost + + " " + + currentResolverResult); + final Resolver.Result seeOtherResult = currentResolverResult.seeOtherHost(seeOtherHost); + if (seeOtherResult != null) { + this.seeOtherHostResolverResult = seeOtherResult; + throw new StateChangingException(Account.State.SEE_OTHER_HOST); + } else { + throw new StateChangingException(Account.State.STREAM_ERROR); + } } else { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": stream error " + streamError); throw new StateChangingException(Account.State.STREAM_ERROR); @@ -2174,8 +2340,7 @@ public class XmppConnection implements Runnable { synchronized (this.mStanzaQueue) { for (int i = 0; i < mStanzaQueue.size(); ++i) { final AbstractAcknowledgeableStanza stanza = mStanzaQueue.valueAt(i); - if (stanza instanceof MessagePacket) { - final MessagePacket packet = (MessagePacket) stanza; + if (stanza instanceof MessagePacket packet) { final String id = packet.getId(); final Jid to = packet.getTo(); mXmppConnectionService.markMessage( @@ -2187,18 +2352,29 @@ public class XmppConnection implements Runnable { private boolean establishStream(final SSLSockets.Version sslVersion) throws IOException, InterruptedException { - final SaslMechanism quickStartMechanism = - SaslMechanism.ensureAvailable(account.getQuickStartMechanism(), sslVersion); final boolean secureConnection = sslVersion != SSLSockets.Version.NONE; + final SaslMechanism quickStartMechanism; + if (secureConnection) { + quickStartMechanism = + SaslMechanism.ensureAvailable(account.getQuickStartMechanism(), sslVersion); + } else { + quickStartMechanism = null; + } if (secureConnection && Config.QUICKSTART_ENABLED && quickStartMechanism != null && account.isOptionSet(Account.OPTION_QUICKSTART_AVAILABLE)) { mXmppConnectionService.restoredFromDatabaseLatch.await(); - this.saslMechanism = quickStartMechanism; + this.loginInfo = + new LoginInfo( + quickStartMechanism, + SaslMechanism.Version.SASL_2, + Bind2.QUICKSTART_FEATURES); final boolean usingFast = quickStartMechanism instanceof HashedToken; final Element authenticate = - generateAuthenticationRequest(quickStartMechanism.getClientFirstMessage(sslSocketOrNull(this.socket)), usingFast); + generateAuthenticationRequest( + quickStartMechanism.getClientFirstMessage(sslSocketOrNull(this.socket)), + usingFast); authenticate.setAttribute("mechanism", quickStartMechanism.getMechanism()); sendStartStream(true, false); synchronized (this.mStanzaQueue) { @@ -2225,7 +2401,7 @@ public class XmppConnection implements Runnable { } stream.setAttribute("version", "1.0"); stream.setAttribute("xml:lang", LocalizedContent.STREAM_LANGUAGE); - stream.setAttribute("xmlns", "jabber:client"); + stream.setAttribute("xmlns", Namespace.JABBER_CLIENT); stream.setAttribute("xmlns:stream", Namespace.STREAMS); tagWriter.writeTag(stream, flush); } @@ -2289,9 +2465,7 @@ public class XmppConnection implements Runnable { + " do not write stanza to unbound stream " + packet.toString()); } - if (packet instanceof AbstractAcknowledgeableStanza) { - AbstractAcknowledgeableStanza stanza = (AbstractAcknowledgeableStanza) packet; - + if (packet instanceof AbstractAcknowledgeableStanza stanza) { if (this.mStanzaQueue.size() != 0) { int currentHighestKey = this.mStanzaQueue.keyAt(this.mStanzaQueue.size() - 1); if (currentHighestKey != stanzasSent) { @@ -2301,7 +2475,13 @@ public class XmppConnection implements Runnable { ++stanzasSent; if (Config.EXTENDED_SM_LOGGING) { - Log.d(Config.LOGTAG, account.getJid().asBareJid()+": counting outbound "+packet.getName()+" as #" + stanzasSent); + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": counting outbound " + + packet.getName() + + " as #" + + stanzasSent); } this.mStanzaQueue.append(stanzasSent, stanza); if (stanza instanceof MessagePacket && stanza.getId() != null && inSmacksSession) { @@ -2422,6 +2602,7 @@ public class XmppConnection implements Runnable { private void resetStreamId() { this.streamId = null; + this.boundStreamFeatures = null; } private List> findDiscoItemsByFeature(final String feature) { @@ -2480,10 +2661,15 @@ public class XmppConnection implements Runnable { return servers.size() > 0 ? servers.get(0) : null; } - public int getTimeToNextAttempt() { - final int additionalTime = - account.getLastErrorStatus() == Account.State.POLICY_VIOLATION ? 3 : 0; - final int interval = Math.min((int) (25 * Math.pow(1.3, (additionalTime + attempt))), 300); + public int getTimeToNextAttempt(final boolean aggressive) { + final int interval; + if (aggressive) { + interval = Math.min((int) (3 * Math.pow(1.3, attempt)), 60); + } else { + final int additionalTime = + account.getLastErrorStatus() == Account.State.POLICY_VIOLATION ? 3 : 0; + interval = Math.min((int) (25 * Math.pow(1.3, (additionalTime + attempt))), 300); + } final int secondsSinceLast = (int) ((SystemClock.elapsedRealtime() - this.lastConnect) / 1000); return interval - secondsSinceLast; @@ -2584,6 +2770,67 @@ public class XmppConnection implements Runnable { } } + private static class LoginInfo { + public final SaslMechanism saslMechanism; + public final SaslMechanism.Version saslVersion; + public final List inlineBindFeatures; + public final AtomicBoolean success = new AtomicBoolean(false); + + private LoginInfo( + final SaslMechanism saslMechanism, + final SaslMechanism.Version saslVersion, + final Collection inlineBindFeatures) { + Preconditions.checkNotNull(saslMechanism, "SASL Mechanism must not be null"); + Preconditions.checkNotNull(saslVersion, "SASL version must not be null"); + this.saslMechanism = saslMechanism; + this.saslVersion = saslVersion; + this.inlineBindFeatures = + inlineBindFeatures == null + ? Collections.emptyList() + : ImmutableList.copyOf(inlineBindFeatures); + } + + public static SaslMechanism mechanism(final LoginInfo loginInfo) { + return loginInfo == null ? null : loginInfo.saslMechanism; + } + + public void success(final String challenge, final SSLSocket sslSocket) + throws SaslMechanism.AuthenticationException { + final var response = this.saslMechanism.getResponse(challenge, sslSocket); + if (!Strings.isNullOrEmpty(response)) { + throw new SaslMechanism.AuthenticationException( + "processing success yielded another response"); + } + if (this.success.compareAndSet(false, true)) { + return; + } + throw new SaslMechanism.AuthenticationException("Process 'success' twice"); + } + + public static boolean isSuccess(final LoginInfo loginInfo) { + return loginInfo != null && loginInfo.success.get(); + } + } + + private static class StreamId { + public final String id; + public final Resolver.Result location; + + private StreamId(String id, Resolver.Result location) { + this.id = id; + this.location = location; + } + + @NonNull + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("location", location) + .toString(); + } + } + private static class StateChangingError extends Error { private final Account.State state; @@ -2636,17 +2883,12 @@ public class XmppConnection implements Runnable { && pepPublishOptions(); } - public boolean avatarConversion() { - return hasDiscoFeature(account.getJid().asBareJid(), Namespace.AVATAR_CONVERSION) - && pepPublishOptions(); - } - public boolean blocking() { return hasDiscoFeature(account.getDomain(), Namespace.BLOCKING); } public boolean spamReporting() { - return hasDiscoFeature(account.getDomain(), "urn:xmpp:reporting:reason:spam:0"); + return hasDiscoFeature(account.getDomain(), Namespace.REPORTING); } public boolean flexibleOfflineMessageRetrieval() { @@ -2666,7 +2908,8 @@ public class XmppConnection implements Runnable { public boolean sm() { return streamId != null || (connection.streamFeatures != null - && connection.streamFeatures.hasChild("sm", Namespace.STREAM_MANAGEMENT)); + && connection.streamFeatures.hasChild( + "sm", Namespace.STREAM_MANAGEMENT)); } public boolean csi() { @@ -2787,8 +3030,8 @@ public class XmppConnection implements Runnable { } public boolean bookmarks2() { - return Config - .USE_BOOKMARKS2 /* || hasDiscoFeature(account.getJid().asBareJid(), Namespace.BOOKMARKS2_COMPAT)*/; + return pepPublishOptions() + && hasDiscoFeature(account.getJid().asBareJid(), Namespace.BOOKMARKS2_COMPAT); } public boolean externalServiceDiscovery() { diff --git a/src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java b/src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java index 21c957a0f..c3f847eca 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java +++ b/src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java @@ -1,5 +1,6 @@ package eu.siacs.conversations.xmpp.bind; +import com.google.common.base.Predicates; import com.google.common.collect.Collections2; import java.util.Arrays; @@ -27,7 +28,12 @@ public class Bind2 { if (inlineBind2Inline == null) { return Collections.emptyList(); } - return Collections2.transform( - inlineBind2Inline.getChildren(), c -> c == null ? null : c.getAttribute("var")); + return Collections2.filter( + Collections2.transform( + Collections2.filter( + inlineBind2Inline.getChildren(), + c -> "feature".equals(c.getName())), + c -> c.getAttribute("var")), + Predicates.notNull()); } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractContentMap.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractContentMap.java new file mode 100644 index 000000000..847678a05 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractContentMap.java @@ -0,0 +1,82 @@ +package eu.siacs.conversations.xmpp.jingle; + +import com.google.common.collect.Collections2; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.Group; +import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public abstract class AbstractContentMap< + D extends GenericDescription, T extends GenericTransportInfo> { + + public final Group group; + + public final Map> contents; + + protected AbstractContentMap( + final Group group, final Map> contents) { + this.group = group; + this.contents = contents; + } + + public static class UnsupportedApplicationException extends IllegalArgumentException { + UnsupportedApplicationException(String message) { + super(message); + } + } + + public static class UnsupportedTransportException extends IllegalArgumentException { + UnsupportedTransportException(String message) { + super(message); + } + } + + public Set getSenders() { + return ImmutableSet.copyOf(Collections2.transform(contents.values(), dt -> dt.senders)); + } + + public List getNames() { + return ImmutableList.copyOf(contents.keySet()); + } + + JinglePacket toJinglePacket(final JinglePacket.Action action, final String sessionId) { + final JinglePacket jinglePacket = new JinglePacket(action, sessionId); + for (final Map.Entry> entry : this.contents.entrySet()) { + final DescriptionTransport descriptionTransport = entry.getValue(); + final Content content = + new Content( + Content.Creator.INITIATOR, + descriptionTransport.senders, + entry.getKey()); + if (descriptionTransport.description != null) { + content.addChild(descriptionTransport.description); + } + content.addChild(descriptionTransport.transport); + jinglePacket.addJingleContent(content); + } + if (this.group != null) { + jinglePacket.addGroup(this.group); + } + return jinglePacket; + } + + void requireContentDescriptions() { + if (this.contents.size() == 0) { + throw new IllegalStateException("No contents available"); + } + for (final Map.Entry> entry : this.contents.entrySet()) { + if (entry.getValue().description == null) { + throw new IllegalStateException( + String.format("%s is lacking content description", entry.getKey())); + } + } + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java index d719c729e..efc32f5ff 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java @@ -1,47 +1,352 @@ package eu.siacs.conversations.xmpp.jingle; +import android.util.Log; + import androidx.annotation.NonNull; import com.google.common.base.MoreObjects; import com.google.common.base.Objects; import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import eu.siacs.conversations.Config; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.entities.Presence; +import eu.siacs.conversations.entities.ServiceDiscoveryResult; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; +import eu.siacs.conversations.xmpp.jingle.stanzas.Reason; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; public abstract class AbstractJingleConnection { public static final String JINGLE_MESSAGE_PROPOSE_ID_PREFIX = "jm-propose-"; public static final String JINGLE_MESSAGE_PROCEED_ID_PREFIX = "jm-proceed-"; + protected static final List TERMINATED = + Arrays.asList( + State.ACCEPTED, + State.REJECTED, + State.REJECTED_RACED, + State.RETRACTED, + State.RETRACTED_RACED, + State.TERMINATED_SUCCESS, + State.TERMINATED_DECLINED_OR_BUSY, + State.TERMINATED_CONNECTIVITY_ERROR, + State.TERMINATED_CANCEL_OR_TIMEOUT, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR); + + private static final Map> VALID_TRANSITIONS; + + static { + final ImmutableMap.Builder> transitionBuilder = + new ImmutableMap.Builder<>(); + transitionBuilder.put( + State.NULL, + ImmutableList.of( + State.PROPOSED, + State.SESSION_INITIALIZED, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR)); + transitionBuilder.put( + State.PROPOSED, + ImmutableList.of( + State.ACCEPTED, + State.PROCEED, + State.REJECTED, + State.RETRACTED, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR, + State.TERMINATED_CONNECTIVITY_ERROR // only used when the xmpp connection + // rebinds + )); + transitionBuilder.put( + State.PROCEED, + ImmutableList.of( + State.REJECTED_RACED, + State.RETRACTED_RACED, + State.SESSION_INITIALIZED_PRE_APPROVED, + State.TERMINATED_SUCCESS, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR, + State.TERMINATED_CONNECTIVITY_ERROR // at this state used for error + // bounces of the proceed message + )); + transitionBuilder.put( + State.SESSION_INITIALIZED, + ImmutableList.of( + State.SESSION_ACCEPTED, + State.TERMINATED_SUCCESS, + State.TERMINATED_DECLINED_OR_BUSY, + State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors + // and IQ timeouts + State.TERMINATED_CANCEL_OR_TIMEOUT, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR)); + transitionBuilder.put( + State.SESSION_INITIALIZED_PRE_APPROVED, + ImmutableList.of( + State.SESSION_ACCEPTED, + State.TERMINATED_SUCCESS, + State.TERMINATED_DECLINED_OR_BUSY, + State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors + // and IQ timeouts + State.TERMINATED_CANCEL_OR_TIMEOUT, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR)); + transitionBuilder.put( + State.SESSION_ACCEPTED, + ImmutableList.of( + State.TERMINATED_SUCCESS, + State.TERMINATED_DECLINED_OR_BUSY, + State.TERMINATED_CONNECTIVITY_ERROR, + State.TERMINATED_CANCEL_OR_TIMEOUT, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR)); + VALID_TRANSITIONS = transitionBuilder.build(); + } + final JingleConnectionManager jingleConnectionManager; protected final XmppConnectionService xmppConnectionService; protected final Id id; private final Jid initiator; - AbstractJingleConnection(final JingleConnectionManager jingleConnectionManager, final Id id, final Jid initiator) { + protected State state = State.NULL; + + AbstractJingleConnection( + final JingleConnectionManager jingleConnectionManager, + final Id id, + final Jid initiator) { this.jingleConnectionManager = jingleConnectionManager; this.xmppConnectionService = jingleConnectionManager.getXmppConnectionService(); this.id = id; this.initiator = initiator; } - boolean isInitiator() { - return initiator.equals(id.account.getJid()); - } - - abstract void deliverPacket(JinglePacket jinglePacket); - public Id getId() { return id; } + boolean isInitiator() { + return initiator.equals(id.account.getJid()); + } + + boolean isResponder() { + return !initiator.equals(id.account.getJid()); + } + + public State getState() { + return this.state; + } + + protected synchronized boolean isInState(State... state) { + return Arrays.asList(state).contains(this.state); + } + + protected boolean transition(final State target) { + return transition(target, null); + } + + protected synchronized boolean transition(final State target, final Runnable runnable) { + final Collection validTransitions = VALID_TRANSITIONS.get(this.state); + if (validTransitions != null && validTransitions.contains(target)) { + this.state = target; + if (runnable != null) { + runnable.run(); + } + Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target); + return true; + } else { + return false; + } + } + + protected void transitionOrThrow(final State target) { + if (!transition(target)) { + throw new IllegalStateException( + String.format("Unable to transition from %s to %s", this.state, target)); + } + } + + boolean isTerminated() { + return TERMINATED.contains(this.state); + } + + abstract void deliverPacket(JinglePacket jinglePacket); + + protected void receiveOutOfOrderAction( + final JinglePacket jinglePacket, final JinglePacket.Action action) { + Log.d( + Config.LOGTAG, + String.format( + "%s: received %s even though we are in state %s", + id.account.getJid().asBareJid(), action, getState())); + if (isTerminated()) { + Log.d( + Config.LOGTAG, + String.format( + "%s: got a reason to terminate with out-of-order. but already in state %s", + id.account.getJid().asBareJid(), getState())); + respondWithOutOfOrder(jinglePacket); + } else { + terminateWithOutOfOrder(jinglePacket); + } + } + + protected void terminateWithOutOfOrder(final JinglePacket jinglePacket) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": terminating session with out-of-order"); + terminateTransport(); + transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE); + respondWithOutOfOrder(jinglePacket); + this.finish(); + } + + protected void finish() { + if (isTerminated()) { + this.jingleConnectionManager.finishConnectionOrThrow(this); + } else { + throw new AssertionError( + String.format("Unable to call finish from %s", this.state)); + } + } + + protected abstract void terminateTransport(); + abstract void notifyRebound(); + protected void sendSessionTerminate( + final Reason reason, final String text, final Consumer trigger) { + final State previous = this.state; + final State target = reasonToState(reason); + transitionOrThrow(target); + if (previous != State.NULL && trigger != null) { + trigger.accept(target); + } + final JinglePacket jinglePacket = + new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId); + jinglePacket.setReason(reason, text); + send(jinglePacket); + finish(); + } + + protected void send(final JinglePacket jinglePacket) { + jinglePacket.setTo(id.with); + xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse); + } + + protected void respondOk(final JinglePacket jinglePacket) { + xmppConnectionService.sendIqPacket( + id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null); + } + + protected void respondWithTieBreak(final JinglePacket jinglePacket) { + respondWithJingleError(jinglePacket, "tie-break", "conflict", "cancel"); + } + + protected void respondWithOutOfOrder(final JinglePacket jinglePacket) { + respondWithJingleError(jinglePacket, "out-of-order", "unexpected-request", "wait"); + } + + protected void respondWithItemNotFound(final JinglePacket jinglePacket) { + respondWithJingleError(jinglePacket, null, "item-not-found", "cancel"); + } + + private void respondWithJingleError( + final IqPacket original, + String jingleCondition, + String condition, + String conditionType) { + jingleConnectionManager.respondWithJingleError( + id.account, original, jingleCondition, condition, conditionType); + } + + private synchronized void handleIqResponse(final Account account, final IqPacket response) { + if (response.getType() == IqPacket.TYPE.ERROR) { + handleIqErrorResponse(response); + return; + } + if (response.getType() == IqPacket.TYPE.TIMEOUT) { + handleIqTimeoutResponse(response); + } + } + + protected void handleIqErrorResponse(final IqPacket response) { + Preconditions.checkArgument(response.getType() == IqPacket.TYPE.ERROR); + final String errorCondition = response.getErrorCondition(); + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": received IQ-error from " + + response.getFrom() + + " in RTP session. " + + errorCondition); + if (isTerminated()) { + Log.i( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": ignoring error because session was already terminated"); + return; + } + this.terminateTransport(); + final State target; + if (Arrays.asList( + "service-unavailable", + "recipient-unavailable", + "remote-server-not-found", + "remote-server-timeout") + .contains(errorCondition)) { + target = State.TERMINATED_CONNECTIVITY_ERROR; + } else { + target = State.TERMINATED_APPLICATION_FAILURE; + } + transitionOrThrow(target); + this.finish(); + } + + protected void handleIqTimeoutResponse(final IqPacket response) { + Preconditions.checkArgument(response.getType() == IqPacket.TYPE.TIMEOUT); + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": received IQ timeout in RTP session with " + + id.with + + ". terminating with connectivity error"); + if (isTerminated()) { + Log.i( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": ignoring error because session was already terminated"); + return; + } + this.terminateTransport(); + transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR); + this.finish(); + } + + protected boolean remoteHasFeature(final String feature) { + final Contact contact = id.getContact(); + final Presence presence = + contact.getPresences().get(Strings.nullToEmpty(id.with.getResource())); + final ServiceDiscoveryResult serviceDiscoveryResult = + presence == null ? null : presence.getServiceDiscoveryResult(); + final List features = + serviceDiscoveryResult == null ? null : serviceDiscoveryResult.getFeatures(); + return features != null && features.contains(feature); + } public static class Id implements OngoingRtpSession { public final Account account; @@ -73,8 +378,7 @@ public abstract class AbstractJingleConnection { return new Id( message.getConversation().getAccount(), message.getCounterpart(), - JingleConnectionManager.nextRandomId() - ); + JingleConnectionManager.nextRandomId()); } public Contact getContact() { @@ -86,9 +390,9 @@ public abstract class AbstractJingleConnection { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Id id = (Id) o; - return Objects.equal(account.getUuid(), id.account.getUuid()) && - Objects.equal(with, id.with) && - Objects.equal(sessionId, id.sessionId); + return Objects.equal(account.getUuid(), id.account.getUuid()) + && Objects.equal(with, id.with) + && Objects.equal(sessionId, id.sessionId); } @Override @@ -122,23 +426,36 @@ public abstract class AbstractJingleConnection { } } + protected static State reasonToState(Reason reason) { + return switch (reason) { + case SUCCESS -> State.TERMINATED_SUCCESS; + case DECLINE, BUSY -> State.TERMINATED_DECLINED_OR_BUSY; + case CANCEL, TIMEOUT -> State.TERMINATED_CANCEL_OR_TIMEOUT; + case SECURITY_ERROR -> State.TERMINATED_SECURITY_ERROR; + case FAILED_APPLICATION, UNSUPPORTED_TRANSPORTS, UNSUPPORTED_APPLICATIONS -> State + .TERMINATED_APPLICATION_FAILURE; + default -> State.TERMINATED_CONNECTIVITY_ERROR; + }; + } public enum State { - NULL, //default value; nothing has been sent or received yet + NULL, // default value; nothing has been sent or received yet PROPOSED, ACCEPTED, PROCEED, REJECTED, - REJECTED_RACED, //used when we want to reject but haven’t received session init yet + REJECTED_RACED, // used when we want to reject but haven’t received session init yet RETRACTED, - RETRACTED_RACED, //used when receiving a retract after we already asked to proceed - SESSION_INITIALIZED, //equal to 'PENDING' + RETRACTED_RACED, // used when receiving a retract after we already asked to proceed + SESSION_INITIALIZED, // equal to 'PENDING' SESSION_INITIALIZED_PRE_APPROVED, - SESSION_ACCEPTED, //equal to 'ACTIVE' - TERMINATED_SUCCESS, //equal to 'ENDED' (after successful call) ui will just close - TERMINATED_DECLINED_OR_BUSY, //equal to 'ENDED' (after other party declined the call) - TERMINATED_CONNECTIVITY_ERROR, //equal to 'ENDED' (but after network failures; ui will display retry button) - TERMINATED_CANCEL_OR_TIMEOUT, //more or less the same as retracted; caller pressed end call before session was accepted + SESSION_ACCEPTED, // equal to 'ACTIVE' + TERMINATED_SUCCESS, // equal to 'ENDED' (after successful call) ui will just close + TERMINATED_DECLINED_OR_BUSY, // equal to 'ENDED' (after other party declined the call) + TERMINATED_CONNECTIVITY_ERROR, // equal to 'ENDED' (but after network failures; ui will + // display retry button) + TERMINATED_CANCEL_OR_TIMEOUT, // more or less the same as retracted; caller pressed end call + // before session was accepted TERMINATED_APPLICATION_FAILURE, TERMINATED_SECURITY_ERROR } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java index 97bf802fd..ab2dffc6d 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java @@ -1,5 +1,7 @@ package eu.siacs.conversations.xmpp.jingle; +import androidx.annotation.NonNull; + import com.google.common.base.MoreObjects; import com.google.common.base.Objects; import com.google.common.collect.Collections2; @@ -8,6 +10,8 @@ import com.google.common.collect.ImmutableSet; import java.util.Set; import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; public final class ContentAddition { @@ -32,12 +36,13 @@ public final class ContentAddition { Collections2.transform( rtpContentMap.contents.entrySet(), e -> { - final RtpContentMap.DescriptionTransport dt = e.getValue(); + final DescriptionTransport dt = e.getValue(); return new Summary(e.getKey(), dt.description.getMedia(), dt.senders); })); } @Override + @NonNull public String toString() { return MoreObjects.toStringHelper(this) .add("direction", direction) @@ -77,6 +82,7 @@ public final class ContentAddition { } @Override + @NonNull public String toString() { return MoreObjects.toStringHelper(this) .add("name", name) diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/DescriptionTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/DescriptionTransport.java new file mode 100644 index 000000000..70d6c512c --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/DescriptionTransport.java @@ -0,0 +1,19 @@ +package eu.siacs.conversations.xmpp.jingle; + +import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; + +public class DescriptionTransport { + + public final Content.Senders senders; + public final D description; + public final T transport; + + public DescriptionTransport( + final Content.Senders senders, final D description, final T transport) { + this.senders = senders; + this.description = description; + this.transport = transport; + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/DirectConnectionUtils.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/DirectConnectionUtils.java index 83a2b95e4..a2a5c4032 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/DirectConnectionUtils.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/DirectConnectionUtils.java @@ -1,5 +1,7 @@ package eu.siacs.conversations.xmpp.jingle; +import com.google.common.collect.ImmutableList; + import java.net.Inet6Address; import java.net.InetAddress; import java.net.NetworkInterface; @@ -15,13 +17,13 @@ import eu.siacs.conversations.xmpp.Jid; public class DirectConnectionUtils { - private static List getLocalAddresses() { - final List addresses = new ArrayList<>(); + public static List getLocalAddresses() { + final ImmutableList.Builder inetAddresses = new ImmutableList.Builder<>(); final Enumeration interfaces; try { interfaces = NetworkInterface.getNetworkInterfaces(); - } catch (SocketException e) { - return addresses; + } catch (final SocketException e) { + return inetAddresses.build(); } while (interfaces.hasMoreElements()) { NetworkInterface networkInterface = interfaces.nextElement(); @@ -34,31 +36,15 @@ public class DirectConnectionUtils { if (inetAddress instanceof Inet6Address) { //let's get rid of scope try { - addresses.add(Inet6Address.getByAddress(inetAddress.getAddress())); + inetAddresses.add(Inet6Address.getByAddress(inetAddress.getAddress())); } catch (UnknownHostException e) { //ignored } } else { - addresses.add(inetAddress); + inetAddresses.add(inetAddress); } } } - return addresses; + return inetAddresses.build(); } - - public static List getLocalCandidates(Jid jid) { - SecureRandom random = new SecureRandom(); - ArrayList candidates = new ArrayList<>(); - for (InetAddress inetAddress : getLocalAddresses()) { - final JingleCandidate candidate = new JingleCandidate(UUID.randomUUID().toString(), true); - candidate.setHost(inetAddress.getHostAddress()); - candidate.setPort(random.nextInt(60000) + 1024); - candidate.setType(JingleCandidate.TYPE_DIRECT); - candidate.setJid(jid); - candidate.setPriority(8257536 + candidates.size()); - candidates.add(candidate); - } - return candidates; - } - } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/FileTransferContentMap.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/FileTransferContentMap.java new file mode 100644 index 000000000..c678c91cb --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/FileTransferContentMap.java @@ -0,0 +1,219 @@ +package eu.siacs.conversations.xmpp.jingle; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import com.google.common.collect.Maps; + +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.Group; +import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; +import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.SocksByteStreamsTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo; +import eu.siacs.conversations.xmpp.jingle.transports.Transport; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class FileTransferContentMap + extends AbstractContentMap { + + private static final List> SUPPORTED_TRANSPORTS = + Arrays.asList( + SocksByteStreamsTransportInfo.class, + IbbTransportInfo.class, + WebRTCDataChannelTransportInfo.class); + + protected FileTransferContentMap( + final Group group, final Map> + contents) { + super(group, contents); + } + + public static FileTransferContentMap of(final JinglePacket jinglePacket) { + final Map> + contents = of(jinglePacket.getJingleContents()); + return new FileTransferContentMap(jinglePacket.getGroup(), contents); + } + + public static DescriptionTransport of( + final Content content) { + final GenericDescription description = content.getDescription(); + final GenericTransportInfo transportInfo = content.getTransport(); + final Content.Senders senders = content.getSenders(); + final FileTransferDescription fileTransferDescription; + if (description == null) { + fileTransferDescription = null; + } else if (description instanceof FileTransferDescription ftDescription) { + fileTransferDescription = ftDescription; + } else { + throw new UnsupportedApplicationException( + "Content does not contain file transfer description"); + } + if (!SUPPORTED_TRANSPORTS.contains(transportInfo.getClass())) { + throw new UnsupportedTransportException("Content does not have supported transport"); + } + return new DescriptionTransport<>(senders, fileTransferDescription, transportInfo); + } + + private static Map> + of(final Map contents) { + return ImmutableMap.copyOf( + Maps.transformValues(contents, content -> content == null ? null : of(content))); + } + + public static FileTransferContentMap of( + final FileTransferDescription.File file, final Transport.InitialTransportInfo initialTransportInfo) { + // TODO copy groups + final var transportInfo = initialTransportInfo.transportInfo; + return new FileTransferContentMap(initialTransportInfo.group, + Map.of( + initialTransportInfo.contentName, + new DescriptionTransport<>( + Content.Senders.INITIATOR, + FileTransferDescription.of(file), + transportInfo))); + } + + public FileTransferDescription.File requireOnlyFile() { + if (this.contents.size() != 1) { + throw new IllegalStateException("Only one file at a time is supported"); + } + final var dt = Iterables.getOnlyElement(this.contents.values()); + return dt.description.getFile(); + } + + public FileTransferDescription requireOnlyFileTransferDescription() { + if (this.contents.size() != 1) { + throw new IllegalStateException("Only one file at a time is supported"); + } + final var dt = Iterables.getOnlyElement(this.contents.values()); + return dt.description; + } + + public GenericTransportInfo requireOnlyTransportInfo() { + if (this.contents.size() != 1) { + throw new IllegalStateException( + "We expect exactly one content with one transport info"); + } + final var dt = Iterables.getOnlyElement(this.contents.values()); + return dt.transport; + } + + public FileTransferContentMap withTransport(final Transport.TransportInfo transportWrapper) { + final var transportInfo = transportWrapper.transportInfo; + return new FileTransferContentMap(transportWrapper.group, + ImmutableMap.copyOf( + Maps.transformValues( + contents, + content -> { + if (content == null) { + return null; + } + return new DescriptionTransport<>( + content.senders, content.description, transportInfo); + }))); + } + + public FileTransferContentMap candidateUsed(final String streamId, final String cid) { + return new FileTransferContentMap(null, + ImmutableMap.copyOf( + Maps.transformValues( + contents, + content -> { + if (content == null) { + return null; + } + final var transportInfo = + new SocksByteStreamsTransportInfo( + streamId, Collections.emptyList()); + final Element candidateUsed = + transportInfo.addChild( + "candidate-used", + Namespace.JINGLE_TRANSPORTS_S5B); + candidateUsed.setAttribute("cid", cid); + return new DescriptionTransport<>( + content.senders, null, transportInfo); + }))); + } + + public FileTransferContentMap candidateError(final String streamId) { + return new FileTransferContentMap(null, + ImmutableMap.copyOf( + Maps.transformValues( + contents, + content -> { + if (content == null) { + return null; + } + final var transportInfo = + new SocksByteStreamsTransportInfo( + streamId, Collections.emptyList()); + transportInfo.addChild( + "candidate-error", Namespace.JINGLE_TRANSPORTS_S5B); + return new DescriptionTransport<>( + content.senders, null, transportInfo); + }))); + } + + public FileTransferContentMap proxyActivated(final String streamId, final String cid) { + return new FileTransferContentMap(null, + ImmutableMap.copyOf( + Maps.transformValues( + contents, + content -> { + if (content == null) { + return null; + } + final var transportInfo = + new SocksByteStreamsTransportInfo( + streamId, Collections.emptyList()); + final Element candidateUsed = + transportInfo.addChild( + "activated", Namespace.JINGLE_TRANSPORTS_S5B); + candidateUsed.setAttribute("cid", cid); + return new DescriptionTransport<>( + content.senders, null, transportInfo); + }))); + } + + FileTransferContentMap transportInfo() { + return new FileTransferContentMap(this.group, + Maps.transformValues( + contents, + dt -> new DescriptionTransport<>(dt.senders, null, dt.transport))); + } + + FileTransferContentMap transportInfo( + final String contentName, final IceUdpTransportInfo.Candidate candidate) { + final DescriptionTransport descriptionTransport = + contents.get(contentName); + if (descriptionTransport == null) { + throw new IllegalArgumentException( + "Unable to find transport info for content name " + contentName); + } + final WebRTCDataChannelTransportInfo transportInfo; + if (descriptionTransport.transport instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) { + transportInfo = webRTCDataChannelTransportInfo; + } else { + throw new IllegalStateException("TransportInfo is not WebRTCDataChannel"); + } + final WebRTCDataChannelTransportInfo newTransportInfo = transportInfo.cloneWrapper(); + newTransportInfo.addCandidate(candidate); + return new FileTransferContentMap( + null, + ImmutableMap.of( + contentName, + new DescriptionTransport<>( + descriptionTransport.senders, null, newTransportInfo))); + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/IceServers.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/IceServers.java new file mode 100644 index 000000000..7b2f88457 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/IceServers.java @@ -0,0 +1,98 @@ +package eu.siacs.conversations.xmpp.jingle; + +import android.util.Log; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Ints; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.utils.IP; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +import org.webrtc.PeerConnection; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public final class IceServers { + + public static List parse(final IqPacket response) { + ImmutableList.Builder listBuilder = new ImmutableList.Builder<>(); + if (response.getType() == IqPacket.TYPE.RESULT) { + final Element services = + response.findChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY); + final List children = + services == null ? Collections.emptyList() : services.getChildren(); + for (final Element child : children) { + if ("service".equals(child.getName())) { + final String type = child.getAttribute("type"); + final String host = child.getAttribute("host"); + final String sport = child.getAttribute("port"); + final Integer port = sport == null ? null : Ints.tryParse(sport); + final String transport = child.getAttribute("transport"); + final String username = child.getAttribute("username"); + final String password = child.getAttribute("password"); + if (Strings.isNullOrEmpty(host) || port == null) { + continue; + } + if (port < 0 || port > 65535) { + continue; + } + + if (Arrays.asList("stun", "stuns", "turn", "turns").contains(type) + && Arrays.asList("udp", "tcp").contains(transport)) { + if (Arrays.asList("stuns", "turns").contains(type) + && "udp".equals(transport)) { + Log.w( + Config.LOGTAG, + "skipping invalid combination of udp/tls in external services"); + continue; + } + + // STUN URLs do not support a query section since M110 + final String uri; + if (Arrays.asList("stun", "stuns").contains(type)) { + uri = String.format("%s:%s:%s", type, IP.wrapIPv6(host), port); + } else { + uri = + String.format( + "%s:%s:%s?transport=%s", + type, IP.wrapIPv6(host), port, transport); + } + + final PeerConnection.IceServer.Builder iceServerBuilder = + PeerConnection.IceServer.builder(uri); + iceServerBuilder.setTlsCertPolicy( + PeerConnection.TlsCertPolicy.TLS_CERT_POLICY_INSECURE_NO_CHECK); + if (username != null && password != null) { + iceServerBuilder.setUsername(username); + iceServerBuilder.setPassword(password); + } else if (Arrays.asList("turn", "turns").contains(type)) { + // The WebRTC spec requires throwing an + // InvalidAccessError when username (from libwebrtc + // source coder) + // https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc + Log.w( + Config.LOGTAG, + "skipping " + + type + + "/" + + transport + + " without username and password"); + continue; + } + final PeerConnection.IceServer iceServer = + iceServerBuilder.createIceServer(); + Log.w(Config.LOGTAG, "discovered ICE Server: " + iceServer); + listBuilder.add(iceServer); + } + } + } + } + return listBuilder.build(); + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java deleted file mode 100644 index 78ffb28be..000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java +++ /dev/null @@ -1,152 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle; - -import java.util.ArrayList; -import java.util.List; - -import eu.siacs.conversations.xml.Element; -import eu.siacs.conversations.xmpp.InvalidJid; -import eu.siacs.conversations.xmpp.Jid; - -public class JingleCandidate { - - public static int TYPE_UNKNOWN; - public static int TYPE_DIRECT = 0; - public static int TYPE_PROXY = 1; - - private final boolean ours; - private boolean usedByCounterpart = false; - private final String cid; - private String host; - private int port; - private int type; - private Jid jid; - private int priority; - - public JingleCandidate(String cid, boolean ours) { - this.ours = ours; - this.cid = cid; - } - - public String getCid() { - return cid; - } - - public void setHost(String host) { - this.host = host; - } - - public String getHost() { - return this.host; - } - - public void setJid(final Jid jid) { - this.jid = jid; - } - - public Jid getJid() { - return this.jid; - } - - public void setPort(int port) { - this.port = port; - } - - public int getPort() { - return this.port; - } - - public void setType(int type) { - this.type = type; - } - - public void setType(String type) { - if (type == null) { - this.type = TYPE_UNKNOWN; - return; - } - switch (type) { - case "proxy": - this.type = TYPE_PROXY; - break; - case "direct": - this.type = TYPE_DIRECT; - break; - default: - this.type = TYPE_UNKNOWN; - break; - } - } - - public void setPriority(int i) { - this.priority = i; - } - - public int getPriority() { - return this.priority; - } - - public boolean equals(JingleCandidate other) { - return this.getCid().equals(other.getCid()); - } - - public boolean equalValues(JingleCandidate other) { - return other != null && other.getHost().equals(this.getHost()) && (other.getPort() == this.getPort()); - } - - public boolean isOurs() { - return ours; - } - - public int getType() { - return this.type; - } - - public static List parse(final List elements) { - final List candidates = new ArrayList<>(); - for (final Element element : elements) { - if ("candidate".equals(element.getName())) { - candidates.add(JingleCandidate.parse(element)); - } - } - return candidates; - } - - public static JingleCandidate parse(Element element) { - final JingleCandidate candidate = new JingleCandidate(element.getAttribute("cid"), false); - candidate.setHost(element.getAttribute("host")); - candidate.setJid(InvalidJid.getNullForInvalid(element.getAttributeAsJid("jid"))); - candidate.setType(element.getAttribute("type")); - candidate.setPriority(Integer.parseInt(element.getAttribute("priority"))); - candidate.setPort(Integer.parseInt(element.getAttribute("port"))); - return candidate; - } - - public Element toElement() { - Element element = new Element("candidate"); - element.setAttribute("cid", this.getCid()); - element.setAttribute("host", this.getHost()); - element.setAttribute("port", Integer.toString(this.getPort())); - if (jid != null) { - element.setAttribute("jid", jid); - } - element.setAttribute("priority", Integer.toString(this.getPriority())); - if (this.getType() == TYPE_DIRECT) { - element.setAttribute("type", "direct"); - } else if (this.getType() == TYPE_PROXY) { - element.setAttribute("type", "proxy"); - } - return element; - } - - public void flagAsUsedByCounterpart() { - this.usedByCounterpart = true; - } - - public boolean isUsedByCounterpart() { - return this.usedByCounterpart; - } - - public String toString() { - return String.format("%s:%s (priority=%s,ours=%s)", getHost(), getPort(), getPriority(), isOurs()); - } -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java index b39673fa5..23d4f175b 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java @@ -12,20 +12,6 @@ import com.google.common.collect.Collections2; import com.google.common.collect.ComparisonChain; import com.google.common.collect.ImmutableSet; -import java.lang.ref.WeakReference; -import java.security.SecureRandom; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - import eu.siacs.conversations.Config; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; @@ -39,18 +25,33 @@ import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.xml.Element; import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; -import eu.siacs.conversations.xmpp.OnIqPacketReceived; import eu.siacs.conversations.xmpp.XmppConnection; import eu.siacs.conversations.xmpp.jingle.stanzas.Content; import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription; import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; import eu.siacs.conversations.xmpp.jingle.stanzas.Propose; import eu.siacs.conversations.xmpp.jingle.stanzas.Reason; import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; +import eu.siacs.conversations.xmpp.jingle.transports.InbandBytestreamsTransport; +import eu.siacs.conversations.xmpp.jingle.transports.Transport; import eu.siacs.conversations.xmpp.stanzas.IqPacket; import eu.siacs.conversations.xmpp.stanzas.MessagePacket; +import java.lang.ref.WeakReference; +import java.security.SecureRandom; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + public class JingleConnectionManager extends AbstractConnectionManager { static final ScheduledExecutorService SCHEDULED_EXECUTOR_SERVICE = Executors.newSingleThreadScheduledExecutor(); @@ -63,8 +64,6 @@ public class JingleConnectionManager extends AbstractConnectionManager { private final Cache terminatedSessions = CacheBuilder.newBuilder().expireAfterWrite(24, TimeUnit.HOURS).build(); - private final HashMap primaryCandidates = new HashMap<>(); - public JingleConnectionManager(XmppConnectionService service) { super(service); this.toneManager = new ToneManager(service); @@ -73,7 +72,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { static String nextRandomId() { final byte[] id = new byte[16]; new SecureRandom().nextBytes(id); - return Base64.encodeToString(id, Base64.NO_WRAP | Base64.NO_PADDING); + return Base64.encodeToString(id, Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE); } public void deliverPacket(final Account account, final JinglePacket packet) { @@ -92,7 +91,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { final String descriptionNamespace = content == null ? null : content.getDescriptionNamespace(); final AbstractJingleConnection connection; - if (FileTransferDescription.NAMESPACES.contains(descriptionNamespace)) { + if (Namespace.JINGLE_APPS_FILE_TRANSFER.equals(descriptionNamespace)) { connection = new JingleFileTransferConnection(this, id, from); } else if (Namespace.JINGLE_APPS_RTP.equals(descriptionNamespace) && isUsingClearNet(account)) { @@ -100,7 +99,8 @@ public class JingleConnectionManager extends AbstractConnectionManager { this.terminatedSessions.asMap().containsKey(PersistableSessionId.of(id)); final boolean stranger = isWithStrangerAndStrangerNotificationsAreOff(account, id.with); - if (isBusy() || sessionEnded || stranger) { + final boolean busy = isBusy(); + if (busy || sessionEnded || stranger) { Log.d( Config.LOGTAG, id.account.getJid().asBareJid() @@ -117,6 +117,15 @@ public class JingleConnectionManager extends AbstractConnectionManager { sessionTermination.setTo(id.with); sessionTermination.setReason(Reason.BUSY, null); mXmppConnectionService.sendIqPacket(account, sessionTermination, null); + if (busy || stranger) { + writeLogMissedIncoming( + account, + id.with, + id.sessionId, + null, + System.currentTimeMillis(), + stranger); + } return; } connection = new JingleRtpConnection(this, id, from); @@ -158,10 +167,23 @@ public class JingleConnectionManager extends AbstractConnectionManager { } } + public boolean hasJingleRtpConnection(final Account account) { + for (AbstractJingleConnection connection : this.connections.values()) { + if (connection instanceof JingleRtpConnection rtpConnection) { + if (rtpConnection.isTerminated()) { + continue; + } + if (rtpConnection.id.account == account) { + return true; + } + } + } + return false; + } + public void notifyPhoneCallStarted() { for (AbstractJingleConnection connection : connections.values()) { - if (connection instanceof JingleRtpConnection) { - final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection; + if (connection instanceof JingleRtpConnection rtpConnection) { if (rtpConnection.isTerminated()) { continue; } @@ -195,8 +217,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { private boolean hasMatchingRtpSession( final Account account, final Jid with, final Set media) { for (AbstractJingleConnection connection : this.connections.values()) { - if (connection instanceof JingleRtpConnection) { - final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection; + if (connection instanceof JingleRtpConnection rtpConnection) { if (rtpConnection.isTerminated()) { continue; } @@ -235,7 +256,9 @@ public class JingleConnectionManager extends AbstractConnectionManager { final Element error = response.addChild("error"); error.setAttribute("type", conditionType); error.addChild(condition, "urn:ietf:params:xml:ns:xmpp-stanzas"); - error.addChild(jingleCondition, Namespace.JINGLE_ERRORS); + if (jingleCondition != null) { + error.addChild(jingleCondition, Namespace.JINGLE_ERRORS); + } account.getXmppConnection().sendIqPacket(response, null); } @@ -254,8 +277,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { } if ("accept".equals(message.getName())) { for (AbstractJingleConnection connection : connections.values()) { - if (connection instanceof JingleRtpConnection) { - final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection; + if (connection instanceof JingleRtpConnection rtpConnection) { final AbstractJingleConnection.Id id = connection.getId(); if (id.account == account && id.sessionId.equals(sessionId)) { rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp); @@ -266,6 +288,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { return; } final boolean fromSelf = from.asBareJid().equals(account.getJid().asBareJid()); + // XEP version 0.6.0 sends proceed, reject, ringing to bare jid final boolean addressedDirectly = to != null && to.equals(account.getJid()); final AbstractJingleConnection.Id id; if (fromSelf) { @@ -310,6 +333,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { Config.LOGTAG, id.account.getJid().asBareJid() + ": updated previous busy because call got picked up by another device"); + mXmppConnectionService.getNotificationService().clearMissedCall(previousBusy); return; } } @@ -366,12 +390,14 @@ public class JingleConnectionManager extends AbstractConnectionManager { this.connections.put(id, rtpConnection); rtpConnection.setProposedMedia(ImmutableSet.copyOf(media)); rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp); + // TODO actually do the automatic accept?! } else { Log.d( Config.LOGTAG, account.getJid().asBareJid() + ": our session won tie break. waiting for other party to accept. winningSession=" + ourSessionId); + // TODO reject their session with ? } return; } @@ -379,7 +405,12 @@ public class JingleConnectionManager extends AbstractConnectionManager { isWithStrangerAndStrangerNotificationsAreOff(account, id.with); if (isBusy() || stranger) { writeLogMissedIncoming( - account, id.with.asBareJid(), id.sessionId, serverMsgId, timestamp); + account, + id.with.asBareJid(), + id.sessionId, + serverMsgId, + timestamp, + stranger); if (stranger) { Log.d( Config.LOGTAG, @@ -435,7 +466,9 @@ public class JingleConnectionManager extends AbstractConnectionManager { Log.d( Config.LOGTAG, account.getJid().asBareJid() - + ": no rtp session proposal found for " + + ": no rtp session (" + + sessionId + + ") proposal found for " + from + " to deliver proceed"); if (remoteMsgId == null) { @@ -474,12 +507,19 @@ public class JingleConnectionManager extends AbstractConnectionManager { + " to deliver reject"); } } + } else if (addressedDirectly && "ringing".equals(message.getName())) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + from + " started ringing"); + updateProposedSessionDiscovered( + account, from, sessionId, DeviceDiscoveryState.DISCOVERED); } else { Log.d( Config.LOGTAG, - account.getJid().asBareJid() - + ": retrieved out of order jingle message" - + message); + account.getJid() + + ": retrieved out of order jingle message from " + + from + + message + + ", addressedDirectly=" + + addressedDirectly); } } @@ -514,10 +554,11 @@ public class JingleConnectionManager extends AbstractConnectionManager { private void writeLogMissedIncoming( final Account account, - Jid with, + final Jid with, final String sessionId, - String serverMsgId, - long timestamp) { + final String serverMsgId, + final long timestamp, + final boolean stranger) { final Conversation conversation = mXmppConnectionService.findOrCreateConversation( account, with.asBareJid(), false, false); @@ -527,7 +568,12 @@ public class JingleConnectionManager extends AbstractConnectionManager { message.setBody(new RtpSessionStatus(false, 0).toString()); message.setServerMsgId(serverMsgId); message.setTime(timestamp); + message.setCounterpart(with); writeMessage(message); + if (stranger) { + return; + } + mXmppConnectionService.getNotificationService().pushMissedCallNow(message); } private void writeMessage(final Message message) { @@ -548,13 +594,10 @@ public class JingleConnectionManager extends AbstractConnectionManager { if (old != null) { old.cancel(); } - final Account account = message.getConversation().getAccount(); - final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(message); final JingleFileTransferConnection connection = - new JingleFileTransferConnection(this, id, account.getJid()); - mXmppConnectionService.markMessage(message, Message.STATUS_WAITING); - this.connections.put(id, connection); - connection.init(message); + new JingleFileTransferConnection(this, message); + this.connections.put(connection.getId(), connection); + connection.sendSessionInitialize(); } public Optional getOngoingRtpConnection(final Contact contact) { @@ -593,15 +636,16 @@ public class JingleConnectionManager extends AbstractConnectionManager { final AbstractJingleConnection.Id id = connection.getId(); if (this.connections.remove(id) == null) { throw new IllegalStateException( - String.format("Unable to finish connection with id=%s", id.toString())); + String.format("Unable to finish connection with id=%s", id)); } + // update chat UI to remove 'ongoing call' icon + mXmppConnectionService.updateConversationUi(); } public boolean fireJingleRtpConnectionStateUpdates() { boolean firedUpdates = false; for (final AbstractJingleConnection connection : this.connections.values()) { - if (connection instanceof JingleRtpConnection) { - final JingleRtpConnection jingleRtpConnection = (JingleRtpConnection) connection; + if (connection instanceof JingleRtpConnection jingleRtpConnection) { if (jingleRtpConnection.isTerminated()) { continue; } @@ -612,73 +656,6 @@ public class JingleConnectionManager extends AbstractConnectionManager { return firedUpdates; } - void getPrimaryCandidate( - final Account account, - final boolean initiator, - final OnPrimaryCandidateFound listener) { - if (Config.DISABLE_PROXY_LOOKUP) { - listener.onPrimaryCandidateFound(false, null); - return; - } - if (!this.primaryCandidates.containsKey(account.getJid().asBareJid())) { - final Jid proxy = - account.getXmppConnection().findDiscoItemByFeature(Namespace.BYTE_STREAMS); - if (proxy != null) { - IqPacket iq = new IqPacket(IqPacket.TYPE.GET); - iq.setTo(proxy); - iq.query(Namespace.BYTE_STREAMS); - account.getXmppConnection() - .sendIqPacket( - iq, - new OnIqPacketReceived() { - - @Override - public void onIqPacketReceived( - Account account, IqPacket packet) { - final Element streamhost = - packet.query() - .findChild( - "streamhost", - Namespace.BYTE_STREAMS); - final String host = - streamhost == null - ? null - : streamhost.getAttribute("host"); - final String port = - streamhost == null - ? null - : streamhost.getAttribute("port"); - if (host != null && port != null) { - try { - JingleCandidate candidate = - new JingleCandidate(nextRandomId(), true); - candidate.setHost(host); - candidate.setPort(Integer.parseInt(port)); - candidate.setType(JingleCandidate.TYPE_PROXY); - candidate.setJid(proxy); - candidate.setPriority( - 655360 + (initiator ? 30 : 0)); - primaryCandidates.put( - account.getJid().asBareJid(), candidate); - listener.onPrimaryCandidateFound(true, candidate); - } catch (final NumberFormatException e) { - listener.onPrimaryCandidateFound(false, null); - } - } else { - listener.onPrimaryCandidateFound(false, null); - } - } - }); - } else { - listener.onPrimaryCandidateFound(false, null); - } - - } else { - listener.onPrimaryCandidateFound( - true, this.primaryCandidates.get(account.getJid().asBareJid())); - } - } - public void retractSessionProposal(final Account account, final Jid with) { synchronized (this.rtpSessionProposals) { RtpSessionProposal matchingProposal = null; @@ -777,40 +754,53 @@ public class JingleConnectionManager extends AbstractConnectionManager { return false; } - public void deliverIbbPacket(Account account, IqPacket packet) { + public void deliverIbbPacket(final Account account, final IqPacket packet) { final String sid; final Element payload; + final InbandBytestreamsTransport.PacketType packetType; if (packet.hasChild("open", Namespace.IBB)) { + packetType = InbandBytestreamsTransport.PacketType.OPEN; payload = packet.findChild("open", Namespace.IBB); sid = payload.getAttribute("sid"); } else if (packet.hasChild("data", Namespace.IBB)) { + packetType = InbandBytestreamsTransport.PacketType.DATA; payload = packet.findChild("data", Namespace.IBB); sid = payload.getAttribute("sid"); } else if (packet.hasChild("close", Namespace.IBB)) { + packetType = InbandBytestreamsTransport.PacketType.CLOSE; payload = packet.findChild("close", Namespace.IBB); sid = payload.getAttribute("sid"); } else { + packetType = null; payload = null; sid = null; } - if (sid != null) { - for (final AbstractJingleConnection connection : this.connections.values()) { - if (connection instanceof JingleFileTransferConnection) { - final JingleFileTransferConnection fileTransfer = - (JingleFileTransferConnection) connection; - final JingleTransport transport = fileTransfer.getTransport(); - if (transport instanceof JingleInBandTransport) { - final JingleInBandTransport inBandTransport = - (JingleInBandTransport) transport; - if (inBandTransport.matches(account, sid)) { - inBandTransport.deliverPayload(packet, payload); + if (sid == null) { + Log.d(Config.LOGTAG, account.getJid().asBareJid()+": unable to deliver ibb packet. missing sid"); + account.getXmppConnection() + .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null); + return; + } + for (final AbstractJingleConnection connection : this.connections.values()) { + if (connection instanceof JingleFileTransferConnection fileTransfer) { + final Transport transport = fileTransfer.getTransport(); + if (transport instanceof InbandBytestreamsTransport inBandTransport) { + if (sid.equals(inBandTransport.getStreamId())) { + if (inBandTransport.deliverPacket(packetType, packet.getFrom(), payload)) { + account.getXmppConnection() + .sendIqPacket( + packet.generateResponse(IqPacket.TYPE.RESULT), null); + } else { + account.getXmppConnection() + .sendIqPacket( + packet.generateResponse(IqPacket.TYPE.ERROR), null); } return; } } } } - Log.d(Config.LOGTAG, "unable to deliver ibb packet: " + packet.toString()); + Log.d(Config.LOGTAG, account.getJid().asBareJid()+": unable to deliver ibb packet with sid="+sid); account.getXmppConnection() .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null); } @@ -916,7 +906,8 @@ public class JingleConnectionManager extends AbstractConnectionManager { } } - public void failProceed(Account account, final Jid with, final String sessionId, final String message) { + public void failProceed( + Account account, final Jid with, final String sessionId, final String message) { final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, with, sessionId); final AbstractJingleConnection existingJingleConnection = connections.get(id); @@ -990,15 +981,11 @@ public class JingleConnectionManager extends AbstractConnectionManager { FAILED; public RtpEndUserState toEndUserState() { - switch (this) { - case SEARCHING: - case SEARCHING_ACKNOWLEDGED: - return RtpEndUserState.FINDING_DEVICE; - case DISCOVERED: - return RtpEndUserState.RINGING; - default: - return RtpEndUserState.CONNECTIVITY_ERROR; - } + return switch (this) { + case SEARCHING, SEARCHING_ACKNOWLEDGED -> RtpEndUserState.FINDING_DEVICE; + case DISCOVERED -> RtpEndUserState.RINGING; + default -> RtpEndUserState.CONNECTIVITY_ERROR; + }; } } @@ -1008,10 +995,6 @@ public class JingleConnectionManager extends AbstractConnectionManager { public final Set media; private final Account account; - private RtpSessionProposal(Account account, Jid with, String sessionId) { - this(account, with, sessionId, Collections.emptySet()); - } - private RtpSessionProposal(Account account, Jid with, String sessionId, Set media) { this.account = account; this.with = with; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java index c4ed04bd0..632a8f034 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java @@ -1,1222 +1,306 @@ package eu.siacs.conversations.xmpp.jingle; -import android.util.Base64; import android.util.Log; +import androidx.annotation.NonNull; + import com.google.common.base.Preconditions; import com.google.common.base.Strings; -import com.google.common.collect.Collections2; -import com.google.common.collect.FluentIterable; +import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Map.Entry; -import java.util.concurrent.ConcurrentHashMap; +import com.google.common.hash.Hashing; +import com.google.common.primitives.Ints; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; import eu.siacs.conversations.Config; -import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage; -import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Conversation; -import eu.siacs.conversations.entities.DownloadableFile; import eu.siacs.conversations.entities.Message; -import eu.siacs.conversations.entities.Presence; -import eu.siacs.conversations.entities.ServiceDiscoveryResult; import eu.siacs.conversations.entities.Transferable; import eu.siacs.conversations.entities.TransferablePlaceholder; -import eu.siacs.conversations.parser.IqParser; -import eu.siacs.conversations.persistance.FileBackend; import eu.siacs.conversations.services.AbstractConnectionManager; -import eu.siacs.conversations.utils.CryptoHelper; -import eu.siacs.conversations.xml.Element; import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; -import eu.siacs.conversations.xmpp.OnIqPacketReceived; -import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.XmppConnection; import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription; import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; import eu.siacs.conversations.xmpp.jingle.stanzas.Reason; -import eu.siacs.conversations.xmpp.jingle.stanzas.S5BTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.SocksByteStreamsTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo; +import eu.siacs.conversations.xmpp.jingle.transports.InbandBytestreamsTransport; +import eu.siacs.conversations.xmpp.jingle.transports.SocksByteStreamsTransport; +import eu.siacs.conversations.xmpp.jingle.transports.Transport; +import eu.siacs.conversations.xmpp.jingle.transports.WebRTCDataChannelTransport; import eu.siacs.conversations.xmpp.stanzas.IqPacket; -public class JingleFileTransferConnection extends AbstractJingleConnection implements Transferable { +import org.bouncycastle.crypto.engines.AESEngine; +import org.bouncycastle.crypto.io.CipherInputStream; +import org.bouncycastle.crypto.io.CipherOutputStream; +import org.bouncycastle.crypto.modes.AEADBlockCipher; +import org.bouncycastle.crypto.modes.GCMBlockCipher; +import org.bouncycastle.crypto.params.AEADParameters; +import org.bouncycastle.crypto.params.KeyParameter; +import org.webrtc.IceCandidate; - private static final int JINGLE_STATUS_TRANSMITTING = 5; - private static final String JET_OMEMO_CIPHER = "urn:xmpp:ciphers:aes-128-gcm-nopadding"; - private static final int JINGLE_STATUS_INITIATED = 0; - private static final int JINGLE_STATUS_ACCEPTED = 1; - private static final int JINGLE_STATUS_FINISHED = 4; - private static final int JINGLE_STATUS_FAILED = 99; - private static final int JINGLE_STATUS_OFFERED = -1; +import java.io.Closeable; +import java.io.EOFException; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.CountDownLatch; - private static final int MAX_IBB_BLOCK_SIZE = 8192; +public class JingleFileTransferConnection extends AbstractJingleConnection + implements Transport.Callback, Transferable { - private int ibbBlockSize = MAX_IBB_BLOCK_SIZE; + private final Message message; - private int mJingleStatus = JINGLE_STATUS_OFFERED; //migrate to enum - private int mStatus = Transferable.STATUS_UNKNOWN; - private Message message; - private Jid responder; - private final List candidates = new ArrayList<>(); - private final ConcurrentHashMap connections = new ConcurrentHashMap<>(); + private FileTransferContentMap initiatorFileTransferContentMap; + private FileTransferContentMap responderFileTransferContentMap; - private String transportId; - private FileTransferDescription description; - private DownloadableFile file = null; - - private boolean proxyActivationFailed = false; - - private String contentName; - private Content.Creator contentCreator; - private Content.Senders contentSenders; - private Class initialTransport; - private boolean remoteSupportsOmemoJet; - - private int mProgress = 0; - - private boolean receivedCandidate = false; - private boolean sentCandidate = false; + private Transport transport; + private TransportSecurity transportSecurity; + private AbstractFileTransceiver fileTransceiver; + private final Queue pendingIncomingIceCandidates = new LinkedList<>(); private boolean acceptedAutomatically = false; - private boolean cancelled = false; - private XmppAxolotlMessage mXmppAxolotlMessage; - - private JingleTransport transport = null; - - private OutputStream mFileOutputStream; - private InputStream mFileInputStream; - - private final OnIqPacketReceived responseListener = (account, packet) -> { - if (packet.getType() != IqPacket.TYPE.RESULT) { - if (mJingleStatus != JINGLE_STATUS_FAILED && mJingleStatus != JINGLE_STATUS_FINISHED) { - fail(IqParser.extractErrorMessage(packet)); - } else { - Log.d(Config.LOGTAG, "ignoring late delivery of jingle packet to jingle session with status=" + mJingleStatus + ": " + packet.toString()); - } - } - }; - private byte[] expectedHash = new byte[0]; - private final OnFileTransmissionStatusChanged onFileTransmissionStatusChanged = new OnFileTransmissionStatusChanged() { - - @Override - public void onFileTransmitted(DownloadableFile file) { - if (responding()) { - if (expectedHash.length > 0) { - if (Arrays.equals(expectedHash, file.getSha1Sum())) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received file matched the expected hash"); - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": hashes did not match"); - } - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": other party did not include file hash in file transfer"); - } - sendSuccess(); - xmppConnectionService.getFileBackend().updateFileParams(message); - xmppConnectionService.databaseBackend.createMessage(message); - xmppConnectionService.markMessage(message, Message.STATUS_RECEIVED); - if (acceptedAutomatically) { - message.markUnread(); - if (message.getEncryption() == Message.ENCRYPTION_PGP) { - id.account.getPgpDecryptionService().decrypt(message, true); - } else { - xmppConnectionService.getFileBackend().updateMediaScanner(file, () -> JingleFileTransferConnection.this.xmppConnectionService.getNotificationService().push(message)); - - } - Log.d(Config.LOGTAG, "successfully transmitted file:" + file.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")"); - return; - } else if (message.getEncryption() == Message.ENCRYPTION_PGP) { - id.account.getPgpDecryptionService().decrypt(message, true); - } - } else { - if (description.getVersion() == FileTransferDescription.Version.FT_5) { //older Conversations will break when receiving a session-info - sendHash(); - } - if (message.getEncryption() == Message.ENCRYPTION_PGP) { - id.account.getPgpDecryptionService().decrypt(message, false); - } - if (message.getEncryption() == Message.ENCRYPTION_PGP || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) { - file.delete(); - } - } - Log.d(Config.LOGTAG, "successfully transmitted file:" + file.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")"); - if (message.getEncryption() != Message.ENCRYPTION_PGP) { - xmppConnectionService.getFileBackend().updateMediaScanner(file); - } - } - - @Override - public void onFileTransferAborted() { - JingleFileTransferConnection.this.sendSessionTerminate(Reason.CONNECTIVITY_ERROR); - JingleFileTransferConnection.this.fail(); - } - }; - private final OnTransportConnected onIbbTransportConnected = new OnTransportConnected() { - @Override - public void failed() { - Log.d(Config.LOGTAG, "ibb open failed"); - } - - @Override - public void established() { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ibb transport connected. sending file"); - mJingleStatus = JINGLE_STATUS_TRANSMITTING; - JingleFileTransferConnection.this.transport.send(file, onFileTransmissionStatusChanged); - } - }; - private final OnProxyActivated onProxyActivated = new OnProxyActivated() { - - @Override - public void success() { - if (isInitiator()) { - Log.d(Config.LOGTAG, "we were initiating. sending file"); - transport.send(file, onFileTransmissionStatusChanged); - } else { - transport.receive(file, onFileTransmissionStatusChanged); - Log.d(Config.LOGTAG, "we were responding. receiving file"); - } - } - - @Override - public void failed() { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": proxy activation failed"); - proxyActivationFailed = true; - if (isInitiator()) { - sendFallbackToIbb(); - } - } - }; - - JingleFileTransferConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) { - super(jingleConnectionManager, id, initiator); - } - - private static long parseLong(final Element element, final long l) { - final String input = element == null ? null : element.getContent(); - if (input == null) { - return l; - } - try { - return Long.parseLong(input); - } catch (Exception e) { - return l; - } - } - - //TODO get rid and use isInitiator() instead - private boolean responding() { - return responder != null && responder.equals(id.account.getJid()); - } - - - InputStream getFileInputStream() { - return this.mFileInputStream; - } - - OutputStream getFileOutputStream() throws IOException { - if (this.file == null) { - Log.d(Config.LOGTAG, "file object was not assigned"); - return null; - } - final File parent = this.file.getParentFile(); - if (parent != null && parent.mkdirs()) { - Log.d(Config.LOGTAG, "created parent directories for file " + file.getAbsolutePath()); - } - if (this.file.createNewFile()) { - Log.d(Config.LOGTAG, "created output file " + file.getAbsolutePath()); - } - this.mFileOutputStream = AbstractConnectionManager.createOutputStream(this.file, false, true); - return this.mFileOutputStream; - } - - @Override - void deliverPacket(final JinglePacket packet) { - final JinglePacket.Action action = packet.getAction(); - //TODO switch case - if (action == JinglePacket.Action.SESSION_INITIATE) { - init(packet); - } else if (action == JinglePacket.Action.SESSION_TERMINATE) { - final Reason reason = packet.getReason().reason; - switch (reason) { - case CANCEL: - this.cancelled = true; - this.fail(); - break; - case SUCCESS: - this.receiveSuccess(); - break; - default: - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session-terminate with reason " + reason); - this.fail(); - break; - - } - } else if (action == JinglePacket.Action.SESSION_ACCEPT) { - receiveAccept(packet); - } else if (action == JinglePacket.Action.SESSION_INFO) { - final Element checksum = packet.getJingleChild("checksum"); - final Element file = checksum == null ? null : checksum.findChild("file"); - final Element hash = file == null ? null : file.findChild("hash", "urn:xmpp:hashes:2"); - if (hash != null && "sha-1".equalsIgnoreCase(hash.getAttribute("algo"))) { - try { - this.expectedHash = Base64.decode(hash.getContent(), Base64.DEFAULT); - } catch (Exception e) { - this.expectedHash = new byte[0]; - } - } - respondToIq(packet, true); - } else if (action == JinglePacket.Action.TRANSPORT_INFO) { - receiveTransportInfo(packet); - } else if (action == JinglePacket.Action.TRANSPORT_REPLACE) { - final Content content = packet.getJingleContent(); - final GenericTransportInfo transportInfo = content == null ? null : content.getTransport(); - if (transportInfo instanceof IbbTransportInfo) { - receiveFallbackToIbb(packet, (IbbTransportInfo) transportInfo); - } else { - Log.d(Config.LOGTAG, "trying to fallback to something unknown" + packet.toString()); - respondToIq(packet, false); - } - } else if (action == JinglePacket.Action.TRANSPORT_ACCEPT) { - receiveTransportAccept(packet); - } else { - Log.d(Config.LOGTAG, "packet arrived in connection. action was " + packet.getAction()); - respondToIq(packet, false); - } - } - - @Override - void notifyRebound() { - if (getJingleStatus() == JINGLE_STATUS_TRANSMITTING) { - abort(Reason.CONNECTIVITY_ERROR); - } - } - - private void respondToIq(final IqPacket packet, final boolean result) { - final IqPacket response; - if (result) { - response = packet.generateResponse(IqPacket.TYPE.RESULT); - } else { - response = packet.generateResponse(IqPacket.TYPE.ERROR); - final Element error = response.addChild("error").setAttribute("type", "cancel"); - error.addChild("not-acceptable", "urn:ietf:params:xml:ns:xmpp-stanzas"); - } - xmppConnectionService.sendIqPacket(id.account, response, null); - } - - private void respondToIqWithOutOfOrder(final IqPacket packet) { - final IqPacket response = packet.generateResponse(IqPacket.TYPE.ERROR); - final Element error = response.addChild("error").setAttribute("type", "wait"); - error.addChild("unexpected-request", "urn:ietf:params:xml:ns:xmpp-stanzas"); - error.addChild("out-of-order", "urn:xmpp:jingle:errors:1"); - xmppConnectionService.sendIqPacket(id.account, response, null); - } - - public void init(final Message message) { - Preconditions.checkArgument(message.isFileOrImage()); - if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) { - Conversation conversation = (Conversation) message.getConversation(); - conversation.getAccount().getAxolotlService().prepareKeyTransportMessage(conversation, xmppAxolotlMessage -> { - if (xmppAxolotlMessage != null) { - init(message, xmppAxolotlMessage); - } else { - fail(); - } - }); - } else { - init(message, null); - } - } - - private void init(final Message message, final XmppAxolotlMessage xmppAxolotlMessage) { - this.mXmppAxolotlMessage = xmppAxolotlMessage; - this.contentCreator = Content.Creator.INITIATOR; - this.contentSenders = Content.Senders.INITIATOR; - this.contentName = JingleConnectionManager.nextRandomId(); + public JingleFileTransferConnection( + final JingleConnectionManager jingleConnectionManager, final Message message) { + super( + jingleConnectionManager, + AbstractJingleConnection.Id.of(message), + message.getConversation().getAccount().getJid()); + Preconditions.checkArgument( + message.isFileOrImage(), + "only file or images messages can be transported via jingle"); this.message = message; - final List remoteFeatures = getRemoteFeatures(); - final FileTransferDescription.Version remoteVersion = getAvailableFileTransferVersion(remoteFeatures); - this.initialTransport = remoteFeatures.contains(Namespace.JINGLE_TRANSPORTS_S5B) ? S5BTransportInfo.class : IbbTransportInfo.class; - this.remoteSupportsOmemoJet = remoteFeatures.contains(Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO); this.message.setTransferable(this); - this.mStatus = Transferable.STATUS_UPLOADING; - this.responder = this.id.with; - this.transportId = JingleConnectionManager.nextRandomId(); - this.setupDescription(remoteVersion); - if (this.initialTransport == IbbTransportInfo.class) { - this.sendInitRequest(); - } else { - gatherAndConnectDirectCandidates(); - this.jingleConnectionManager.getPrimaryCandidate(id.account, isInitiator(), (success, candidate) -> { - if (success) { - final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, candidate); - connections.put(candidate.getCid(), socksConnection); - socksConnection.connect(new OnTransportConnected() { - - @Override - public void failed() { - Log.d(Config.LOGTAG, String.format("connection to our own proxy65 candidate failed (%s:%d)", candidate.getHost(), candidate.getPort())); - sendInitRequest(); - } - - @Override - public void established() { - Log.d(Config.LOGTAG, "successfully connected to our own proxy65 candidate"); - mergeCandidate(candidate); - sendInitRequest(); - } - }); - mergeCandidate(candidate); - } else { - Log.d(Config.LOGTAG, "no proxy65 candidate of our own was found"); - sendInitRequest(); - } - }); - } - + xmppConnectionService.markMessage(message, Message.STATUS_WAITING); } - private void gatherAndConnectDirectCandidates() { - final List directCandidates; - if (Config.USE_DIRECT_JINGLE_CANDIDATES) { - if (id.account.isOnion() || xmppConnectionService.useTorToConnect()) { - directCandidates = Collections.emptyList(); - } else { - directCandidates = DirectConnectionUtils.getLocalCandidates(id.account.getJid()); - } - } else { - directCandidates = Collections.emptyList(); - } - for (JingleCandidate directCandidate : directCandidates) { - final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, directCandidate); - connections.put(directCandidate.getCid(), socksConnection); - candidates.add(directCandidate); - } - } - - private FileTransferDescription.Version getAvailableFileTransferVersion(List remoteFeatures) { - if (remoteFeatures.contains(FileTransferDescription.Version.FT_5.getNamespace())) { - return FileTransferDescription.Version.FT_5; - } else if (remoteFeatures.contains(FileTransferDescription.Version.FT_4.getNamespace())) { - return FileTransferDescription.Version.FT_4; - } else { - return FileTransferDescription.Version.FT_3; - } - } - - private List getRemoteFeatures() { - final String resource = Strings.nullToEmpty(this.id.with.getResource()); - final Presence presence = this.id.account.getRoster().getContact(id.with).getPresences().get(resource); - final ServiceDiscoveryResult result = presence != null ? presence.getServiceDiscoveryResult() : null; - return result == null ? Collections.emptyList() : result.getFeatures(); - } - - private void init(JinglePacket packet) { //should move to deliverPacket - //TODO if not 'OFFERED' reply with out-of-order - this.mJingleStatus = JINGLE_STATUS_INITIATED; - final Conversation conversation = this.xmppConnectionService.findOrCreateConversation(id.account, id.with.asBareJid(), false, false); + public JingleFileTransferConnection( + final JingleConnectionManager jingleConnectionManager, + final Id id, + final Jid initiator) { + super(jingleConnectionManager, id, initiator); + final Conversation conversation = + this.xmppConnectionService.findOrCreateConversation( + id.account, id.with.asBareJid(), false, false); this.message = new Message(conversation, "", Message.ENCRYPTION_NONE); this.message.setStatus(Message.STATUS_RECEIVED); - this.mStatus = Transferable.STATUS_OFFER; + this.message.setErrorMessage(null); this.message.setTransferable(this); - this.message.setCounterpart(this.id.with); - this.responder = this.id.account.getJid(); - final Content content = packet.getJingleContent(); - final GenericTransportInfo transportInfo = content.getTransport(); - this.contentCreator = content.getCreator(); - Content.Senders senders; - try { - senders = content.getSenders(); - } catch (final Exception e) { - senders = Content.Senders.INITIATOR; - } - this.contentSenders = senders; - this.contentName = content.getAttribute("name"); - - if (transportInfo instanceof S5BTransportInfo) { - final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo; - this.transportId = s5BTransportInfo.getTransportId(); - this.initialTransport = s5BTransportInfo.getClass(); - this.mergeCandidates(s5BTransportInfo.getCandidates()); - } else if (transportInfo instanceof IbbTransportInfo) { - final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo; - this.initialTransport = ibbTransportInfo.getClass(); - this.transportId = ibbTransportInfo.getTransportId(); - final int remoteBlockSize = ibbTransportInfo.getBlockSize(); - if (remoteBlockSize <= 0) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote party requested invalid ibb block size"); - respondToIq(packet, false); - this.fail(); - } - this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, ibbTransportInfo.getBlockSize()); - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote tried to use unknown transport " + transportInfo.getNamespace()); - respondToIq(packet, false); - this.fail(); - return; - } - - this.description = (FileTransferDescription) content.getDescription(); - - final Element fileOffer = this.description.getFileOffer(); - - if (fileOffer != null) { - boolean remoteIsUsingJet = false; - Element encrypted = fileOffer.findChild("encrypted", AxolotlService.PEP_PREFIX); - if (encrypted == null) { - final Element security = content.findChild("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT); - if (security != null && AxolotlService.PEP_PREFIX.equals(security.getAttribute("type"))) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received jingle file offer with JET"); - encrypted = security.findChild("encrypted", AxolotlService.PEP_PREFIX); - remoteIsUsingJet = true; - } - } - if (encrypted != null) { - this.mXmppAxolotlMessage = XmppAxolotlMessage.fromElement(encrypted, packet.getFrom().asBareJid()); - } - Element fileSize = fileOffer.findChild("size"); - final String path = fileOffer.findChildContent("name"); - if (path != null) { - AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(path); - if (VALID_IMAGE_EXTENSIONS.contains(extension.main)) { - message.setType(Message.TYPE_IMAGE); - xmppConnectionService.getFileBackend().setupRelativeFilePath(message, message.getUuid() + "." + extension.main); - } else if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) { - if (VALID_IMAGE_EXTENSIONS.contains(extension.secondary)) { - message.setType(Message.TYPE_IMAGE); - xmppConnectionService.getFileBackend().setupRelativeFilePath(message,message.getUuid() + "." + extension.secondary); - } else { - message.setType(Message.TYPE_FILE); - xmppConnectionService.getFileBackend().setupRelativeFilePath(message,message.getUuid() + (extension.secondary != null ? ("." + extension.secondary) : "")); - } - message.setEncryption(Message.ENCRYPTION_PGP); - } else { - message.setType(Message.TYPE_FILE); - xmppConnectionService.getFileBackend().setupRelativeFilePath(message,message.getUuid() + (extension.main != null ? ("." + extension.main) : "")); - } - long size = parseLong(fileSize, 0); - message.setBody(Long.toString(size)); - conversation.add(message); - jingleConnectionManager.updateConversationUi(true); - this.file = this.xmppConnectionService.getFileBackend().getFile(message, false); - if (mXmppAxolotlMessage != null) { - XmppAxolotlMessage.XmppAxolotlKeyTransportMessage transportMessage = id.account.getAxolotlService().processReceivingKeyTransportMessage(mXmppAxolotlMessage, false); - if (transportMessage != null) { - message.setEncryption(Message.ENCRYPTION_AXOLOTL); - this.file.setKey(transportMessage.getKey()); - this.file.setIv(transportMessage.getIv()); - message.setFingerprint(transportMessage.getFingerprint()); - } else { - Log.d(Config.LOGTAG, "could not process KeyTransportMessage"); - } - } - message.resetFileParams(); - //legacy OMEMO encrypted file transfers reported the file size after encryption - //JET reports the plain text size. however lower levels of our receiving code still - //expect the cipher text size. so we just + 16 bytes (auth tag size) here - this.file.setExpectedSize(size + (remoteIsUsingJet ? 16 : 0)); - - respondToIq(packet, true); - - if (id.account.getRoster().getContact(id.with).showInContactList() - && jingleConnectionManager.hasStoragePermission() - && size < this.jingleConnectionManager.getAutoAcceptFileSize() - && xmppConnectionService.isDataSaverDisabled()) { - Log.d(Config.LOGTAG, "auto accepting file from " + id.with); - this.acceptedAutomatically = true; - this.sendAccept(); - } else { - message.markUnread(); - Log.d(Config.LOGTAG, - "not auto accepting new file offer with size: " - + size - + " allowed size:" - + this.jingleConnectionManager - .getAutoAcceptFileSize()); - this.xmppConnectionService.getNotificationService().push(message); - } - Log.d(Config.LOGTAG, "receiving file: expecting size of " + this.file.getExpectedSize()); - return; - } - respondToIq(packet, false); - } - } - - private void setupDescription(final FileTransferDescription.Version version) { - this.file = this.xmppConnectionService.getFileBackend().getFile(message, false); - final FileTransferDescription description; - if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) { - this.file.setKey(mXmppAxolotlMessage.getInnerKey()); - this.file.setIv(mXmppAxolotlMessage.getIV()); - //legacy OMEMO encrypted file transfer reported file size of the encrypted file - //JET uses the file size of the plain text file. The difference is only 16 bytes (auth tag) - this.file.setExpectedSize(file.getSize() + (this.remoteSupportsOmemoJet ? 0 : 16)); - if (remoteSupportsOmemoJet) { - description = FileTransferDescription.of(this.file, version, null); - } else { - description = FileTransferDescription.of(this.file, version, this.mXmppAxolotlMessage); - } - } else { - this.file.setExpectedSize(file.getSize()); - description = FileTransferDescription.of(this.file, version, null); - } - this.description = description; - } - - private void sendInitRequest() { - final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.SESSION_INITIATE); - final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL && remoteSupportsOmemoJet) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote announced support for JET"); - final Element security = new Element("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT); - security.setAttribute("name", this.contentName); - security.setAttribute("cipher", JET_OMEMO_CIPHER); - security.setAttribute("type", AxolotlService.PEP_PREFIX); - security.addChild(mXmppAxolotlMessage.toElement()); - content.addChild(security); - } - content.setDescription(this.description); - message.resetFileParams(); - try { - this.mFileInputStream = new FileInputStream(file); - } catch (FileNotFoundException e) { - fail(e.getMessage()); - return; - } - if (this.initialTransport == IbbTransportInfo.class) { - content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize)); - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending IBB offer"); - } else { - final Collection candidates = getOurCandidates(); - content.setTransport(new S5BTransportInfo(this.transportId, candidates)); - Log.d(Config.LOGTAG, String.format("%s: sending S5B offer with %d candidates", id.account.getJid().asBareJid(), candidates.size())); - } - packet.addJingleContent(content); - this.sendJinglePacket(packet, (account, response) -> { - if (response.getType() == IqPacket.TYPE.RESULT) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": other party received offer"); - if (mJingleStatus == JINGLE_STATUS_OFFERED) { - mJingleStatus = JINGLE_STATUS_INITIATED; - xmppConnectionService.markMessage(message, Message.STATUS_OFFERED); - } else { - Log.d(Config.LOGTAG, "received ack for offer when status was " + mJingleStatus); - } - } else { - fail(IqParser.extractErrorMessage(response)); - } - }); - - } - - private void sendHash() { - final Element checksum = new Element("checksum", description.getVersion().getNamespace()); - checksum.setAttribute("creator", "initiator"); - checksum.setAttribute("name", "a-file-offer"); - Element hash = checksum.addChild("file").addChild("hash", "urn:xmpp:hashes:2"); - hash.setAttribute("algo", "sha-1").setContent(Base64.encodeToString(file.getSha1Sum(), Base64.NO_WRAP)); - - final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.SESSION_INFO); - packet.addJingleChild(checksum); - xmppConnectionService.sendIqPacket(id.account, packet, (account, response) -> { - if (response.getType() == IqPacket.TYPE.ERROR) { - Log.d(Config.LOGTAG,account.getJid().asBareJid()+": ignoring error response to our session-info (hash transmission)"); - } - }); - } - - private Collection getOurCandidates() { - return Collections2.filter(this.candidates, c -> c != null && c.isOurs()); - } - - private void sendAccept() { - mJingleStatus = JINGLE_STATUS_ACCEPTED; - this.mStatus = Transferable.STATUS_DOWNLOADING; - this.jingleConnectionManager.updateConversationUi(true); - if (initialTransport == S5BTransportInfo.class) { - sendAcceptSocks(); - } else { - sendAcceptIbb(); - } - } - - private void sendAcceptSocks() { - gatherAndConnectDirectCandidates(); - this.jingleConnectionManager.getPrimaryCandidate(this.id.account, isInitiator(), (success, candidate) -> { - final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_ACCEPT); - final Content content = new Content(contentCreator, contentSenders, contentName); - content.setDescription(this.description); - if (success && candidate != null && !equalCandidateExists(candidate)) { - final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, candidate); - connections.put(candidate.getCid(), socksConnection); - socksConnection.connect(new OnTransportConnected() { - - @Override - public void failed() { - Log.d(Config.LOGTAG, "connection to our own proxy65 candidate failed"); - content.setTransport(new S5BTransportInfo(transportId, getOurCandidates())); - packet.addJingleContent(content); - sendJinglePacket(packet); - connectNextCandidate(); - } - - @Override - public void established() { - Log.d(Config.LOGTAG, "connected to proxy65 candidate"); - mergeCandidate(candidate); - content.setTransport(new S5BTransportInfo(transportId, getOurCandidates())); - packet.addJingleContent(content); - sendJinglePacket(packet); - connectNextCandidate(); - } - }); - } else { - Log.d(Config.LOGTAG, "did not find a proxy65 candidate for ourselves"); - content.setTransport(new S5BTransportInfo(transportId, getOurCandidates())); - packet.addJingleContent(content); - sendJinglePacket(packet); - connectNextCandidate(); - } - }); - } - - private void sendAcceptIbb() { - this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize); - final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_ACCEPT); - final Content content = new Content(contentCreator, contentSenders, contentName); - content.setDescription(this.description); - content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize)); - packet.addJingleContent(content); - this.transport.receive(file, onFileTransmissionStatusChanged); - this.sendJinglePacket(packet); - } - - private JinglePacket bootstrapPacket(JinglePacket.Action action) { - final JinglePacket packet = new JinglePacket(action, this.id.sessionId); - packet.setTo(id.with); - return packet; - } - - private void sendJinglePacket(JinglePacket packet) { - xmppConnectionService.sendIqPacket(id.account, packet, responseListener); - } - - private void sendJinglePacket(JinglePacket packet, OnIqPacketReceived callback) { - xmppConnectionService.sendIqPacket(id.account, packet, callback); - } - - private void receiveAccept(JinglePacket packet) { - if (responding()) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order session-accept (we were responding)"); - respondToIqWithOutOfOrder(packet); - return; - } - if (this.mJingleStatus != JINGLE_STATUS_INITIATED) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order session-accept"); - respondToIqWithOutOfOrder(packet); - return; - } - this.mJingleStatus = JINGLE_STATUS_ACCEPTED; - xmppConnectionService.markMessage(message, Message.STATUS_UNSEND); - final Content content = packet.getJingleContent(); - final GenericTransportInfo transportInfo = content.getTransport(); - //TODO we want to fail if transportInfo doesn’t match our intialTransport and/or our id - if (transportInfo instanceof S5BTransportInfo) { - final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo; - respondToIq(packet, true); - //TODO calling merge is probably a bug because that might eliminate candidates of the other party and lead to us not sending accept/deny - //TODO: we probably just want to call add - mergeCandidates(s5BTransportInfo.getCandidates()); - this.connectNextCandidate(); - } else if (transportInfo instanceof IbbTransportInfo) { - final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo; - final int remoteBlockSize = ibbTransportInfo.getBlockSize(); - if (remoteBlockSize > 0) { - this.ibbBlockSize = Math.min(ibbBlockSize, remoteBlockSize); - } - respondToIq(packet, true); - this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize); - this.transport.connect(onIbbTransportConnected); - } else { - respondToIq(packet, false); - } - } - - private void receiveTransportInfo(JinglePacket packet) { - final Content content = packet.getJingleContent(); - final GenericTransportInfo transportInfo = content.getTransport(); - if (transportInfo instanceof S5BTransportInfo) { - final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo; - if (s5BTransportInfo.hasChild("activated")) { - respondToIq(packet, true); - if ((this.transport != null) && (this.transport instanceof JingleSocks5Transport)) { - onProxyActivated.success(); - } else { - String cid = s5BTransportInfo.findChild("activated").getAttribute("cid"); - Log.d(Config.LOGTAG, "received proxy activated (" + cid - + ")prior to choosing our own transport"); - JingleSocks5Transport connection = this.connections.get(cid); - if (connection != null) { - connection.setActivated(true); - } else { - Log.d(Config.LOGTAG, "activated connection not found"); - sendSessionTerminate(Reason.FAILED_TRANSPORT); - this.fail(); - } - } - } else if (s5BTransportInfo.hasChild("proxy-error")) { - respondToIq(packet, true); - onProxyActivated.failed(); - } else if (s5BTransportInfo.hasChild("candidate-error")) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received candidate error"); - respondToIq(packet, true); - this.receivedCandidate = true; - if (mJingleStatus == JINGLE_STATUS_ACCEPTED && this.sentCandidate) { - this.connect(); - } - } else if (s5BTransportInfo.hasChild("candidate-used")) { - String cid = s5BTransportInfo.findChild("candidate-used").getAttribute("cid"); - if (cid != null) { - Log.d(Config.LOGTAG, "candidate used by counterpart:" + cid); - JingleCandidate candidate = getCandidate(cid); - if (candidate == null) { - Log.d(Config.LOGTAG, "could not find candidate with cid=" + cid); - respondToIq(packet, false); - return; - } - respondToIq(packet, true); - candidate.flagAsUsedByCounterpart(); - this.receivedCandidate = true; - if (mJingleStatus == JINGLE_STATUS_ACCEPTED && this.sentCandidate) { - this.connect(); - } else { - Log.d(Config.LOGTAG, "ignoring because file is already in transmission or we haven't sent our candidate yet status=" + mJingleStatus + " sentCandidate=" + sentCandidate); - } - } else { - respondToIq(packet, false); - } - } else { - respondToIq(packet, false); - } - } else { - respondToIq(packet, true); - } - } - - private void connect() { - final JingleSocks5Transport connection = chooseConnection(); - this.transport = connection; - if (connection == null) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": could not find suitable candidate"); - this.disconnectSocks5Connections(); - if (isInitiator()) { - this.sendFallbackToIbb(); - } - } else { - //TODO at this point we can already close other connections to free some resources - final JingleCandidate candidate = connection.getCandidate(); - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": elected candidate " + candidate.toString()); - this.mJingleStatus = JINGLE_STATUS_TRANSMITTING; - if (connection.needsActivation()) { - if (connection.getCandidate().isOurs()) { - final String sid; - if (description.getVersion() == FileTransferDescription.Version.FT_3) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": use session ID instead of transport ID to activate proxy"); - sid = id.sessionId; - } else { - sid = getTransportId(); - } - Log.d(Config.LOGTAG, "candidate " - + connection.getCandidate().getCid() - + " was our proxy. going to activate"); - IqPacket activation = new IqPacket(IqPacket.TYPE.SET); - activation.setTo(connection.getCandidate().getJid()); - activation.query("http://jabber.org/protocol/bytestreams") - .setAttribute("sid", sid); - activation.query().addChild("activate") - .setContent(this.id.with.toEscapedString()); - xmppConnectionService.sendIqPacket(this.id.account, activation, (account, response) -> { - if (response.getType() != IqPacket.TYPE.RESULT) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": " + response.toString()); - sendProxyError(); - onProxyActivated.failed(); - } else { - sendProxyActivated(connection.getCandidate().getCid()); - onProxyActivated.success(); - } - }); - } else { - Log.d(Config.LOGTAG, - "candidate " - + connection.getCandidate().getCid() - + " was a proxy. waiting for other party to activate"); - } - } else { - if (isInitiator()) { - Log.d(Config.LOGTAG, "we were initiating. sending file"); - connection.send(file, onFileTransmissionStatusChanged); - } else { - Log.d(Config.LOGTAG, "we were responding. receiving file"); - connection.receive(file, onFileTransmissionStatusChanged); - } - } - } - } - - private JingleSocks5Transport chooseConnection() { - final List establishedConnections = FluentIterable.from(connections.entrySet()) - .transform(Entry::getValue) - .filter(c -> (c != null && c.isEstablished() && (c.getCandidate().isUsedByCounterpart() || !c.getCandidate().isOurs()))) - .toSortedList((a, b) -> { - final int compare = Integer.compare(b.getCandidate().getPriority(), a.getCandidate().getPriority()); - if (compare == 0) { - if (isInitiator()) { - //pick the one we sent a candidate-used for (meaning not ours) - return a.getCandidate().isOurs() ? 1 : -1; - } else { - //pick the one they sent a candidate-used for (meaning ours) - return a.getCandidate().isOurs() ? -1 : 1; - } - } - return compare; - }); - return Iterables.getFirst(establishedConnections, null); - } - - private void sendSuccess() { - sendSessionTerminate(Reason.SUCCESS); - this.disconnectSocks5Connections(); - this.mJingleStatus = JINGLE_STATUS_FINISHED; - this.message.setStatus(Message.STATUS_RECEIVED); - this.message.setTransferable(null); - this.xmppConnectionService.updateMessage(message, false); - this.jingleConnectionManager.finishConnection(this); - } - - private void sendFallbackToIbb() { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending fallback to ibb"); - final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.TRANSPORT_REPLACE); - final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - this.transportId = JingleConnectionManager.nextRandomId(); - content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize)); - packet.addJingleContent(content); - this.sendJinglePacket(packet); - } - - - private void receiveFallbackToIbb(final JinglePacket packet, final IbbTransportInfo transportInfo) { - if (isInitiator()) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-replace (we were initiating)"); - respondToIqWithOutOfOrder(packet); - return; - } - final boolean validState = mJingleStatus == JINGLE_STATUS_ACCEPTED || (proxyActivationFailed && mJingleStatus == JINGLE_STATUS_TRANSMITTING); - if (!validState) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-replace"); - respondToIqWithOutOfOrder(packet); - return; - } - this.proxyActivationFailed = false; //fallback received; now we no longer need to accept another one; - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": receiving fallback to ibb"); - final int remoteBlockSize = transportInfo.getBlockSize(); - if (remoteBlockSize > 0) { - this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, remoteBlockSize); - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to parse block size in transport-replace"); - } - this.transportId = transportInfo.getTransportId(); //TODO: handle the case where this is null by the remote party - this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize); - - final JinglePacket answer = bootstrapPacket(JinglePacket.Action.TRANSPORT_ACCEPT); - - final Content content = new Content(contentCreator, contentSenders, contentName); - content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize)); - answer.addJingleContent(content); - - respondToIq(packet, true); - - if (isInitiator()) { - this.sendJinglePacket(answer, (account, response) -> { - if (response.getType() == IqPacket.TYPE.RESULT) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + " recipient ACKed our transport-accept. creating ibb"); - transport.connect(onIbbTransportConnected); - } - }); - } else { - this.transport.receive(file, onFileTransmissionStatusChanged); - this.sendJinglePacket(answer); - } - } - - private void receiveTransportAccept(JinglePacket packet) { - if (responding()) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-accept (we were responding)"); - respondToIqWithOutOfOrder(packet); - return; - } - final boolean validState = mJingleStatus == JINGLE_STATUS_ACCEPTED || (proxyActivationFailed && mJingleStatus == JINGLE_STATUS_TRANSMITTING); - if (!validState) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-accept"); - respondToIqWithOutOfOrder(packet); - return; - } - this.proxyActivationFailed = false; //fallback accepted; now we no longer need to accept another one; - final Content content = packet.getJingleContent(); - final GenericTransportInfo transportInfo = content == null ? null : content.getTransport(); - if (transportInfo instanceof IbbTransportInfo) { - final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo; - final int remoteBlockSize = ibbTransportInfo.getBlockSize(); - if (remoteBlockSize > 0) { - this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, remoteBlockSize); - } - final String sid = ibbTransportInfo.getTransportId(); - this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize); - - if (sid == null || !sid.equals(this.transportId)) { - Log.w(Config.LOGTAG, String.format("%s: sid in transport-accept (%s) did not match our sid (%s) ", id.account.getJid().asBareJid(), sid, transportId)); - } - respondToIq(packet, true); - //might be receive instead if we are not initiating - if (isInitiator()) { - this.transport.connect(onIbbTransportConnected); - } - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received invalid transport-accept"); - respondToIq(packet, false); - } - } - - private void receiveSuccess() { - if (isInitiator()) { - this.mJingleStatus = JINGLE_STATUS_FINISHED; - this.xmppConnectionService.markMessage(this.message, Message.STATUS_SEND_RECEIVED); - this.disconnectSocks5Connections(); - if (this.transport instanceof JingleInBandTransport) { - this.transport.disconnect(); - } - this.message.setTransferable(null); - this.jingleConnectionManager.finishConnection(this); - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session-terminate/success while responding"); - } } @Override - public void cancel() { - this.cancelled = true; - abort(Reason.CANCEL); + void deliverPacket(final JinglePacket jinglePacket) { + switch (jinglePacket.getAction()) { + case SESSION_ACCEPT -> receiveSessionAccept(jinglePacket); + case SESSION_INITIATE -> receiveSessionInitiate(jinglePacket); + case SESSION_INFO -> receiveSessionInfo(jinglePacket); + case SESSION_TERMINATE -> receiveSessionTerminate(jinglePacket); + case TRANSPORT_ACCEPT -> receiveTransportAccept(jinglePacket); + case TRANSPORT_INFO -> receiveTransportInfo(jinglePacket); + case TRANSPORT_REPLACE -> receiveTransportReplace(jinglePacket); + default -> { + respondOk(jinglePacket); + Log.d( + Config.LOGTAG, + String.format( + "%s: received unhandled jingle action %s", + id.account.getJid().asBareJid(), jinglePacket.getAction())); + } + } } - private void abort(final Reason reason) { - this.disconnectSocks5Connections(); - if (this.transport instanceof JingleInBandTransport) { - this.transport.disconnect(); - } - sendSessionTerminate(reason); - this.jingleConnectionManager.finishConnection(this); - if (responding()) { - this.message.setTransferable(new TransferablePlaceholder(cancelled ? Transferable.STATUS_CANCELLED : Transferable.STATUS_FAILED)); - if (this.file != null) { - file.delete(); - } - this.jingleConnectionManager.updateConversationUi(true); + public void sendSessionInitialize() { + final ListenableFuture> keyTransportMessage; + if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) { + keyTransportMessage = + Futures.transform( + id.account + .getAxolotlService() + .prepareKeyTransportMessage(requireConversation()), + Optional::of, + MoreExecutors.directExecutor()); } else { - this.xmppConnectionService.markMessage(this.message, Message.STATUS_SEND_FAILED, cancelled ? Message.ERROR_MESSAGE_CANCELLED : null); - this.message.setTransferable(null); + keyTransportMessage = Futures.immediateFuture(Optional.empty()); + } + Futures.addCallback( + keyTransportMessage, + new FutureCallback<>() { + @Override + public void onSuccess(final Optional xmppAxolotlMessage) { + sendSessionInitialize(xmppAxolotlMessage.orElse(null)); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + Log.d(Config.LOGTAG, "can not send message"); + } + }, + MoreExecutors.directExecutor()); + } + + private void sendSessionInitialize(final XmppAxolotlMessage xmppAxolotlMessage) { + this.transport = setupTransport(); + this.transport.setTransportCallback(this); + final File file = xmppConnectionService.getFileBackend().getFile(message); + final var fileDescription = + new FileTransferDescription.File( + file.length(), + file.getName(), + message.getMimeType(), + Collections.emptyList()); + final var transportInfoFuture = this.transport.asInitialTransportInfo(); + Futures.addCallback( + transportInfoFuture, + new FutureCallback<>() { + @Override + public void onSuccess( + final Transport.InitialTransportInfo initialTransportInfo) { + final FileTransferContentMap contentMap = + FileTransferContentMap.of(fileDescription, initialTransportInfo); + sendSessionInitialize(xmppAxolotlMessage, contentMap); + } + + @Override + public void onFailure(@NonNull Throwable throwable) {} + }, + MoreExecutors.directExecutor()); + } + + private Conversation requireConversation() { + final var conversational = message.getConversation(); + if (conversational instanceof Conversation c) { + return c; + } else { + throw new IllegalStateException("Message had no proper conversation attached"); } } - private void fail() { - fail(null); - } - - private void fail(String errorMessage) { - this.mJingleStatus = JINGLE_STATUS_FAILED; - this.disconnectSocks5Connections(); - if (this.transport instanceof JingleInBandTransport) { - this.transport.disconnect(); - } - FileBackend.close(mFileInputStream); - FileBackend.close(mFileOutputStream); - if (this.message != null) { - if (responding()) { - this.message.setTransferable(new TransferablePlaceholder(cancelled ? Transferable.STATUS_CANCELLED : Transferable.STATUS_FAILED)); - if (this.file != null) { - file.delete(); + private void sendSessionInitialize( + final XmppAxolotlMessage xmppAxolotlMessage, final FileTransferContentMap contentMap) { + if (transition( + State.SESSION_INITIALIZED, + () -> this.initiatorFileTransferContentMap = contentMap)) { + final var jinglePacket = + contentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId); + if (xmppAxolotlMessage != null) { + this.transportSecurity = + new TransportSecurity( + xmppAxolotlMessage.getInnerKey(), xmppAxolotlMessage.getIV()); + final var contents = jinglePacket.getJingleContents(); + final var rawContent = + contents.get(Iterables.getOnlyElement(contentMap.contents.keySet())); + if (rawContent != null) { + rawContent.setSecurity(xmppAxolotlMessage); } - this.jingleConnectionManager.updateConversationUi(true); + } + jinglePacket.setTo(id.with); + xmppConnectionService.sendIqPacket( + id.account, + jinglePacket, + (a, response) -> { + if (response.getType() == IqPacket.TYPE.RESULT) { + xmppConnectionService.markMessage(message, Message.STATUS_OFFERED); + return; + } + if (response.getType() == IqPacket.TYPE.ERROR) { + handleIqErrorResponse(response); + return; + } + if (response.getType() == IqPacket.TYPE.TIMEOUT) { + handleIqTimeoutResponse(response); + } + }); + this.transport.readyToSentAdditionalCandidates(); + } + } + + private void receiveSessionAccept(final JinglePacket jinglePacket) { + Log.d(Config.LOGTAG, "receive file transfer session accept"); + if (isResponder()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT); + return; + } + final FileTransferContentMap contentMap; + try { + contentMap = FileTransferContentMap.of(jinglePacket); + contentMap.requireOnlyFileTransferDescription(); + } catch (final RuntimeException e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": improperly formatted contents", + Throwables.getRootCause(e)); + respondOk(jinglePacket); + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; + } + receiveSessionAccept(jinglePacket, contentMap); + } + + private void receiveSessionAccept( + final JinglePacket jinglePacket, final FileTransferContentMap contentMap) { + if (transition(State.SESSION_ACCEPTED, () -> setRemoteContentMap(contentMap))) { + respondOk(jinglePacket); + final var transport = this.transport; + if (configureTransportWithPeerInfo(transport, contentMap)) { + transport.connect(); } else { - this.xmppConnectionService.markMessage(this.message, - Message.STATUS_SEND_FAILED, - cancelled ? Message.ERROR_MESSAGE_CANCELLED : errorMessage); - this.message.setTransferable(null); + Log.e( + Config.LOGTAG, + "Transport in session accept did not match our session-initialize"); + terminateTransport(); + sendSessionTerminate( + Reason.FAILED_APPLICATION, + "Transport in session accept did not match our session-initialize"); } - } - this.jingleConnectionManager.finishConnection(this); - } - - private void sendSessionTerminate(Reason reason) { - final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_TERMINATE); - packet.setReason(reason, null); - this.sendJinglePacket(packet); - } - - private void connectNextCandidate() { - for (JingleCandidate candidate : this.candidates) { - if ((!connections.containsKey(candidate.getCid()) && (!candidate - .isOurs()))) { - this.connectWithCandidate(candidate); - return; - } - } - this.sendCandidateError(); - } - - private void connectWithCandidate(final JingleCandidate candidate) { - final JingleSocks5Transport socksConnection = new JingleSocks5Transport( - this, candidate); - connections.put(candidate.getCid(), socksConnection); - socksConnection.connect(new OnTransportConnected() { - - @Override - public void failed() { - Log.d(Config.LOGTAG, - "connection failed with " + candidate.getHost() + ":" - + candidate.getPort()); - connectNextCandidate(); - } - - @Override - public void established() { - Log.d(Config.LOGTAG, - "established connection with " + candidate.getHost() - + ":" + candidate.getPort()); - sendCandidateUsed(candidate.getCid()); - } - }); - } - - private void disconnectSocks5Connections() { - Iterator> it = this.connections - .entrySet().iterator(); - while (it.hasNext()) { - Entry pairs = it.next(); - pairs.getValue().disconnect(); - it.remove(); + } else { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": receive out of order session-accept"); + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT); } } - private void sendProxyActivated(String cid) { - final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO); - final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - content.setTransport(new S5BTransportInfo(this.transportId, new Element("activated").setAttribute("cid", cid))); - packet.addJingleContent(content); - this.sendJinglePacket(packet); - } - - private void sendProxyError() { - final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO); - final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - content.setTransport(new S5BTransportInfo(this.transportId, new Element("proxy-error"))); - packet.addJingleContent(content); - this.sendJinglePacket(packet); - } - - private void sendCandidateUsed(final String cid) { - JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO); - final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - content.setTransport(new S5BTransportInfo(this.transportId, new Element("candidate-used").setAttribute("cid", cid))); - packet.addJingleContent(content); - this.sentCandidate = true; - if ((receivedCandidate) && (mJingleStatus == JINGLE_STATUS_ACCEPTED)) { - connect(); - } - this.sendJinglePacket(packet); - } - - private void sendCandidateError() { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending candidate error"); - JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO); - Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - content.setTransport(new S5BTransportInfo(this.transportId, new Element("candidate-error"))); - packet.addJingleContent(content); - this.sentCandidate = true; - this.sendJinglePacket(packet); - if (receivedCandidate && mJingleStatus == JINGLE_STATUS_ACCEPTED) { - connect(); - } - } - - private int getJingleStatus() { - return this.mJingleStatus; - } - - private boolean equalCandidateExists(JingleCandidate candidate) { - for (JingleCandidate c : this.candidates) { - if (c.equalValues(candidate)) { - return true; - } - } - return false; - } - - private void mergeCandidate(JingleCandidate candidate) { - for (JingleCandidate c : this.candidates) { - if (c.equals(candidate)) { - return; - } - } - this.candidates.add(candidate); - } - - private void mergeCandidates(List candidates) { - Collections.sort(candidates, (a, b) -> Integer.compare(b.getPriority(), a.getPriority())); - for (JingleCandidate c : candidates) { - mergeCandidate(c); - } - } - - private JingleCandidate getCandidate(String cid) { - for (JingleCandidate c : this.candidates) { - if (c.getCid().equals(cid)) { - return c; - } - } - return null; - } - - void updateProgress(int i) { - this.mProgress = i; - jingleConnectionManager.updateConversationUi(false); - } - - String getTransportId() { - return this.transportId; - } - - FileTransferDescription.Version getFtVersion() { - return this.description.getVersion(); - } - - public JingleTransport getTransport() { - return this.transport; - } - - public boolean start() { - if (id.account.getStatus() == Account.State.ONLINE) { - if (mJingleStatus == JINGLE_STATUS_INITIATED) { - new Thread(this::sendAccept).start(); + private static boolean configureTransportWithPeerInfo( + final Transport transport, final FileTransferContentMap contentMap) { + final GenericTransportInfo transportInfo = contentMap.requireOnlyTransportInfo(); + if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport + && transportInfo instanceof WebRTCDataChannelTransportInfo) { + webRTCDataChannelTransport.setResponderDescription(SessionDescription.of(contentMap)); + return true; + } else if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport + && transportInfo + instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) { + socksBytestreamsTransport.setTheirCandidates( + socksBytestreamsTransportInfo.getCandidates()); + return true; + } else if (transport instanceof InbandBytestreamsTransport inbandBytestreamsTransport + && transportInfo instanceof IbbTransportInfo ibbTransportInfo) { + final var peerBlockSize = ibbTransportInfo.getBlockSize(); + if (peerBlockSize != null) { + inbandBytestreamsTransport.setPeerBlockSize(peerBlockSize); } return true; } else { @@ -1224,32 +308,1161 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple } } + private void receiveSessionInitiate(final JinglePacket jinglePacket) { + if (isInitiator()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE); + return; + } + Log.d(Config.LOGTAG, "receive session initiate " + jinglePacket); + final FileTransferContentMap contentMap; + final FileTransferDescription.File file; + try { + contentMap = FileTransferContentMap.of(jinglePacket); + contentMap.requireContentDescriptions(); + file = contentMap.requireOnlyFile(); + // TODO check is offer + } catch (final RuntimeException e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": improperly formatted contents", + Throwables.getRootCause(e)); + respondOk(jinglePacket); + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; + } + final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage; + final var contents = jinglePacket.getJingleContents(); + final var rawContent = contents.get(Iterables.getOnlyElement(contentMap.contents.keySet())); + final var security = + rawContent == null ? null : rawContent.getSecurity(jinglePacket.getFrom()); + if (security != null) { + Log.d(Config.LOGTAG, "found security element!"); + keyTransportMessage = + id.account + .getAxolotlService() + .processReceivingKeyTransportMessage(security, false); + } else { + keyTransportMessage = null; + } + receiveSessionInitiate(jinglePacket, contentMap, file, keyTransportMessage); + } + + private void receiveSessionInitiate( + final JinglePacket jinglePacket, + final FileTransferContentMap contentMap, + final FileTransferDescription.File file, + final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage) { + + if (transition(State.SESSION_INITIALIZED, () -> setRemoteContentMap(contentMap))) { + respondOk(jinglePacket); + Log.d( + Config.LOGTAG, + "got file offer " + file + " jet=" + Objects.nonNull(keyTransportMessage)); + setFileOffer(file); + if (keyTransportMessage != null) { + this.transportSecurity = + new TransportSecurity( + keyTransportMessage.getKey(), keyTransportMessage.getIv()); + this.message.setFingerprint(keyTransportMessage.getFingerprint()); + this.message.setEncryption(Message.ENCRYPTION_AXOLOTL); + } else { + this.transportSecurity = null; + this.message.setFingerprint(null); + } + final var conversation = (Conversation) message.getConversation(); + conversation.add(message); + + // make auto accept decision + if (id.account.getRoster().getContact(id.with).showInContactList() + && jingleConnectionManager.hasStoragePermission() + && file.size <= this.jingleConnectionManager.getAutoAcceptFileSize() + && xmppConnectionService.isDataSaverDisabled()) { + Log.d(Config.LOGTAG, "auto accepting file from " + id.with); + this.acceptedAutomatically = true; + this.sendSessionAccept(); + } else { + Log.d( + Config.LOGTAG, + "not auto accepting new file offer with size: " + + file.size + + " allowed size:" + + this.jingleConnectionManager.getAutoAcceptFileSize()); + message.markUnread(); + this.xmppConnectionService.updateConversationUi(); + this.xmppConnectionService.getNotificationService().push(message); + } + } else { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": receive out of order session-initiate"); + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE); + } + } + + private void setFileOffer(final FileTransferDescription.File file) { + final AbstractConnectionManager.Extension extension = + AbstractConnectionManager.Extension.of(file.name); + if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) { + this.message.setEncryption(Message.ENCRYPTION_PGP); + } else { + this.message.setEncryption(Message.ENCRYPTION_NONE); + } + final String ext = extension.getExtension(); + final String filename = + Strings.isNullOrEmpty(ext) + ? message.getUuid() + : String.format("%s.%s", message.getUuid(), ext); + xmppConnectionService.getFileBackend().setupRelativeFilePath(message, filename); + } + + public void sendSessionAccept() { + final FileTransferContentMap contentMap = this.initiatorFileTransferContentMap; + final Transport transport; + try { + transport = setupTransport(contentMap.requireOnlyTransportInfo()); + } catch (final RuntimeException e) { + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; + } + transitionOrThrow(State.SESSION_ACCEPTED); + this.transport = transport; + this.transport.setTransportCallback(this); + if (this.transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport) { + final var sessionDescription = SessionDescription.of(contentMap); + webRTCDataChannelTransport.setInitiatorDescription(sessionDescription); + } + final var transportInfoFuture = transport.asTransportInfo(); + Futures.addCallback( + transportInfoFuture, + new FutureCallback<>() { + @Override + public void onSuccess(final Transport.TransportInfo transportInfo) { + final FileTransferContentMap responderContentMap = + contentMap.withTransport(transportInfo); + sendSessionAccept(responderContentMap); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + failureToAcceptSession(throwable); + } + }, + MoreExecutors.directExecutor()); + } + + private void sendSessionAccept(final FileTransferContentMap contentMap) { + setLocalContentMap(contentMap); + final var jinglePacket = + contentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId); + send(jinglePacket); + // this needs to come after session-accept or else our candidate-error might arrive first + this.transport.connect(); + this.transport.readyToSentAdditionalCandidates(); + if (this.transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport) { + drainPendingIncomingIceCandidates(webRTCDataChannelTransport); + } + } + + private void drainPendingIncomingIceCandidates( + final WebRTCDataChannelTransport webRTCDataChannelTransport) { + while (this.pendingIncomingIceCandidates.peek() != null) { + final var candidate = this.pendingIncomingIceCandidates.poll(); + if (candidate == null) { + continue; + } + webRTCDataChannelTransport.addIceCandidates(ImmutableList.of(candidate)); + } + } + + private Transport setupTransport(final GenericTransportInfo transportInfo) { + final XmppConnection xmppConnection = id.account.getXmppConnection(); + final boolean useTor = id.account.isOnion() || xmppConnectionService.useTorToConnect(); + if (transportInfo instanceof IbbTransportInfo ibbTransportInfo) { + final String streamId = ibbTransportInfo.getTransportId(); + final Long blockSize = ibbTransportInfo.getBlockSize(); + if (streamId == null || blockSize == null) { + throw new IllegalStateException("ibb transport is missing sid and/or block-size"); + } + return new InbandBytestreamsTransport( + xmppConnection, + id.with, + isInitiator(), + streamId, + Ints.saturatedCast(blockSize)); + } else if (transportInfo + instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) { + final String streamId = socksBytestreamsTransportInfo.getTransportId(); + final String destination = socksBytestreamsTransportInfo.getDestinationAddress(); + final List candidates = + socksBytestreamsTransportInfo.getCandidates(); + Log.d(Config.LOGTAG, "received socks candidates " + candidates); + return new SocksByteStreamsTransport( + xmppConnection, id, isInitiator(), useTor, streamId, candidates); + } else if (!useTor && transportInfo instanceof WebRTCDataChannelTransportInfo) { + return new WebRTCDataChannelTransport( + xmppConnectionService.getApplicationContext(), + xmppConnection, + id.account, + isInitiator()); + } else { + throw new IllegalArgumentException("Do not know how to create transport"); + } + } + + private Transport setupTransport() { + final XmppConnection xmppConnection = id.account.getXmppConnection(); + final boolean useTor = id.account.isOnion() || xmppConnectionService.useTorToConnect(); + if (!useTor && remoteHasFeature(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL)) { + return new WebRTCDataChannelTransport( + xmppConnectionService.getApplicationContext(), + xmppConnection, + id.account, + isInitiator()); + } + if (remoteHasFeature(Namespace.JINGLE_TRANSPORTS_S5B)) { + return new SocksByteStreamsTransport(xmppConnection, id, isInitiator(), useTor); + } + return setupLastResortTransport(); + } + + private Transport setupLastResortTransport() { + final XmppConnection xmppConnection = id.account.getXmppConnection(); + return new InbandBytestreamsTransport(xmppConnection, id.with, isInitiator()); + } + + private void failureToAcceptSession(final Throwable throwable) { + if (isTerminated()) { + return; + } + final Throwable rootCause = Throwables.getRootCause(throwable); + Log.d(Config.LOGTAG, "unable to send session accept", rootCause); + sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage()); + } + + private void receiveSessionInfo(final JinglePacket jinglePacket) { + respondOk(jinglePacket); + final var sessionInfo = FileTransferDescription.getSessionInfo(jinglePacket); + if (sessionInfo instanceof FileTransferDescription.Checksum checksum) { + receiveSessionInfoChecksum(checksum); + } else if (sessionInfo instanceof FileTransferDescription.Received received) { + receiveSessionInfoReceived(received); + } + } + + private void receiveSessionInfoChecksum(final FileTransferDescription.Checksum checksum) { + Log.d(Config.LOGTAG, "received checksum " + checksum); + } + + private void receiveSessionInfoReceived(final FileTransferDescription.Received received) { + Log.d(Config.LOGTAG, "peer confirmed received " + received); + } + + private void receiveSessionTerminate(final JinglePacket jinglePacket) { + respondOk(jinglePacket); + final JinglePacket.ReasonWrapper wrapper = jinglePacket.getReason(); + final State previous = this.state; + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": received session terminate reason=" + + wrapper.reason + + "(" + + Strings.nullToEmpty(wrapper.text) + + ") while in state " + + previous); + if (TERMINATED.contains(previous)) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": ignoring session terminate because already in " + + previous); + return; + } + if (isInitiator()) { + this.message.setErrorMessage( + Strings.isNullOrEmpty(wrapper.text) ? wrapper.reason.toString() : wrapper.text); + } + terminateTransport(); + final State target = reasonToState(wrapper.reason); + transitionOrThrow(target); + finish(); + } + + private void receiveTransportAccept(final JinglePacket jinglePacket) { + if (isResponder()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_ACCEPT); + return; + } + Log.d(Config.LOGTAG, "receive transport accept " + jinglePacket); + final GenericTransportInfo transportInfo; + try { + transportInfo = FileTransferContentMap.of(jinglePacket).requireOnlyTransportInfo(); + } catch (final RuntimeException e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": improperly formatted contents", + Throwables.getRootCause(e)); + respondOk(jinglePacket); + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; + } + if (isInState(State.SESSION_ACCEPTED)) { + final var group = jinglePacket.getGroup(); + receiveTransportAccept(jinglePacket, new Transport.TransportInfo(transportInfo, group)); + } else { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_ACCEPT); + } + } + + private void receiveTransportAccept( + final JinglePacket jinglePacket, final Transport.TransportInfo transportInfo) { + final FileTransferContentMap remoteContentMap = + getRemoteContentMap().withTransport(transportInfo); + setRemoteContentMap(remoteContentMap); + respondOk(jinglePacket); + final var transport = this.transport; + if (configureTransportWithPeerInfo(transport, remoteContentMap)) { + transport.connect(); + } else { + Log.e( + Config.LOGTAG, + "Transport in transport-accept did not match our transport-replace"); + terminateTransport(); + sendSessionTerminate( + Reason.FAILED_APPLICATION, + "Transport in transport-accept did not match our transport-replace"); + } + } + + private void receiveTransportInfo(final JinglePacket jinglePacket) { + final FileTransferContentMap contentMap; + final GenericTransportInfo transportInfo; + try { + contentMap = FileTransferContentMap.of(jinglePacket); + transportInfo = contentMap.requireOnlyTransportInfo(); + } catch (final RuntimeException e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": improperly formatted contents", + Throwables.getRootCause(e)); + respondOk(jinglePacket); + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; + } + respondOk(jinglePacket); + final var transport = this.transport; + if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport + && transportInfo + instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) { + receiveTransportInfo(socksBytestreamsTransport, socksBytestreamsTransportInfo); + } else if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport + && transportInfo + instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) { + receiveTransportInfo( + Iterables.getOnlyElement(contentMap.contents.keySet()), + webRTCDataChannelTransport, + webRTCDataChannelTransportInfo); + } else if (transportInfo + instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) { + receiveTransportInfo( + Iterables.getOnlyElement(contentMap.contents.keySet()), + webRTCDataChannelTransportInfo); + } else { + Log.d(Config.LOGTAG, "could not deliver transport-info to transport"); + } + } + + private void receiveTransportInfo( + final String contentName, + final WebRTCDataChannelTransport webRTCDataChannelTransport, + final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) { + final var credentials = webRTCDataChannelTransportInfo.getCredentials(); + final var iceCandidates = + WebRTCDataChannelTransport.iceCandidatesOf( + contentName, credentials, webRTCDataChannelTransportInfo.getCandidates()); + final var localContentMap = getLocalContentMap(); + if (localContentMap == null) { + Log.d(Config.LOGTAG, "transport not ready. add pending ice candidate"); + this.pendingIncomingIceCandidates.addAll(iceCandidates); + } else { + webRTCDataChannelTransport.addIceCandidates(iceCandidates); + } + } + + private void receiveTransportInfo( + final String contentName, + final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) { + final var credentials = webRTCDataChannelTransportInfo.getCredentials(); + final var iceCandidates = + WebRTCDataChannelTransport.iceCandidatesOf( + contentName, credentials, webRTCDataChannelTransportInfo.getCandidates()); + this.pendingIncomingIceCandidates.addAll(iceCandidates); + } + + private void receiveTransportInfo( + final SocksByteStreamsTransport socksBytestreamsTransport, + final SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) { + final var transportInfo = socksBytestreamsTransportInfo.getTransportInfo(); + if (transportInfo instanceof SocksByteStreamsTransportInfo.CandidateError) { + socksBytestreamsTransport.setCandidateError(); + } else if (transportInfo + instanceof SocksByteStreamsTransportInfo.CandidateUsed candidateUsed) { + if (!socksBytestreamsTransport.setCandidateUsed(candidateUsed.cid)) { + sendSessionTerminate( + Reason.FAILED_TRANSPORT, + String.format( + "Peer is not connected to our candidate %s", candidateUsed.cid)); + } + } else if (transportInfo instanceof SocksByteStreamsTransportInfo.Activated activated) { + socksBytestreamsTransport.setProxyActivated(activated.cid); + } else if (transportInfo instanceof SocksByteStreamsTransportInfo.ProxyError) { + socksBytestreamsTransport.setProxyError(); + } + } + + private void receiveTransportReplace(final JinglePacket jinglePacket) { + if (isInitiator()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_REPLACE); + return; + } + final GenericTransportInfo transportInfo; + try { + transportInfo = FileTransferContentMap.of(jinglePacket).requireOnlyTransportInfo(); + } catch (final RuntimeException e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": improperly formatted contents", + Throwables.getRootCause(e)); + respondOk(jinglePacket); + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; + } + if (isInState(State.SESSION_ACCEPTED)) { + receiveTransportReplace(jinglePacket, transportInfo); + } else { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_REPLACE); + } + } + + private void receiveTransportReplace( + final JinglePacket jinglePacket, final GenericTransportInfo transportInfo) { + respondOk(jinglePacket); + final Transport currentTransport = this.transport; + if (currentTransport != null) { + Log.d( + Config.LOGTAG, + "terminating " + + currentTransport.getClass().getSimpleName() + + " upon receiving transport-replace"); + currentTransport.setTransportCallback(null); + currentTransport.terminate(); + } + final Transport nextTransport; + try { + nextTransport = setupTransport(transportInfo); + } catch (final RuntimeException e) { + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; + } + this.transport = nextTransport; + Log.d( + Config.LOGTAG, + "replacing transport with " + nextTransport.getClass().getSimpleName()); + this.transport.setTransportCallback(this); + final var transportInfoFuture = nextTransport.asTransportInfo(); + Futures.addCallback( + transportInfoFuture, + new FutureCallback<>() { + @Override + public void onSuccess(final Transport.TransportInfo transportWrapper) { + final FileTransferContentMap contentMap = + getLocalContentMap().withTransport(transportWrapper); + sendTransportAccept(contentMap); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + // transition into application failed (analogues to failureToAccept + } + }, + MoreExecutors.directExecutor()); + } + + private void sendTransportAccept(final FileTransferContentMap contentMap) { + setLocalContentMap(contentMap); + final var jinglePacket = + contentMap + .transportInfo() + .toJinglePacket(JinglePacket.Action.TRANSPORT_ACCEPT, id.sessionId); + send(jinglePacket); + transport.connect(); + } + + protected void sendSessionTerminate(final Reason reason, final String text) { + if (isInitiator()) { + this.message.setErrorMessage(Strings.isNullOrEmpty(text) ? reason.toString() : text); + } + sendSessionTerminate(reason, text, null); + } + + private FileTransferContentMap getLocalContentMap() { + return isInitiator() + ? this.initiatorFileTransferContentMap + : this.responderFileTransferContentMap; + } + + private FileTransferContentMap getRemoteContentMap() { + return isInitiator() + ? this.responderFileTransferContentMap + : this.initiatorFileTransferContentMap; + } + + private void setLocalContentMap(final FileTransferContentMap contentMap) { + if (isInitiator()) { + this.initiatorFileTransferContentMap = contentMap; + } else { + this.responderFileTransferContentMap = contentMap; + } + } + + private void setRemoteContentMap(final FileTransferContentMap contentMap) { + if (isInitiator()) { + this.responderFileTransferContentMap = contentMap; + } else { + this.initiatorFileTransferContentMap = contentMap; + } + } + + public Transport getTransport() { + return this.transport; + } + + @Override + protected void terminateTransport() { + final var transport = this.transport; + if (transport == null) { + return; + } + transport.terminate(); + this.transport = null; + } + + @Override + void notifyRebound() {} + + @Override + public void onTransportEstablished() { + Log.d(Config.LOGTAG, "on transport established"); + final AbstractFileTransceiver fileTransceiver; + try { + fileTransceiver = setupTransceiver(isResponder()); + } catch (final Exception e) { + Log.d(Config.LOGTAG, "failed to set up file transceiver", e); + sendSessionTerminate(Reason.ofThrowable(e), e.getMessage()); + return; + } + this.fileTransceiver = fileTransceiver; + final var fileTransceiverThread = new Thread(fileTransceiver); + fileTransceiverThread.start(); + Futures.addCallback( + fileTransceiver.complete, + new FutureCallback<>() { + @Override + public void onSuccess(final List hashes) { + onFileTransmissionComplete(hashes); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + onFileTransmissionFailed(throwable); + } + }, + MoreExecutors.directExecutor()); + } + + private void onFileTransmissionComplete(final List hashes) { + // TODO if we ever support receiving files this should become isSending(); isReceiving() + if (isInitiator()) { + sendSessionInfoChecksum(hashes); + } else { + Log.d(Config.LOGTAG, "file transfer complete " + hashes); + sendFileSessionInfoReceived(); + terminateTransport(); + messageReceivedSuccess(); + sendSessionTerminate(Reason.SUCCESS, null); + } + } + + private void messageReceivedSuccess() { + this.message.setTransferable(null); + xmppConnectionService.getFileBackend().updateFileParams(message); + xmppConnectionService.databaseBackend.createMessage(message); + final File file = xmppConnectionService.getFileBackend().getFile(message); + if (acceptedAutomatically) { + message.markUnread(); + if (message.getEncryption() == Message.ENCRYPTION_PGP) { + id.account.getPgpDecryptionService().decrypt(message, true); + } else { + xmppConnectionService + .getFileBackend() + .updateMediaScanner( + file, + () -> + JingleFileTransferConnection.this + .xmppConnectionService + .getNotificationService() + .push(message)); + } + } else if (message.getEncryption() == Message.ENCRYPTION_PGP) { + id.account.getPgpDecryptionService().decrypt(message, false); + } else { + xmppConnectionService.getFileBackend().updateMediaScanner(file); + } + } + + private void onFileTransmissionFailed(final Throwable throwable) { + if (isTerminated()) { + Log.d( + Config.LOGTAG, + "file transfer failed but session is already terminated", + throwable); + } else { + terminateTransport(); + Log.d(Config.LOGTAG, "on file transmission failed", throwable); + sendSessionTerminate(Reason.CONNECTIVITY_ERROR, null); + } + } + + private AbstractFileTransceiver setupTransceiver(final boolean receiving) throws IOException { + final var fileDescription = getLocalContentMap().requireOnlyFile(); + final File file = xmppConnectionService.getFileBackend().getFile(message); + final Runnable updateRunnable = () -> jingleConnectionManager.updateConversationUi(false); + if (receiving) { + return new FileReceiver( + file, + this.transportSecurity, + transport.getInputStream(), + transport.getTerminationLatch(), + fileDescription.size, + updateRunnable); + } else { + return new FileTransmitter( + file, + this.transportSecurity, + transport.getOutputStream(), + transport.getTerminationLatch(), + fileDescription.size, + updateRunnable); + } + } + + private void sendFileSessionInfoReceived() { + final var contentMap = getLocalContentMap(); + final String name = Iterables.getOnlyElement(contentMap.contents.keySet()); + sendSessionInfo(new FileTransferDescription.Received(name)); + } + + private void sendSessionInfoChecksum(List hashes) { + final var contentMap = getLocalContentMap(); + final String name = Iterables.getOnlyElement(contentMap.contents.keySet()); + sendSessionInfo(new FileTransferDescription.Checksum(name, hashes)); + } + + private void sendSessionInfo(final FileTransferDescription.SessionInfo sessionInfo) { + final var jinglePacket = + new JinglePacket(JinglePacket.Action.SESSION_INFO, this.id.sessionId); + jinglePacket.addJingleChild(sessionInfo.asElement()); + jinglePacket.setTo(this.id.with); + send(jinglePacket); + } + + @Override + public void onTransportSetupFailed() { + final var transport = this.transport; + if (transport == null) { + // this really is not supposed to happen + sendSessionTerminate(Reason.FAILED_APPLICATION, null); + return; + } + Log.d(Config.LOGTAG, "onTransportSetupFailed"); + final var isTransportInBand = transport instanceof InbandBytestreamsTransport; + if (isTransportInBand) { + terminateTransport(); + sendSessionTerminate(Reason.CONNECTIVITY_ERROR, "Failed to setup IBB transport"); + return; + } + // terminate the current transport + transport.terminate(); + if (isInitiator()) { + this.transport = setupLastResortTransport(); + Log.d( + Config.LOGTAG, + "replacing transport with " + this.transport.getClass().getSimpleName()); + this.transport.setTransportCallback(this); + final var transportInfoFuture = this.transport.asTransportInfo(); + Futures.addCallback( + transportInfoFuture, + new FutureCallback<>() { + @Override + public void onSuccess(final Transport.TransportInfo transportWrapper) { + final FileTransferContentMap contentMap = getLocalContentMap(); + sendTransportReplace(contentMap.withTransport(transportWrapper)); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + // TODO send application failure; + } + }, + MoreExecutors.directExecutor()); + + } else { + Log.d(Config.LOGTAG, "transport setup failed. waiting for initiator to replace"); + } + } + + private void sendTransportReplace(final FileTransferContentMap contentMap) { + setLocalContentMap(contentMap); + final var jinglePacket = + contentMap + .transportInfo() + .toJinglePacket(JinglePacket.Action.TRANSPORT_REPLACE, id.sessionId); + send(jinglePacket); + } + + @Override + public void onAdditionalCandidate( + final String contentName, final Transport.Candidate candidate) { + if (candidate instanceof IceUdpTransportInfo.Candidate iceCandidate) { + sendTransportInfo(contentName, iceCandidate); + } + } + + public void sendTransportInfo( + final String contentName, final IceUdpTransportInfo.Candidate candidate) { + final FileTransferContentMap transportInfo; + try { + final FileTransferContentMap rtpContentMap = getLocalContentMap(); + transportInfo = rtpContentMap.transportInfo(contentName, candidate); + } catch (final Exception e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": unable to prepare transport-info from candidate for content=" + + contentName); + return; + } + final JinglePacket jinglePacket = + transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId); + send(jinglePacket); + } + + @Override + public void onCandidateUsed( + final String streamId, final SocksByteStreamsTransport.Candidate candidate) { + final FileTransferContentMap contentMap = getLocalContentMap(); + if (contentMap == null) { + Log.e(Config.LOGTAG, "local content map is null on candidate used"); + return; + } + final var jinglePacket = + contentMap + .candidateUsed(streamId, candidate.cid) + .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId); + Log.d(Config.LOGTAG, "sending candidate used " + jinglePacket); + send(jinglePacket); + } + + @Override + public void onCandidateError(final String streamId) { + final FileTransferContentMap contentMap = getLocalContentMap(); + if (contentMap == null) { + Log.e(Config.LOGTAG, "local content map is null on candidate used"); + return; + } + final var jinglePacket = + contentMap + .candidateError(streamId) + .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId); + Log.d(Config.LOGTAG, "sending candidate error " + jinglePacket); + send(jinglePacket); + } + + @Override + public void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate) { + final FileTransferContentMap contentMap = getLocalContentMap(); + if (contentMap == null) { + Log.e(Config.LOGTAG, "local content map is null on candidate used"); + return; + } + final var jinglePacket = + contentMap + .proxyActivated(streamId, candidate.cid) + .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId); + send(jinglePacket); + } + + @Override + protected boolean transition(final State target, final Runnable runnable) { + final boolean transitioned = super.transition(target, runnable); + if (transitioned && isInitiator()) { + Log.d(Config.LOGTAG, "running mark message hooks"); + if (target == State.SESSION_ACCEPTED) { + xmppConnectionService.markMessage(message, Message.STATUS_UNSEND); + } else if (target == State.TERMINATED_SUCCESS) { + xmppConnectionService.markMessage(message, Message.STATUS_SEND_RECEIVED); + } else if (TERMINATED.contains(target)) { + xmppConnectionService.markMessage( + message, Message.STATUS_SEND_FAILED, message.getErrorMessage()); + } else { + xmppConnectionService.updateConversationUi(); + } + } else { + if (Arrays.asList(State.TERMINATED_CANCEL_OR_TIMEOUT, State.TERMINATED_DECLINED_OR_BUSY) + .contains(target)) { + this.message.setTransferable( + new TransferablePlaceholder(Transferable.STATUS_CANCELLED)); + } else if (target != State.TERMINATED_SUCCESS && TERMINATED.contains(target)) { + this.message.setTransferable( + new TransferablePlaceholder(Transferable.STATUS_FAILED)); + } + xmppConnectionService.updateConversationUi(); + } + return transitioned; + } + + @Override + protected void finish() { + if (transport != null) { + throw new AssertionError( + "finish MUST not be called without terminating the transport first"); + } + // we don't want to remove TransferablePlaceholder + if (message.getTransferable() instanceof JingleFileTransferConnection) { + Log.d(Config.LOGTAG, "nulling transferable on message"); + this.message.setTransferable(null); + } + super.finish(); + } + + private int getTransferableStatus() { + // status in file transfer is a bit weird. for sending it is mostly handled via + // Message.STATUS_* (offered, unsend (sic) send_received) the transferable status is just + // uploading + // for receiving the message status remains at 'received' but Transferable goes through + // various status + if (isInitiator()) { + return Transferable.STATUS_UPLOADING; + } + final var state = getState(); + return switch (state) { + case NULL, SESSION_INITIALIZED, SESSION_INITIALIZED_PRE_APPROVED -> Transferable + .STATUS_OFFER; + case TERMINATED_APPLICATION_FAILURE, + TERMINATED_CONNECTIVITY_ERROR, + TERMINATED_DECLINED_OR_BUSY, + TERMINATED_SECURITY_ERROR -> Transferable.STATUS_FAILED; + case TERMINATED_CANCEL_OR_TIMEOUT -> Transferable.STATUS_CANCELLED; + case SESSION_ACCEPTED -> Transferable.STATUS_DOWNLOADING; + default -> Transferable.STATUS_UNKNOWN; + }; + } + + // these methods are for interacting with 'Transferable' - we might want to remove the concept + // at some point + + @Override + public boolean start() { + Log.d(Config.LOGTAG, "user pressed start()"); + // TODO there is a 'connected' check apparently? + if (isInState(State.SESSION_INITIALIZED)) { + sendSessionAccept(); + } + return true; + } + @Override public int getStatus() { - return this.mStatus; + return getTransferableStatus(); } @Override public Long getFileSize() { - if (this.file != null) { - return this.file.getExpectedSize(); - } else { - return null; + final var transceiver = this.fileTransceiver; + if (transceiver != null) { + return transceiver.total; } + final var contentMap = this.initiatorFileTransferContentMap; + if (contentMap != null) { + return contentMap.requireOnlyFile().size; + } + return null; } @Override public int getProgress() { - return this.mProgress; + final var transceiver = this.fileTransceiver; + return transceiver != null ? transceiver.getProgress() : 0; } - AbstractConnectionManager getConnectionManager() { - return this.jingleConnectionManager; + @Override + public void cancel() { + if (stopFileTransfer()) { + Log.d(Config.LOGTAG, "user has stopped file transfer"); + } else { + Log.d(Config.LOGTAG, "user pressed cancel but file transfer was already terminated?"); + } } - interface OnProxyActivated { - void success(); + private boolean stopFileTransfer() { + if (isInitiator()) { + return stopFileTransfer(Reason.CANCEL); + } else { + return stopFileTransfer(Reason.DECLINE); + } + } - void failed(); + private boolean stopFileTransfer(final Reason reason) { + final State target = reasonToState(reason); + if (transition(target)) { + // we change state before terminating transport so we don't consume the following + // IOException and turn it into a connectivity error + terminateTransport(); + final JinglePacket jinglePacket = + new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId); + jinglePacket.setReason(reason, "User requested to stop file transfer"); + send(jinglePacket); + finish(); + return true; + } else { + return false; + } + } + + private abstract static class AbstractFileTransceiver implements Runnable { + + protected final SettableFuture> complete = + SettableFuture.create(); + + protected final File file; + protected final TransportSecurity transportSecurity; + + protected final CountDownLatch transportTerminationLatch; + protected final long total; + protected long transmitted = 0; + private int progress = Integer.MIN_VALUE; + private final Runnable updateRunnable; + + private AbstractFileTransceiver( + final File file, + final TransportSecurity transportSecurity, + final CountDownLatch transportTerminationLatch, + final long total, + final Runnable updateRunnable) { + this.file = file; + this.transportSecurity = transportSecurity; + this.transportTerminationLatch = transportTerminationLatch; + this.total = transportSecurity == null ? total : (total + 16); + this.updateRunnable = updateRunnable; + } + + static void closeTransport(final Closeable stream) { + try { + stream.close(); + } catch (final IOException e) { + Log.d(Config.LOGTAG, "transport has already been closed. good"); + } + } + + public int getProgress() { + return Ints.saturatedCast(Math.round((1.0 * transmitted / total) * 100)); + } + + public void updateProgress() { + final int current = getProgress(); + final boolean update; + synchronized (this) { + if (this.progress != current) { + this.progress = current; + update = true; + } else { + update = false; + } + if (update) { + this.updateRunnable.run(); + } + } + } + + protected void awaitTransportTermination() { + try { + this.transportTerminationLatch.await(); + } catch (final InterruptedException ignored) { + return; + } + Log.d(Config.LOGTAG, getClass().getSimpleName() + " says Goodbye!"); + } + } + + private static class FileTransmitter extends AbstractFileTransceiver { + + private final OutputStream outputStream; + + private FileTransmitter( + final File file, + final TransportSecurity transportSecurity, + final OutputStream outputStream, + final CountDownLatch transportTerminationLatch, + final long total, + final Runnable updateRunnable) { + super(file, transportSecurity, transportTerminationLatch, total, updateRunnable); + this.outputStream = outputStream; + } + + private InputStream openFileInputStream() throws FileNotFoundException { + final var fileInputStream = new FileInputStream(this.file); + if (this.transportSecurity == null) { + return fileInputStream; + } else { + final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine()); + cipher.init( + true, + new AEADParameters( + new KeyParameter(transportSecurity.key), + 128, + transportSecurity.iv)); + Log.d(Config.LOGTAG, "setting up CipherInputStream"); + return new CipherInputStream(fileInputStream, cipher); + } + } + + @Override + public void run() { + Log.d(Config.LOGTAG, "file transmitter attempting to send " + total + " bytes"); + final var sha1Hasher = Hashing.sha1().newHasher(); + final var sha256Hasher = Hashing.sha256().newHasher(); + try (final var fileInputStream = openFileInputStream()) { + final var buffer = new byte[4096]; + while (total - transmitted > 0) { + final int count = fileInputStream.read(buffer); + if (count == -1) { + throw new EOFException( + String.format("reached EOF after %d/%d", transmitted, total)); + } + outputStream.write(buffer, 0, count); + sha1Hasher.putBytes(buffer, 0, count); + sha256Hasher.putBytes(buffer, 0, count); + transmitted += count; + updateProgress(); + } + outputStream.flush(); + Log.d( + Config.LOGTAG, + "transmitted " + transmitted + " bytes from " + file.getAbsolutePath()); + final List hashes = + ImmutableList.of( + new FileTransferDescription.Hash( + sha1Hasher.hash().asBytes(), + FileTransferDescription.Algorithm.SHA_1), + new FileTransferDescription.Hash( + sha256Hasher.hash().asBytes(), + FileTransferDescription.Algorithm.SHA_256)); + complete.set(hashes); + } catch (final Exception e) { + complete.setException(e); + } + // the transport implementations backed by PipedOutputStreams do not like it when + // the writing Thread (this thread) goes away. so we just wait until the other peer + // has received our file and we are shutting down the transport + Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread"); + awaitTransportTermination(); + closeTransport(outputStream); + } + } + + private static class FileReceiver extends AbstractFileTransceiver { + + private final InputStream inputStream; + + private FileReceiver( + final File file, + final TransportSecurity transportSecurity, + final InputStream inputStream, + final CountDownLatch transportTerminationLatch, + final long total, + final Runnable updateRunnable) { + super(file, transportSecurity, transportTerminationLatch, total, updateRunnable); + this.inputStream = inputStream; + } + + private OutputStream openFileOutputStream() throws FileNotFoundException { + final var directory = this.file.getParentFile(); + if (directory != null && directory.mkdirs()) { + Log.d(Config.LOGTAG, "created directory " + directory.getAbsolutePath()); + } + final var fileOutputStream = new FileOutputStream(this.file); + if (this.transportSecurity == null) { + return fileOutputStream; + } else { + final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine()); + cipher.init( + false, + new AEADParameters( + new KeyParameter(transportSecurity.key), + 128, + transportSecurity.iv)); + Log.d(Config.LOGTAG, "setting up CipherOutputStream"); + return new CipherOutputStream(fileOutputStream, cipher); + } + } + + @Override + public void run() { + Log.d(Config.LOGTAG, "file receiver attempting to receive " + total + " bytes"); + final var sha1Hasher = Hashing.sha1().newHasher(); + final var sha256Hasher = Hashing.sha256().newHasher(); + try (final var fileOutputStream = openFileOutputStream()) { + final var buffer = new byte[4096]; + while (total - transmitted > 0) { + final int count = inputStream.read(buffer); + if (count == -1) { + throw new EOFException( + String.format("reached EOF after %d/%d", transmitted, total)); + } + fileOutputStream.write(buffer, 0, count); + sha1Hasher.putBytes(buffer, 0, count); + sha256Hasher.putBytes(buffer, 0, count); + transmitted += count; + updateProgress(); + } + Log.d( + Config.LOGTAG, + "written " + transmitted + " bytes to " + file.getAbsolutePath()); + final List hashes = + ImmutableList.of( + new FileTransferDescription.Hash( + sha1Hasher.hash().asBytes(), + FileTransferDescription.Algorithm.SHA_1), + new FileTransferDescription.Hash( + sha256Hasher.hash().asBytes(), + FileTransferDescription.Algorithm.SHA_256)); + complete.set(hashes); + } catch (final Exception e) { + complete.setException(e); + } + Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread"); + awaitTransportTermination(); + closeTransport(inputStream); + } + } + + private static final class TransportSecurity { + final byte[] key; + final byte[] iv; + + private TransportSecurity(byte[] key, byte[] iv) { + this.key = key; + this.iv = iv; + } } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java deleted file mode 100644 index c68941928..000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java +++ /dev/null @@ -1,265 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle; - -import android.util.Base64; -import android.util.Log; - -import com.google.common.base.Preconditions; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Arrays; - -import eu.siacs.conversations.Config; -import eu.siacs.conversations.entities.Account; -import eu.siacs.conversations.entities.DownloadableFile; -import eu.siacs.conversations.persistance.FileBackend; -import eu.siacs.conversations.services.AbstractConnectionManager; -import eu.siacs.conversations.xml.Element; -import eu.siacs.conversations.xmpp.Jid; -import eu.siacs.conversations.xmpp.OnIqPacketReceived; -import eu.siacs.conversations.xmpp.stanzas.IqPacket; - -public class JingleInBandTransport extends JingleTransport { - - private final Account account; - private final Jid counterpart; - private final int blockSize; - private int seq = 0; - private final String sessionId; - - private boolean established = false; - - private boolean connected = true; - - private DownloadableFile file; - private final JingleFileTransferConnection connection; - - private InputStream fileInputStream = null; - private InputStream innerInputStream = null; - private OutputStream fileOutputStream = null; - private long remainingSize = 0; - private long fileSize = 0; - private MessageDigest digest; - - private OnFileTransmissionStatusChanged onFileTransmissionStatusChanged; - - private final OnIqPacketReceived onAckReceived = new OnIqPacketReceived() { - @Override - public void onIqPacketReceived(Account account, IqPacket packet) { - if (!connected) { - return; - } - if (packet.getType() == IqPacket.TYPE.RESULT) { - if (remainingSize > 0) { - sendNextBlock(); - } - } else if (packet.getType() == IqPacket.TYPE.ERROR) { - onFileTransmissionStatusChanged.onFileTransferAborted(); - } - } - }; - - JingleInBandTransport(final JingleFileTransferConnection connection, final String sid, final int blockSize) { - this.connection = connection; - this.account = connection.getId().account; - this.counterpart = connection.getId().with; - this.blockSize = blockSize; - this.sessionId = sid; - } - - private void sendClose() { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sending ibb close"); - IqPacket iq = new IqPacket(IqPacket.TYPE.SET); - iq.setTo(this.counterpart); - Element close = iq.addChild("close", "http://jabber.org/protocol/ibb"); - close.setAttribute("sid", this.sessionId); - this.account.getXmppConnection().sendIqPacket(iq, null); - } - - public boolean matches(final Account account, final String sessionId) { - return this.account == account && this.sessionId.equals(sessionId); - } - - public void connect(final OnTransportConnected callback) { - IqPacket iq = new IqPacket(IqPacket.TYPE.SET); - iq.setTo(this.counterpart); - Element open = iq.addChild("open", "http://jabber.org/protocol/ibb"); - open.setAttribute("sid", this.sessionId); - open.setAttribute("stanza", "iq"); - open.setAttribute("block-size", Integer.toString(this.blockSize)); - this.connected = true; - this.account.getXmppConnection().sendIqPacket(iq, (account, packet) -> { - if (packet.getType() != IqPacket.TYPE.RESULT) { - callback.failed(); - } else { - callback.established(); - } - }); - } - - @Override - public void receive(DownloadableFile file, OnFileTransmissionStatusChanged callback) { - this.onFileTransmissionStatusChanged = Preconditions.checkNotNull(callback); - this.file = file; - try { - this.digest = MessageDigest.getInstance("SHA-1"); - digest.reset(); - this.fileOutputStream = connection.getFileOutputStream(); - if (this.fileOutputStream == null) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": could not create output stream"); - callback.onFileTransferAborted(); - return; - } - this.remainingSize = this.fileSize = file.getExpectedSize(); - } catch (final NoSuchAlgorithmException | IOException e) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + " " + e.getMessage()); - callback.onFileTransferAborted(); - } - } - - @Override - public void send(DownloadableFile file, OnFileTransmissionStatusChanged callback) { - this.onFileTransmissionStatusChanged = Preconditions.checkNotNull(callback); - this.file = file; - try { - this.remainingSize = this.file.getExpectedSize(); - this.fileSize = this.remainingSize; - this.digest = MessageDigest.getInstance("SHA-1"); - this.digest.reset(); - fileInputStream = connection.getFileInputStream(); - if (fileInputStream == null) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": could no create input stream"); - callback.onFileTransferAborted(); - return; - } - innerInputStream = AbstractConnectionManager.upgrade(file, fileInputStream); - if (this.connected) { - this.sendNextBlock(); - } - } catch (Exception e) { - callback.onFileTransferAborted(); - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + e.getMessage()); - } - } - - @Override - public void disconnect() { - this.connected = false; - FileBackend.close(fileOutputStream); - FileBackend.close(fileInputStream); - } - - private void sendNextBlock() { - byte[] buffer = new byte[this.blockSize]; - try { - int count = innerInputStream.read(buffer); - if (count == -1) { - sendClose(); - file.setSha1Sum(digest.digest()); - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sendNextBlock() count was -1"); - this.onFileTransmissionStatusChanged.onFileTransmitted(file); - fileInputStream.close(); - return; - } else if (count != buffer.length) { - int rem = innerInputStream.read(buffer, count, buffer.length - count); - if (rem > 0) { - count += rem; - } - } - this.remainingSize -= count; - this.digest.update(buffer, 0, count); - String base64 = Base64.encodeToString(buffer, 0, count, Base64.NO_WRAP); - IqPacket iq = new IqPacket(IqPacket.TYPE.SET); - iq.setTo(this.counterpart); - Element data = iq.addChild("data", "http://jabber.org/protocol/ibb"); - data.setAttribute("seq", Integer.toString(this.seq)); - data.setAttribute("block-size", Integer.toString(this.blockSize)); - data.setAttribute("sid", this.sessionId); - data.setContent(base64); - this.account.getXmppConnection().sendIqPacket(iq, this.onAckReceived); - this.account.getXmppConnection().r(); //don't fill up stanza queue too much - this.seq++; - connection.updateProgress((int) ((((double) (this.fileSize - this.remainingSize)) / this.fileSize) * 100)); - if (this.remainingSize <= 0) { - file.setSha1Sum(digest.digest()); - this.onFileTransmissionStatusChanged.onFileTransmitted(file); - sendClose(); - fileInputStream.close(); - } - } catch (IOException e) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": io exception during sendNextBlock() " + e.getMessage()); - FileBackend.close(fileInputStream); - this.onFileTransmissionStatusChanged.onFileTransferAborted(); - } - } - - private void receiveNextBlock(String data) { - try { - byte[] buffer = Base64.decode(data, Base64.NO_WRAP); - if (this.remainingSize < buffer.length) { - buffer = Arrays.copyOfRange(buffer, 0, (int) this.remainingSize); - } - this.remainingSize -= buffer.length; - this.fileOutputStream.write(buffer); - this.digest.update(buffer); - if (this.remainingSize <= 0) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received last block. waiting for close"); - } else { - connection.updateProgress((int) ((((double) (this.fileSize - this.remainingSize)) / this.fileSize) * 100)); - } - } catch (Exception e) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + e.getMessage(), e); - FileBackend.close(fileOutputStream); - this.onFileTransmissionStatusChanged.onFileTransferAborted(); - } - } - - private void done() { - try { - file.setSha1Sum(digest.digest()); - fileOutputStream.flush(); - fileOutputStream.close(); - this.onFileTransmissionStatusChanged.onFileTransmitted(file); - } catch (Exception e) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + e.getMessage()); - FileBackend.close(fileOutputStream); - this.onFileTransmissionStatusChanged.onFileTransferAborted(); - } - } - - void deliverPayload(IqPacket packet, Element payload) { - if (payload.getName().equals("open")) { - if (!established) { - established = true; - connected = true; - this.receiveNextBlock(""); - this.account.getXmppConnection().sendIqPacket( - packet.generateResponse(IqPacket.TYPE.RESULT), null); - } else { - this.account.getXmppConnection().sendIqPacket( - packet.generateResponse(IqPacket.TYPE.ERROR), null); - } - } else if (connected && payload.getName().equals("data")) { - this.receiveNextBlock(payload.getContent()); - this.account.getXmppConnection().sendIqPacket( - packet.generateResponse(IqPacket.TYPE.RESULT), null); - } else if (connected && payload.getName().equals("close")) { - this.connected = false; - this.account.getXmppConnection().sendIqPacket( - packet.generateResponse(IqPacket.TYPE.RESULT), null); - if (this.remainingSize <= 0) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received ibb close. done"); - done(); - } else { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received ibb close with " + this.remainingSize + " remaining"); - FileBackend.close(fileOutputStream); - this.onFileTransmissionStatusChanged.onFileTransferAborted(); - } - } else { - this.account.getXmppConnection().sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null); - } - } -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java index 23004d980..5e1c5eba5 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java @@ -13,8 +13,10 @@ import com.google.common.base.Strings; import com.google.common.base.Throwables; import com.google.common.collect.Collections2; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; +import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.common.primitives.Ints; import com.google.common.util.concurrent.FutureCallback; @@ -22,6 +24,31 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import eu.siacs.conversations.BuildConfig; +import eu.siacs.conversations.Config; +import eu.siacs.conversations.crypto.axolotl.AxolotlService; +import eu.siacs.conversations.crypto.axolotl.CryptoFailedException; +import eu.siacs.conversations.crypto.axolotl.FingerprintStatus; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Conversational; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.entities.RtpSessionStatus; +import eu.siacs.conversations.services.AppRTCAudioManager; +import eu.siacs.conversations.utils.IP; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.Jid; +import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.jingle.stanzas.Group; +import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; +import eu.siacs.conversations.xmpp.jingle.stanzas.Proceed; +import eu.siacs.conversations.xmpp.jingle.stanzas.Propose; +import eu.siacs.conversations.xmpp.jingle.stanzas.Reason; +import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; +import eu.siacs.conversations.xmpp.stanzas.MessagePacket; + import org.webrtc.DtmfSender; import org.webrtc.EglBase; import org.webrtc.IceCandidate; @@ -40,35 +67,6 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -import eu.siacs.conversations.BuildConfig; -import eu.siacs.conversations.Config; -import eu.siacs.conversations.crypto.axolotl.AxolotlService; -import eu.siacs.conversations.crypto.axolotl.CryptoFailedException; -import eu.siacs.conversations.crypto.axolotl.FingerprintStatus; -import eu.siacs.conversations.entities.Account; -import eu.siacs.conversations.entities.Contact; -import eu.siacs.conversations.entities.Conversation; -import eu.siacs.conversations.entities.Conversational; -import eu.siacs.conversations.entities.Message; -import eu.siacs.conversations.entities.Presence; -import eu.siacs.conversations.entities.RtpSessionStatus; -import eu.siacs.conversations.entities.ServiceDiscoveryResult; -import eu.siacs.conversations.services.AppRTCAudioManager; -import eu.siacs.conversations.utils.IP; -import eu.siacs.conversations.xml.Element; -import eu.siacs.conversations.xml.Namespace; -import eu.siacs.conversations.xmpp.Jid; -import eu.siacs.conversations.xmpp.jingle.stanzas.Content; -import eu.siacs.conversations.xmpp.jingle.stanzas.Group; -import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; -import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; -import eu.siacs.conversations.xmpp.jingle.stanzas.Proceed; -import eu.siacs.conversations.xmpp.jingle.stanzas.Propose; -import eu.siacs.conversations.xmpp.jingle.stanzas.Reason; -import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; -import eu.siacs.conversations.xmpp.stanzas.IqPacket; -import eu.siacs.conversations.xmpp.stanzas.MessagePacket; - public class JingleRtpConnection extends AbstractJingleConnection implements WebRTCWrapper.EventCallback { @@ -76,96 +74,13 @@ public class JingleRtpConnection extends AbstractJingleConnection Arrays.asList( State.PROCEED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED); private static final long BUSY_TIME_OUT = 30; - private static final List TERMINATED = - Arrays.asList( - State.ACCEPTED, - State.REJECTED, - State.REJECTED_RACED, - State.RETRACTED, - State.RETRACTED_RACED, - State.TERMINATED_SUCCESS, - State.TERMINATED_DECLINED_OR_BUSY, - State.TERMINATED_CONNECTIVITY_ERROR, - State.TERMINATED_CANCEL_OR_TIMEOUT, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR); - - private static final Map> VALID_TRANSITIONS; - - static { - final ImmutableMap.Builder> transitionBuilder = - new ImmutableMap.Builder<>(); - transitionBuilder.put( - State.NULL, - ImmutableList.of( - State.PROPOSED, - State.SESSION_INITIALIZED, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR)); - transitionBuilder.put( - State.PROPOSED, - ImmutableList.of( - State.ACCEPTED, - State.PROCEED, - State.REJECTED, - State.RETRACTED, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR, - State.TERMINATED_CONNECTIVITY_ERROR // only used when the xmpp connection - // rebinds - )); - transitionBuilder.put( - State.PROCEED, - ImmutableList.of( - State.REJECTED_RACED, - State.RETRACTED_RACED, - State.SESSION_INITIALIZED_PRE_APPROVED, - State.TERMINATED_SUCCESS, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR, - State.TERMINATED_CONNECTIVITY_ERROR // at this state used for error - // bounces of the proceed message - )); - transitionBuilder.put( - State.SESSION_INITIALIZED, - ImmutableList.of( - State.SESSION_ACCEPTED, - State.TERMINATED_SUCCESS, - State.TERMINATED_DECLINED_OR_BUSY, - State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors - // and IQ timeouts - State.TERMINATED_CANCEL_OR_TIMEOUT, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR)); - transitionBuilder.put( - State.SESSION_INITIALIZED_PRE_APPROVED, - ImmutableList.of( - State.SESSION_ACCEPTED, - State.TERMINATED_SUCCESS, - State.TERMINATED_DECLINED_OR_BUSY, - State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors - // and IQ timeouts - State.TERMINATED_CANCEL_OR_TIMEOUT, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR)); - transitionBuilder.put( - State.SESSION_ACCEPTED, - ImmutableList.of( - State.TERMINATED_SUCCESS, - State.TERMINATED_DECLINED_OR_BUSY, - State.TERMINATED_CONNECTIVITY_ERROR, - State.TERMINATED_CANCEL_OR_TIMEOUT, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR)); - VALID_TRANSITIONS = transitionBuilder.build(); - } private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this); - private final Queue> + private final Queue>> pendingIceCandidates = new LinkedList<>(); private final OmemoVerification omemoVerification = new OmemoVerification(); private final Message message; - private State state = State.NULL; + private Set proposedMedia; private RtpContentMap initiatorRtpContentMap; private RtpContentMap responderRtpContentMap; @@ -190,62 +105,26 @@ public class JingleRtpConnection extends AbstractJingleConnection id.sessionId); } - private static State reasonToState(Reason reason) { - switch (reason) { - case SUCCESS: - return State.TERMINATED_SUCCESS; - case DECLINE: - case BUSY: - return State.TERMINATED_DECLINED_OR_BUSY; - case CANCEL: - case TIMEOUT: - return State.TERMINATED_CANCEL_OR_TIMEOUT; - case SECURITY_ERROR: - return State.TERMINATED_SECURITY_ERROR; - case FAILED_APPLICATION: - case UNSUPPORTED_TRANSPORTS: - case UNSUPPORTED_APPLICATIONS: - return State.TERMINATED_APPLICATION_FAILURE; - default: - return State.TERMINATED_CONNECTIVITY_ERROR; - } - } - @Override synchronized void deliverPacket(final JinglePacket jinglePacket) { switch (jinglePacket.getAction()) { - case SESSION_INITIATE: - receiveSessionInitiate(jinglePacket); - break; - case TRANSPORT_INFO: - receiveTransportInfo(jinglePacket); - break; - case SESSION_ACCEPT: - receiveSessionAccept(jinglePacket); - break; - case SESSION_TERMINATE: - receiveSessionTerminate(jinglePacket); - break; - case CONTENT_ADD: - receiveContentAdd(jinglePacket); - break; - case CONTENT_ACCEPT: - receiveContentAccept(jinglePacket); - break; - case CONTENT_REJECT: - receiveContentReject(jinglePacket); - break; - case CONTENT_REMOVE: - receiveContentRemove(jinglePacket); - break; - default: + case SESSION_INITIATE -> receiveSessionInitiate(jinglePacket); + case TRANSPORT_INFO -> receiveTransportInfo(jinglePacket); + case SESSION_ACCEPT -> receiveSessionAccept(jinglePacket); + case SESSION_TERMINATE -> receiveSessionTerminate(jinglePacket); + case CONTENT_ADD -> receiveContentAdd(jinglePacket); + case CONTENT_ACCEPT -> receiveContentAccept(jinglePacket); + case CONTENT_REJECT -> receiveContentReject(jinglePacket); + case CONTENT_REMOVE -> receiveContentRemove(jinglePacket); + case CONTENT_MODIFY -> receiveContentModify(jinglePacket); + default -> { respondOk(jinglePacket); Log.d( Config.LOGTAG, String.format( "%s: received unhandled jingle action %s", id.account.getJid().asBareJid(), jinglePacket.getAction())); - break; + } } } @@ -255,7 +134,7 @@ public class JingleRtpConnection extends AbstractJingleConnection return; } webRTCWrapper.close(); - if (!isInitiator() && isInState(State.PROPOSED, State.SESSION_INITIALIZED)) { + if (isResponder() && isInState(State.PROPOSED, State.SESSION_INITIALIZED)) { xmppConnectionService.getNotificationService().cancelIncomingCallNotification(); } if (isInState( @@ -350,25 +229,41 @@ public class JingleRtpConnection extends AbstractJingleConnection private void receiveTransportInfo( final JinglePacket jinglePacket, final RtpContentMap contentMap) { - final Set> candidates = + final Set>> candidates = contentMap.contents.entrySet(); - if (this.state == State.SESSION_ACCEPTED) { - // zero candidates + modified credentials are an ICE restart offer - if (checkForIceRestart(jinglePacket, contentMap)) { - return; - } - respondOk(jinglePacket); - try { - processCandidates(candidates); - } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) { - Log.w( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": PeerConnection was not initialized when processing transport info. this usually indicates a race condition that can be ignored"); - } - } else { + final RtpContentMap remote = getRemoteContentMap(); + final Set remoteContentIds = + remote == null ? Collections.emptySet() : remote.contents.keySet(); + if (Collections.disjoint(remoteContentIds, contentMap.contents.keySet())) { + Log.d( + Config.LOGTAG, + "received transport-info for unknown contents " + + contentMap.contents.keySet() + + " (known: " + + remoteContentIds + + ")"); respondOk(jinglePacket); pendingIceCandidates.addAll(candidates); + return; + } + if (this.state != State.SESSION_ACCEPTED) { + Log.d(Config.LOGTAG, "received transport-info prematurely. adding to backlog"); + respondOk(jinglePacket); + pendingIceCandidates.addAll(candidates); + return; + } + // zero candidates + modified credentials are an ICE restart offer + if (checkForIceRestart(jinglePacket, contentMap)) { + return; + } + respondOk(jinglePacket); + try { + processCandidates(candidates); + } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) { + Log.w( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": PeerConnection was not initialized when processing transport info. this usually indicates a race condition that can be ignored"); } } @@ -388,7 +283,34 @@ public class JingleRtpConnection extends AbstractJingleConnection return; } if (isInState(State.SESSION_ACCEPTED)) { - receiveContentAdd(jinglePacket, modification); + final boolean hasFullTransportInfo = modification.hasFullTransportInfo(); + final ListenableFuture future = + receiveRtpContentMap( + modification, + this.omemoVerification.hasFingerprint() && hasFullTransportInfo); + Futures.addCallback( + future, + new FutureCallback<>() { + @Override + public void onSuccess(final RtpContentMap rtpContentMap) { + receiveContentAdd(jinglePacket, rtpContentMap); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + respondOk(jinglePacket); + final Throwable rootCause = Throwables.getRootCause(throwable); + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": improperly formatted contents in content-add", + throwable); + webRTCWrapper.close(); + sendSessionTerminate( + Reason.ofThrowable(rootCause), rootCause.getMessage()); + } + }, + MoreExecutors.directExecutor()); } else { terminateWithOutOfOrder(jinglePacket); } @@ -473,7 +395,27 @@ public class JingleRtpConnection extends AbstractJingleConnection if (ourSummary.equals(ContentAddition.summary(receivedContentAccept))) { this.outgoingContentAdd = null; respondOk(jinglePacket); - receiveContentAccept(receivedContentAccept); + final boolean hasFullTransportInfo = receivedContentAccept.hasFullTransportInfo(); + final ListenableFuture future = + receiveRtpContentMap( + receivedContentAccept, + this.omemoVerification.hasFingerprint() && hasFullTransportInfo); + Futures.addCallback( + future, + new FutureCallback<>() { + @Override + public void onSuccess(final RtpContentMap result) { + receiveContentAccept(result); + } + + @Override + public void onFailure(@NonNull final Throwable throwable) { + webRTCWrapper.close(); + sendSessionTerminate( + Reason.ofThrowable(throwable), throwable.getMessage()); + } + }, + MoreExecutors.directExecutor()); } else { Log.d(Config.LOGTAG, "received content-accept did not match our outgoing content-add"); terminateWithOutOfOrder(jinglePacket); @@ -487,7 +429,7 @@ public class JingleRtpConnection extends AbstractJingleConnection setRemoteContentMap(modifiedContentMap); - final SessionDescription answer = SessionDescription.of(modifiedContentMap, !isInitiator()); + final SessionDescription answer = SessionDescription.of(modifiedContentMap, isResponder()); final org.webrtc.SessionDescription sdp = new org.webrtc.SessionDescription( @@ -506,12 +448,123 @@ public class JingleRtpConnection extends AbstractJingleConnection sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage()); return; } - updateEndUserState(); Log.d( Config.LOGTAG, id.getAccount().getJid().asBareJid() + ": remote has accepted content-add " + ContentAddition.summary(receivedContentAccept)); + processCandidates(receivedContentAccept.contents.entrySet()); + updateEndUserState(); + } + + private void receiveContentModify(final JinglePacket jinglePacket) { + if (this.state != State.SESSION_ACCEPTED) { + terminateWithOutOfOrder(jinglePacket); + return; + } + final Map modification = + Maps.transformEntries( + jinglePacket.getJingleContents(), (key, value) -> value.getSenders()); + final boolean isInitiator = isInitiator(); + final RtpContentMap currentOutgoing = this.outgoingContentAdd; + final RtpContentMap remoteContentMap = this.getRemoteContentMap(); + final Set currentOutgoingMediaIds = + currentOutgoing == null + ? Collections.emptySet() + : currentOutgoing.contents.keySet(); + Log.d(Config.LOGTAG, "receiveContentModification(" + modification + ")"); + if (currentOutgoing != null && currentOutgoingMediaIds.containsAll(modification.keySet())) { + respondOk(jinglePacket); + final RtpContentMap modifiedContentMap; + try { + modifiedContentMap = + currentOutgoing.modifiedSendersChecked(isInitiator, modification); + } catch (final IllegalArgumentException e) { + webRTCWrapper.close(); + sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage()); + return; + } + this.outgoingContentAdd = modifiedContentMap; + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": processed content-modification for pending content-add"); + } else if (remoteContentMap != null + && remoteContentMap.contents.keySet().containsAll(modification.keySet())) { + respondOk(jinglePacket); + final RtpContentMap modifiedRemoteContentMap; + try { + modifiedRemoteContentMap = + remoteContentMap.modifiedSendersChecked(isInitiator, modification); + } catch (final IllegalArgumentException e) { + webRTCWrapper.close(); + sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage()); + return; + } + final SessionDescription offer; + try { + offer = SessionDescription.of(modifiedRemoteContentMap, isResponder()); + } catch (final IllegalArgumentException | NullPointerException e) { + Log.d( + Config.LOGTAG, + id.getAccount().getJid().asBareJid() + + ": unable convert offer from content-modify to SDP", + e); + webRTCWrapper.close(); + sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage()); + return; + } + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": auto accepting content-modification"); + this.autoAcceptContentModify(modifiedRemoteContentMap, offer); + } else { + Log.d(Config.LOGTAG, "received unsupported content modification " + modification); + respondWithItemNotFound(jinglePacket); + } + } + + private void autoAcceptContentModify( + final RtpContentMap modifiedRemoteContentMap, final SessionDescription offer) { + this.setRemoteContentMap(modifiedRemoteContentMap); + final org.webrtc.SessionDescription sdp = + new org.webrtc.SessionDescription( + org.webrtc.SessionDescription.Type.OFFER, offer.toString()); + try { + this.webRTCWrapper.setRemoteDescription(sdp).get(); + // auto accept is only done when we already have tracks + final SessionDescription answer = setLocalSessionDescription(); + final RtpContentMap rtpContentMap = RtpContentMap.of(answer, isInitiator()); + modifyLocalContentMap(rtpContentMap); + // we do not need to send an answer but do we have to resend the candidates currently in + // SDP? + // resendCandidatesFromSdp(answer); + webRTCWrapper.setIsReadyToReceiveIceCandidates(true); + } catch (final Exception e) { + Log.d(Config.LOGTAG, "unable to accept content add", Throwables.getRootCause(e)); + webRTCWrapper.close(); + sendSessionTerminate(Reason.FAILED_APPLICATION); + } + } + + private static ImmutableMultimap parseCandidates( + final SessionDescription answer) { + final ImmutableMultimap.Builder candidateBuilder = + new ImmutableMultimap.Builder<>(); + for (final SessionDescription.Media media : answer.media) { + final String mid = Iterables.getFirst(media.attributes.get("mid"), null); + if (Strings.isNullOrEmpty(mid)) { + continue; + } + for (final String sdpCandidate : media.attributes.get("candidate")) { + final IceUdpTransportInfo.Candidate candidate = + IceUdpTransportInfo.Candidate.fromSdpAttributeValue(sdpCandidate, null); + if (candidate != null) { + candidateBuilder.put(mid, candidate); + } + } + } + return candidateBuilder.build(); } private void receiveContentReject(final JinglePacket jinglePacket) { @@ -539,7 +592,7 @@ public class JingleRtpConnection extends AbstractJingleConnection if (ourSummary.equals(ContentAddition.summary(receivedContentReject))) { this.outgoingContentAdd = null; respondOk(jinglePacket); - Log.d(Config.LOGTAG,jinglePacket.toString()); + Log.d(Config.LOGTAG, jinglePacket.toString()); receiveContentReject(ourSummary); } else { Log.d(Config.LOGTAG, "received content-reject did not match our outgoing content-add"); @@ -608,7 +661,7 @@ public class JingleRtpConnection extends AbstractJingleConnection "%s only supports %s as a means to retract a not yet accepted %s", BuildConfig.APP_NAME, JinglePacket.Action.CONTENT_REMOVE, - JinglePacket.Action.CONTENT_ACCEPT)); + JinglePacket.Action.CONTENT_ADD)); } } @@ -669,13 +722,14 @@ public class JingleRtpConnection extends AbstractJingleConnection final RtpContentMap nextRemote = currentRemote.addContent( patch.modifiedSenders(Content.Senders.NONE), getPeerDtlsSetup()); - return SessionDescription.of(nextRemote, !isInitiator()); + return SessionDescription.of(nextRemote, isResponder()); } throw new IllegalStateException( "Unexpected rollback condition. Senders were not uniformly none"); } - public synchronized void acceptContentAdd(@NonNull final Set contentAddition) { + public synchronized void acceptContentAdd( + @NonNull final Set contentAddition) { final RtpContentMap incomingContentAdd = this.incomingContentAdd; if (incomingContentAdd == null) { throw new IllegalStateException("No incoming content add"); @@ -683,22 +737,64 @@ public class JingleRtpConnection extends AbstractJingleConnection if (contentAddition.equals(ContentAddition.summary(incomingContentAdd))) { this.incomingContentAdd = null; - acceptContentAdd(contentAddition, incomingContentAdd); + final Set senders = incomingContentAdd.getSenders(); + Log.d(Config.LOGTAG, "senders of incoming content-add: " + senders); + if (senders.equals(Content.Senders.receiveOnly(isInitiator()))) { + Log.d( + Config.LOGTAG, + "content addition is receive only. we want to upgrade to 'both'"); + final RtpContentMap modifiedSenders = + incomingContentAdd.modifiedSenders(Content.Senders.BOTH); + final JinglePacket proposedContentModification = + modifiedSenders + .toStub() + .toJinglePacket(JinglePacket.Action.CONTENT_MODIFY, id.sessionId); + proposedContentModification.setTo(id.with); + xmppConnectionService.sendIqPacket( + id.account, + proposedContentModification, + (account, response) -> { + if (response.getType() == IqPacket.TYPE.RESULT) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": remote has accepted our upgrade to senders=both"); + acceptContentAdd( + ContentAddition.summary(modifiedSenders), modifiedSenders); + } else { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": remote has rejected our upgrade to senders=both"); + acceptContentAdd(contentAddition, incomingContentAdd); + } + }); + } else { + acceptContentAdd(contentAddition, incomingContentAdd); + } } else { - throw new IllegalStateException("Accepted content add does not match pending content-add"); + throw new IllegalStateException( + "Accepted content add does not match pending content-add"); } } - private void acceptContentAdd(@NonNull final Set contentAddition, final RtpContentMap incomingContentAdd) { + private void acceptContentAdd( + @NonNull final Set contentAddition, + final RtpContentMap incomingContentAdd) { final IceUdpTransportInfo.Setup setup = getPeerDtlsSetup(); - final RtpContentMap modifiedContentMap = getRemoteContentMap().addContent(incomingContentAdd, setup); + final RtpContentMap modifiedContentMap = + getRemoteContentMap().addContent(incomingContentAdd, setup); this.setRemoteContentMap(modifiedContentMap); final SessionDescription offer; try { - offer = SessionDescription.of(modifiedContentMap, !isInitiator()); + offer = SessionDescription.of(modifiedContentMap, isResponder()); } catch (final IllegalArgumentException | NullPointerException e) { - Log.d(Config.LOGTAG, id.getAccount().getJid().asBareJid() + ": unable convert offer from content-add to SDP", e); + Log.d( + Config.LOGTAG, + id.getAccount().getJid().asBareJid() + + ": unable convert offer from content-add to SDP", + e); webRTCWrapper.close(); sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage()); return; @@ -729,13 +825,33 @@ public class JingleRtpConnection extends AbstractJingleConnection final RtpContentMap contentAcceptMap = rtpContentMap.toContentModification( Collections2.transform(contentAddition, ca -> ca.name)); + Log.d( Config.LOGTAG, id.getAccount().getJid().asBareJid() + ": sending content-accept " + ContentAddition.summary(contentAcceptMap)); + + addIceCandidatesFromBlackLog(); + modifyLocalContentMap(rtpContentMap); - sendContentAccept(contentAcceptMap); + final ListenableFuture future = + prepareOutgoingContentMap(contentAcceptMap); + Futures.addCallback( + future, + new FutureCallback<>() { + @Override + public void onSuccess(final RtpContentMap rtpContentMap) { + sendContentAccept(rtpContentMap); + webRTCWrapper.setIsReadyToReceiveIceCandidates(true); + } + + @Override + public void onFailure(@NonNull final Throwable throwable) { + failureToPerformAction(JinglePacket.Action.CONTENT_ACCEPT, throwable); + } + }, + MoreExecutors.directExecutor()); } catch (final Exception e) { Log.d(Config.LOGTAG, "unable to accept content add", Throwables.getRootCause(e)); webRTCWrapper.close(); @@ -744,7 +860,8 @@ public class JingleRtpConnection extends AbstractJingleConnection } private void sendContentAccept(final RtpContentMap contentAcceptMap) { - final JinglePacket jinglePacket = contentAcceptMap.toJinglePacket(JinglePacket.Action.CONTENT_ACCEPT, id.sessionId); + final JinglePacket jinglePacket = + contentAcceptMap.toJinglePacket(JinglePacket.Action.CONTENT_ACCEPT, id.sessionId); send(jinglePacket); } @@ -790,6 +907,9 @@ public class JingleRtpConnection extends AbstractJingleConnection // ICE-restart // and if that's the case we are seeing an answer. // This might be more spec compliant but also more error prone potentially + final boolean isSignalStateStable = + this.webRTCWrapper.getSignalingState() == PeerConnection.SignalingState.STABLE; + // TODO a stable signal state can be another indicator that we have an offer to restart ICE final boolean isOffer = rtpContentMap.emptyCandidates(); final RtpContentMap restartContentMap; try { @@ -852,7 +972,8 @@ public class JingleRtpConnection extends AbstractJingleConnection final RtpContentMap restartContentMap, final boolean isOffer) throws ExecutionException, InterruptedException { - final SessionDescription sessionDescription = SessionDescription.of(restartContentMap, !isInitiator()); + final SessionDescription sessionDescription = + SessionDescription.of(restartContentMap, isResponder()); final org.webrtc.SessionDescription.Type type = isOffer ? org.webrtc.SessionDescription.Type.OFFER @@ -869,7 +990,6 @@ public class JingleRtpConnection extends AbstractJingleConnection webRTCWrapper.setRemoteDescription(sdp).get(); setRemoteContentMap(restartContentMap); if (isOffer) { - webRTCWrapper.setIsReadyToReceiveIceCandidates(false); final SessionDescription localSessionDescription = setLocalSessionDescription(); setLocalContentMap(RtpContentMap.of(localSessionDescription, isInitiator())); // We need to respond OK before sending any candidates @@ -882,14 +1002,14 @@ public class JingleRtpConnection extends AbstractJingleConnection } private void processCandidates( - final Set> contents) { - for (final Map.Entry content : contents) { + final Set>> contents) { + for (final Map.Entry> content : contents) { processCandidate(content); } } private void processCandidate( - final Map.Entry content) { + final Map.Entry> content) { final RtpContentMap rtpContentMap = getRemoteContentMap(); final List indices = toIdentificationTags(rtpContentMap); final String sdpMid = content.getKey(); // aka content name @@ -947,12 +1067,22 @@ public class JingleRtpConnection extends AbstractJingleConnection private ListenableFuture receiveRtpContentMap( final JinglePacket jinglePacket, final boolean expectVerification) { - final RtpContentMap receivedContentMap; try { - receivedContentMap = RtpContentMap.of(jinglePacket); + return receiveRtpContentMap(RtpContentMap.of(jinglePacket), expectVerification); } catch (final Exception e) { return Futures.immediateFailedFuture(e); } + } + + private ListenableFuture receiveRtpContentMap( + final RtpContentMap receivedContentMap, final boolean expectVerification) { + Log.d( + Config.LOGTAG, + "receiveRtpContentMap(" + + receivedContentMap.getClass().getSimpleName() + + ",expectVerification=" + + expectVerification + + ")"); if (receivedContentMap instanceof OmemoVerifiedRtpContentMap) { final ListenableFuture> future = id.account @@ -981,27 +1111,13 @@ public class JingleRtpConnection extends AbstractJingleConnection private void receiveSessionInitiate(final JinglePacket jinglePacket) { if (isInitiator()) { - Log.d( - Config.LOGTAG, - String.format( - "%s: received session-initiate even though we were initiating", - id.account.getJid().asBareJid())); - if (isTerminated()) { - Log.d( - Config.LOGTAG, - String.format( - "%s: got a reason to terminate with out-of-order. but already in state %s", - id.account.getJid().asBareJid(), getState())); - respondWithOutOfOrder(jinglePacket); - } else { - terminateWithOutOfOrder(jinglePacket); - } + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE); return; } final ListenableFuture future = receiveRtpContentMap(jinglePacket, false); Futures.addCallback( future, - new FutureCallback() { + new FutureCallback<>() { @Override public void onSuccess(@Nullable RtpContentMap rtpContentMap) { receiveSessionInitiate(jinglePacket, rtpContentMap); @@ -1077,20 +1193,15 @@ public class JingleRtpConnection extends AbstractJingleConnection } private void receiveSessionAccept(final JinglePacket jinglePacket) { - if (!isInitiator()) { - Log.d( - Config.LOGTAG, - String.format( - "%s: received session-accept even though we were responding", - id.account.getJid().asBareJid())); - terminateWithOutOfOrder(jinglePacket); + if (isResponder()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT); return; } final ListenableFuture future = receiveRtpContentMap(jinglePacket, this.omemoVerification.hasFingerprint()); Futures.addCallback( future, - new FutureCallback() { + new FutureCallback<>() { @Override public void onSuccess(@Nullable RtpContentMap rtpContentMap) { receiveSessionAccept(jinglePacket, rtpContentMap); @@ -1223,8 +1334,9 @@ public class JingleRtpConnection extends AbstractJingleConnection + ": ICE servers got discovered when session was already terminated. nothing to do."); return; } + final boolean includeCandidates = remoteHasSdpOfferAnswer(); try { - setupWebRTC(media, iceServers); + setupWebRTC(media, iceServers, !includeCandidates); } catch (final WebRTCWrapper.InitializationException e) { Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC"); webRTCWrapper.close(); @@ -1238,8 +1350,8 @@ public class JingleRtpConnection extends AbstractJingleConnection this.webRTCWrapper.setRemoteDescription(sdp).get(); addIceCandidatesFromBlackLog(); org.webrtc.SessionDescription webRTCSessionDescription = - this.webRTCWrapper.setLocalDescription().get(); - prepareSessionAccept(webRTCSessionDescription); + this.webRTCWrapper.setLocalDescription(includeCandidates).get(); + prepareSessionAccept(webRTCSessionDescription, includeCandidates); } catch (final Exception e) { failureToAcceptSession(e); } @@ -1255,8 +1367,19 @@ public class JingleRtpConnection extends AbstractJingleConnection sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage()); } + private void failureToPerformAction( + final JinglePacket.Action action, final Throwable throwable) { + if (isTerminated()) { + return; + } + final Throwable rootCause = Throwables.getRootCause(throwable); + Log.d(Config.LOGTAG, "unable to send " + action, rootCause); + webRTCWrapper.close(); + sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage()); + } + private void addIceCandidatesFromBlackLog() { - Map.Entry foo; + Map.Entry> foo; while ((foo = this.pendingIceCandidates.poll()) != null) { processCandidate(foo); Log.d( @@ -1266,20 +1389,36 @@ public class JingleRtpConnection extends AbstractJingleConnection } private void prepareSessionAccept( - final org.webrtc.SessionDescription webRTCSessionDescription) { + final org.webrtc.SessionDescription webRTCSessionDescription, + final boolean includeCandidates) { final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description); final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription, false); + final ImmutableMultimap candidates; + if (includeCandidates) { + candidates = parseCandidates(sessionDescription); + } else { + candidates = ImmutableMultimap.of(); + } this.responderRtpContentMap = respondingRtpContentMap; storePeerDtlsSetup(respondingRtpContentMap.getDtlsSetup().flip()); final ListenableFuture outgoingContentMapFuture = prepareOutgoingContentMap(respondingRtpContentMap); Futures.addCallback( outgoingContentMapFuture, - new FutureCallback() { + new FutureCallback<>() { @Override public void onSuccess(final RtpContentMap outgoingContentMap) { - sendSessionAccept(outgoingContentMap); + if (includeCandidates) { + Log.d( + Config.LOGTAG, + "including " + + candidates.size() + + " candidates in session accept"); + sendSessionAccept(outgoingContentMap.withCandidates(candidates)); + } else { + sendSessionAccept(outgoingContentMap); + } webRTCWrapper.setIsReadyToReceiveIceCandidates(true); } @@ -1339,30 +1478,23 @@ public class JingleRtpConnection extends AbstractJingleConnection + ": delivered message to JingleRtpConnection " + message); switch (message.getName()) { - case "propose": - receivePropose(from, Propose.upgrade(message), serverMessageId, timestamp); - break; - case "proceed": - receiveProceed(from, Proceed.upgrade(message), serverMessageId, timestamp); - break; - case "retract": - receiveRetract(from, serverMessageId, timestamp); - break; - case "reject": - receiveReject(from, serverMessageId, timestamp); - break; - case "accept": - receiveAccept(from, serverMessageId, timestamp); - break; - default: - break; + case "propose" -> receivePropose( + from, Propose.upgrade(message), serverMessageId, timestamp); + case "proceed" -> receiveProceed( + from, Proceed.upgrade(message), serverMessageId, timestamp); + case "retract" -> receiveRetract(from, serverMessageId, timestamp); + case "reject" -> receiveReject(from, serverMessageId, timestamp); + case "accept" -> receiveAccept(from, serverMessageId, timestamp); } } void deliverFailedProceed(final String message) { Log.d( Config.LOGTAG, - id.account.getJid().asBareJid() + ": receive message error for proceed message ("+Strings.nullToEmpty(message)+")"); + id.account.getJid().asBareJid() + + ": receive message error for proceed message (" + + Strings.nullToEmpty(message) + + ")"); if (transition(State.TERMINATED_CONNECTIVITY_ERROR)) { webRTCWrapper.close(); Log.d( @@ -1384,6 +1516,7 @@ public class JingleRtpConnection extends AbstractJingleConnection id.account.getJid().asBareJid() + ": unable to transition to accept because already in state=" + this.state); + Log.d(Config.LOGTAG, id.account.getJid() + ": received accept from " + from); } } else { Log.d( @@ -1399,9 +1532,7 @@ public class JingleRtpConnection extends AbstractJingleConnection this.message.setTime(timestamp); this.message.setCarbon(true); // indicate that call was accepted on other device this.writeLogMessageSuccess(0); - this.xmppConnectionService - .getNotificationService() - .cancelIncomingCallNotification(); + this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification(); this.finish(); } @@ -1511,6 +1642,9 @@ public class JingleRtpConnection extends AbstractJingleConnection } this.message.setTime(timestamp); startRinging(); + if (xmppConnectionService.confirmMessages() && id.getContact().showInContactList()) { + sendJingleMessage("ringing"); + } } else { Log.d( Config.LOGTAG, @@ -1536,14 +1670,14 @@ public class JingleRtpConnection extends AbstractJingleConnection private synchronized void ringingTimeout() { Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": timeout reached for ringing"); switch (this.state) { - case PROPOSED: + case PROPOSED -> { message.markUnread(); rejectCallFromProposed(); - break; - case SESSION_INITIALIZED: + } + case SESSION_INITIALIZED -> { message.markUnread(); rejectCallFromSessionInitiate(); - break; + } } xmppConnectionService.getNotificationService().pushMissedCallNow(message); } @@ -1674,8 +1808,9 @@ public class JingleRtpConnection extends AbstractJingleConnection + ": ICE servers got discovered when session was already terminated. nothing to do."); return; } + final boolean includeCandidates = remoteHasSdpOfferAnswer(); try { - setupWebRTC(media, iceServers); + setupWebRTC(media, iceServers, !includeCandidates); } catch (final WebRTCWrapper.InitializationException e) { Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC"); webRTCWrapper.close(); @@ -1684,8 +1819,8 @@ public class JingleRtpConnection extends AbstractJingleConnection } try { org.webrtc.SessionDescription webRTCSessionDescription = - this.webRTCWrapper.setLocalDescription().get(); - prepareSessionInitiate(webRTCSessionDescription, targetState); + this.webRTCWrapper.setLocalDescription(includeCandidates).get(); + prepareSessionInitiate(webRTCSessionDescription, includeCandidates, targetState); } catch (final Exception e) { // TODO sending the error text is worthwhile as well. Especially for FailureToSet // exceptions @@ -1718,19 +1853,37 @@ public class JingleRtpConnection extends AbstractJingleConnection } private void prepareSessionInitiate( - final org.webrtc.SessionDescription webRTCSessionDescription, final State targetState) { + final org.webrtc.SessionDescription webRTCSessionDescription, + final boolean includeCandidates, + final State targetState) { final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description); final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, true); + final ImmutableMultimap candidates; + if (includeCandidates) { + candidates = parseCandidates(sessionDescription); + } else { + candidates = ImmutableMultimap.of(); + } this.initiatorRtpContentMap = rtpContentMap; final ListenableFuture outgoingContentMapFuture = encryptSessionInitiate(rtpContentMap); Futures.addCallback( outgoingContentMapFuture, - new FutureCallback() { + new FutureCallback<>() { @Override public void onSuccess(final RtpContentMap outgoingContentMap) { - sendSessionInitiate(outgoingContentMap, targetState); + if (includeCandidates) { + Log.d( + Config.LOGTAG, + "including " + + candidates.size() + + " candidates in session initiate"); + sendSessionInitiate( + outgoingContentMap.withCandidates(candidates), targetState); + } else { + sendSessionInitiate(outgoingContentMap, targetState); + } webRTCWrapper.setIsReadyToReceiveIceCandidates(true); } @@ -1796,25 +1949,16 @@ public class JingleRtpConnection extends AbstractJingleConnection } } - private void sendSessionTerminate(final Reason reason) { + protected void sendSessionTerminate(final Reason reason) { sendSessionTerminate(reason, null); } - private void sendSessionTerminate(final Reason reason, final String text) { - final State previous = this.state; - final State target = reasonToState(reason); - transitionOrThrow(target); - if (previous != State.NULL) { - writeLogMessage(target); - } - final JinglePacket jinglePacket = - new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId); - jinglePacket.setReason(reason, text); - Log.d(Config.LOGTAG, jinglePacket.toString()); - send(jinglePacket); - finish(); + + protected void sendSessionTerminate(final Reason reason, final String text) { + sendSessionTerminate(reason,text, this::writeLogMessage); } + private void sendTransportInfo( final String contentName, IceUdpTransportInfo.Candidate candidate) { final RtpContentMap transportInfo; @@ -1835,161 +1979,64 @@ public class JingleRtpConnection extends AbstractJingleConnection send(jinglePacket); } - private void send(final JinglePacket jinglePacket) { - jinglePacket.setTo(id.with); - xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse); - } - - private synchronized void handleIqResponse(final Account account, final IqPacket response) { - if (response.getType() == IqPacket.TYPE.ERROR) { - handleIqErrorResponse(response); - return; - } - if (response.getType() == IqPacket.TYPE.TIMEOUT) { - handleIqTimeoutResponse(response); - } - } - - private void handleIqErrorResponse(final IqPacket response) { - Preconditions.checkArgument(response.getType() == IqPacket.TYPE.ERROR); - final String errorCondition = response.getErrorCondition(); - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": received IQ-error from " - + response.getFrom() - + " in RTP session. " - + errorCondition); - if (isTerminated()) { - Log.i( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": ignoring error because session was already terminated"); - return; - } - this.webRTCWrapper.close(); - final State target; - if (Arrays.asList( - "service-unavailable", - "recipient-unavailable", - "remote-server-not-found", - "remote-server-timeout") - .contains(errorCondition)) { - target = State.TERMINATED_CONNECTIVITY_ERROR; - } else { - target = State.TERMINATED_APPLICATION_FAILURE; - } - transitionOrThrow(target); - this.finish(); - } - - private void handleIqTimeoutResponse(final IqPacket response) { - Preconditions.checkArgument(response.getType() == IqPacket.TYPE.TIMEOUT); - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": received IQ timeout in RTP session with " - + id.with - + ". terminating with connectivity error"); - if (isTerminated()) { - Log.i( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": ignoring error because session was already terminated"); - return; - } - this.webRTCWrapper.close(); - transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR); - this.finish(); - } - - private void terminateWithOutOfOrder(final JinglePacket jinglePacket) { - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() + ": terminating session with out-of-order"); - this.webRTCWrapper.close(); - transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE); - respondWithOutOfOrder(jinglePacket); - this.finish(); - } - - private void respondWithTieBreak(final JinglePacket jinglePacket) { - respondWithJingleError(jinglePacket, "tie-break", "conflict", "cancel"); - } - - private void respondWithOutOfOrder(final JinglePacket jinglePacket) { - respondWithJingleError(jinglePacket, "out-of-order", "unexpected-request", "wait"); - } - - void respondWithJingleError( - final IqPacket original, - String jingleCondition, - String condition, - String conditionType) { - jingleConnectionManager.respondWithJingleError( - id.account, original, jingleCondition, condition, conditionType); - } - - private void respondOk(final JinglePacket jinglePacket) { - xmppConnectionService.sendIqPacket( - id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null); - } - public RtpEndUserState getEndUserState() { switch (this.state) { - case NULL: - case PROPOSED: - case SESSION_INITIALIZED: + case NULL, PROPOSED, SESSION_INITIALIZED -> { if (isInitiator()) { return RtpEndUserState.RINGING; } else { return RtpEndUserState.INCOMING_CALL; } - case PROCEED: + } + case PROCEED -> { if (isInitiator()) { return RtpEndUserState.RINGING; } else { return RtpEndUserState.ACCEPTING_CALL; } - case SESSION_INITIALIZED_PRE_APPROVED: + } + case SESSION_INITIALIZED_PRE_APPROVED -> { if (isInitiator()) { return RtpEndUserState.RINGING; } else { return RtpEndUserState.CONNECTING; } - case SESSION_ACCEPTED: + } + case SESSION_ACCEPTED -> { final ContentAddition ca = getPendingContentAddition(); if (ca != null && ca.direction == ContentAddition.Direction.INCOMING) { return RtpEndUserState.INCOMING_CONTENT_ADD; } return getPeerConnectionStateAsEndUserState(); - case REJECTED: - case REJECTED_RACED: - case TERMINATED_DECLINED_OR_BUSY: + } + case REJECTED, REJECTED_RACED, TERMINATED_DECLINED_OR_BUSY -> { if (isInitiator()) { return RtpEndUserState.DECLINED_OR_BUSY; } else { return RtpEndUserState.ENDED; } - case TERMINATED_SUCCESS: - case ACCEPTED: - case RETRACTED: - case TERMINATED_CANCEL_OR_TIMEOUT: + } + case TERMINATED_SUCCESS, ACCEPTED, RETRACTED, TERMINATED_CANCEL_OR_TIMEOUT -> { return RtpEndUserState.ENDED; - case RETRACTED_RACED: + } + case RETRACTED_RACED -> { if (isInitiator()) { return RtpEndUserState.ENDED; } else { return RtpEndUserState.RETRACTED; } - case TERMINATED_CONNECTIVITY_ERROR: + } + case TERMINATED_CONNECTIVITY_ERROR -> { return zeroDuration() ? RtpEndUserState.CONNECTIVITY_ERROR : RtpEndUserState.CONNECTIVITY_LOST_ERROR; - case TERMINATED_APPLICATION_FAILURE: + } + case TERMINATED_APPLICATION_FAILURE -> { return RtpEndUserState.APPLICATION_ERROR; - case TERMINATED_SECURITY_ERROR: + } + case TERMINATED_SECURITY_ERROR -> { return RtpEndUserState.SECURITY_ERROR; + } } throw new IllegalStateException( String.format("%s has no equivalent EndUserState", this.state)); @@ -2004,19 +2051,14 @@ public class JingleRtpConnection extends AbstractJingleConnection // be in SESSION_ACCEPTED even though the peerConnection has been torn down return RtpEndUserState.ENDING_CALL; } - switch (state) { - case CONNECTED: - return RtpEndUserState.CONNECTED; - case NEW: - case CONNECTING: - return RtpEndUserState.CONNECTING; - case CLOSED: - return RtpEndUserState.ENDING_CALL; - default: - return zeroDuration() - ? RtpEndUserState.CONNECTIVITY_ERROR - : RtpEndUserState.RECONNECTING; - } + return switch (state) { + case CONNECTED -> RtpEndUserState.CONNECTED; + case NEW, CONNECTING -> RtpEndUserState.CONNECTING; + case CLOSED -> RtpEndUserState.ENDING_CALL; + default -> zeroDuration() + ? RtpEndUserState.CONNECTIVITY_ERROR + : RtpEndUserState.RECONNECTING; + }; } public ContentAddition getPendingContentAddition() { @@ -2051,9 +2093,10 @@ public class JingleRtpConnection extends AbstractJingleConnection } else if (initiatorContentMap != null) { return initiatorContentMap.getMedia(); } else if (isTerminated()) { - return Collections.emptySet(); //we might fail before we ever got a chance to set media + return Collections.emptySet(); // we might fail before we ever got a chance to set media } else { - return Preconditions.checkNotNull(this.proposedMedia, "RTP connection has not been initialized properly"); + return Preconditions.checkNotNull( + this.proposedMedia, "RTP connection has not been initialized properly"); } } @@ -2073,35 +2116,29 @@ public class JingleRtpConnection extends AbstractJingleConnection throw new IllegalStateException(String.format("%s has already been proposed", media)); } // TODO add state protection - can only add while ACCEPTED or so - Log.d(Config.LOGTAG,"adding media: "+media); + Log.d(Config.LOGTAG, "adding media: " + media); return webRTCWrapper.addTrack(media); } public synchronized void acceptCall() { switch (this.state) { - case PROPOSED: + case PROPOSED -> { cancelRingingTimeout(); acceptCallFromProposed(); - break; - case SESSION_INITIALIZED: + } + case SESSION_INITIALIZED -> { cancelRingingTimeout(); acceptCallFromSessionInitialized(); - break; - case ACCEPTED: - Log.w( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": the call has already been accepted with another client. UI was just lagging behind"); - break; - case PROCEED: - case SESSION_ACCEPTED: - Log.w( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": the call has already been accepted. user probably double tapped the UI"); - break; - default: - throw new IllegalStateException("Can not accept call from " + this.state); + } + case ACCEPTED -> Log.w( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": the call has already been accepted with another client. UI was just lagging behind"); + case PROCEED, SESSION_ACCEPTED -> Log.w( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": the call has already been accepted. user probably double tapped the UI"); + default -> throw new IllegalStateException("Can not accept call from " + this.state); } } @@ -2123,14 +2160,9 @@ public class JingleRtpConnection extends AbstractJingleConnection return; } switch (this.state) { - case PROPOSED: - rejectCallFromProposed(); - break; - case SESSION_INITIALIZED: - rejectCallFromSessionInitiate(); - break; - default: - throw new IllegalStateException("Can not reject call from " + this.state); + case PROPOSED -> rejectCallFromProposed(); + case SESSION_INITIALIZED -> rejectCallFromSessionInitiate(); + default -> throw new IllegalStateException("Can not reject call from " + this.state); } } @@ -2142,7 +2174,7 @@ public class JingleRtpConnection extends AbstractJingleConnection + ": received endCall() when session has already been terminated. nothing to do"); return; } - if (isInState(State.PROPOSED) && !isInitiator()) { + if (isInState(State.PROPOSED) && isResponder()) { rejectCallFromProposed(); return; } @@ -2195,10 +2227,15 @@ public class JingleRtpConnection extends AbstractJingleConnection finish(); } - private void setupWebRTC(final Set media, final List iceServers) throws WebRTCWrapper.InitializationException { + private void setupWebRTC( + final Set media, + final List iceServers, + final boolean trickle) + throws WebRTCWrapper.InitializationException { this.jingleConnectionManager.ensureConnectionIsRegistered(this); - this.webRTCWrapper.setup(this.xmppConnectionService, AppRTCAudioManager.SpeakerPhonePreference.of(media)); - this.webRTCWrapper.initializePeerConnection(media, iceServers); + this.webRTCWrapper.setup( + this.xmppConnectionService, AppRTCAudioManager.SpeakerPhonePreference.of(media)); + this.webRTCWrapper.initializePeerConnection(media, iceServers, trickle); } private void acceptCallFromProposed() { @@ -2266,22 +2303,10 @@ public class JingleRtpConnection extends AbstractJingleConnection sendSessionAccept(); } - private synchronized boolean isInState(State... state) { - return Arrays.asList(state).contains(this.state); - } - private boolean transition(final State target) { - return transition(target, null); - } - - private synchronized boolean transition(final State target, final Runnable runnable) { - final Collection validTransitions = VALID_TRANSITIONS.get(this.state); - if (validTransitions != null && validTransitions.contains(target)) { - this.state = target; - if (runnable != null) { - runnable.run(); - } - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target); + @Override + protected synchronized boolean transition(final State target, final Runnable runnable) { + if (super.transition(target, runnable)) { updateEndUserState(); updateOngoingCallNotification(); return true; @@ -2290,13 +2315,6 @@ public class JingleRtpConnection extends AbstractJingleConnection } } - void transitionOrThrow(final State target) { - if (!transition(target)) { - throw new IllegalStateException( - String.format("Unable to transition from %s to %s", this.state, target)); - } - } - @Override public void onIceCandidate(final IceCandidate iceCandidate) { final RtpContentMap rtpContentMap = @@ -2357,7 +2375,7 @@ public class JingleRtpConnection extends AbstractJingleConnection private void restartIce() { this.stateHistory.clear(); - this.webRTCWrapper.restartIce(); + this.webRTCWrapper.restartIceAsync(); } @Override @@ -2371,8 +2389,15 @@ public class JingleRtpConnection extends AbstractJingleConnection sessionDescription = setLocalSessionDescription(); } catch (final Exception e) { final Throwable cause = Throwables.getRootCause(e); - Log.d(Config.LOGTAG, "failed to renegotiate", cause); webRTCWrapper.close(); + if (isTerminated()) { + Log.d( + Config.LOGTAG, + "failed to renegotiate. session was already terminated", + cause); + return; + } + Log.d(Config.LOGTAG, "failed to renegotiate. sending session-terminate", cause); sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage()); return; } @@ -2450,6 +2475,27 @@ public class JingleRtpConnection extends AbstractJingleConnection private void sendContentAdd(final RtpContentMap rtpContentMap, final Collection added) { final RtpContentMap contentAdd = rtpContentMap.toContentModification(added); this.outgoingContentAdd = contentAdd; + final ListenableFuture outgoingContentMapFuture = + prepareOutgoingContentMap(contentAdd); + Futures.addCallback( + outgoingContentMapFuture, + new FutureCallback<>() { + @Override + public void onSuccess(final RtpContentMap outgoingContentMap) { + sendContentAdd(outgoingContentMap); + webRTCWrapper.setIsReadyToReceiveIceCandidates(true); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + failureToPerformAction(JinglePacket.Action.CONTENT_ADD, throwable); + } + }, + MoreExecutors.directExecutor()); + } + + private void sendContentAdd(final RtpContentMap contentAdd) { + final JinglePacket jinglePacket = contentAdd.toJinglePacket(JinglePacket.Action.CONTENT_ADD, id.sessionId); jinglePacket.setTo(id.with); @@ -2506,7 +2552,7 @@ public class JingleRtpConnection extends AbstractJingleConnection private SessionDescription setLocalSessionDescription() throws ExecutionException, InterruptedException { final org.webrtc.SessionDescription sessionDescription = - this.webRTCWrapper.setLocalDescription().get(); + this.webRTCWrapper.setLocalDescription(false).get(); return SessionDescription.parse(sessionDescription.description); } @@ -2604,91 +2650,7 @@ public class JingleRtpConnection extends AbstractJingleConnection id.account, request, (account, response) -> { - ImmutableList.Builder listBuilder = - new ImmutableList.Builder<>(); - if (response.getType() == IqPacket.TYPE.RESULT) { - final Element services = - response.findChild( - "services", Namespace.EXTERNAL_SERVICE_DISCOVERY); - final List children = - services == null - ? Collections.emptyList() - : services.getChildren(); - for (final Element child : children) { - if ("service".equals(child.getName())) { - final String type = child.getAttribute("type"); - final String host = child.getAttribute("host"); - final String sport = child.getAttribute("port"); - final Integer port = - sport == null ? null : Ints.tryParse(sport); - final String transport = child.getAttribute("transport"); - final String username = child.getAttribute("username"); - final String password = child.getAttribute("password"); - if (Strings.isNullOrEmpty(host) || port == null) { - continue; - } - if (port < 0 || port > 65535) { - continue; - } - if (Arrays.asList("stun", "stuns", "turn", "turns") - .contains(type) - && Arrays.asList("udp", "tcp").contains(transport)) { - if (Arrays.asList("stuns", "turns").contains(type) - && "udp".equals(transport)) { - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": skipping invalid combination of udp/tls in external services"); - continue; - } - // TODO Starting on milestone 110, Chromium will perform - // stricter validation of TURN and STUN URLs passed to the - // constructor of an RTCPeerConnection. More specifically, - // STUN URLs will not support a query section, and TURN URLs - // will support only a transport parameter in their query - // section. - final PeerConnection.IceServer.Builder iceServerBuilder = - PeerConnection.IceServer.builder( - String.format( - "%s:%s:%s?transport=%s", - type, - IP.wrapIPv6(host), - port, - transport)); - iceServerBuilder.setTlsCertPolicy( - PeerConnection.TlsCertPolicy - .TLS_CERT_POLICY_INSECURE_NO_CHECK); - if (username != null && password != null) { - iceServerBuilder.setUsername(username); - iceServerBuilder.setPassword(password); - } else if (Arrays.asList("turn", "turns").contains(type)) { - // The WebRTC spec requires throwing an - // InvalidAccessError when username (from libwebrtc - // source coder) - // https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": skipping " - + type - + "/" - + transport - + " without username and password"); - continue; - } - final PeerConnection.IceServer iceServer = - iceServerBuilder.createIceServer(); - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": discovered ICE Server: " - + iceServer); - listBuilder.add(iceServer); - } - } - } - } - final List iceServers = listBuilder.build(); + final var iceServers = IceServers.parse(response); if (iceServers.size() == 0) { Log.w( Config.LOGTAG, @@ -2705,13 +2667,19 @@ public class JingleRtpConnection extends AbstractJingleConnection onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList()); } } + + @Override + protected void terminateTransport() { + this.webRTCWrapper.close(); + } - private void finish() { + @Override + protected void finish() { if (isTerminated()) { this.cancelRingingTimeout(); this.webRTCWrapper.verifyClosed(); this.jingleConnectionManager.setTerminalSessionState(id, getEndUserState(), getMedia()); - this.jingleConnectionManager.finishConnectionOrThrow(this); + super.finish(); } else { throw new IllegalStateException( String.format("Unable to call finish from %s", this.state)); @@ -2749,14 +2717,6 @@ public class JingleRtpConnection extends AbstractJingleConnection } } - public State getState() { - return this.state; - } - - boolean isTerminated() { - return TERMINATED.contains(this.state); - } - public Optional getLocalVideoTrack() { return webRTCWrapper.getLocalVideoTrack(); } @@ -2788,14 +2748,11 @@ public class JingleRtpConnection extends AbstractJingleConnection } private boolean remoteHasVideoFeature() { - final Contact contact = id.getContact(); - final Presence presence = - contact.getPresences().get(Strings.nullToEmpty(id.with.getResource())); - final ServiceDiscoveryResult serviceDiscoveryResult = - presence == null ? null : presence.getServiceDiscoveryResult(); - final List features = - serviceDiscoveryResult == null ? null : serviceDiscoveryResult.getFeatures(); - return features != null && features.contains(Namespace.JINGLE_FEATURE_VIDEO); + return remoteHasFeature(Namespace.JINGLE_FEATURE_VIDEO); + } + + private boolean remoteHasSdpOfferAnswer() { + return remoteHasFeature(Namespace.SDP_OFFER_ANSWER); } private interface OnIceServersDiscovered { diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java deleted file mode 100644 index a57f4927f..000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java +++ /dev/null @@ -1,305 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle; - -import android.os.PowerManager; -import android.util.Log; - -import com.google.common.io.ByteStreams; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.SocketAddress; -import java.nio.ByteBuffer; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -import eu.siacs.conversations.Config; -import eu.siacs.conversations.entities.Account; -import eu.siacs.conversations.entities.DownloadableFile; -import eu.siacs.conversations.persistance.FileBackend; -import eu.siacs.conversations.services.AbstractConnectionManager; -import eu.siacs.conversations.utils.CryptoHelper; -import eu.siacs.conversations.utils.SocksSocketFactory; -import eu.siacs.conversations.utils.WakeLockHelper; -import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription; - -public class JingleSocks5Transport extends JingleTransport { - - private static final int SOCKET_TIMEOUT_DIRECT = 3000; - private static final int SOCKET_TIMEOUT_PROXY = 5000; - - private final JingleCandidate candidate; - private final JingleFileTransferConnection connection; - private final String destination; - private final Account account; - private OutputStream outputStream; - private InputStream inputStream; - private boolean isEstablished = false; - private boolean activated = false; - private ServerSocket serverSocket; - private Socket socket; - - JingleSocks5Transport(JingleFileTransferConnection jingleConnection, JingleCandidate candidate) { - final MessageDigest messageDigest; - try { - messageDigest = MessageDigest.getInstance("SHA-1"); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - this.candidate = candidate; - this.connection = jingleConnection; - this.account = jingleConnection.getId().account; - final StringBuilder destBuilder = new StringBuilder(); - if (this.connection.getFtVersion() == FileTransferDescription.Version.FT_3) { - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": using session Id instead of transport Id for proxy destination"); - destBuilder.append(this.connection.getId().sessionId); - } else { - destBuilder.append(this.connection.getTransportId()); - } - if (candidate.isOurs()) { - destBuilder.append(this.account.getJid()); - destBuilder.append(this.connection.getId().with); - } else { - destBuilder.append(this.connection.getId().with); - destBuilder.append(this.account.getJid()); - } - messageDigest.reset(); - this.destination = CryptoHelper.bytesToHex(messageDigest.digest(destBuilder.toString().getBytes())); - if (candidate.isOurs() && candidate.getType() == JingleCandidate.TYPE_DIRECT) { - createServerSocket(); - } - } - - private void createServerSocket() { - try { - serverSocket = new ServerSocket(); - serverSocket.bind(new InetSocketAddress(InetAddress.getByName(candidate.getHost()), candidate.getPort())); - new Thread(() -> { - try { - final Socket socket = serverSocket.accept(); - new Thread(() -> { - try { - acceptIncomingSocketConnection(socket); - } catch (IOException e) { - Log.d(Config.LOGTAG, "unable to read from socket", e); - - } - }).start(); - } catch (IOException e) { - if (!serverSocket.isClosed()) { - Log.d(Config.LOGTAG, "unable to accept socket", e); - } - } - }).start(); - } catch (IOException e) { - Log.d(Config.LOGTAG, "unable to bind server socket ", e); - } - } - - private void acceptIncomingSocketConnection(final Socket socket) throws IOException { - Log.d(Config.LOGTAG, "accepted connection from " + socket.getInetAddress().getHostAddress()); - socket.setSoTimeout(SOCKET_TIMEOUT_DIRECT); - final byte[] authBegin = new byte[2]; - final InputStream inputStream = socket.getInputStream(); - final OutputStream outputStream = socket.getOutputStream(); - ByteStreams.readFully(inputStream, authBegin); - if (authBegin[0] != 0x5) { - socket.close(); - } - final short methodCount = authBegin[1]; - final byte[] methods = new byte[methodCount]; - ByteStreams.readFully(inputStream, methods); - if (SocksSocketFactory.contains((byte) 0x00, methods)) { - outputStream.write(new byte[]{0x05, 0x00}); - } else { - outputStream.write(new byte[]{0x05, (byte) 0xff}); - } - final byte[] connectCommand = new byte[4]; - ByteStreams.readFully(inputStream, connectCommand); - if (connectCommand[0] == 0x05 && connectCommand[1] == 0x01 && connectCommand[3] == 0x03) { - int destinationCount = inputStream.read(); - final byte[] destination = new byte[destinationCount]; - ByteStreams.readFully(inputStream, destination); - final byte[] port = new byte[2]; - ByteStreams.readFully(inputStream, port); - final String receivedDestination = new String(destination); - final ByteBuffer response = ByteBuffer.allocate(7 + destination.length); - final byte[] responseHeader; - final boolean success; - if (receivedDestination.equals(this.destination) && this.socket == null) { - responseHeader = new byte[]{0x05, 0x00, 0x00, 0x03}; - success = true; - } else { - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": destination mismatch. received " + receivedDestination + " (expected " + this.destination + ")"); - responseHeader = new byte[]{0x05, 0x04, 0x00, 0x03}; - success = false; - } - response.put(responseHeader); - response.put((byte) destination.length); - response.put(destination); - response.put(port); - outputStream.write(response.array()); - outputStream.flush(); - if (success) { - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": successfully processed connection to candidate " + candidate.getHost() + ":" + candidate.getPort()); - socket.setSoTimeout(0); - this.socket = socket; - this.inputStream = inputStream; - this.outputStream = outputStream; - this.isEstablished = true; - FileBackend.close(serverSocket); - } else { - FileBackend.close(socket); - } - } else { - socket.close(); - } - } - - public void connect(final OnTransportConnected callback) { - new Thread(() -> { - final int timeout = candidate.getType() == JingleCandidate.TYPE_DIRECT ? SOCKET_TIMEOUT_DIRECT : SOCKET_TIMEOUT_PROXY; - try { - final boolean useTor = this.account.isOnion() || connection.getConnectionManager().getXmppConnectionService().useTorToConnect(); - if (useTor) { - socket = SocksSocketFactory.createSocketOverTor(candidate.getHost(), candidate.getPort()); - } else { - socket = new Socket(); - SocketAddress address = new InetSocketAddress(candidate.getHost(), candidate.getPort()); - socket.connect(address, timeout); - } - inputStream = socket.getInputStream(); - outputStream = socket.getOutputStream(); - socket.setSoTimeout(timeout); - SocksSocketFactory.createSocksConnection(socket, destination, 0); - socket.setSoTimeout(0); - isEstablished = true; - callback.established(); - } catch (final IOException e) { - callback.failed(); - } - }).start(); - - } - - public void send(final DownloadableFile file, final OnFileTransmissionStatusChanged callback) { - new Thread(() -> { - InputStream fileInputStream = null; - final PowerManager.WakeLock wakeLock = connection.getConnectionManager().createWakeLock("jingle_send_" + connection.getId().sessionId); - long transmitted = 0; - try { - wakeLock.acquire(); - MessageDigest digest = MessageDigest.getInstance("SHA-1"); - digest.reset(); - fileInputStream = connection.getFileInputStream(); - if (fileInputStream == null) { - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": could not create input stream"); - callback.onFileTransferAborted(); - return; - } - final InputStream innerInputStream = AbstractConnectionManager.upgrade(file, fileInputStream); - long size = file.getExpectedSize(); - int count; - byte[] buffer = new byte[8192]; - while ((count = innerInputStream.read(buffer)) > 0) { - outputStream.write(buffer, 0, count); - digest.update(buffer, 0, count); - transmitted += count; - connection.updateProgress((int) ((((double) transmitted) / size) * 100)); - } - outputStream.flush(); - file.setSha1Sum(digest.digest()); - if (callback != null) { - callback.onFileTransmitted(file); - } - } catch (Exception e) { - final Account account = this.account; - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": failed sending file after " + transmitted + "/" + file.getExpectedSize() + " (" + socket.getInetAddress() + ":" + socket.getPort() + ")", e); - callback.onFileTransferAborted(); - } finally { - FileBackend.close(fileInputStream); - WakeLockHelper.release(wakeLock); - } - }).start(); - - } - - public void receive(final DownloadableFile file, final OnFileTransmissionStatusChanged callback) { - new Thread(() -> { - OutputStream fileOutputStream = null; - final PowerManager.WakeLock wakeLock = connection.getConnectionManager().createWakeLock("jingle_receive_" + connection.getId().sessionId); - try { - wakeLock.acquire(); - MessageDigest digest = MessageDigest.getInstance("SHA-1"); - digest.reset(); - //inputStream.skip(45); - socket.setSoTimeout(30000); - fileOutputStream = connection.getFileOutputStream(); - if (fileOutputStream == null) { - callback.onFileTransferAborted(); - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": could not create output stream"); - return; - } - double size = file.getExpectedSize(); - long remainingSize = file.getExpectedSize(); - byte[] buffer = new byte[8192]; - int count; - while (remainingSize > 0) { - count = inputStream.read(buffer); - if (count == -1) { - callback.onFileTransferAborted(); - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": file ended prematurely with " + remainingSize + " bytes remaining"); - return; - } else { - fileOutputStream.write(buffer, 0, count); - digest.update(buffer, 0, count); - remainingSize -= count; - } - connection.updateProgress((int) (((size - remainingSize) / size) * 100)); - } - fileOutputStream.flush(); - fileOutputStream.close(); - file.setSha1Sum(digest.digest()); - callback.onFileTransmitted(file); - } catch (Exception e) { - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": " + e.getMessage()); - callback.onFileTransferAborted(); - } finally { - WakeLockHelper.release(wakeLock); - FileBackend.close(fileOutputStream); - FileBackend.close(inputStream); - } - }).start(); - } - - public boolean isProxy() { - return this.candidate.getType() == JingleCandidate.TYPE_PROXY; - } - - public boolean needsActivation() { - return (this.isProxy() && !this.activated); - } - - public void disconnect() { - FileBackend.close(inputStream); - FileBackend.close(outputStream); - FileBackend.close(socket); - FileBackend.close(serverSocket); - } - - public boolean isEstablished() { - return this.isEstablished; - } - - public JingleCandidate getCandidate() { - return this.candidate; - } - - public void setActivated(boolean activated) { - this.activated = activated; - } -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java deleted file mode 100644 index e832d3f58..000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java +++ /dev/null @@ -1,15 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle; - -import eu.siacs.conversations.entities.DownloadableFile; - -public abstract class JingleTransport { - public abstract void connect(final OnTransportConnected callback); - - public abstract void receive(final DownloadableFile file, - final OnFileTransmissionStatusChanged callback); - - public abstract void send(final DownloadableFile file, - final OnFileTransmissionStatusChanged callback); - - public abstract void disconnect(); -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java index 67e275414..db33666cb 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java @@ -1,6 +1,7 @@ package eu.siacs.conversations.xmpp.jingle; -import com.google.common.collect.ArrayListMultimap; +import com.google.common.base.Joiner; +import com.google.common.collect.Multimap; import java.util.List; @@ -8,9 +9,9 @@ public class MediaBuilder { private String media; private int port; private String protocol; - private List formats; + private String format; private String connectionData; - private ArrayListMultimap attributes; + private Multimap attributes; public MediaBuilder setMedia(String media) { this.media = media; @@ -27,8 +28,13 @@ public class MediaBuilder { return this; } - public MediaBuilder setFormats(List formats) { - this.formats = formats; + public MediaBuilder setFormats(final List formats) { + this.format = Joiner.on(' ').join(formats); + return this; + } + + public MediaBuilder setFormat(final String format) { + this.format = format; return this; } @@ -37,12 +43,13 @@ public class MediaBuilder { return this; } - public MediaBuilder setAttributes(ArrayListMultimap attributes) { + public MediaBuilder setAttributes(Multimap attributes) { this.attributes = attributes; return this; } public SessionDescription.Media createMedia() { - return new SessionDescription.Media(media, port, protocol, formats, connectionData, attributes); + return new SessionDescription.Media( + media, port, protocol, format, connectionData, attributes); } -} \ No newline at end of file +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/OmemoVerifiedRtpContentMap.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/OmemoVerifiedRtpContentMap.java index f5e041014..0d5d32d50 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/OmemoVerifiedRtpContentMap.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/OmemoVerifiedRtpContentMap.java @@ -3,12 +3,14 @@ package eu.siacs.conversations.xmpp.jingle; import java.util.Map; import eu.siacs.conversations.xmpp.jingle.stanzas.Group; +import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; public class OmemoVerifiedRtpContentMap extends RtpContentMap { - public OmemoVerifiedRtpContentMap(Group group, Map contents) { + public OmemoVerifiedRtpContentMap(Group group, Map> contents) { super(group, contents); - for(final DescriptionTransport descriptionTransport : contents.values()) { + for(final DescriptionTransport descriptionTransport : contents.values()) { if (descriptionTransport.transport instanceof OmemoVerifiedIceUdpTransportInfo) { ((OmemoVerifiedIceUdpTransportInfo) descriptionTransport.transport).ensureNoPlaintextFingerprint(); continue; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java deleted file mode 100644 index 76e337177..000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java +++ /dev/null @@ -1,5 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle; - -public interface OnPrimaryCandidateFound { - void onPrimaryCandidateFound(boolean success, JingleCandidate canditate); -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpCapability.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpCapability.java index 8a7581f04..b53f0b9e8 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpCapability.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpCapability.java @@ -1,9 +1,11 @@ package eu.siacs.conversations.xmpp.jingle; import com.google.common.base.Strings; +import com.google.common.collect.Collections2; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; @@ -77,6 +79,18 @@ public class RtpCapability { return result; } + // do all devices that support Rtp Call also support JMI? + public static boolean jmiSupport(final Contact contact) { + return !Collections2.transform( + Collections2.filter( + contact.getPresences().getPresences(), + p -> RtpCapability.check(p) != RtpCapability.Capability.NONE), + p -> { + ServiceDiscoveryResult disco = p.getServiceDiscoveryResult(); + return disco != null && disco.getFeatures().contains(Namespace.JINGLE_MESSAGE); + }).contains(false); + } + public enum Capability { NONE, AUDIO, VIDEO; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java index 994c3a233..94f8ca300 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java @@ -6,21 +6,13 @@ import com.google.common.base.Preconditions; import com.google.common.base.Predicates; import com.google.common.base.Strings; import com.google.common.collect.Collections2; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Maps; import com.google.common.collect.Sets; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import javax.annotation.Nonnull; - import eu.siacs.conversations.xmpp.jingle.stanzas.Content; import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription; import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; @@ -30,19 +22,25 @@ import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; -public class RtpContentMap { +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; - public final Group group; - public final Map contents; +import javax.annotation.Nonnull; - public RtpContentMap(Group group, Map contents) { - this.group = group; - this.contents = contents; +public class RtpContentMap extends AbstractContentMap { + + public RtpContentMap( + Group group, + Map> contents) { + super(group, contents); } public static RtpContentMap of(final JinglePacket jinglePacket) { - final Map contents = - DescriptionTransport.of(jinglePacket.getJingleContents()); + final Map> contents = + of(jinglePacket.getJingleContents()); if (isOmemoVerified(contents)) { return new OmemoVerifiedRtpContentMap(jinglePacket.getGroup(), contents); } else { @@ -50,12 +48,15 @@ public class RtpContentMap { } } - private static boolean isOmemoVerified(Map contents) { - final Collection values = contents.values(); + private static boolean isOmemoVerified( + Map> contents) { + final Collection> values = + contents.values(); if (values.size() == 0) { return false; } - for (final DescriptionTransport descriptionTransport : values) { + for (final DescriptionTransport descriptionTransport : + values) { if (descriptionTransport.transport instanceof OmemoVerifiedIceUdpTransportInfo) { continue; } @@ -66,13 +67,13 @@ public class RtpContentMap { public static RtpContentMap of( final SessionDescription sessionDescription, final boolean isInitiator) { - final ImmutableMap.Builder contentMapBuilder = - new ImmutableMap.Builder<>(); + final ImmutableMap.Builder< + String, DescriptionTransport> + contentMapBuilder = new ImmutableMap.Builder<>(); for (SessionDescription.Media media : sessionDescription.media) { final String id = Iterables.getFirst(media.attributes.get("mid"), null); Preconditions.checkNotNull(id, "media has no mid"); - contentMapBuilder.put( - id, DescriptionTransport.of(sessionDescription, isInitiator, media)); + contentMapBuilder.put(id, of(sessionDescription, isInitiator, media)); } final String groupAttribute = Iterables.getFirst(sessionDescription.attributes.get("group"), null); @@ -93,26 +94,6 @@ public class RtpContentMap { })); } - public Set getSenders() { - return ImmutableSet.copyOf(Collections2.transform(contents.values(),dt -> dt.senders)); - } - - public List getNames() { - return ImmutableList.copyOf(contents.keySet()); - } - - void requireContentDescriptions() { - if (this.contents.size() == 0) { - throw new IllegalStateException("No contents available"); - } - for (Map.Entry entry : this.contents.entrySet()) { - if (entry.getValue().description == null) { - throw new IllegalStateException( - String.format("%s is lacking content description", entry.getKey())); - } - } - } - void requireDTLSFingerprint() { requireDTLSFingerprint(false); } @@ -121,7 +102,8 @@ public class RtpContentMap { if (this.contents.size() == 0) { throw new IllegalStateException("No contents available"); } - for (Map.Entry entry : this.contents.entrySet()) { + for (Map.Entry> entry : + this.contents.entrySet()) { final IceUdpTransportInfo transport = entry.getValue().transport; final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint(); if (fingerprint == null @@ -145,31 +127,10 @@ public class RtpContentMap { } } } - - JinglePacket toJinglePacket(final JinglePacket.Action action, final String sessionId) { - final JinglePacket jinglePacket = new JinglePacket(action, sessionId); - if (this.group != null) { - jinglePacket.addGroup(this.group); - } - for (Map.Entry entry : this.contents.entrySet()) { - final DescriptionTransport descriptionTransport = entry.getValue(); - final Content content = - new Content( - Content.Creator.INITIATOR, - descriptionTransport.senders, - entry.getKey()); - if (descriptionTransport.description != null) { - content.addChild(descriptionTransport.description); - } - content.addChild(descriptionTransport.transport); - jinglePacket.addJingleContent(content); - } - return jinglePacket; - } - RtpContentMap transportInfo( final String contentName, final IceUdpTransportInfo.Candidate candidate) { - final RtpContentMap.DescriptionTransport descriptionTransport = contents.get(contentName); + final DescriptionTransport descriptionTransport = + contents.get(contentName); final IceUdpTransportInfo transportInfo = descriptionTransport == null ? null : descriptionTransport.transport; if (transportInfo == null) { @@ -182,7 +143,7 @@ public class RtpContentMap { null, ImmutableMap.of( contentName, - new DescriptionTransport( + new DescriptionTransport<>( descriptionTransport.senders, null, newTransportInfo))); } @@ -192,10 +153,31 @@ public class RtpContentMap { Maps.transformValues( contents, dt -> - new DescriptionTransport( + new DescriptionTransport<>( dt.senders, null, dt.transport.cloneWrapper()))); } + RtpContentMap withCandidates( + ImmutableMultimap candidates) { + final ImmutableMap.Builder< + String, DescriptionTransport> + contentBuilder = new ImmutableMap.Builder<>(); + for (final Map.Entry> + entry : this.contents.entrySet()) { + final String name = entry.getKey(); + final DescriptionTransport descriptionTransport = + entry.getValue(); + final var transport = descriptionTransport.transport; + contentBuilder.put( + name, + new DescriptionTransport<>( + descriptionTransport.senders, + descriptionTransport.description, + transport.withCandidates(candidates.get(name)))); + } + return new RtpContentMap(group, contentBuilder.build()); + } + public IceUdpTransportInfo.Credentials getDistinctCredentials() { final Set allCredentials = getCredentials(); final IceUdpTransportInfo.Credentials credentials = @@ -210,6 +192,12 @@ public class RtpContentMap { throw new IllegalStateException("Content map does not have distinct credentials"); } + private Set getCombinedIceOptions() { + final Collection> combinedIceOptions = + Collections2.transform(contents.values(), dt -> dt.transport.getIceOptions()); + return ImmutableSet.copyOf(Iterables.concat(combinedIceOptions)); + } + public Set getCredentials() { final Set credentials = ImmutableSet.copyOf( @@ -222,7 +210,7 @@ public class RtpContentMap { } public IceUdpTransportInfo.Credentials getCredentials(final String contentName) { - final DescriptionTransport descriptionTransport = this.contents.get(contentName); + final var descriptionTransport = this.contents.get(contentName); if (descriptionTransport == null) { throw new IllegalArgumentException( String.format( @@ -262,25 +250,32 @@ public class RtpContentMap { public boolean emptyCandidates() { int count = 0; - for (DescriptionTransport descriptionTransport : contents.values()) { + for (final var descriptionTransport : contents.values()) { count += descriptionTransport.transport.getCandidates().size(); } return count == 0; } + public boolean hasFullTransportInfo() { + return Collections2.transform(this.contents.values(), dt -> dt.transport.isStub()) + .contains(false); + } + public RtpContentMap modifiedCredentials( IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) { - final ImmutableMap.Builder contentMapBuilder = - new ImmutableMap.Builder<>(); - for (final Map.Entry content : contents.entrySet()) { - final DescriptionTransport descriptionTransport = content.getValue(); + final ImmutableMap.Builder< + String, DescriptionTransport> + contentMapBuilder = new ImmutableMap.Builder<>(); + for (final Map.Entry> + content : contents.entrySet()) { + final var descriptionTransport = content.getValue(); final RtpDescription rtpDescription = descriptionTransport.description; final IceUdpTransportInfo transportInfo = descriptionTransport.transport; final IceUdpTransportInfo modifiedTransportInfo = transportInfo.modifyCredentials(credentials, setup); contentMapBuilder.put( content.getKey(), - new DescriptionTransport( + new DescriptionTransport<>( descriptionTransport.senders, rtpDescription, modifiedTransportInfo)); } return new RtpContentMap(this.group, contentMapBuilder.build()); @@ -291,17 +286,65 @@ public class RtpContentMap { this.group, Maps.transformValues( contents, - dt -> new DescriptionTransport(senders, dt.description, dt.transport))); + dt -> new DescriptionTransport<>(senders, dt.description, dt.transport))); + } + + public RtpContentMap modifiedSendersChecked( + final boolean isInitiator, final Map modification) { + final ImmutableMap.Builder< + String, DescriptionTransport> + contentMapBuilder = new ImmutableMap.Builder<>(); + for (final Map.Entry> + content : contents.entrySet()) { + final String id = content.getKey(); + final var descriptionTransport = content.getValue(); + final Content.Senders currentSenders = descriptionTransport.senders; + final Content.Senders targetSenders = modification.get(id); + if (targetSenders == null || currentSenders == targetSenders) { + contentMapBuilder.put(id, descriptionTransport); + } else { + checkSenderModification(isInitiator, currentSenders, targetSenders); + contentMapBuilder.put( + id, + new DescriptionTransport<>( + targetSenders, + descriptionTransport.description, + descriptionTransport.transport)); + } + } + return new RtpContentMap(this.group, contentMapBuilder.build()); + } + + private static void checkSenderModification( + final boolean isInitiator, + final Content.Senders current, + final Content.Senders target) { + if (isInitiator) { + // we were both sending and now other party only wants to receive + if (current == Content.Senders.BOTH && target == Content.Senders.INITIATOR) { + return; + } + // only we were sending but now other party wants to send too + if (current == Content.Senders.INITIATOR && target == Content.Senders.BOTH) { + return; + } + } else { + // we were both sending and now other party only wants to receive + if (current == Content.Senders.BOTH && target == Content.Senders.RESPONDER) { + return; + } + // only we were sending but now other party wants to send too + if (current == Content.Senders.RESPONDER && target == Content.Senders.BOTH) { + return; + } + } + throw new IllegalArgumentException( + String.format("Unsupported senders modification %s -> %s", current, target)); } public RtpContentMap toContentModification(final Collection modifications) { return new RtpContentMap( - this.group, - Maps.transformValues( - Maps.filterKeys(contents, Predicates.in(modifications)), - dt -> - new DescriptionTransport( - dt.senders, dt.description, IceUdpTransportInfo.STUB))); + this.group, Maps.filterKeys(contents, Predicates.in(modifications))); } public RtpContentMap toStub() { @@ -310,14 +353,15 @@ public class RtpContentMap { Maps.transformValues( this.contents, dt -> - new DescriptionTransport( + new DescriptionTransport<>( dt.senders, RtpDescription.stub(dt.description.getMedia()), IceUdpTransportInfo.STUB))); } public RtpContentMap activeContents() { - return new RtpContentMap(group, Maps.filterValues(this.contents, dt -> dt.senders != Content.Senders.NONE)); + return new RtpContentMap( + group, Maps.filterValues(this.contents, dt -> dt.senders != Content.Senders.NONE)); } public Diff diff(final RtpContentMap rtpContentMap) { @@ -337,101 +381,97 @@ public class RtpContentMap { } public RtpContentMap addContent( - final RtpContentMap modification, final IceUdpTransportInfo.Setup setup) { - final IceUdpTransportInfo.Credentials credentials = getDistinctCredentials(); - final DTLS dtls = getDistinctDtls(); - final IceUdpTransportInfo iceUdpTransportInfo = - IceUdpTransportInfo.of(credentials, setup, dtls.hash, dtls.fingerprint); - final Map combined = merge(contents, modification.contents); - /*new ImmutableMap.Builder() - .putAll(contents) - .putAll(modification.contents) - .build();*/ - final Map combinedFixedTransport = - Maps.transformValues( - combined, - dt -> - new DescriptionTransport( - dt.senders, dt.description, iceUdpTransportInfo)); - return new RtpContentMap(modification.group, combinedFixedTransport); + final RtpContentMap modification, final IceUdpTransportInfo.Setup setupOverwrite) { + final Map> combined = + merge(contents, modification.contents); + final Map> + combinedFixedTransport = + Maps.transformValues( + combined, + dt -> { + final IceUdpTransportInfo iceUdpTransportInfo; + if (dt.transport.isStub()) { + final IceUdpTransportInfo.Credentials credentials = + getDistinctCredentials(); + final Collection iceOptions = + getCombinedIceOptions(); + final DTLS dtls = getDistinctDtls(); + iceUdpTransportInfo = + IceUdpTransportInfo.of( + credentials, + iceOptions, + setupOverwrite, + dtls.hash, + dtls.fingerprint); + } else { + final IceUdpTransportInfo.Fingerprint fp = + dt.transport.getFingerprint(); + final IceUdpTransportInfo.Setup setup = fp.getSetup(); + iceUdpTransportInfo = + IceUdpTransportInfo.of( + dt.transport.getCredentials(), + dt.transport.getIceOptions(), + setup == IceUdpTransportInfo.Setup.ACTPASS + ? setupOverwrite + : setup, + fp.getHash(), + fp.getContent()); + } + return new DescriptionTransport<>( + dt.senders, dt.description, iceUdpTransportInfo); + }); + return new RtpContentMap(modification.group, ImmutableMap.copyOf(combinedFixedTransport)); } - private static Map merge( - final Map a, final Map b) { - final Map combined = new HashMap<>(); + private static Map> merge( + final Map> a, + final Map> b) { + final Map> combined = + new LinkedHashMap<>(); combined.putAll(a); combined.putAll(b); return ImmutableMap.copyOf(combined); } - public static class DescriptionTransport { - public final Content.Senders senders; - public final RtpDescription description; - public final IceUdpTransportInfo transport; - - public DescriptionTransport( - final Content.Senders senders, - final RtpDescription description, - final IceUdpTransportInfo transport) { - this.senders = senders; - this.description = description; - this.transport = transport; + public static DescriptionTransport of( + final Content content) { + final GenericDescription description = content.getDescription(); + final GenericTransportInfo transportInfo = content.getTransport(); + final Content.Senders senders = content.getSenders(); + final RtpDescription rtpDescription; + final IceUdpTransportInfo iceUdpTransportInfo; + if (description == null) { + rtpDescription = null; + } else if (description instanceof RtpDescription) { + rtpDescription = (RtpDescription) description; + } else { + throw new UnsupportedApplicationException("Content does not contain rtp description"); } - - public static DescriptionTransport of(final Content content) { - final GenericDescription description = content.getDescription(); - final GenericTransportInfo transportInfo = content.getTransport(); - final Content.Senders senders = content.getSenders(); - final RtpDescription rtpDescription; - final IceUdpTransportInfo iceUdpTransportInfo; - if (description == null) { - rtpDescription = null; - } else if (description instanceof RtpDescription) { - rtpDescription = (RtpDescription) description; - } else { - throw new UnsupportedApplicationException( - "Content does not contain rtp description"); - } - if (transportInfo instanceof IceUdpTransportInfo) { - iceUdpTransportInfo = (IceUdpTransportInfo) transportInfo; - } else { - throw new UnsupportedTransportException( - "Content does not contain ICE-UDP transport"); - } - return new DescriptionTransport( - senders, - rtpDescription, - OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo)); - } - - private static DescriptionTransport of( - final SessionDescription sessionDescription, - final boolean isInitiator, - final SessionDescription.Media media) { - final Content.Senders senders = Content.Senders.of(media, isInitiator); - final RtpDescription rtpDescription = RtpDescription.of(sessionDescription, media); - final IceUdpTransportInfo transportInfo = - IceUdpTransportInfo.of(sessionDescription, media); - return new DescriptionTransport(senders, rtpDescription, transportInfo); - } - - public static Map of(final Map contents) { - return ImmutableMap.copyOf( - Maps.transformValues( - contents, content -> content == null ? null : of(content))); + if (transportInfo instanceof IceUdpTransportInfo) { + iceUdpTransportInfo = (IceUdpTransportInfo) transportInfo; + } else { + throw new UnsupportedTransportException("Content does not contain ICE-UDP transport"); } + return new DescriptionTransport<>( + senders, + rtpDescription, + OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo)); } - public static class UnsupportedApplicationException extends IllegalArgumentException { - UnsupportedApplicationException(String message) { - super(message); - } + private static DescriptionTransport of( + final SessionDescription sessionDescription, + final boolean isInitiator, + final SessionDescription.Media media) { + final Content.Senders senders = Content.Senders.of(media, isInitiator); + final RtpDescription rtpDescription = RtpDescription.of(sessionDescription, media); + final IceUdpTransportInfo transportInfo = IceUdpTransportInfo.of(sessionDescription, media); + return new DescriptionTransport<>(senders, rtpDescription, transportInfo); } - public static class UnsupportedTransportException extends IllegalArgumentException { - UnsupportedTransportException(String message) { - super(message); - } + private static Map> of( + final Map contents) { + return ImmutableMap.copyOf( + Maps.transformValues(contents, content -> content == null ? null : of(content))); } public static final class Diff { diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java index f0f98260b..025a7acc9 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java @@ -10,24 +10,34 @@ import com.google.common.base.Joiner; import com.google.common.base.Strings; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ImmutableList; - -import java.util.List; -import java.util.Locale; -import java.util.Map; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.Multimap; import eu.siacs.conversations.Config; import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.Group; import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; public class SessionDescription { public static final String LINE_DIVIDER = "\r\n"; private static final String HARDCODED_MEDIA_PROTOCOL = "UDP/TLS/RTP/SAVPF"; // probably only true for DTLS-SRTP aka when we have a fingerprint + private static final String HARDCODED_APPLICATION_PROTOCOL = "UDP/DTLS/SCTP"; + private static final String FORMAT_WEBRTC_DATA_CHANNEL = "webrtc-datachannel"; private static final int HARDCODED_MEDIA_PORT = 9; - private static final String HARDCODED_ICE_OPTIONS = "trickle"; + private static final Collection HARDCODED_ICE_OPTIONS = + Collections.singleton("trickle"); private static final String HARDCODED_CONNECTION = "IN IP4 0.0.0.0"; public final int version; @@ -49,9 +59,8 @@ public class SessionDescription { this.media = media; } - private static void appendAttributes( - StringBuilder s, ArrayListMultimap attributes) { - for (Map.Entry attribute : attributes.entries()) { + private static void appendAttributes(StringBuilder s, Multimap attributes) { + for (final Map.Entry attribute : attributes.entries()) { final String key = attribute.getKey(); final String value = attribute.getValue(); s.append("a=").append(key); @@ -76,24 +85,20 @@ public class SessionDescription { final char key = pair[0].charAt(0); final String value = pair[1]; switch (key) { - case 'v': - sessionDescriptionBuilder.setVersion(ignorantIntParser(value)); - break; - case 'c': + case 'v' -> sessionDescriptionBuilder.setVersion(ignorantIntParser(value)); + case 'c' -> { if (currentMediaBuilder != null) { currentMediaBuilder.setConnectionData(value); } else { sessionDescriptionBuilder.setConnectionData(value); } - break; - case 's': - sessionDescriptionBuilder.setName(value); - break; - case 'a': + } + case 's' -> sessionDescriptionBuilder.setName(value); + case 'a' -> { final Pair attribute = parseAttribute(value); attributeMap.put(attribute.first, attribute.second); - break; - case 'm': + } + case 'm' -> { if (currentMediaBuilder == null) { sessionDescriptionBuilder.setAttributes(attributeMap); } else { @@ -115,7 +120,7 @@ public class SessionDescription { } else { Log.d(Config.LOGTAG, "skipping media line " + line); } - break; + } } } if (currentMediaBuilder != null) { @@ -128,7 +133,58 @@ public class SessionDescription { return sessionDescriptionBuilder.createSessionDescription(); } - public static SessionDescription of(final RtpContentMap contentMap, final boolean isInitiatorContentMap) { + public static SessionDescription of(final FileTransferContentMap contentMap) { + final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder(); + final ArrayListMultimap attributeMap = ArrayListMultimap.create(); + final ImmutableList.Builder mediaListBuilder = new ImmutableList.Builder<>(); + + final Group group = contentMap.group; + if (group != null) { + final String semantics = group.getSemantics(); + checkNoWhitespace(semantics, "group semantics value must not contain any whitespace"); + final var idTags = group.getIdentificationTags(); + for (final String content : idTags) { + checkNoWhitespace(content, "group content names must not contain any whitespace"); + } + attributeMap.put("group", group.getSemantics() + " " + Joiner.on(' ').join(idTags)); + } + + // TODO my-media-stream can be removed I think + attributeMap.put("msid-semantic", " WMS my-media-stream"); + + for (final Map.Entry< + String, DescriptionTransport> + entry : contentMap.contents.entrySet()) { + final var dt = entry.getValue(); + final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo; + if (dt.transport instanceof WebRTCDataChannelTransportInfo transportInfo) { + webRTCDataChannelTransportInfo = transportInfo; + } else { + throw new IllegalArgumentException("Transport is not of type WebRTCDataChannel"); + } + final String name = entry.getKey(); + checkNoWhitespace(name, "content name must not contain any whitespace"); + + final MediaBuilder mediaBuilder = new MediaBuilder(); + mediaBuilder.setMedia("application"); + mediaBuilder.setConnectionData(HARDCODED_CONNECTION); + mediaBuilder.setPort(HARDCODED_MEDIA_PORT); + mediaBuilder.setProtocol(HARDCODED_APPLICATION_PROTOCOL); + mediaBuilder.setAttributes( + transportInfoMediaAttributes(webRTCDataChannelTransportInfo)); + mediaBuilder.setFormat(FORMAT_WEBRTC_DATA_CHANNEL); + mediaListBuilder.add(mediaBuilder.createMedia()); + } + + sessionDescriptionBuilder.setVersion(0); + sessionDescriptionBuilder.setName("-"); + sessionDescriptionBuilder.setMedia(mediaListBuilder.build()); + sessionDescriptionBuilder.setAttributes(attributeMap); + return sessionDescriptionBuilder.createSessionDescription(); + } + + public static SessionDescription of( + final RtpContentMap contentMap, final boolean isInitiatorContentMap) { final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder(); final ArrayListMultimap attributeMap = ArrayListMultimap.create(); final ImmutableList.Builder mediaListBuilder = new ImmutableList.Builder<>(); @@ -136,48 +192,27 @@ public class SessionDescription { if (group != null) { final String semantics = group.getSemantics(); checkNoWhitespace(semantics, "group semantics value must not contain any whitespace"); - attributeMap.put( - "group", - group.getSemantics() - + " " - + Joiner.on(' ').join(group.getIdentificationTags())); + final var idTags = group.getIdentificationTags(); + for (final String content : idTags) { + checkNoWhitespace(content, "group content names must not contain any whitespace"); + } + attributeMap.put("group", group.getSemantics() + " " + Joiner.on(' ').join(idTags)); } + // TODO my-media-stream can be removed I think attributeMap.put("msid-semantic", " WMS my-media-stream"); - for (final Map.Entry entry : - contentMap.contents.entrySet()) { + for (final Map.Entry> + entry : contentMap.contents.entrySet()) { final String name = entry.getKey(); - RtpContentMap.DescriptionTransport descriptionTransport = entry.getValue(); - RtpDescription description = descriptionTransport.description; - IceUdpTransportInfo transport = descriptionTransport.transport; + checkNoWhitespace(name, "content name must not contain any whitespace"); + final DescriptionTransport descriptionTransport = + entry.getValue(); + final RtpDescription description = descriptionTransport.description; final ArrayListMultimap mediaAttributes = ArrayListMultimap.create(); - final String ufrag = transport.getAttribute("ufrag"); - final String pwd = transport.getAttribute("pwd"); - if (Strings.isNullOrEmpty(ufrag)) { - throw new IllegalArgumentException( - "Transport element is missing required ufrag attribute"); - } - checkNoWhitespace(ufrag, "ufrag value must not contain any whitespaces"); - mediaAttributes.put("ice-ufrag", ufrag); - if (Strings.isNullOrEmpty(pwd)) { - throw new IllegalArgumentException( - "Transport element is missing required pwd attribute"); - } - checkNoWhitespace(pwd, "pwd value must not contain any whitespaces"); - mediaAttributes.put("ice-pwd", pwd); - mediaAttributes.put("ice-options", HARDCODED_ICE_OPTIONS); - final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint(); - if (fingerprint != null) { - mediaAttributes.put( - "fingerprint", fingerprint.getHash() + " " + fingerprint.getContent()); - final IceUdpTransportInfo.Setup setup = fingerprint.getSetup(); - if (setup != null) { - mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT)); - } - } + mediaAttributes.putAll(transportInfoMediaAttributes(descriptionTransport.transport)); final ImmutableList.Builder formatBuilder = new ImmutableList.Builder<>(); - for (RtpDescription.PayloadType payloadType : description.getPayloadTypes()) { + for (final RtpDescription.PayloadType payloadType : description.getPayloadTypes()) { final String id = payloadType.getId(); if (Strings.isNullOrEmpty(id)) { throw new IllegalArgumentException("Payload type is missing id"); @@ -207,12 +242,14 @@ public class SessionDescription { } checkNoWhitespace( type, "feedback negotiation type must not contain whitespace"); - mediaAttributes.put( - "rtcp-fb", - id - + " " - + type - + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype)); + if (Strings.isNullOrEmpty(subtype)) { + mediaAttributes.put("rtcp-fb", id + " " + type); + } else { + checkNoWhitespace( + subtype, + "feedback negotiation subtype must not contain whitespace"); + mediaAttributes.put("rtcp-fb", id + " " + type + " " + subtype); + } } for (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt : payloadType.feedbackNegotiationTrrInts()) { @@ -229,9 +266,13 @@ public class SessionDescription { throw new IllegalArgumentException("a feedback negotiation is missing type"); } checkNoWhitespace(type, "feedback negotiation type must not contain whitespace"); - mediaAttributes.put( - "rtcp-fb", - "* " + type + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype)); + if (Strings.isNullOrEmpty(subtype)) { + mediaAttributes.put("rtcp-fb", "* " + type); + } else { + checkNoWhitespace( + subtype, "feedback negotiation subtype must not contain whitespace"); + mediaAttributes.put("rtcp-fb", "* " + type + " " + subtype); /**/ + } } for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt : description.feedbackNegotiationTrrInts()) { @@ -268,6 +309,9 @@ public class SessionDescription { if (groups.size() == 0) { throw new IllegalArgumentException("A SSRC group is missing SSRC ids"); } + for (final String source : groups) { + checkNoWhitespace(source, "Sources must not contain whitespace"); + } mediaAttributes.put( "ssrc-group", String.format("%s %s", semantics, Joiner.on(' ').join(groups))); @@ -291,13 +335,21 @@ public class SessionDescription { throw new IllegalArgumentException( "A source specific media attribute is missing its value"); } - mediaAttributes.put("ssrc", id + " " + parameterName + ":" + parameterValue); + checkNoWhitespace( + parameterName, + "A source specific media attribute name not not contain whitespace"); + checkNoNewline( + parameterValue, + "A source specific media attribute value must not contain new lines"); + mediaAttributes.put( + "ssrc", id + " " + parameterName + ":" + parameterValue.trim()); } } mediaAttributes.put("mid", name); - mediaAttributes.put(descriptionTransport.senders.asMediaAttribute(isInitiatorContentMap), ""); + mediaAttributes.put( + descriptionTransport.senders.asMediaAttribute(isInitiatorContentMap), ""); if (description.hasChild("rtcp-mux", Namespace.JINGLE_APPS_RTP) || group != null) { mediaAttributes.put("rtcp-mux", ""); } @@ -322,6 +374,69 @@ public class SessionDescription { return sessionDescriptionBuilder.createSessionDescription(); } + private static Multimap transportInfoMediaAttributes( + final IceUdpTransportInfo transport) { + final ArrayListMultimap mediaAttributes = ArrayListMultimap.create(); + final String ufrag = transport.getAttribute("ufrag"); + final String pwd = transport.getAttribute("pwd"); + if (Strings.isNullOrEmpty(ufrag)) { + throw new IllegalArgumentException( + "Transport element is missing required ufrag attribute"); + } + checkNoWhitespace(ufrag, "ufrag value must not contain any whitespaces"); + mediaAttributes.put("ice-ufrag", ufrag); + if (Strings.isNullOrEmpty(pwd)) { + throw new IllegalArgumentException( + "Transport element is missing required pwd attribute"); + } + checkNoWhitespace(pwd, "pwd value must not contain any whitespaces"); + mediaAttributes.put("ice-pwd", pwd); + final List negotiatedIceOptions = transport.getIceOptions(); + final Collection iceOptions = + negotiatedIceOptions.isEmpty() ? HARDCODED_ICE_OPTIONS : negotiatedIceOptions; + mediaAttributes.put("ice-options", Joiner.on(' ').join(iceOptions)); + final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint(); + if (fingerprint != null) { + final String hashFunction = fingerprint.getHash(); + final String hash = fingerprint.getContent(); + if (Strings.isNullOrEmpty(hashFunction) || Strings.isNullOrEmpty(hash)) { + throw new IllegalArgumentException("DTLS-SRTP missing hash"); + } + checkNoWhitespace(hashFunction, "DTLS-SRTP hash function must not contain whitespace"); + checkNoWhitespace(hash, "DTLS-SRTP hash must not contain whitespace"); + mediaAttributes.put("fingerprint", hashFunction + " " + hash); + final IceUdpTransportInfo.Setup setup = fingerprint.getSetup(); + if (setup != null) { + mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT)); + } + } + return ImmutableMultimap.copyOf(mediaAttributes); + } + + private static Multimap transportInfoMediaAttributes( + final WebRTCDataChannelTransportInfo transport) { + final ArrayListMultimap mediaAttributes = ArrayListMultimap.create(); + final var iceUdpTransportInfo = transport.innerIceUdpTransportInfo(); + if (iceUdpTransportInfo == null) { + throw new IllegalArgumentException( + "Transport element is missing inner ice-udp transport"); + } + mediaAttributes.putAll(transportInfoMediaAttributes(iceUdpTransportInfo)); + final Integer sctpPort = transport.getSctpPort(); + if (sctpPort == null) { + throw new IllegalArgumentException( + "Transport element is missing required sctp-port attribute"); + } + mediaAttributes.put("sctp-port", String.valueOf(sctpPort)); + final Integer maxMessageSize = transport.getMaxMessageSize(); + if (maxMessageSize == null) { + throw new IllegalArgumentException( + "Transport element is missing required max-message-size"); + } + mediaAttributes.put("max-message-size", String.valueOf(maxMessageSize)); + return ImmutableMultimap.copyOf(mediaAttributes); + } + public static String checkNoWhitespace(final String input, final String message) { if (CharMatcher.whitespace().matchesAnyOf(input)) { throw new IllegalArgumentException(message); @@ -329,6 +444,13 @@ public class SessionDescription { return input; } + public static String checkNoNewline(final String input, final String message) { + if (CharMatcher.anyOf("\r\n").matchesAnyOf(message)) { + throw new IllegalArgumentException(message); + } + return input; + } + public static int ignorantIntParser(final String input) { try { return Integer.parseInt(input); @@ -383,7 +505,7 @@ public class SessionDescription { .append(' ') .append(media.protocol) .append(' ') - .append(Joiner.on(' ').join(media.formats)) + .append(media.format) .append(LINE_DIVIDER); s.append("c=").append(media.connectionData).append(LINE_DIVIDER); appendAttributes(s, media.attributes); @@ -395,21 +517,21 @@ public class SessionDescription { public final String media; public final int port; public final String protocol; - public final List formats; + public final String format; public final String connectionData; - public final ArrayListMultimap attributes; + public final Multimap attributes; public Media( String media, int port, String protocol, - List formats, + String format, String connectionData, - ArrayListMultimap attributes) { + Multimap attributes) { this.media = media; this.port = port; this.protocol = protocol; - this.formats = formats; + this.format = format; this.connectionData = connectionData; this.attributes = attributes; } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java index 045468d01..3034dd6d3 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java @@ -3,6 +3,7 @@ package eu.siacs.conversations.xmpp.jingle; import android.content.Context; import android.media.AudioManager; import android.media.ToneGenerator; +import android.os.Build; import android.util.Log; import java.util.Arrays; @@ -14,9 +15,11 @@ import eu.siacs.conversations.Config; import static java.util.Arrays.asList; +import androidx.core.content.ContextCompat; + class ToneManager { - private final ToneGenerator toneGenerator; + private ToneGenerator toneGenerator; private final Context context; private ToneState state = null; @@ -26,14 +29,6 @@ class ToneManager { private boolean appRtcAudioManagerHasControl = false; ToneManager(final Context context) { - ToneGenerator toneGenerator; - try { - toneGenerator = new ToneGenerator(AudioManager.STREAM_VOICE_CALL, 60); - } catch (final RuntimeException e) { - Log.e(Config.LOGTAG, "unable to instantiate ToneGenerator", e); - toneGenerator = null; - } - this.toneGenerator = toneGenerator; this.context = context; } @@ -166,16 +161,44 @@ class ToneManager { if (currentTone != null) { currentTone.cancel(true); } - if (toneGenerator != null) { + stopTone(toneGenerator); + } + + private static void stopTone(final ToneGenerator toneGenerator) { + if (toneGenerator == null) { + return; + } + try { toneGenerator.stopTone(); + } catch (final RuntimeException e) { + Log.w(Config.LOGTAG,"tone has already stopped"); } } public void startTone(final int toneType, final int durationMs) { + if (this.toneGenerator != null) { + this.toneGenerator.release();; + + } + final AudioManager audioManager = ContextCompat.getSystemService(context, AudioManager.class); + final boolean ringerModeNormal = audioManager == null || audioManager.getRingerMode() == AudioManager.RINGER_MODE_NORMAL; + this.toneGenerator = getToneGenerator(ringerModeNormal); if (toneGenerator != null) { this.toneGenerator.startTone(toneType, durationMs); - } else { - Log.e(Config.LOGTAG, "failed to start tone. ToneGenerator doesn't exist"); + } + } + + private static ToneGenerator getToneGenerator(final boolean ringerModeNormal) { + try { + // when silent and on Android 12+ use STREAM_MUSIC + if (ringerModeNormal || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return new ToneGenerator(AudioManager.STREAM_VOICE_CALL,60); + } else { + return new ToneGenerator(AudioManager.STREAM_MUSIC,100); + } + } catch (final Exception e) { + Log.d(Config.LOGTAG,"could not create tone generator",e); + return null; } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/TrackWrapper.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/TrackWrapper.java index 31c3577ee..e62aa18fd 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/TrackWrapper.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/TrackWrapper.java @@ -6,6 +6,8 @@ import com.google.common.base.CaseFormat; import com.google.common.base.Optional; import com.google.common.base.Preconditions; +import eu.siacs.conversations.Config; + import org.webrtc.MediaStreamTrack; import org.webrtc.PeerConnection; import org.webrtc.RtpSender; @@ -16,8 +18,6 @@ import java.util.UUID; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import eu.siacs.conversations.Config; - class TrackWrapper { public final T track; public final RtpSender rtpSender; @@ -43,7 +43,13 @@ class TrackWrapper { final RtpTransceiver transceiver = peerConnection == null ? null : getTransceiver(peerConnection, trackWrapper); if (transceiver == null) { - Log.w(Config.LOGTAG, "unable to detect transceiver for " + trackWrapper.rtpSender.id()); + final String id; + try { + id = trackWrapper.rtpSender.id(); + } catch (final IllegalStateException e) { + return Optional.absent(); + } + Log.w(Config.LOGTAG, "unable to detect transceiver for " + id); return Optional.of(trackWrapper.track); } final RtpTransceiver.RtpTransceiverDirection direction = transceiver.getDirection(); diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java index 8852b63a1..1f79086eb 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java @@ -16,6 +16,10 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.SettableFuture; +import eu.siacs.conversations.Config; +import eu.siacs.conversations.services.AppRTCAudioManager; +import eu.siacs.conversations.services.XmppConnectionService; + import org.webrtc.AudioSource; import org.webrtc.AudioTrack; import org.webrtc.CandidatePairChangeEvent; @@ -36,7 +40,6 @@ import org.webrtc.SdpObserver; import org.webrtc.SessionDescription; import org.webrtc.VideoTrack; import org.webrtc.audio.JavaAudioDeviceModule; -import org.webrtc.voiceengine.WebRtcAudioEffects; import java.util.LinkedList; import java.util.List; @@ -45,21 +48,21 @@ import java.util.Queue; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import eu.siacs.conversations.Config; -import eu.siacs.conversations.services.AppRTCAudioManager; -import eu.siacs.conversations.services.XmppConnectionService; - @SuppressWarnings("UnstableApiUsage") public class WebRTCWrapper { private static final String EXTENDED_LOGGING_TAG = WebRTCWrapper.class.getSimpleName(); private final ExecutorService executorService = Executors.newSingleThreadExecutor(); + private final ExecutorService localDescriptionExecutorService = + Executors.newSingleThreadExecutor(); private static final int TONE_DURATION = 200; private static final Map TONE_CODES; @@ -93,6 +96,7 @@ public class WebRTCWrapper { .add("E5823") // Sony z5 compact .add("Redmi Note 5") .add("FP2") // Fairphone FP2 + .add("FP4") // Fairphone FP4 .add("MI 5") .add("GT-I9515") // Samsung Galaxy S4 Value Edition (jfvelte) .add("GT-I9515L") // Samsung Galaxy S4 Value Edition (jfvelte) @@ -115,6 +119,8 @@ public class WebRTCWrapper { private TrackWrapper localAudioTrack = null; private TrackWrapper localVideoTrack = null; private VideoTrack remoteVideoTrack = null; + + private final SettableFuture iceGatheringComplete = SettableFuture.create(); private final PeerConnection.Observer peerConnectionObserver = new PeerConnection.Observer() { @Override @@ -149,8 +155,11 @@ public class WebRTCWrapper { @Override public void onIceGatheringChange( - PeerConnection.IceGatheringState iceGatheringState) { + final PeerConnection.IceGatheringState iceGatheringState) { Log.d(EXTENDED_LOGGING_TAG, "onIceGatheringChange(" + iceGatheringState + ")"); + if (iceGatheringState == PeerConnection.IceGatheringState.COMPLETE) { + iceGatheringComplete.set(null); + } } @Override @@ -277,15 +286,16 @@ public class WebRTCWrapper { } synchronized void initializePeerConnection( - final Set media, final List iceServers) + final Set media, + final List iceServers, + final boolean trickle) throws InitializationException { Preconditions.checkState(this.eglBase != null); Preconditions.checkNotNull(media); Preconditions.checkArgument( media.size() > 0, "media can not be empty when initializing peer connection"); final boolean setUseHardwareAcousticEchoCanceler = - WebRtcAudioEffects.canUseAcousticEchoCanceler() - && !HARDWARE_AEC_BLACKLIST.contains(Build.MODEL); + !HARDWARE_AEC_BLACKLIST.contains(Build.MODEL); Log.d( Config.LOGTAG, String.format( @@ -305,7 +315,7 @@ public class WebRTCWrapper { .createAudioDeviceModule()) .createPeerConnectionFactory(); - final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers); + final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers, trickle); final PeerConnection peerConnection = requirePeerConnectionFactory() .createPeerConnection(rtcConfig, peerConnectionObserver); @@ -419,39 +429,44 @@ public class WebRTCWrapper { } } - private static PeerConnection.RTCConfiguration buildConfiguration( - final List iceServers) { + public static PeerConnection.RTCConfiguration buildConfiguration( + final List iceServers, final boolean trickle) { final PeerConnection.RTCConfiguration rtcConfig = new PeerConnection.RTCConfiguration(iceServers); rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED; // XEP-0176 doesn't support tcp - rtcConfig.continualGatheringPolicy = - PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; + if (trickle) { + rtcConfig.continualGatheringPolicy = + PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; + } else { + rtcConfig.continualGatheringPolicy = + PeerConnection.ContinualGatheringPolicy.GATHER_ONCE; + } rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.NEGOTIATE; rtcConfig.enableImplicitRollback = true; return rtcConfig; } - void reconfigurePeerConnection(final List iceServers) { - requirePeerConnection().setConfiguration(buildConfiguration(iceServers)); + void reconfigurePeerConnection( + final List iceServers, final boolean trickle) { + requirePeerConnection().setConfiguration(buildConfiguration(iceServers, trickle)); } - void restartIce() { - executorService.execute( - () -> { - final PeerConnection peerConnection; - try { - peerConnection = requirePeerConnection(); - } catch (final PeerConnectionNotInitialized e) { - Log.w( - EXTENDED_LOGGING_TAG, - "PeerConnection vanished before we could execute restart"); - return; - } - setIsReadyToReceiveIceCandidates(false); - peerConnection.restartIce(); - }); + void restartIceAsync() { + this.execute(this::restartIce); + } + + private void restartIce() { + final PeerConnection peerConnection; + try { + peerConnection = requirePeerConnection(); + } catch (final PeerConnectionNotInitialized e) { + Log.w(EXTENDED_LOGGING_TAG, "PeerConnection vanished before we could execute restart"); + return; + } + setIsReadyToReceiveIceCandidates(false); + peerConnection.restartIce(); } public void setIsReadyToReceiveIceCandidates(final boolean ready) { @@ -544,7 +559,7 @@ public class WebRTCWrapper { return false; } } else { - throw new IllegalStateException("Local audio track does not exist (yet)"); + return false; } } @@ -584,7 +599,9 @@ public class WebRTCWrapper { throw new IllegalStateException("Local video track does not exist"); } - synchronized ListenableFuture setLocalDescription() { + synchronized ListenableFuture setLocalDescription( + final boolean waitForCandidates) { + this.setIsReadyToReceiveIceCandidates(false); return Futures.transformAsync( getPeerConnectionFuture(), peerConnection -> { @@ -597,11 +614,20 @@ public class WebRTCWrapper { new SetSdpObserver() { @Override public void onSetSuccess() { - final SessionDescription description = - peerConnection.getLocalDescription(); - Log.d(EXTENDED_LOGGING_TAG, "set local description:"); - logDescription(description); - future.set(description); + if (waitForCandidates) { + final var delay = getIceGatheringCompleteOrTimeout(); + final var delayedSessionDescription = + Futures.transformAsync( + delay, + v -> { + iceCandidates.clear(); + return getLocalDescriptionFuture(); + }, + MoreExecutors.directExecutor()); + future.setFuture(delayedSessionDescription); + } else { + future.setFuture(getLocalDescriptionFuture()); + } } @Override @@ -615,6 +641,35 @@ public class WebRTCWrapper { MoreExecutors.directExecutor()); } + private ListenableFuture getIceGatheringCompleteOrTimeout() { + return Futures.catching( + Futures.withTimeout( + iceGatheringComplete, + 2, + TimeUnit.SECONDS, + JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE), + TimeoutException.class, + ex -> { + Log.d( + EXTENDED_LOGGING_TAG, + "timeout while waiting for ICE gathering to complete"); + return null; + }, + MoreExecutors.directExecutor()); + } + + private ListenableFuture getLocalDescriptionFuture() { + return Futures.submit( + () -> { + final SessionDescription description = + requirePeerConnection().getLocalDescription(); + Log.d(EXTENDED_LOGGING_TAG, "local description:"); + logDescription(description); + return description; + }, + localDescriptionExecutorService); + } + public static void logDescription(final SessionDescription sessionDescription) { for (final String line : sessionDescription.description.split( @@ -737,7 +792,7 @@ public class WebRTCWrapper { } void execute(final Runnable command) { - executorService.execute(command); + this.executorService.execute(command); } public void switchSpeakerPhonePreference(AppRTCAudioManager.SpeakerPhonePreference preference) { @@ -756,7 +811,7 @@ public class WebRTCWrapper { void onRenegotiationNeeded(); } - private abstract static class SetSdpObserver implements SdpObserver { + public abstract static class SetSdpObserver implements SdpObserver { @Override public void onCreateSuccess(org.webrtc.SessionDescription sessionDescription) { @@ -782,12 +837,12 @@ public class WebRTCWrapper { public static class PeerConnectionNotInitialized extends IllegalStateException { - private PeerConnectionNotInitialized() { + public PeerConnectionNotInitialized() { super("initialize PeerConnection first"); } } - private static class FailureToSetDescriptionException extends IllegalArgumentException { + public static class FailureToSetDescriptionException extends IllegalArgumentException { public FailureToSetDescriptionException(String message) { super(message); } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java index 061cea752..a4cb13e07 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java @@ -6,15 +6,19 @@ import androidx.annotation.NonNull; import com.google.common.base.Preconditions; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableSet; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.crypto.axolotl.AxolotlService; +import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.Jid; +import eu.siacs.conversations.xmpp.jingle.SessionDescription; import java.util.Locale; import java.util.Set; -import eu.siacs.conversations.Config; -import eu.siacs.conversations.xml.Element; -import eu.siacs.conversations.xml.Namespace; -import eu.siacs.conversations.xmpp.jingle.SessionDescription; - public class Content extends Element { public Content(final Creator creator, final Senders senders, final String name) { @@ -64,7 +68,7 @@ public class Content extends Element { return null; } final String namespace = description.getNamespace(); - if (FileTransferDescription.NAMESPACES.contains(namespace)) { + if (Namespace.JINGLE_APPS_FILE_TRANSFER.equals(namespace)) { return FileTransferDescription.upgrade(description); } else if (Namespace.JINGLE_APPS_RTP.equals(namespace)) { return RtpDescription.upgrade(description); @@ -89,9 +93,11 @@ public class Content extends Element { if (Namespace.JINGLE_TRANSPORTS_IBB.equals(namespace)) { return IbbTransportInfo.upgrade(transport); } else if (Namespace.JINGLE_TRANSPORTS_S5B.equals(namespace)) { - return S5BTransportInfo.upgrade(transport); + return SocksByteStreamsTransportInfo.upgrade(transport); } else if (Namespace.JINGLE_TRANSPORT_ICE_UDP.equals(namespace)) { return IceUdpTransportInfo.upgrade(transport); + } else if (Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL.equals(namespace)) { + return WebRTCDataChannelTransportInfo.upgrade(transport); } else if (transport != null) { return GenericTransportInfo.upgrade(transport); } else { @@ -99,6 +105,36 @@ public class Content extends Element { } } + public void setSecurity(final XmppAxolotlMessage xmppAxolotlMessage) { + final String contentName = this.getContentName(); + final Element security = new Element("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT); + security.setAttribute("name", contentName); + security.setAttribute("cipher", "urn:xmpp:ciphers:aes-128-gcm-nopadding"); + security.setAttribute("type", AxolotlService.PEP_PREFIX); + security.addChild(xmppAxolotlMessage.toElement()); + this.addChild(security); + } + + public XmppAxolotlMessage getSecurity(final Jid from) { + final String contentName = this.getContentName(); + for (final Element child : this.children) { + if ("security".equals(child.getName()) + && Namespace.JINGLE_ENCRYPTED_TRANSPORT.equals(child.getNamespace())) { + final String name = child.getAttribute("name"); + final String type = child.getAttribute("type"); + final String cipher = child.getAttribute("cipher"); + if (contentName.equals(name) + && AxolotlService.PEP_PREFIX.equals(type) + && "urn:xmpp:ciphers:aes-128-gcm-nopadding".equals(cipher)) { + final var encrypted = child.findChild("encrypted", AxolotlService.PEP_PREFIX); + if (encrypted != null) { + return XmppAxolotlMessage.fromElement(encrypted, from.asBareJid()); + } + } + } + } + return null; + } public void setTransport(GenericTransportInfo transportInfo) { this.addChild(transportInfo); @@ -140,13 +176,17 @@ public class Content extends Element { } else if (attributes.contains("recvonly")) { return initiator ? RESPONDER : INITIATOR; } - Log.w(Config.LOGTAG,"assuming default value for senders"); + Log.w(Config.LOGTAG, "assuming default value for senders"); // If none of the attributes "sendonly", "recvonly", "inactive", and "sendrecv" is // present, "sendrecv" SHOULD be assumed as the default // https://www.rfc-editor.org/rfc/rfc4566 return BOTH; } + public static Set receiveOnly(final boolean initiator) { + return ImmutableSet.of(initiator ? RESPONDER : INITIATOR); + } + @Override @NonNull public String toString() { diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java index 8e0f2ebad..3878d98d9 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java @@ -1,89 +1,233 @@ package eu.siacs.conversations.xmpp.jingle.stanzas; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.common.base.CaseFormat; +import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.io.BaseEncoding; +import com.google.common.primitives.Longs; -import java.util.Arrays; -import java.util.List; - -import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage; -import eu.siacs.conversations.entities.DownloadableFile; +import eu.siacs.conversations.Config; import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; + +import java.util.List; public class FileTransferDescription extends GenericDescription { - public static List NAMESPACES = Arrays.asList( - Version.FT_3.namespace, - Version.FT_4.namespace, - Version.FT_5.namespace - ); - - - private FileTransferDescription(String name, String namespace) { - super(name, namespace); + private FileTransferDescription() { + super("description", Namespace.JINGLE_APPS_FILE_TRANSFER); } - public Version getVersion() { - final String namespace = getNamespace(); - if (namespace.equals(Version.FT_3.namespace)) { - return Version.FT_3; - } else if (namespace.equals(Version.FT_4.namespace)) { - return Version.FT_4; - } else if (namespace.equals(Version.FT_5.namespace)) { - return Version.FT_5; - } else { - throw new IllegalStateException("Unknown namespace"); - } - } - - public Element getFileOffer() { - final Version version = getVersion(); - if (version == Version.FT_3) { - final Element offer = this.findChild("offer"); - return offer == null ? null : offer.findChild("file"); - } else { - return this.findChild("file"); - } - } - - public static FileTransferDescription of(DownloadableFile file, Version version, XmppAxolotlMessage axolotlMessage) { - final FileTransferDescription description = new FileTransferDescription("description", version.getNamespace()); - final Element fileElement; - if (version == Version.FT_3) { - Element offer = description.addChild("offer"); - fileElement = offer.addChild("file"); - } else { - fileElement = description.addChild("file"); - } - fileElement.addChild("size").setContent(Long.toString(file.getExpectedSize())); - fileElement.addChild("name").setContent(file.getName()); - if (axolotlMessage != null) { - fileElement.addChild(axolotlMessage.toElement()); + public static FileTransferDescription of(final File fileDescription) { + final var description = new FileTransferDescription(); + final var file = description.addChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER); + file.addChild("name").setContent(fileDescription.name); + file.addChild("size").setContent(Long.toString(fileDescription.size)); + if (fileDescription.mediaType != null) { + file.addChild("mediaType").setContent(fileDescription.mediaType); } return description; } + public File getFile() { + final Element fileElement = this.findChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER); + if (fileElement == null) { + Log.d(Config.LOGTAG,"no file? "+this); + throw new IllegalStateException("file transfer description has no file"); + } + final String name = fileElement.findChildContent("name"); + final String sizeAsString = fileElement.findChildContent("size"); + final String mediaType = fileElement.findChildContent("mediaType"); + if (Strings.isNullOrEmpty(name) || Strings.isNullOrEmpty(sizeAsString)) { + throw new IllegalStateException("File definition is missing name and/or size"); + } + final Long size = Longs.tryParse(sizeAsString); + if (size == null) { + throw new IllegalStateException("Invalid file size"); + } + final List hashes = findHashes(fileElement.getChildren()); + return new File(size, name, mediaType, hashes); + } + + public static SessionInfo getSessionInfo(@NonNull final JinglePacket jinglePacket) { + Preconditions.checkNotNull(jinglePacket); + Preconditions.checkArgument( + jinglePacket.getAction() == JinglePacket.Action.SESSION_INFO, + "jingle packet is not a session-info"); + final Element jingle = jinglePacket.findChild("jingle", Namespace.JINGLE); + if (jingle == null) { + return null; + } + final Element checksum = jingle.findChild("checksum", Namespace.JINGLE_APPS_FILE_TRANSFER); + if (checksum != null) { + final Element file = checksum.findChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER); + final String name = checksum.getAttribute("name"); + if (file == null || Strings.isNullOrEmpty(name)) { + return null; + } + return new Checksum(name, findHashes(file.getChildren())); + } + final Element received = jingle.findChild("received", Namespace.JINGLE_APPS_FILE_TRANSFER); + if (received != null) { + final String name = received.getAttribute("name"); + if (Strings.isNullOrEmpty(name)) { + return new Received(name); + } + } + return null; + } + + private static List findHashes(final List elements) { + final ImmutableList.Builder hashes = new ImmutableList.Builder<>(); + for (final Element child : elements) { + if ("hash".equals(child.getName()) && Namespace.HASHES.equals(child.getNamespace())) { + final Algorithm algorithm; + try { + algorithm = Algorithm.of(child.getAttribute("algo")); + } catch (final IllegalArgumentException e) { + continue; + } + final String content = child.getContent(); + if (Strings.isNullOrEmpty(content)) { + continue; + } + if (BaseEncoding.base64().canDecode(content)) { + hashes.add(new Hash(BaseEncoding.base64().decode(content), algorithm)); + } + } + } + return hashes.build(); + } + public static FileTransferDescription upgrade(final Element element) { - Preconditions.checkArgument("description".equals(element.getName()), "Name of provided element is not description"); - Preconditions.checkArgument(NAMESPACES.contains(element.getNamespace()), "Element does not match a file transfer namespace"); - final FileTransferDescription description = new FileTransferDescription("description", element.getNamespace()); + Preconditions.checkArgument( + "description".equals(element.getName()), + "Name of provided element is not description"); + Preconditions.checkArgument( + element.getNamespace().equals(Namespace.JINGLE_APPS_FILE_TRANSFER), + "Element does not match a file transfer namespace"); + final FileTransferDescription description = new FileTransferDescription(); description.setAttributes(element.getAttributes()); description.setChildren(element.getChildren()); return description; } - public enum Version { - FT_3("urn:xmpp:jingle:apps:file-transfer:3"), - FT_4("urn:xmpp:jingle:apps:file-transfer:4"), - FT_5("urn:xmpp:jingle:apps:file-transfer:5"); + public static final class Checksum extends SessionInfo { + public final List hashes; - private final String namespace; - - Version(String namespace) { - this.namespace = namespace; + public Checksum(final String name, List hashes) { + super(name); + this.hashes = hashes; } - public String getNamespace() { - return namespace; + @Override + @NonNull + public String toString() { + return MoreObjects.toStringHelper(this).add("hashes", hashes).toString(); + } + + @Override + public Element asElement() { + final var checksum = new Element("checksum", Namespace.JINGLE_APPS_FILE_TRANSFER); + checksum.setAttribute("name", name); + final var file = checksum.addChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER); + for (final Hash hash : hashes) { + final var element = file.addChild("hash", Namespace.HASHES); + element.setAttribute( + "algo", + CaseFormat.UPPER_UNDERSCORE.to( + CaseFormat.LOWER_HYPHEN, hash.algorithm.toString())); + element.setContent(BaseEncoding.base64().encode(hash.hash)); + } + return checksum; + } + } + + public static final class Received extends SessionInfo { + + public Received(String name) { + super(name); + } + + @Override + public Element asElement() { + final var element = new Element("received", Namespace.JINGLE_APPS_FILE_TRANSFER); + element.setAttribute("name", name); + return element; + } + } + + public abstract static sealed class SessionInfo permits Checksum, Received { + + public final String name; + + protected SessionInfo(final String name) { + this.name = name; + } + + public abstract Element asElement(); + } + + public static class File { + public final long size; + public final String name; + public final String mediaType; + + public final List hashes; + + public File(long size, String name, String mediaType, List hashes) { + this.size = size; + this.name = name; + this.mediaType = mediaType; + this.hashes = hashes; + } + + @Override + @NonNull + public String toString() { + return MoreObjects.toStringHelper(this) + .add("size", size) + .add("name", name) + .add("mediaType", mediaType) + .add("hashes", hashes) + .toString(); + } + } + + public static class Hash { + public final byte[] hash; + public final Algorithm algorithm; + + public Hash(byte[] hash, Algorithm algorithm) { + this.hash = hash; + this.algorithm = algorithm; + } + + @Override + @NonNull + public String toString() { + return MoreObjects.toStringHelper(this) + .add("hash", hash) + .add("algorithm", algorithm) + .toString(); + } + } + + public enum Algorithm { + SHA_1, + SHA_256; + + public static Algorithm of(final String value) { + if (Strings.isNullOrEmpty(value)) { + return null; + } + return valueOf(CaseFormat.LOWER_HYPHEN.to(CaseFormat.UPPER_UNDERSCORE, value)); } } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java index a8db0d09f..3bb3076a7 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java @@ -8,6 +8,7 @@ public class GenericDescription extends Element { GenericDescription(String name, final String namespace) { super(name, namespace); + Preconditions.checkArgument("description".equals(name)); } public static GenericDescription upgrade(final Element element) { diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java index eb5c32252..1d2f7515f 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java @@ -41,7 +41,7 @@ public class Group extends Element { } public static Group ofSdpString(final String input) { - ImmutableList.Builder tagBuilder = new ImmutableList.Builder<>(); + final ImmutableList.Builder tagBuilder = new ImmutableList.Builder<>(); final String[] parts = input.split(" "); if (parts.length >= 2) { final String semantics = parts[0]; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java index 90fb32903..ddab9640f 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java @@ -1,6 +1,8 @@ package eu.siacs.conversations.xmpp.jingle.stanzas; import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.primitives.Longs; import eu.siacs.conversations.xml.Element; import eu.siacs.conversations.xml.Namespace; @@ -23,16 +25,9 @@ public class IbbTransportInfo extends GenericTransportInfo { return this.getAttribute("sid"); } - public int getBlockSize() { + public Long getBlockSize() { final String blockSize = this.getAttribute("block-size"); - if (blockSize == null) { - return 0; - } - try { - return Integer.parseInt(blockSize); - } catch (NumberFormatException e) { - return 0; - } + return Strings.isNullOrEmpty(blockSize) ? null : Longs.tryParse(blockSize); } public static IbbTransportInfo upgrade(final Element element) { diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java index 432333090..8d90b1982 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java @@ -1,17 +1,31 @@ package eu.siacs.conversations.xmpp.jingle.stanzas; +import android.util.Log; + import androidx.annotation.NonNull; import com.google.common.base.Joiner; import com.google.common.base.MoreObjects; import com.google.common.base.Objects; import com.google.common.base.Preconditions; +import com.google.common.base.Splitter; import com.google.common.base.Strings; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Collections2; +import com.google.common.collect.ImmutableCollection; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; +import com.google.common.collect.Multimap; +import eu.siacs.conversations.Config; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.jingle.SessionDescription; +import eu.siacs.conversations.xmpp.jingle.transports.Transport; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.Hashtable; import java.util.LinkedHashMap; @@ -20,10 +34,6 @@ import java.util.Locale; import java.util.Map; import java.util.UUID; -import eu.siacs.conversations.xml.Element; -import eu.siacs.conversations.xml.Namespace; -import eu.siacs.conversations.xmpp.jingle.SessionDescription; - public class IceUdpTransportInfo extends GenericTransportInfo { public static final IceUdpTransportInfo STUB = new IceUdpTransportInfo(); @@ -59,15 +69,25 @@ public class IceUdpTransportInfo extends GenericTransportInfo { if (fingerprint != null) { iceUdpTransportInfo.addChild(fingerprint); } + for (final String iceOption : IceOption.of(media)) { + iceUdpTransportInfo.addChild(new IceOption(iceOption)); + } return iceUdpTransportInfo; } public static IceUdpTransportInfo of( - final Credentials credentials, final Setup setup, final String hash, final String fingerprint) { + final Credentials credentials, + final Collection iceOptions, + final Setup setup, + final String hash, + final String fingerprint) { final IceUdpTransportInfo iceUdpTransportInfo = new IceUdpTransportInfo(); iceUdpTransportInfo.addChild(Fingerprint.of(setup, hash, fingerprint)); iceUdpTransportInfo.setAttribute("ufrag", credentials.ufrag); iceUdpTransportInfo.setAttribute("pwd", credentials.password); + for (final String iceOption : iceOptions) { + iceUdpTransportInfo.addChild(new IceOption(iceOption)); + } return iceUdpTransportInfo; } @@ -76,12 +96,29 @@ public class IceUdpTransportInfo extends GenericTransportInfo { return fingerprint == null ? null : Fingerprint.upgrade(fingerprint); } + public List getIceOptions() { + final ImmutableList.Builder optionBuilder = new ImmutableList.Builder<>(); + for (final Element child : this.children) { + if (Namespace.JINGLE_TRANSPORT_ICE_OPTION.equals(child.getNamespace()) + && IceOption.WELL_KNOWN.contains(child.getName())) { + optionBuilder.add(child.getName()); + } + } + return optionBuilder.build(); + } + public Credentials getCredentials() { final String ufrag = this.getAttribute("ufrag"); final String password = this.getAttribute("pwd"); return new Credentials(ufrag, password); } + public boolean isStub() { + return Strings.isNullOrEmpty(this.getAttribute("ufrag")) + && Strings.isNullOrEmpty(this.getAttribute("pwd")) + && this.children.isEmpty(); + } + public List getCandidates() { final ImmutableList.Builder builder = new ImmutableList.Builder<>(); for (final Element child : getChildren()) { @@ -112,6 +149,19 @@ public class IceUdpTransportInfo extends GenericTransportInfo { transportInfo.addChild(fingerprint); } } + for (final String iceOption : this.getIceOptions()) { + transportInfo.addChild(new IceOption(iceOption)); + } + return transportInfo; + } + + public IceUdpTransportInfo withCandidates(ImmutableCollection candidates) { + final IceUdpTransportInfo transportInfo = new IceUdpTransportInfo(); + transportInfo.setAttributes(new Hashtable<>(getAttributes())); + transportInfo.setChildren(this.getChildren()); + for(final Candidate candidate : candidates) { + transportInfo.addChild(candidate); + } return transportInfo; } @@ -147,7 +197,7 @@ public class IceUdpTransportInfo extends GenericTransportInfo { } } - public static class Candidate extends Element { + public static class Candidate extends Element implements Transport.Candidate { private Candidate() { super("candidate"); @@ -165,41 +215,46 @@ public class IceUdpTransportInfo extends GenericTransportInfo { public static Candidate fromSdpAttribute(final String attribute, String currentUfrag) { final String[] pair = attribute.split(":", 2); if (pair.length == 2 && "candidate".equals(pair[0])) { - final String[] segments = pair[1].split(" "); - if (segments.length >= 6) { - final String id = UUID.randomUUID().toString(); - final String foundation = segments[0]; - final String component = segments[1]; - final String transport = segments[2].toLowerCase(Locale.ROOT); - final String priority = segments[3]; - final String connectionAddress = segments[4]; - final String port = segments[5]; - final HashMap additional = new HashMap<>(); - for (int i = 6; i < segments.length - 1; i = i + 2) { - additional.put(segments[i], segments[i + 1]); - } - final String ufrag = additional.get("ufrag"); - if (ufrag != null && !ufrag.equals(currentUfrag)) { - return null; - } - final Candidate candidate = new Candidate(); - candidate.setAttribute("component", component); - candidate.setAttribute("foundation", foundation); - candidate.setAttribute("generation", additional.get("generation")); - candidate.setAttribute("rel-addr", additional.get("raddr")); - candidate.setAttribute("rel-port", additional.get("rport")); - candidate.setAttribute("id", id); - candidate.setAttribute("ip", connectionAddress); - candidate.setAttribute("port", port); - candidate.setAttribute("priority", priority); - candidate.setAttribute("protocol", transport); - candidate.setAttribute("type", additional.get("typ")); - return candidate; - } + return fromSdpAttributeValue(pair[1], currentUfrag); } return null; } + public static Candidate fromSdpAttributeValue(final String value, final String currentUfrag) { + final String[] segments = value.split(" "); + if (segments.length < 6) { + return null; + } + final String id = UUID.randomUUID().toString(); + final String foundation = segments[0]; + final String component = segments[1]; + final String transport = segments[2].toLowerCase(Locale.ROOT); + final String priority = segments[3]; + final String connectionAddress = segments[4]; + final String port = segments[5]; + final HashMap additional = new HashMap<>(); + for (int i = 6; i < segments.length - 1; i = i + 2) { + additional.put(segments[i], segments[i + 1]); + } + final String ufrag = additional.get("ufrag"); + if (currentUfrag != null && ufrag != null && !ufrag.equals(currentUfrag)) { + return null; + } + final Candidate candidate = new Candidate(); + candidate.setAttribute("component", component); + candidate.setAttribute("foundation", foundation); + candidate.setAttribute("generation", additional.get("generation")); + candidate.setAttribute("rel-addr", additional.get("raddr")); + candidate.setAttribute("rel-port", additional.get("rport")); + candidate.setAttribute("id", id); + candidate.setAttribute("ip", connectionAddress); + candidate.setAttribute("port", port); + candidate.setAttribute("priority", priority); + candidate.setAttribute("protocol", transport); + candidate.setAttribute("type", additional.get("typ")); + return candidate; + } + public int getComponent() { return getAttributeAsInt("component"); } @@ -343,7 +398,7 @@ public class IceUdpTransportInfo extends GenericTransportInfo { return fingerprint; } - private static Fingerprint of(ArrayListMultimap attributes) { + private static Fingerprint of(final Multimap attributes) { final String fingerprint = Iterables.getFirst(attributes.get("fingerprint"), null); final String setup = Iterables.getFirst(attributes.get("setup"), null); if (setup != null && fingerprint != null) { @@ -408,4 +463,29 @@ public class IceUdpTransportInfo extends GenericTransportInfo { throw new IllegalStateException(this.name() + " can not be flipped"); } } + + public static class IceOption extends Element { + + public static final List WELL_KNOWN = Arrays.asList("trickle", "renomination"); + + public IceOption(final String name) { + super(name, Namespace.JINGLE_TRANSPORT_ICE_OPTION); + } + + public static Collection of(SessionDescription.Media media) { + final String iceOptions = Iterables.getFirst(media.attributes.get("ice-options"), null); + if (Strings.isNullOrEmpty(iceOptions)) { + return Collections.emptyList(); + } + final ImmutableList.Builder optionBuilder = new ImmutableList.Builder<>(); + for (final String iceOption : Splitter.on(' ').split(iceOptions)) { + if (WELL_KNOWN.contains(iceOption)) { + optionBuilder.add(iceOption); + } else { + Log.w(Config.LOGTAG, "unrecognized ice option: " + iceOption); + } + } + return optionBuilder.build(); + } + } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java index 0863b29df..82c5b155c 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java @@ -7,13 +7,13 @@ import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.collect.ImmutableMap; -import java.util.Map; - import eu.siacs.conversations.xml.Element; import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.stanzas.IqPacket; +import java.util.Map; + public class JinglePacket extends IqPacket { private JinglePacket() { @@ -36,7 +36,7 @@ public class JinglePacket extends IqPacket { return jinglePacket; } - //TODO deprecate this somehow and make file transfer fail if there are multiple (or something) + // TODO deprecate this somehow and make file transfer fail if there are multiple (or something) public Content getJingleContent() { final Element content = getJingleChild("content"); return content == null ? null : Content.upgrade(content); @@ -64,7 +64,7 @@ public class JinglePacket extends IqPacket { return builder.build(); } - public void addJingleContent(final Content content) { //take content interface + public void addJingleContent(final Content content) { // take content interface addJingleChild(content); } @@ -94,13 +94,13 @@ public class JinglePacket extends IqPacket { } } - //RECOMMENDED for session-initiate, NOT RECOMMENDED otherwise + // RECOMMENDED for session-initiate, NOT RECOMMENDED otherwise public void setInitiator(final Jid initiator) { Preconditions.checkArgument(initiator.isFullJid(), "initiator should be a full JID"); findChild("jingle", Namespace.JINGLE).setAttribute("initiator", initiator); } - //RECOMMENDED for session-accept, NOT RECOMMENDED otherwise + // RECOMMENDED for session-accept, NOT RECOMMENDED otherwise public void setResponder(Jid responder) { Preconditions.checkArgument(responder.isFullJid(), "responder should be a full JID"); findChild("jingle", Namespace.JINGLE).setAttribute("responder", responder); @@ -142,7 +142,7 @@ public class JinglePacket extends IqPacket { TRANSPORT_REPLACE; public static Action of(final String value) { - //TODO handle invalid + // TODO handle invalid return Action.valueOf(CaseFormat.LOWER_HYPHEN.to(CaseFormat.UPPER_UNDERSCORE, value)); } @@ -153,7 +153,6 @@ public class JinglePacket extends IqPacket { } } - public static class ReasonWrapper { public final Reason reason; public final String text; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java index da3a93da3..4b513c7b9 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java @@ -18,7 +18,7 @@ public class Propose extends Element { for (final Element child : this.children) { if ("description".equals(child.getName())) { final String namespace = child.getNamespace(); - if (FileTransferDescription.NAMESPACES.contains(namespace)) { + if (Namespace.JINGLE_APPS_FILE_TRANSFER.equals(namespace)) { builder.add(FileTransferDescription.upgrade(child)); } else if (Namespace.JINGLE_APPS_RTP.equals(namespace)) { builder.add(RtpDescription.upgrade(child)); diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java deleted file mode 100644 index 8f8f13416..000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java +++ /dev/null @@ -1,50 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle.stanzas; - -import com.google.common.base.Preconditions; - -import java.util.Collection; -import java.util.List; - -import eu.siacs.conversations.xml.Element; -import eu.siacs.conversations.xml.Namespace; -import eu.siacs.conversations.xmpp.jingle.JingleCandidate; - -public class S5BTransportInfo extends GenericTransportInfo { - - private S5BTransportInfo(final String name, final String xmlns) { - super(name, xmlns); - } - - public String getTransportId() { - return this.getAttribute("sid"); - } - - public S5BTransportInfo(final String transportId, final Collection candidates) { - super("transport", Namespace.JINGLE_TRANSPORTS_S5B); - Preconditions.checkNotNull(transportId,"transport id must not be null"); - for(JingleCandidate candidate : candidates) { - this.addChild(candidate.toElement()); - } - this.setAttribute("sid", transportId); - } - - public S5BTransportInfo(final String transportId, final Element child) { - super("transport", Namespace.JINGLE_TRANSPORTS_S5B); - Preconditions.checkNotNull(transportId,"transport id must not be null"); - this.addChild(child); - this.setAttribute("sid", transportId); - } - - public List getCandidates() { - return JingleCandidate.parse(this.getChildren()); - } - - public static S5BTransportInfo upgrade(final Element element) { - Preconditions.checkArgument("transport".equals(element.getName()), "Name of provided element is not transport"); - Preconditions.checkArgument(Namespace.JINGLE_TRANSPORTS_S5B.equals(element.getNamespace()), "Element does not match s5b transport namespace"); - final S5BTransportInfo transportInfo = new S5BTransportInfo("transport", Namespace.JINGLE_TRANSPORTS_S5B); - transportInfo.setAttributes(element.getAttributes()); - transportInfo.setChildren(element.getChildren()); - return transportInfo; - } -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/SocksByteStreamsTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/SocksByteStreamsTransportInfo.java new file mode 100644 index 000000000..4b1d85847 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/SocksByteStreamsTransportInfo.java @@ -0,0 +1,117 @@ +package eu.siacs.conversations.xmpp.jingle.stanzas; + +import android.util.Log; + +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.jingle.transports.SocksByteStreamsTransport; + +import java.util.Collection; +import java.util.List; + +public class SocksByteStreamsTransportInfo extends GenericTransportInfo { + + private SocksByteStreamsTransportInfo() { + super("transport", Namespace.JINGLE_TRANSPORTS_S5B); + } + + public String getTransportId() { + return this.getAttribute("sid"); + } + + public SocksByteStreamsTransportInfo( + final String transportId, + final Collection candidates) { + super("transport", Namespace.JINGLE_TRANSPORTS_S5B); + Preconditions.checkNotNull(transportId, "transport id must not be null"); + for (SocksByteStreamsTransport.Candidate candidate : candidates) { + this.addChild(candidate.asElement()); + } + this.setAttribute("sid", transportId); + } + + public TransportInfo getTransportInfo() { + if (hasChild("proxy-error")) { + return new ProxyError(); + } else if (hasChild("candidate-error")) { + return new CandidateError(); + } else if (hasChild("candidate-used")) { + final Element candidateUsed = findChild("candidate-used"); + final String cid = candidateUsed == null ? null : candidateUsed.getAttribute("cid"); + if (Strings.isNullOrEmpty(cid)) { + return null; + } else { + return new CandidateUsed(cid); + } + } else if (hasChild("activated")) { + final Element activated = findChild("activated"); + final String cid = activated == null ? null : activated.getAttribute("cid"); + if (Strings.isNullOrEmpty(cid)) { + return null; + } else { + return new Activated(cid); + } + } else { + return null; + } + } + + public List getCandidates() { + final ImmutableList.Builder candidateBuilder = + new ImmutableList.Builder<>(); + for (final Element child : this.children) { + if ("candidate".equals(child.getName()) + && Namespace.JINGLE_TRANSPORTS_S5B.equals(child.getNamespace())) { + try { + candidateBuilder.add(SocksByteStreamsTransport.Candidate.of(child)); + } catch (final Exception e) { + Log.d(Config.LOGTAG, "skip over broken candidate", e); + } + } + } + return candidateBuilder.build(); + } + + public static SocksByteStreamsTransportInfo upgrade(final Element element) { + Preconditions.checkArgument( + "transport".equals(element.getName()), "Name of provided element is not transport"); + Preconditions.checkArgument( + Namespace.JINGLE_TRANSPORTS_S5B.equals(element.getNamespace()), + "Element does not match s5b transport namespace"); + final SocksByteStreamsTransportInfo transportInfo = new SocksByteStreamsTransportInfo(); + transportInfo.setAttributes(element.getAttributes()); + transportInfo.setChildren(element.getChildren()); + return transportInfo; + } + + public String getDestinationAddress() { + return this.getAttribute("dstaddr"); + } + + public abstract static class TransportInfo {} + + public static class CandidateUsed extends TransportInfo { + public final String cid; + + public CandidateUsed(String cid) { + this.cid = cid; + } + } + + public static class Activated extends TransportInfo { + public final String cid; + + public Activated(final String cid) { + this.cid = cid; + } + } + + public static class CandidateError extends TransportInfo {} + + public static class ProxyError extends TransportInfo {} +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/WebRTCDataChannelTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/WebRTCDataChannelTransportInfo.java new file mode 100644 index 000000000..88c4f9f00 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/WebRTCDataChannelTransportInfo.java @@ -0,0 +1,111 @@ +package eu.siacs.conversations.xmpp.jingle.stanzas; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Iterables; +import com.google.common.primitives.Ints; + +import java.util.Collections; +import java.util.Hashtable; +import java.util.List; + +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.jingle.SessionDescription; +import eu.siacs.conversations.xmpp.jingle.transports.Transport; + +public class WebRTCDataChannelTransportInfo extends GenericTransportInfo { + + public static final WebRTCDataChannelTransportInfo STUB = new WebRTCDataChannelTransportInfo(); + + public WebRTCDataChannelTransportInfo() { + super("transport", Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL); + } + + public static WebRTCDataChannelTransportInfo upgrade(final Element element) { + Preconditions.checkArgument( + "transport".equals(element.getName()), "Name of provided element is not transport"); + Preconditions.checkArgument( + Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL.equals(element.getNamespace()), + "Element does not match ice-udp transport namespace"); + final WebRTCDataChannelTransportInfo transportInfo = new WebRTCDataChannelTransportInfo(); + transportInfo.setAttributes(element.getAttributes()); + transportInfo.setChildren(element.getChildren()); + return transportInfo; + } + + public IceUdpTransportInfo innerIceUdpTransportInfo() { + final var iceUdpTransportInfo = + this.findChild("transport", Namespace.JINGLE_TRANSPORT_ICE_UDP); + if (iceUdpTransportInfo != null) { + return IceUdpTransportInfo.upgrade(iceUdpTransportInfo); + } + return null; + } + + public static Transport.InitialTransportInfo of(final SessionDescription sessionDescription) { + final SessionDescription.Media media = Iterables.getOnlyElement(sessionDescription.media); + final String id = Iterables.getFirst(media.attributes.get("mid"), null); + Preconditions.checkNotNull(id, "media has no mid"); + final String maxMessageSize = + Iterables.getFirst(media.attributes.get("max-message-size"), null); + final Integer maxMessageSizeInt = + maxMessageSize == null ? null : Ints.tryParse(maxMessageSize); + final String sctpPort = Iterables.getFirst(media.attributes.get("sctp-port"), null); + final Integer sctpPortInt = sctpPort == null ? null : Ints.tryParse(sctpPort); + final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo = + new WebRTCDataChannelTransportInfo(); + if (maxMessageSizeInt != null) { + webRTCDataChannelTransportInfo.setAttribute("max-message-size", maxMessageSizeInt); + } + if (sctpPortInt != null) { + webRTCDataChannelTransportInfo.setAttribute("sctp-port", sctpPortInt); + } + webRTCDataChannelTransportInfo.addChild(IceUdpTransportInfo.of(sessionDescription, media)); + + final String groupAttribute = + Iterables.getFirst(sessionDescription.attributes.get("group"), null); + final Group group = groupAttribute == null ? null : Group.ofSdpString(groupAttribute); + return new Transport.InitialTransportInfo(id, webRTCDataChannelTransportInfo, group); + } + + public Integer getSctpPort() { + final var attribute = this.getAttribute("sctp-port"); + if (attribute == null) { + return null; + } + return Ints.tryParse(attribute); + } + + public Integer getMaxMessageSize() { + final var attribute = this.getAttribute("max-message-size"); + if (attribute == null) { + return null; + } + return Ints.tryParse(attribute); + } + + public WebRTCDataChannelTransportInfo cloneWrapper() { + final var iceUdpTransport = this.innerIceUdpTransportInfo(); + final WebRTCDataChannelTransportInfo transportInfo = new WebRTCDataChannelTransportInfo(); + transportInfo.setAttributes(new Hashtable<>(getAttributes())); + transportInfo.addChild(iceUdpTransport.cloneWrapper()); + return transportInfo; + } + + public void addCandidate(final IceUdpTransportInfo.Candidate candidate) { + this.innerIceUdpTransportInfo().addChild(candidate); + } + + public List getCandidates() { + final var innerTransportInfo = this.innerIceUdpTransportInfo(); + if (innerTransportInfo == null) { + return Collections.emptyList(); + } + return innerTransportInfo.getCandidates(); + } + + public IceUdpTransportInfo.Credentials getCredentials() { + final var innerTransportInfo = this.innerIceUdpTransportInfo(); + return innerTransportInfo == null ? null : innerTransportInfo.getCredentials(); + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/InbandBytestreamsTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/InbandBytestreamsTransport.java new file mode 100644 index 000000000..ce2d4b31f --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/InbandBytestreamsTransport.java @@ -0,0 +1,321 @@ +package eu.siacs.conversations.xmpp.jingle.transports; + +import android.util.Log; + +import com.google.common.base.Strings; +import com.google.common.io.BaseEncoding; +import com.google.common.io.Closeables; +import com.google.common.primitives.Ints; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.Jid; +import eu.siacs.conversations.xmpp.XmppConnection; +import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public class InbandBytestreamsTransport implements Transport { + + private static final int DEFAULT_BLOCK_SIZE = 8192; + + private final PipedInputStream pipedInputStream = new PipedInputStream(DEFAULT_BLOCK_SIZE); + private final PipedOutputStream pipedOutputStream = new PipedOutputStream(); + private final CountDownLatch terminationLatch = new CountDownLatch(1); + + private final XmppConnection xmppConnection; + + private final Jid with; + + private final boolean initiator; + + private final String streamId; + + private int blockSize; + private Callback transportCallback; + private final BlockSender blockSender; + + private final Thread blockSenderThread; + + private final AtomicBoolean isReceiving = new AtomicBoolean(false); + + public InbandBytestreamsTransport( + final XmppConnection xmppConnection, final Jid with, final boolean initiator) { + this(xmppConnection, with, initiator, UUID.randomUUID().toString(), DEFAULT_BLOCK_SIZE); + } + + public InbandBytestreamsTransport( + final XmppConnection xmppConnection, + final Jid with, + final boolean initiator, + final String streamId, + final int blockSize) { + this.xmppConnection = xmppConnection; + this.with = with; + this.initiator = initiator; + this.streamId = streamId; + this.blockSize = Math.min(DEFAULT_BLOCK_SIZE, blockSize); + this.blockSender = + new BlockSender(xmppConnection, with, streamId, this.blockSize, pipedInputStream); + this.blockSenderThread = new Thread(blockSender); + } + + public void setTransportCallback(final Callback callback) { + this.transportCallback = callback; + } + + public String getStreamId() { + return this.streamId; + } + + public void connect() { + if (initiator) { + openInBandTransport(); + } + } + + @Override + public CountDownLatch getTerminationLatch() { + return this.terminationLatch; + } + + private void openInBandTransport() { + final var iqPacket = new IqPacket(IqPacket.TYPE.SET); + iqPacket.setTo(with); + final var open = iqPacket.addChild("open", Namespace.IBB); + open.setAttribute("block-size", this.blockSize); + open.setAttribute("sid", this.streamId); + Log.d(Config.LOGTAG, "sending ibb open"); + Log.d(Config.LOGTAG, iqPacket.toString()); + xmppConnection.sendIqPacket(iqPacket, this::receiveResponseToOpen); + } + + private void receiveResponseToOpen(final Account account, final IqPacket response) { + if (response.getType() == IqPacket.TYPE.RESULT) { + Log.d(Config.LOGTAG, "ibb open was accepted"); + this.transportCallback.onTransportEstablished(); + this.blockSenderThread.start(); + } else { + this.transportCallback.onTransportSetupFailed(); + } + } + + public boolean deliverPacket( + final PacketType packetType, final Jid from, final Element payload) { + if (from == null || !from.equals(with)) { + Log.d( + Config.LOGTAG, + "ibb packet received from wrong address. was " + from + " expected " + with); + return false; + } + return switch (packetType) { + case OPEN -> receiveOpen(); + case DATA -> receiveData(payload.getContent()); + case CLOSE -> receiveClose(); + default -> throw new IllegalArgumentException("Invalid packet type"); + }; + } + + private boolean receiveData(final String encoded) { + final byte[] buffer; + if (Strings.isNullOrEmpty(encoded)) { + buffer = new byte[0]; + } else { + buffer = BaseEncoding.base64().decode(encoded); + } + Log.d(Config.LOGTAG, "ibb received " + buffer.length + " bytes"); + try { + pipedOutputStream.write(buffer); + pipedOutputStream.flush(); + return true; + } catch (final IOException e) { + Log.d(Config.LOGTAG, "unable to receive ibb data", e); + return false; + } + } + + private boolean receiveClose() { + if (this.isReceiving.compareAndSet(true, false)) { + try { + this.pipedOutputStream.close(); + return true; + } catch (final IOException e) { + Log.d(Config.LOGTAG, "could not close pipedOutStream"); + return false; + } + } else { + Log.d(Config.LOGTAG, "received ibb close but was not receiving"); + return false; + } + } + + private boolean receiveOpen() { + Log.d(Config.LOGTAG, "receiveOpen()"); + if (this.isReceiving.get()) { + Log.d(Config.LOGTAG, "ibb received open even though we were already open"); + return false; + } + this.isReceiving.set(true); + transportCallback.onTransportEstablished(); + return true; + } + + public void terminate() { + // TODO send close + Log.d(Config.LOGTAG, "IbbTransport.terminate()"); + this.terminationLatch.countDown(); + this.blockSender.close(); + this.blockSenderThread.interrupt(); + closeQuietly(this.pipedOutputStream); + } + + private static void closeQuietly(final OutputStream outputStream) { + try { + outputStream.close(); + } catch (final IOException ignored) { + + } + } + + @Override + public OutputStream getOutputStream() throws IOException { + final var outputStream = new PipedOutputStream(); + this.pipedInputStream.connect(outputStream); + return outputStream; + } + + @Override + public InputStream getInputStream() throws IOException { + final var inputStream = new PipedInputStream(); + this.pipedOutputStream.connect(inputStream); + return inputStream; + } + + @Override + public ListenableFuture asTransportInfo() { + return Futures.immediateFuture( + new TransportInfo(new IbbTransportInfo(streamId, blockSize), null)); + } + + @Override + public ListenableFuture asInitialTransportInfo() { + return Futures.immediateFuture( + new InitialTransportInfo( + UUID.randomUUID().toString(), + new IbbTransportInfo(streamId, blockSize), + null)); + } + + public void setPeerBlockSize(long peerBlockSize) { + this.blockSize = Math.min(Ints.saturatedCast(peerBlockSize), DEFAULT_BLOCK_SIZE); + if (this.blockSize < DEFAULT_BLOCK_SIZE) { + Log.d(Config.LOGTAG, "peer reconfigured IBB block size to " + this.blockSize); + } + this.blockSender.setBlockSize(this.blockSize); + } + + private static class BlockSender implements Runnable, Closeable { + + private final XmppConnection xmppConnection; + + private final Jid with; + private final String streamId; + + private int blockSize; + private final PipedInputStream inputStream; + private final Semaphore semaphore = new Semaphore(3); + private final AtomicInteger sequencer = new AtomicInteger(); + private final AtomicBoolean isSending = new AtomicBoolean(true); + + private BlockSender( + XmppConnection xmppConnection, + final Jid with, + String streamId, + int blockSize, + PipedInputStream inputStream) { + this.xmppConnection = xmppConnection; + this.with = with; + this.streamId = streamId; + this.blockSize = blockSize; + this.inputStream = inputStream; + } + + @Override + public void run() { + final var buffer = new byte[blockSize]; + try { + while (isSending.get()) { + final int count = this.inputStream.read(buffer); + if (count < 0) { + Log.d(Config.LOGTAG, "block sender reached EOF"); + return; + } + this.semaphore.acquire(); + final var block = new byte[count]; + System.arraycopy(buffer, 0, block, 0, block.length); + sendIbbBlock(sequencer.getAndIncrement(), block); + } + } catch (final InterruptedException | InterruptedIOException e) { + if (isSending.get()) { + Log.w(Config.LOGTAG, "IbbBlockSender got interrupted while sending", e); + } + } catch (final IOException e) { + Log.d(Config.LOGTAG, "block sender terminated", e); + } finally { + Closeables.closeQuietly(inputStream); + } + } + + private void sendIbbBlock(final int sequence, final byte[] block) { + Log.d(Config.LOGTAG, "sending ibb block #" + sequence + " " + block.length + " bytes"); + final var iqPacket = new IqPacket(IqPacket.TYPE.SET); + iqPacket.setTo(with); + final var data = iqPacket.addChild("data", Namespace.IBB); + data.setAttribute("sid", this.streamId); + data.setAttribute("seq", sequence); + data.setContent(BaseEncoding.base64().encode(block)); + this.xmppConnection.sendIqPacket( + iqPacket, + (a, response) -> { + if (response.getType() != IqPacket.TYPE.RESULT) { + Log.d( + Config.LOGTAG, + "received iq error in response to data block #" + sequence); + isSending.set(false); + } + semaphore.release(); + }); + } + + @Override + public void close() { + this.isSending.set(false); + } + + public void setBlockSize(final int blockSize) { + this.blockSize = blockSize; + } + } + + public enum PacketType { + OPEN, + DATA, + CLOSE + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/SocksByteStreamsTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/SocksByteStreamsTransport.java new file mode 100644 index 000000000..bbda1c622 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/SocksByteStreamsTransport.java @@ -0,0 +1,901 @@ +package eu.siacs.conversations.xmpp.jingle.transports; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.common.base.Joiner; +import com.google.common.base.MoreObjects; +import com.google.common.base.Optional; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.Collections2; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.common.collect.Ordering; +import com.google.common.hash.Hashing; +import com.google.common.io.ByteStreams; +import com.google.common.primitives.Ints; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.utils.SocksSocketFactory; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.Jid; +import eu.siacs.conversations.xmpp.XmppConnection; +import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection; +import eu.siacs.conversations.xmpp.jingle.DirectConnectionUtils; +import eu.siacs.conversations.xmpp.jingle.stanzas.SocksByteStreamsTransportInfo; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.SocketException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +public class SocksByteStreamsTransport implements Transport { + + private final XmppConnection xmppConnection; + + private final AbstractJingleConnection.Id id; + + private final boolean initiator; + private final boolean useTor; + + private final String streamId; + + private ImmutableList theirCandidates; + private final String theirDestination; + private final SettableFuture selectedByThemCandidate = SettableFuture.create(); + private final SettableFuture theirProxyActivation = SettableFuture.create(); + + private final CountDownLatch terminationLatch = new CountDownLatch(1); + + private final ConnectionProvider connectionProvider; + private final ListenableFuture ourProxyConnection; + + private Connection connection; + + private Callback transportCallback; + + public SocksByteStreamsTransport( + final XmppConnection xmppConnection, + final AbstractJingleConnection.Id id, + final boolean initiator, + final boolean useTor, + final String streamId, + final Collection theirCandidates) { + this.xmppConnection = xmppConnection; + this.id = id; + this.initiator = initiator; + this.useTor = useTor; + this.streamId = streamId; + this.theirDestination = + Hashing.sha1() + .hashString( + Joiner.on("") + .join( + Arrays.asList( + streamId, + id.with.toEscapedString(), + id.account.getJid().toEscapedString())), + StandardCharsets.UTF_8) + .toString(); + final var ourDestination = + Hashing.sha1() + .hashString( + Joiner.on("") + .join( + Arrays.asList( + streamId, + id.account.getJid().toEscapedString(), + id.with.toEscapedString())), + StandardCharsets.UTF_8) + .toString(); + + this.connectionProvider = + new ConnectionProvider(id.account.getJid(), ourDestination, useTor); + new Thread(connectionProvider).start(); + this.ourProxyConnection = getOurProxyConnection(ourDestination); + setTheirCandidates(theirCandidates); + } + + public SocksByteStreamsTransport( + final XmppConnection xmppConnection, + final AbstractJingleConnection.Id id, + final boolean initiator, + final boolean useTor) { + this( + xmppConnection, + id, + initiator, + useTor, + UUID.randomUUID().toString(), + Collections.emptyList()); + } + + public void connectTheirCandidates() { + Preconditions.checkState( + this.transportCallback != null, "transport callback needs to be set"); + // TODO this needs to go into a variable so we can cancel it + final var connectionFinder = + new ConnectionFinder( + theirCandidates, theirDestination, selectedByThemCandidate, useTor); + new Thread(connectionFinder).start(); + Futures.addCallback( + connectionFinder.connectionFuture, + new FutureCallback<>() { + @Override + public void onSuccess(final Connection connection) { + final Candidate candidate = connection.candidate; + transportCallback.onCandidateUsed(streamId, candidate); + establishTransport(connection); + } + + @Override + public void onFailure(@NonNull final Throwable throwable) { + if (throwable instanceof CandidateErrorException) { + transportCallback.onCandidateError(streamId); + } + establishTransport(null); + } + }, + MoreExecutors.directExecutor()); + } + + private void establishTransport(final Connection selectedByUs) { + Futures.addCallback( + selectedByThemCandidate, + new FutureCallback<>() { + @Override + public void onSuccess(Connection result) { + establishTransport(selectedByUs, result); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + establishTransport(selectedByUs, null); + } + }, + MoreExecutors.directExecutor()); + } + + private void establishTransport( + final Connection selectedByUs, final Connection selectedByThem) { + final var selection = selectConnection(selectedByUs, selectedByThem); + if (selection == null) { + transportCallback.onTransportSetupFailed(); + return; + } + if (selection.connection.candidate.type == CandidateType.DIRECT) { + Log.d(Config.LOGTAG, "final selection " + selection.connection.candidate); + this.connection = selection.connection; + this.transportCallback.onTransportEstablished(); + } else { + final ListenableFuture proxyActivation; + if (selection.owner == Owner.THEIRS) { + proxyActivation = this.theirProxyActivation; + } else { + proxyActivation = activateProxy(selection.connection.candidate); + } + Log.d(Config.LOGTAG, "waiting for proxy activation"); + Futures.addCallback( + proxyActivation, + new FutureCallback<>() { + @Override + public void onSuccess(final String cid) { + // TODO compare cid to selection.connection.candidate + connection = selection.connection; + transportCallback.onTransportEstablished(); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + Log.d(Config.LOGTAG, "failed to activate proxy"); + } + }, + MoreExecutors.directExecutor()); + } + } + + private ConnectionWithOwner selectConnection( + final Connection selectedByUs, final Connection selectedByThem) { + if (selectedByUs != null && selectedByThem != null) { + if (selectedByUs.candidate.priority == selectedByThem.candidate.priority) { + return initiator + ? new ConnectionWithOwner(selectedByUs, Owner.THEIRS) + : new ConnectionWithOwner(selectedByThem, Owner.OURS); + } else if (selectedByUs.candidate.priority > selectedByThem.candidate.priority) { + return new ConnectionWithOwner(selectedByUs, Owner.THEIRS); + } else { + return new ConnectionWithOwner(selectedByThem, Owner.OURS); + } + } + if (selectedByUs != null) { + return new ConnectionWithOwner(selectedByUs, Owner.THEIRS); + } + if (selectedByThem != null) { + return new ConnectionWithOwner(selectedByThem, Owner.OURS); + } + return null; + } + + private ListenableFuture activateProxy(final Candidate candidate) { + Log.d(Config.LOGTAG, "trying to activate our proxy " + candidate); + final SettableFuture iqFuture = SettableFuture.create(); + final IqPacket proxyActivation = new IqPacket(IqPacket.TYPE.SET); + proxyActivation.setTo(candidate.jid); + final Element query = proxyActivation.addChild("query", Namespace.BYTE_STREAMS); + query.setAttribute("sid", this.streamId); + final Element activate = query.addChild("activate"); + activate.setContent(id.with.toEscapedString()); + xmppConnection.sendIqPacket( + proxyActivation, + (a, response) -> { + if (response.getType() == IqPacket.TYPE.RESULT) { + Log.d(Config.LOGTAG, "our proxy has been activated"); + transportCallback.onProxyActivated(this.streamId, candidate); + iqFuture.set(candidate.cid); + } else if (response.getType() == IqPacket.TYPE.TIMEOUT) { + iqFuture.setException(new TimeoutException()); + } else { + Log.d( + Config.LOGTAG, + a.getJid().asBareJid() + + ": failed to activate proxy on " + + candidate.jid); + iqFuture.setException(new IllegalStateException("Proxy activation failed")); + } + }); + return iqFuture; + } + + private ListenableFuture getOurProxyConnection(final String ourDestination) { + final var proxyFuture = getProxyCandidate(); + return Futures.transformAsync( + proxyFuture, + proxy -> { + final var connectionFinder = + new ConnectionFinder( + ImmutableList.of(proxy), ourDestination, null, useTor); + new Thread(connectionFinder).start(); + return Futures.transform( + connectionFinder.connectionFuture, + c -> { + try { + c.socket.setKeepAlive(true); + Log.d( + Config.LOGTAG, + "set keep alive on our own proxy connection"); + } catch (final SocketException e) { + throw new RuntimeException(e); + } + return c; + }, + MoreExecutors.directExecutor()); + }, + MoreExecutors.directExecutor()); + } + + private ListenableFuture getProxyCandidate() { + if (Config.DISABLE_PROXY_LOOKUP) { + return Futures.immediateFailedFuture( + new IllegalStateException("Proxy look up is disabled")); + } + final Jid streamer = xmppConnection.findDiscoItemByFeature(Namespace.BYTE_STREAMS); + if (streamer == null) { + return Futures.immediateFailedFuture( + new IllegalStateException("No proxy/streamer found")); + } + final IqPacket iqRequest = new IqPacket(IqPacket.TYPE.GET); + iqRequest.setTo(streamer); + iqRequest.query(Namespace.BYTE_STREAMS); + final SettableFuture candidateFuture = SettableFuture.create(); + xmppConnection.sendIqPacket( + iqRequest, + (a, response) -> { + if (response.getType() == IqPacket.TYPE.RESULT) { + final Element query = response.findChild("query", Namespace.BYTE_STREAMS); + final Element streamHost = + query == null + ? null + : query.findChild("streamhost", Namespace.BYTE_STREAMS); + final String host = + streamHost == null ? null : streamHost.getAttribute("host"); + final Integer port = + Ints.tryParse( + Strings.nullToEmpty( + streamHost == null + ? null + : streamHost.getAttribute("port"))); + if (Strings.isNullOrEmpty(host) || port == null) { + candidateFuture.setException( + new IOException("Proxy response is missing attributes")); + return; + } + candidateFuture.set( + new Candidate( + UUID.randomUUID().toString(), + host, + streamer, + port, + 655360 + (initiator ? 0 : 15), + CandidateType.PROXY)); + + } else if (response.getType() == IqPacket.TYPE.TIMEOUT) { + candidateFuture.setException(new TimeoutException()); + } else { + candidateFuture.setException( + new IOException( + "received iq error in response to proxy discovery")); + } + }); + return candidateFuture; + } + + @Override + public OutputStream getOutputStream() throws IOException { + final var connection = this.connection; + if (connection == null) { + throw new IOException("No candidate has been selected yet"); + } + return connection.socket.getOutputStream(); + } + + @Override + public InputStream getInputStream() throws IOException { + final var connection = this.connection; + if (connection == null) { + throw new IOException("No candidate has been selected yet"); + } + return connection.socket.getInputStream(); + } + + @Override + public ListenableFuture asTransportInfo() { + final ListenableFuture> proxyConnections = + getOurProxyConnectionsFuture(); + return Futures.transform( + proxyConnections, + proxies -> { + final var candidateBuilder = new ImmutableList.Builder(); + candidateBuilder.addAll(this.connectionProvider.candidates); + candidateBuilder.addAll(Collections2.transform(proxies, p -> p.candidate)); + final var transportInfo = + new SocksByteStreamsTransportInfo( + this.streamId, candidateBuilder.build()); + return new TransportInfo(transportInfo, null); + }, + MoreExecutors.directExecutor()); + } + + @Override + public ListenableFuture asInitialTransportInfo() { + return Futures.transform( + asTransportInfo(), + ti -> + new InitialTransportInfo( + UUID.randomUUID().toString(), ti.transportInfo, ti.group), + MoreExecutors.directExecutor()); + } + + private ListenableFuture> getOurProxyConnectionsFuture() { + return Futures.catching( + Futures.transform( + this.ourProxyConnection, + Collections::singleton, + MoreExecutors.directExecutor()), + Exception.class, + ex -> { + Log.d(Config.LOGTAG, "could not find a proxy of our own", ex); + return Collections.emptyList(); + }, + MoreExecutors.directExecutor()); + } + + private Collection getOurProxyConnections() { + final var future = getOurProxyConnectionsFuture(); + if (future.isDone()) { + try { + return future.get(); + } catch (final Exception e) { + return Collections.emptyList(); + } + } else { + return Collections.emptyList(); + } + } + + @Override + public void terminate() { + Log.d(Config.LOGTAG, "terminating socks transport"); + this.terminationLatch.countDown(); + final var connection = this.connection; + if (connection != null) { + closeSocket(connection.socket); + } + this.connectionProvider.close(); + } + + @Override + public void setTransportCallback(final Callback callback) { + this.transportCallback = callback; + } + + @Override + public void connect() { + this.connectTheirCandidates(); + } + + @Override + public CountDownLatch getTerminationLatch() { + return this.terminationLatch; + } + + public boolean setCandidateUsed(final String cid) { + final var ourProxyConnections = getOurProxyConnections(); + final var proxyConnection = + Iterables.tryFind(ourProxyConnections, c -> c.candidate.cid.equals(cid)); + if (proxyConnection.isPresent()) { + this.selectedByThemCandidate.set(proxyConnection.get()); + return true; + } + + // the peer selected a connection that is not our proxy. so we can close our proxies + closeConnections(ourProxyConnections); + + final var connection = this.connectionProvider.findPeerConnection(cid); + if (connection.isPresent()) { + this.selectedByThemCandidate.set(connection.get()); + return true; + } else { + Log.d(Config.LOGTAG, "none of the connected candidates has cid " + cid); + return false; + } + } + + public void setCandidateError() { + this.selectedByThemCandidate.setException( + new CandidateErrorException("Remote could not connect to any of our candidates")); + } + + public void setProxyActivated(final String cid) { + this.theirProxyActivation.set(cid); + } + + public void setProxyError() { + this.theirProxyActivation.setException( + new IllegalStateException("Remote could not activate their proxy")); + } + + public void setTheirCandidates(Collection candidates) { + this.theirCandidates = + Ordering.from( + (Comparator) + (o1, o2) -> Integer.compare(o2.priority, o1.priority)) + .immutableSortedCopy(candidates); + } + + private static void closeSocket(final Socket socket) { + try { + socket.close(); + } catch (final IOException e) { + Log.w(Config.LOGTAG, "error closing socket", e); + } + } + + private static class ConnectionProvider implements Runnable { + + private final ExecutorService clientConnectionExecutorService = + Executors.newFixedThreadPool(4); + + private final ImmutableList candidates; + + private final int port; + + private final AtomicBoolean acceptingConnections = new AtomicBoolean(true); + + private ServerSocket serverSocket; + + private final String destination; + + private final ArrayList peerConnections = new ArrayList<>(); + + private ConnectionProvider( + final Jid account, final String destination, final boolean useTor) { + final SecureRandom secureRandom = new SecureRandom(); + this.port = secureRandom.nextInt(60_000) + 1024; + this.destination = destination; + final InetAddress[] localAddresses; + if (Config.USE_DIRECT_JINGLE_CANDIDATES && !useTor) { + localAddresses = + DirectConnectionUtils.getLocalAddresses().toArray(new InetAddress[0]); + } else { + localAddresses = new InetAddress[0]; + } + final var candidateBuilder = new ImmutableList.Builder(); + for (int i = 0; i < localAddresses.length; ++i) { + final var inetAddress = localAddresses[i]; + candidateBuilder.add( + new Candidate( + UUID.randomUUID().toString(), + inetAddress.getHostAddress(), + account, + port, + 8257536 + i, + CandidateType.DIRECT)); + } + this.candidates = candidateBuilder.build(); + } + + @Override + public void run() { + if (this.candidates.isEmpty()) { + Log.d(Config.LOGTAG, "no direct candidates. stopping ConnectionProvider"); + return; + } + try (final ServerSocket serverSocket = new ServerSocket(this.port)) { + this.serverSocket = serverSocket; + while (acceptingConnections.get()) { + final Socket clientSocket; + try { + clientSocket = serverSocket.accept(); + } catch (final SocketException ignored) { + Log.d(Config.LOGTAG, "server socket has been closed."); + return; + } + clientConnectionExecutorService.execute( + () -> acceptClientConnection(clientSocket)); + } + } catch (final IOException e) { + Log.d(Config.LOGTAG, "could not create server socket", e); + } + } + + private void acceptClientConnection(final Socket socket) { + final var localAddress = socket.getLocalAddress(); + final var hostAddress = localAddress == null ? null : localAddress.getHostAddress(); + final var candidate = + Iterables.tryFind(this.candidates, c -> c.host.equals(hostAddress)); + if (candidate.isPresent()) { + acceptingConnections(socket, candidate.get()); + + } else { + closeSocket(socket); + Log.d(Config.LOGTAG, "no local candidate found for connection on " + hostAddress); + } + } + + private void acceptingConnections(final Socket socket, final Candidate candidate) { + final var remoteAddress = socket.getRemoteSocketAddress(); + Log.d( + Config.LOGTAG, + "accepted client connection from " + remoteAddress + " to " + candidate); + try { + socket.setSoTimeout(3000); + final byte[] authBegin = new byte[2]; + final InputStream inputStream = socket.getInputStream(); + final OutputStream outputStream = socket.getOutputStream(); + ByteStreams.readFully(inputStream, authBegin); + if (authBegin[0] != 0x5) { + socket.close(); + } + final short methodCount = authBegin[1]; + final byte[] methods = new byte[methodCount]; + ByteStreams.readFully(inputStream, methods); + if (SocksSocketFactory.contains((byte) 0x00, methods)) { + outputStream.write(new byte[] {0x05, 0x00}); + } else { + outputStream.write(new byte[] {0x05, (byte) 0xff}); + } + final byte[] connectCommand = new byte[4]; + ByteStreams.readFully(inputStream, connectCommand); + if (connectCommand[0] == 0x05 + && connectCommand[1] == 0x01 + && connectCommand[3] == 0x03) { + int destinationCount = inputStream.read(); + final byte[] destination = new byte[destinationCount]; + ByteStreams.readFully(inputStream, destination); + final byte[] port = new byte[2]; + ByteStreams.readFully(inputStream, port); + final String receivedDestination = new String(destination); + final ByteBuffer response = ByteBuffer.allocate(7 + destination.length); + final byte[] responseHeader; + final boolean success; + if (receivedDestination.equals(this.destination)) { + responseHeader = new byte[] {0x05, 0x00, 0x00, 0x03}; + synchronized (this.peerConnections) { + peerConnections.add(new Connection(candidate, socket)); + } + success = true; + } else { + Log.d( + Config.LOGTAG, + "destination mismatch. received " + + receivedDestination + + " (expected " + + this.destination + + ")"); + responseHeader = new byte[] {0x05, 0x04, 0x00, 0x03}; + success = false; + } + response.put(responseHeader); + response.put((byte) destination.length); + response.put(destination); + response.put(port); + outputStream.write(response.array()); + outputStream.flush(); + if (success) { + Log.d( + Config.LOGTAG, + remoteAddress + " successfully connected to " + candidate); + } else { + closeSocket(socket); + } + } + } catch (final IOException e) { + Log.d(Config.LOGTAG, "failed to accept client connection to " + candidate, e); + closeSocket(socket); + } + } + + private static void closeServerSocket(@Nullable final ServerSocket serverSocket) { + if (serverSocket == null) { + return; + } + try { + serverSocket.close(); + } catch (final IOException ignored) { + + } + } + + public Optional findPeerConnection(String cid) { + synchronized (this.peerConnections) { + return Iterables.tryFind( + this.peerConnections, connection -> connection.candidate.cid.equals(cid)); + } + } + + public void close() { + this.acceptingConnections.set(false); // we have probably done this earlier already + closeServerSocket(this.serverSocket); + synchronized (this.peerConnections) { + closeConnections(this.peerConnections); + this.peerConnections.clear(); + } + } + } + + private static void closeConnections(final Iterable connections) { + for (final var connection : connections) { + closeSocket(connection.socket); + } + } + + private static class ConnectionFinder implements Runnable { + + private final SettableFuture connectionFuture = SettableFuture.create(); + + private final ImmutableList candidates; + private final String destination; + + private final ListenableFuture selectedByThemCandidate; + private final boolean useTor; + + private ConnectionFinder( + final ImmutableList candidates, + final String destination, + final ListenableFuture selectedByThemCandidate, + final boolean useTor) { + this.candidates = candidates; + this.destination = destination; + this.selectedByThemCandidate = selectedByThemCandidate; + this.useTor = useTor; + } + + @Override + public void run() { + for (final Candidate candidate : this.candidates) { + final Integer selectedByThemCandidatePriority = + getSelectedByThemCandidatePriority(); + if (selectedByThemCandidatePriority != null + && selectedByThemCandidatePriority > candidate.priority) { + Log.d( + Config.LOGTAG, + "The candidate selected by peer had a higher priority then anything we could try"); + connectionFuture.setException( + new CandidateErrorException( + "The candidate selected by peer had a higher priority then anything we could try")); + return; + } + try { + connectionFuture.set(connect(candidate)); + Log.d(Config.LOGTAG, "connected to " + candidate); + return; + } catch (final IOException e) { + Log.d(Config.LOGTAG, "could not connect to candidate " + candidate); + } + } + connectionFuture.setException( + new CandidateErrorException( + String.format( + Locale.US, + "Gave up after %d candidates", + this.candidates.size()))); + } + + private Connection connect(final Candidate candidate) throws IOException { + final var timeout = 3000; + final Socket socket; + if (useTor) { + Log.d(Config.LOGTAG, "using Tor to connect to candidate " + candidate.host); + socket = SocksSocketFactory.createSocketOverTor(candidate.host, candidate.port); + } else { + socket = new Socket(); + final SocketAddress address = new InetSocketAddress(candidate.host, candidate.port); + socket.connect(address, timeout); + } + socket.setSoTimeout(timeout); + SocksSocketFactory.createSocksConnection(socket, destination, 0); + socket.setSoTimeout(0); + return new Connection(candidate, socket); + } + + private Integer getSelectedByThemCandidatePriority() { + final var future = this.selectedByThemCandidate; + if (future != null && future.isDone()) { + try { + final var connection = future.get(); + return connection.candidate.priority; + } catch (ExecutionException | InterruptedException e) { + return null; + } + } else { + return null; + } + } + } + + public static class CandidateErrorException extends IllegalStateException { + private CandidateErrorException(final String message) { + super(message); + } + } + + private enum Owner { + THEIRS, + OURS + } + + public static class ConnectionWithOwner { + public final Connection connection; + public final Owner owner; + + public ConnectionWithOwner(Connection connection, Owner owner) { + this.connection = connection; + this.owner = owner; + } + } + + public static class Connection { + + public final Candidate candidate; + public final Socket socket; + + public Connection(Candidate candidate, Socket socket) { + this.candidate = candidate; + this.socket = socket; + } + } + + public static class Candidate implements Transport.Candidate { + public final String cid; + public final String host; + public final Jid jid; + public final int port; + public final int priority; + public final CandidateType type; + + public Candidate( + final String cid, + final String host, + final Jid jid, + int port, + int priority, + final CandidateType type) { + this.cid = cid; + this.host = host; + this.jid = jid; + this.port = port; + this.priority = priority; + this.type = type; + } + + public static Candidate of(final Element element) { + Preconditions.checkArgument( + "candidate".equals(element.getName()), + "trying to construct candidate from non candidate element"); + Preconditions.checkArgument( + Namespace.JINGLE_TRANSPORTS_S5B.equals(element.getNamespace()), + "candidate element is in correct namespace"); + final String cid = element.getAttribute("cid"); + final String host = element.getAttribute("host"); + final String jid = element.getAttribute("jid"); + final String port = element.getAttribute("port"); + final String priority = element.getAttribute("priority"); + final String type = element.getAttribute("type"); + if (Strings.isNullOrEmpty(cid) + || Strings.isNullOrEmpty(host) + || Strings.isNullOrEmpty(jid) + || Strings.isNullOrEmpty(port) + || Strings.isNullOrEmpty(priority) + || Strings.isNullOrEmpty(type)) { + throw new IllegalArgumentException("Candidate is missing non optional attribute"); + } + return new Candidate( + cid, + host, + Jid.ofEscaped(jid), + Integer.parseInt(port), + Integer.parseInt(priority), + CandidateType.valueOf(type.toUpperCase(Locale.ROOT))); + } + + @Override + @NonNull + public String toString() { + return MoreObjects.toStringHelper(this) + .add("cid", cid) + .add("host", host) + .add("jid", jid) + .add("port", port) + .add("priority", priority) + .add("type", type) + .toString(); + } + + public Element asElement() { + final var element = new Element("candidate", Namespace.JINGLE_TRANSPORTS_S5B); + element.setAttribute("cid", this.cid); + element.setAttribute("host", this.host); + element.setAttribute("jid", this.jid); + element.setAttribute("port", this.port); + element.setAttribute("priority", this.priority); + element.setAttribute("type", this.type.toString().toLowerCase(Locale.ROOT)); + return element; + } + } + + public enum CandidateType { + DIRECT, + PROXY + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/Transport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/Transport.java new file mode 100644 index 000000000..ce99ac8cc --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/Transport.java @@ -0,0 +1,80 @@ +package eu.siacs.conversations.xmpp.jingle.transports; + +import com.google.common.util.concurrent.ListenableFuture; + +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.Group; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.CountDownLatch; + +public interface Transport { + + OutputStream getOutputStream() throws IOException; + + InputStream getInputStream() throws IOException; + + ListenableFuture asTransportInfo(); + + ListenableFuture asInitialTransportInfo(); + + default void readyToSentAdditionalCandidates() {} + + void terminate(); + + void setTransportCallback(final Callback callback); + + void connect(); + + CountDownLatch getTerminationLatch(); + + interface Callback { + void onTransportEstablished(); + + void onTransportSetupFailed(); + + void onAdditionalCandidate(final String contentName, final Candidate candidate); + + void onCandidateUsed(String streamId, SocksByteStreamsTransport.Candidate candidate); + + void onCandidateError(String streamId); + + void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate); + } + + enum Direction { + SEND, + RECEIVE, + SEND_RECEIVE + } + + class InitialTransportInfo extends TransportInfo { + public final String contentName; + + public InitialTransportInfo( + String contentName, GenericTransportInfo transportInfo, Group group) { + super(transportInfo, group); + this.contentName = contentName; + } + } + + class TransportInfo { + + public final GenericTransportInfo transportInfo; + public final Group group; + + public TransportInfo(final GenericTransportInfo transportInfo, final Group group) { + this.transportInfo = transportInfo; + this.group = group; + } + + public TransportInfo(final GenericTransportInfo transportInfo) { + this.transportInfo = transportInfo; + this.group = null; + } + } + + interface Candidate {} +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java new file mode 100644 index 000000000..e4dd730d5 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java @@ -0,0 +1,632 @@ +package eu.siacs.conversations.xmpp.jingle.transports; + +import static eu.siacs.conversations.xmpp.jingle.WebRTCWrapper.buildConfiguration; +import static eu.siacs.conversations.xmpp.jingle.WebRTCWrapper.logDescription; + +import android.content.Context; +import android.util.Log; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.io.Closeables; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.XmppConnection; +import eu.siacs.conversations.xmpp.jingle.IceServers; +import eu.siacs.conversations.xmpp.jingle.WebRTCWrapper; +import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +import org.webrtc.CandidatePairChangeEvent; +import org.webrtc.DataChannel; +import org.webrtc.IceCandidate; +import org.webrtc.MediaStream; +import org.webrtc.PeerConnection; +import org.webrtc.PeerConnectionFactory; +import org.webrtc.SessionDescription; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.annotation.Nonnull; + +public class WebRTCDataChannelTransport implements Transport { + + private static final int BUFFER_SIZE = 16_384; + private static final int MAX_SENT_BUFFER = 256 * 1024; + + private final ExecutorService executorService = Executors.newSingleThreadExecutor(); + private final ExecutorService localDescriptionExecutorService = + Executors.newSingleThreadExecutor(); + + private final AtomicBoolean readyToSentIceCandidates = new AtomicBoolean(false); + private final Queue pendingOutgoingIceCandidates = new LinkedList<>(); + + private final PipedOutputStream pipedOutputStream = new PipedOutputStream(); + private final WritableByteChannel writableByteChannel = Channels.newChannel(pipedOutputStream); + private final PipedInputStream pipedInputStream = new PipedInputStream(BUFFER_SIZE); + + private final AtomicBoolean connected = new AtomicBoolean(false); + + private final CountDownLatch terminationLatch = new CountDownLatch(1); + + private final Queue stateHistory = new LinkedList<>(); + + private final XmppConnection xmppConnection; + private final Account account; + private PeerConnectionFactory peerConnectionFactory; + private ListenableFuture peerConnectionFuture; + + private ListenableFuture localDescriptionFuture; + + private DataChannel dataChannel; + + private Callback transportCallback; + + private final PeerConnection.Observer peerConnectionObserver = + new PeerConnection.Observer() { + @Override + public void onSignalingChange(PeerConnection.SignalingState signalingState) { + Log.d(Config.LOGTAG, "onSignalChange(" + signalingState + ")"); + } + + @Override + public void onConnectionChange(final PeerConnection.PeerConnectionState state) { + stateHistory.add(state); + Log.d(Config.LOGTAG, "onConnectionChange(" + state + ")"); + if (state == PeerConnection.PeerConnectionState.CONNECTED) { + if (connected.compareAndSet(false, true)) { + executorService.execute(() -> onIceConnectionConnected()); + } + } + if (state == PeerConnection.PeerConnectionState.FAILED) { + final boolean neverConnected = + !stateHistory.contains( + PeerConnection.PeerConnectionState.CONNECTED); + // we want to terminate the connection a) to properly fail if a connection + // drops during a transfer and b) to avoid race conditions if we find a + // connection after failure while waiting for the initiator to replace + // transport + executorService.execute(() -> terminate()); + if (neverConnected) { + executorService.execute(() -> onIceConnectionFailed()); + } + } + } + + @Override + public void onIceConnectionChange( + final PeerConnection.IceConnectionState newState) {} + + @Override + public void onIceConnectionReceivingChange(boolean b) {} + + @Override + public void onIceGatheringChange( + final PeerConnection.IceGatheringState iceGatheringState) { + Log.d(Config.LOGTAG, "onIceGatheringChange(" + iceGatheringState + ")"); + } + + @Override + public void onIceCandidate(final IceCandidate iceCandidate) { + if (readyToSentIceCandidates.get()) { + WebRTCDataChannelTransport.this.onIceCandidate( + iceCandidate.sdpMid, iceCandidate.sdp); + } else { + pendingOutgoingIceCandidates.add(iceCandidate); + } + } + + @Override + public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {} + + @Override + public void onAddStream(MediaStream mediaStream) {} + + @Override + public void onRemoveStream(MediaStream mediaStream) {} + + @Override + public void onDataChannel(final DataChannel dataChannel) { + Log.d(Config.LOGTAG, "onDataChannel()"); + WebRTCDataChannelTransport.this.setDataChannel(dataChannel); + } + + @Override + public void onRenegotiationNeeded() { + Log.d(Config.LOGTAG, "onRenegotiationNeeded"); + } + + @Override + public void onSelectedCandidatePairChanged(CandidatePairChangeEvent event) { + Log.d(Config.LOGTAG, "remote candidate selected: " + event.remote); + Log.d(Config.LOGTAG, "local candidate selected: " + event.local); + } + }; + + private DataChannelWriter dataChannelWriter; + + private void onIceConnectionConnected() { + this.transportCallback.onTransportEstablished(); + } + + private void onIceConnectionFailed() { + final var callback = this.transportCallback; + if (callback == null) { + Log.d( + Config.LOGTAG, + "not calling onTransportSetupFailed(). Transport likely has been replaced"); + return; + } + callback.onTransportSetupFailed(); + } + + private void setDataChannel(final DataChannel dataChannel) { + Log.d(Config.LOGTAG, "the 'receiving' data channel has id " + dataChannel.id()); + this.dataChannel = dataChannel; + this.dataChannel.registerObserver( + new OnMessageObserver() { + @Override + public void onMessage(final DataChannel.Buffer buffer) { + try { + WebRTCDataChannelTransport.this.writableByteChannel.write(buffer.data); + } catch (final IOException e) { + Log.d(Config.LOGTAG, "error writing to output stream"); + } + } + }); + } + + protected void onIceCandidate(final String mid, final String sdp) { + final var candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(sdp, null); + this.transportCallback.onAdditionalCandidate(mid, candidate); + } + + public WebRTCDataChannelTransport( + final Context context, + final XmppConnection xmppConnection, + final Account account, + final boolean initiator) { + PeerConnectionFactory.initialize( + PeerConnectionFactory.InitializationOptions.builder(context) + .setFieldTrials("WebRTC-BindUsingInterfaceName/Enabled/") + .createInitializationOptions()); + this.peerConnectionFactory = PeerConnectionFactory.builder().createPeerConnectionFactory(); + this.xmppConnection = xmppConnection; + this.account = account; + this.peerConnectionFuture = + Futures.transform( + getIceServers(), + iceServers -> createPeerConnection(iceServers, true), + MoreExecutors.directExecutor()); + if (initiator) { + this.localDescriptionFuture = setLocalDescription(); + } + } + + private ListenableFuture> getIceServers() { + if (Config.DISABLE_PROXY_LOOKUP) { + return Futures.immediateFuture(Collections.emptyList()); + } + if (xmppConnection.getFeatures().externalServiceDiscovery()) { + final SettableFuture> iceServerFuture = + SettableFuture.create(); + final IqPacket request = new IqPacket(IqPacket.TYPE.GET); + request.setTo(this.account.getDomain()); + request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY); + xmppConnection.sendIqPacket( + request, + (account, response) -> { + final var iceServers = IceServers.parse(response); + if (iceServers.size() == 0) { + Log.w( + Config.LOGTAG, + account.getJid().asBareJid() + + ": no ICE server found " + + response); + } + iceServerFuture.set(iceServers); + }); + return iceServerFuture; + } else { + return Futures.immediateFuture(Collections.emptyList()); + } + } + + private PeerConnection createPeerConnection( + final List iceServers, final boolean trickle) { + final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers, trickle); + final PeerConnection peerConnection = + requirePeerConnectionFactory() + .createPeerConnection(rtcConfig, peerConnectionObserver); + if (peerConnection == null) { + throw new IllegalStateException("Unable to create PeerConnection"); + } + final var dataChannelInit = new DataChannel.Init(); + dataChannelInit.protocol = "xmpp-jingle"; + final var dataChannel = peerConnection.createDataChannel("test", dataChannelInit); + this.dataChannelWriter = new DataChannelWriter(this.pipedInputStream, dataChannel); + Log.d(Config.LOGTAG, "the 'sending' data channel has id " + dataChannel.id()); + new Thread(this.dataChannelWriter).start(); + return peerConnection; + } + + @Override + public OutputStream getOutputStream() throws IOException { + final var outputStream = new PipedOutputStream(); + this.pipedInputStream.connect(outputStream); + this.dataChannelWriter.pipedInputStreamLatch.countDown(); + return outputStream; + } + + @Override + public InputStream getInputStream() throws IOException { + final var inputStream = new PipedInputStream(BUFFER_SIZE); + this.pipedOutputStream.connect(inputStream); + return inputStream; + } + + @Override + public ListenableFuture asTransportInfo() { + Preconditions.checkState( + this.localDescriptionFuture != null, + "Make sure you are setting initiator description first"); + return Futures.transform( + asInitialTransportInfo(), info -> info, MoreExecutors.directExecutor()); + } + + @Override + public ListenableFuture asInitialTransportInfo() { + return Futures.transform( + localDescriptionFuture, + sdp -> + WebRTCDataChannelTransportInfo.of( + eu.siacs.conversations.xmpp.jingle.SessionDescription.parse( + sdp.description)), + MoreExecutors.directExecutor()); + } + + @Override + public void readyToSentAdditionalCandidates() { + readyToSentIceCandidates.set(true); + while (this.pendingOutgoingIceCandidates.peek() != null) { + final var candidate = pendingOutgoingIceCandidates.poll(); + if (candidate == null) { + continue; + } + onIceCandidate(candidate.sdpMid, candidate.sdp); + } + } + + @Override + public void terminate() { + terminate(this.dataChannel); + this.dataChannel = null; + final var dataChannelWriter = this.dataChannelWriter; + if (dataChannelWriter != null) { + dataChannelWriter.close(); + } + this.dataChannelWriter = null; + final var future = this.peerConnectionFuture; + if (future != null) { + future.cancel(true); + } + try { + final PeerConnection peerConnection = requirePeerConnection(); + terminate(peerConnection); + } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) { + Log.d(Config.LOGTAG, "peer connection was not initialized during termination"); + } + this.peerConnectionFuture = null; + final var peerConnectionFactory = this.peerConnectionFactory; + if (peerConnectionFactory != null) { + peerConnectionFactory.dispose(); + } + this.peerConnectionFactory = null; + closeQuietly(this.pipedOutputStream); + this.terminationLatch.countDown(); + Log.d(Config.LOGTAG, WebRTCDataChannelTransport.class.getSimpleName() + " terminated"); + } + + private static void closeQuietly(final OutputStream outputStream) { + try { + outputStream.close(); + } catch (final IOException ignored) { + + } + } + + private static void terminate(final DataChannel dataChannel) { + if (dataChannel == null) { + Log.d(Config.LOGTAG, "nothing to terminate. data channel is already null"); + return; + } + try { + dataChannel.close(); + } catch (final IllegalStateException e) { + Log.w(Config.LOGTAG, "could not close data channel"); + } + try { + dataChannel.dispose(); + } catch (final IllegalStateException e) { + Log.w(Config.LOGTAG, "could not dispose data channel"); + } + } + + private static void terminate(final PeerConnection peerConnection) { + if (peerConnection == null) { + return; + } + try { + peerConnection.dispose(); + Log.d(Config.LOGTAG, "terminated peer connection!"); + } catch (final IllegalStateException e) { + Log.w(Config.LOGTAG, "could not dispose of peer connection"); + } + } + + @Override + public void setTransportCallback(final Callback callback) { + this.transportCallback = callback; + } + + @Override + public void connect() {} + + @Override + public CountDownLatch getTerminationLatch() { + return this.terminationLatch; + } + + synchronized ListenableFuture setLocalDescription() { + return Futures.transformAsync( + peerConnectionFuture, + peerConnection -> { + if (peerConnection == null) { + return Futures.immediateFailedFuture( + new IllegalStateException("PeerConnection was null")); + } + final SettableFuture future = SettableFuture.create(); + peerConnection.setLocalDescription( + new WebRTCWrapper.SetSdpObserver() { + @Override + public void onSetSuccess() { + future.setFuture(getLocalDescriptionFuture(peerConnection)); + } + + @Override + public void onSetFailure(final String message) { + future.setException( + new WebRTCWrapper.FailureToSetDescriptionException( + message)); + } + }); + return future; + }, + MoreExecutors.directExecutor()); + } + + private ListenableFuture getLocalDescriptionFuture( + final PeerConnection peerConnection) { + return Futures.submit( + () -> { + final SessionDescription description = peerConnection.getLocalDescription(); + WebRTCWrapper.logDescription(description); + return description; + }, + localDescriptionExecutorService); + } + + @Nonnull + private PeerConnectionFactory requirePeerConnectionFactory() { + final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory; + if (peerConnectionFactory == null) { + throw new IllegalStateException("Make sure PeerConnectionFactory is initialized"); + } + return peerConnectionFactory; + } + + @Nonnull + private PeerConnection requirePeerConnection() { + final var future = this.peerConnectionFuture; + if (future != null && future.isDone()) { + try { + return future.get(); + } catch (final InterruptedException | ExecutionException e) { + throw new WebRTCWrapper.PeerConnectionNotInitialized(); + } + } else { + throw new WebRTCWrapper.PeerConnectionNotInitialized(); + } + } + + public static List iceCandidatesOf( + final String contentName, + final IceUdpTransportInfo.Credentials credentials, + final List candidates) { + final ImmutableList.Builder iceCandidateBuilder = + new ImmutableList.Builder<>(); + for (final IceUdpTransportInfo.Candidate candidate : candidates) { + final String sdp; + try { + sdp = candidate.toSdpAttribute(credentials.ufrag); + } catch (final IllegalArgumentException e) { + continue; + } + // TODO mLneIndex should probably not be hard coded + iceCandidateBuilder.add(new IceCandidate(contentName, 0, sdp)); + } + return iceCandidateBuilder.build(); + } + + public void addIceCandidates(final List iceCandidates) { + try { + for (final var candidate : iceCandidates) { + requirePeerConnection().addIceCandidate(candidate); + } + } catch (WebRTCWrapper.PeerConnectionNotInitialized e) { + Log.w(Config.LOGTAG, "could not add ice candidate. peer connection is not initialized"); + } + } + + public void setInitiatorDescription( + final eu.siacs.conversations.xmpp.jingle.SessionDescription sessionDescription) { + final var sdp = + new SessionDescription( + SessionDescription.Type.OFFER, sessionDescription.toString()); + final var setFuture = setRemoteDescriptionFuture(sdp); + this.localDescriptionFuture = + Futures.transformAsync( + setFuture, v -> setLocalDescription(), MoreExecutors.directExecutor()); + } + + public void setResponderDescription( + final eu.siacs.conversations.xmpp.jingle.SessionDescription sessionDescription) { + Log.d(Config.LOGTAG, "setResponder description"); + final var sdp = + new SessionDescription( + SessionDescription.Type.ANSWER, sessionDescription.toString()); + logDescription(sdp); + setRemoteDescriptionFuture(sdp); + } + + synchronized ListenableFuture setRemoteDescriptionFuture( + final SessionDescription sessionDescription) { + return Futures.transformAsync( + this.peerConnectionFuture, + peerConnection -> { + if (peerConnection == null) { + return Futures.immediateFailedFuture( + new IllegalStateException("PeerConnection was null")); + } + final SettableFuture future = SettableFuture.create(); + peerConnection.setRemoteDescription( + new WebRTCWrapper.SetSdpObserver() { + @Override + public void onSetSuccess() { + future.set(null); + } + + @Override + public void onSetFailure(final String message) { + future.setException( + new WebRTCWrapper.FailureToSetDescriptionException( + message)); + } + }, + sessionDescription); + return future; + }, + MoreExecutors.directExecutor()); + } + + private static class DataChannelWriter implements Runnable { + + private final CountDownLatch pipedInputStreamLatch = new CountDownLatch(1); + private final CountDownLatch dataChannelLatch = new CountDownLatch(1); + private final AtomicBoolean isSending = new AtomicBoolean(true); + private final InputStream inputStream; + private final DataChannel dataChannel; + + private DataChannelWriter(InputStream inputStream, DataChannel dataChannel) { + this.inputStream = inputStream; + this.dataChannel = dataChannel; + final StateChangeObserver stateChangeObserver = + new StateChangeObserver() { + + @Override + public void onStateChange() { + if (dataChannel.state() == DataChannel.State.OPEN) { + dataChannelLatch.countDown(); + } + } + }; + this.dataChannel.registerObserver(stateChangeObserver); + } + + public void run() { + try { + this.pipedInputStreamLatch.await(); + this.dataChannelLatch.await(); + final var buffer = new byte[4096]; + while (isSending.get()) { + final long bufferedAmount = dataChannel.bufferedAmount(); + if (bufferedAmount > MAX_SENT_BUFFER) { + Thread.sleep(50); + continue; + } + final int count = this.inputStream.read(buffer); + if (count < 0) { + Log.d(Config.LOGTAG, "DataChannelWriter reached EOF"); + return; + } + send(ByteBuffer.wrap(buffer, 0, count)); + } + } catch (final InterruptedException | InterruptedIOException e) { + if (isSending.get()) { + Log.w(Config.LOGTAG, "DataChannelWriter got interrupted while sending", e); + } + } catch (final IOException e) { + Log.d(Config.LOGTAG, "DataChannelWriter terminated", e); + } finally { + Closeables.closeQuietly(inputStream); + } + } + + private void send(final ByteBuffer byteBuffer) throws IOException { + try { + dataChannel.send(new DataChannel.Buffer(byteBuffer, true)); + } catch (final IllegalStateException e) { + // dataChannel can be 'disposed' if we waited too long between `isSending` check and + // actually trying to send + throw new IOException(e); + } + } + + public void close() { + this.isSending.set(false); + terminate(this.dataChannel); + } + } + + private abstract static class StateChangeObserver implements DataChannel.Observer { + + @Override + public void onBufferedAmountChange(final long change) {} + + @Override + public void onMessage(final DataChannel.Buffer buffer) {} + } + + private abstract static class OnMessageObserver implements DataChannel.Observer { + + @Override + public void onBufferedAmountChange(long l) {} + + @Override + public void onStateChange() {} + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java b/src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java index 62f65ee1f..24b429fd7 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java +++ b/src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java @@ -30,7 +30,7 @@ public class PublishOptions { options.putString("pubsub#persist_items", "true"); options.putString("pubsub#access_model", "whitelist"); options.putString("pubsub#send_last_published_item", "never"); - options.putString("pubsub#max_items", "128"); //YOLO! + options.putString("pubsub#max_items", "max"); options.putString("pubsub#notify_delete", "true"); options.putString("pubsub#notify_retract", "true"); //one could also set notify=true on the retract diff --git a/src/main/res/drawable-hdpi/ic_content_copy_black_24dp.png b/src/main/res/drawable-hdpi/ic_content_copy_black_24dp.png deleted file mode 100644 index 9a9e5706f..000000000 Binary files a/src/main/res/drawable-hdpi/ic_content_copy_black_24dp.png and /dev/null differ diff --git a/src/main/res/drawable-mdpi/ic_content_copy_black_24dp.png b/src/main/res/drawable-mdpi/ic_content_copy_black_24dp.png deleted file mode 100644 index c94cc28f1..000000000 Binary files a/src/main/res/drawable-mdpi/ic_content_copy_black_24dp.png and /dev/null differ diff --git a/src/main/res/drawable-v24/ic_launcher_background.xml b/src/main/res/drawable-v24/ic_launcher_background.xml new file mode 100644 index 000000000..ec3c58ad5 --- /dev/null +++ b/src/main/res/drawable-v24/ic_launcher_background.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/res/drawable-xhdpi/ic_content_copy_black_24dp.png b/src/main/res/drawable-xhdpi/ic_content_copy_black_24dp.png deleted file mode 100644 index 1cf76a960..000000000 Binary files a/src/main/res/drawable-xhdpi/ic_content_copy_black_24dp.png and /dev/null differ diff --git a/src/main/res/drawable-xxhdpi/ic_content_copy_black_24dp.png b/src/main/res/drawable-xxhdpi/ic_content_copy_black_24dp.png deleted file mode 100644 index 074ea8807..000000000 Binary files a/src/main/res/drawable-xxhdpi/ic_content_copy_black_24dp.png and /dev/null differ diff --git a/src/main/res/drawable-xxxhdpi/ic_content_copy_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_content_copy_black_24dp.png deleted file mode 100644 index 1f6af72d0..000000000 Binary files a/src/main/res/drawable-xxxhdpi/ic_content_copy_black_24dp.png and /dev/null differ diff --git a/src/main/res/drawable/ic_logout_white_24dp.xml b/src/main/res/drawable/ic_logout_white_24dp.xml new file mode 100644 index 000000000..5f818ab16 --- /dev/null +++ b/src/main/res/drawable/ic_logout_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/main/res/drawable/ic_play_lesson_black_24.xml b/src/main/res/drawable/ic_play_lesson_black_24.xml new file mode 100644 index 000000000..4c4a46ce7 --- /dev/null +++ b/src/main/res/drawable/ic_play_lesson_black_24.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src/main/res/drawable/ic_play_lesson_white_48dp.xml b/src/main/res/drawable/ic_play_lesson_white_48dp.xml new file mode 100644 index 000000000..67fe7c696 --- /dev/null +++ b/src/main/res/drawable/ic_play_lesson_white_48dp.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src/main/res/drawable/ic_qr_code_black_24dp.xml b/src/main/res/drawable/ic_qr_code_black_24dp.xml new file mode 100644 index 000000000..e33c1a622 --- /dev/null +++ b/src/main/res/drawable/ic_qr_code_black_24dp.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + diff --git a/src/main/res/drawable/ic_qr_code_white_24dp.xml b/src/main/res/drawable/ic_qr_code_white_24dp.xml new file mode 100644 index 000000000..d345816e0 --- /dev/null +++ b/src/main/res/drawable/ic_qr_code_white_24dp.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + diff --git a/src/main/res/layout/activity_contact_details.xml b/src/main/res/layout/activity_contact_details.xml index a7d569a4a..fc7219ee1 100644 --- a/src/main/res/layout/activity_contact_details.xml +++ b/src/main/res/layout/activity_contact_details.xml @@ -186,6 +186,19 @@ android:orientation="vertical" android:padding="@dimen/card_padding_list"/> + + + + + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> - + android:layout_marginBottom="@dimen/activity_vertical_margin"> + app:riv_corner_radius="8dp" /> + app:errorTextAppearance="@style/TextAppearance.Conversations.Design.Error" + app:hintTextAppearance="@style/TextAppearance.Conversations.Design.Hint"> + android:textColor="?attr/edit_text_color" /> @@ -76,21 +77,21 @@ android:id="@+id/account_password_layout" android:layout_width="match_parent" android:layout_height="wrap_content" + app:errorTextAppearance="@style/TextAppearance.Conversations.Design.Error" + app:hintTextAppearance="@style/TextAppearance.Conversations.Design.Hint" app:passwordToggleDrawable="@drawable/visibility_toggle_drawable" app:passwordToggleEnabled="true" - app:passwordToggleTint="?android:textColorSecondary" - app:hintTextAppearance="@style/TextAppearance.Conversations.Design.Hint" - app:errorTextAppearance="@style/TextAppearance.Conversations.Design.Error"> + app:passwordToggleTint="?android:textColorSecondary"> + android:textColor="?attr/edit_text_color" /> + app:errorTextAppearance="@style/TextAppearance.Conversations.Design.Error" + app:hintTextAppearance="@style/TextAppearance.Conversations.Design.Hint"> + android:inputType="textWebEmailAddress" /> @@ -135,16 +136,16 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="@string/account_settings_port" - app:hintTextAppearance="@style/TextAppearance.Conversations.Design.Hint" - app:errorTextAppearance="@style/TextAppearance.Conversations.Design.Error"> + app:errorTextAppearance="@style/TextAppearance.Conversations.Design.Error" + app:hintTextAppearance="@style/TextAppearance.Conversations.Design.Hint"> + android:maxLength="5" /> @@ -155,7 +156,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:text="@string/register_account"/> + android:text="@string/register_account" /> @@ -164,10 +165,10 @@ android:id="@+id/os_optimization" android:layout_width="fill_parent" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/activity_vertical_margin" android:layout_marginLeft="@dimen/activity_horizontal_margin" - android:layout_marginRight="@dimen/activity_horizontal_margin" android:layout_marginTop="@dimen/activity_vertical_margin" + android:layout_marginRight="@dimen/activity_horizontal_margin" + android:layout_marginBottom="@dimen/activity_vertical_margin" android:visibility="gone"> + android:textAppearance="@style/TextAppearance.Conversations.Title" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + android:textColor="?colorAccent" /> @@ -222,10 +223,10 @@ android:id="@+id/stats" android:layout_width="fill_parent" android:layout_height="fill_parent" - android:layout_marginBottom="@dimen/activity_vertical_margin" android:layout_marginLeft="@dimen/activity_horizontal_margin" - android:layout_marginRight="@dimen/activity_horizontal_margin" android:layout_marginTop="@dimen/activity_vertical_margin" + android:layout_marginRight="@dimen/activity_horizontal_margin" + android:layout_marginBottom="@dimen/activity_vertical_margin" android:visibility="gone"> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> @@ -282,7 +283,7 @@ android:ellipsize="end" android:singleLine="true" android:text="@string/server_info_pep" - android:textAppearance="@style/TextAppearance.Conversations.Body1"/> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + tools:ignore="RtlHardcoded" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + tools:ignore="RtlHardcoded" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + tools:ignore="RtlHardcoded" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + tools:ignore="RtlHardcoded" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + tools:ignore="RtlHardcoded" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + tools:ignore="RtlHardcoded" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + tools:ignore="RtlHardcoded" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + tools:ignore="RtlHardcoded" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> + android:textAppearance="@style/TextAppearance.Conversations.Body1" /> @@ -512,14 +513,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/no_name_set_instructions" - android:textAppearance="@style/TextAppearance.Conversations.Body1.Tertiary"/> + android:textAppearance="@style/TextAppearance.Conversations.Body1.Tertiary" /> + android:textAppearance="@style/TextAppearance.Conversations.Caption" /> + android:visibility="visible" /> + android:textAppearance="@style/TextAppearance.Conversations.Fingerprint" /> + android:textAppearance="@style/TextAppearance.Conversations.Caption" /> + android:visibility="visible" /> + android:textAppearance="@style/TextAppearance.Conversations.Fingerprint" /> + android:textAppearance="@style/TextAppearance.Conversations.Caption" /> + android:src="?attr/icon_qr_code" + android:visibility="visible" /> + android:visibility="gone" /> @@ -642,41 +643,83 @@ android:id="@+id/other_device_keys_card" android:layout_width="fill_parent" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/activity_vertical_margin" android:layout_marginLeft="@dimen/activity_horizontal_margin" - android:layout_marginRight="@dimen/activity_horizontal_margin" android:layout_marginTop="@dimen/activity_vertical_margin" + android:layout_marginRight="@dimen/activity_horizontal_margin" + android:layout_marginBottom="@dimen/activity_vertical_margin" android:visibility="gone"> - - + android:orientation="vertical"> + android:orientation="vertical" + android:padding="@dimen/card_padding_list"> -