diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..8efd9b442 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# UTF-8, 4 spaces indentation and no trailing spaces +[*.{java,xml,gradle}] +charset = utf-8 +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +# Opening curly brackets on the same line +# Spaces around operators (a + b) +[*.java] +curly_bracket_next_line = false +spaces_around_operators = true diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..e532ec5c4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,35 @@ +> Following this template is not mandatory, however it will greatly improve +> the chance of getting your issue fixed in less time. Please remove this comment +> before creating the issue. Thanks! + +### Expected behavior +Tell us what should happen + +### Actual behavior +Tell us what happens instead + +### Steps to reproduce +1. +2. +3. + +### Environment + +Kontalk version: (please also specify origin, e.g. F-Droid or Google Play) + +Android version: (please also specify custom ROM name if applicable) + +Device model: + +### Logs + +If you have a system log related to the issue please send it via email at +devteam (at) kontalk.org. Please do not attach it or publish it anywhere because +it may contain sensitive information. +You can produce a log by activating the debug log in Kontalk preferences (go to +maintenance settings), **reproduce the issue** and then use "Send debug log". +That will open your email client with the log ready to be sent to us. + +### Other + +If there is a relevant forum discussion, please link to it here. diff --git a/.github/RELEASE_TEMPLATE.md b/.github/RELEASE_TEMPLATE.md new file mode 100644 index 000000000..e4647a4da --- /dev/null +++ b/.github/RELEASE_TEMPLATE.md @@ -0,0 +1,11 @@ +v@VERSION_NAME@ + +@INTRO@ + +@CHANGELOG@ + +You can see all the details for the issues closed with this release [here](https://github.com/kontalk/androidclient/issues?utf8=%E2%9C%93&q=milestone%3A@VERSION_NAME@%20is%3Aclosed). + +Download apk files here: +- https://zeta.kontalk.net/files/Kontalk-@VERSION_NAME@-googleplay.apk (Google Play flavor) +- https://zeta.kontalk.net/files/Kontalk-@VERSION_NAME@-basic.apk (basic flavor) diff --git a/.gitmodules b/.gitmodules index 147135cc9..acf28e17c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "client-common-java"] path = client-common-java url = https://github.com/kontalk/client-common-java.git -[submodule "emojicon"] - path = emojicon - url = https://github.com/kontalk/emojicon.git diff --git a/.travis.yml b/.travis.yml index c10ae55fb..20151f7b3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,31 @@ language: android +jdk: openjdk8 android: components: - - build-tools-22.0.1 - - android-22 - licences: + - tools + - platform-tools + - tools + - build-tools-26.0.2 + - build-tools-27.0.1 + - build-tools-28.0.2 + - build-tools-28.0.3 + - android-29 + - extra-android-m2repository + - extra-google-m2repository + - extra-google-google_play_services + licenses: - 'android-sdk-license-.+' -script: ./gradlew build +before_install: + - yes | sdkmanager "platforms;android-28" +script: + - ./gradlew clean build jacocoTestReport +after_success: + - bash <(curl -s https://codecov.io/bash) +before_deploy: + - openssl aes-256-cbc -K $encrypted_022658120507_key -iv $encrypted_022658120507_iv -in dist/googleplay-developer-key.json.enc -out dist/googleplay-developer-key.json -d +deploy: + skip_cleanup: true + provider: script + script: ./gradlew publishBeta + on: + tags: true diff --git a/AUTHORS b/AUTHORS deleted file mode 100644 index 6b9b61139..000000000 --- a/AUTHORS +++ /dev/null @@ -1,2 +0,0 @@ -Daniele Ricci -Andrea Cappelli diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..8fc6ef687 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,664 @@ +# Changelog + +## [Next] +### Changed +- Use same preference screen also with no account present + +### Fixed +- Fix crash after account deletion +- Fix crash while reopening registration screen + +## [4.4.0] - 2020-07-04 +### Changed +- Dark theme (#41) +- Sharing to groups (#864) +- Migrate to AndroidX (#1269) +- Support for Android 10 (#1282) +- On-demand export of media to public storage (#1291) +- Improved multiselection experience +- Revamped settings appearance +- Automatically accept invitations from local network users (#1294) +- Removed Google Play donation +- Add tutorial hints (#1014) +- Improve audio recordings quality +- Delete media on message delete (#668) +- Moved the update server list function in account preferences + +### Fixed +- Fix crash on devices without a browser +- Fix message center service crash (#1265) +- Fix emoji popup not visible on Android 4.4 (#1267) +- Fix crash on accessing files on external storage (#1273) +- Fix some registration issues (#1271, #1272, #1274, #1275) +- Fix storage permission issue (#1277) +- Fail gracefully when contacts access is denied (#1281) +- Contact pictures in notification on Android 9+ (#1288) +- Fix deadlock in faulty networks +- Fix audio recordings permission problem on Android 10 +- Fix crash sharing a file we don't have permission for +- Fix crash on some devices generating ECC keys + +## [4.3.1] - 2019-08-24 +### Fixed +- Fix crash caused by database access from multiple threads (#1268) +- Fix crash when managing non-Kontalk users in a group (#1270) + +## [4.3.0] - 2019-08-17 +### Changed +- Group chat indicator in chat list (#818) +- Allow more control over search query +- Introduce more recent and performant SQLite library +- Support for Android Oreo 8.0 (#1215, #1228) +- Force use of foreground service only when absolutely necessary +- Support for multi-window (#1101) +- Improve registration screen (#1209) +- Allow replying to own messages +- Use same emoji type across all screens (#1229) +- Use an event bus for communicating with the message center (#1236) +- Update Smack library to version 4.3.1 +- Generate QR codes in the background (#1237) +- Move some account-related preferences into their own new section +- Ask user to accept service terms if server requests it (#1211) +- Internal refactoring of registration process (#1241) + +### Fixed +- Sort search results in inverted timestamp order (#1223) +- Faster reconnection on faulty networks +- Fix rare crash during PTT recording +- Fix message center not waking up when replying from notification +- Fix messages skipped until next app opening +- Fix message center not shutting down when inactive +- Fix invisible incoming replied messages (#1224) +- Remove unneeded permissions (#1238) +- Fix search items in wrong order (#1232) +- Fix crash on invoking camera app (#1231) +- Fix crash when sending debug log (#1239) +- Fix dangling download notification +- Fix crash when chatting with XMPP users (#1242) +- Fix OpenStreetMap static maps (#1251) +- Fix Google Maps static maps (#1254) +- Fix double upload of media (#1259) +- Fix database upgrade crash when upgrading from 4.1.6 (#1262) +- Fix message center crashing while in background (#1264) +- Picture notification for groups showing group title (#1230) + +## [4.2.0] - 2018-09-04 +### Changed +- Archived chats screen (#941) + +### Fixed +- Fix out of memory issue with emojis (#1185) +- Fix crash on resuming location screen (#1199) +- Do not fix GPS on resume (#1202) +- Fix media messages not being sent (#1214) + +## [4.2.0-beta4] - 2018-08-28 +### Changed +- Massive refactoring of conversations list (#1206) +- Defer message center activities to the background (#1210) + +### Fixed +- Fix crash during contacts sync (#1207) +- Fix crash in places search (#1208) + +## [4.2.0-beta3] - 2018-08-21 +### Fixed +- Fix push notifications not working + +## [4.2.0-beta2] - 2018-08-21 +### Changed +- Notification channels (aka notification categoris) +- Opt-out of server administrator messages (#1132) + +### Fixed +- Fix messages stuck in sending state +- Fix push notifications not working + +## [4.2.0-beta1] - 2018-08-06 +### Changed +- Badge indicators for OEMs supporting it (#292) +- Migrate to Firebase Cloud Messaging (#779) +- Copy multiple messages to clipboard (#841) +- Upgrade to Smack 4.3 (#1058) +- Blind Trust Before Verification (#947) +- Accepted invitation is no longer required to chat (#947) +- More precise Google Maps location (#1197) + +### Fixed +- Fix mixup with keys (#1127, #1171) +- Verify server key automatically (#1130) +- Fix group commands not processed (#1189) +- Fix crash in location view (#1991) +- Workaround Android bug 37032031 (#1192) +- Fix messages not marked as sent (#1193) +- Fix database deadlock (#1194) +- Ask for permission to detect user's phone number (#1201) + +## [4.1.6] - 2018-06-24 +### Fixed +- Temporarily revert to stable emoji version +- Fix public key retrieval +- Fix attachment panel close icon (#1184) + +## [4.1.5] - 2018-06-19 +### Changed +- Some major performance improvements (#1160) + +### Fixed +- Fix rare crash on some devices (#1175, #1182, #1183) +- Fix several issues (#1167, #1169, 1170) + +## [4.1.4] - 2018-03-18 +### Changed +- Typing status in groups (#1152) + +### Fixed +- Camera permission is needed also for external camera app (#1136) +- Allow HTTP connections for media uploads (#1137) +- Keep wake lock during message delivery and incoming processing (#1138) +- Crash with some balloon themes (#1142) +- Direct call requires explicit permission (#1144) +- Permission request loop with Google Maps provider (#1145) +- Crash with notifications on Android 4 (#1146) +- Crash requesting permission in contacts list (#1147) +- Use system-provided time on buggy firmwares (#1149) +- Request storage permission before importing personal key (#1153) +- Inapproriate use of plurals leading to crash (#1154) + +## [4.1.3] - 2018-01-17 +### Changed +- Image preview in notifications (#56) +- Play sound in open chats (#75) +- Support for Android 6 runtime permissions (#617) +- Direct reply for older Android versions (#1001) +- User name in group messages (#1122) + +### Fixed +- Fix error exporting personal key (#1111) +- Fix crash for older Android versions (#1114) +- Fix emoji problem in tablets (#1115) +- Fix upload progress stuck at 100% (#1116) +- Fix CPU eating problem during download/upload (#1117) +- Fix wrong sender in contextual reply (#1118) +- Fix problem on crappy firmwares (#1119) +- Fix performance problem on very big address books (#1120) +- Fix QR codes inside viewport (#1131) + +## [4.1.2] - 2017-12-06 +### Changed +- Contextual reply (#594) + +### Fixed +- Faster contacts sync (#1093) +- Fix crash during private key upload (#1102) +- Fix delay in delivery receipts (#1106) +- Workaround Cyanogenmod/LineageOS bug (#1112) + +## [4.1.1] - 2017-11-13 +### Changed +- Private key transfer (#122, #1090) +- Contact information screen (#456) +- New emoji set and keyboard (#814) +- Drop support for Android 2 (#1073) + +### Fixed +- Crash when searching for places (#1091) +- Fix message delivery delay in some cases (#1094) + +## [4.1.0] - 2017-11-01 +### Changed +- Foreground service notification (#1084) +- Some performance improvements on messages operations + +### Fixed +- Fix bug in smiley to emoji conversion (#1087) +- Fix serious bug in group members list status +- Show contact picture on Motorola Active Display + +## [4.1.0-beta4] - 2017-10-12 +### Changed +- Automatic conversion of text smileys into emojis (#940) + +### Fixed +- Fix compatibility issue with Gajim (#1053) +- Fix rare crash (#1076) +- Fix Google location request loop (#1077) +- Show group name in search results (#1079) + +## [4.1.0-beta3] - 2017-09-28 +### Fixed +- Fix group creation issue (#1067) + +## [4.1.0-beta2] - 2017-09-26 +### Changed +- Support for plain HTTP download URLs (#1071) + +### Fixed +- Workaround for Samsung devices not adhereing to battery optimization API (#1037) +- Fixed regression on status update (#1068) +- Allow use of location when precise location (GPS) is off (#1069) +- Fix inactive state request sent before authentication (#1070) + +## [4.1.0-beta1] - 2017-09-10 +### Changed +- Location sharing (#1004, #1027, #1062) +- Update to Smack 4.2.1 (#957) +- Autotrust (as in verification ignored) first key after an invitation (#866) +- Reuse connection object (#327, #956) +- Remember scrolling position (#853, #938) + +### Fixed +- Fix memory leak (#1022, #1046) +- Proximity wake lock workaround for some devices (#1043) +- Downgrade Glide to version 3 for compatibility with Android 2 (#1047) +- Fix crash in direct share (#1050) +- Cleanup leftovers of abandoned groups (#1051) +- Use Bouncy Castle SHA-1 implementation (#1052) +- Fix crash on Android prior to Nougat (#1060, #1064) + +## [4.0.4] - 2017-07-08 +### Changed +- Reply directly from notification (#774) +- Improve notifications (#774) +- Receive any type of file (#697) +- Show max time of recording (#932) +- Image thumbnails respects original image ratio (#662) +- Show up to three line of preview for unread conversations (#888) +- Listen audio messages from phone speaker (#858) +- Direct share (#820) +- Support for Doze mode (#1030) + +### Fixed +- Duck music when playing audio messages (#967) +- Handle no SIM card scenario (#998) +- Fix exchange images with other XMPP clients (#861) +- Workaround Android bug in encryption (#972) +- Fix clock sync issues (#813) +- Fix photo orientation issue (#927) +- Fix crash when deleting messages (#1005) +- Fix crash during search (#1011) + +## [4.0.3] - 2017-04-22 +### Fixed +- Fix crash on playing audio (#995) +- Fix rare crash (#996) + +## [4.0.2] - 2017-04-22 +### Fixed +- Fix crash on some devices (#994) + +## [4.0.1] - 2017-04-17 +### Changed +- Audio recording limit is now 2m on PTT and 5m on audio dialog +- Improve battery saving in offline mode (#869) +- Improve app navigation (#961) +- Fix group member information (#968) + +### Fixed +- Fix audio files downloaded in wrong directory (#952) +- Fix crash on tablets (#953) +- Fix PTT crash after reaching time limit (#954) +- Fix registration error in rare cases (#963) +- Fix key never marked as trusted (#973) +- Fix upgrades from older versions (#983) +- Fix some crash issues (#955, #958, #965, #975, #976, #977) + +## [4.0.0] - 2017-02-12 +### Changed +- Groups: button for adding users again (for members losing their messages) + +### Fixed +- Do not extract keyboard UI in landscape (#936) +- Fix floating action button animation (#937) +- Keep screen on while playing audio messages (#939) +- Fix crash on tablets (#943) + +## [4.0.0-beta6.1] - 2017-01-23 +### Fixed +- Fix email recipient for debug log (#934) +- Fix sharing pictures and text to Kontalk (#935) + +## [4.0.0-beta6] - 2017-01-22 +### Changed +- Toggle encryption per conversation (#271) +- Internal logging system (#623) + +### Fixed +- Fix group chat issues (#874) +- Handle some Argentinian number special cases (#917) +- Request presence after accepting invitation (#924) +- Show subscription status in group members list (#928) +- Fix missing translations causing crash (#930) +- Fix crash in rare situations (#933) + +## [4.0.0-beta5] - 2016-12-31 +### Changed +- New 2017 icon! +- Better explain what happens when you delete a group thread (#898) +- Difference chat balloon themes for single and group chats (#906) +- Switch to Let's Encrypt certificates (#913) + +### Fixed +- Fix profile picture disappearing in group chat members selection (#895) +- Fix wrong picture orientation on some devices (#899) +- Fix crash when sending square images (#903) +- Fix performance issue when composing messages (#904) +- Fix several stability issues (#901, #905, #908, #911) +- Fix several translation issues + +## [4.0.0-beta4] - 2016-12-11 +### Changed +- New 2017 icon! +- Allow multiple users selection in "add users to group" contexts (#892) +- Use different part message when owner leaves the group + +### Fixed +- Allow add user to group only to owner (#890) +- Fix several fragment issues (#878) + +## [4.0.0-beta3] - 2016-12-09 +### Changed +- Create group: multiselect checkbox (by @acappelli, #886) +- Mark admin of a group (#876) +- Sticky conversations (#887) +- Group info screen: add users button (#875) + +### Fixed +- Fix several group notifications and presentation bugs (#884) + +## [4.0.0-beta2] - 2016-12-06 +### Fixed +- Fix crash for some users (#883) + +## [4.0.0-beta1] - 2016-12-04 +### Changed +- Group chat (#179) +- Ask passphrase on first start if not available (#650) +- Improvement and clarification of some preferences (#749, #823) +- No need to input the phone number anymore when importing the key (#772) +- Preference to opt-out of Crashlytics (#827) +- User-initiated missed call verification (#829) + +### Fixed +- Handle database errors (#46) +- Fix attachments panel crash (#699) +- Fix several tablet issues (#701, #793) +- Fix crash when blocking while offline (#723) +- Fix crash when clicking outside "Load more messages" button (#750) +- Fix search view bug (#752) +- Fix crash on missing external apps (#777, #782, #860) +- Fix user always being away (#787) +- Compress images and generate thumbnail in the background (#789) +- Fix compose activity not receiving correct lifecycle calls (#833) + +## [3.1.10] - 2016-05-14 +### Changed +- Update country flags (#731) + +### Fixed +- Disable notification LED when requested (#722) +- Fix multiple photo upload (#727) +- Fix registration error in Persian locale (#728) +- Check for installed browser (#733) +- Fix crash sending photo from camera (#734) + +## [3.1.9.1] - 2016-05-08 +### Fixed +- Fix Huawei Protected Apps dialog (#720) + +## [3.1.9] - 2016-05-08 +### Changed +- Date stamp style improvements (#702) +- Composer bar style improvements (#713) +- Split preference sections into fragments (#719) + +### Fixed +- Fight Huawei protected apps feature (#670) +- Fix several race conditions (#693, #694, #695, #698, #704, #708) +- Fix SSL/crypto issues (#685, #700, #706, #716) +- Fix non-latin locale SSL issue (#710) +- Fix several other crashes (#709, #711, #714, #715) + +## [3.1.8] - 2016-04-23 +### Changed +- Show message when search returns an empty result set (#647) +- New material icons (#689) + +### Fixed +- Prevent app hanging during registration (#690) + +## [3.1.7] - 2016-04-18 +### Changed +- Update to latest emojis +- New file upload method + +### Fixed +- Fix crash after donating through Google Play (#687) + +## [3.1.6] - 2016-04-14 +### Fixed +- Fix connection error (#676) +- Fix media upload/download (#677) +- Fix personal key export (#678) +- Fix crash on opening (#680) + +## [3.1.5] - 2016-04-07 +### Changed +- Automatic media download (#34) +- Increase play audio button in messages (#669) + +### Fixed +- Fix message width issue in RTL languages (#653) +- Fix contact picture causing crash in older Android versions (#671) +- Fix blocking contact picture retrieval (#673) + +## [3.1.4] - 2016-04-04 +### Changed +- Sent images are now saved into Pictures/Kontalk/Sent (#539) +- Make personal key information copyable (#651) + +### Fixed +- Fix outgoing images that could not be opened in gallery (#539) +- Fix tablet issue with text entry (#583) +- Fix available/away state (#637) +- Fix scroll-to-result when reloading in a search (#641) +- Fix possible crash during registration (#643) +- Fix presence handling (#644) +- Mark as read only notified conversation (#648) +- Correctly detect image rotation (#649) +- Fix truncated text in RTL languages (#653) +- Fix notification action not working (#660) +- Fix contact picture in Android 6 (#664) + +## [3.1.3] - 2016-01-31 +### Changed +- Search bar with new material search widget (#596) + +### Fixed +- Fix regression on tablets (#632) +- Fix regression on search behaviour (#634) +- Fix crash on accepting chat invitation (#638) +- Fix crash on sending a picture in Android 6.0 (#640) + +## [3.1.2] - 2016-01-24 +### Added +- Preference for hiding blocked contacts (#384) +- Resend menu entry for failed messages (#606) +- Mark as read and call actions from notification (Android 4.1+) (#630) + +### Changed +- Custom notification LED color preference (#299) +- Paged message list (1000 messages at a time) (#447) +- Export key trust preferences with personal key pack (#505) +- Export key in user-defined path (#610) +- Update to Smack 4.1.6 (#631) +- New attachment selection menu + +### Fixed +- Jolla/Alien Dalvik compatibility (#616) +- Finally fix unexpected key change warning (#568) +- Fix duplicate entry in Android contacts (#592) +- Fix contact scrolling issues (#593) +- Fix adaptive ping manager (#597) +- Fix contact sorting issues (#601) +- Fix crash when sending multiple images (#607) +- Fix crash when sending media from another app (#625) + +## [3.1.1] - 2015-11-09 +### Added +- Offline indicator on main activity (#481) + +### Changed +- Anticipate users update before completing registration (#572) +- Listen for contact changes in real time (#574) + +### Fixed +- Fix own profile contact integration (#285) +- Unsubscribe from non-existing users (#464) +- Fix contact list showing hash values instead of actual name (#554) +- Fix unexpected key change warning (#568) +- Fix crash on Android 2.x (#570) +- Fix ringtone preference selection (#576) +- Fix possible crash on attachment selection (#582) +- Queue pending messages immediately after invitation is accepted (#588) +- Fix message loss in particular cases (#589) + +## [3.1] - 2015-10-11 +### Added +- New Hangout balloon theme with avatars (#551) + +### Changed +- Material design (#412) +- Show message details on clicking a message (#557) + +### Fixed +- Fix input field covered by warning bar (#542) +- Warn user when cancelling registration (#560) + +## [3.0.6] - 2015-09-18 +### Fixed +- This is an emergency bugfix release + +## [3.0.5] - 2015-09-17 +### Changed +- Verify message timestamp against digital signature (#201) +- New sliding pane layout interface (#543) + +### Fixed +- Resume voice playback after rotation (#473) + +## [3.0.4] - 2015-09-05 +### Added +- Missing call based registration (#544) + +### Fixed +- Fix timestamp inconsistencies (#513, #532) + +## [3.0.3] - 2015-08-15 +### Fixed +- Fix push-to-talk button issue (#515) +- Fix incompatibility issue with Android 2.2 (#528) +- Use correct status message when importing key (#529) +- Fix several crashes (#476, #526, #530, #535) + +## [3.0.2] - 2015-06-25 +### Changed +- Larger input field (#460) + +### Fixed +- Don't hide keyboard if enter is configured as "send" (#451) +- Autoupdate contacts on conversation list (#484) +- Audio recording issues (#508, #515) +- Interrupt backoff if we connect to a new network (#507) +- Several crash fixes (#46, #485, #510, #511, #519) + +## [3.0.1] - 2015-06-28 +### Added +- New help link in settings for the wiki (#472) + +### Changed +- Show when a user has been blocked by you (#239) +- Show when a contact is using an older version of the app (#469) + +### Fixed +- Fix escaping of translated strings (#458) +- Fix crash during audio recording (#459) +- Keep device awake while recording audio (#474) + +### Security +- Fix issues with encryption and signing (#417, #468) + +## [3.0] - 2015-06-14 +### Added +- Credits section (#199) + +### Changed +- Switch to XMPP +- Warn user about registering another device (#338) +- Use name on public key when available (#424) +- Use a more meaningful name for attachments (#429) +- Request last seen on demand (#453) + +### Fixed +- Do a login test when importing the key (#427) +- Fix file system location for attachments (#430) +- Fix wrong key warning when self chatting (#440) +- Fix bugs in push-to-talk message (#441, #442) +- Fix communication with unknown contacts (#446) +- Fix draft saving on incoming message (#448) +- Fix crash during sync (#454) + +[Next]: https://github.com/kontalk/androidclient/compare/v4.4.0...HEAD +[4.4.0]: https://github.com/kontalk/androidclient/compare/v4.3.1...v4.4.0 +[4.3.1]: https://github.com/kontalk/androidclient/compare/v4.3.0...v4.3.1 +[4.3.0]: https://github.com/kontalk/androidclient/compare/v4.2.0...v4.3.0 +[4.2.0]: https://github.com/kontalk/androidclient/compare/v4.2.0-beta4...v4.2.0 +[4.2.0-beta4]: https://github.com/kontalk/androidclient/compare/v4.2.0-beta3...v4.2.0-beta4 +[4.2.0-beta3]: https://github.com/kontalk/androidclient/compare/v4.2.0-beta2...v4.2.0-beta3 +[4.2.0-beta2]: https://github.com/kontalk/androidclient/compare/v4.2.0-beta1...v4.2.0-beta2 +[4.2.0-beta1]: https://github.com/kontalk/androidclient/compare/v4.1.6...v4.2.0-beta1 +[4.1.6]: https://github.com/kontalk/androidclient/compare/v4.1.5...v4.1.6 +[4.1.5]: https://github.com/kontalk/androidclient/compare/v4.1.4...v4.1.5 +[4.1.4]: https://github.com/kontalk/androidclient/compare/v4.1.3...v4.1.4 +[4.1.3]: https://github.com/kontalk/androidclient/compare/v4.1.2...v4.1.3 +[4.1.2]: https://github.com/kontalk/androidclient/compare/v4.1.1...v4.1.2 +[4.1.1]: https://github.com/kontalk/androidclient/compare/v4.1.0...v4.1.1 +[4.1.0]: https://github.com/kontalk/androidclient/compare/v4.1.0-beta4...v4.1.0 +[4.1.0-beta4]: https://github.com/kontalk/androidclient/compare/v4.1.0-beta3...v4.1.0-beta4 +[4.1.0-beta3]: https://github.com/kontalk/androidclient/compare/v4.1.0-beta2...v4.1.0-beta3 +[4.1.0-beta2]: https://github.com/kontalk/androidclient/compare/v4.1.0-beta1...v4.1.0-beta2 +[4.1.0-beta1]: https://github.com/kontalk/androidclient/compare/v4.0.4...v4.1.0-beta1 +[4.0.4]: https://github.com/kontalk/androidclient/compare/v4.0.3...v4.0.4 +[4.0.3]: https://github.com/kontalk/androidclient/compare/v4.0.2...v4.0.3 +[4.0.2]: https://github.com/kontalk/androidclient/compare/v4.0.1...v4.0.2 +[4.0.1]: https://github.com/kontalk/androidclient/compare/v4.0.0...v4.0.1 +[4.0.0]: https://github.com/kontalk/androidclient/compare/v4.0.0-beta6.1...v4.0.0 +[4.0.0-beta6.1]: https://github.com/kontalk/androidclient/compare/v4.0.0-beta6...v4.0.0-beta6.1 +[4.0.0-beta6]: https://github.com/kontalk/androidclient/compare/v4.0.0-beta5...v4.0.0-beta6 +[4.0.0-beta5]: https://github.com/kontalk/androidclient/compare/v4.0.0-beta4...v4.0.0-beta5 +[4.0.0-beta4]: https://github.com/kontalk/androidclient/compare/v4.0.0-beta3...v4.0.0-beta4 +[4.0.0-beta3]: https://github.com/kontalk/androidclient/compare/v4.0.0-beta2...v4.0.0-beta3 +[4.0.0-beta2]: https://github.com/kontalk/androidclient/compare/v4.0.0-beta1...v4.0.0-beta2 +[4.0.0-beta1]: https://github.com/kontalk/androidclient/compare/v3.1.10...v4.0.0-beta1 +[3.1.10]: https://github.com/kontalk/androidclient/compare/v3.1.9.1...v3.1.10 +[3.1.9.1]: https://github.com/kontalk/androidclient/compare/v3.1.9...v3.1.9.1 +[3.1.9]: https://github.com/kontalk/androidclient/compare/v3.1.8...v3.1.9 +[3.1.8]: https://github.com/kontalk/androidclient/compare/v3.1.7...v3.1.8 +[3.1.7]: https://github.com/kontalk/androidclient/compare/v3.1.6...v3.1.7 +[3.1.6]: https://github.com/kontalk/androidclient/compare/v3.1.5...v3.1.6 +[3.1.5]: https://github.com/kontalk/androidclient/compare/v3.1.4...v3.1.5 +[3.1.4]: https://github.com/kontalk/androidclient/compare/v3.1.3...v3.1.4 +[3.1.3]: https://github.com/kontalk/androidclient/compare/v3.1.2...v3.1.3 +[3.1.2]: https://github.com/kontalk/androidclient/compare/v3.1.1...v3.1.2 +[3.1.1]: https://github.com/kontalk/androidclient/compare/v3.1...v3.1.1 +[3.1]: https://github.com/kontalk/androidclient/compare/v3.1-beta3...v3.1 +[3.1-beta3]: https://github.com/kontalk/androidclient/compare/v3.0.6...v3.1-beta3 +[3.0.6]: https://github.com/kontalk/androidclient/compare/v3.0.5...v3.0.6 +[3.0.5]: https://github.com/kontalk/androidclient/compare/v3.0.4...v3.0.5 +[3.0.4]: https://github.com/kontalk/androidclient/compare/v3.1-beta2...v3.0.4 +[3.1-beta2]: https://github.com/kontalk/androidclient/compare/v3.1-beta1...v3.1-beta2 +[3.1-beta1]: https://github.com/kontalk/androidclient/compare/v3.0.3...v3.1-beta1 +[3.0.3]: https://github.com/kontalk/androidclient/compare/v3.0.2...v3.0.3 +[3.0.2]: https://github.com/kontalk/androidclient/compare/v3.0.1...v3.0.2 +[3.0.1]: https://github.com/kontalk/androidclient/compare/v3.0...v3.0.1 +[3.0]: https://github.com/kontalk/androidclient/compare/v3.0-rc4...v3.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..54b37f10b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# How to contribute + +Kontalk is a community project and therefore relies on contributions. +There are several ways to contribute to Kontalk, even if you are not a +programmer. There are a lot of things to do. Really a lot. + +## Reporting bugs + +Like all humans, we are not perfect. As software is written by humans, it is not +perfect either. Bugs can happen. We don't have a testing team and we mainly +rely on automed tests (still covering a tiny part of the app though) and +automated crash reports. You can help by reporting issues and malfunctions +on [GitHub issue tracking system](//github.com/kontalk/androidclient/issues/new). + +## Submitting patches + +Pull requests are very welcome! Here a simple set of rules to make your patch +accepted more quickly: + +* **It should contain just one thing/feature/fix/whatever** +* Summarize the contents of the patch in the pull request description +* Go into specific details in commit messages +* Try to squash working commits into one or more significant commits +* Use our [editorconfig](.editorconfig) rules +* Give it a `./gradlew :app:checkstyle` to be sure everything is nice + +If you don't know what patch to contribute, you may start by looking at +issues labeled with [help wanted](//github.com/kontalk/androidclient/labels/help%20wanted) +or [good first issue](//github.com/kontalk/androidclient/labels/good%20first%20issue). + +## Translating + +Translating this awesome app is also a way of contributing. You know your mother +tongue better than anyone else and here is your chance to make Kontalk even a +better app, in your own language. +You may [create an account on our translation portal](https://translate.kontalk.org/engage/kontalk-androidclient/) +and start translating right away! + +## Discuss in our forum + +[Our forum](https://forum.kontalk.org/) is a place full of people sharing your +interest for Kontalk. There you will find us (the developers) and other users +that can help you discuss issues, ideas, new features. diff --git a/README.md b/README.md index 6d14e1c26..81ec2cc56 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Kontalk official Android client =============================== [![Build Status](https://travis-ci.org/kontalk/androidclient.svg?branch=master)](https://travis-ci.org/kontalk/androidclient) -[![Translation status](http://translate.kontalk.org/widgets/kontalk-androidclient/-/svg-badge.svg)](https://translate.kontalk.org/engage/kontalk-androidclient/?utm_source=widget) +[![Translation status](https://translate.kontalk.org/widgets/kontalk-androidclient/-/svg-badge.svg)](https://translate.kontalk.org/engage/kontalk-androidclient/?utm_source=widget) This is the official Android client for the Kontalk messaging system. It always implements the latest protocol. @@ -10,10 +10,46 @@ All releases are available in the [Releases](//github.com/kontalk/androidclient/ Stable versions are available through Google Play Store and F-Droid: Get it on Google Play + alt="Get it on Google Play" height="80" + src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" /> Available on F-Droid + alt="Get it on F-Droid" height="80" + src="https://f-droid.org/badge/get-it-on.png" /> -Help us translate Kontalk! Sign up for a free account on our translation portal: + +### Community support + +Developers and users can discuss about Kontalk through: + +* Forum: https://forum.kontalk.org/ +* IRC: [Freenode #kontalk](irc://chat.freenode.net/kontalk) + + +### Contribute + +Please refer to our [contribution guidelines](CONTRIBUTING.md) to know how to contribute to Kontalk. + + +### Translations + +Help us translate Kontalk! Sign up for a free account on our translation portal: https://translate.kontalk.org/engage/kontalk-androidclient/ + + +### Cryptography Notice + +This distribution includes cryptographic software. The country in which you +currently reside may have restrictions on the import, possession, use, and/or +re-export to another country, of encryption software. BEFORE using any +encryption software, please check your country's laws, regulations and policies +concerning the import, possession, or use, and re-export of encryption software, +to see if this is permitted. See http://www.wassenaar.org/ for more information. + + +### Licensing + +Kontalk is licensed under the [GPLv3](http://www.gnu.org/licenses/gpl-3.0.html). + +Google Material icons are licensed by Google under [CC-BY 4.0](http://creativecommons.org/licenses/by/4.0/). + +Balloon themes are licensed by [@relan](https://github.com/relan) under [CC-BY-SA 4.0](http://creativecommons.org/licenses/by-sa/4.0/). diff --git a/THANKS b/THANKS deleted file mode 100644 index 55c4459d9..000000000 --- a/THANKS +++ /dev/null @@ -1,11 +0,0 @@ -Giovanni Palleschi - * Spanish translation - -Daniele Loddo - * Testing - -André Landwehr - * German translation - -Giovanni Lauricella - * New icon design diff --git a/app/build.gradle b/app/build.gradle index 6e33ebdee..cdc0b80f4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,43 +1,109 @@ -apply plugin: 'com.android.application' -apply plugin: 'checkstyle' +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-android-extensions' + id 'checkstyle' + id 'com.github.triplet.play' version '2.7.2' + id 'androidsvgdrawable' + id 'io.fabric' + // waiting for https://github.com/arturdm/jacoco-android-gradle-plugin/pull/75 + //id 'jacoco-android' + id 'com.hiya.jacoco-android' +} check.dependsOn 'checkstyle' -task checkstyle(type: Checkstyle){ +task checkstyle(type: Checkstyle) { source 'src' include '**/*.java' // code from Google/AOSP exclude 'main/java/org/kontalk/util/ThumbnailUtils.java', 'main/java/org/kontalk/crypto/PRNGFixes.java', 'main/java/org/kontalk/ui/AccountAuthenticatorActionBarActivity.java', - 'main/java/org/kontalk/billing/*.java', - '*/java/org/kontalk/billing/GoogleBillingService.java', - '*/java/org/kontalk/billing/Inventory.java', - '*/java/org/kontalk/billing/ProductDetails.java', - '*/java/org/kontalk/billing/Purchase.java' + 'main/java/org/kontalk/ui/prefs/TextMessagePreference.java' // 3rd-party code - exclude '**/IconContextMenu.java', '**/CircularSeekBar.java', - '**/KeyboardAwareRelativeLayout.java', '**/FrameLayoutFixed.java' + exclude '**/CircularSeekBar.java', + '**/FrameLayoutFixed.java', + '**/AbsListViewScrollDetector.java', + '**/java/org/kontalk/client/smack/**', + '**/ProximityScreenLocker*.java', + '**/CustomSimpleXmppStringprep.java', + 'main/java/com/**', + 'androidTest/java/net/**' def configProps = ['baseDir': projectDir.absolutePath] configProperties configProps classpath = files() } +checkstyle { + configFile = new File(projectDir, "config/checkstyle/checkstyle.xml") +} + +task generateThemeDrawables(type: fr.avianey.androidsvgdrawable.gradle.SvgDrawableTask) { + from = files('../artwork/themes') + to = file('build/generated/res/themes') + createMissingDirectories = true + overwriteMode = 'ifModified' + targetedDensities = ['ldpi', 'hdpi', 'mdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi'] + ninePatchConfig = file('../artwork/themes/9patch.json') + outputFormat = 'PNG' + outputType = 'drawable' +} + +jacoco { + toolVersion = "0.8.4" +} + +tasks.withType(Test) { + jacoco.includeNoLocationClasses = true +} + android { - compileSdkVersion 22 - buildToolsVersion '22.0.1' + compileSdkVersion project.compileSdkVersion defaultConfig { - applicationId "org.kontalk" - versionCode 53 - versionName "3.0.1-preview" - targetSdkVersion 22 + applicationId project.applicationId + versionCode project.versionCode + versionName project.versionName + targetSdkVersion project.targetSdkVersion + minSdkVersion project.minSdkVersion + resConfigs "en", "de", "fr", "it", "es", "ca", "cs", "el", "fa", "gl", "ja", "nl", "pt", "pt-rBR", "ru", "sr", "zh-rCN", "ar", "hi", "tr", "nb-rNO" + resValue "string", "application_id", applicationId resValue "string", "account_type", applicationId + '.account' - buildConfigField "String", "ACCOUNT_TYPE", '"org.kontalk.account"' + buildConfigField "String", "ACCOUNT_TYPE", '"' + applicationId + '.account"' + vectorDrawables.useSupportLibrary = true + multiDexEnabled true + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + javaCompileOptions { + annotationProcessorOptions { + arguments = [ eventBusIndex : 'org.kontalk.util.EventBusIndex' ] + } + } + + // for testing + buildConfigField "java.util.concurrent.atomic.AtomicBoolean", "TESTING", "new java.util.concurrent.atomic.AtomicBoolean(false)" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } - if (project.hasProperty('RELEASE_STORE_FILE')) { - signingConfigs { + sourceSets { + main.res.srcDirs = [main.res.srcDirs, "build/generated/res/themes"] + } + + signingConfigs { + if (travisBuild) { + release { + storeFile rootProject.file('dist/release.keystore') + storePassword System.getenv("RELEASE_STORE_PASSWORD") + keyAlias System.getenv("RELEASE_KEY_ALIAS") + keyPassword System.getenv("RELEASE_KEY_PASSWORD") + } + } + else if (project.hasProperty('RELEASE_STORE_FILE')) { release { storeFile file(RELEASE_STORE_FILE) storePassword RELEASE_STORE_PASSWORD @@ -47,17 +113,36 @@ android { } } + flavorDimensions "all" + buildTypes { release { minifyEnabled true - proguardFile 'proguard.cfg' - if (project.hasProperty('RELEASE_STORE_FILE')) + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.cfg' + if (project.hasProperty('RELEASE_STORE_FILE') || travisBuild) signingConfig signingConfigs.release } debug { + applicationIdSuffix '.debug' + resValue "string", "application_id", defaultConfig.applicationId + '.debug' + resValue "string", "account_type", defaultConfig.applicationId + '.debug.account' + buildConfigField "String", "ACCOUNT_TYPE", '"' + defaultConfig.applicationId + '.debug.account"' + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.cfg' + testProguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.cfg', 'proguard-test.cfg' + ext.enableCrashlytics = false + ext.alwaysUpdateBuildId = false + testCoverageEnabled false } } + dexOptions { + // skip pre-dexing when running on Travis CI or when disabled via -Dpre-dex=false. + preDexLibraries = preDexEnabled && !travisBuild + } + lintOptions { checkReleaseBuilds false abortOnError false @@ -65,42 +150,151 @@ android { testOptions { unitTests.returnDefaultValues = true + animationsDisabled = true } productFlavors { basic { - minSdkVersion 8 + ext.enableCrashlytics = false } googleplay { - minSdkVersion 9 } } } dependencies { - compile (project(':client-common-java')) { + api (project(':client-common-java')) { exclude group: 'net.sf.kxml' } - compile project(':emojicon:library') - - compile 'com.android.support:appcompat-v7:20.0.0' - compile 'com.madgag.spongycastle:core:1.51.0.0' - compile 'com.madgag.spongycastle:prov:1.51.0.0' - compile 'com.madgag.spongycastle:pkix:1.51.0.0' - compile 'com.madgag.spongycastle:pg:1.51.0.0' - compile 'com.googlecode.libphonenumber:libphonenumber:7.0.4' - compile 'com.nineoldandroids:library:2.4.0' - compile 'com.google.zxing:core:3.2.0' - compile 'com.segment.backo:backo:1.0.0' - compile "org.igniterealtime.smack:smack-android:$smackVersion" - compile "org.igniterealtime.smack:smack-core:$smackVersion" - compile "org.igniterealtime.smack:smack-tcp:$smackVersion" - compile "org.igniterealtime.smack:smack-extensions:$smackVersion" - compile "org.igniterealtime.smack:smack-experimental:$smackVersion" - compile "org.igniterealtime.smack:smack-android-extensions:$smackVersion" - - googleplayCompile 'com.google.android.gms:play-services-base:6.5.87' - - testCompile 'junit:junit:4.+' + + // support libraries + implementation 'androidx.multidex:multidex:2.0.1' + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'com.google.android.material:material:1.2.1' + implementation 'androidx.gridlayout:gridlayout:1.0.0' + implementation 'androidx.exifinterface:exifinterface:1.3.1' + implementation 'androidx.recyclerview:recyclerview:1.1.0' + implementation 'androidx.recyclerview:recyclerview-selection:1.1.0-rc03' + implementation 'androidx.dynamicanimation:dynamicanimation:1.0.0' + // these are actually to override an emoji dependency + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.vectordrawable:vectordrawable-animated:1.1.0' + implementation 'androidx.legacy:legacy-support-v13:1.0.0' + implementation "androidx.core:core-ktx:1.3.2" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" + + // network/protocol libraries + implementation "org.igniterealtime.smack:smack-tcp:$smackVersion" + implementation "org.igniterealtime.smack:smack-experimental:$smackVersion" + implementation "org.igniterealtime.smack:smack-android:$smackVersion" + implementation 'info.guardianproject.netcipher:netcipher:1.2.1' + implementation 'com.squareup.okhttp3:okhttp:4.5.0' + implementation 'com.segment.backo:backo:1.0.0' + implementation 'com.google.code.gson:gson:2.8.6' + + // encryption libraries + implementation ("org.bouncycastle:bcprov-jdk15on:$bouncyCastleVersion") { + exclude group: 'junit' + } + implementation ("org.bouncycastle:bcpkix-jdk15on:$bouncyCastleVersion") { + exclude group: 'junit' + } + implementation ("org.bouncycastle:bcpg-jdk15on:$bouncyCastleVersion") { + exclude group: 'junit' + } + + // storage/persistence libraries + implementation 'androidx.sqlite:sqlite:2.1.0' + implementation 'io.requery:sqlite-android:3.29.0' + + // UI libraries + implementation 'pub.devrel:easypermissions:3.0.0' + implementation 'com.github.machinarius:preferencefragment:0.1.2' + // TODO MaterialDialogs broke the API in version 3 + implementation 'com.afollestad.material-dialogs:core:0.9.6.0' + implementation 'com.afollestad.material-dialogs:commons:0.9.6.0' + implementation 'com.github.castorflex.smoothprogressbar:library:1.1.0' + implementation 'com.nispok:snackbar:2.11.0' + implementation 'com.github.bumptech.glide:glide:4.11.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' + implementation 'com.github.daniele-athome:FloatingActionButton:2e5bbbf155' + implementation 'com.github.vlivanov:ListViewVariants:f606578467' + implementation 'com.vanniktech:emoji-ios:0.6.0' + // waiting for our pull request to be accepted + implementation 'com.github.kontalk:CircularReveal:bc59b8dfcf' + implementation 'me.leolin:ShortcutBadger:1.1.22@aar' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + implementation 'androidx.paging:paging-runtime-ktx:2.1.2' + implementation 'androidx.preference:preference-ktx:1.1.1' + implementation 'de.hdodenhof:circleimageview:3.1.0' + implementation 'com.getkeepsafe.taptargetview:taptargetview:1.13.0' + + // other libraries + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.12' + // TODO version 3.4 requires API level 24 (Java 8) + implementation 'com.google.zxing:core:3.3.3' + implementation 'me.dm7.barcodescanner:zxing:1.9.8' + implementation 'com.github.instacart.truetime-android:library:3.3' + implementation 'org.greenrobot:eventbus:3.2.0' + annotationProcessor 'org.greenrobot:eventbus-annotation-processor:3.2.0' + implementation 'com.jakewharton:butterknife:10.2.1' + annotationProcessor 'com.jakewharton:butterknife-compiler:10.2.1' + + // maps libraries + implementation "com.github.kontalk.AnyMaps:anymaps-base:$anyMapsVersion" + implementation "com.github.kontalk.AnyMaps:anymaps-osm:$anyMapsVersion" + googleplayImplementation "com.github.kontalk.AnyMaps:anymaps-google:$anyMapsVersion" + + // Google Play Services + googleplayImplementation "com.google.android.gms:play-services-base:17.5.0" + googleplayImplementation "com.google.android.gms:play-services-maps:17.0.0" + googleplayImplementation "com.google.android.gms:play-services-location:17.1.0" + googleplayImplementation 'com.google.firebase:firebase-core:18.0.0' + googleplayImplementation 'com.google.firebase:firebase-messaging:21.0.0' + googleplayImplementation('com.crashlytics.sdk.android:crashlytics:2.10.1@aar') { + transitive = true + } + + // test dependencies + testImplementation 'junit:junit:4.13' + androidTestImplementation 'com.android.support:multidex-instrumentation:1.0.3' + androidTestImplementation 'androidx.test:core:1.3.0' + androidTestImplementation 'androidx.test:runner:1.3.0' + androidTestImplementation 'androidx.test:rules:1.3.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.2' + androidTestImplementation 'androidx.annotation:annotation:1.1.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + androidTestImplementation 'androidx.test.espresso:espresso-intents:3.3.0' + androidTestImplementation 'androidx.test:monitor:1.3.0' + androidTestImplementation 'com.jayway.android.robotium:robotium-solo:5.6.3' +} + +if (travisBuild) { + play { + track = 'beta' + releaseStatus = 'completed' + serviceAccountCredentials = rootProject.file('dist/googleplay-developer-key.json') + } + + task publishBeta(dependsOn: ['publishGoogleplayRelease']) { + group = 'Kontalk' + description = 'Publish to Google Play beta channel' + } + publishBeta.dependsOn 'testGoogleplayReleaseUnitTest' +} +else { + play { + track = 'alpha' + releaseStatus = 'draft' + serviceAccountCredentials = rootProject.file('dist/googleplay-developer-key.json') + } +} + +apply plugin: 'com.google.gms.google-services' + +googleServices { + // temporary workaround for Smack dependency problem + disableVersionCheck = true } diff --git a/app/config/checkstyle/checkstyle.xml b/app/config/checkstyle/checkstyle.xml index bae4e506b..5aeabb8f6 100644 --- a/app/config/checkstyle/checkstyle.xml +++ b/app/config/checkstyle/checkstyle.xml @@ -1,7 +1,7 @@ + "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" + "https://checkstyle.org/dtds/configuration_1_3.dtd"> diff --git a/app/config/header.txt b/app/config/header.txt index 26082252e..0a3e7fbff 100644 --- a/app/config/header.txt +++ b/app/config/header.txt @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 000000000..b986ad02c --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,73 @@ +{ + "project_info": { + "project_number": "266408146310", + "firebase_url": "https://kontalk-168810.firebaseio.com", + "project_id": "kontalk-168810", + "storage_bucket": "kontalk-168810.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:266408146310:android:f641231f2f1ff0b0", + "android_client_info": { + "package_name": "org.kontalk" + } + }, + "oauth_client": [ + { + "client_id": "266408146310-7nvb5q1hvo52j95n76osbqtldvn2ulji.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBpL4DBXyd3kgZLwxg2aiRscIKDR4kCWa0" + } + ], + "services": { + "analytics_service": { + "status": 1 + }, + "appinvite_service": { + "status": 1, + "other_platform_oauth_client": [] + }, + "ads_service": { + "status": 2 + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:266408146310:android:c5cf675b43e7f86f", + "android_client_info": { + "package_name": "org.kontalk.debug" + } + }, + "oauth_client": [ + { + "client_id": "266408146310-7nvb5q1hvo52j95n76osbqtldvn2ulji.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBpL4DBXyd3kgZLwxg2aiRscIKDR4kCWa0" + } + ], + "services": { + "analytics_service": { + "status": 1 + }, + "appinvite_service": { + "status": 1, + "other_platform_oauth_client": [] + }, + "ads_service": { + "status": 2 + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 000000000..ffec9f86a --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/proguard-test.cfg b/app/proguard-test.cfg new file mode 100644 index 000000000..e52635381 --- /dev/null +++ b/app/proguard-test.cfg @@ -0,0 +1,22 @@ +# ProGuard configuration file for tests +# used with proguard-android.txt and proguard.cfg + +-ignorewarnings + +-keepattributes *Annotation* + +-keep class junit.** { *; } +-keep class org.junit.** { *; } +-keep class androidx.test.** { *; } +-keep class org.hamcrest.** { *; } + +-dontwarn junit.** +-dontnote junit.** +-dontwarn androidx.test.** +-dontnote androidx.test.** +-dontwarn org.junit.** +-dontnote org.junit.** +-dontwarn org.hamcrest.** +-dontnote org.hamcrest.** +-dontwarn com.squareup.** +-dontnote com.squareup.** diff --git a/app/proguard.cfg b/app/proguard.cfg index 4af9ddab9..fb2e2e407 100644 --- a/app/proguard.cfg +++ b/app/proguard.cfg @@ -1,63 +1,5 @@ -# BEGIN ANDROID TEMPLATE -# This is a configuration file for ProGuard. -# http://proguard.sourceforge.net/index.html#manual/usage.html - --dontusemixedcaseclassnames --dontskipnonpubliclibraryclasses --verbose - -# Optimization is turned off by default. Dex does not like code run -# through the ProGuard optimize and preverify steps (and performs some -# of these optimizations on its own). --dontoptimize --dontpreverify -# Note that if you want to enable optimization, you cannot just -# include optimization flags in your own project configuration file; -# instead you will need to point to the -# "proguard-android-optimize.txt" file instead of this one from your -# project.properties file. - --keepattributes *Annotation* --keep public class com.google.vending.licensing.ILicensingService --keep public class com.android.vending.licensing.ILicensingService - -# For native methods, see http://proguard.sourceforge.net/manual/examples.html#native --keepclasseswithmembernames class * { - native ; -} - -# keep setters in Views so that animations can still work. -# see http://proguard.sourceforge.net/manual/examples.html#beans --keepclassmembers public class * extends android.view.View { - void set*(***); - *** get*(); -} - -# We want to keep methods in Activity that could be used in the XML attribute onClick --keepclassmembers class * extends android.app.Activity { - public void *(android.view.View); -} - -# For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations --keepclassmembers enum * { - public static **[] values(); - public static ** valueOf(java.lang.String); -} - --keep class * implements android.os.Parcelable { - public static final android.os.Parcelable$Creator *; -} - --keepclassmembers class **.R$* { - public static ; -} - -# The support library contains references to newer platform versions. -# Don't warn about those in case this app is linking against an older -# platform version. We know about them, and they are safe. --dontwarn android.support.** - -# END ANDROID TEMPLATE +# ProGuard configuration file +# used with proguard-android.txt # we don't want to obfuscate - we are open source! -dontobfuscate @@ -65,42 +7,141 @@ # missing package names on Android -dontwarn javax.naming.** -dontwarn sun.net.spi.** +-dontwarn sun.misc.** +-dontwarn org.xmlpull.v1.** +-dontwarn java.lang.management.** + +-keepattributes *Annotation*, Signature, Exception # keep all of Kontalk --keep class org.kontalk.** { - *; -} +-keep class org.kontalk.** { *; } + +# keep Bouncy Castle +-keep class org.bouncycastle.crypto.* { *; } +-keep class org.bouncycastle.crypto.agreement.** { *; } +-keep class org.bouncycastle.crypto.digests.* { *; } +-keep class org.bouncycastle.crypto.ec.* { *; } +-keep class org.bouncycastle.crypto.encodings.* { *; } +-keep class org.bouncycastle.crypto.engines.* { *; } +-keep class org.bouncycastle.crypto.macs.* { *; } +-keep class org.bouncycastle.crypto.modes.* { *; } +-keep class org.bouncycastle.crypto.paddings.* { *; } +-keep class org.bouncycastle.crypto.params.* { *; } +-keep class org.bouncycastle.crypto.prng.* { *; } +-keep class org.bouncycastle.crypto.signers.* { *; } + +-keep class org.bouncycastle.jcajce.provider.asymmetric.* { *; } +-keep class org.bouncycastle.jcajce.provider.asymmetric.dh.* { *; } +-keep class org.bouncycastle.jcajce.provider.asymmetric.dsa.* { *; } +-keep class org.bouncycastle.jcajce.provider.asymmetric.ec.* { *; } +-keep class org.bouncycastle.jcajce.provider.asymmetric.elgamal.* { *; } +-keep class org.bouncycastle.jcajce.provider.asymmetric.rsa.* { *; } +-keep class org.bouncycastle.jcajce.provider.asymmetric.util.* { *; } +-keep class org.bouncycastle.jcajce.provider.asymmetric.x509.* { *; } + +-keep class org.bouncycastle.jcajce.provider.digest.** { *; } +-keep class org.bouncycastle.jcajce.provider.keystore.** { *; } +-keep class org.bouncycastle.jcajce.provider.symmetric.** { *; } +-keep class org.bouncycastle.jcajce.spec.* { *; } +-keep class org.bouncycastle.jce.** { *; } + +-keep class org.bouncycastle.x509.** { *; } +-keep class org.bouncycastle.bcpg.** { *; } +-keep class org.bouncycastle.openpgp.** { *; } + +# Smack Core classes should be figured out by Proguard +-keep class org.jivesoftware.smack.initializer.** { *; } + +# keep Smack IM +-keep class org.jivesoftware.smack.im.** { *; } +-keep class org.jivesoftware.smack.chat.** { *; } +-keep class org.jivesoftware.smack.roster.** { *; } + +# keep Smack TCP +-keep class org.jivesoftware.smack.tcp.** { *; } +-keep class org.jivesoftware.smack.sm.** { *; } + +# keep Smack experimental +-keep class org.jivesoftware.smack.experimental.** { *; } +-keep class org.jivesoftware.smackx.csi.** { *; } + +# keep Smack extensions +-keep class org.jivesoftware.smack.extensions.** { *; } +-keep class org.jivesoftware.smackx.address.** { *; } +-keep class org.jivesoftware.smackx.caps.** { *; } +-keep class org.jivesoftware.smackx.chatstates.** { *; } +-keep class org.jivesoftware.smackx.commands.** { *; } +-keep class org.jivesoftware.smackx.delay.** { *; } +-keep class org.jivesoftware.smackx.disco.** { *; } +-keep class org.jivesoftware.smackx.iqlast.** { *; } +-keep class org.jivesoftware.smackx.iqregister.** { *; } +-keep class org.jivesoftware.smackx.iqversion.** { *; } +-keep class org.jivesoftware.smackx.delay.** { *; } +-keep class org.jivesoftware.smackx.ping.** { *; } +-keep class org.jivesoftware.smackx.receipts.** { *; } +-keep class org.jivesoftware.smackx.vcardtemp.** { *; } +-keep class org.jivesoftware.smackx.xdata.** { *; } +-keep class org.jivesoftware.smackx.forward.** { *; } + +# keep other Smack utilities +-keep class org.jivesoftware.smack.**.java7.** { *; } +-keep class org.jivesoftware.smack.util.dns.** { *; } +-keep class org.jivesoftware.smack.sasl.** { *; } + +# keep Smack Android +-keep class org.jivesoftware.**.android.** { *; } -# keep all of Spongy Castle (until we can find another more reliable way) --keep class org.spongycastle.** { - *; -} +# keep all of libphonenumber +-keep class com.google.i18n.phonenumbers.** { *; } -# keep all of Smack --keep class org.jivesoftware.** { - *; +# EventBus +-keepclassmembers class * { + @org.greenrobot.eventbus.Subscribe ; } +-keep enum org.greenrobot.eventbus.ThreadMode { *; } -# keep all of libphonenumber --keep class com.google.i18n.phonenumbers.** { - *; +# netcipher +-dontwarn info.guardianproject.netcipher.client.** + +# Glide +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep public class * extends com.bumptech.glide.module.AppGlideModule +-keep public enum com.bumptech.glide.load.ImageHeaderParser$** { + **[] $VALUES; + public *; } # suggestions from Google Play Services documentation -keep class * extends java.util.ListResourceBundle { - protected Object[][] getContents(); + protected java.lang.Object[][] getContents(); } --keep public class com.google.android.gms.common.internal.safeparcel.SafeParcelable { - public static final *** NULL; +-keepnames class * implements android.os.Parcelable { + public static final ** CREATOR; } --keepnames @com.google.android.gms.common.annotation.KeepName class * --keepclassmembernames class * { - @com.google.android.gms.common.annotation.KeepName *; -} +-keep class android.support.v7.widget.SearchView { *; } --keepnames class * implements android.os.Parcelable { - public static final ** CREATOR; +# OpenStretMap +-dontwarn org.osmdroid.views.overlay.compass.CompassOverlay +-dontwarn org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay +-dontwarn org.osmdroid.tileprovider.modules.NetworkAvailabliltyCheck + +# Glide +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** { + **[] $VALUES; + public *; } + +# OkHttp +-dontwarn okio.** +-dontwarn javax.annotation.Nullable +-dontwarn javax.annotation.ParametersAreNonnullByDefault +-dontwarn okhttp3.** +-dontwarn okio.** +-dontwarn javax.annotation.** +-dontwarn org.conscrypt.** +# A resource is loaded with a relative path so the package of this class must be preserved. +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase diff --git a/app/src/androidTest/assets/keys/kontalk-keys.zip b/app/src/androidTest/assets/keys/kontalk-keys.zip new file mode 100644 index 000000000..4c12f01fa Binary files /dev/null and b/app/src/androidTest/assets/keys/kontalk-keys.zip differ diff --git a/app/src/androidTest/assets/keys/login.crt b/app/src/androidTest/assets/keys/login.crt new file mode 100644 index 000000000..4057cd9ee --- /dev/null +++ b/app/src/androidTest/assets/keys/login.crt @@ -0,0 +1,62 @@ +-----BEGIN CERTIFICATE----- +MIILOTCCCiGgAwIBAgIBATANBgkqhkiG9w0BAQUFADByMSAwHgYDVQQKDBdPcGVu +UEdQIHRvIFguNTA5IEJyaWRnZTFOMEwGA1UEAwxFZGV2LTU1NTQgPDRiZGQ0Zjky +OWYzYTEwNjIyNTNlNGU0OTZiYWZiYTBiZGZiNWRiNzVAcHJpbWUua29udGFsay5u +ZXQ+MB4XDTE5MDMyNTIwMjU0MFoXDTE5MDMyNTIwMjU0MFowcjEgMB4GA1UECgwX +T3BlblBHUCB0byBYLjUwOSBCcmlkZ2UxTjBMBgNVBAMMRWRldi01NTU0IDw0YmRk +NGY5MjlmM2ExMDYyMjUzZTRlNDk2YmFmYmEwYmRmYjVkYjc1QHByaW1lLmtvbnRh +bGsubmV0PjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALjFJF6qC1pW +797pRG7zY02n36y6gmXdYUYBLUTspQerImPWrEDoCbLgqOCBPg5FJmBABKrL8BlA +BEiFfZsAIkWKdYkGDEz3cvRbJmmI8m9yKZo6jpatBv6sn9bdF/Db8+30ZI8HAai7 +GUKZz6Y5qfinJNU8arVAYum2DyyhUeX+Zpb0Dj/qSNn7dz4IMuVMevVOosVuQRwJ +qrZX9TOJS/5LL/xb3xNKkw3yG4IYayXsi7Z0ZYIK/2NxFqkVznAAAE4d+jnTkYJc +jhUo/FOigUNqR9dNBI49/OtsGk9cUJE81wEewiyRn0x4rQ+liquPz7r6/+7HZT1D +YJrBnWn0twcCAwEAAaOCB9gwggfUMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ +BAQDAgLsMBEGCWCGSAGG+EIBAQQEAwIFoDAdBgNVHQ4EFgQULlhnIzR+KRnIABcl +3EFykn9TO2MwHwYDVR0jBBgwFoAULlhnIzR+KRnIABcl3EFykn9TO2MwUQYDVR0R +BEowSKBGBggrBgEFBQcIBQw6NGJkZDRmOTI5ZjNhMTA2MjI1M2U0ZTQ5NmJhZmJh +MGJkZmI1ZGI3NUBwcmltZS5rb250YWxrLm5ldDCCBwkGE2nJ6KKojcvgx8eJy8CA +qq7XihsEggbwA4IG7ACZAQ0EXJk5RAEIALjFJF6qC1pW797pRG7zY02n36y6gmXd +YUYBLUTspQerImPWrEDoCbLgqOCBPg5FJmBABKrL8BlABEiFfZsAIkWKdYkGDEz3 +cvRbJmmI8m9yKZo6jpatBv6sn9bdF/Db8+30ZI8HAai7GUKZz6Y5qfinJNU8arVA +Yum2DyyhUeX+Zpb0Dj/qSNn7dz4IMuVMevVOosVuQRwJqrZX9TOJS/5LL/xb3xNK +kw3yG4IYayXsi7Z0ZYIK/2NxFqkVznAAAE4d+jnTkYJcjhUo/FOigUNqR9dNBI49 +/OtsGk9cUJE81wEewiyRn0x4rQ+liquPz7r6/+7HZT1DYJrBnWn0twcAEQEAAbRF +ZGV2LTU1NTQgPDRiZGQ0ZjkyOWYzYTEwNjIyNTNlNGU0OTZiYWZiYTBiZGZiNWRi +NzVAcHJpbWUua29udGFsay5uZXQ+iQEiBBMBCAAMBQJcmTlEAhshAhkBAAoJEJGl +CAEdzXgWT6gIAKB9Ixr6TydCgzUPchhYOgo/wIVlLEFPwHPxraTWWW9VEbqvzdrt +NTnlIfuJKHxSO9W2EttbBFTLZpIJgcPnmvJnwpWwfdh6RhVD/Pivn81N2IRKE2CO +5MK4Zykn2JUk/Uu06Hfnwmcfc1DkfglLPxXttyfbCm7+Eg+G9ZtCMLvjQyPQ3GGM +3QC3i0SFI0uKsAiIOfrT/42Q1OLI6R+P5+ZwqCv+AaCjN+pFvgtR3dpfIVEnF1Zn +/clsdsDWKCuXG01UFpNBXy6DY57t0CDJDrfVOKMVdqhd5QwfOO8FscL8dlsSVQJF +IfkaQuzbkpW42Tf1i8lQzlv0u5HY9PYvIymJARwEEAEKAAYFAlyZOUQACgkQ4jEO +XpWBtY5pFAgAlU96Lno+/VC3Wla8B/PcLobAtTTHl/AiJJ/nHQlk7fMqWhBgUBSD +Ddbxr7A2aN/Skehc2wdoVq6mXv9de4VPGYO/ANupGBEpQpo4ArPO7jjVtcB24ets +f4ilXaBbMrsVE2nfzW+e7NMeQ2/GSQ7KCsqmT+WflcMqgzHpn0j6g75YqqXlGsy7 +Hw/jNTs1M8vLvtF24Uec0avT1mhJtsP9POQXcIHLwxjvalNW597xeJoLcDhRdOkz +x7FzuKWJxXdo6LNA/E4Qq1UxvpDSN3SYnYQQf8EAKe6FSAOg5pqmWVlVCSpZDhYe +EkIrai6rZXPG3uiM9GI9kSaTk/4bY7zfALhSBFyZOUQTCCqGSM49AwEHAgMEc2Vk +jfRyPsdX+20l4sFgLw/3Qn3372LK+xnRns94FsAGW0E2kSPwVCYqB0oQ1htk7IIV +1QpZo7cDMN0rZFXF04kBfwQYAQgAaQUCXJk5RAIbAl8gBBkTCAAGBQJcmTlEAAoJ +EEZh0TGtnlHpFyABAK0B/07zEQdPhDrQttYlE5JJ+zs7cAe5JdHxa2/N1YMZAP0b +V2VYIIEc31SnTy2cMKg0evUl2zxNWK3wv+7FlwOgCAAKCRCRpQgBHc14FvOQB/oC +fzUdehtuOnPA2+Vcvb0wX2a/Cu5AMQmBG1UzNzmpugTFcJLVvd1Vad0dwyKgUmSn +AxeWRXbFU1lGN35Q8YVgI0gDPkmUfdmViCPoHKP4OgvPb1huqrl03NtQpUdoCxyH +lUIR4LJImsTYMjn+/2pdabvY9aUWoe1hDQLE0K05a2iAEtN95xxmx++lyH4v0qBr +c6KH95II52ZJS+mwzKN8QHuaHCmZMDHXFt+KHL5zsx6UaisTVc6yvObAJLQ8Q+Sc +cGa4Z6WZqTp5QiLVFlShCM9yxecUE0nZTzeuUzTbavYd6kIot3YZqSfQHbGo3wzx +4eR2kkXlQVJ0qoEVwjlwuFYEXJk5RBIIKoZIzj0DAQcCAwR7TjgMiFI4tBCi5EEu +H3IKG9rtoZd97RTiqY/lFYqfBrdsaHZtOUpB/R1a7YTdycZDaR01Mz7DYzNGCmnF +TtbAAwEIB4kBHwQYAQgACQUCXJk5RAIbBAAKCRCRpQgBHc14FmWZCAC2y4+XtMla +yXdGsAeg3+WXw1q3IoOLID4PKDHhvUM1jiQofTYDIsWrKlDNsQExCqFveKQChlIK +4sg5kw5NgXckcYHpydUpAX7rCEheJQ6Z+IMH0zwu6nCZg5bx9isJXfjzYsGTpbu5 +UQ9t4BoWh2AVZPYexXELmClrEfvBFDHNtEzf0U4HoCUmq4T8tTacnLFmudUTeXcj +nKp0CStaKThUm5fU+Z4pHHgCohOqvyzQxvseCwUJmJHLA+svetLq31wij9yzoxlT +3Fr51Rm9fMD7kydR3eYCqGkathUXvHXx1Asi0+SAcPsde22+iynMv7wlnk8qd1es +edtgPiJh8/VDMA0GCSqGSIb3DQEBBQUAA4IBAQAeiNxi9PP7KzGd2FTpSy8eBebN +ECT8UJjrPkVjjV1nDMudnSpC3oHEsVE0m2QjYD4nrL559sAaSszZrUJrdCzhvjEJ +c5pKZTLOZk8ThYTCHNXnRmhjAOpaRxsFmxIg5/ds0mCr3CHP+liXyDV0L3Uu9nFu +6hHFaJ7MW+pDC0dJpbEhiBO8PvpuUOitA2CTDrvuT4FWo4o+hVnT+mgvLbloKjtc +oSQjl9furZsg/i8zaBcj0St3zFiUD50KI4GC7ohdZnlqpiDOWJS3JMRKKpDTz7EA +VNItsQh6CcbFZ+k7S9L93xx08PrvAJVVEcVF6F5oSsmzKOEmx7gYSQ32Ec1K +-----END CERTIFICATE----- diff --git a/app/src/androidTest/assets/keys/login.key b/app/src/androidTest/assets/keys/login.key new file mode 100644 index 000000000..a46ae6f40 --- /dev/null +++ b/app/src/androidTest/assets/keys/login.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAuMUkXqoLWlbv3ulEbvNjTaffrLqCZd1hRgEtROylB6siY9as +QOgJsuCo4IE+DkUmYEAEqsvwGUAESIV9mwAiRYp1iQYMTPdy9FsmaYjyb3IpmjqO +lq0G/qyf1t0X8Nvz7fRkjwcBqLsZQpnPpjmp+Kck1TxqtUBi6bYPLKFR5f5mlvQO +P+pI2ft3Pggy5Ux69U6ixW5BHAmqtlf1M4lL/ksv/FvfE0qTDfIbghhrJeyLtnRl +ggr/Y3EWqRXOcAAATh36OdORglyOFSj8U6KBQ2pH100Ejj3862waT1xQkTzXAR7C +LJGfTHitD6WKq4/Puvr/7sdlPUNgmsGdafS3BwIDAQABAoIBABNZOvVEy5qNYbdk +kwYmq5PytpFhGI2akKNNipQzTxbN/MnFWFVrAbT/IgAcg0c9m4asaDC12XHeH8oJ +Bz2yK7nqMA5bbpyoCZwoErjYyUEStzjk1TtXFCqay27VyTovi6GJRvcpTdWsU2QD +ilicT5MadwNzZ7M3nQQJ923Tg37Y0YsffUA8pjKqBHB303WZ8G1WcHchXZaOFFhz +tDVYq2DQJFU9EHd43JFi62ONpbthp0hXYpc2zvnrfqE0TQHebOhT+wbUtg3SkxOX +N22/i4uYcgUPJZSKrwQojKvTButu8eoqfmiMAceXPil67b4LOfirohnbDQsh0Qiy +avJgQBkCgYEAxquOQa57k7lNJtzwnuMY2HN/Y5by5o2xvTj9oM30zIX3CLP5IaRx +I4dctDBdBE5UnOqib74AG3FFpAY770K+66JMoX41pLs5y52ohECt3dazUmM9/tvj +0LWX4R6JGfMnBJG+Tzx2Ih7N9ojhMf0vDE5Bai0nnqr0J776IxMAP38CgYEA7ha9 +5krF5ea5QI0ZWLTtaXlNqmrGZ3jJ88x+hPmc5req230g/jtqcODEz5SWx1+YlFgl +O+DM2w2jRgvQ+s4ngYD661gvS+wsGF/Ny9AfiAiDvpsziTyKnr80eeJL0LV6mAWI +4DHv0hGYkYIIS/yofsrMMeklg8GSEWePKiKDTHkCgYEAteQn0FbRaYSD8Irj+nRt +MO89uI5hQViFXHsrKuilsgMNE3jDJVwLAb5Sz95fqyuUKD5iDfBk5LsmSXAzu3De +n5nukwScVH/eAGAOA1e8V5jUgR+/iPEFePZpYYC1gX6vQG23UZBHkCGn7ImRtFTb +mGalWXKSDaDy/NjK5wXkej0CgYAeicA1x6V2hY+BXhfALwS8oJh7oVJtdyrAIJyh +xbqARVKe8H/I3Coc7FeSIIbub66MaHnuIjlkZ21n0KAvaz3Z+Ic2pIr8fABz5+Xn +nHxO4Hi1fAayNZe6IOBEOehrjrt4wEz8JKuUEDm3gCw8Qt4B1VU3maX8Llnj7ZqJ +Vv/sUQKBgQCFOatKV9ZGgYt3LO9kw5OPOVGf6TFsXe/OQBKWyvL+bA/5zbeWXHDd +69AVpC36LKRXNOEgexl7bs4Si+GJ0jHRn5mhukiluMveRd03YJKUedzJ8PSSfkBf +4QB+X3hpd1qWO8SxdHh2SzI70+Mlw/45o/xwf8U/Yf6g7NcVNv5Y6A== +-----END RSA PRIVATE KEY----- diff --git a/app/src/androidTest/assets/keys/private.key b/app/src/androidTest/assets/keys/private.key new file mode 100644 index 000000000..401ac85d1 Binary files /dev/null and b/app/src/androidTest/assets/keys/private.key differ diff --git a/app/src/androidTest/assets/keys/privatekey.asc b/app/src/androidTest/assets/keys/privatekey.asc new file mode 100644 index 000000000..3a5aede35 --- /dev/null +++ b/app/src/androidTest/assets/keys/privatekey.asc @@ -0,0 +1,60 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: BCPG v1.54 + +lQPGBFyZOUQBCAC4xSReqgtaVu/e6URu82NNp9+suoJl3WFGAS1E7KUHqyJj1qxA +6Amy4KjggT4ORSZgQASqy/AZQARIhX2bACJFinWJBgxM93L0WyZpiPJvcimaOo6W +rQb+rJ/W3Rfw2/Pt9GSPBwGouxlCmc+mOan4pyTVPGq1QGLptg8soVHl/maW9A4/ +6kjZ+3c+CDLlTHr1TqLFbkEcCaq2V/UziUv+Sy/8W98TSpMN8huCGGsl7Iu2dGWC +Cv9jcRapFc5wAABOHfo505GCXI4VKPxTooFDakfXTQSOPfzrbBpPXFCRPNcBHsIs +kZ9MeK0PpYqrj8+6+v/ux2U9Q2CawZ1p9LcHABEBAAH+CQMC7MxD/YN3mTBgxTKw +DDFLXchY3iPH4odvQdpE78TwlVpig+gP5iCGnrMc5wwdynzB6Bw4B3lq9m8gEfAn +cLtZbVD26lrPtGN/UYOcJeWc5ZEabXbzBYyfWDTVQYhDu8gLG+RUtM038Gb86lOr +EbYMs69s4126RLr/3c9vu+MeQOai0rJ1+egg5/RNn1FUxFlq/UNHjD32PePeneA+ +ge7AxoqdG1nlJpcN7b3wN9Ik5tPqEoiTYOOuIRkSKl4NWbiZF1y2Kj7rWdX7eNCY +IqKAcG2ezKi8+hxsUxQBNBcMIMiW3f9vNZEGPD4T6SvwYQ6gnCDs+6zKiGj2I66d +t3Qr4tbWG8eokKucmSE/f/hETPcUCjIJ3l3wERJBybLbQJJ3PHS5h9CxNhe7ema5 +Fw6Kd0N7yE+mGhCLdxmJTTxo+hZ/6qvKq6z3ArrzDxtTbnxZZA6T6q8JtK5wA/h1 +LmRvaMiUCu+ZRFqdBv1kV31uzh+hYBEkGJBeELsSAULwE+y9EIFHlH8qKwqAcbp1 +8QbhRUSxeRVp8TWmcLbWT3Fqh+RqSrfzN22qq+pVApkqPvv6M2BwwmawvuZGh0or +2JVJdNJGhiLy37VW0l6IIx++I9+dOVOCZcRmNJZDu0JfHTrKczSiVIO3lgPo02pR +FEq/cLFbqTuX5+neeyqcPfTjJROZiyxi5pB/WeMGXwBB/XdVWZ/LnWFLsIgOuzTO +N6HwBYp3a826eZgFBJhe/hB0DMjWtJElavz167FXFc26IIS4EEr5pVXyulJ3+PbR +5As8K/3hbj4vHJ7sugtBklDVH0lQPU4jHudWvbbP7S66+6Ot5h+23YIH3tZ2NQDm +8pQ2UbqV0QGe2rK0mNuQrE2/nT6Yh9g05vpYa5dL0mgRWcuda6Lw5CmnXGMTo9NP +TMB8azY21+FFtEVkZXYtNTU1NCA8NGJkZDRmOTI5ZjNhMTA2MjI1M2U0ZTQ5NmJh +ZmJhMGJkZmI1ZGI3NUBwcmltZS5rb250YWxrLm5ldD6JASIEEwEIAAwFAlyZOUQC +GyECGQEACgkQkaUIAR3NeBZPqAgAoH0jGvpPJ0KDNQ9yGFg6Cj/AhWUsQU/Ac/Gt +pNZZb1URuq/N2u01OeUh+4kofFI71bYS21sEVMtmkgmBw+ea8mfClbB92HpGFUP8 ++K+fzU3YhEoTYI7kwrhnKSfYlST9S7Tod+fCZx9zUOR+CUs/Fe23J9sKbv4SD4b1 +m0Iwu+NDI9DcYYzdALeLRIUjS4qwCIg5+tP/jZDU4sjpH4/n5nCoK/4BoKM36kW+ +C1Hd2l8hUScXVmf9yWx2wNYoK5cbTVQWk0FfLoNjnu3QIMkOt9U4oxV2qF3lDB84 +7wWxwvx2WxJVAkUh+RpC7NuSlbjZN/WLyVDOW/S7kdj09i8jKYkBHAQQAQoABgUC +XJk5RAAKCRDiMQ5elYG1jmkUCACVT3ouej79ULdaVrwH89wuhsC1NMeX8CIkn+cd +CWTt8ypaEGBQFIMN1vGvsDZo39KR6FzbB2hWrqZe/117hU8Zg78A26kYESlCmjgC +s87uONW1wHbh62x/iKVdoFsyuxUTad/Nb57s0x5Db8ZJDsoKyqZP5Z+VwyqDMemf +SPqDvliqpeUazLsfD+M1OzUzy8u+0XbhR5zRq9PWaEm2w/085BdwgcvDGO9qU1bn +3vF4mgtwOFF06TPHsXO4pYnFd2jos0D8ThCrVTG+kNI3dJidhBB/wQAp7oVIA6Dm +mqZZWVUJKlkOFh4SQitqLqtlc8be6Iz0Yj2RJpOT/htjvN8AnKUEXJk5RBMIKoZI +zj0DAQcCAwRzZWSN9HI+x1f7bSXiwWAvD/dCfffvYsr7GdGez3gWwAZbQTaRI/BU +JioHShDWG2TsghXVClmjtwMw3StkVcXT/gkDAuzMQ/2Dd5kwYFh2WL2Wjdy6TBm5 +xDTMulHC9voUaseUyqnfWYMaosJong7zsWJr9gxP+gXE6ZzNrdOxldSpN8PgTSpV +TqBvkUueH1rhK6+JAX8EGAEIAGkFAlyZOUQCGwJfIAQZEwgABgUCXJk5RAAKCRBG +YdExrZ5R6RcgAQCtAf9O8xEHT4Q60LbWJROSSfs7O3AHuSXR8WtvzdWDGQD9G1dl +WCCBHN9Up08tnDCoNHr1Jds8TVit8L/uxZcDoAgACgkQkaUIAR3NeBbzkAf6An81 +HXobbjpzwNvlXL29MF9mvwruQDEJgRtVMzc5qboExXCS1b3dVWndHcMioFJkpwMX +lkV2xVNZRjd+UPGFYCNIAz5JlH3ZlYgj6Byj+DoLz29Ybqq5dNzbUKVHaAsch5VC +EeCySJrE2DI5/v9qXWm72PWlFqHtYQ0CxNCtOWtogBLTfeccZsfvpch+L9Kga3Oi +h/eSCOdmSUvpsMyjfEB7mhwpmTAx1xbfihy+c7MelGorE1XOsrzmwCS0PEPknHBm +uGelmak6eUIi1RZUoQjPcsXnFBNJ2U83rlM022r2HepCKLd2Gakn0B2xqN8M8eHk +dpJF5UFSdKqBFcI5cJypBFyZOUQSCCqGSM49AwEHAgMEe044DIhSOLQQouRBLh9y +Chva7aGXfe0U4qmP5RWKnwa3bGh2bTlKQf0dWu2E3cnGQ2kdNTM+w2MzRgppxU7W +wAMBCAf+CQMC7MxD/YN3mTBgJi/VhZBuRaEIpN0MPZKyuUbyX429Xmc2XxjHXZJY +m3QIKu+N2177WyxT3cSTe54XilwqskI51KrOSpCChFP6N7Aj/OmrFokBHwQYAQgA +CQUCXJk5RAIbBAAKCRCRpQgBHc14FmWZCAC2y4+XtMlayXdGsAeg3+WXw1q3IoOL +ID4PKDHhvUM1jiQofTYDIsWrKlDNsQExCqFveKQChlIK4sg5kw5NgXckcYHpydUp +AX7rCEheJQ6Z+IMH0zwu6nCZg5bx9isJXfjzYsGTpbu5UQ9t4BoWh2AVZPYexXEL +mClrEfvBFDHNtEzf0U4HoCUmq4T8tTacnLFmudUTeXcjnKp0CStaKThUm5fU+Z4p +HHgCohOqvyzQxvseCwUJmJHLA+svetLq31wij9yzoxlT3Fr51Rm9fMD7kydR3eYC +qGkathUXvHXx1Asi0+SAcPsde22+iynMv7wlnk8qd1esedtgPiJh8/VD +=ZK1S +-----END PGP PRIVATE KEY BLOCK----- diff --git a/app/src/androidTest/assets/keys/public.key b/app/src/androidTest/assets/keys/public.key new file mode 100644 index 000000000..6bdc96be7 Binary files /dev/null and b/app/src/androidTest/assets/keys/public.key differ diff --git a/app/src/androidTest/assets/keys/publickey.asc b/app/src/androidTest/assets/keys/publickey.asc new file mode 100644 index 000000000..0be6afaa5 --- /dev/null +++ b/app/src/androidTest/assets/keys/publickey.asc @@ -0,0 +1,42 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: BCPG v1.54 + +mQENBFyZOUQBCAC4xSReqgtaVu/e6URu82NNp9+suoJl3WFGAS1E7KUHqyJj1qxA +6Amy4KjggT4ORSZgQASqy/AZQARIhX2bACJFinWJBgxM93L0WyZpiPJvcimaOo6W +rQb+rJ/W3Rfw2/Pt9GSPBwGouxlCmc+mOan4pyTVPGq1QGLptg8soVHl/maW9A4/ +6kjZ+3c+CDLlTHr1TqLFbkEcCaq2V/UziUv+Sy/8W98TSpMN8huCGGsl7Iu2dGWC +Cv9jcRapFc5wAABOHfo505GCXI4VKPxTooFDakfXTQSOPfzrbBpPXFCRPNcBHsIs +kZ9MeK0PpYqrj8+6+v/ux2U9Q2CawZ1p9LcHABEBAAG0RWRldi01NTU0IDw0YmRk +NGY5MjlmM2ExMDYyMjUzZTRlNDk2YmFmYmEwYmRmYjVkYjc1QHByaW1lLmtvbnRh +bGsubmV0PokBIgQTAQgADAUCXJk5RAIbIQIZAQAKCRCRpQgBHc14Fk+oCACgfSMa ++k8nQoM1D3IYWDoKP8CFZSxBT8Bz8a2k1llvVRG6r83a7TU55SH7iSh8UjvVthLb +WwRUy2aSCYHD55ryZ8KVsH3YekYVQ/z4r5/NTdiEShNgjuTCuGcpJ9iVJP1LtOh3 +58JnH3NQ5H4JSz8V7bcn2wpu/hIPhvWbQjC740Mj0NxhjN0At4tEhSNLirAIiDn6 +0/+NkNTiyOkfj+fmcKgr/gGgozfqRb4LUd3aXyFRJxdWZ/3JbHbA1igrlxtNVBaT +QV8ug2Oe7dAgyQ631TijFXaoXeUMHzjvBbHC/HZbElUCRSH5GkLs25KVuNk39YvJ +UM5b9LuR2PT2LyMpiQEcBBABCgAGBQJcmTlEAAoJEOIxDl6VgbWOaRQIAJVPei56 +Pv1Qt1pWvAfz3C6GwLU0x5fwIiSf5x0JZO3zKloQYFAUgw3W8a+wNmjf0pHoXNsH +aFaupl7/XXuFTxmDvwDbqRgRKUKaOAKzzu441bXAduHrbH+IpV2gWzK7FRNp381v +nuzTHkNvxkkOygrKpk/ln5XDKoMx6Z9I+oO+WKql5RrMux8P4zU7NTPLy77RduFH +nNGr09ZoSbbD/TzkF3CBy8MY72pTVufe8XiaC3A4UXTpM8exc7ilicV3aOizQPxO +EKtVMb6Q0jd0mJ2EEH/BACnuhUgDoOaapllZVQkqWQ4WHhJCK2ouq2Vzxt7ojPRi +PZEmk5P+G2O83wC4UgRcmTlEEwgqhkjOPQMBBwIDBHNlZI30cj7HV/ttJeLBYC8P +90J99+9iyvsZ0Z7PeBbABltBNpEj8FQmKgdKENYbZOyCFdUKWaO3AzDdK2RVxdOJ +AX8EGAEIAGkFAlyZOUQCGwJfIAQZEwgABgUCXJk5RAAKCRBGYdExrZ5R6RcgAQCt +Af9O8xEHT4Q60LbWJROSSfs7O3AHuSXR8WtvzdWDGQD9G1dlWCCBHN9Up08tnDCo +NHr1Jds8TVit8L/uxZcDoAgACgkQkaUIAR3NeBbzkAf6An81HXobbjpzwNvlXL29 +MF9mvwruQDEJgRtVMzc5qboExXCS1b3dVWndHcMioFJkpwMXlkV2xVNZRjd+UPGF +YCNIAz5JlH3ZlYgj6Byj+DoLz29Ybqq5dNzbUKVHaAsch5VCEeCySJrE2DI5/v9q +XWm72PWlFqHtYQ0CxNCtOWtogBLTfeccZsfvpch+L9Kga3Oih/eSCOdmSUvpsMyj +fEB7mhwpmTAx1xbfihy+c7MelGorE1XOsrzmwCS0PEPknHBmuGelmak6eUIi1RZU +oQjPcsXnFBNJ2U83rlM022r2HepCKLd2Gakn0B2xqN8M8eHkdpJF5UFSdKqBFcI5 +cLhWBFyZOUQSCCqGSM49AwEHAgMEe044DIhSOLQQouRBLh9yChva7aGXfe0U4qmP +5RWKnwa3bGh2bTlKQf0dWu2E3cnGQ2kdNTM+w2MzRgppxU7WwAMBCAeJAR8EGAEI +AAkFAlyZOUQCGwQACgkQkaUIAR3NeBZlmQgAtsuPl7TJWsl3RrAHoN/ll8NatyKD +iyA+Dygx4b1DNY4kKH02AyLFqypQzbEBMQqhb3ikAoZSCuLIOZMOTYF3JHGB6cnV +KQF+6whIXiUOmfiDB9M8LupwmYOW8fYrCV3482LBk6W7uVEPbeAaFodgFWT2HsVx +C5gpaxH7wRQxzbRM39FOB6AlJquE/LU2nJyxZrnVE3l3I5yqdAkrWik4VJuX1Pme +KRx4AqITqr8s0Mb7HgsFCZiRywPrL3rS6t9cIo/cs6MZU9xa+dUZvXzA+5MnUd3m +AqhpGrYVF7x18dQLItPkgHD7HXttvospzL+8JZ5PKndXrHnbYD4iYfP1Qw== +=p/n+ +-----END PGP PUBLIC KEY BLOCK----- diff --git a/app/src/androidTest/assets/messages_v10.db b/app/src/androidTest/assets/messages_v10.db new file mode 100644 index 000000000..9c138886a Binary files /dev/null and b/app/src/androidTest/assets/messages_v10.db differ diff --git a/app/src/androidTest/assets/messages_v11.db b/app/src/androidTest/assets/messages_v11.db new file mode 100644 index 000000000..00a81b34f Binary files /dev/null and b/app/src/androidTest/assets/messages_v11.db differ diff --git a/app/src/androidTest/assets/messages_v12.db b/app/src/androidTest/assets/messages_v12.db new file mode 100644 index 000000000..2bb237fb1 Binary files /dev/null and b/app/src/androidTest/assets/messages_v12.db differ diff --git a/app/src/androidTest/assets/messages_v13.db b/app/src/androidTest/assets/messages_v13.db new file mode 100644 index 000000000..a05f6aeff Binary files /dev/null and b/app/src/androidTest/assets/messages_v13.db differ diff --git a/app/src/androidTest/assets/messages_v14.db b/app/src/androidTest/assets/messages_v14.db new file mode 100644 index 000000000..246cec7c0 Binary files /dev/null and b/app/src/androidTest/assets/messages_v14.db differ diff --git a/app/src/androidTest/assets/messages_v15.db b/app/src/androidTest/assets/messages_v15.db new file mode 100644 index 000000000..3478c97d3 Binary files /dev/null and b/app/src/androidTest/assets/messages_v15.db differ diff --git a/app/src/androidTest/assets/messages_v16.db b/app/src/androidTest/assets/messages_v16.db new file mode 100644 index 000000000..8be4f08f1 Binary files /dev/null and b/app/src/androidTest/assets/messages_v16.db differ diff --git a/app/src/androidTest/assets/messages_v17.db b/app/src/androidTest/assets/messages_v17.db new file mode 100644 index 000000000..ac38fdeb2 Binary files /dev/null and b/app/src/androidTest/assets/messages_v17.db differ diff --git a/app/src/androidTest/assets/messages_v8.db b/app/src/androidTest/assets/messages_v8.db new file mode 100644 index 000000000..79437e29f Binary files /dev/null and b/app/src/androidTest/assets/messages_v8.db differ diff --git a/app/src/androidTest/assets/messages_v9.db b/app/src/androidTest/assets/messages_v9.db new file mode 100644 index 000000000..fdb8b22c4 Binary files /dev/null and b/app/src/androidTest/assets/messages_v9.db differ diff --git a/app/src/androidTest/assets/users_v11.db b/app/src/androidTest/assets/users_v11.db new file mode 100644 index 000000000..3cae52acc Binary files /dev/null and b/app/src/androidTest/assets/users_v11.db differ diff --git a/app/src/androidTest/assets/users_v9.db b/app/src/androidTest/assets/users_v9.db new file mode 100644 index 000000000..16f39d7cd Binary files /dev/null and b/app/src/androidTest/assets/users_v9.db differ diff --git a/app/src/androidTest/java/net/slideshare/mobile/test/util/OrientationChangeAction.java b/app/src/androidTest/java/net/slideshare/mobile/test/util/OrientationChangeAction.java new file mode 100644 index 000000000..c8e3797df --- /dev/null +++ b/app/src/androidTest/java/net/slideshare/mobile/test/util/OrientationChangeAction.java @@ -0,0 +1,92 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - Nathan Barraille + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ +package net.slideshare.mobile.test.util; + +import java.util.Collection; + +import org.hamcrest.Matcher; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.pm.ActivityInfo; +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; +import androidx.test.runner.lifecycle.Stage; +import android.view.View; + +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; + +/** + * An Espresso ViewAction that changes the orientation of the screen + */ +public class OrientationChangeAction implements ViewAction { + private final int orientation; + + private OrientationChangeAction(int orientation) { + this.orientation = orientation; + } + + @Override + public Matcher getConstraints() { + return isRoot(); + } + + @Override + public String getDescription() { + return "change orientation to " + orientation; + } + + @Override + public void perform(UiController uiController, View view) { + uiController.loopMainThreadUntilIdle(); + final Activity activity = getActivity(view.getContext()); + activity.setRequestedOrientation(orientation); + + Collection resumedActivities = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED); + if (resumedActivities.isEmpty()) { + throw new RuntimeException("Could not change orientation"); + } + } + + private Activity getActivity(Context context) { + while (context instanceof ContextWrapper) { + if (context instanceof Activity) { + return (Activity) context; + } + context = ((ContextWrapper) context).getBaseContext(); + } + return null; + } + + public static ViewAction orientationLandscape() { + return new OrientationChangeAction(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + } + + public static ViewAction orientationPortrait() { + return new OrientationChangeAction(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + } +} diff --git a/app/src/androidTest/java/org/kontalk/DefaultAccountTest.java b/app/src/androidTest/java/org/kontalk/DefaultAccountTest.java new file mode 100644 index 000000000..3dfc8bba7 --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/DefaultAccountTest.java @@ -0,0 +1,106 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk; + + +import java.io.IOException; +import java.io.InputStream; + +import org.junit.AfterClass; +import org.junit.BeforeClass; + +import android.accounts.AccountManager; +import android.content.Context; +import android.os.Bundle; +import androidx.test.platform.app.InstrumentationRegistry; +import android.util.Base64; + +import org.kontalk.authenticator.Authenticator; +import org.kontalk.service.msgcenter.SQLiteRosterStore; +import org.kontalk.util.ByteArrayInOutStream; +import org.kontalk.util.MessageUtils; + +/** + * Extend this class to write a test that requires the default account to exist. + */ +public abstract class DefaultAccountTest extends TestServerTest { + + public static final String TEST_USERNAME = "dev-5554"; + public static final String TEST_USERID = "+15555215554"; + public static final String TEST_PASSPHRASE = "integration"; + + private static final long MAX_KEY_SIZE = 102400; // 100 KB + + @BeforeClass + public static void setUpAccount() throws Exception { + createFakeAccount(TEST_USERNAME, TEST_USERID); + Kontalk.setServicesEnabled(InstrumentationRegistry.getInstrumentation().getTargetContext(), true); + } + + @AfterClass + public static void tearDownAccount() throws Exception { + TestUtils.removeDefaultAccount(); + } + + private static void createFakeAccount(String displayName, String phoneNumber) throws IOException, InterruptedException { + TestUtils.removeDefaultAccount(); + + InputStream privateInput = InstrumentationRegistry.getInstrumentation().getContext().getAssets().open("keys/private.key"); + InputStream publicInput = InstrumentationRegistry.getInstrumentation().getContext().getAssets().open("keys/public.key"); + InputStream bridgeCertInput = InstrumentationRegistry.getInstrumentation().getContext().getAssets().open("keys/login.crt"); + + ByteArrayInOutStream privateKeyData = MessageUtils.readFully(privateInput, MAX_KEY_SIZE); + ByteArrayInOutStream publicKeyData = MessageUtils.readFully(publicInput, MAX_KEY_SIZE); + ByteArrayInOutStream bridgeCertData = MessageUtils.readFully(bridgeCertInput, MAX_KEY_SIZE); + + privateInput.close(); + publicInput.close(); + bridgeCertInput.close(); + + final android.accounts.Account account = new android.accounts + .Account(phoneNumber, Authenticator.ACCOUNT_TYPE); + + AccountManager am = (AccountManager) InstrumentationRegistry.getInstrumentation().getContext() + .getSystemService(Context.ACCOUNT_SERVICE); + + // account userdata + Bundle data = new Bundle(); + data.putString(Authenticator.DATA_PRIVATEKEY, Base64 + .encodeToString(privateKeyData.toByteArray(), Base64.NO_WRAP)); + data.putString(Authenticator.DATA_PUBLICKEY, Base64 + .encodeToString(publicKeyData.toByteArray(), Base64.NO_WRAP)); + data.putString(Authenticator.DATA_BRIDGECERT, Base64 + .encodeToString(bridgeCertData.toByteArray(), Base64.NO_WRAP)); + data.putString(Authenticator.DATA_NAME, displayName); + data.putString(Authenticator.DATA_SERVER_URI, TEST_SERVER_URI); + + am.addAccountExplicitly(account, TEST_PASSPHRASE, data); + + // put data once more (workaround for Android bug http://stackoverflow.com/a/11698139/1045199) + am.setUserData(account, Authenticator.DATA_PRIVATEKEY, data.getString(Authenticator.DATA_PRIVATEKEY)); + am.setUserData(account, Authenticator.DATA_PUBLICKEY, data.getString(Authenticator.DATA_PUBLICKEY)); + am.setUserData(account, Authenticator.DATA_BRIDGECERT, data.getString(Authenticator.DATA_BRIDGECERT)); + am.setUserData(account, Authenticator.DATA_NAME, data.getString(Authenticator.DATA_NAME)); + am.setUserData(account, Authenticator.DATA_SERVER_URI, TEST_SERVER_URI); + + // clear old roster information + SQLiteRosterStore.purge(InstrumentationRegistry.getInstrumentation().getTargetContext()); + } + +} diff --git a/app/src/androidTest/java/org/kontalk/EventIdlingResource.java b/app/src/androidTest/java/org/kontalk/EventIdlingResource.java new file mode 100644 index 000000000..872eded1f --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/EventIdlingResource.java @@ -0,0 +1,100 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; + +import android.os.Handler; +import android.os.Looper; +import androidx.test.espresso.IdlingResource; + + +/** + * An idling resource that waits for an event to be idle. + */ +public class EventIdlingResource implements IdlingResource { + + private static final long DEFAULT_TIMEOUT_MS = 2000; + + private final String mName; + private final EventBus mBus; + private final Class mClass; + + private final long mTimeoutMs; + + private final Handler mHandler; + private final Runnable mTransitionToIdle; + + private boolean mRunning; + private ResourceCallback mCallback; + private boolean mEventReceived; + + public EventIdlingResource(String name, EventBus bus, Class klass) { + this(name, bus, klass, DEFAULT_TIMEOUT_MS); + } + + public EventIdlingResource(String name, EventBus bus, Class klass, long timeoutMs) { + mName = name; + mBus = bus; + mClass = klass; + mBus.register(this); + mTimeoutMs = timeoutMs; + mHandler = new Handler(Looper.getMainLooper()); + + mTransitionToIdle = new Runnable() { + @Override + public void run() { + mEventReceived = true; + if (mCallback != null) { + mCallback.onTransitionToIdle(); + } + } + }; + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onEvent(Object event) { + if (event.getClass() == mClass) { + Log.d("TEST", "got idling event: " + event); + mHandler.postDelayed(mTransitionToIdle, mTimeoutMs); + mBus.unregister(this); + } + } + + @Override + public String getName() { + return mName; + } + + @Override + public boolean isIdleNow() { + return !mRunning || mEventReceived; + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback callback) { + this.mCallback = callback; + } + + public void start() { + mRunning = true; + } +} diff --git a/app/src/androidTest/java/org/kontalk/TestServerTest.java b/app/src/androidTest/java/org/kontalk/TestServerTest.java new file mode 100644 index 000000000..47a16a108 --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/TestServerTest.java @@ -0,0 +1,42 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk; + +import org.junit.Before; + +import org.kontalk.client.EndpointServer; +import org.kontalk.util.Preferences; + + +/** + * Sets the test server address and some other stuff in {@link #setUp()}. + */ +public abstract class TestServerTest { + public static final String TEST_SERVER_URI = "prime.kontalk.net|10.0.2.2:5222"; + + protected EndpointServer.EndpointServerProvider mTestServerProvider; + + @Before + public void setUp() throws Exception { + Preferences.setServerURI(TEST_SERVER_URI); + Preferences.setAcceptAnyCertificate(true); + mTestServerProvider = new EndpointServer.SingleServerProvider(TEST_SERVER_URI); + } + +} diff --git a/app/src/androidTest/java/org/kontalk/TestUtils.java b/app/src/androidTest/java/org/kontalk/TestUtils.java new file mode 100644 index 000000000..3f6523ebc --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/TestUtils.java @@ -0,0 +1,159 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk; + +import java.util.concurrent.TimeUnit; + +import org.greenrobot.eventbus.EventBus; +import org.hamcrest.CustomMatcher; +import org.hamcrest.Matcher; + +import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; +import android.content.Context; +import android.database.Cursor; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.espresso.IdlingPolicies; +import androidx.test.espresso.IdlingRegistry; +import androidx.test.espresso.IdlingResource; +import android.view.View; +import android.widget.Adapter; +import android.widget.AdapterView; + +import org.kontalk.authenticator.Authenticator; +import org.kontalk.message.CompositeMessage; +import org.kontalk.message.TextComponent; +import org.kontalk.util.MessageUtils; + +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertThat; +import static org.junit.Assume.assumeFalse; +import static org.junit.Assume.assumeTrue; + + +public class TestUtils { + static { + BuildConfig.TESTING.set(true); + } + + private TestUtils() { + } + + public static void skipIfDefaultAccountDoesNotExist() { + assumeFalse("default account does not exist - skipping test", + Authenticator.getDefaultAccount + (InstrumentationRegistry.getInstrumentation().getTargetContext()) == null); + } + + public static void skipIfDefaultAccountExists() { + assumeTrue("default account exists - skipping test", + Authenticator.getDefaultAccount + (InstrumentationRegistry.getInstrumentation().getTargetContext()) == null); + } + + public static void assertDefaultAccountExists() { + assertThat("default account does not exist", + Authenticator.getDefaultAccount + (InstrumentationRegistry.getInstrumentation().getTargetContext()), + notNullValue()); + } + + public static void removeDefaultAccount() throws InterruptedException { + final Object monitor = new Object(); + AccountManagerCallback callback = new AccountManagerCallback() { + public void run(AccountManagerFuture future) { + synchronized (monitor) { + monitor.notify(); + } + } + }; + Authenticator.removeDefaultAccount(InstrumentationRegistry.getInstrumentation().getTargetContext(), callback); + synchronized (monitor) { + monitor.wait(3000); + } + } + + public static Matcher withMessageDirection(final int direction) { + return new CustomMatcher("with message direction: ") { + @Override + public boolean matches(Object item) { + if (!(item instanceof AdapterView)) { + return false; + } + + Adapter adapter = ((AdapterView) item).getAdapter(); + for (int i = 0; i < adapter.getCount(); i++) { + Cursor message = (Cursor) adapter.getItem(i); + if (message != null && MessageUtils.getMessageDirection(message) == direction) { + return true; + } + } + return false; + } + }; + } + + public static Matcher withMessageContent(final Context context, final String content) { + return new CustomMatcher("with message content: ") { + @Override + public boolean matches(Object item) { + if (!(item instanceof AdapterView)) { + return false; + } + + Adapter adapter = ((AdapterView) item).getAdapter(); + for (int i = 0; i < adapter.getCount(); i++) { + Cursor cursor = (Cursor) adapter.getItem(i); + if (cursor != null) { + CompositeMessage message = CompositeMessage.fromCursor(context, cursor); + TextComponent text = message.getComponent(TextComponent.class); + if (text != null && text.getContent().equalsIgnoreCase(content)) { + return true; + } + } + } + return false; + } + }; + } + + public static EventIdlingResource registerEventIdlingResource(EventBus bus, Class klass) { + IdlingPolicies.setIdlingResourceTimeout(5, TimeUnit.MINUTES); + IdlingPolicies.setMasterPolicyTimeout(5, TimeUnit.MINUTES); + + EventIdlingResource resource = new EventIdlingResource(klass.getSimpleName(), bus, klass); + IdlingRegistry.getInstance().register(resource); + return resource; + } + + public static T registerIdlingResource(T resource) { + IdlingPolicies.setIdlingResourceTimeout(5, TimeUnit.MINUTES); + IdlingPolicies.setMasterPolicyTimeout(5, TimeUnit.MINUTES); + + IdlingRegistry.getInstance().register(resource); + return resource; + } + + public static void unregisterIdlingResource(IdlingResource resource) { + IdlingPolicies.setIdlingResourceTimeout(1, TimeUnit.MINUTES); + IdlingPolicies.setMasterPolicyTimeout(1, TimeUnit.MINUTES); + + IdlingRegistry.getInstance().unregister(resource); + } +} diff --git a/app/src/androidTest/java/org/kontalk/crypto/PGPDeviceTest.java b/app/src/androidTest/java/org/kontalk/crypto/PGPDeviceTest.java new file mode 100644 index 000000000..f160ce54e --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/crypto/PGPDeviceTest.java @@ -0,0 +1,55 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.crypto; + +import java.util.Date; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.FlakyTest; + +import static org.junit.Assert.assertNotNull; + + +@RunWith(AndroidJUnit4.class) +@FlakyTest +public class PGPDeviceTest { + + @Before + public void setUp() { + PGP.registerProvider(); + } + + /** + * Some devices have problem running crypto stuff. So this test should be + * run on as much devices as possible. + */ + @Test + public void testCreate() throws Exception { + PGP.PGPDecryptedKeyPairRing ring = PGP.create(new Date()); + assertNotNull(ring); + assertNotNull(ring.authKey); + assertNotNull(ring.encryptKey); + assertNotNull(ring.signKey); + } + +} diff --git a/app/src/androidTest/java/org/kontalk/demo/ShowreelTest.java b/app/src/androidTest/java/org/kontalk/demo/ShowreelTest.java new file mode 100644 index 000000000..c7eb4e7e5 --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/demo/ShowreelTest.java @@ -0,0 +1,295 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.demo; + +import java.util.Random; +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import android.Manifest; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.filters.LargeTest; +import androidx.test.rule.ActivityTestRule; +import androidx.test.rule.GrantPermissionRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.kontalk.TestServerTest; +import org.kontalk.TestUtils; +import org.kontalk.crypto.Coder; +import org.kontalk.provider.MyMessages; +import org.kontalk.provider.MyUsers; +import org.kontalk.ui.ConversationsActivity; +import org.kontalk.util.MessageUtils; + +import static org.junit.Assert.assertNotNull; + + +/** + * An integration test to produce random conversations and other fancy things. + * Device must be already filled with contacts. This test will somewhat ruin the + * local database, so use with caution. + *
    + *
  • Mark all contacts as registered
  • + *
  • Take up to NUM_USERS users and generate random conversations
  • + *
  • Manual roster write to see all users in a random state
  • + *
+ */ +@RunWith(AndroidJUnit4.class) +@LargeTest +public class ShowreelTest extends TestServerTest { + + private static final int NUM_USERS = 20; + private static final int NUM_MESSAGES = 50; + + private static final int[] TEST_IN_STATUS = { + //MyMessages.Messages.STATUS_INCOMING, + MyMessages.Messages.STATUS_CONFIRMED, + }; + + private static final int[] TEST_OUT_STATUS = { + //MyMessages.Messages.STATUS_SENDING, + //MyMessages.Messages.STATUS_SENT, + MyMessages.Messages.STATUS_RECEIVED, + }; + + private static final String[] TEST_MESSAGES = { + "Suscipit orci interdum id", + "Litora hendrerit tincidunt neque amet tempus", + "Laoreet habitant", + "Rhoncus netus suscipit", + "Feugiat hac lectus rhoncus purus placerat eu varius", + "Justo mi ultricies pulvinar nulla", + "Lobortis varius vel per ante", + "Interdum dictumst cras metus cursus eleifend", + "Etiam nulla metus sem laoreet facilisis potenti posuere", + "Condimentum elementum habitant", + "Elit eget quisque ullamcorper porttitor nullam quam a", + "Volutpat conubia nam sodales facilisis blandit", + "Fringilla duis volutpat", + "Varius pharetra lacus", + "Elit", + "Dolor", + "Fringilla quisque", + "Commodo malesuada auctor nisl consequat", + "Non conubia purus pretium a", + "Habitant suspendisse urna sit", + "Senectus habitant est", + "Nisi sodales ipsum", + "Fames ad quisque taciti viverra etiam risus", + "Semper", + "Urna pharetra donec habitant quisque fames", + "Feugiat augue praesent placerat tincidunt quis praesent curabitur", + "Sit per pharetra", + "Netus posuere velit consequat semper luctus sem sed", + "Platea integer ultricies nunc", + "Rutrum aliquam pharetra aenean lorem", + "Eleifend convallis phasellus aenean magna", + "Pellentesque duis", + "Et", + "Nam mollis urna commodo eros", + "Arcu felis fermentum suscipit tempus nisl tellus bibendum", + "Tortor nisl taciti sem donec", + "Viverra posuere risus ac phasellus feugiat ut", + "Donec a tempor diam sed tempus sociosqu", + "Aptent inceptos pharetra primis", + "Lectus maecenas congue accumsan", + "Senectus habitant est", + "Nisi sodales ipsum", + "Fames ad quisque taciti viverra etiam risus", + "Semper", + "Urna pharetra donec habitant quisque fames", + "Feugiat augue praesent placerat tincidunt quis praesent curabitur", + "Sit per pharetra", + "Netus posuere velit consequat semper luctus sem sed", + "Platea integer ultricies nunc", + "Rutrum aliquam pharetra aenean lorem", + "Eleifend convallis phasellus aenean magna", + "Pellentesque duis", + "Et", + "Nam mollis urna commodo eros", + "Arcu felis fermentum suscipit tempus nisl tellus bibendum", + "Tortor nisl taciti sem donec", + "Viverra posuere risus ac phasellus feugiat ut", + "Donec a tempor diam sed tempus sociosqu", + "Aptent inceptos pharetra primis", + "Lectus maecenas congue accumsan", + "Sociosqu dolor", + "Integer luctus ut himenaeos eget turpis", + "Imperdiet proin tincidunt etiam orci taciti rhoncus magna", + "Tortor habitasse ut etiam tempor", + "Ante molestie neque", + "Pellentesque himenaeos ac consequat justo", + "Porta varius", + "Arcu", + "Condimentum eget pharetra", + "Interdum sem sit nibh ultricies ac maecenas", + "Sodales ipsum donec leo suspendisse", + "Taciti facilisis luctus ad bibendum purus quis", + "Habitasse primis felis tristique sagittis", + "Consectetur ac interdum", + "Mattis praesent", + "Ante ultricies odio", + "Gravida", + "Aliquam phasellus sociosqu curabitur viverra odio senectus", + "A cursus posuere", + "Dictumst euismod sit condimentum blandit netus", + "Amet", + "Magna nullam aenean", + "Pulvinar massa rhoncus", + "Tempor fermentum ac massa interdum", + "Vehicula commodo bibendum pharetra congue ullamcorper libero", + "Justo faucibus imperdiet habitasse at condimentum", + "Porta", + "Morbi suspendisse nisi integer", + "Mi in fusce per aliquam", + "Congue ornare", + "Molestie vehicula lectus", + "Ultricies mollis eleifend lectus", + "Accumsan aliquet bibendum ut", + "Feugiat tristique neque convallis sagittis lorem lectus primis", + "Condimentum vehicula lacinia venenatis risus proin fringilla per", + "Commodo odio porttitor leo lorem aliquam", + "Leo scelerisque", + "Malesuada hac ullamcorper conubia tristique aptent aenean", + "Arcu non fusce gravida lectus eu porttitor", + "Nulla", + "Suscipit orci interdum id", + "Litora hendrerit tincidunt neque amet tempus", + "Laoreet habitant", + "Rhoncus netus suscipit", + "Feugiat hac lectus rhoncus purus placerat eu varius", + "Justo mi ultricies pulvinar nulla", + "Lobortis varius vel per ante", + "Interdum dictumst cras metus cursus eleifend", + "Etiam nulla metus sem laoreet facilisis potenti posuere", + "Condimentum elementum habitant", + "Elit eget quisque ullamcorper porttitor nullam quam a", + "Volutpat conubia nam sodales facilisis blandit", + "Fringilla duis volutpat", + "Varius pharetra lacus", + "Elit", + "Dolor", + "Fringilla quisque", + "Commodo malesuada auctor nisl consequat", + "Non conubia purus pretium a", + "Habitant suspendisse urna sit", + }; + + @Rule + public ActivityTestRule mActivityTestRule = + new ActivityTestRule<>(ConversationsActivity.class); + + @Rule + public GrantPermissionRule mPermissionRule = + GrantPermissionRule.grant(Manifest.permission.READ_PHONE_STATE, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.CAMERA); + + @Before + public void setUp() throws Exception { + TestUtils.skipIfDefaultAccountDoesNotExist(); + super.setUp(); + } + + private void markAllUsersRegistered() { + ContentValues registered = new ContentValues(1); + registered.put(MyUsers.Users.REGISTERED, 1); + InstrumentationRegistry.getInstrumentation().getTargetContext() + .getContentResolver().update(MyUsers.Users.CONTENT_URI, registered, null, null); + } + + private void deleteAllThreads() { + InstrumentationRegistry.getInstrumentation().getTargetContext() + .getContentResolver().delete(MyMessages.Threads.Conversations.CONTENT_URI, null, null); + } + + private long randomPastTime() { + return System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7); + } + + private Uri insertRandomMessage(String peer, long time) { + byte[] content = TEST_MESSAGES[new Random() + .nextInt(TEST_MESSAGES.length)].getBytes(); + int direction = new Random().nextInt(2); + int status; + if (direction == MyMessages.Messages.DIRECTION_IN) { + status = TEST_IN_STATUS[new Random().nextInt(TEST_IN_STATUS.length)]; + } + else { + status = TEST_OUT_STATUS[new Random().nextInt(TEST_OUT_STATUS.length)]; + } + + // save to local storage + ContentValues values = new ContentValues(); + values.put(MyMessages.Messages.MESSAGE_ID, MessageUtils.messageId()); + values.put(MyMessages.Messages.PEER, peer); + + values.put(MyMessages.Messages.BODY_CONTENT, content); + values.put(MyMessages.Messages.BODY_LENGTH, content.length); + values.put(MyMessages.Messages.BODY_MIME, "text/plain"); + + values.put(MyMessages.Messages.ENCRYPTED, false); + values.put(MyMessages.Messages.SECURITY_FLAGS, Coder.SECURITY_BASIC); + + values.put(MyMessages.Messages.SERVER_TIMESTAMP, time); + + values.put(MyMessages.Messages.STATUS, status); + values.put(MyMessages.Messages.STATUS_CHANGED, time); + // group commands don't get notifications + values.put(MyMessages.Messages.UNREAD, false); + values.put(MyMessages.Messages.NEW, false); + values.put(MyMessages.Messages.DIRECTION, direction); + values.put(MyMessages.Messages.TIMESTAMP, time); + + return InstrumentationRegistry.getInstrumentation().getTargetContext() + .getContentResolver().insert(MyMessages.Messages.CONTENT_URI, values); + } + + @Test + public void fillData() { + markAllUsersRegistered(); + deleteAllThreads(); + + Cursor users = InstrumentationRegistry.getInstrumentation().getTargetContext() + .getContentResolver().query(MyUsers.Users.CONTENT_URI, + new String[]{MyUsers.Users.JID}, null, null, null); + assertNotNull(users); + int count = 0; + while (users.moveToNext() && count < NUM_USERS) { + long time = randomPastTime(); + for (int i = 0; i < NUM_MESSAGES; i++) { + assertNotNull(insertRandomMessage(users.getString(0), time)); + time += TimeUnit.HOURS.toMillis(new Random().nextInt(6)); + } + count++; + } + users.close(); + } + +} diff --git a/app/src/androidTest/java/org/kontalk/provider/DatabaseHelpersTest.java b/app/src/androidTest/java/org/kontalk/provider/DatabaseHelpersTest.java new file mode 100644 index 000000000..54c404c5c --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/provider/DatabaseHelpersTest.java @@ -0,0 +1,128 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.provider; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Locale; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.filters.LargeTest; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import android.util.Log; + + +/** + * Automated database upgrade tests. + * Inspired by https://riggaroo.co.za/automated-testing-sqlite-database-upgrades-android/ + */ +@RunWith(AndroidJUnit4.class) +@LargeTest +public class DatabaseHelpersTest { + + @Test + public void testMessagesDatabaseUpgrades() throws IOException { + // ensure database is closed + new MessagesProvider.DatabaseHelper(InstrumentationRegistry.getInstrumentation().getTargetContext()).close(); + + for (int i = 8; i < MessagesProvider.DatabaseHelper.DATABASE_VERSION; i++) { + MessagesProvider.DatabaseHelper dbHelper = null; + try { + copyDatabase("messages", MessagesProvider.DatabaseHelper.DATABASE_NAME, i); + Log.d(getClass().getSimpleName(), "Testing messages upgrade from version: " + i); + } + catch (FileNotFoundException e) { + Log.d(getClass().getSimpleName(), "Skipping messages upgrade from version: " + i); + continue; + } + + try { + dbHelper = new MessagesProvider + .DatabaseHelper(InstrumentationRegistry.getInstrumentation().getTargetContext()); + Assert.assertEquals(MessagesProvider.DatabaseHelper.DATABASE_VERSION, + dbHelper.getWritableDatabase().getVersion()); + } + finally { + if (dbHelper != null) + dbHelper.close(); + } + } + } + + @Test + public void testUsersDatabaseUpgrades() throws IOException { + new UsersProvider.DatabaseHelper(InstrumentationRegistry.getInstrumentation().getTargetContext()).close(); + + for (int i = 7; i < UsersProvider.DATABASE_VERSION; i++) { + UsersProvider.DatabaseHelper dbHelper = null; + try { + copyDatabase("users", UsersProvider.DATABASE_NAME, i); + Log.d(getClass().getSimpleName(), "Testing users upgrade from version: " + i); + } + catch (FileNotFoundException e) { + Log.d(getClass().getSimpleName(), "Skipping users upgrade from version: " + i); + continue; + } + + try { + dbHelper = new UsersProvider + .DatabaseHelper(InstrumentationRegistry.getInstrumentation().getTargetContext()); + Assert.assertEquals(UsersProvider.DATABASE_VERSION, + dbHelper.getWritableDatabase().getVersion()); + } + finally { + if (dbHelper != null) + dbHelper.close(); + } + } + } + + private void copyDatabase(String prefix, String dbNameDest, int version) throws IOException { + String dbPath = InstrumentationRegistry.getInstrumentation().getTargetContext() + .getDatabasePath(dbNameDest).getAbsolutePath(); + + String dbName = String.format(Locale.US, prefix + "_v%d.db", version); + InputStream mInput = InstrumentationRegistry.getInstrumentation().getContext().getAssets().open(dbName); + + File db = new File(dbPath); + if (!db.exists()){ + db.getParentFile().mkdirs(); + db.createNewFile(); + } + OutputStream mOutput = new FileOutputStream(dbPath); + byte[] mBuffer = new byte[1024]; + int mLength; + while ((mLength = mInput.read(mBuffer)) > 0) { + mOutput.write(mBuffer, 0, mLength); + } + mOutput.flush(); + mOutput.close(); + mInput.close(); + } + +} diff --git a/app/src/androidTest/java/org/kontalk/provider/MessagesProviderTest.java b/app/src/androidTest/java/org/kontalk/provider/MessagesProviderTest.java new file mode 100644 index 000000000..32e9a127d --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/provider/MessagesProviderTest.java @@ -0,0 +1,226 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.provider; + +import java.lang.reflect.Field; + +import org.jivesoftware.smack.util.StringUtils; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.jxmpp.util.XmppStringUtils; + +import android.annotation.TargetApi; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.rule.provider.ProviderTestRule; + +import org.kontalk.provider.MyMessages.Groups; +import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.provider.MyMessages.Threads; +import org.kontalk.util.DataUtils; +import org.kontalk.util.MessageUtils; +import org.kontalk.util.XMPPUtils; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.arrayContainingInAnyOrder; +import static org.junit.Assert.*; + + +@RunWith(AndroidJUnit4.class) +public class MessagesProviderTest { + + @Rule + public ProviderTestRule mProviderRule = + new ProviderTestRule.Builder(MessagesProvider.class, MessagesProvider.AUTHORITY).build(); + + private Context mContext; + + private static final String TEST_USERID = XmppStringUtils + .completeJidFrom(XMPPUtils.createLocalpart("+15555215554"), "prime.kontalk.net"); + + @Before + public void setUp() throws ReflectiveOperationException { + // damn it Google.. + final Field contextField = mProviderRule.getClass().getDeclaredField("context"); + contextField.setAccessible(true); + mContext = (Context) contextField.get(mProviderRule); + } + + @Test + public void testSetup() { + assertQuery(Messages.CONTENT_URI); + assertQuery(Threads.CONTENT_URI); + } + + @Test + public void testInsertMessages() { + String msgId = MessageUtils.messageId(); + Uri msg = MessagesProviderClient.newOutgoingMessage(mContext, + msgId, TEST_USERID, "Test message for you", true, 0); + assertNotNull(msg); + assertQueryValues(msg, + Messages.MESSAGE_ID, msgId, + Messages.BODY_CONTENT, "Test message for you", + Messages.BODY_MIME, "text/plain", + Messages.DIRECTION, String.valueOf(Messages.DIRECTION_OUT)); + } + + @Test + public void testDeleteMessage() { + String msgId = MessageUtils.messageId(); + + Uri msg = MessagesProviderClient.newOutgoingMessage(mContext, + msgId, TEST_USERID, "Test message for you", true, 0); + assertNotNull(msg); + MessagesProviderClient.deleteMessage(mContext, ContentUris.parseId(msg)); + assertQueryCount(msg, 0); + } + + @Test + public void testDeleteThread() { + String msgId = MessageUtils.messageId(); + Uri msg = MessagesProviderClient.newOutgoingMessage(mContext, + msgId, TEST_USERID, "Test message for you", true, 0); + assertNotNull(msg); + long threadId = MessagesProviderClient.getThreadByMessage(mContext, msg); + assertTrue(threadId > 0); + MessagesProviderClient.deleteThread(mContext, threadId, false); + assertQueryCount(msg, 0); + assertQueryCount(ContentUris.withAppendedId(Threads.CONTENT_URI, threadId), 0); + } + + @Test + public void testCreateGroup() { + String groupId = StringUtils.randomString(20); + String groupOwner = TEST_USERID; + String groupJid = KontalkGroupCommands.createGroupJid(groupId, groupOwner); + String[] members = { + "alice@prime.kontalk.net", + "bob@prime.kontalk.net", + "charlie@prime.kontalk.net", + }; + long threadId = MessagesProviderClient.createGroupThread(mContext, groupJid, null, + members, ""); + assertTrue(threadId > 0); + assertQueryCount(ContentUris.withAppendedId(Threads.CONTENT_URI, threadId), 1); + + assertQueryValues(Groups.getUri(groupJid), + Groups.THREAD_ID, String.valueOf(threadId)); + + String[] actualMembers = MessagesProviderClient.getGroupMembers(mContext, groupJid, 0); + assertThat(actualMembers, arrayContainingInAnyOrder(members)); + } + + @Test + public void testGroupAddRemove() { + String groupId = StringUtils.randomString(20); + String groupOwner = TEST_USERID; + String groupJid = KontalkGroupCommands.createGroupJid(groupId, groupOwner); + String[] members = { + "alice@prime.kontalk.net", + "bob@prime.kontalk.net", + }; + long threadId = MessagesProviderClient.createGroupThread(mContext, groupJid, null, + members, ""); + assertTrue(threadId > 0); + + // add a user now + MessagesProviderClient.addGroupMembers(mContext, groupJid, + new String[] { "charlie@prime.kontalk.net" }, true); + + // user list should return the same list as per create message + String[] actualMembers = MessagesProviderClient.getGroupMembers(mContext, groupJid, 0); + assertThat(actualMembers, arrayContainingInAnyOrder(members)); + + // clear pending flag + mContext.getContentResolver().update(Groups + .getMembersUri(groupJid).buildUpon() + .appendPath("charlie@prime.kontalk.net") + .appendQueryParameter(Messages.CLEAR_PENDING, String.valueOf(Groups.MEMBER_PENDING_ADDED)) + .build(), null, null, null); + + // user list should return charlie too now + actualMembers = MessagesProviderClient.getGroupMembers(mContext, groupJid, 0); + members = DataUtils.concatenate(members, "charlie@prime.kontalk.net"); + assertThat(actualMembers, arrayContainingInAnyOrder(members)); + } + + /** Tries to reproduce issue #761. */ + @Test + public void testEmptyPeer() { + // from MessagingNotification + String query = MyMessages.Threads.NEW + " <> 0 AND " + + MyMessages.Threads.DIRECTION + " = " + Messages.DIRECTION_IN + + " AND " + MyMessages.Threads.PEER + " <> ? AND " + + Groups.GROUP_JID + " <> ?"; + String[] args = { "", "" }; + Cursor c = mProviderRule.getResolver().query(Threads.CONTENT_URI, null, query, args, null); + assertNotNull(c); + c.close(); + args = new String[] { " ", " " }; + c = mProviderRule.getResolver().query(Threads.CONTENT_URI, null, query, args, null); + assertNotNull(c); + c.close(); + } + + private void assertQuery(Uri uri) { + Cursor c = mProviderRule.getResolver().query(uri, null, null, null, null); + assertNotNull(c); + c.close(); + } + + private void assertQueryCount(Uri uri, int count) { + Cursor c = mProviderRule.getResolver().query(uri, null, null, null, null); + assertNotNull(c); + assertEquals(count, c.getCount()); + c.close(); + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + private void assertQueryValues(Uri uri, String... columnsExpected) { + String[] columns = new String[columnsExpected.length / 2]; + for (int i = 0; i < columns.length; i++) + columns[i] = columnsExpected[i*2]; + + Cursor c = mProviderRule.getResolver().query(uri, columns, null, null, null); + assertNotNull(c); + assertTrue(c.moveToFirst()); + + for (int i = 0; i < columns.length; i++) { + String expected = columnsExpected[i*2+1]; + String actual; + if (c.getType(i) == Cursor.FIELD_TYPE_BLOB) { + actual = new String(c.getBlob(i)); + } + else { + actual = c.getString(i); + } + assertEquals(expected, actual); + } + + c.close(); + } + +} diff --git a/app/src/androidTest/java/org/kontalk/provider/UsersProviderTest.java b/app/src/androidTest/java/org/kontalk/provider/UsersProviderTest.java new file mode 100644 index 000000000..10a8cd095 --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/provider/UsersProviderTest.java @@ -0,0 +1,528 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.provider; + +import java.io.IOException; +import java.lang.reflect.Field; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.jxmpp.util.XmppStringUtils; +import org.kontalk.util.XMPPUtils; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKeyRing; + +import android.annotation.TargetApi; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.rule.provider.ProviderTestRule; + +import android.util.Base64; + +import org.kontalk.crypto.PGP; + +import static org.junit.Assert.*; + + +@RunWith(AndroidJUnit4.class) +public class UsersProviderTest { + + @Rule + public ProviderTestRule mProviderRule = + new ProviderTestRule.Builder(UsersProvider.class, UsersProvider.AUTHORITY).build(); + + private Context mContext; + + private static final String TEST_USERID = XmppStringUtils + .completeJidFrom(XMPPUtils.createLocalpart("+15555215554"), "prime.kontalk.net"); + private static final String TEST_KEYDATA = + "mQENBFRhD1UBCADEtFP2NtlMbcPtIsWBoCnwNkuFT0RF9QLr2G9/UD4wbaoRksgbh3qplYkpgN0O\n" + + "HWzi7ea3YbpOCJPDZQLut5a2W8Bl7O1sAjwxLv9qUhDwSWLa9KS28nqh6TbtxtNl86/2JweFhBLe\n" + + "a2x8/EoKEe6mZkKhTc80kPEifvX5jib37VJK08gHyUALi3MuQbIT3uSFqN5PSGmGJNzPN+zt2tg2\n" + + "ek7gN/ve/+3Pc+m5gDPAxRbnpr4rA14z0JQJHEQi4AQM4uqcPIBKIWTBPjAfjcpS90BX7HJTZTkL\n" + + "HiLIbj9VsEF0BHYjABIqdHya13EDrOvihefg1tFXMhtyv4G/DiAfABEBAAG0SERldmljZS01NTU0\n" + + "IDw0YmRkNGY5MjlmM2ExMDYyMjUzZTRlNDk2YmFmYmEwYmRmYjVkYjc1QHByaW1lLmtvbnRhbGsu\n" + + "bmV0PokBOQQTAQIAIwUCVGEPVQIbAwcLCQgHAwIBBhUIAgkKCwQWAgMBAh4BAheAAAoJEGobfA1w\n" + + "X55P85EIAKvXj/b+MQYSaoMlsihzo7K0X8Tkf+krT1UZm+VUibexVjRRbzSKOBqTtg0miI6ptd7g\n" + + "Myppz//a60tFsXTrgDAlKzxVgqAT3ppmYZcOKg+o09vjSa3YCz2FWMQ2GCnVCAPlARGGZasXs093\n" + + "FJS4ULy0yFwipcQERv3KNp+T6eDdZQbLktKRSwvVbsF3cIcaONjTj5yfL7uJ4teh8HFVpiJTtdST\n" + + "2XB/1iRRDPB7x1lAAkNf061fJxMMLeCFPrTFgBD5uEqXsbIpOMY/NMCDtcSG986mbPYPoWQG09f5\n" + + "jMy4Q1UFCasp+mwkeXnEru/DOmHcqy92zH/wtrkWGVZpM4OIXgQQEQgABgUCVGETAQAKCRBMlTm0\n" + + "AfginODPAQCubTYIUk9qCjqEm4UEy1MTAhc1bkTpu7zCtqGz4bodiQD/d5q67oRFZbmGl5Qu0TC6\n" + + "uYEA60YWvBtXGPyhaOgaPr25AQ0EVGEPVQEIAMeuSb/IwVJsT4F+TWg6QmRtxnxyXu1pRNn8wvy/\n" + + "PPV2iY099u2ZsHrTiGPww5jW8xCIZRtnBIy6p8VbIym0dd6Z8/4jekSmea780nNY494bS42xBsrA\n" + + "FRLn/9V/i9nUfMrv1pLh+MqfBmrYD+rJcRFAJngwGvV3uj9G8c8VzQNooCROqLktPinKf//y/5w1\n" + + "PJqBnfz4v9WEZd6YNk3UoMSa7LF9iU+aiLV8R0EP0l1RSoaUw93YrqDPLVSw/xwgQlQ3h/VjNcwd\n" + + "7mtpDv11TMSBNZi8RHxM3OqmtDgaypmGnyFprhlLYM2cqGLxvSu+ZssHFRpxWydpO3uPxlEMavsA\n" + + "EQEAAYkBHwQYAQIACQUCVGEPVQIbDAAKCRBqG3wNcF+eT3LkB/wPfRaRc5WfaiEjQAgvvjF2ijde\n" + + "yZ1zhi6k4zVHNfjfSInls6f4M/DAzch1BOF89A3AnV1+cPeoWk4qTJ8I1VagnzOIhXVJ75L46jgJ\n" + + "EGIo40HI5JJEQ2dXHdMJZu8iXjUNqIrOQq/Fo+BpbpRIt5lcoRAY6t+7Qemt3tyzgU8pZcOnbpcS\n" + + "Bd5yEnSKD6TkihNX0p5oJeY+F9rPLHUJRt2qbhhNr6edqRa45hD24hnsj3OKLqutzKvJJnvUDJ4A\n" + + "+Dr/IcHk1aj0uUmY4AU7d3CEFHpKFtJnOzcAKu051H+RfSOD9pSq/btZsd6ERSD8U934eee9EErW\n" + + "yxG9umnl9G+HmQENBFa2MEoBCADvCZPTPKnW4QMZOseUVx1HybSTQ93VRtczEh01WLYBxV0X9FPC\n" + + "0Q/ttaV425UJa61ntZgDkvURnNOi86njoJrAzNouv6aOOyPrpphOjEUMUyxw2trq+JRmohFK5Kgm\n" + + "tvCffoheX5yOXx1LhUwjnsecPmZFIejd0ILExG2ziwbLMFMXv26UtN3rqLJHN0vFOd8yrigfSr4A\n" + + "TnzY9egIDdubMHgejg8nY83f72E294zuYR9hoAQL3NIML6dkcsbq9hjxidIroa4C7BMOd0KVh7Ge\n" + + "lNTF9qpUuMWTLQzhNuevw4fIE/+VJqcCG3pvQ4My89oIfrtmthTXwqQe7oUkHIzZABEBAAG0RWRl\n" + + "di01NTU0IDw0YmRkNGY5MjlmM2ExMDYyMjUzZTRlNDk2YmFmYmEwYmRmYjVkYjc1QHByaW1lLmtv\n" + + "bnRhbGsubmV0PokBIgQTAQgADAUCVrY0rQIbIQIZAQAKCRCsbYLm7FNQ943ZB/9uLdpCJaQo0ty6\n" + + "uF0wtYo/c9biiPFRp6Sg3+pwmU5VOQ2kup9edOY5pjm4OL2kQUSn0Skij0u4Yx78qSQhZibw0nPv\n" + + "wsHJjtZzfl8mI0Ojc9hMdwk+Z9MTvOIIBgN3az9bDlp7BAhBdOO6z1Hzhp3I7/b+wJHRQT8QFgBT\n" + + "+tTcsPtneIx4w3LFfSmE8pTvJWE2A0ilCiEc8crmPK06wKY9lgYlFiHMwAWmawZH6vC9RBHAbkX9\n" + + "EErNy0FOIITa3ynJz7GJzyd0jHov7fM206h8Tj6ABeOom72/tsh8Z3pAoRYS9kjVqDusH8eZQEua\n" + + "GHutw1LV+ET9ZPWfBHAONaJWiF4EEBEIAAYFAla2NLIACgkQTJU5tAH4Ipz+tQD/XsdEmXAisinO\n" + + "uFgGjND8N14w6J37AtEs32X0IhcCluQA/36JcJ2ZKhpn4tWUvLfZ71RZWgJ8HffjgQNQRqfHGjGQ\n" + + "uFIEVrYwShMIKoZIzj0DAQcCAwS5KFXuN2k7tXPAADf/SynCxhj8U6JxdhGHmD5PZMBlv8/Zjuy6\n" + + "4gXjYafyBS4vjT+Kd/+f+hvx8U55qMC02INTiQF/BBgBCABpBQJWtjStAhsCXyAEGRMIAAYFAla2\n" + + "NK0ACgkQe4E/yHlofSzYRQEA4eZZyh9VZgSRyyzCtZx9FBz8biT/34lWzDPh29h5cCYBAJ71FeKq\n" + + "IUdKgZb2MtXOmHfzDvU72Q5ItAUQDQAaMHcuAAoJEKxtgubsU1D3S34H/1ZY241ByQuCgAi2RY34\n" + + "ol2eTReE4kcnLe2FYbFzY20RhXBhk+4EkfIqPn2KcM0n7G4vZf6ZNZTKMUBiwkcNYJPwjrOmVz08\n" + + "5vNIVGx0xJHo8OZOJwmZLLjpR0J1O1RaiPVwuZsQLiBj/Cs0aEehYpAP4XPmT6fZiKFNUsNDo27o\n" + + "R46csqRz4VG4YUQgZEqt58mhBAiJff6oLVlT1enx9f0rxX5z5vboNCGAYXlFoVMXX7jk+DIaZROy\n" + + "JMgqMS8su8OOevZSrcQsrNSo9DoopnMCryOAhUFQA4o5iw1gXVtJO3cNiKn/aHxB4scLNwMN2vLy\n" + + "7Cl3i7BHL5A6GE0Qc3a4VgRWtjBKEggqhkjOPQMBBwIDBDPWe6IEO8GspcAWgh9FEUfIav856gHQ\n" + + "S1dtyEFrLkMcor8wUc8o6rI3yRQbjKP+vrYbGLLki6mdv+Z/t1KCIegDAQgHiQEfBBgBCAAJBQJW\n" + + "tjStAhsEAAoJEKxtgubsU1D3h7IIALjgIaAMUDqjXBKwHE7eaW36+q9GsZ7GTVapOiv/8VVUQf28\n" + + "MolI95pHWTDfMpX+Inj7KbsHkrgzMrx0yOniqcIatXgykohcMOZlnL4L9NIf6SQFzi+dJsDDjjvH\n" + + "YpEi9rKguz53qdZapBVAroc83dFYkxaEiEjEd0BBImlajkbYDXTXdWB78EBIc2F+sbp6jrwrU7z1\n" + + "XkcKEmJOMsFNXe1pyjjdmxYjL1IkKXE5rYxrfzgpu3vbz8C8T3lXPQk93wiw9jI9RWs5C/JWxdqa\n" + + "9PLiH+AU+b6M3D6r4FaCKAxuPyXkrA3FWMRdDzyyqfNSOpyC2wyySa9yvQTmzRQFSrSZAQ0EVvBN\n" + + "6AEIAOWSKCdFgwFBdDTs8Y0OpZ6YGepvt7c84A6N4oIfHOeJaXy8Kl4psM5iamv5bGpKZ+jpnHWz\n" + + "GvVqzNPeND5fqTwoL2LPQp5+vixmbTznWf+T2fr1Tm6nphrhyev/SknkIhbTSuegqtmaMe3FN58u\n" + + "p03YAqAyPK2XZs+gUMkg3mDq/sLNl3LuqErNrYhk7gkWyI4z7AmUrQ03oxpDf+MwsUQMSVV5WcIl\n" + + "LNYBbvK9yKGh9+CPt4l5+NMkQDs3fsnMWHo+k8MGw+rqLzPzbjEuylWzh2Ey51fEDHc6rFrz/IlK\n" + + "K5C3u6TzJM70zKoTitq+1K6+hNQE3e9WJAxr7FjeRn8AEQEAAbRFZGV2LTU1NTQgPDRiZGQ0Zjky\n" + + "OWYzYTEwNjIyNTNlNGU0OTZiYWZiYTBiZGZiNWRiNzVAcHJpbWUua29udGFsay5uZXQ+iQEiBBMB\n" + + "CAAMBQJW8E4AAhshAhkBAAoJEMnHtUzO0cXZKBQH/2nID1x1NCELsJowD05I7qWeV3unMdPo1ldb\n" + + "GQ3B0EUxIndLn1qAPo/0k5YSqpyekJG8taV7gTv8Qao2wgtYojIcEVBS8KhrTVNGiQJk1avIiJ5R\n" + + "XQ6HWTILYxI14DNY8Dod+U6b2+XG7ZmNrTp0mlyHlKAk+WoSvPXi++ci6A4Z4zORC5Ol41icB/2K\n" + + "kzE1HRGdE5ZBzg8DvunaprHr8ulieyT4dDFo/DhUtAEw4sjp7fKotbXN1krFSqZDsD052mou4s9y\n" + + "qI5uKuknogaoBKtKuwtsM841v+ggWUs4HWZBCXTllSBixlbD2+kbefzHDbHnE4BHKpG/JoeBG57W\n" + + "lPyIXgQQEQgABgUCVvBOGwAKCRBMlTm0AfginLO7AP4rdOmexsK/qenzzSvxK27PuMXWHwt92SsZ\n" + + "KcMvkmnVBAD/daeASjydi7UXLwio9gqUzsEulFHsR8zG3AmInbtEetS4UgRW8E3oEwgqhkjOPQMB\n" + + "BwIDBC5BbfC83NgBuuT4ncXB0zocEZhccXct66tTX1khY4IbUGXvovXd6bux5pf3U/SnXGAi/ETX\n" + + "Hapgqx6+tI3KgfuJAX8EGAEIAGkFAlbwTgECGwJfIAQZEwgABgUCVvBOAQAKCRDh8qX+rsMyaaUy\n" + + "AQC+8hNsTD40BqqRdl59zhWJiBMSLumQNQfYCmh0SMwN6AEAg0PVEOXRgFuFrM85OhCtTyJt0AB/\n" + + "T5qod1UC7i1uVd4ACgkQyce1TM7Rxdnj/wf/cbrZOw5iB42TOBvE3mmaOuk15BTO9j7DX+demYWw\n" + + "imAjKSWAyeanD3Jbi8n0bqaOorz3EwVcJpFAtlps3AYYPZqqRyQkqdFbxHuM16paxuJ1aPgCpD9x\n" + + "a/UE2TseZWl+V/m3xs+hRFw55uFxh0E5W/5s80pi9WAKvWOhmNTGHqi5gXaZd+YT40/iI3Z2DhGB\n" + + "yMj24ENU90WUP45rOVkZQfMrCT/HX/2Roy7jq59riHufXTuE4+8F3U0DSD+2CxgpU41WmkjY7HEP\n" + + "67o3BKIRfP9+xwCGRxuXo8twqpjTJwyfYHUh0cRNHnzgoujFD2dJH3tz5uJLNwGaZHT5XDf3XLhW\n" + + "BFbwTegSCCqGSM49AwEHAgMEc5mbIH9tsF0FvZfi4/nGnT1oUgpUpcAb8Uqwmi8sgKdSGha5XVp9\n" + + "WIBuU7yuDDBXLk7S1322lLizEFL9ll55WAMBCAeJAR8EGAEIAAkFAlbwTgECGwQACgkQyce1TM7R\n" + + "xdkOkwgAncUWlZPZ1inLokbbgTnuDDfzvzMd3eNP9aZBcMyoh6cfbFKyJZ6MEU0F7liifNriAKey\n" + + "khL8dPql7KJfJQEnW+YXLsVV/1lTkr9zgHs9uSfPuzf0TlvwXfywvHFN2Cd97fpHCNjGp/2uh0C9\n" + + "0EcUmpJkE6QufNOY5KOi0MNIDKo5fyf8JQEenlBBxTwsaBquvM9mzGKm2AMUF+ECr6K+wP/s7HCY\n" + + "PB+cUBVBl/3I/Sj6BvgInuEYekNFsuT0A9w+nFPlTb3+9axe0G/2Ab1mKAeWKK67ZNh1MBrykPFZ\n" + + "9oLDI236Z6ktpgFHNgXaUMfXDkMtbaL+Krfsc03q9GKZzpkBDQRXAXJUAQgAtXzniDT/hIae8hTn\n" + + "1g/9IwG64zlJsbfa8+5Cey7GQfnNAOijgpMsBJhgvuKYyV1wif1bUBX7bDlN0hAmACqciTbEiG4c\n" + + "VHSwlHLNylIjle62vNyZKinb8Jk6cMUlF3SAnY9N9ekr6joW3O6jBPrBJSNrKjHmVHgochdd8Z7/\n" + + "hSJsJHZdqIjF4uPWymRXadt3p/ofWxSpz7q2KLvrSBqR1/4LyVs/eMk6mVRjIj0nd1coeRJfN1sl\n" + + "K9r1/z4WHFXTevI7OIHxFvE4Wq968pnh1rQYptzfmFWO1Td5kWjuOBn24mV8cvFdIbs9f4g9Laj2\n" + + "LUgEqV2Rpx/8Y6m2HgOGvwARAQABtEVkZXYtNTU1NCA8NGJkZDRmOTI5ZjNhMTA2MjI1M2U0ZTQ5\n" + + "NmJhZmJhMGJkZmI1ZGI3NUBwcmltZS5rb250YWxrLm5ldD6JASIEEwEIAAwFAlcBcnICGyECGQEA\n" + + "CgkQDNleO6neOop/6AgAm+AkLnXZdSElzjnMxngb9U4vbsa9znyvOP8oAXDOvHKBoTBldGb2URcW\n" + + "RLbAB0/L+rSSaaI41tk7q7kEnAFugFqxUKBAP2r9LnQ6nQhyO0QQK4gAJmY5e52LPV9nSfuWWZzN\n" + + "0csyw3E/UIaZompIAIRewdZdpVSRZ8NVDFoDwBGE5sYWzpQtr6gQTrutuaN1jAKSsY8Pg2LZyhke\n" + + "wO1BnaYyO/2/rqnb7gsDVmnLmNK/ZQhJbvxk32cnDolXDxq5lca+fvpzq+uQJXV7Vrm71+gfjd3l\n" + + "ZNGw9qrifwAQxH/DHO05sRaKua4o7om/RioTq11P4NmF82dYuQH9v1KBHoheBBARCAAGBQJXAXLQ\n" + + "AAoJEEyVObQB+CKcQpQA/iq1m9P6UzNeA3Nltj3LsCkb23qFK8K8DWjmv51fdkQhAP9dKQc8Jli9\n" + + "H7I+zGjQXpCL6HPT7EM+l4g2taGyfRTkBIkBIgQTAQgADAUCVwFzSwIbIQIZAQAKCRAM2V47qd46\n" + + "isdnB/wP9SXxuZbOtuGy9nD47M4xapfUPp47/8wVfexWD/j1jja6nPHyLRBkGDpJMTzIyNzC/7wo\n" + + "YL9vvJP+jbvHF+KWwLe4/hS8vh2pfiaGtqxKjc/btbQNo8/4CYpUhWHGdfDX9EwfNQdoqY2O9Y8L\n" + + "PgBVWDriZ5HnvxlToNUwK6KGyZiviCoSd/njOHCjz9X4g0YKozbRGRUVwipu5Db4nio5R5ufAlcu\n" + + "5ybAB6fh1VA2aVqlq2Q1W05r3s90frdQ+COYR+QvABiNrEWk9nVWNLZ71xLceghBoEw3ecGQVwWH\n" + + "9QdLRxMet2TxJ0WBdNHEEmxaYCI1VsT2Dadtd9pkUd4ZuFIEVwFyVBMIKoZIzj0DAQcCAwQmwpq7\n" + + "8ypN8RY5/lwnv/31qXo+4BFEx4I2GaZI8t6ZbJx1506+gevXmZlTMvhswd7jm7cqqrPCePv6Piiq\n" + + "lyxgiQF/BBgBCABpBQJXAXJyAhsCXyAEGRMIAAYFAlcBcnIACgkQhBr/Ny4F8Pl2GwEAvwSCbWPI\n" + + "lY/hQTcp1dcF3lq8h/Sj/Z3D5bSPDHFD2XcBAPo3qyAO+sBrUetwiLTVhVtCSviMUqV9sZA4sso7\n" + + "c2DzAAoJEAzZXjup3jqKuBAH/3gv3gCQ8E5nUYeI3Wwu/FqOHsqq4MgILWUDjtmmXa4WNsbnVRPX\n" + + "1jvasUC3y4G7GtBEaleywSC6RZcfDylGDyHogtq38Y9gMBAyf+iGGCdu5md2uKkoafPOBLpPemjv\n" + + "UoHr53RsH7013RgT9dMfzOWEbq5ea+A4R1pK0c0AfX9hDPU7wiLcKTd7Tgv7nevYrTpIDpvxbb57\n" + + "qN/hfoF/lwunTTHqYHb9mXfK9D//on+NYgL6p5s1AVRCnGXQvDMD49tknHY7AOzr1vFIst8y+4/F\n" + + "x5XTbf58fJcrVV1zt5VRdNrX376N9+4OHrHRpPtWekEGmo/0qse1XEAJrUxnesaJAX8EGAEIAGkF\n" + + "AlcBc0sCGwJfIAQZEwgABgUCVwFzSwAKCRCEGv83LgXw+dwXAP4jArSbUjyC1c3eQdq6s0VnBCBJ\n" + + "KnwfurLm+bIiL0qILgEAyPJ9b3Iu4eVJj71LcZgu/zzIHZVdY0ynOLb7NlaaZ8AACgkQDNleO6ne\n" + + "OorCSAf/bLDokMBfpPQFX/7DvGmDqAz4u8PgrWNE668ftWJt0uBuYyQbSMIm/p6nJzRp6FLcsJY9\n" + + "g5aNKadCmhJpcLB4eyPUgOPZ4RhBE+l3ohgPjfPouMJWfSpMB8xo63oaP2i3sXg43Cok2TJDPbFl\n" + + "VwAIfcmrFw+oDCZrONHYa+zE7ezW1sBN7kN4r3UX24870d99qyNSDwbPeJAu8qCq8wBPv2ZWUyTF\n" + + "51Ml3Xyr3lPfAvoH6IoNWYb0ApRp/kCyMzzxtBe85LnUTI5Hy4qRS954fJo5xcLSrftTyvh0aCh7\n" + + "2+whCX7hcQ4EBp+mE//3v8+w28O8kafPwBQCipXK/l0V9rhWBFcBclQSCCqGSM49AwEHAgME5zEV\n" + + "vDEPpxo/+DrhwQNKo+c6SQUuG/nFP3tFeWE4Reopf41JwTmdKYLeped54nRrCNGJznggb8W9mYiQ\n" + + "1+WEAQMBCAeJAR8EGAEIAAkFAlcBcnICGwQACgkQDNleO6neOooEUQf7BpmM2ePUbiOT+jpIZXvX\n" + + "4KsSL2JtutuOJEUCD2Fz9JBAQ+XwD5ZCp60Lq2Dad9gxAvEuyZ6aoqi/YxuXgv3c0v9I7aW70TDY\n" + + "wFRYSS/PGU5vlINqYwWgdnhwNcnU9ZOh1POLJ+tj92/kPcQXo5fknP0F4UpHX81nktsXolzMe58L\n" + + "4ySlru+RsufLy+LRqZYpBvlJwYJnFkCzqSKhTXNFUhdAudAsA+4JRCBEhUq/aXMGUGqP9hFeJfqr\n" + + "mS6p+CPos0usf3AHhyBoOqwAtB2j83Nn83g4WodyvzzBiNNUdnCoN66IgmjZ9PKNKMsFyjpNomcQ\n" + + "ykUfWEkzT1pMebg8g4kBHwQYAQgACQUCVwFzSwIbBAAKCRAM2V47qd46igrUB/9RI1UW4lwFJKF8\n" + + "L1fkKmKiDfLSGH7FaJ0PLmgGMQLzuKVIDGV1DzugR2isvb7bl2mRJLHnU/oeDBCpZloqwZxWYQrf\n" + + "FDtEJyfgjOIbr+/oWMY+SxxYv8AFWBHP9/VtH3itYclLx6urfoByA41wu//iAaBQ8m8BYDVQOWk4\n" + + "M0/QgZv2Cc+3SFd41rt+Gdovcd8h7in5JYCNLlRjEdKCn3Hlh3l26mfhAyqWwpuoWEW0fDNR7Ccw\n" + + "oo+5KLvWsQ29v1kArceatyVmKxzmCHq9YQO3C5In7Fj677tOsq5xzxIe/RHpLoSTS7h2cmGjhqME\n" + + "i8LeRrlfYLILmEGGGFK9JDmdmQENBFcGsPUBCADeIRWevHHDL1c0SP3VdOwiVox3iIGiB1OX8+BG\n" + + "CmUTcuGEXljupg9u9HUqGTq3Jnf51ZZZRBCslTPA4HxICKatVFtKScZHisxwGl+RO6gmbzPyJIP7\n" + + "of93IalTvA1c8YaaauBMnpigcnFrfJnT47mhd3JTaWGWrTdz9wvETog8yGVhruW3UunsJAPOwGeQ\n" + + "IimHgkAeU/zkVdCes4E+gkAAIvdvWUfibPc6YwG+kEBgeg9K63vWbXfqR/zmLsKx/R77ZXp/ZIr7\n" + + "bn0FlIVML98Sgub2Gowkqhxva1bVL8ek+4y/fqFRGlM/dcQvgfhbPW/spuA81AqLSSmYQTJ3omst\n" + + "ABEBAAG0RWRldi01NTU0IDw0YmRkNGY5MjlmM2ExMDYyMjUzZTRlNDk2YmFmYmEwYmRmYjVkYjc1\n" + + "QHByaW1lLmtvbnRhbGsubmV0PokBIgQTAQgADAUCVwaw/AIbIQIZAQAKCRCcmnTIYWbV1lPiB/wP\n" + + "y1N6RQFgRc6/8p+D2282kzvBdZQesuruAk4AtauJgIgLIsKtomApdE2tbSTmlam9SwEbX+5a26kh\n" + + "PpMq+Kpkv1VvurkBw9zbsc9GJS2fYYsRdFjQ7rikD7J1NCnUFvtl6nkLImvJ27NVVVC9m7R3iDjF\n" + + "BKrB2VG8wugilPVAYe8Cfn2GBkw3LOgbuVQjg+VA+XHsVUEjd53wOyHql3imqQHkvo6S0NwcUXcA\n" + + "sRjuza8hFxtZEp+fhnvqt6cOL7s3Km0FBhyI2j7/qjvzV0AvirkG426j5gzh70Hv73wOowzJQCCv\n" + + "P5NFMH70s6PX2FwZC/mBI1Q5YajrIj+moI92iF4EEBEIAAYFAlcGsP4ACgkQTJU5tAH4IpwwPAEA\n" + + "vW+9PwasaaJHjOvOvUVu9DOXLuckICYAbiK1m/ytpwoA/ioznGp6IN5Y+H82vlpIJOylDdddxLPW\n" + + "vj0ODXa8tL9luFIEVwaw9RMIKoZIzj0DAQcCAwSLD7fuprnb1sFIcIpf8x2IwnXld5F/4SgbhOwl\n" + + "yhmtoJdqf/SzUC6m3DXdGcoqP4YTMsLM9o4vH8zZK3lpr6cQiQF/BBgBCABpBQJXBrD9AhsCXyAE\n" + + "GRMIAAYFAlcGsPwACgkQpXI1jR6vcV/jwAD/fGE7MikESr4C8Kw7zgbYB/sCFRqfaPre/137awf7\n" + + "UkAA/R7ELgzj/ixLeYxxeIGpvIFxVMQrSe7VRoPwoZytLJbfAAoJEJyadMhhZtXW+mAH+wRxOghP\n" + + "ACF/tT8zVnuxaPiFDeva+Wh5f8sjvJDm9GwQSgqzmILs3JmS3SxqpRYL+bOoBx7Qpl1CasFGOa6G\n" + + "y1ka1NqfQT9fx2K9dEc7fHcaHcVMYKl6CJyp+EcR6AUMmvKX9xZVZdozZDK/WavrfQs0kQOBmb8C\n" + + "cm9rtAzpR5OXx5K4F4hh5yCWAwq6eFN2/xseSJldNFczDnpc4KY0NTFDi0/qMVH99uJEzgxNMabW\n" + + "XHB7aT/+I5vUj+sXrjkIvwwKqWhjVY5VYPnnuAzfi7X2M0J+YlRgjnTXLYjar+P7paXDAX7i38mB\n" + + "hlgzvg1YsEBDAD34KtJSH82s+LQXrNO4VgRXBrD1EggqhkjOPQMBBwIDBJSp9L1CFe5C6/XZ5nf2\n" + + "RH1ts2vHXpR/0P/wDOjGkIzvGCxJjervmfnQEYCXRoVz4ZemKkcHgWm15iweVTYDuB0DAQgHiQEf\n" + + "BBgBCAAJBQJXBrD9AhsEAAoJEJyadMhhZtXW/yAH/2Zffczmg+IbmuhQS7DbVmTgBU4rgkbfSjdr\n" + + "WWr5fpXYJ85CTVV1BEqQfWFMx7rxkOmXbBcz2Rt8QGDl9RrtEKfMZbyEcPiOHZAM0uwp25Ws/7+J\n" + + "LNJm4irS3yw6pjaE1ncsoqAaKVE8LwrEACxFgfUkoufNcZxhpX5HD2kIpZeCqbLh+P/q6k+9ObyB\n" + + "NgrCLPaIa0K8SsCJuvjqk+xl6aHLnCcV9atYlUc3sxbD4wnIkGIzqUx6eczRuzzX9SZp/JKUu9Tg\n" + + "8JGjhKrD73HWEhDPRFDSVKfy3OIEddO3Y5+5b80D2AX1XY3Iz1GP5ibYL279ZFFdTZUaGFopZe2a\n" + + "t92ZAQ0EVxJ2twEIAMVs75oLxS8HvRceGQzG5bC3EiVai00+WBI3wHjMtqbSFxdyGShDJo8sN4mI\n" + + "yYrLGnKkSVVUdupd7Equx78l4lgGMRJMI8ka6d4E7IRx2BZ/PKtq2F8nlPqWQ4wb6TUQV2oYTQTb\n" + + "ETlz80xDiqfuCJ8GH57etQurWCcFudCnzMUMMIHymi+o38SUv5urhJ8jBVu9iKnY46sr7UtbK0se\n" + + "t0Vx9xb/qgfi2YBPcYq1iCi3Iqb/8w84bBfa1AOc/plWpLsG2XJw5hcTn1txs273UnR6i1P42agS\n" + + "ABYcoUqlRp/MPMSDHnDOPSkh5Vvw9EU4zmuoFO/JmcpPX+28t8ZIpoUAEQEAAbRFZGV2LTU1NTQg\n" + + "PDRiZGQ0ZjkyOWYzYTEwNjIyNTNlNGU0OTZiYWZiYTBiZGZiNWRiNzVAcHJpbWUua29udGFsay5u\n" + + "ZXQ+iQEiBBMBCAAMBQJXEnjMAhshAhkBAAoJEH7fpbgumeyxU7UH/0cANfQA0qVa74TeRbSsasKQ\n" + + "iwnbHaALTbxxTp4+aUJfX84sOhT+DALQf+1DyI/xDtjKY6wHPtk4vqzvp8uSwetKr+az5Rl9nEfL\n" + + "Lrrk+AZlSAONCXk9O3BfAtx5KBgXA1Jdw5VLGt8+2uHldcxNikvZr7mPoCX8ifAql/gHzAM83IhN\n" + + "7TsWOJ+q0HgwJz7fz2O19oPe0jYDRDD9Jz2BeekUtIyb7pARqNrx8wLbQ0e1IUfhR589PTH1Y+d7\n" + + "JOG8XVtcsU4k+HNoB4cwQR53cjoGhCPmbikHbb7SLcAJAM2YZ+t0yVXVmaFdZTrqy45+rfgVvrcb\n" + + "ReTiHRHcwwRimJCIXgQQEQgABgUCVxJ43gAKCRBMlTm0AfginONdAQDFl54BTlPWs2Uo2LlkKZZG\n" + + "JnGkPB7ucO8VnffQ+K8FsAEAw6tSAZoFXfNbSdWdLogsdjVwKJ87qH3lt5CxKeQSWrm4UgRXEna3\n" + + "EwgqhkjOPQMBBwIDBNaMYpECw2emjRRRzp0JtQ3ZBu/1vfEzRiQCdKFNweKSK0qItQEHGDmjy19s\n" + + "5tVx/0BAURs07YwgNYhSbYm3KLCJAX8EGAEIAGkFAlcSeMwCGwJfIAQZEwgABgUCVxJ4zAAKCRAt\n" + + "tPoxnhX3e6Q2AQCHaDlaTYuI9Sgx5R6OQxjzZfYDGu7niCoxHHGU2AlhZwD+IPvSrfm2+EE1a8Yb\n" + + "W1fOHWL8FGebULhOidBcoyhd9RgACgkQft+luC6Z7LF/yQgArXBlc56kp465YX8oIPPS1Wr5BL7c\n" + + "8uTYXviHEGL9Ll9wc5wJoKGhgJ8B0JiunIqc1ktlneZ43OAvX1lW2l/zDkPxr4AGSfRP/9jmYB12\n" + + "hoUx2M5UpX6zcRpRbuyjapfMpdZQP9VdUddQAazrc2dnULnyreoZousy0tLIqf4WKJRIRSKPutF3\n" + + "dmCnRdAB5D6TegmCJjNQZHI0slvZ6a7c4TOuOwXEc7GPv3pggRQJtb4p1leCY/dGyhZkWK5ht3AF\n" + + "FvDYYrzvHtSDDnF+57YQRQh2fDRFUy6Al0AvkFU96+g5UikqBgvPAeiyWeFUgcc99jVfZ1yoOTV3\n" + + "MH/iREN1JbhWBFcSdrcSCCqGSM49AwEHAgMEN8ddm97xrNYV9qqB4x3vlgKbrJTpbhgHZlmtRnly\n" + + "LqIhFj2E+ZdzTNY0rXAjWnd7G9qqqmffnwy3WR0hVCgzBQMBCAeJAR8EGAEIAAkFAlcSeMwCGwQA\n" + + "CgkQft+luC6Z7LGmvwf9E+nbgjexaMoc/gvLcxqOS+a97qbLTIIGgLQmvzY8RxJkbUAREbNKSL3M\n" + + "Ghcfn+1OfUdrESxpOaThCebp99yXL/rDzmRlZDR0giIBy4KaAYcDjb/LwHipEmIy/oZqhnuTmuEL\n" + + "Euy3q8kY/iIx+Hwt6emooMXwGKUt82Qgesa1fITtER1kfaS/cw3xs+usO9HvtzANcDwbCYnJoq14\n" + + "sBUcnkml6YaCPv3KHJt0fGvewArObZXVotjeXe1LZUQPizHeUsM+tjomSt3jvWBiL2RZQmU+pO2+\n" + + "zFOTuqiN/7UT63iNhdhYjLsgd30i3sbFDiN09WGhknqf8lLbBXjHgG84ipkBDQRXE7H8AQgAtKB2\n" + + "1lsk2j6jf+ipGdaOo5tVw2FZo4r8ayNXwQuXxO8oLMqK95x0vujw6QCKJB3r9YoXCiZyhhTVK6ir\n" + + "Ua8l8itohz9dutJauefNow7kur6VK+b/aODVXRLSWaqTPOPyQ5qjwVOjtQdakVZkiPU/vHhyFDCm\n" + + "uM2FTAtmbpBvJBS0VVM+AGGL/E5RfytVzRZqspV+GPVdaaBosS+1ZKAHYXl0qavJgNBe1puhoXrV\n" + + "37qtk8QXoPK9mNzlhroKPT0nB+CB0hGP1ZBj5L1w0J7Kh7g+45R56ksZxGbqOPDOywDwTJ79jb/i\n" + + "V6R/Li7DEOUEG/qce3y8WsUQmFN3jXGzjQARAQABtEVkZXYtNTU1NCA8NGJkZDRmOTI5ZjNhMTA2\n" + + "MjI1M2U0ZTQ5NmJhZmJhMGJkZmI1ZGI3NUBwcmltZS5rb250YWxrLm5ldD6JASIEEwEIAAwFAlcT\n" + + "sgECGyECGQEACgkQYSr06g1EAQNyswgAh+TIr1kGtrRZNf8Qw/gN/eL5zjHIty7YjKa0A0cWfHGW\n" + + "EnEd5+MXJ3FyJ4ouencuFdKW2iUwopESi8Ca/zbhKUkmt7TVtyaYOa+r63n7ym7cPTQuPnRp17oh\n" + + "2mOGVBuYtEJvEGwlBkQ5J+5BxEoopP+vad7IRAdOFPnX5zcQMh1Jwgk5edCswc0ALvQP5ltTc8UH\n" + + "ub95iIsymYmEv0hZu6cILiRvwNILA6sR8cnPGZYhQ40pVQhfoUUCiFzZ6l0wTwyL1at2hKKIep0V\n" + + "gTPd4K4IBzw1LJ3vrjvQZ2w8LeKXDTlE9nDPZZNEf8/CgkEb/ukeq8EUtmVw5kiVRjIc0YheBBAR\n" + + "CAAGBQJXE7IbAAoJEEyVObQB+CKcM9AA/1pQtPRQO67dvv/LVXoijI7tK/obYaEojKZYORKGvU/x\n" + + "AQDH5WURMJ46qMxuQW+AOn4T9NgW6gEOL9giC/zeN7Zq2LhSBFcTsfwTCCqGSM49AwEHAgMEt6ds\n" + + "ByY+WKIpVFGGoA/GDg5Zp6gbodz9EBel4iStj7jtngUZYjkPfnX+2SzYmgaoDi02/7FoyrQqGzjj\n" + + "N5ELFokBfwQYAQgAaQUCVxOyAQIbAl8gBBkTCAAGBQJXE7IBAAoJELj4ifTt5FVd4d8BAJTEwaL9\n" + + "GQd9NMe+OjrTK1KOYcknbxR+9fNPX1ZjgzFzAQDzFuAsKqzYKZgD/+T+gDus87GHBlnTIn8gdkYy\n" + + "FKL45QAKCRBhKvTqDUQBA+oLB/9FFAqYFyzGUa3oH35IydnCpHkv1AbNlVquJhcO/du90N26tCYZ\n" + + "++urGeUDIxGNsibQ/33sI8cfNYYZHSmokZkrMK3hM5g2ifPAQ5nP2Q+XZctBrgTo8IJTR7ld06H2\n" + + "k6PNss7n/AVqJ2Q5Jf8O8ICgRISc2pekjl8cQzG0REoX3kcocafqn+KOq/AzBn2Kg8kiV36Ll4FJ\n" + + "YNgmdYAdWVT/6DyizraWdxxQqaQUxHRvRakQiQQGreZRuf49Z39nHRCbq1LFPDZKH/gKDkiu94/9\n" + + "0ksDqWuhdl8Nh3hzx+3fgZfpTiyt0KdBrxU/auKy0KvVUl8ZH2Ofq1EnOYWewoz4uFYEVxOx/BII\n" + + "KoZIzj0DAQcCAwTDOvZTUg65qk1xEQ4EByOKpt21kaJGhd1YCqSl3dhhpNvVFSZkj9g/Njd6UtYw\n" + + "w5LfGQHG5RFkh2fNdR4qjWivAwEIB4kBHwQYAQgACQUCVxOyAQIbBAAKCRBhKvTqDUQBA7zuB/9K\n" + + "FWlmPRSo48y5QrzI1cjjPo+z2KlOMcWhzlgI6UGx0Q4sNjbnKcT6keVRk5lpEZHH7LIhseW+aUkW\n" + + "ydqW1JcK3FQ4OPcld2vzaII7W9QHt52iKSEcP9w+ANEml+7eLdM1V/qfDxSsw4RggMhV+bZx3A2u\n" + + "FzEYNTVspR0ktqP8DtLTd+JySifWDa1f4j07NUPI+LIITBcwVpDSiBTep2Di8RE1EsaZ0me1vtfn\n" + + "mTlNNEWJYcw+CFOIHSXLM7MMJGkpyXA4Cx9jp+Kud7PexhBtf0FhZ9xt74Bp4+E5wscBQJBRnSU8\n" + + "I1vhZvcIP6oaij9vQK5jIO0zS8cSunTJqqd3mQENBFcZKOQBCACcylvhygjN+ZNGRaL06FF3kT5j\n" + + "KRl+TDgRGf4jSa+4IEU7wv2s6Q2N7LPJLoMlHEhdvUVxFE7oA0V2y3cLM7JRcdQgJdmoYd2KAruW\n" + + "elMJNdzxAhKqtpGz94FpAXVBHm6q4TWx5Mj4SYUIcvl2s/4tUv2mhHhiWEMDH562D9GV98nqIBJw\n" + + "sBOVuTg13r3wbNhPkZQ+5ZtxvamMjfmlqMKlIuC/EJk8ikXHqf6EHwJKiZv0mHLjUKn+ff7oGrg3\n" + + "h0FC9n0uvD4JCyWI0ruBP7j4zbMtbu+80GFU3olfwJXzpgZyqZZph9CbLEHcnsiI+5Ch5lcnRAFz\n" + + "XVruqk0G/OxnABEBAAG0RWRldi01NTU0IDw0YmRkNGY5MjlmM2ExMDYyMjUzZTRlNDk2YmFmYmEw\n" + + "YmRmYjVkYjc1QHByaW1lLmtvbnRhbGsubmV0PokBIgQTAQgADAUCVxkpTwIbIQIZAQAKCRBM5tdq\n" + + "6LqLhQJIB/oC7PKfuN0i+DYNwDPqCyE6X1Bf4BhOWG30vd/jbyn2PFzAe7ugQcSUB0JC4K9jF5A8\n" + + "yRwhGRnvXs0cPr0Y5O2wUN7XX96qaYUpbf7KmOGcOPRkwUtnJ5qB4w7v8HnGWsJ1TvO0wttoU3hr\n" + + "NeKkW8ZpAawsh2YK4BjCuW3Q3nes9HQmHan8tuCg4NSGOCg1vD1Lg2P3xJtZgmQi/PyaqyyIpngD\n" + + "IYL5401l/J5Y2uoz+bwJEbp1YetEs7yxtwuNT2uRhFk+3iAgAXWyzAGkORcD6ydnHaUhSOveySrJ\n" + + "EOfWWqVLXeQ9qkjBdD4pnSyJS8fl3GSFk2PD8I5qCds5FtnXiF4EEBEIAAYFAlcZKVMACgkQTJU5\n" + + "tAH4Ipz+iAD/XTCd9PRygSohUiBOmQ7+iFLe2+G6/bOa/+xO05W14zoBAJ1KcS4tEWniQl/3AddY\n" + + "01Id/45CAkfuHn1AGJOakIfguFIEVxko5BMIKoZIzj0DAQcCAwRT5+XDKw0RsWlEuQHaVrnOQsR9\n" + + "inaloLU/lMxk5C6bPWscQdMTSBdS1ojUMY8T8m4dkOc64B31omkQKdo9pevSiQF/BBgBCABpBQJX\n" + + "GSlPAhsCXyAEGRMIAAYFAlcZKU8ACgkQZgyOa9ZcitRorgEA7Yiexv3OH5TGSAICY0IKXSWBt3NQ\n" + + "NwAg7DYHVNsqvAgA/3kA9ropvkvGrJyvvKFR+eFvs3prtuOTlklI4CuLBKOzAAoJEEzm12rououF\n" + + "BOgH/ixHo7gkonSStmMI/ggbaJ631oFweFijDjCQ5mZZRaoo4zoA2m2qVwPNDj4on6dPhaO1ES2x\n" + + "pK6V+WeqARR5zgQeX4kFgavRJJc/g4LgcYZDfzMPs+x/3i63yh/96pM5KPDA0xERYcWQKnugaoAY\n" + + "qisgCbvBMSrg5qsrz3UgqsNGrdvB7IllAUQTZIreHQUEO9zrIeBj7OuEno4rfMh5y+r4mjXrEPTS\n" + + "jrxMnnXHSywZ8wa7wbwXxCLl28RsFCTQudlexCe5R8puS5XQBleWtE5wiPxXtKNhVaSPF1s8SvYp\n" + + "Z2wyuqW/EWRZ5XDN//mHQs1xEZB+xXldtMAWg1V86d+4VgRXGSjkEggqhkjOPQMBBwIDBJQ4L81B\n" + + "4E0JwJ3C8CJnAfBJ6yuT5qs/W+rol/rPpCCWxIPGrIPbOO7NzCpWm2Ap774+XC5Q0XgIfwLvufNs\n" + + "cyUDAQgHiQEfBBgBCAAJBQJXGSlPAhsEAAoJEEzm12rououF0kYH/2kRSf2NklOgmqetW0O6jwVa\n" + + "w1TYR97mUx7K8VVnhCgKpTLew2JVop5mxa3OsLncm7UdczzKf/tY8tcxSlMNZm/o7/PP3EufvMbA\n" + + "tbMtyKWoB49j9cfmxh8d6XtsJnKOoTeA9mA1YhbT/HhvQuzY1C2QsvxgIYQ4WLh28oFQrUE3Yv5r\n" + + "yYFlGOawyQm2Okzp5T/PGL+WngbyoDT8gRDVy6jKLGeC/KzZzVcXM96wRBqil0tniclMjAsaHoRL\n" + + "SanHCifHh41upIqDjTQv1onWqh+frdpmQyoTF408Udjq5YBrFdL/CF48ATyhFIBUI8CwfdOssh0K\n" + + "UjFoNERyOxRPNc6ZAQ0EVxyqnQEIAKVuwLFgQRVAMgjBt5K5TZLtQuPhrIPcmncOQpxpE1PV5kM3\n" + + "Jjferb9TSUti1v5NwxiVHGBR/gc4fbu5NMwOEow4sMH/l1WJxLMO4DIeP4nZRcLsTLZuGKF3YSwv\n" + + "zbS5/z+L+C87K6CnYhQAbIinTVHt1wO9Z77C4NVc4xv4b+96FMtojbd8Gq8fze7IGOqrQXirsgqy\n" + + "p11Seori4ikSUG3yxUO2W66DTAzc6LH3J60agJIjEQFCb9WiRtP/l5afPQ0qb6yGLPdPywf4djgj\n" + + "+RDqv2aNBwNOfC2E8EhvPbKXEL0bqWnm0nURPId3QSJHaxGffvCepSNZtuW3Bk/qJckAEQEAAbRF\n" + + "ZGV2LTU1NTQgPDRiZGQ0ZjkyOWYzYTEwNjIyNTNlNGU0OTZiYWZiYTBiZGZiNWRiNzVAcHJpbWUu\n" + + "a29udGFsay5uZXQ+iQEiBBMBCAAMBQJXHKwIAhshAhkBAAoJEKO4BSPMD714N5AH/1x9Ybj/sOd7\n" + + "iQYuLYa+QXB779j1kYnVQMvBTihHH8923M0UVRbyGu/X/Go0ccQcpdGnxbqRbbZQ1cPke6jseGHn\n" + + "64NNWj5eLfW0TcKtCBQg9NF119rZf60NFNGRqzGDY/3HEo97VEeSLSABGOxqOtOMFMxXVsWS4qne\n" + + "wd2oekfci4eA9C5vTzTFZYp15/1Th3i6kYOUIUIwA1KTQFFnCBQBdjTSTsIAssw6HqxlwaLN2uYd\n" + + "/cvVaKW+z0LrttiQcTccQ/2EQ1Bq0PsEqUbyxxnsfFw1K2hoFpxWbm6JdwmvSG9Z88BFVZHoFJ7z\n" + + "cWK9jkQjpUYELh1QtntVFnvzsb+IXgQQEQgABgUCVxysFwAKCRBMlTm0AfginII8AQCM7p4txedX\n" + + "FD0uOLf4HyFpXg6PgiKS0fZ8DnPs3byz7AD9HQ+NUdwEkWSy2xf4L78cswcXjiLnyuH6ojP6Q394\n" + + "p/W4UgRXHKqdEwgqhkjOPQMBBwIDBKPHGAMCo9sv+DuzKYPY+HvXpy3laUyzC6NHsLTlDN08zm9t\n" + + "3pu2KHKg6qV0dkLAvbifIAqs/chSu1oTNjttNLCJAX8EGAEIAGkFAlccrAkCGwJfIAQZEwgABgUC\n" + + "VxysCQAKCRAhS4x/9JiZ4K7WAQCzzZME2qEag/gtUX5qRq+yqprVLLVWVxOh5Ce5iYzoGAEAqHdV\n" + + "Ro82bl+yfW+50z6d0UD3JU2ynGEK1rG9OHssRcIACgkQo7gFI8wPvXgcRwgAkDhuZTdm2S4mClw7\n" + + "Bk7aSCQVkyqY3qn3MQ+bv7Z1iYvGS1OEAPnkt+1pe30PhtVnx3ILoH38zXZiMz/v01GRRCJUMcfq\n" + + "cjRPP7jGQzS2quDGv7ro3TiiawLDm7xhB0h1bQnKNB2o1HsNXsFNLxqeakxdZsLyqgadO8HwmIet\n" + + "McuJh9mP3YcBF0GTU37DtkIq6asgMYpMBExCpSLxEvoNL5Ir0CbdDXosGy+TP7xyKHuRhCYkSgNC\n" + + "XaSvID46cQNQ7f28+c+XzQAwdtcf7pvHBqQhxjbaUTLtzWnxi+i4JoYAh99Z+xPT3zRYoUjzKDDX\n" + + "3oB7TwsxUwF56e6grBDfgbhWBFccqp0SCCqGSM49AwEHAgMEEwDy/7+KZXps7J01z48EVITFapRs\n" + + "wagAmBt3SCaCGHBWPsAI/D1xQ4TGi65ObVYLUtUkTGK/J0VWhGAiDC4cfAMBCAeJAR8EGAEIAAkF\n" + + "AlccrAkCGwQACgkQo7gFI8wPvXgCjAf9EcSknXpAU+QCz6fakaxkIk9REIPsAxghy4EvOD/ZxmQm\n" + + "hd9d4vA9Q1rZZWQ37mahiZmehraW8AhuSjTsHgzEw+YD5AxW7/FWBqjpjiNtyN+7JYD6+gZL7DSZ\n" + + "IZPKHjHjnpTG0854VZaLbMCY9NBkwSolNJgp0LX/sDBcCi2hTXSt4fVF1juGg5RIKdgOvnXy775a\n" + + "5X7wZJbhNyoWPuIGH7YCukxPbZy7XHvgypmKnujAF+8DyHrhJ3dfZBZGJtQ6K5omOI+YnbO/Be/R\n" + + "7SoZkVDzJmbF7EApGigQ6TWHx7iJSdDzn7Kd5zq0WVeGZXg/ku0p5eVcX2JNaB7nmalrWpkBDQRX\n" + + "Ly4ZAQgAw36w0388lpwbc+LYQOKYH4mP0L8HGZIjIUrsHpHYO5K1V+FfkunudESzweFezSgKEkQT\n" + + "YT3ncf4Mo+/S+Ou3MLaSd7u3sv2d70F0lj1DzM8PYaNAbsPmIMUX+UKSSbTA7wBDiTLIKig3blIU\n" + + "G0i3OFFmvMnBvpKfits/kw17iIf6xCKdjl0BKoZ+UixeZaS7zS/rjtMENIv2W3mqOYgRy+SDsFtx\n" + + "L4i1Fd+8JvltD3RVNRvLfHLdwkuKj0YEaB13W90ikfz9cKfxbSFulOq50NHeCjbdQQhJrLYnxB9g\n" + + "PPmwhfLAxDXWdB7b7+ohq4dzqw928z5JKUXnqm7UADxEkQARAQABtEVkZXYtNTU1NCA8NGJkZDRm\n" + + "OTI5ZjNhMTA2MjI1M2U0ZTQ5NmJhZmJhMGJkZmI1ZGI3NUBwcmltZS5rb250YWxrLm5ldD6JASIE\n" + + "EwEIAAwFAlcvLpACGyECGQEACgkQZMGYqZqLCuMR8Af5AeKy2YMIAORzb8ntBjIG6CZnPlfqI16H\n" + + "zWmEED2xhxlsTwZAmNU5ovR5ZsW/vSw/CeMc3gcxjdPIrT1II3zb56t1mR+RxMGcxAo+y9BoCG7r\n" + + "k9HU8c9DkPZkeE7wORNNjv5Rn/8PHfFqKS77wN/Hxpsdo8cnqkdWk2qNVzvoTz6OTz4yymTYPzoG\n" + + "n+QNYCIhJ3KHZ+jOdX7H7+D0RP3YbDDls3X0xglpZAmAG4hwjN9vQ2qlJNk6c84t3s+wvg94aWRw\n" + + "qn41UqVyszp041CzPjuOem5vu8rZ85IPuVNr3gxS9H9af9r1ZWTM6VMxK1CuA8yvOqqKDjISW7kA\n" + + "LNXBG4heBBARCAAGBQJXLy6SAAoJEEyVObQB+CKcAzkA+wdDwqQm2ataoJ/wjrMbHlgeAREeCN6C\n" + + "W4gxbmycAE/gAQDMyhp5WYJPcSidYYTEJy3cNkrAC55XELRFUixPAADGXrhSBFcvLhkTCCqGSM49\n" + + "AwEHAgMEF3ZmrK58coQ7Iuwwt+/MtJtIaDLgiNA/c4a0f/rHhRq8y6Of4nTRi13JIBjXGo08egPZ\n" + + "ess+VjlcSuOcyXIkHIkBfwQYAQgAaQUCVy8ukAIbAl8gBBkTCAAGBQJXLy6QAAoJEEZ+t8PPIBOr\n" + + "GzQBAMeIYDiCJ0iklnJWk/yWScz3AOCh72nWRjTrcZQxvV3EAP9uGSJVij7jYI5oIFAAjFnZvJ3Z\n" + + "OcBdkup/TOWhLzlwpgAKCRBkwZipmosK4/bEB/96zDBeqgmsuTMzs5PlJ+6uFn410ar19FhTfdjD\n" + + "Oz2qDsUrQyDtRz+qGNe0eqsEI8omNs4AxHDXwkwCL5nE2s8zl5G5illCCEB9/aRdesoTp/ht654R\n" + + "b845wS78oeWUZ2kOOhZC/8gqAF5OELfGKmWjVLvXlkh58ExVgRjhfqNM3NKHVBNWfl5rBtq8BoBF\n" + + "x7Fn8ywxSIA00+V2DpDnXc1TRMWaGvDzx2JGr4jId0gmWRdmJpUkItNxWgUctN5usU/1KPyHjn4l\n" + + "85AXv27mD1QoZGFIOOhjn1dL2SB+aQXDrADsajyzZRU1ChVemFZI+bl23algIeHhKzN1EIxUpOSZ\n" + + "uFYEVy8uGRIIKoZIzj0DAQcCAwREwWrPxXSID3oV80hsyPhGu7MbrWLqHLHEyGYVTTiwLg2CX8dV\n" + + "lq+/QPybxR3S0jgjRP70VfkHy0ez1spFBEcuAwEIB4kBHwQYAQgACQUCVy8ukAIbBAAKCRBkwZip\n" + + "mosK48VhCACmYNw6pBdbFGLshPECSi2DgFPteVz10uHUBdjD2IWJMVhZAeyA5PrJM9aiu5TCMUd/\n" + + "787h6zYf2StoVYExRT9iVH7PGrOldPN8AAg417ABmobIZFN9Zo5f8gR9nvAbUyEiryQsxuY3z+BI\n" + + "RdG8IjTwQmo7V9suwI4kSd7+jvaLYHrZKmZ1rtD+zMG1roJwONGseq0MZqnGBamSItB09NLLI6k2\n" + + "y6+iRz0cy3W4TPzyZ03BdG63+GmDTyleq5LFgd7dLqUuX7QWVTWbcT/ZqrJxo2AaWnEpFDYScx7U\n" + + "eVLIxOGxFbcn3fq/w0MFN5BPhlBGY3rHyBwFHJUuTprZ48itmQENBFcvSU4BCADkltyL1BieiozX\n" + + "h3buIX8lXeFBSdbC1wS5qDDgewmmzxvtE12KuHo6Wm1dE/Pd2afGwXzUGU3EsiUXaBfnimdZdNC/\n" + + "dJlJO/mjXW53i9Qu1XTY2+WDAvg8euYO6scsUy0b0+svwDX/K3Nn2sR6LO0Jc0UQWJPGzk/F5R0e\n" + + "+moF/yjCMEdUpf/7EHAnFABt2cigRjDKBFI6aBthCMSQqzI1cRYqQmMheoz5jDMZpyOCmITTTpmN\n" + + "gy4JNXk4NsdlpJCO8EEBit3dy5IVBD7RymPNfhxjT9JFnKa1WkIEg0KAaMGtTQgdrj1kryg5mzJi\n" + + "SaoaUV7Zu0YhCaBYrZgb2virABEBAAG0RWRldi01NTU0IDw0YmRkNGY5MjlmM2ExMDYyMjUzZTRl\n" + + "NDk2YmFmYmEwYmRmYjVkYjc1QHByaW1lLmtvbnRhbGsubmV0PokBIgQTAQgADAUCVy9JcgIbIQIZ\n" + + "AQAKCRBdOssxvBI8viEXCACpJsdJ1I4JHa0t0GPOzHmoaP60ceQxWJW1JEhSPrjwc+4b4VSamCBR\n" + + "43Rsqavsf2p/3KMRCLjZsr8NXd2N5OAp9ATuFHJuMx5fVCb/9NmIux0J2DOkO0wSFGAjqGbiBnAU\n" + + "aLp9uhy6lU8+Klh+FVCYeUR8ZtDHRO7a4+yHvYXYx3CG8WAuCZRc2bzLEZsd69UJQRTS5/7gnKZB\n" + + "5eBIRlPv8Bu97FnGhECWG+T3r5FsvrF6UjSyA6CAaorj6Cq8exp1KGskiFqcU/4X579Rk9nHhypi\n" + + "lXepuXh4l5CFuXmsthLTjZdf1SfsKQsRnz2EWi05EVVQsJgj9qOL0c61LDRTiF4EEBEIAAYFAlcv\n" + + "SYAACgkQTJU5tAH4Ipz22gD/WC6IpUTBKg/5jNiOFeuME7N3GC2BxdK+pBUmy/Rt5OkA/1rFGFUt\n" + + "8FMY9Z7RzB6DI5/tTXD5frpUTkguvTW6LZBauFIEVy9JThMIKoZIzj0DAQcCAwRi+C0aK8hVGivz\n" + + "fludgOmdV/C9VCR4tIeQoZCJPZSqa5oE129YO1x9nVhPsub2ddWmb/HHv1fnGcgFI3dGPpaviQF/\n" + + "BBgBCABpBQJXL0lyAhsCXyAEGRMIAAYFAlcvSXIACgkQPMEjsinkm8+StAEAtFg0TREGtv0AHfTU\n" + + "g0BvtgsE0NnATMWej4tfrYVvI8IA/RSAwemr9fxLnQ49/DA2X+Ljbk8rXJvCBQutd/RUWmElAAoJ\n" + + "EF06yzG8Ejy+CkkH/jf3icqHGTSEymZuuAL7J+XabTkob/fjtsKks+8mXOiOKlDi8+8I/RI+Z/uT\n" + + "q8fY50ELG5TA1WARMgu8gNaPS1Zus00YLX9ZvliAlI4jDQ9u2bkFV3tzKIy5OI47h6tXZ4klKcbc\n" + + "GEOOoRsiqb2EWZgD/+d90zlTCkrPLu/Lup0SzslK4fI2tQIZ1noR8YOaPBRSQBGRZQtU4Bi0t5Pd\n" + + "WKM1TOL1gjgVsJtDTzDyxECz8p4/8EDO6uTl7qxL6eAoBEgKqsW/ODlzOz7VUaRL60/DZIfip+Jg\n" + + "/BeW+5OgX10LKf80Mo4j23i9kZTtJ90kSoh1UBn0Yl6upXeGvp0Xu2u4VgRXL0lOEggqhkjOPQMB\n" + + "BwIDBNE95AX7ROBdE6sJ4OFXcG0vo4Lc3+ZsQFzd/0oZOiG3uWsLhU5q5F6xm9FB0MvL2poEq+Py\n" + + "kOCmj4tUeVqfaHIDAQgHiQEfBBgBCAAJBQJXL0lyAhsEAAoJEF06yzG8Ejy+uyYIALCAtBf5oAZ5\n" + + "b9JFXr6NaHnqIMicD23lErqNhNWJXTJI4h7SBWsugoKScfuRx0g0qqXpTbKxUBrGunJtxtgVA+jY\n" + + "p1YRK9iyorIqK8gKnUAq2oBe+xBTT7kiYHZoqb9k9nrA2zwQ1BNgKf9TW1kgK4XMDOh9ZJ0ABzfP\n" + + "3mYathRaTBktnDtjF4wCowoMkQ9IjGn9tPQko7CvyqH3pcYR6tYCtrN/eJHURjnCzrwlBIzK16eO\n" + + "iwip+maTVvsOOxbOAAoPsKlOa1tioA71+KHqH7Zgo870P7Ryq/aGicsCmtwd628AxpRMLPH6qeGD\n" + + "hgDYVMtwsMUCShRQ6KN86CaNrYmZAQ0EVzdj5wEIAKSoezwde70mnWngXx5rh+ec0DnPDL9oWIXg\n" + + "OG4D0UOgmsZnEWcEyPOcQf1Ey7VAbqbQVEoEYu2fS5wCM82EIyzpAAR0/v6RQUkQDPV+pHyVuehD\n" + + "5oa+yaLYvd/ND7jw8XBNq1mblCQGxuLna1BZzpvdtFEx5z7zZ5wsjz0GIlPeMcOU2+ktOzLNBgBJ\n" + + "D8nX8hL2UVUWEaGoCcQ3Nr11/JwahK5rgYrdovpG0hwKAOxGuS7dUXHqMnpqMqQ48wN5zPr0e8ws\n" + + "8eaeHuV4nMbXQTv7Vos3PWWPexYxmkHU9ktJrAayD6gxIa6pSIu2/h8m1BLRb+X8/ErdSZ1XBZ3/\n" + + "ioMAEQEAAbRFZGV2LTU1NTQgPDRiZGQ0ZjkyOWYzYTEwNjIyNTNlNGU0OTZiYWZiYTBiZGZiNWRi\n" + + "NzVAcHJpbWUua29udGFsay5uZXQ+iQEiBBMBCAAMBQJXN2RgAhshAhkBAAoJEOC9vZfa96U4lR8H\n" + + "/0mQSx9SVtubVnKhoNI8gVe/6nvZUtw1OZ1lWP5FGGdbLazdGnWqQJNL5fGKiG3mywCmuARXFe+G\n" + + "XIPtPbzZ6f4QIyldYg/oY7IA+02ZZMijdAkppreEBM+/xtgUnh+A9zHqIWbOqTm4tMtyWJIt3msy\n" + + "rPZk4K2PHu6TdeNwlqfMLRRAtulNFXWk0sBIz6gj//MdoElcIswV+6EO6tPFMxVxkuVg5S5TRbYq\n" + + "XrF6E7NsG1tlLP0qEQ95ve42GABS2GVb638mze8+Dyew8c8ilTajJ0AdqB9ZMSkfF6Nw+o4+aLo4\n" + + "ju0TFENKNSfGb79NwsHx7V8t1zLGyEYVGS4akUCIXgQQEQgABgUCVzdkfQAKCRBMlTm0AfginI16\n" + + "AQCVoeMPBMbjhDDDqbpmKCgWmbpsItat1vk9lCoG9nQzugEAu35Mh3iQSiErt8w83gTNyhfZE+mS\n" + + "eQyKrYGzY4hHvA24UgRXN2PnEwgqhkjOPQMBBwIDBCHYkJJph+3mPDfKo1krN6i3yVREBKrVxrvJ\n" + + "sIBfjiNMi2Aw0NEvwlbxo+i0wtT9zNjLn03VShcJMVc/W/IBVeeJAX8EGAEIAGkFAlc3ZGACGwJf\n" + + "IAQZEwgABgUCVzdkYAAKCRBepidkhB2vSoxSAQCU0bKuR2dg77FRj++ZUt2c7x9HNDsMIrFqjHw/\n" + + "TUrg/AD/dq1adDv+ohd0htJit4Yv00B609adhrZUoMCmcA3RpLUACgkQ4L29l9r3pTj3JAgAjltg\n" + + "MK+XTZQjBMPYqcL+nxwEVTXC7E5E3+MNyfxlyNcb2dxj8KOYt6UbPN3zgTUu0HSxxhxTmxKzl9gg\n" + + "4L3eIdI/8SIHJSMCP8High5zteZ4r+NQvSEchN+ZloQ4rJdzkyvrmOtyZYGQ3Rc4k7eEe27RtEdU\n" + + "Mne8Rlz51lhzfBnzB6rRKYluEhM6oYVBr3wpw5vIAFs73kdN+qpQo7zLTTW/otPmaFS7XJuzKtvp\n" + + "WOMrDt831xtQhOiL7eEiChRqgppPM1rBb4TqY1eZSBeCsFH+g6b04QovyVzP9fF+DFl7BqxGMtz7\n" + + "9EgPnDcLo707rGZOOQFMR5FYrCw/hb/Q1bhWBFc3Y+cSCCqGSM49AwEHAgMEDU1Dmompvd9oBSMJ\n" + + "0UbS5lhPwVQn6k/F5+yFM/M6Oa8BO1JMpLW7erN5qIr5i1VWMM3/CNCK3Ybf9BfoqcRp7wMBCAeJ\n" + + "AR8EGAEIAAkFAlc3ZGACGwQACgkQ4L29l9r3pTje/wf/TXMvMXS6BH6zg/PQ+PWnNvrbqTPqzoxW\n" + + "d16Zz/+/ZMzRS0hj2THlMBMcWuZhkCPrtCnfVbZ0u2ekRlMkVScQTgMMVQm2asCQ24At8DdxNgVy\n" + + "szdUfW/wXlI9B29C0w0Ws6jt2HA0jpIzKgTcDmHbRNurjx2dpo3L66J+QtbQTcu9iSch7fPlgqPP\n" + + "eBDBIkEBeomdBLDXTxWse88wS12uduPmdFlbXSBuoarGlkVK0s1A0oUYglZ6TwSvTUr096iwhRiK\n" + + "BYrz+/toKn5tQdJC7M1SUT2IDqXOJtvoghMTxp1g4RvqykbhbyTQMy1csxx+Z9AF0cIkwiuBeN2H\n" + + "nXkVY5kBDQRXPLQmAQgA0uCLnIltbIKZ+Dg2hobRWUp9IeFurvfU/PCQp2jJi+pN9lBbJVmaKM2G\n" + + "PAiY5y977hZi9g/pXtMZxzNM7L7b9UjmnJUiKm9TuyG2/DQkrsqcnSpDbq4AbpSaHcDVUA4GeV3Y\n" + + "LI6/7g+PYEcaZAYwvHOTGXcYt0sE/bmdgazRPZBUgQcEa3jZ40J1ByA9DyJcWHf+wQS42/Wt9rJc\n" + + "NOqbbXgLU126GbOUCm77PIEAUP9fZJycttQQXwJUo0kPxJgx0yJtorxIam9XRnEPMUxjJBKxPK0p\n" + + "SfKsc9R3d3hLexwY5qDUJkRdQXZipLxxZX/bmOwk0742I7blgmTGgBnOCwARAQABtEVkZXYtNTU1\n" + + "NCA8NGJkZDRmOTI5ZjNhMTA2MjI1M2U0ZTQ5NmJhZmJhMGJkZmI1ZGI3NUBwcmltZS5rb250YWxr\n" + + "Lm5ldD6JASIEEwEIAAwFAlc8tFICGyECGQEACgkQTcCtNH1vHP+sxwgAyDMH8XOmHcbeFsj7idL9\n" + + "yZr2Z57MJKj5M6q5YDCY1vJimMjZgBcAfh38BzGGN2/nnjOQe3A9GafP/jryFRKuB5PffZcvYkJa\n" + + "Iu4H47Nc3hq5kPoX3AgjbCn2ia+q9lKc+ZlVJgDa0ycz+NxPTBXYoZRjuwChWlNfqUEtmypBFe07\n" + + "hXTvspjqC5CpfmHBhv0NimZXdSKhfEsyb0BXahkZTU5niKE3LqGjoQ6dw4/93rUdj8WVw3kL4Q8W\n" + + "rTgCEPEmygpMGrfwAn06+O/dV5kFqghgge6XmLf1goe5l8fyDj9lwvsNknjQGLZz2VAj0dNq+cpj\n" + + "wQorpFiHxDuuRXnV54heBBARCAAGBQJXPLRfAAoJEEyVObQB+CKc4f0A/2IpKvJrxWTEsw/oKaiv\n" + + "/aEVKwXA0Mp9YwgfRSNfh2eGAQDGlO3sHnrBo76uqWfMQ9dvdMQtdqTKX9XDgDy2UP38jbhSBFc8\n" + + "tCYTCCqGSM49AwEHAgMEL0UkuFUTODetUGtdodzMFvDCBZh+1NUl8qBWwMbLBj3ECJUVk6WwpG2z\n" + + "dgEqjsUEtlQrd6sqN5pNI6siK0t2XYkBfwQYAQgAaQUCVzy0UgIbAl8gBBkTCAAGBQJXPLRSAAoJ\n" + + "EF8fge4e6H3gCkABAKkYlohaUBTTn/5AOF0R/UvC4xqxdTW56VmbU6uittDoAQC6XOw0UXscamH7\n" + + "sqQwgY7OYFE9ii1yEfNKOvqWaYSg3wAKCRBNwK00fW8c/5QsB/9C+s4jPiRx18wbfVLmmh2QcV3L\n" + + "zTkmR15YEROjDFNcg9YmC0bjF05Nm4Sgo0+6dBfr3qE+XcFDwjORLBAcAr9dPz9WlCcvwx+OXDda\n" + + "RZvumtBu1JjmmPSeF7UKBMS15qGzWly93IFpttEY6daPLlm+JzCZd2sQDu/+rLtZVs4900fY0aoT\n" + + "TuNgLKg3J3yogFw46u01TA3fGPx0xKrffr3ICrwl0b0OV4tYQQe7XWldRRNzetJVyThbz7Mv8DCc\n" + + "srsnTaEFw0Nb2g+X3cwV0PMAX+WNyzTMZ/wg2R3kk/oDGUjfjrZ7MFy4AZzF2vxAiYpA/gh0+lX/\n" + + "rYcPWPW0un5luFYEVzy0JhIIKoZIzj0DAQcCAwSCf9tpmmxDTMTHlZSnFrjZbl0aMjT8KIEdfWta\n" + + "8UEIsw0xtNboAAC/kiJqrumLEfndN/kFAFYJ4L7xu2hJ4KKsAwEIB4kBHwQYAQgACQUCVzy0UwIb\n" + + "BAAKCRBNwK00fW8c/4oeB/9NHf6x6986IFBMXm9CbTabu7YMw92MJl+ryLbVFWiBkDNtp+MTjgr9\n" + + "l/68noPLew982g1f0vf9RJlZmPmL9ju0LjQpDefEoHYM7f8VizV3woLUHARsOPXhTREWJ6gzCU58\n" + + "SMLC9KMhVXvgH1GP0I0e791cKrI7f0Q/t8TZ3Fs3W4IAPjqX1e6/xZfNovQxGn0ua0XurUdV01Y0\n" + + "SBxAYpHpIAKIwYN8rNi7iI5WGhVT9mI0Wn+gM9Rhl9LVLqk2vbUEq0UKpZ+xmkx1ThKciD6rDITS\n" + + "Ng6qZ+nTmq/Fm3zGO/MC9AOAzZENFNY5mu8iJ3UOrxNM1xHBnF2Re7dJISb8mQENBFmsahoBCADP\n" + + "9ngfVg+EzAxKATW5c5Qy+6VvEYMvIayMqCeWBMbcy4d4XPyxObE+AV9UmglO7p9MxZDr13aJSrSB\n" + + "p8fL7QHwQT3bcz0lxak1K6I68OGspsbEUHbAVp9fARESXBT7By+2mCo/EJUX6PdBIwO41AjwEKMN\n" + + "ah56d1FYx0xuGU2HB/4snDA9dXaVckOS93eu1PliLrgL27458UQPwQRyU0FGoeTxDafpxQ7avhsZ\n" + + "An7PdzDvGpXDmkPZNiWvTwdcUsBg/31shbSZK1WWesf2iPfZbnezXONQ0a36EeC5pkD2/Bf+SdKv\n" + + "uxvyxWfEvHR7GdN9Q9WzLqWbCjTvngK/mf9vABEBAAG0RWRldi01NTU0IDw0YmRkNGY5MjlmM2Ex\n" + + "MDYyMjUzZTRlNDk2YmFmYmEwYmRmYjVkYjc1QHByaW1lLmtvbnRhbGsubmV0PokBVAQTAQgAPhYh\n" + + "BEfklEgJlK6dEOXAywNJgcMpbzbUBQJZrGoaAhsDBQkDwmcABQsJCAcCBhUICQoLAgQWAgMBAh4B\n" + + "AheAAAoJEANJgcMpbzbUY+kIAKux8dnkkh9uxn5Yqm8oq1OTOQ/sj1Jgj2NaQgD7iXTnSl2kgIvM\n" + + "64X2kUAMPVsseS3bt33i6mn3iJ5Dtu8cW4hDeRAat60dvn5gR2Xea6h6vYfvyBi20iJQySaTVHAU\n" + + "h0IBBVZxgoUfbDiFU/eMU39lJ0qsgD9pvUaJijxsppmiNRU185BjcbkanSBlP16tYpChNOzLUQK3\n" + + "R6ROMfzW6Cu/nnouUqkYn6Rfn25Gi/B5U4wdjfyer/O1+zcNxiG2p6iUEA41fmHuwzJEnYTcHZGA\n" + + "7I1RgCT2ILHmf9fvRu69EReokawwlWLVFRbdaonhDD7GXvQ8FPf3IYptEBnysh65AQ0EWaxqGgEI\n" + + "AJ9BAC3bSw1mA9sVZSxb+c58KwI3Tx0eCG3Mdz21qYGZYO/ZXTHKuSc/frqenoAevqvVvnO0mk/d\n" + + "lFtU5ypQSwu4ctH7RpwhLtMNVLFsUQdHM+knuA0Gdh0zzZcaq3oabh6m3dhqlVRUnCu2rRPmiuA/\n" + + "6IyjYgTI7LtZLtAuUTnLsdnVhsvoPIs/8YlWF/pOS7gz/JxdQHCjer/p/TcCdYqnY1rf6kcWoevP\n" + + "fjhgKJ0zdQx8HfhDRzIp4Ny4UMxbZl8o3x4sOOaYwHNezPSPnF9G2SQ89dU5YPA32C2BE8bWfFrx\n" + + "mr07TqYejgAD7qeC2IxgOTDcXCEveAMhJhFzUf0AEQEAAYkBPAQYAQgAJhYhBEfklEgJlK6dEOXA\n" + + "ywNJgcMpbzbUBQJZrGoaAhsMBQkDwmcAAAoJEANJgcMpbzbUSsUH/Rt2CtJv50sxuPgOL+X63mHR\n" + + "zbHIfCuUUzOeqo8U/8iEje3yIZZHnquFmeLP3+1AWOPsd9rkvu7fQlTztUAisGIm1Rq3oqs8Vsgd\n" + + "gF77/VDzJTovUeh5KuKOxL2BHoVBhH2CveHD49C83fTptmG3H4OlUXSwl8Iv9Q9fhcvlYIAaXcLJ\n" + + "oJNMZdiSCh1cd7nCFTEQ0DyMckFUwlofmFyJzHfPVh0TKz36OPA9oUwCr8Tazs6H4d6dxd6neS3g\n" + + "iWyz93dtfgCGQmAMvOoYGu4QDuGyIpsiWib3wpTAP92TLQG5skEqBCNWzU7wMpU7s0YPgTh9NBbR\n" + + "eiy71AHnPDvE1l4="; + + @Before + public void setUp() throws ReflectiveOperationException { + // damn it Google.. + final Field contextField = mProviderRule.getClass().getDeclaredField("context"); + contextField.setAccessible(true); + mContext = (Context) contextField.get(mProviderRule); + } + + @Test + public void testSetup() { + try { + assertQuery(MyUsers.Users.CONTENT_URI); + } + catch (UnsupportedOperationException e) { + // tried to start sync with no account. All good! + } + catch (NullPointerException e) { + // tried to start sync with no account. All good! (for API 21) + } + assertQuery(MyUsers.Keys.CONTENT_URI); + } + + @Test + public void testAutotrustedLevel() throws IOException, PGPException { + Keyring.setAutoTrustLevel(mContext, TEST_USERID, MyUsers.Keys.TRUST_VERIFIED); + assertQueryValues(MyUsers.Keys.getUri(TEST_USERID, Keyring.VALUE_AUTOTRUST), + MyUsers.Keys.JID, TEST_USERID, + MyUsers.Keys.FINGERPRINT, Keyring.VALUE_AUTOTRUST); + + byte[] keydata = Base64.decode(TEST_KEYDATA, Base64.DEFAULT); + PGPPublicKeyRing originalKey = PGP.readPublicKeyring(keydata); + Keyring.setKey(mContext, TEST_USERID, keydata); + PGPPublicKeyRing publicKey = Keyring.getPublicKey(mContext, TEST_USERID, MyUsers.Keys.TRUST_VERIFIED); + assertNotNull(publicKey); + assertArrayEquals(publicKey.getEncoded(), originalKey.getEncoded()); + + String testFingerprint = PGP.getFingerprint(originalKey.getPublicKey()); + mProviderRule.getResolver().delete(MyUsers.Keys.getUri(TEST_USERID, testFingerprint), null, null); + } + + private void assertQuery(Uri uri) { + Cursor c = mProviderRule.getResolver().query(uri, null, null, null, null); + assertNotNull(c); + c.close(); + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + private void assertQueryValues(Uri uri, String... columnsExpected) { + String[] columns = new String[columnsExpected.length / 2]; + for (int i = 0; i < columns.length; i++) + columns[i] = columnsExpected[i*2]; + + Cursor c = mProviderRule.getResolver().query(uri, columns, null, null, null); + assertNotNull(c); + assertTrue(c.moveToFirst()); + + for (int i = 0; i < columns.length; i++) { + String expected = columnsExpected[i*2+1]; + String actual; + if (c.getType(i) == Cursor.FIELD_TYPE_BLOB) { + actual = new String(c.getBlob(i)); + } + else { + actual = c.getString(i); + } + assertEquals(expected, actual); + } + + c.close(); + } + +} diff --git a/app/src/androidTest/java/org/kontalk/service/registration/RegistrationServiceTest.java b/app/src/androidTest/java/org/kontalk/service/registration/RegistrationServiceTest.java new file mode 100644 index 000000000..25d2b75c9 --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/service/registration/RegistrationServiceTest.java @@ -0,0 +1,369 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration; + +import java.io.InputStream; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import android.content.Intent; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.filters.LargeTest; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.kontalk.DefaultAccountTest; +import org.kontalk.TestServerTest; +import org.kontalk.TestUtils; +import org.kontalk.service.registration.event.AcceptTermsRequest; +import org.kontalk.service.registration.event.AccountCreatedEvent; +import org.kontalk.service.registration.event.ChallengeError; +import org.kontalk.service.registration.event.ChallengeRequest; +import org.kontalk.service.registration.event.ImportKeyError; +import org.kontalk.service.registration.event.ImportKeyRequest; +import org.kontalk.service.registration.event.LoginTestEvent; +import org.kontalk.service.registration.event.TermsAcceptedEvent; +import org.kontalk.service.registration.event.VerificationError; +import org.kontalk.service.registration.event.VerificationRequest; +import org.kontalk.service.registration.event.VerificationRequestedEvent; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class RegistrationServiceTest extends TestServerTest { + + private EventBus mBus = RegistrationService.bus(); + + @BeforeClass + public static void setUpBeforeClass() throws InterruptedException { + // always start with a clean slate + TestUtils.removeDefaultAccount(); + RegistrationService.clearSavedState(); + } + + @Before + public void setUp() throws Exception { + super.setUp(); + startService(); + } + + @After + public void tearDown() throws Exception { + stopService(); + // remove any created account + TestUtils.removeDefaultAccount(); + } + + private void startService() { + InstrumentationRegistry.getInstrumentation().getTargetContext() + .startService(new Intent(InstrumentationRegistry.getInstrumentation().getTargetContext(), + RegistrationService.class)); + try { + Thread.sleep(1000); + } + catch (InterruptedException ignored) { + } + } + + private void stopService() { + InstrumentationRegistry.getInstrumentation().getTargetContext() + .stopService(new Intent(InstrumentationRegistry.getInstrumentation().getTargetContext(), + RegistrationService.class)); + try { + Thread.sleep(1000); + } + catch (InterruptedException ignored) { + } + } + + @Test + public void testFixNumber() throws Exception { + // see https://github.com/kontalk/androidclient/issues/917 + assertEquals("+5491112345678", RegistrationService.fixNumber("+54 9 11 1234 5678", "+5491112345678", "AR", 0)); + assertEquals("+5491112345678", RegistrationService.fixNumber("+54 11 1234 5678", "+5491112345678", "AR", 0)); + // invalid case: RegistrationService.fixNumber("15 1234 5678", "+5491112345678", "AR", 0); + assertEquals("+5491112345678", RegistrationService.fixNumber("0 11 15 1234 5678", "+5491112345678", "AR", 0)); + + assertEquals("+393351234567", RegistrationService.fixNumber("+39 335 123 4567", "+393321213456", "IT", 0)); + assertEquals("+393351234567", RegistrationService.fixNumber("335 123 4567", "+393321213456", "IT", 0)); + } + + @Test + public void testAddSignificantDigits() { + assertEquals(91112345678L, RegistrationService.addSignificantDigits(1112345678L, 9)); + assertEquals(341112345678L, RegistrationService.addSignificantDigits(1112345678L, 34)); + } + + @Test + public void requestVerificationTest() throws Exception { + RequestVerificationTestListener listener = new RequestVerificationTestListener(); + mBus.register(listener); + + mBus.post(new VerificationRequest("+15555215554", "Device-5554", + mTestServerProvider, true, RegistrationService.BRAND_IMAGE_LARGE)); + + listener.waitAndReset(10, TimeUnit.SECONDS, 1); + + if (listener.verificationError != null) + throw listener.verificationError; + + assertTrue(listener.acceptTermsEvent); + assertTrue(listener.verificationRequestedEvent); + + // send challenge + mBus.post(new ChallengeRequest("123456")); + + listener.waitAndReset(10, TimeUnit.SECONDS, 1); + + mBus.unregister(listener); + + if (listener.challengeError != null) + throw listener.challengeError; + + assertTrue(listener.accountCreated); + + // we should have an account now + TestUtils.assertDefaultAccountExists(); + } + + @Test + public void saveAndResumeRequestVerificationTest() throws Exception { + RequestVerificationTestListener listener = new RequestVerificationTestListener(); + mBus.register(listener); + + mBus.post(new VerificationRequest("+15555215554", "Device-5554", + mTestServerProvider, true, RegistrationService.BRAND_IMAGE_LARGE)); + + // a VerificationRequested will also be triggered, so count must be 2 + listener.waitAndReset(600, TimeUnit.SECONDS, 2); + + if (listener.verificationError != null) + throw listener.verificationError; + + assertTrue(listener.acceptTermsEvent); + assertTrue(listener.verificationRequestedEvent); + + stopService(); + + startService(); + + // send challenge + mBus.post(new ChallengeRequest("123456")); + + listener.waitAndReset(600, TimeUnit.SECONDS, 1); + + mBus.unregister(listener); + + if (listener.challengeError != null) + throw listener.challengeError; + + assertTrue(listener.accountCreated); + + // we should have an account now + TestUtils.assertDefaultAccountExists(); + } + + /** + * Tests registration workflow using secret data passed on by another device. + */ + @Test + public void registerDeviceTest() { + // TODO + throw new AssertionError("Not implemented."); + } + + @Test + public void importKeyTest() throws Exception { + RequestVerificationTestListener listener = new RequestVerificationTestListener(); + mBus.register(listener); + + InputStream keyPackInput = InstrumentationRegistry.getInstrumentation().getContext().getAssets().open("keys/kontalk-keys.zip"); + + mBus.post(new ImportKeyRequest(mTestServerProvider.next(), + keyPackInput, DefaultAccountTest.TEST_PASSPHRASE, + true, RegistrationService.BRAND_IMAGE_LARGE)); + + listener.waitAndReset(10, TimeUnit.SECONDS, 1); + + keyPackInput.close(); + + if (listener.importKeyError != null) + throw listener.importKeyError; + + assertTrue(listener.acceptTermsEvent); + + listener.waitAndReset(10, TimeUnit.SECONDS, 1); + + // either the account was created or a verification workflow has started + assertTrue(listener.verificationRequestedEvent || listener.accountCreated); + + if (listener.verificationRequestedEvent) { + // send challenge + mBus.post(new ChallengeRequest("123456")); + + listener.waitAndReset(10, TimeUnit.SECONDS, 1); + + if (listener.challengeError != null) + throw listener.challengeError; + } + else { + // this shouldn't happen since we set fallbackVerification to true + if (listener.loginTestError != null) + throw listener.loginTestError; + } + + mBus.unregister(listener); + + assertTrue(listener.accountCreated); + + // we should have an account now + TestUtils.assertDefaultAccountExists(); + } + + @Test + public void saveAndResumeImportKeyTest() throws Exception { + // for this test to work we need to first create a new account + // so the fallback registration procedure will trigger + requestVerificationTest(); + tearDown(); + setUp(); + + RequestVerificationTestListener listener = new RequestVerificationTestListener(); + mBus.register(listener); + + InputStream keyPackInput = InstrumentationRegistry.getInstrumentation().getContext().getAssets().open("keys/kontalk-keys.zip"); + + mBus.post(new ImportKeyRequest(mTestServerProvider.next(), + keyPackInput, DefaultAccountTest.TEST_PASSPHRASE, + true, RegistrationService.BRAND_IMAGE_LARGE)); + + // wait for error or verification requested + listener.waitAndReset(10, TimeUnit.SECONDS, 1); + + keyPackInput.close(); + + if (listener.importKeyError != null) + throw listener.importKeyError; + + assertTrue(listener.acceptTermsEvent); + assertTrue(listener.verificationRequestedEvent); + + stopService(); + + listener.verificationRequestedEvent = false; + + startService(); + + // wait for verification requested + listener.waitAndReset(10, TimeUnit.SECONDS, 1); + + assertTrue(listener.verificationRequestedEvent); + + // send challenge + mBus.post(new ChallengeRequest("123456")); + + listener.waitAndReset(20, TimeUnit.SECONDS, 1); + + if (listener.challengeError != null) + throw listener.challengeError; + + mBus.unregister(listener); + + assertTrue(listener.accountCreated); + + // we should have an account now + TestUtils.assertDefaultAccountExists(); + } + + public class RequestVerificationTestListener { + public CountDownLatch lock = new CountDownLatch(1); + + public boolean acceptTermsEvent; + public boolean verificationRequestedEvent; + public boolean accountCreated; + + public Exception verificationError; + public Exception challengeError; + public Exception importKeyError; + public Exception loginTestError; + + public void waitAndReset(long timeout, TimeUnit unit, int count) throws InterruptedException { + lock.await(timeout, unit); + reset(count); + } + + public void reset(int count) { + lock = new CountDownLatch(count); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAcceptTermsRequested(AcceptTermsRequest request) { + acceptTermsEvent = true; + mBus.post(new TermsAcceptedEvent()); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onVerificationRequested(VerificationRequestedEvent event) { + this.verificationRequestedEvent = true; + lock.countDown(); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onVerificationError(VerificationError error) { + this.verificationError = error.exception; + lock.countDown(); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onChallengeError(ChallengeError error) { + this.challengeError = error.exception; + lock.countDown(); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onImportKeyError(ImportKeyError error) { + this.importKeyError = error.exception; + lock.countDown(); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAccountCreated(AccountCreatedEvent event) { + this.accountCreated = true; + lock.countDown(); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onLoginTest(LoginTestEvent event) { + this.loginTestError = event.exception; + lock.countDown(); + } + } + +} diff --git a/app/src/androidTest/java/org/kontalk/ui/ConversationsActivityTest.java b/app/src/androidTest/java/org/kontalk/ui/ConversationsActivityTest.java new file mode 100644 index 000000000..3f38686fe --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/ui/ConversationsActivityTest.java @@ -0,0 +1,62 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import com.robotium.solo.Solo; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.espresso.intent.rule.IntentsTestRule; +import androidx.test.filters.LargeTest; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.kontalk.TestUtils; + +import static org.junit.Assert.assertTrue; + + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class ConversationsActivityTest { + + @Rule + public IntentsTestRule mActivityRule = + new IntentsTestRule<>(ConversationsActivity.class, false, false); + + private Solo solo; + + @Before + public void setUp() { + solo = new Solo(InstrumentationRegistry.getInstrumentation()); + } + + @Test + public void testOpenRegistration() throws Exception { + TestUtils.skipIfDefaultAccountExists(); + + // this must be done now otherwise solo won't see it + mActivityRule.launchActivity(null); + assertTrue(solo.waitForActivity(NumberValidation.class)); + } + +} diff --git a/app/src/androidTest/java/org/kontalk/ui/NumberValidationTest.java b/app/src/androidTest/java/org/kontalk/ui/NumberValidationTest.java new file mode 100644 index 000000000..d0ff68921 --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/ui/NumberValidationTest.java @@ -0,0 +1,93 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import com.robotium.solo.Solo; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import android.Manifest; +import android.app.Activity; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.filters.LargeTest; +import androidx.test.rule.ActivityTestRule; +import androidx.test.rule.GrantPermissionRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.kontalk.BuildConfig; +import org.kontalk.R; + +import static org.junit.Assert.*; + + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class NumberValidationTest { + + @Rule + public ActivityTestRule mActivityRule + = new ActivityTestRule<>(NumberValidation.class); + private Solo solo; + + @Rule + public GrantPermissionRule mPermissionRule = + GrantPermissionRule.grant(Manifest.permission.READ_PHONE_STATE, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.CAMERA); + + @Before + public void setUp() throws Exception { + solo = new Solo(InstrumentationRegistry.getInstrumentation(), + mActivityRule.getActivity()); + } + + @Test + public void testCheckInput() throws Exception { + Activity activity = mActivityRule.getActivity(); + solo.unlockScreen(); + // just click it + solo.clickOnButton(0); + assertTrue(solo.waitForDialogToOpen()); + assertTrue(solo.searchText(activity.getString(R.string.msg_no_name))); + solo.clickOnText(activity.getString(android.R.string.ok)); + // clear everything + solo.clearEditText(0); + solo.clearEditText(1); + solo.clickOnButton(0); + assertTrue(solo.waitForDialogToOpen()); + assertTrue(solo.searchText(activity.getString(R.string.msg_no_name))); + solo.clickOnText(activity.getString(android.R.string.ok)); + // this test will work on release build only + if (!BuildConfig.DEBUG) { + // fill the name + solo.enterText(0, "Instrumentation Test"); + solo.clickOnButton(0); + assertTrue(solo.waitForDialogToOpen()); + assertTrue(solo.searchText(activity.getString(R.string.msg_invalid_number))); + solo.clickOnText(activity.getString(android.R.string.ok)); + } + } + +} diff --git a/app/src/androidTest/java/org/kontalk/ui/QuickSelfChatTest.java b/app/src/androidTest/java/org/kontalk/ui/QuickSelfChatTest.java new file mode 100644 index 000000000..8d80c57a2 --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/ui/QuickSelfChatTest.java @@ -0,0 +1,152 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.espresso.ViewInteraction; +import androidx.test.filters.LargeTest; +import androidx.test.rule.ActivityTestRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; + +import org.kontalk.DefaultAccountTest; +import org.kontalk.R; +import org.kontalk.TestUtils; +import org.kontalk.provider.MyMessages; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; +import static androidx.test.espresso.matcher.ViewMatchers.withClassName; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withParent; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.is; + + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class QuickSelfChatTest extends DefaultAccountTest { + private static final String TEST_MESSAGE = "Ping"; + + @Rule + public ActivityTestRule mActivityTestRule = + new ActivityTestRule<>(ConversationsActivity.class); + + @Test + public void quickSelfChatTest() { + ViewInteraction floatingActionButton = onView( + allOf(withClassName(is("com.github.clans.fab.FloatingActionButton")), + withParent(allOf(withId(R.id.action), + withParent(withId(R.id.fragment_conversation_list)))), + isDisplayed())); + floatingActionButton.perform(click()); + + // Added a sleep statement to match the app's execution delay. + // The recommended way to handle such scenarios is to use Espresso idling resources: + // https://google.github.io/android-testing-support-library/docs/espresso/idling-resource/index.html + try { + Thread.sleep(500); + } + catch (InterruptedException e) { + e.printStackTrace(); + } + + onView( + allOf(withId(R.id.action_compose), + withParent(allOf(withId(R.id.action), + withParent(withId(R.id.fragment_conversation_list)))), + isDisplayed())) + .perform(click()); + + // Added a sleep statement to match the app's execution delay. + // The recommended way to handle such scenarios is to use Espresso idling resources: + // https://google.github.io/android-testing-support-library/docs/espresso/idling-resource/index.html + try { + Thread.sleep(500); + } + catch (InterruptedException e) { + e.printStackTrace(); + } + + onView( + allOf(childAtPosition( + withId(android.R.id.list), + 0), + isDisplayed())) + .perform(click()); + + onView( + allOf(withId(R.id.text_editor), + withParent(allOf(withId(R.id.composer_bar), + withParent(withId(R.id.container)))), + isDisplayed())) + .perform(replaceText(TEST_MESSAGE), closeSoftKeyboard()); + + onView( + allOf(withId(R.id.send_button), + isEnabled())) + .perform(click()); + + try { + Thread.sleep(1000); + } + catch (InterruptedException e) { + e.printStackTrace(); + } + + onView(withId(android.R.id.list)) + .check(matches(TestUtils.withMessageDirection + (MyMessages.Messages.DIRECTION_IN))) + .check(matches(TestUtils.withMessageContent + (InstrumentationRegistry.getInstrumentation().getTargetContext(), TEST_MESSAGE))); + } + + private static Matcher childAtPosition( + final Matcher parentMatcher, final int position) { + + return new TypeSafeMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("Child at position " + position + " in parent "); + parentMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + ViewParent parent = view.getParent(); + return parent instanceof ViewGroup && parentMatcher.matches(parent) + && view.equals(((ViewGroup) parent).getChildAt(position)); + } + }; + } +} diff --git a/app/src/androidTest/java/org/kontalk/ui/RegistrationTest.java b/app/src/androidTest/java/org/kontalk/ui/RegistrationTest.java new file mode 100644 index 000000000..21ac0600c --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/ui/RegistrationTest.java @@ -0,0 +1,288 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import java.util.Collection; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import android.Manifest; +import android.os.Handler; +import android.os.Looper; +import androidx.test.espresso.Espresso; +import androidx.test.espresso.IdlingRegistry; +import androidx.test.espresso.IdlingResource; +import androidx.test.espresso.NoMatchingRootException; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.filters.LargeTest; +import androidx.test.rule.ActivityTestRule; +import androidx.test.rule.GrantPermissionRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.kontalk.EventIdlingResource; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.TestServerTest; +import org.kontalk.TestUtils; +import org.kontalk.service.registration.RegistrationService; +import org.kontalk.service.registration.event.AcceptTermsRequest; +import org.kontalk.service.registration.event.AccountCreatedEvent; +import org.kontalk.service.registration.event.UserConflictError; +import org.kontalk.service.registration.event.VerificationRequestedEvent; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.action.ViewActions.scrollTo; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.RootMatchers.isDialog; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; + + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class RegistrationTest extends TestServerTest { + private static final String TEST_PIN_CODE = "123456"; + private static final String TEST_USERNAME = "dev-5554"; + private static final String TEST_USERID = "5555215554"; + + private EventBus mBus = RegistrationService.bus(); + + @Rule + public ActivityTestRule mActivityTestRule = + new ActivityTestRule<>(ConversationsActivity.class); + + @Rule + public GrantPermissionRule mPermissionRule = + GrantPermissionRule.grant(Manifest.permission.READ_PHONE_STATE, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.CAMERA); + + @BeforeClass + public static void setUpBeforeClass() throws InterruptedException { + // always start with a clean slate + TestUtils.removeDefaultAccount(); + RegistrationService.clearSavedState(); + } + + @Before + public void setUp() throws Exception { + TestUtils.removeDefaultAccount(); + super.setUp(); + } + + @After + public void tearDown() throws Exception { + TestUtils.removeDefaultAccount(); + Collection idlingResourceList = IdlingRegistry.getInstance().getResources(); + for (IdlingResource resource : idlingResourceList) { + IdlingRegistry.getInstance().unregister(resource); + } + } + + @Test + public void registrationTest() throws Exception { + onView(withId(R.id.name)) + .perform(scrollTo(), replaceText(TEST_USERNAME), closeSoftKeyboard()); + onView(withId(R.id.phone_number)) + .perform(scrollTo(), replaceText(TEST_USERID), closeSoftKeyboard()); + + onView(withId(R.id.button_validate)) + .perform(scrollTo(), click()); + + // register accept terms event + EventIdlingResource acceptTermsResource = TestUtils + .registerEventIdlingResource(mBus, AcceptTermsRequest.class); + + // input confirmation dialog + onView(withText(R.string.msg_register_confirm_number1)) + .inRoot(isDialog()) + .check(matches(isDisplayed())); + onView(allOf(withId(R.id.md_buttonDefaultPositive), + withText(android.R.string.ok), + isDisplayed())) + .inRoot(isDialog()) + .perform(click()); + + acceptTermsResource.start(); + + UserConflictOrVerificationRequestedIdlingResource registrationResource = + TestUtils.registerIdlingResource(new UserConflictOrVerificationRequestedIdlingResource(mBus, 2000)); + + // service terms dialog + try { + onView(withText(R.string.registration_accept_terms_title)) + .inRoot(isDialog()) + .check(matches(isDisplayed())); + + onView(allOf(withId(R.id.md_buttonDefaultPositive), withText(R.string.yes), isDisplayed())) + .inRoot(isDialog()) + .perform(click()); + } + catch (NoMatchingViewException e) { + // A-EHM... ignoring since the dialog might or might not appear + Log.w("TEST", "No matching service terms dialog"); + } + + registrationResource.start(); + + TestUtils.unregisterIdlingResource(acceptTermsResource); + + // force registration dialog + try { + // we are going to try again + registrationResource.reset(); + + onView(allOf(withId(R.id.md_buttonDefaultNeutral), withText(R.string.btn_device_overwrite), isDisplayed())) + .inRoot(isDialog()) + .perform(click()); + + registrationResource.start(); + } + catch (NoMatchingRootException e) { + // A-EHM... ignoring since the dialog might or might not appear + Log.w("TEST", "No matching account override dialog"); + } + + onView(withId(R.id.validation_code)) + .perform(scrollTo(), replaceText(TEST_PIN_CODE), closeSoftKeyboard()); + + TestUtils.unregisterIdlingResource(registrationResource); + + EventIdlingResource accountResource = TestUtils + .registerEventIdlingResource(mBus, AccountCreatedEvent.class); + + onView(withId(R.id.send_button)) + .perform(scrollTo(), click()); + + accountResource.start(); + + Espresso.onIdle(); + + TestUtils.unregisterIdlingResource(accountResource); + + // we should have an account now + TestUtils.assertDefaultAccountExists(); + } + + private static final class UserConflictOrVerificationRequestedIdlingResource + implements IdlingResource { + + private final EventBus mBus; + private final long mTimeoutMs; + + private final Handler mHandler; + private final Runnable mTransitionToIdle; + + private boolean mRunning; + private ResourceCallback mCallback; + private boolean mEventReceived; + + UserConflictOrVerificationRequestedIdlingResource(EventBus bus, long timeoutMs) { + mHandler = new Handler(Looper.getMainLooper()); + mBus = bus; + mBus.register(this); + mTimeoutMs = timeoutMs; + + mTransitionToIdle = new Runnable() { + @Override + public void run() { + mEventReceived = true; + if (mCallback != null) { + mCallback.onTransitionToIdle(); + } + } + }; + } + + @Override + public String getName() { + return getClass().getSimpleName(); + } + + public void reset() { + mRunning = false; + mEventReceived = false; + mHandler.removeCallbacks(mTransitionToIdle); + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onUserConflict(UserConflictError event) { + Log.d("TEST", "got idling event: " + event); + mHandler.postDelayed(mTransitionToIdle, mTimeoutMs); + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onVerificationRequested(VerificationRequestedEvent event) { + Log.d("TEST", "got idling event: " + event); + mHandler.postDelayed(mTransitionToIdle, mTimeoutMs); + mBus.unregister(this); + } + + @Override + public boolean isIdleNow() { + return !mRunning || mEventReceived; + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback callback) { + mCallback = callback; + } + + public void start() { + mRunning = true; + } + } + + /** + * Tests the import key workflow when the server doesn't trust the key + * because it was overruled by another one. User must verify the phone + * number, but the key will be reused. + */ + @Test + public void importUntrustedKeyTest() { + // TODO + throw new AssertionError("Not implemented."); + } + + /** + * Tries to import a revoked key and should fail permanently. + */ + @Test + public void importRevokedKeyTest() { + // TODO + throw new AssertionError("Not implemented."); + } + +} diff --git a/app/src/androidTest/java/org/kontalk/util/XMPPUtilsTest.java b/app/src/androidTest/java/org/kontalk/util/XMPPUtilsTest.java new file mode 100644 index 000000000..a8109cb96 --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/util/XMPPUtilsTest.java @@ -0,0 +1,33 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import org.junit.Test; + +import static org.junit.Assert.*; + + +public class XMPPUtilsTest { + + @Test + public void testGetJIDColor() throws Exception { + assertEquals(0xff55ff00, XMPPUtils.getJIDColor("juliet@capulet.lit")); + } + +} diff --git a/app/src/basic/java/org/kontalk/billing/BillingServiceManager.java b/app/src/basic/java/org/kontalk/billing/BillingServiceManager.java deleted file mode 100644 index b719d30a7..000000000 --- a/app/src/basic/java/org/kontalk/billing/BillingServiceManager.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.billing; - - -import android.content.Context; - -/** - * Billing service singleton container. - * @author Daniele Ricci - */ -public class BillingServiceManager { - - public static final int BILLING_RESPONSE_RESULT_OK = 0; - - private BillingServiceManager() { - } - - public static IBillingService getInstance(Context context) { - return null; - } - - /** Returns true if the billing action should be visible in the UI. */ - public static boolean isEnabled() { - return false; - } - - public static String getResponseDesc(int code) { - return null; - } - -} diff --git a/app/src/basic/java/org/kontalk/position/PositionManager.java b/app/src/basic/java/org/kontalk/position/PositionManager.java new file mode 100644 index 000000000..a2f978614 --- /dev/null +++ b/app/src/basic/java/org/kontalk/position/PositionManager.java @@ -0,0 +1,58 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.position; + +import android.content.Context; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.RecyclerView; + +import org.kontalk.ui.position.PositionOsmFragment; +import org.kontalk.ui.position.SendPositionOsmFragment; + + +/** + * @author Andrea Cappelli + */ +public class PositionManager { + + public static Fragment getSendPositionFragment(Context context) { + return new SendPositionOsmFragment(); + } + + public static Fragment getPositionFragment(Context context) { + return new PositionOsmFragment(); + } + + public static RequestDetails getStaticMapUrl(Context context, double lat, double lon, Integer zoom, int width, int height, Integer scale) { + return new RequestDetails(new OsmStaticUrlBuilder().setCenter(lat, lon).setZoom(zoom) + .setMarker(lat, lon).setSize(width, height).toString()); + } + + public static String getMapsUrl(Context context, double lat, double lon) { + return OsmUrlBuilder.build(lat, lon); + } + + public static RecyclerView.Adapter createSearchPlacesAdapter(Context context) { + return null; + } + + public static RecyclerView.Adapter createPlacesAdapter(Context context) { + return null; + } +} diff --git a/app/src/basic/java/org/kontalk/reporting/ReportingManager.java b/app/src/basic/java/org/kontalk/reporting/ReportingManager.java new file mode 100644 index 000000000..7e32ea33e --- /dev/null +++ b/app/src/basic/java/org/kontalk/reporting/ReportingManager.java @@ -0,0 +1,48 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.reporting; + +import android.content.Context; + + +/** + * Dummy reporting manager doing nothing. + * @author Daniele Ricci + */ +public class ReportingManager { + + private ReportingManager() { + } + + public static void register(Context context) { + } + + public static void unregister(Context context) { + } + + public static void logException(Throwable exception) { + } + + public static void logRegister(String method) { + } + + public static void logSignUp(String method) { + } + +} diff --git a/app/src/basic/java/org/kontalk/service/msgcenter/DummyPushService.java b/app/src/basic/java/org/kontalk/service/msgcenter/DummyPushService.java deleted file mode 100644 index e026647ad..000000000 --- a/app/src/basic/java/org/kontalk/service/msgcenter/DummyPushService.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.service.msgcenter; - -import android.content.Context; - - -/** - * Dummy push client service. - * @author Daniele Ricci - */ -public class DummyPushService implements IPushService { - - public DummyPushService(Context context) { - } - - @Override - public void register(IPushListener listener, String senderId) { - } - - @Override - public void unregister(IPushListener listener) { - } - - @Override - public void retry() { - } - - @Override - public boolean isRegistered() { - return false; - } - - @Override - public boolean isServiceAvailable() { - return false; - } - - @Override - public void setRegisteredOnServer(boolean flag) { - } - - @Override - public boolean isRegisteredOnServer() { - return false; - } - - @Override - public long getRegisterOnServerLifespan() { - return 0; - } - - @Override - public void setRegisterOnServerLifespan(long lifespan) { - } - - @Override - public String getRegistrationId() { - return null; - } - -} diff --git a/app/src/basic/java/org/kontalk/service/msgcenter/PushServiceManager.java b/app/src/basic/java/org/kontalk/service/msgcenter/PushServiceManager.java index e50615a30..ee446dfaa 100644 --- a/app/src/basic/java/org/kontalk/service/msgcenter/PushServiceManager.java +++ b/app/src/basic/java/org/kontalk/service/msgcenter/PushServiceManager.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,6 +19,7 @@ package org.kontalk.service.msgcenter; import android.content.Context; +import androidx.annotation.Nullable; /** @@ -27,13 +28,10 @@ */ public class PushServiceManager { - private static IPushService sInstance; - + /** No push service compiled in, return null. */ + @Nullable public static IPushService getInstance(Context context) { - if (sInstance == null) - sInstance = new DummyPushService(context); - - return sInstance; + return null; } public static IPushListener getDefaultListener() { diff --git a/app/src/basic/java/org/kontalk/service/msgcenter/SecureConnectionManager.java b/app/src/basic/java/org/kontalk/service/msgcenter/SecureConnectionManager.java new file mode 100644 index 000000000..6b5d1daa1 --- /dev/null +++ b/app/src/basic/java/org/kontalk/service/msgcenter/SecureConnectionManager.java @@ -0,0 +1,32 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + +import android.content.Context; + + +public class SecureConnectionManager { + + public static void init(Context context) { + } + + public static void waitForInit() { + } + +} diff --git a/app/src/basic/res/values/config.xml b/app/src/basic/res/values/config.xml index 324843bbf..c0410bad9 100644 --- a/app/src/basic/res/values/config.xml +++ b/app/src/basic/res/values/config.xml @@ -1,7 +1,7 @@ true + + 0 diff --git a/app/src/basic/res/values/ids.xml b/app/src/basic/res/values/ids.xml new file mode 100644 index 000000000..75cf9a12f --- /dev/null +++ b/app/src/basic/res/values/ids.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/app/src/debug/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..7a12b01e1 Binary files /dev/null and b/app/src/debug/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-ldpi/ic_launcher.png b/app/src/debug/res/mipmap-ldpi/ic_launcher.png new file mode 100644 index 000000000..a98bf40d0 Binary files /dev/null and b/app/src/debug/res/mipmap-ldpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/app/src/debug/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..992007c11 Binary files /dev/null and b/app/src/debug/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..612470243 Binary files /dev/null and b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..c265ab44a Binary files /dev/null and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..af5a03ff2 Binary files /dev/null and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/googleplay/AndroidManifest.xml b/app/src/googleplay/AndroidManifest.xml index baf449879..2cc11aacb 100644 --- a/app/src/googleplay/AndroidManifest.xml +++ b/app/src/googleplay/AndroidManifest.xml @@ -1,6 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools" + package="org.kontalk"> - - - - - - - + - - - - - - - - - - + + + + + + + + + + + + diff --git a/app/src/googleplay/aidl/com/android/vending/billing/IInAppBillingService.aidl b/app/src/googleplay/aidl/com/android/vending/billing/IInAppBillingService.aidl deleted file mode 100644 index 2a492f784..000000000 --- a/app/src/googleplay/aidl/com/android/vending/billing/IInAppBillingService.aidl +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (C) 2012 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.vending.billing; - -import android.os.Bundle; - -/** - * InAppBillingService is the service that provides in-app billing version 3 and beyond. - * This service provides the following features: - * 1. Provides a new API to get details of in-app items published for the app including - * price, type, title and description. - * 2. The purchase flow is synchronous and purchase information is available immediately - * after it completes. - * 3. Purchase information of in-app purchases is maintained within the Google Play system - * till the purchase is consumed. - * 4. An API to consume a purchase of an inapp item. All purchases of one-time - * in-app items are consumable and thereafter can be purchased again. - * 5. An API to get current purchases of the user immediately. This will not contain any - * consumed purchases. - * - * All calls will give a response code with the following possible values - * RESULT_OK = 0 - success - * RESULT_USER_CANCELED = 1 - user pressed back or canceled a dialog - * RESULT_BILLING_UNAVAILABLE = 3 - this billing API version is not supported for the type requested - * RESULT_ITEM_UNAVAILABLE = 4 - requested SKU is not available for purchase - * RESULT_DEVELOPER_ERROR = 5 - invalid arguments provided to the API - * RESULT_ERROR = 6 - Fatal error during the API action - * RESULT_ITEM_ALREADY_OWNED = 7 - Failure to purchase since item is already owned - * RESULT_ITEM_NOT_OWNED = 8 - Failure to consume since item is not owned - */ -interface IInAppBillingService { - /** - * Checks support for the requested billing API version, package and in-app type. - * Minimum API version supported by this interface is 3. - * @param apiVersion the billing version which the app is using - * @param packageName the package name of the calling app - * @param type type of the in-app item being purchased "inapp" for one-time purchases - * and "subs" for subscription. - * @return RESULT_OK(0) on success, corresponding result code on failures - */ - int isBillingSupported(int apiVersion, String packageName, String type); - - /** - * Provides details of a list of SKUs - * Given a list of SKUs of a valid type in the skusBundle, this returns a bundle - * with a list JSON strings containing the productId, price, title and description. - * This API can be called with a maximum of 20 SKUs. - * @param apiVersion billing API version that the Third-party is using - * @param packageName the package name of the calling app - * @param skusBundle bundle containing a StringArrayList of SKUs with key "ITEM_ID_LIST" - * @return Bundle containing the following key-value pairs - * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on - * failure as listed above. - * "DETAILS_LIST" with a StringArrayList containing purchase information - * in JSON format similar to: - * '{ "productId" : "exampleSku", "type" : "inapp", "price" : "$5.00", - * "title : "Example Title", "description" : "This is an example description" }' - */ - Bundle getSkuDetails(int apiVersion, String packageName, String type, in Bundle skusBundle); - - /** - * Returns a pending intent to launch the purchase flow for an in-app item by providing a SKU, - * the type, a unique purchase token and an optional developer payload. - * @param apiVersion billing API version that the app is using - * @param packageName package name of the calling app - * @param sku the SKU of the in-app item as published in the developer console - * @param type the type of the in-app item ("inapp" for one-time purchases - * and "subs" for subscription). - * @param developerPayload optional argument to be sent back with the purchase information - * @return Bundle containing the following key-value pairs - * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on - * failure as listed above. - * "BUY_INTENT" - PendingIntent to start the purchase flow - * - * The Pending intent should be launched with startIntentSenderForResult. When purchase flow - * has completed, the onActivityResult() will give a resultCode of OK or CANCELED. - * If the purchase is successful, the result data will contain the following key-value pairs - * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on - * failure as listed above. - * "INAPP_PURCHASE_DATA" - String in JSON format similar to - * '{"orderId":"12999763169054705758.1371079406387615", - * "packageName":"com.example.app", - * "productId":"exampleSku", - * "purchaseTime":1345678900000, - * "purchaseToken" : "122333444455555", - * "developerPayload":"example developer payload" }' - * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that - * was signed with the private key of the developer - * TODO: change this to app-specific keys. - */ - Bundle getBuyIntent(int apiVersion, String packageName, String sku, String type, - String developerPayload); - - /** - * Returns the current SKUs owned by the user of the type and package name specified along with - * purchase information and a signature of the data to be validated. - * This will return all SKUs that have been purchased in V3 and managed items purchased using - * V1 and V2 that have not been consumed. - * @param apiVersion billing API version that the app is using - * @param packageName package name of the calling app - * @param type the type of the in-app items being requested - * ("inapp" for one-time purchases and "subs" for subscription). - * @param continuationToken to be set as null for the first call, if the number of owned - * skus are too many, a continuationToken is returned in the response bundle. - * This method can be called again with the continuation token to get the next set of - * owned skus. - * @return Bundle containing the following key-value pairs - * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on - * failure as listed above. - * "INAPP_PURCHASE_ITEM_LIST" - StringArrayList containing the list of SKUs - * "INAPP_PURCHASE_DATA_LIST" - StringArrayList containing the purchase information - * "INAPP_DATA_SIGNATURE_LIST"- StringArrayList containing the signatures - * of the purchase information - * "INAPP_CONTINUATION_TOKEN" - String containing a continuation token for the - * next set of in-app purchases. Only set if the - * user has more owned skus than the current list. - */ - Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken); - - /** - * Consume the last purchase of the given SKU. This will result in this item being removed - * from all subsequent responses to getPurchases() and allow re-purchase of this item. - * @param apiVersion billing API version that the app is using - * @param packageName package name of the calling app - * @param purchaseToken token in the purchase information JSON that identifies the purchase - * to be consumed - * @return 0 if consumption succeeded. Appropriate error values for failures. - */ - int consumePurchase(int apiVersion, String packageName, String purchaseToken); -} diff --git a/app/src/googleplay/java/org/kontalk/billing/BillingServiceManager.java b/app/src/googleplay/java/org/kontalk/billing/BillingServiceManager.java deleted file mode 100644 index 527503fc7..000000000 --- a/app/src/googleplay/java/org/kontalk/billing/BillingServiceManager.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.billing; - -import android.content.Context; - - -/** - * Billing service singleton container. - * @author Daniele Ricci - */ -public class BillingServiceManager { - - public static final int BILLING_RESPONSE_RESULT_OK = 0; - - private static IBillingService sInstance; - - private BillingServiceManager() { - } - - public static IBillingService getInstance(Context context) { - if (sInstance == null || sInstance.isDisposed()) - sInstance = new GoogleBillingService(context); - - return sInstance; - } - - /** Returns true if the billing action should be visible in the UI. */ - public static boolean isEnabled() { - return true; - } - - public static String getResponseDesc(int code) { - return GoogleBillingService.getResponseDesc(code); - } - -} diff --git a/app/src/googleplay/java/org/kontalk/billing/GoogleBillingService.java b/app/src/googleplay/java/org/kontalk/billing/GoogleBillingService.java deleted file mode 100644 index 533b87f8b..000000000 --- a/app/src/googleplay/java/org/kontalk/billing/GoogleBillingService.java +++ /dev/null @@ -1,890 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - -import android.app.Activity; -import android.app.PendingIntent; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentSender.SendIntentException; -import android.content.ServiceConnection; -import android.content.pm.ResolveInfo; -import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.os.RemoteException; -import android.text.TextUtils; -import android.util.Log; - -import com.android.vending.billing.IInAppBillingService; - -import org.json.JSONException; - -import java.util.ArrayList; -import java.util.List; - - -/** - * Provides convenience methods for in-app billing. You can create one instance of this - * class for your application and use it to process in-app billing operations. - * It provides synchronous (blocking) and asynchronous (non-blocking) methods for - * many common in-app billing operations, as well as automatic signature - * verification. - * - * After instantiating, you must perform setup in order to start using the object. - * To perform setup, call the {@link #startSetup} method and provide a listener; - * that listener will be notified when setup is complete, after which (and not before) - * you may call other methods. - * - * After setup is complete, you will typically want to request an inventory of owned - * items and subscriptions. See {@link #queryInventory}, {@link #queryInventoryAsync} - * and related methods. - * - * When you are done with this object, don't forget to call {@link #dispose} - * to ensure proper cleanup. This object holds a binding to the in-app billing - * service, which will leak unless you dispose of it correctly. If you created - * the object on an Activity's onCreate method, then the recommended - * place to dispose of it is the Activity's onDestroy method. - * - * A note about threading: When using this object from a background thread, you may - * call the blocking versions of methods; when using from a UI thread, call - * only the asynchronous versions and handle the results via callbacks. - * Also, notice that you can only call one asynchronous operation at a time; - * attempting to start a second asynchronous operation while the first one - * has not yet completed will result in an exception being thrown. - * - * @author Bruno Oliveira (Google) - * - */ -public class GoogleBillingService implements IBillingService { - // Is debug logging enabled? - boolean mDebugLog = false; - String mDebugTag = "IabHelper"; - - // Is setup done? - boolean mSetupDone = false; - - // Has this object been disposed of? (If so, we should ignore callbacks, etc) - boolean mDisposed = false; - - // Are subscriptions supported? - boolean mSubscriptionsSupported = false; - - // Is an asynchronous operation in progress? - // (only one at a time can be in progress) - boolean mAsyncInProgress = false; - - // (for logging/debugging) - // if mAsyncInProgress == true, what asynchronous operation is in progress? - String mAsyncOperation = ""; - - // Context we were passed during initialization - Context mContext; - - // Connection to the service - IInAppBillingService mService; - ServiceConnection mServiceConn; - - // The request code used to launch purchase flow - int mRequestCode; - - // The item type of the current purchase flow - String mPurchasingItemType; - - // Billing response codes - public static final int BILLING_RESPONSE_RESULT_OK = 0; - public static final int BILLING_RESPONSE_RESULT_USER_CANCELED = 1; - public static final int BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE = 3; - public static final int BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE = 4; - public static final int BILLING_RESPONSE_RESULT_DEVELOPER_ERROR = 5; - public static final int BILLING_RESPONSE_RESULT_ERROR = 6; - public static final int BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7; - public static final int BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8; - - // IAB Helper error codes - public static final int IABHELPER_ERROR_BASE = -1000; - public static final int IABHELPER_REMOTE_EXCEPTION = -1001; - public static final int IABHELPER_BAD_RESPONSE = -1002; - public static final int IABHELPER_VERIFICATION_FAILED = -1003; - public static final int IABHELPER_SEND_INTENT_FAILED = -1004; - public static final int IABHELPER_USER_CANCELLED = -1005; - public static final int IABHELPER_UNKNOWN_PURCHASE_RESPONSE = -1006; - public static final int IABHELPER_MISSING_TOKEN = -1007; - public static final int IABHELPER_UNKNOWN_ERROR = -1008; - public static final int IABHELPER_SUBSCRIPTIONS_NOT_AVAILABLE = -1009; - public static final int IABHELPER_INVALID_CONSUMPTION = -1010; - - // Keys for the responses from InAppBillingService - public static final String RESPONSE_CODE = "RESPONSE_CODE"; - public static final String RESPONSE_GET_SKU_DETAILS_LIST = "DETAILS_LIST"; - public static final String RESPONSE_BUY_INTENT = "BUY_INTENT"; - public static final String RESPONSE_INAPP_PURCHASE_DATA = "INAPP_PURCHASE_DATA"; - public static final String RESPONSE_INAPP_SIGNATURE = "INAPP_DATA_SIGNATURE"; - public static final String RESPONSE_INAPP_ITEM_LIST = "INAPP_PURCHASE_ITEM_LIST"; - public static final String RESPONSE_INAPP_PURCHASE_DATA_LIST = "INAPP_PURCHASE_DATA_LIST"; - public static final String RESPONSE_INAPP_SIGNATURE_LIST = "INAPP_DATA_SIGNATURE_LIST"; - public static final String INAPP_CONTINUATION_TOKEN = "INAPP_CONTINUATION_TOKEN"; - - // Item types - public static final String ITEM_TYPE_INAPP = "inapp"; - public static final String ITEM_TYPE_SUBS = "subs"; - - // some fields on the getSkuDetails response bundle - public static final String GET_SKU_DETAILS_ITEM_LIST = "ITEM_ID_LIST"; - public static final String GET_SKU_DETAILS_ITEM_TYPE_LIST = "ITEM_TYPE_LIST"; - - /** - * Creates an instance. After creation, it will not yet be ready to use. You must perform - * setup by calling {@link #startSetup} and wait for setup to complete. This constructor does not - * block and is safe to call from a UI thread. - * - * @param ctx Your application or Activity context. Needed to bind to the in-app billing service. - */ - public GoogleBillingService(Context ctx) { - mContext = ctx.getApplicationContext(); - logDebug("IAB helper created."); - } - - /** - * Enables or disable debug logging through LogCat. - */ - public void enableDebugLogging(boolean enable, String tag) { - checkNotDisposed(); - mDebugLog = enable; - mDebugTag = tag; - } - - public void enableDebugLogging(boolean enable) { - checkNotDisposed(); - mDebugLog = enable; - } - - /** - * Starts the setup process. This will start up the setup process asynchronously. - * You will be notified through the listener when the setup process is complete. - * This method is safe to call from a UI thread. - * - * @param listener The listener to notify when the setup process is complete. - */ - public void startSetup(final OnBillingSetupFinishedListener listener) { - // If already set up, can't do it again. - checkNotDisposed(); - if (mSetupDone) throw new IllegalStateException("IAB helper is already set up."); - - // Connection to IAB service - logDebug("Starting in-app billing setup."); - mServiceConn = new ServiceConnection() { - @Override - public void onServiceDisconnected(ComponentName name) { - logDebug("Billing service disconnected."); - mService = null; - } - - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - if (mDisposed) return; - logDebug("Billing service connected."); - mService = IInAppBillingService.Stub.asInterface(service); - String packageName = mContext.getPackageName(); - try { - logDebug("Checking for in-app billing 3 support."); - - // check for in-app billing v3 support - int response = mService.isBillingSupported(3, packageName, ITEM_TYPE_INAPP); - if (response != BILLING_RESPONSE_RESULT_OK) { - if (listener != null) listener.onSetupFinished(new BillingResult(response, - "Error checking for billing v3 support.")); - - // if in-app purchases aren't supported, neither are subscriptions. - mSubscriptionsSupported = false; - return; - } - logDebug("In-app billing version 3 supported for " + packageName); - - // check for v3 subscriptions support - response = mService.isBillingSupported(3, packageName, ITEM_TYPE_SUBS); - if (response == BILLING_RESPONSE_RESULT_OK) { - logDebug("Subscriptions AVAILABLE."); - mSubscriptionsSupported = true; - } - else { - logDebug("Subscriptions NOT AVAILABLE. Response: " + response); - } - - mSetupDone = true; - } - catch (RemoteException e) { - if (listener != null) { - listener.onSetupFinished(new BillingResult(IABHELPER_REMOTE_EXCEPTION, - "RemoteException while setting up in-app billing.")); - } - e.printStackTrace(); - return; - } - - if (listener != null) { - listener.onSetupFinished(new BillingResult(BILLING_RESPONSE_RESULT_OK, "Setup successful.")); - } - } - }; - - Intent serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND"); - serviceIntent.setPackage("com.android.vending"); - - List services = mContext.getPackageManager().queryIntentServices(serviceIntent, 0); - if (services != null && !services.isEmpty()) { - // service available to handle that Intent - mContext.bindService(serviceIntent, mServiceConn, Context.BIND_AUTO_CREATE); - } - else { - // no service available to handle that Intent - if (listener != null) { - listener.onSetupFinished( - new BillingResult(BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE, - "Billing service unavailable on device.")); - } - } - } - - /** - * Dispose of object, releasing resources. It's very important to call this - * method when you are done with this object. It will release any resources - * used by it such as service connections. Naturally, once the object is - * disposed of, it can't be used again. - */ - public synchronized void dispose() { - logDebug("Disposing."); - mSetupDone = false; - if (mServiceConn != null) { - logDebug("Unbinding from service."); - if (mContext != null) mContext.unbindService(mServiceConn); - } - mDisposed = true; - mContext = null; - mServiceConn = null; - mService = null; - mPurchaseListener = null; - } - - private synchronized void checkNotDisposed() { - if (mDisposed) throw new IllegalStateException("IabHelper was disposed of, so it cannot be used."); - } - - @Override - public synchronized boolean isDisposed() { - return mDisposed; - } - - /** Returns whether subscriptions are supported. */ - public boolean subscriptionsSupported() { - checkNotDisposed(); - return mSubscriptionsSupported; - } - - // The listener registered on launchPurchaseFlow, which we have to call back when - // the purchase finishes - OnPurchaseFinishedListener mPurchaseListener; - - public void launchPurchaseFlow(Activity act, String sku, int requestCode, OnPurchaseFinishedListener listener) { - launchPurchaseFlow(act, sku, requestCode, listener, ""); - } - - public void launchPurchaseFlow(Activity act, String sku, int requestCode, - OnPurchaseFinishedListener listener, String extraData) { - launchPurchaseFlow(act, sku, ITEM_TYPE_INAPP, requestCode, listener, extraData); - } - - public void launchSubscriptionPurchaseFlow(Activity act, String sku, int requestCode, - OnPurchaseFinishedListener listener) { - launchSubscriptionPurchaseFlow(act, sku, requestCode, listener, ""); - } - - public void launchSubscriptionPurchaseFlow(Activity act, String sku, int requestCode, - OnPurchaseFinishedListener listener, String extraData) { - launchPurchaseFlow(act, sku, ITEM_TYPE_SUBS, requestCode, listener, extraData); - } - - /** - * Initiate the UI flow for an in-app purchase. Call this method to initiate an in-app purchase, - * which will involve bringing up the Google Play screen. The calling activity will be paused while - * the user interacts with Google Play, and the result will be delivered via the activity's - * {@link android.app.Activity#onActivityResult} method, at which point you must call - * this object's {@link #handleActivityResult} method to continue the purchase flow. This method - * MUST be called from the UI thread of the Activity. - * - * @param act The calling activity. - * @param sku The sku of the item to purchase. - * @param itemType indicates if it's a product or a subscription (ITEM_TYPE_INAPP or ITEM_TYPE_SUBS) - * @param requestCode A request code (to differentiate from other responses -- - * as in {@link android.app.Activity#startActivityForResult}). - * @param listener The listener to notify when the purchase process finishes - * @param extraData Extra data (developer payload), which will be returned with the purchase data - * when the purchase completes. This extra data will be permanently bound to that purchase - * and will always be returned when the purchase is queried. - */ - public void launchPurchaseFlow(Activity act, String sku, String itemType, int requestCode, - OnPurchaseFinishedListener listener, String extraData) { - checkNotDisposed(); - checkSetupDone("launchPurchaseFlow"); - startAsyncOperation("launchPurchaseFlow"); - BillingResult result; - - if (itemType.equals(ITEM_TYPE_SUBS) && !mSubscriptionsSupported) { - BillingResult r = new BillingResult(IABHELPER_SUBSCRIPTIONS_NOT_AVAILABLE, - "Subscriptions are not available."); - endAsyncOperation(); - if (listener != null) listener.onPurchaseFinished(r, null); - return; - } - - try { - logDebug("Constructing buy intent for " + sku + ", item type: " + itemType); - Bundle buyIntentBundle = mService.getBuyIntent(3, mContext.getPackageName(), sku, itemType, extraData); - int response = getResponseCodeFromBundle(buyIntentBundle); - if (response != BILLING_RESPONSE_RESULT_OK) { - logError("Unable to buy item, Error response: " + getResponseDesc(response)); - endAsyncOperation(); - result = new BillingResult(response, "Unable to buy item"); - if (listener != null) listener.onPurchaseFinished(result, null); - return; - } - - PendingIntent pendingIntent = buyIntentBundle.getParcelable(RESPONSE_BUY_INTENT); - logDebug("Launching buy intent for " + sku + ". Request code: " + requestCode); - mRequestCode = requestCode; - mPurchaseListener = listener; - mPurchasingItemType = itemType; - act.startIntentSenderForResult(pendingIntent.getIntentSender(), - requestCode, new Intent(), - Integer.valueOf(0), Integer.valueOf(0), - Integer.valueOf(0)); - } - catch (SendIntentException e) { - logError("SendIntentException while launching purchase flow for sku " + sku); - e.printStackTrace(); - endAsyncOperation(); - - result = new BillingResult(IABHELPER_SEND_INTENT_FAILED, "Failed to send intent."); - if (listener != null) listener.onPurchaseFinished(result, null); - } - catch (RemoteException e) { - logError("RemoteException while launching purchase flow for sku " + sku); - e.printStackTrace(); - endAsyncOperation(); - - result = new BillingResult(IABHELPER_REMOTE_EXCEPTION, "Remote exception while starting purchase flow"); - if (listener != null) listener.onPurchaseFinished(result, null); - } - } - - /** - * Handles an activity result that's part of the purchase flow in in-app billing. If you - * are calling {@link #launchPurchaseFlow}, then you must call this method from your - * Activity's {@link android.app.Activity@onActivityResult} method. This method - * MUST be called from the UI thread of the Activity. - * - * @param requestCode The requestCode as you received it. - * @param resultCode The resultCode as you received it. - * @param data The data (Intent) as you received it. - * @return Returns true if the result was related to a purchase flow and was handled; - * false if the result was not related to a purchase, in which case you should - * handle it normally. - */ - public boolean handleActivityResult(int requestCode, int resultCode, Intent data) { - BillingResult result; - if (requestCode != mRequestCode) return false; - - checkNotDisposed(); - checkSetupDone("handleActivityResult"); - - // end of async purchase operation that started on launchPurchaseFlow - endAsyncOperation(); - - if (data == null) { - logError("Null data in IAB activity result."); - result = new BillingResult(IABHELPER_BAD_RESPONSE, "Null data in IAB result"); - if (mPurchaseListener != null) mPurchaseListener.onPurchaseFinished(result, null); - return true; - } - - int responseCode = getResponseCodeFromIntent(data); - String purchaseData = data.getStringExtra(RESPONSE_INAPP_PURCHASE_DATA); - String dataSignature = data.getStringExtra(RESPONSE_INAPP_SIGNATURE); - - if (resultCode == Activity.RESULT_OK && responseCode == BILLING_RESPONSE_RESULT_OK) { - logDebug("Successful resultcode from purchase activity."); - logDebug("Purchase data: " + purchaseData); - logDebug("Data signature: " + dataSignature); - logDebug("Extras: " + data.getExtras()); - logDebug("Expected item type: " + mPurchasingItemType); - - if (purchaseData == null || dataSignature == null) { - logError("BUG: either purchaseData or dataSignature is null."); - logDebug("Extras: " + data.getExtras().toString()); - result = new BillingResult(IABHELPER_UNKNOWN_ERROR, "IAB returned null purchaseData or dataSignature"); - if (mPurchaseListener != null) mPurchaseListener.onPurchaseFinished(result, null); - return true; - } - - Purchase purchase = null; - try { - purchase = new Purchase(mPurchasingItemType, purchaseData, dataSignature); - - // we don't need purchase verification - - logDebug("Purchase signature successfully verified."); - } - catch (JSONException e) { - logError("Failed to parse purchase data."); - e.printStackTrace(); - result = new BillingResult(IABHELPER_BAD_RESPONSE, "Failed to parse purchase data."); - if (mPurchaseListener != null) mPurchaseListener.onPurchaseFinished(result, null); - return true; - } - - if (mPurchaseListener != null) { - mPurchaseListener.onPurchaseFinished(new BillingResult(BILLING_RESPONSE_RESULT_OK, "Success"), purchase); - } - } - else if (resultCode == Activity.RESULT_OK) { - // result code was OK, but in-app billing response was not OK. - logDebug("Result code was OK but in-app billing response was not OK: " + getResponseDesc(responseCode)); - if (mPurchaseListener != null) { - result = new BillingResult(responseCode, "Problem purchashing item."); - mPurchaseListener.onPurchaseFinished(result, null); - } - } - else if (resultCode == Activity.RESULT_CANCELED) { - logDebug("Purchase canceled - Response: " + getResponseDesc(responseCode)); - result = new BillingResult(IABHELPER_USER_CANCELLED, "User canceled."); - if (mPurchaseListener != null) mPurchaseListener.onPurchaseFinished(result, null); - } - else { - logError("Purchase failed. Result code: " + Integer.toString(resultCode) - + ". Response: " + getResponseDesc(responseCode)); - result = new BillingResult(IABHELPER_UNKNOWN_PURCHASE_RESPONSE, "Unknown purchase response."); - if (mPurchaseListener != null) mPurchaseListener.onPurchaseFinished(result, null); - } - return true; - } - - public synchronized IInventory queryInventory(boolean querySkuDetails, List moreSkus) throws BillingException { - return queryInventory(querySkuDetails, moreSkus, null); - } - - /** - * Queries the inventory. This will query all owned items from the server, as well as - * information on additional skus, if specified. This method may block or take long to execute. - * Do not call from a UI thread. For that, use the non-blocking version {@link #queryInventoryAsync}. - * - * @param querySkuDetails if true, SKU details (price, description, etc) will be queried as well - * as purchase information. - * @param moreItemSkus additional PRODUCT skus to query information on, regardless of ownership. - * Ignored if null or if querySkuDetails is false. - * @param moreSubsSkus additional SUBSCRIPTIONS skus to query information on, regardless of ownership. - * Ignored if null or if querySkuDetails is false. - * @throws BillingException if a problem occurs while refreshing the inventory. - */ - public synchronized IInventory queryInventory(boolean querySkuDetails, List moreItemSkus, - List moreSubsSkus) throws BillingException { - checkNotDisposed(); - checkSetupDone("queryInventory"); - try { - Inventory inv = new Inventory(); - int r = queryPurchases(inv, ITEM_TYPE_INAPP); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new BillingException(r, "Error refreshing inventory (querying owned items)."); - } - - if (querySkuDetails) { - r = querySkuDetails(ITEM_TYPE_INAPP, inv, moreItemSkus); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new BillingException(r, "Error refreshing inventory (querying prices of items)."); - } - } - - // if subscriptions are supported, then also query for subscriptions - if (mSubscriptionsSupported) { - r = queryPurchases(inv, ITEM_TYPE_SUBS); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new BillingException(r, "Error refreshing inventory (querying owned subscriptions)."); - } - - if (querySkuDetails) { - r = querySkuDetails(ITEM_TYPE_SUBS, inv, moreItemSkus); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new BillingException(r, "Error refreshing inventory (querying prices of subscriptions)."); - } - } - } - - return inv; - } - catch (RemoteException e) { - throw new BillingException(IABHELPER_REMOTE_EXCEPTION, "Remote exception while refreshing inventory.", e); - } - catch (JSONException e) { - throw new BillingException(IABHELPER_BAD_RESPONSE, "Error parsing JSON response while refreshing inventory.", e); - } - } - - /** - * Asynchronous wrapper for inventory query. This will perform an inventory - * query as described in {@link #queryInventory}, but will do so asynchronously - * and call back the specified listener upon completion. This method is safe to - * call from a UI thread. - * - * @param querySkuDetails as in {@link #queryInventory} - * @param moreSkus as in {@link #queryInventory} - * @param listener The listener to notify when the refresh operation completes. - */ - public void queryInventoryAsync(final boolean querySkuDetails, - final List moreSkus, - final QueryInventoryFinishedListener listener) { - final Handler handler = new Handler(); - checkNotDisposed(); - checkSetupDone("queryInventory"); - startAsyncOperation("refresh inventory"); - (new Thread(new Runnable() { - public void run() { - BillingResult result = new BillingResult(BILLING_RESPONSE_RESULT_OK, "Inventory refresh successful."); - IInventory inv = null; - try { - inv = queryInventory(querySkuDetails, moreSkus); - } - catch (BillingException ex) { - result = ex.getResult(); - } - catch (IllegalStateException ex) { - // disposed :( - } - - endAsyncOperation(); - - final BillingResult result_f = result; - final IInventory inv_f = inv; - if (!mDisposed && listener != null) { - handler.post(new Runnable() { - public void run() { - listener.onQueryInventoryFinished(result_f, inv_f); - } - }); - } - } - })).start(); - } - - public void queryInventoryAsync(QueryInventoryFinishedListener listener) { - queryInventoryAsync(true, null, listener); - } - - public void queryInventoryAsync(boolean querySkuDetails, QueryInventoryFinishedListener listener) { - queryInventoryAsync(querySkuDetails, null, listener); - } - - /** - * Consumes a given in-app product. Consuming can only be done on an item - * that's owned, and as a result of consumption, the user will no longer own it. - * This method may block or take long to return. Do not call from the UI thread. - * For that, see {@link #consumeAsync}. - * - * @param itemInfo The PurchaseInfo that represents the item to consume. - * @throws BillingException if there is a problem during consumption. - */ - void consume(IPurchase itemInfo) throws BillingException { - checkNotDisposed(); - checkSetupDone("consume"); - - if (!itemInfo.getItemType().equals(ITEM_TYPE_INAPP)) { - throw new BillingException(IABHELPER_INVALID_CONSUMPTION, - "Items of type '" + itemInfo.getItemType() + "' can't be consumed."); - } - - try { - String token = itemInfo.getToken(); - String sku = itemInfo.getProduct(); - if (token == null || token.equals("")) { - logError("Can't consume "+ sku + ". No token."); - throw new BillingException(IABHELPER_MISSING_TOKEN, "PurchaseInfo is missing token for sku: " - + sku + " " + itemInfo); - } - - logDebug("Consuming sku: " + sku + ", token: " + token); - int response = mService.consumePurchase(3, mContext.getPackageName(), token); - if (response == BILLING_RESPONSE_RESULT_OK) { - logDebug("Successfully consumed sku: " + sku); - } - else { - logDebug("Error consuming consuming sku " + sku + ". " + getResponseDesc(response)); - throw new BillingException(response, "Error consuming sku " + sku); - } - } - catch (RemoteException e) { - throw new BillingException(IABHELPER_REMOTE_EXCEPTION, "Remote exception while consuming. PurchaseInfo: " + itemInfo, e); - } - } - - /** - * Asynchronous wrapper to item consumption. Works like {@link #consume}, but - * performs the consumption in the background and notifies completion through - * the provided listener. This method is safe to call from a UI thread. - * - * @param purchase The purchase to be consumed. - * @param listener The listener to notify when the consumption operation finishes. - */ - public void consumeAsync(IPurchase purchase, OnConsumeFinishedListener listener) { - checkNotDisposed(); - checkSetupDone("consume"); - List purchases = new ArrayList(); - purchases.add(purchase); - consumeAsyncInternal(purchases, listener); - } - - /** - * Returns a human-readable description for the given response code. - * - * @param code The response code - * @return A human-readable string explaining the result code. - * It also includes the result code numerically. - */ - public static String getResponseDesc(int code) { - String[] iab_msgs = ("0:OK/1:User Canceled/2:Unknown/" + - "3:Billing Unavailable/4:Item unavailable/" + - "5:Developer Error/6:Error/7:Item Already Owned/" + - "8:Item not owned").split("/"); - String[] iabhelper_msgs = ("0:OK/-1001:Remote exception during initialization/" + - "-1002:Bad response received/" + - "-1003:Purchase signature verification failed/" + - "-1004:Send intent failed/" + - "-1005:User cancelled/" + - "-1006:Unknown purchase response/" + - "-1007:Missing token/" + - "-1008:Unknown error/" + - "-1009:Subscriptions not available/" + - "-1010:Invalid consumption attempt").split("/"); - - if (code <= IABHELPER_ERROR_BASE) { - int index = IABHELPER_ERROR_BASE - code; - if (index >= 0 && index < iabhelper_msgs.length) return iabhelper_msgs[index]; - else return String.valueOf(code) + ":Unknown IAB Helper Error"; - } - else if (code < 0 || code >= iab_msgs.length) - return String.valueOf(code) + ":Unknown"; - else - return iab_msgs[code]; - } - - - // Checks that setup was done; if not, throws an exception. - void checkSetupDone(String operation) { - if (!mSetupDone) { - logError("Illegal state for operation (" + operation + "): IAB helper is not set up."); - throw new IllegalStateException("IAB helper is not set up. Can't perform operation: " + operation); - } - } - - // Workaround to bug where sometimes response codes come as Long instead of Integer - int getResponseCodeFromBundle(Bundle b) { - Object o = b.get(RESPONSE_CODE); - if (o == null) { - logDebug("Bundle with null response code, assuming OK (known issue)"); - return BILLING_RESPONSE_RESULT_OK; - } - else if (o instanceof Integer) return ((Integer)o).intValue(); - else if (o instanceof Long) return (int)((Long)o).longValue(); - else { - logError("Unexpected type for bundle response code."); - logError(o.getClass().getName()); - throw new RuntimeException("Unexpected type for bundle response code: " + o.getClass().getName()); - } - } - - // Workaround to bug where sometimes response codes come as Long instead of Integer - int getResponseCodeFromIntent(Intent i) { - Object o = i.getExtras().get(RESPONSE_CODE); - if (o == null) { - logError("Intent with no response code, assuming OK (known issue)"); - return BILLING_RESPONSE_RESULT_OK; - } - else if (o instanceof Integer) return ((Integer)o).intValue(); - else if (o instanceof Long) return (int)((Long)o).longValue(); - else { - logError("Unexpected type for intent response code."); - logError(o.getClass().getName()); - throw new RuntimeException("Unexpected type for intent response code: " + o.getClass().getName()); - } - } - - void startAsyncOperation(String operation) { - if (mAsyncInProgress) throw new IllegalStateException("Can't start async operation (" + - operation + ") because another async operation(" + mAsyncOperation + ") is in progress."); - mAsyncOperation = operation; - mAsyncInProgress = true; - logDebug("Starting async operation: " + operation); - } - - public void endAsyncOperation() { - logDebug("Ending async operation: " + mAsyncOperation); - mAsyncOperation = ""; - mAsyncInProgress = false; - } - - - int queryPurchases(Inventory inv, String itemType) throws JSONException, RemoteException { - // Query purchases - logDebug("Querying owned items, item type: " + itemType); - logDebug("Package name: " + mContext.getPackageName()); - boolean verificationFailed = false; - String continueToken = null; - - do { - logDebug("Calling getPurchases with continuation token: " + continueToken); - Bundle ownedItems = mService.getPurchases(3, mContext.getPackageName(), - itemType, continueToken); - - int response = getResponseCodeFromBundle(ownedItems); - logDebug("Owned items response: " + String.valueOf(response)); - if (response != BILLING_RESPONSE_RESULT_OK) { - logDebug("getPurchases() failed: " + getResponseDesc(response)); - return response; - } - if (!ownedItems.containsKey(RESPONSE_INAPP_ITEM_LIST) - || !ownedItems.containsKey(RESPONSE_INAPP_PURCHASE_DATA_LIST) - || !ownedItems.containsKey(RESPONSE_INAPP_SIGNATURE_LIST)) { - logError("Bundle returned from getPurchases() doesn't contain required fields."); - return IABHELPER_BAD_RESPONSE; - } - - ArrayList ownedSkus = ownedItems.getStringArrayList( - RESPONSE_INAPP_ITEM_LIST); - ArrayList purchaseDataList = ownedItems.getStringArrayList( - RESPONSE_INAPP_PURCHASE_DATA_LIST); - ArrayList signatureList = ownedItems.getStringArrayList( - RESPONSE_INAPP_SIGNATURE_LIST); - - for (int i = 0; i < purchaseDataList.size(); ++i) { - String purchaseData = purchaseDataList.get(i); - String signature = signatureList.get(i); - String sku = ownedSkus.get(i); - - // we don't need purchase verification - logDebug("Sku is owned: " + sku); - Purchase purchase = new Purchase(itemType, purchaseData, signature); - - if (TextUtils.isEmpty(purchase.getToken())) { - logWarn("BUG: empty/null token!"); - logDebug("Purchase data: " + purchaseData); - } - - // Record ownership and token - inv.addPurchase(purchase); - } - - continueToken = ownedItems.getString(INAPP_CONTINUATION_TOKEN); - logDebug("Continuation token: " + continueToken); - } while (!TextUtils.isEmpty(continueToken)); - - return verificationFailed ? IABHELPER_VERIFICATION_FAILED : BILLING_RESPONSE_RESULT_OK; - } - - int querySkuDetails(String itemType, Inventory inv, List moreSkus) - throws RemoteException, JSONException { - logDebug("Querying SKU details."); - ArrayList skuList = new ArrayList(); - skuList.addAll(inv.getAllOwnedSkus(itemType)); - if (moreSkus != null) { - for (String sku : moreSkus) { - if (!skuList.contains(sku)) { - skuList.add(sku); - } - } - } - - if (skuList.size() == 0) { - logDebug("queryPrices: nothing to do because there are no SKUs."); - return BILLING_RESPONSE_RESULT_OK; - } - - Bundle querySkus = new Bundle(); - querySkus.putStringArrayList(GET_SKU_DETAILS_ITEM_LIST, skuList); - Bundle skuDetails = mService.getSkuDetails(3, mContext.getPackageName(), - itemType, querySkus); - - if (!skuDetails.containsKey(RESPONSE_GET_SKU_DETAILS_LIST)) { - int response = getResponseCodeFromBundle(skuDetails); - if (response != BILLING_RESPONSE_RESULT_OK) { - logDebug("getSkuDetails() failed: " + getResponseDesc(response)); - return response; - } - else { - logError("getSkuDetails() returned a bundle with neither an error nor a detail list."); - return IABHELPER_BAD_RESPONSE; - } - } - - ArrayList responseList = skuDetails.getStringArrayList( - RESPONSE_GET_SKU_DETAILS_LIST); - - for (String thisResponse : responseList) { - ProductDetails d = new ProductDetails(itemType, thisResponse); - logDebug("Got sku details: " + d); - inv.addSkuDetails(d); - } - return BILLING_RESPONSE_RESULT_OK; - } - - void consumeAsyncInternal(final List purchases, - final OnConsumeFinishedListener listener) { - final Handler handler = new Handler(); - startAsyncOperation("consume"); - (new Thread(new Runnable() { - public void run() { - final List results = new ArrayList(); - for (IPurchase purchase : purchases) { - try { - consume(purchase); - results.add(new BillingResult(BILLING_RESPONSE_RESULT_OK, "Successful consume of sku " + purchase.getProduct())); - } - catch (BillingException ex) { - results.add(ex.getResult()); - } - } - - endAsyncOperation(); - if (!mDisposed && listener != null) { - handler.post(new Runnable() { - public void run() { - listener.onConsumeFinished(purchases.get(0), results.get(0)); - } - }); - } - } - })).start(); - } - - void logDebug(String msg) { - if (mDebugLog) Log.d(mDebugTag, msg); - } - - void logError(String msg) { - Log.e(mDebugTag, "In-app billing error: " + msg); - } - - void logWarn(String msg) { - Log.w(mDebugTag, "In-app billing warning: " + msg); - } -} diff --git a/app/src/googleplay/java/org/kontalk/billing/Inventory.java b/app/src/googleplay/java/org/kontalk/billing/Inventory.java deleted file mode 100644 index 45109b1c1..000000000 --- a/app/src/googleplay/java/org/kontalk/billing/Inventory.java +++ /dev/null @@ -1,91 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Represents a block of information about in-app items. - * An Inventory is returned by such methods as {@link IBillingService#queryInventory}. - */ -public class Inventory implements IInventory { - Map mSkuMap = new HashMap(); - Map mPurchaseMap = new HashMap(); - - Inventory() { } - - /** Returns the listing details for an in-app product. */ - public ProductDetails getSkuDetails(String sku) { - return mSkuMap.get(sku); - } - - /** Returns purchase information for a given product, or null if there is no purchase. */ - public IPurchase getPurchase(String sku) { - return mPurchaseMap.get(sku); - } - - /** Returns whether or not there exists a purchase of the given product. */ - public boolean hasPurchase(String sku) { - return mPurchaseMap.containsKey(sku); - } - - /** Return whether or not details about the given product are available. */ - public boolean hasDetails(String sku) { - return mSkuMap.containsKey(sku); - } - - /** - * Erase a purchase (locally) from the inventory, given its product ID. This just - * modifies the Inventory object locally and has no effect on the server! This is - * useful when you have an existing Inventory object which you know to be up to date, - * and you have just consumed an item successfully, which means that erasing its - * purchase data from the Inventory you already have is quicker than querying for - * a new Inventory. - */ - public void erasePurchase(String sku) { - if (mPurchaseMap.containsKey(sku)) mPurchaseMap.remove(sku); - } - - /** Returns a list of all owned product IDs. */ - List getAllOwnedSkus() { - return new ArrayList(mPurchaseMap.keySet()); - } - - /** Returns a list of all owned product IDs of a given type */ - List getAllOwnedSkus(String itemType) { - List result = new ArrayList(); - for (Purchase p : mPurchaseMap.values()) { - if (p.getItemType().equals(itemType)) result.add(p.getProduct()); - } - return result; - } - - /** Returns a list of all purchases. */ - List getAllPurchases() { - return new ArrayList(mPurchaseMap.values()); - } - - void addSkuDetails(ProductDetails d) { - mSkuMap.put(d.getId(), d); - } - - void addPurchase(Purchase p) { - mPurchaseMap.put(p.getProduct(), p); - } -} diff --git a/app/src/googleplay/java/org/kontalk/billing/ProductDetails.java b/app/src/googleplay/java/org/kontalk/billing/ProductDetails.java deleted file mode 100644 index 45501c22f..000000000 --- a/app/src/googleplay/java/org/kontalk/billing/ProductDetails.java +++ /dev/null @@ -1,59 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - -import org.json.JSONException; -import org.json.JSONObject; -import org.kontalk.billing.IProductDetails; - -/** - * Represents an in-app product's listing details. - */ -public class ProductDetails implements IProductDetails { - String mItemType; - String mSku; - String mType; - String mPrice; - String mTitle; - String mDescription; - String mJson; - - public ProductDetails(String jsonSkuDetails) throws JSONException { - this(GoogleBillingService.ITEM_TYPE_INAPP, jsonSkuDetails); - } - - public ProductDetails(String itemType, String jsonSkuDetails) throws JSONException { - mItemType = itemType; - mJson = jsonSkuDetails; - JSONObject o = new JSONObject(mJson); - mSku = o.optString("productId"); - mType = o.optString("type"); - mPrice = o.optString("price"); - mTitle = o.optString("title"); - mDescription = o.optString("description"); - } - - public String getId() { return mSku; } - public String getType() { return mType; } - public String getPrice() { return mPrice; } - public String getTitle() { return mTitle; } - public String getDescription() { return mDescription; } - - @Override - public String toString() { - return "SkuDetails:" + mJson; - } -} diff --git a/app/src/googleplay/java/org/kontalk/billing/Purchase.java b/app/src/googleplay/java/org/kontalk/billing/Purchase.java deleted file mode 100644 index 0969cde58..000000000 --- a/app/src/googleplay/java/org/kontalk/billing/Purchase.java +++ /dev/null @@ -1,64 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - -import org.json.JSONException; -import org.json.JSONObject; -import org.kontalk.billing.IPurchase; - -/** - * Represents an in-app billing purchase. - */ -public class Purchase implements IPurchase { - String mItemType; // ITEM_TYPE_INAPP or ITEM_TYPE_SUBS - String mOrderId; - String mPackageName; - String mSku; - long mPurchaseTime; - int mPurchaseState; - String mDeveloperPayload; - String mToken; - String mOriginalJson; - String mSignature; - - public Purchase(String itemType, String jsonPurchaseInfo, String signature) throws JSONException { - mItemType = itemType; - mOriginalJson = jsonPurchaseInfo; - JSONObject o = new JSONObject(mOriginalJson); - mOrderId = o.optString("orderId"); - mPackageName = o.optString("packageName"); - mSku = o.optString("productId"); - mPurchaseTime = o.optLong("purchaseTime"); - mPurchaseState = o.optInt("purchaseState"); - mDeveloperPayload = o.optString("developerPayload"); - mToken = o.optString("token", o.optString("purchaseToken")); - mSignature = signature; - } - - public String getItemType() { return mItemType; } - public String getOrderId() { return mOrderId; } - public String getPackageName() { return mPackageName; } - public String getProduct() { return mSku; } - public long getPurchaseTime() { return mPurchaseTime; } - public int getPurchaseState() { return mPurchaseState; } - public String getDeveloperPayload() { return mDeveloperPayload; } - public String getToken() { return mToken; } - public String getOriginalJson() { return mOriginalJson; } - public String getSignature() { return mSignature; } - - @Override - public String toString() { return "PurchaseInfo(type:" + mItemType + "):" + mOriginalJson; } -} diff --git a/app/src/googleplay/java/org/kontalk/position/GMStaticUrlBuilder.java b/app/src/googleplay/java/org/kontalk/position/GMStaticUrlBuilder.java new file mode 100644 index 000000000..1b5ba33cf --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/position/GMStaticUrlBuilder.java @@ -0,0 +1,86 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.position; + + +/** + * Download Google Static Map + * + * @author Daniele Ricci + * @author Andrea Cappelli + */ +public class GMStaticUrlBuilder { + private static final String URL = "http://maps.googleapis.com/maps/api/staticmap"; + + private String mApiKey; + private String mCenter; + private int mZoom = 15; + private String mSize = "600x300"; + private String mMarker; + private int mScale = 1; + + public GMStaticUrlBuilder(String apiKey) { + this.mApiKey = apiKey; + } + + public GMStaticUrlBuilder setCenter(double lat, double lon) { + mCenter = lat + "," + lon; + return this; + } + + public GMStaticUrlBuilder setZoom(Integer zoom) { + if (zoom != null) + mZoom = zoom; + return this; + } + + public GMStaticUrlBuilder setSize(int width, int height) { + mSize = width + "x" + height; + return this; + } + + public GMStaticUrlBuilder setMarker(double lat, double lon) { + mMarker = "%7C" + lat + "," + lon; + return this; + } + + public GMStaticUrlBuilder setScale(Integer scale) { + if (scale != null) + mScale = scale; + + return this; + } + + @Override + public String toString() { + return URL + + "?center=" + + mCenter + + "&zoom=" + + mZoom + + "&size=" + + mSize + + "&scale=" + + mScale + + "&markers=" + + mMarker + + "&key=" + + mApiKey; + } +} diff --git a/app/src/googleplay/java/org/kontalk/position/GMapsUrlBuilder.java b/app/src/googleplay/java/org/kontalk/position/GMapsUrlBuilder.java new file mode 100644 index 000000000..14dc09118 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/position/GMapsUrlBuilder.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.position; + +import java.util.Locale; + + +/** + * Google Maps URL builder. + * @author Daniele Ricci + */ +public class GMapsUrlBuilder { + private static final String URL = "https://maps.google.com/?ll=%1$,.6f,%2$,.6f&q=%1$,.6f,%2$,.6f&z=15"; + + private GMapsUrlBuilder() { + } + + public static String build(double lat, double lon) { + return String.format(Locale.US, URL, lat, lon); + } +} diff --git a/app/src/googleplay/java/org/kontalk/position/PlacesRestClient.java b/app/src/googleplay/java/org/kontalk/position/PlacesRestClient.java new file mode 100644 index 000000000..83b28d9da --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/position/PlacesRestClient.java @@ -0,0 +1,87 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.position; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import android.content.Context; + +import okhttp3.Callback; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; + +/** + * Foursquare Venues rest client + * @author andreacappelli + */ + +public class PlacesRestClient { + + private final static String TAG = PlacesRestClient.class.getSimpleName(); + + private final static String CLIENT_ID = "P2LJNVUONE1PPJSUYCEWEFB5LNV5S1ESRNFDNX15OQRXEF42"; + private final static String CLIENT_SECRET = "VKZJKBW5ZSMQTTW4ITNJXRM4D5G4V0HWACVEPKSPTCVKNI2I"; + + public static void getPlacesByLocation(Context context, double lat, double lon, int limit, Callback callback) { + Date date = new Date(); + SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd", Locale.US); + format.format(date); + + OkHttpClient client = new OkHttpClient(); + HttpUrl.Builder urlBuilder = HttpUrl.parse("https://api.foursquare.com/v2/venues/search").newBuilder(); + urlBuilder.addQueryParameter("v", format.format(date)); + urlBuilder.addQueryParameter("ll", lat + "," + lon); + urlBuilder.addQueryParameter("limit", String.valueOf(limit)); + urlBuilder.addQueryParameter("client_id", CLIENT_ID); + urlBuilder.addQueryParameter("client_secret", CLIENT_SECRET); + + String url = urlBuilder.build().toString(); + + Request request = new Request.Builder() + .url(url) + .build(); + + client.newCall(request).enqueue(callback); + } + + public static void getPlacesByQuery(Context context, double lat, double lon, String query, Callback callback) { + Date date = new Date(); + SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd", Locale.US); + format.format(date); + + OkHttpClient client = new OkHttpClient(); + HttpUrl.Builder urlBuilder = HttpUrl.parse("https://api.foursquare.com/v2/venues/search").newBuilder(); + urlBuilder.addQueryParameter("v", format.format(date)); + urlBuilder.addQueryParameter("ll", lat + "," + lon); + urlBuilder.addQueryParameter("query", query); + urlBuilder.addQueryParameter("client_id", CLIENT_ID); + urlBuilder.addQueryParameter("client_secret", CLIENT_SECRET); + + String url = urlBuilder.build().toString(); + + Request request = new Request.Builder() + .url(url) + .build(); + + client.newCall(request).enqueue(callback); + } +} diff --git a/app/src/googleplay/java/org/kontalk/position/PositionManager.java b/app/src/googleplay/java/org/kontalk/position/PositionManager.java new file mode 100644 index 000000000..23e3d7126 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/position/PositionManager.java @@ -0,0 +1,152 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.position; + +import com.bumptech.glide.load.model.LazyHeaders; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.RecyclerView; +import android.widget.Toast; + +import org.kontalk.R; +import org.kontalk.ui.position.adapter.PlacesAdapter; +import org.kontalk.ui.position.PositionGoogleFragment; +import org.kontalk.ui.position.PositionOsmFragment; +import org.kontalk.ui.position.adapter.SearchPlacesAdapter; +import org.kontalk.ui.position.SendPositionGoogleFragment; +import org.kontalk.ui.position.SendPositionOsmFragment; +import org.kontalk.util.Preferences; + + +/** + * @author Andrea Cappelli + */ +public class PositionManager { + private static final String PROVIDER_GOOGLE = "google"; + private static final String PROVIDER_OSM = "osm"; + + public static Fragment getSendPositionFragment(Context context) { + String provider = Preferences.getMapsProvider(context); + Fragment fragment = null; + if (PROVIDER_GOOGLE.equals(provider)) { + if (isGoogleMapsAvailable(context)) { + fragment = new SendPositionGoogleFragment(); + } + else { + Toast.makeText(context, R.string.err_googlemaps_fallback_osm, + Toast.LENGTH_LONG).show(); + fragment = new SendPositionOsmFragment(); + } + } + else if (PROVIDER_OSM.equals(provider)) { + fragment = new SendPositionOsmFragment(); + } + + return fragment; + } + + public static Fragment getPositionFragment(Context context) { + String provider = Preferences.getMapsProvider(context); + Fragment fragment = null; + if (PROVIDER_GOOGLE.equals(provider)) { + if (isGoogleMapsAvailable(context)) { + fragment = new PositionGoogleFragment(); + } + else { + Toast.makeText(context, R.string.err_googlemaps_fallback_osm, + Toast.LENGTH_LONG).show(); + fragment = new PositionOsmFragment(); + } + } + else if (PROVIDER_OSM.equals(provider)) { + fragment = new PositionOsmFragment(); + } + + return fragment; + } + + public static RequestDetails getStaticMapUrl(Context context, double lat, double lon, Integer zoom, int width, int height, Integer scale) { + String provider = Preferences.getMapsProvider(context); + if (PROVIDER_GOOGLE.equals(provider)) { + return new RequestDetails(new GMStaticUrlBuilder(getGoogleMapsApiKey(context)) + .setCenter(lat, lon) + .setZoom(zoom) + .setMarker(lat, lon) + .setSize(width, height) + .setScale(scale) + .toString()); + } + else if (PROVIDER_OSM.equals(provider)) { + return new RequestDetails(new OsmStaticUrlBuilder() + .setCenter(lat, lon) + .setZoom(zoom) + .setMarker(lat, lon) + .setSize(width, height) + .toString(), + new LazyHeaders.Builder() + .addHeader("Referer", context.getResources().getString(R.string.website)) + .build()); + } + + return null; + } + + public static String getMapsUrl(Context context, double lat, double lon) { + String provider = Preferences.getMapsProvider(context); + if (PROVIDER_GOOGLE.equals(provider)) { + return GMapsUrlBuilder.build(lat, lon); + } + else if (PROVIDER_OSM.equals(provider)) { + return OsmUrlBuilder.build(lat, lon); + } + return null; + } + + private static String getGoogleMapsApiKey(Context context) { + PackageManager pm = context.getPackageManager(); + try { + ApplicationInfo ai = pm.getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + return ai.metaData.getString("com.google.android.geo.API_KEY"); + } + catch (Exception e) { + return null; + } + } + + private static boolean isGoogleMapsAvailable(Context context) { + int status = GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(context); + return status == ConnectionResult.SUCCESS || + status == ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED; + } + + public static RecyclerView.Adapter createSearchPlacesAdapter(Context context) { + return new SearchPlacesAdapter(context); + } + + public static RecyclerView.Adapter createPlacesAdapter(Context context) { + return new PlacesAdapter(context); + } + +} diff --git a/app/src/googleplay/java/org/kontalk/reporting/ReportingManager.java b/app/src/googleplay/java/org/kontalk/reporting/ReportingManager.java new file mode 100644 index 000000000..0a2064de8 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/reporting/ReportingManager.java @@ -0,0 +1,81 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.reporting; + +import com.crashlytics.android.Crashlytics; +import com.crashlytics.android.answers.Answers; +import com.crashlytics.android.answers.CustomEvent; +import com.crashlytics.android.answers.SignUpEvent; + +import android.content.Context; + +import io.fabric.sdk.android.Fabric; + +import org.kontalk.BuildConfig; + + +/** + * Reporting manager for Crashlytics. + * @author Daniele Ricci + */ +public class ReportingManager { + + private static boolean sEnabled; + + private ReportingManager() { + } + + public static void register(Context context) { + if (!BuildConfig.DEBUG && !sEnabled) { + Fabric.with(context, new Crashlytics()); + sEnabled = true; + } + } + + public static void unregister(Context context) { + if (sEnabled) { + // HACK to unregister Fabric crash handler + Thread.setDefaultUncaughtExceptionHandler(null); + sEnabled = false; + } + } + + public static void logException(Throwable exception) { + if (!BuildConfig.DEBUG && sEnabled) + Crashlytics.logException(exception); + } + + /** + * Logs a registration attempt. + * @param method the challenge being used + */ + public static void logRegister(String method) { + if (!BuildConfig.DEBUG && sEnabled) + Answers.getInstance().logCustom(new CustomEvent("Register") + .putCustomAttribute("Method", method)); + } + + public static void logSignUp(String method) { + if (!BuildConfig.DEBUG && sEnabled) + Answers.getInstance().logSignUp(new SignUpEvent() + .putMethod(method) + .putSuccess(true)); + } + +} diff --git a/app/src/googleplay/java/org/kontalk/service/gcm/DefaultGcmListener.java b/app/src/googleplay/java/org/kontalk/service/gcm/DefaultGcmListener.java index 31b07f88a..30a3cc46b 100644 --- a/app/src/googleplay/java/org/kontalk/service/gcm/DefaultGcmListener.java +++ b/app/src/googleplay/java/org/kontalk/service/gcm/DefaultGcmListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,7 +23,7 @@ import org.kontalk.service.msgcenter.MessageCenterService; import android.content.Context; -import android.util.Log; +import org.kontalk.Log; /** diff --git a/app/src/googleplay/java/org/kontalk/service/gcm/GcmBroadcastReceiver.java b/app/src/googleplay/java/org/kontalk/service/gcm/GcmBroadcastReceiver.java deleted file mode 100644 index 3fc9323a4..000000000 --- a/app/src/googleplay/java/org/kontalk/service/gcm/GcmBroadcastReceiver.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.service.gcm; - -import android.app.Activity; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.support.v4.content.WakefulBroadcastReceiver; - - -/** - * Broadcast receiver for Google Cloud Messaging. - * http://code.google.com/p/gcm - */ -public class GcmBroadcastReceiver extends WakefulBroadcastReceiver { - - @Override - public void onReceive(Context context, Intent intent) { - // Explicitly specify that GcmIntentService will handle the intent. - ComponentName comp = new ComponentName(context.getPackageName(), - GcmIntentService.class.getName()); - // Start the service, keeping the device awake while it is launching. - startWakefulService(context, (intent.setComponent(comp))); - setResultCode(Activity.RESULT_OK); - } - -} diff --git a/app/src/googleplay/java/org/kontalk/service/gcm/GcmIntentService.java b/app/src/googleplay/java/org/kontalk/service/gcm/GcmIntentService.java deleted file mode 100644 index 3ee508b74..000000000 --- a/app/src/googleplay/java/org/kontalk/service/gcm/GcmIntentService.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.service.gcm; - -import java.util.Random; - -import org.kontalk.Kontalk; -import org.kontalk.service.msgcenter.MessageCenterService; -import org.kontalk.service.msgcenter.PushServiceManager; -import org.kontalk.util.Preferences; - -import android.app.IntentService; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.util.Log; - -import com.google.android.gms.gcm.GoogleCloudMessaging; - - -/** - * Intent service for GCM broadcasts. - * @author Daniele Ricci - */ -public class GcmIntentService extends IntentService { - private static final String TAG = Kontalk.TAG; - - /** GCM message received from server. */ - private static final String ACTION_CHECK_MESSAGES = "org.kontalk.CHECK_MESSAGES"; - - /** Internal action for handling back-off. */ - private static final String ACTION_RETRY = "org.kontalk.gcm.RETRY"; - - // token used to check intent origin - private static final String TOKEN = - Long.toBinaryString(new Random().nextLong()); - private static final String EXTRA_TOKEN = "token"; - - public GcmIntentService() { - super("GcmIntentService"); - } - - @Override - protected void onHandleIntent(Intent intent) { - - if (ACTION_RETRY.equals(intent.getAction())) { - - String token = intent.getStringExtra(EXTRA_TOKEN); - if (!TOKEN.equals(token)) { - // make sure intent was generated by this class, not by a - // malicious app. - Log.e(TAG, "Received invalid token: " + token); - return; - } - - PushServiceManager.getInstance(this).retry(); - } - - else { - Bundle extras = intent.getExtras(); - GoogleCloudMessaging gcm = GoogleCloudMessaging.getInstance(this); - // The getMessageType() intent parameter must be the intent you received - // in your BroadcastReceiver. - String messageType = gcm.getMessageType(intent); - - if (!extras.isEmpty()) { // has effect of unparcelling Bundle - - if (GoogleCloudMessaging. - MESSAGE_TYPE_MESSAGE.equals(messageType)) { - - String dataAction = intent.getStringExtra("action"); - Log.v(TAG, "cloud message received: " + dataAction); - - // new messages - start message center - if (ACTION_CHECK_MESSAGES.equals(dataAction)) { - // remember we just received a push notifications - // this means that there are really messages waiting for us - Preferences.setLastPushNotification(this, - System.currentTimeMillis()); - - // test message center connection - MessageCenterService.test(getApplicationContext()); - } - - } - } - } - - // Release the wake lock provided by the WakefulBroadcastReceiver - GcmBroadcastReceiver.completeWakefulIntent(intent); - } - - static PendingIntent getRetryIntent(Context context) { - Intent retryIntent = new Intent(context.getApplicationContext(), GcmIntentService.class); - retryIntent.setAction(ACTION_RETRY); - retryIntent.putExtra(EXTRA_TOKEN, TOKEN); - - return PendingIntent.getService(context, - 0, retryIntent, 0); - - } - -} diff --git a/app/src/googleplay/java/org/kontalk/service/gcm/GcmListenerService.java b/app/src/googleplay/java/org/kontalk/service/gcm/GcmListenerService.java new file mode 100644 index 000000000..d01ac95c3 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/service/gcm/GcmListenerService.java @@ -0,0 +1,62 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.gcm; + +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.util.Preferences; + + +/** + * Intent service for GCM broadcasts. + * @author Daniele Ricci + */ +public class GcmListenerService extends FirebaseMessagingService { + private static final String TAG = Kontalk.TAG; + + /** GCM message received from server. */ + private static final String ACTION_CHECK_MESSAGES = "org.kontalk.CHECK_MESSAGES"; + + @Override + public void onMessageReceived(RemoteMessage remoteMessage) { + String dataAction = remoteMessage.getData().get("action"); + Log.v(TAG, "cloud message received: " + dataAction); + + // new messages - start message center + if (ACTION_CHECK_MESSAGES.equals(dataAction)) { + // remember we just received a push notifications + // this means that there are really messages waiting for us + Preferences.setLastPushNotification( + System.currentTimeMillis()); + + // test message center connection + MessageCenterService.test(getApplicationContext(), false); + } + } + + @Override + public void onNewToken(String token) { + // not really used. Or is it? + } + +} diff --git a/app/src/googleplay/java/org/kontalk/service/gcm/GcmPushService.java b/app/src/googleplay/java/org/kontalk/service/gcm/GcmPushService.java index 831bddbce..7d7ae6aed 100644 --- a/app/src/googleplay/java/org/kontalk/service/gcm/GcmPushService.java +++ b/app/src/googleplay/java/org/kontalk/service/gcm/GcmPushService.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,27 +18,29 @@ package org.kontalk.service.gcm; -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.SharedPreferences; -import android.os.SystemClock; -import android.util.Log; +import java.io.IOException; +import java.sql.Timestamp; +import java.util.concurrent.TimeUnit; import com.google.android.gms.common.ConnectionResult; -import com.google.android.gms.common.GooglePlayServicesUtil; -import com.google.android.gms.gcm.GoogleCloudMessaging; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.android.gms.tasks.OnCanceledListener; +import com.google.android.gms.tasks.OnFailureListener; +import com.google.android.gms.tasks.OnSuccessListener; +import com.google.firebase.iid.FirebaseInstanceId; +import com.google.firebase.iid.InstanceIdResult; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import androidx.annotation.NonNull; import org.kontalk.Kontalk; +import org.kontalk.Log; import org.kontalk.service.msgcenter.IPushListener; import org.kontalk.service.msgcenter.IPushService; import org.kontalk.util.SystemUtils; -import java.io.IOException; -import java.sql.Timestamp; -import java.util.Random; -import java.util.concurrent.TimeUnit; - /** * Push service for Google Cloud Messaging. @@ -51,16 +53,7 @@ public class GcmPushService implements IPushService { * Default lifespan (7 days) of the {@link #isRegisteredOnServer()} * flag until it is considered expired. */ - // NOTE: cannot use TimeUnit.DAYS because it's not available on API Level 8 - public static final long DEFAULT_ON_SERVER_LIFESPAN_MS = - 1000 * 3600 * 24 * 7; - - private static final Random sRandom = new Random(); - - private static final String BACKOFF_MS = "backoff_ms"; - private static final int DEFAULT_BACKOFF_MS = 3000; - private static final int MAX_BACKOFF_MS = - (int) TimeUnit.SECONDS.toMillis(3600); // 1 hour + private static final long DEFAULT_ON_SERVER_LIFESPAN_MS = TimeUnit.DAYS.toMillis(7); private static final String PROPERTY_REG_ID = "registration_id"; private static final String PROPERTY_APP_VERSION = "appVersion"; @@ -72,54 +65,48 @@ public class GcmPushService implements IPushService { private IPushListener mListener; private Context mContext; - private GoogleCloudMessaging mGcm; public GcmPushService(Context context) { mContext = context.getApplicationContext(); } - private void ensureGcmInstance() { - if (mGcm == null) - mGcm = GoogleCloudMessaging.getInstance(mContext); - } - @Override public void register(final IPushListener listener, final String senderId) { mListener = listener; - resetBackoff(); - - new Thread(new Runnable() { - public void run() { - ensureGcmInstance(); - - try { - String regId = mGcm.register(senderId); + FirebaseInstanceId.getInstance().getInstanceId() + .addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(InstanceIdResult instanceIdResult) { // persist the regID - no need to register again. - storeRegistrationId(regId); + storeRegistrationId(instanceIdResult.getToken()); // call the listener - listener.onRegistered(mContext, regId); - + listener.onRegistered(mContext, instanceIdResult.getToken()); } - catch (IOException e) { + }) + .addOnFailureListener(new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { listener.onError(mContext, e.toString()); } - } - }).start(); + }) + .addOnCanceledListener(new OnCanceledListener() { + @Override + public void onCanceled() { + listener.onError(mContext, "canceled"); + } + }); } @Override public void unregister(final IPushListener listener) { mListener = listener; - resetBackoff(); new Thread(new Runnable() { public void run() { - ensureGcmInstance(); - try { - mGcm.unregister(); + FirebaseInstanceId.getInstance().deleteInstanceId(); // persist the regID - no need to register again. storeRegistrationId(""); @@ -130,8 +117,6 @@ public void run() { } catch (IOException e) { listener.onError(mContext, e.toString()); - - retryOnError(); } } }).start(); @@ -146,7 +131,6 @@ public void retry() { else { register(mListener, senderId); } - } @Override @@ -156,12 +140,13 @@ public boolean isRegistered() { @Override public boolean isServiceAvailable() { - int status = GooglePlayServicesUtil + int status = GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(mContext); return status == ConnectionResult.SUCCESS || status == ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED; } + @SuppressLint("ApplySharedPref") @Override public void setRegisteredOnServer(boolean flag) { final SharedPreferences prefs = getGCMPreferences(mContext); @@ -206,7 +191,7 @@ public String getRegistrationId() { // since the existing regID is not guaranteed to work with the new // app version. int registeredVersion = prefs.getInt(PROPERTY_APP_VERSION, Integer.MIN_VALUE); - int currentVersion = SystemUtils.getVersionCode(mContext); + int currentVersion = SystemUtils.getVersionCode(); if (registeredVersion != currentVersion) { Log.i(TAG, "App version changed."); return ""; @@ -222,6 +207,7 @@ public long getRegisterOnServerLifespan() { return lifespan; } + @SuppressLint("ApplySharedPref") @Override public void setRegisterOnServerLifespan(long lifespan) { final SharedPreferences prefs = getGCMPreferences(mContext); @@ -230,70 +216,16 @@ public void setRegisterOnServerLifespan(long lifespan) { editor.commit(); } - /** - * Resets the backoff counter. - *

- * This method should be called after a GCM call succeeds. - * - */ - void resetBackoff() { - Log.d(TAG, "resetting backoff for " + mContext.getPackageName()); - setBackoff(DEFAULT_BACKOFF_MS); - } - - /** - * Gets the current backoff counter. - * - * @return current backoff counter, in milliseconds. - */ - int getBackoff() { - final SharedPreferences prefs = getGCMPreferences(mContext); - return prefs.getInt(BACKOFF_MS, DEFAULT_BACKOFF_MS); - } - - /** - * Sets the backoff counter. - *

- * This method should be called after a GCM call fails, passing an - * exponential value. - * - * @param backoff new backoff counter, in milliseconds. - */ - void setBackoff(int backoff) { - final SharedPreferences prefs = getGCMPreferences(mContext); - SharedPreferences.Editor editor = prefs.edit(); - editor.putInt(BACKOFF_MS, backoff); - editor.commit(); - } - + @SuppressLint("ApplySharedPref") private void storeRegistrationId(String regId) { final SharedPreferences prefs = getGCMPreferences(mContext); - int appVersion = SystemUtils.getVersionCode(mContext); + int appVersion = SystemUtils.getVersionCode(); prefs.edit() .putString(PROPERTY_REG_ID, regId) .putInt(PROPERTY_APP_VERSION, appVersion) .commit(); } - private void retryOnError() { - int backoffTimeMs = getBackoff(); - int nextAttempt = backoffTimeMs / 2 + sRandom.nextInt(backoffTimeMs); - Log.d(TAG, "Scheduling registration retry, backoff = " - + nextAttempt + " (" + backoffTimeMs + ")"); - - PendingIntent retryPendingIntent = GcmIntentService.getRetryIntent(mContext); - AlarmManager am = (AlarmManager) mContext - .getSystemService(Context.ALARM_SERVICE); - am.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() - + nextAttempt, retryPendingIntent); - - // Next retry should wait longer. - if (backoffTimeMs < MAX_BACKOFF_MS) { - setBackoff(backoffTimeMs * 2); - } - - } - private static SharedPreferences getGCMPreferences(Context context) { // This sample app persists the registration ID in shared preferences, but // how you store the regID in your app is up to you. diff --git a/app/src/googleplay/java/org/kontalk/service/msgcenter/PushServiceManager.java b/app/src/googleplay/java/org/kontalk/service/msgcenter/PushServiceManager.java index 4c88a41c8..6b38f8cb6 100644 --- a/app/src/googleplay/java/org/kontalk/service/msgcenter/PushServiceManager.java +++ b/app/src/googleplay/java/org/kontalk/service/msgcenter/PushServiceManager.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,6 +19,7 @@ package org.kontalk.service.msgcenter; import android.content.Context; +import androidx.annotation.Nullable; import org.kontalk.service.gcm.DefaultGcmListener; import org.kontalk.service.gcm.GcmPushService; @@ -33,6 +34,7 @@ public class PushServiceManager { private static IPushService sInstance; private static IPushListener sListener; + @Nullable public static IPushService getInstance(Context context) { if (sInstance == null) sInstance = new GcmPushService(context); diff --git a/app/src/googleplay/java/org/kontalk/service/msgcenter/SecureConnectionManager.java b/app/src/googleplay/java/org/kontalk/service/msgcenter/SecureConnectionManager.java new file mode 100644 index 000000000..aa2f50bb5 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/service/msgcenter/SecureConnectionManager.java @@ -0,0 +1,69 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + +import com.google.android.gms.security.ProviderInstaller; + +import android.content.Context; +import org.kontalk.Log; + +import org.kontalk.Kontalk; + + +public class SecureConnectionManager { + + @SuppressWarnings("WeakerAccess") + static final Object sLock = new Object(); + @SuppressWarnings("WeakerAccess") + static volatile boolean sInit; + + public static void init(final Context context) { + new Thread(new Runnable() { + @Override + public void run() { + synchronized (sLock) { + try { + ProviderInstaller.installIfNeeded(context); + } + catch (Exception e) { + Log.w(Kontalk.TAG, + "Unable to install Google Play security provider (" + e.toString() + ")"); + } + finally { + sInit = true; + sLock.notifyAll(); + } + } + } + }).start(); + } + + public static void waitForInit() { + synchronized (sLock) { + if (!sInit) { + try { + sLock.wait(); + } + catch (InterruptedException ignored) { + } + } + } + } + +} diff --git a/app/src/googleplay/java/org/kontalk/ui/position/PositionGoogleFragment.java b/app/src/googleplay/java/org/kontalk/ui/position/PositionGoogleFragment.java new file mode 100644 index 000000000..64658cf62 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/PositionGoogleFragment.java @@ -0,0 +1,264 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import com.car2go.maps.CameraUpdateFactory; +import com.car2go.maps.google.BitmapDescriptorFactory; +import com.car2go.maps.google.MapsConfiguration; +import com.car2go.maps.model.BitmapDescriptor; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.common.api.PendingResult; +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.location.LocationListener; +import com.google.android.gms.location.LocationRequest; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.location.LocationSettingsRequest; +import com.google.android.gms.location.LocationSettingsResult; +import com.google.android.gms.location.LocationSettingsStatusCodes; + +import android.app.Activity; +import android.content.Intent; +import android.content.IntentSender; +import android.location.Location; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.ui.ToolbarActivity; + + +/** + * Position Google Maps fragment + * + * @author Andrea Cappelli + */ +public class PositionGoogleFragment extends PositionAbstractFragment implements + GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener, LocationListener, ResultCallback { + + private final static String TAG = SendPositionGoogleFragment.class.getSimpleName(); + private static final int REQUEST_LOCATION = 1; + + private GoogleApiClient mGoogleApiClient; + + private static final long UPDATE_INTERVAL = 20 * 1000; /* 20 secs */ + private static final long FASTEST_INTERVAL = 4000; /* 4 secs */ + private LocationRequest mLocationRequest; + /** This will be non-null if we were unable to obtain a location. */ + private Status mLastStatus; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (mGoogleApiClient == null) { + mGoogleApiClient = new GoogleApiClient.Builder(getContext()) + .addConnectionCallbacks(this) + .addOnConnectionFailedListener(this) + .addApi(LocationServices.API) + .build(); + } + } + + @Override + protected View onInflateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_position_google, container, false); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + + MapsConfiguration.getInstance().initialize(getContext()); + + return view; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + if (!TextUtils.isEmpty(mPosition.getName())) { + ActionBar actionBar = ((ToolbarActivity) getActivity()).getSupportActionBar(); + actionBar.setTitle(mPosition.getName()); + actionBar.setSubtitle(mPosition.getAddress()); + } + } + + @Override + protected CameraUpdateFactory getCameraUpdateFactory() { + return com.car2go.maps.google.CameraUpdateFactory.getInstance(); + } + + @Override + protected BitmapDescriptor createMarkerBitmap() { + return BitmapDescriptorFactory.getInstance().fromResource(R.drawable.ic_map_pin_google); + } + + @Override + public void onStart() { + super.onStart(); + } + + @Override + public void onStop() { + super.onStop(); + + if (mGoogleApiClient != null && mGoogleApiClient.isConnected()) { + LocationServices.FusedLocationApi.removeLocationUpdates(mGoogleApiClient, this); + mGoogleApiClient.disconnect(); + } + } + + @Override + protected void requestLocation() { + // we have permission to begin! + mGoogleApiClient.connect(); + } + + @Override + protected boolean isLocationEnabled() { + if (mLastStatus != null) { + if (mLastStatus.hasResolution()) { + try { + startIntentSenderForResult(mLastStatus.getResolution().getIntentSender(), + REQUEST_LOCATION, null, 0, 0, 0, null); + } + catch (IntentSender.SendIntentException e) { + Toast.makeText(getContext(), R.string.err_location_access_unknown_error, + Toast.LENGTH_LONG).show(); + } + } + else if (mLastStatus.getStatusCode() == LocationSettingsStatusCodes.SETTINGS_CHANGE_UNAVAILABLE) { + // no location no party! + Toast.makeText(getContext(), R.string.err_location_access_unknown_error, + Toast.LENGTH_LONG).show(); + } + return false; + } + return true; + } + + private void startLocationUpdates() { + try { + LocationServices.FusedLocationApi.requestLocationUpdates(mGoogleApiClient, + mLocationRequest, this); + } + catch (SecurityException e) { + Toast.makeText(getContext(), R.string.err_location_permission, + Toast.LENGTH_LONG).show(); + } + } + + private void requestAndPollLastLocation() { + Location lastLocation = null; + boolean permissionDenied = false; + try { + lastLocation = LocationServices.FusedLocationApi.getLastLocation(mGoogleApiClient); + } + catch (SecurityException e) { + permissionDenied = true; + Toast.makeText(getContext(), R.string.err_location_permission, + Toast.LENGTH_LONG).show(); + } + + // Note that this can be NULL if last location isn't already known. + if (lastLocation != null) { + // Print current location if not null + Log.d(TAG, "last location: " + lastLocation.toString()); + mMyLocation.setLatitude(lastLocation.getLatitude()); + mMyLocation.setLongitude(lastLocation.getLongitude()); + } + // Begin polling for new location updates. + if (!permissionDenied) + startLocationUpdates(); + } + + @Override + public void onResult(@NonNull LocationSettingsResult result) { + if (result.getStatus().isSuccess()) { + requestAndPollLastLocation(); + } + else { + mLastStatus = result.getStatus(); + } + } + + @Override + public void onConnected(@Nullable Bundle bundle) { + requestLocation(LocationRequest.PRIORITY_HIGH_ACCURACY); + } + + private void requestLocation(int accuracy) { + // this will be our location request + mLocationRequest = LocationRequest.create() + .setPriority(accuracy) + .setInterval(UPDATE_INTERVAL) + .setFastestInterval(FASTEST_INTERVAL); + + // check for location settings now + LocationSettingsRequest.Builder builder = new LocationSettingsRequest.Builder() + .addLocationRequest(mLocationRequest); + PendingResult pendingResult = LocationServices + .SettingsApi.checkLocationSettings(mGoogleApiClient, builder.build()); + pendingResult.setResultCallback(this); + } + + @Override + public void onConnectionSuspended(int i) { + if (i == CAUSE_SERVICE_DISCONNECTED) { + Log.d(TAG, "Disconnected. Please re-connect."); + } + else if (i == CAUSE_NETWORK_LOST) { + Log.d(TAG, "Network lost. Please re-connect."); + } + } + + @Override + public void onConnectionFailed(@NonNull ConnectionResult connectionResult) { + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_LOCATION) { + mLastStatus = null; + + switch (resultCode) { + case Activity.RESULT_OK: + requestAndPollLastLocation(); + break; + case Activity.RESULT_CANCELED: + // try again with low power (i.e. network only) + requestLocation(LocationRequest.PRIORITY_LOW_POWER); + break; + } + } + } +} + diff --git a/app/src/googleplay/java/org/kontalk/ui/position/SendPositionGoogleFragment.java b/app/src/googleplay/java/org/kontalk/ui/position/SendPositionGoogleFragment.java new file mode 100644 index 000000000..e80ddfd1f --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/SendPositionGoogleFragment.java @@ -0,0 +1,326 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.content.Intent; +import android.content.IntentSender; +import android.location.Location; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import com.car2go.maps.google.CameraUpdateFactory; +import com.car2go.maps.google.MapsConfiguration; +import com.car2go.maps.model.LatLng; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.common.api.PendingResult; +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.location.LocationListener; +import com.google.android.gms.location.LocationRequest; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.location.LocationSettingsRequest; +import com.google.android.gms.location.LocationSettingsResult; +import com.google.android.gms.location.LocationSettingsStatusCodes; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.util.Permissions; +import org.kontalk.util.ViewUtils; + + +/** + * Send Position Google Maps Fragment + * + * @author Andrea Cappelli + */ +public class SendPositionGoogleFragment extends SendPositionAbstractFragment implements + GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener, LocationListener, ResultCallback { + + private final static String TAG = SendPositionGoogleFragment.class.getSimpleName(); + + private static final String STATE_LOCATION_REQUESTED = SendPositionGoogleFragment.class.getSimpleName() + ".locationRequested"; + + private static final int REQUEST_LOCATION = 1; + + private GoogleApiClient mGoogleApiClient; + + private AnimatorSet mAnimatorSet; + + private LocationRequest mLocationRequest; + private boolean mRequested; + /** This will be non-null if we were unable to obtain a location. */ + private Status mLastStatus; + + private static final long UPDATE_INTERVAL = 20 * 1000; /* 20 secs */ + private static final long FASTEST_INTERVAL = 4000; /* 4 secs */ + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mGoogleApiClient = new GoogleApiClient.Builder(getContext()) + .addConnectionCallbacks(this) + .addOnConnectionFailedListener(this) + .addApi(LocationServices.API) + .build(); + + if (savedInstanceState != null) + mRequested = savedInstanceState.getBoolean(STATE_LOCATION_REQUESTED, false); + } + + @Override + protected View onInflateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_send_position_google, container, false); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + + MapsConfiguration.getInstance().initialize(getContext()); + + return view; + } + + @Override + protected boolean isPlacesEnabled() { + return true; + } + + @Override + protected CameraUpdateFactory getCameraUpdateFactory() { + return com.car2go.maps.google.CameraUpdateFactory.getInstance(); + } + + @Override + protected void onFabClicked(Location location) { + setCustomLocation(null); + } + + @Override + protected void onMapTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + if (mAnimatorSet != null) { + mAnimatorSet.cancel(); + } + mAnimatorSet = new AnimatorSet(); + mAnimatorSet.setDuration(200); + mAnimatorSet.playTogether( + ObjectAnimator.ofFloat(mMapPin, "translationY", mMarkerTop + -ViewUtils.dp(getContext(), 10)), + ObjectAnimator.ofFloat(mPinX, "alpha", 1.0f)); + mAnimatorSet.start(); + } + else if (ev.getAction() == MotionEvent.ACTION_UP) { + if (mAnimatorSet != null) { + mAnimatorSet.cancel(); + } + mAnimatorSet = new AnimatorSet(); + mAnimatorSet.setDuration(200); + mAnimatorSet.playTogether( + ObjectAnimator.ofFloat(mMapPin, "translationY", mMarkerTop), + ObjectAnimator.ofFloat(mPinX, "alpha", 0.0f)); + mAnimatorSet.start(); + } + if (ev.getAction() == MotionEvent.ACTION_MOVE) { + if (!mUserLocationMoved) { + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.setDuration(200); + animatorSet.play(ObjectAnimator.ofFloat(mFabMyLocation, "alpha", 1.0f)); + animatorSet.start(); + mUserLocationMoved = true; + } + + if (mMap != null && mMyLocation != null) { + mUserLocation.setLatitude(mMap.getCameraPosition().target.latitude); + mUserLocation.setLongitude(mMap.getCameraPosition().target.longitude); + } + + setCustomLocation(mUserLocation); + } + } + + protected boolean isLocationEnabled() { + if (mLastStatus != null) { + if (mLastStatus.hasResolution()) { + try { + startIntentSenderForResult(mLastStatus.getResolution().getIntentSender(), + REQUEST_LOCATION, null, 0, 0, 0, null); + } + catch (IntentSender.SendIntentException e) { + Toast.makeText(getContext(), R.string.err_location_access_unknown_error, + Toast.LENGTH_LONG).show(); + } + } + else if (mLastStatus.getStatusCode() == LocationSettingsStatusCodes.SETTINGS_CHANGE_UNAVAILABLE) { + // no location no party! + Toast.makeText(getContext(), R.string.err_location_access_unknown_error, + Toast.LENGTH_LONG).show(); + } + return false; + } + return true; + } + + @Override + public void onStart() { + super.onStart(); + mGoogleApiClient.connect(); + } + + @Override + public void onStop() { + super.onStop(); + + if (mGoogleApiClient.isConnected()) { + LocationServices.FusedLocationApi.removeLocationUpdates(mGoogleApiClient, this); + mGoogleApiClient.disconnect(); + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(STATE_LOCATION_REQUESTED, mRequested); + } + + @Override + protected void requestLocation() { + // we have permission to begin! + if (mGoogleApiClient.isConnected()) { + // simulate connection event since the previous one was ignored + onConnected(null); + } + } + + protected void startLocationUpdates() { + try { + LocationServices.FusedLocationApi.requestLocationUpdates(mGoogleApiClient, + mLocationRequest, this); + } + catch (SecurityException e) { + Toast.makeText(getContext(), R.string.err_location_permission, + Toast.LENGTH_LONG).show(); + } + } + + private void requestAndPollLastLocation() { + Location lastLocation = null; + boolean permissionDenied = false; + try { + lastLocation = LocationServices.FusedLocationApi.getLastLocation(mGoogleApiClient); + } + catch (SecurityException e) { + permissionDenied = true; + Toast.makeText(getContext(), R.string.err_location_permission, + Toast.LENGTH_LONG).show(); + } + + // Note that this can be NULL if last location isn't already known. + if (lastLocation != null) { + // Print current location if not null + Log.d(TAG, "last location: " + lastLocation.toString()); + LatLng latLng = new LatLng(lastLocation.getLatitude(), lastLocation.getLongitude()); + mMyLocation.setLatitude(lastLocation.getLatitude()); + mMyLocation.setLongitude(lastLocation.getLongitude()); + mMyLocation.setTime(lastLocation.getTime()); + if (mMap != null) + mMap.animateCamera(CameraUpdateFactory.getInstance().newLatLngZoom(latLng, 12)); + } + // Begin polling for new location updates. + if (!permissionDenied) + startLocationUpdates(); + } + + @Override + public void onResult(@NonNull LocationSettingsResult result) { + if (result.getStatus().isSuccess()) { + requestAndPollLastLocation(); + } + else { + mLastStatus = result.getStatus(); + // this will trigger the location services dialog + isLocationEnabled(); + } + } + + @Override + public void onConnected(@Nullable Bundle bundle) { + // check permissions here, eventually we'll be called again after being allowed + if (Permissions.canAccessLocation(getContext()) && !mRequested) + requestLocation(LocationRequest.PRIORITY_HIGH_ACCURACY); + } + + private void requestLocation(int accuracy) { + // this will be our location request + mLocationRequest = LocationRequest.create() + .setPriority(accuracy) + .setInterval(UPDATE_INTERVAL) + .setFastestInterval(FASTEST_INTERVAL); + + // check for location settings now + LocationSettingsRequest.Builder builder = new LocationSettingsRequest.Builder() + .addLocationRequest(mLocationRequest); + PendingResult pendingResult = LocationServices + .SettingsApi.checkLocationSettings(mGoogleApiClient, builder.build()); + pendingResult.setResultCallback(this); + mRequested = true; + } + + @Override + public void onConnectionSuspended(int i) { + if (i == CAUSE_SERVICE_DISCONNECTED) { + Log.d(TAG, "Disconnected. Please re-connect."); + } + else if (i == CAUSE_NETWORK_LOST) { + Log.d(TAG, "Network lost. Please re-connect."); + } + } + + @Override + public void onConnectionFailed(@NonNull ConnectionResult connectionResult) { + + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_LOCATION) { + mLastStatus = null; + + switch (resultCode) { + case Activity.RESULT_OK: + case Activity.RESULT_CANCELED: + // try to request location anyway + requestAndPollLastLocation(); + break; + } + } + } + +} diff --git a/app/src/googleplay/java/org/kontalk/ui/position/adapter/PlacesAdapter.java b/app/src/googleplay/java/org/kontalk/ui/position/adapter/PlacesAdapter.java new file mode 100644 index 000000000..9bf3bc6e5 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/adapter/PlacesAdapter.java @@ -0,0 +1,300 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.adapter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import com.google.gson.Gson; + +import android.content.Context; +import android.location.Location; +import android.os.Handler; +import android.os.Looper; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import android.view.View; +import android.view.ViewGroup; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.OkHttpClient; +import okhttp3.Response; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.ui.position.EmptyRow; +import org.kontalk.ui.position.GrayDividerRow; +import org.kontalk.ui.position.LocationLoadingRow; +import org.kontalk.ui.position.LocationRow; +import org.kontalk.position.PlacesRestClient; +import org.kontalk.ui.position.model.Position; +import org.kontalk.ui.position.SendLocationRow; +import org.kontalk.ui.position.model.CategoriesItem; +import org.kontalk.ui.position.model.SearchResponse; +import org.kontalk.ui.position.model.VenuesItem; + +/** + * Google places list adapter + * + * @author andreacappelli + */ + +public class PlacesAdapter extends RecyclerView.Adapter + implements IPlacesAdapter { + + private static final String TAG = PlacesAdapter.class.getSimpleName(); + + private final static int EMPTY = 0; + private final static int SEND_LOCATION = 1; + private final static int GRAY_DIVIDER = 2; + private final static int LOCATION = 3; + private final static int LOADING = 4; + + private Context mContext; + private List mList = new ArrayList<>(); + private int mOverScrollHeight; + private SendLocationRow mSendLocationRow; + private Location mGpsLocation; + private Location mCustomLocation; + + private boolean mSearching; + private Location mLastSearchLocation; + + public static class ViewHolder extends RecyclerView.ViewHolder { + + public ViewHolder(View itemView) { + super(itemView); + } + + } + + public PlacesAdapter(Context context) { + mContext = context; + } + + public void setOverScrollHeight(int value) { + mOverScrollHeight = value; + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = null; + switch (viewType) { + case EMPTY: + view = new EmptyRow(mContext); + break; + case SEND_LOCATION: + view = new SendLocationRow(mContext); + break; + case GRAY_DIVIDER: + view = new GrayDividerRow(mContext); + break; + case LOCATION: + view = new LocationRow(mContext); + break; + case LOADING: + view = new LocationLoadingRow(mContext); + break; + } + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + + switch (holder.getItemViewType()) { + case EMPTY: + ((EmptyRow) holder.itemView).setHeight(mOverScrollHeight); + break; + case SEND_LOCATION: + mSendLocationRow = (SendLocationRow) holder.itemView; + updateRow(); + break; + case GRAY_DIVIDER: + //NOTHING + break; + case LOCATION: + VenuesItem item = mList.get(position - 3); + String iconUrl = null; + if (item.getCategories() != null && item.getCategories().size() > 0) { + CategoriesItem category = item.getCategories().get(0); + iconUrl = String.format("%s64%s", category.getIcon().getPrefix(), category.getIcon().getSuffix()); + } + + String address = null; + if (item.getLocation().getAddress() != null) { + address = item.getLocation().getAddress(); + } + else if (item.getLocation().getCity() != null) { + address = item.getLocation().getCity(); + } + else if (item.getLocation().getState() != null) { + address = item.getLocation().getState(); + } + else if (item.getLocation().getCountry() != null) { + address = item.getLocation().getCountry(); + } + + ((LocationRow) holder.itemView).setLocation(iconUrl, item.getName(), address, true); + break; + case LOADING: + ((LocationLoadingRow) holder.itemView).setLoading(mSearching); + break; + } + + holder.itemView.setEnabled(isEnabled(holder)); + } + + @Override + public int getItemCount() { + if (mSearching || !mSearching && mList.isEmpty()) { + return 4; + } + return 3 + mList.size(); + } + + public void setCustomLocation(Location location) { + mCustomLocation = location; + updateRow(); + } + + public void setGpsPosition(Location location) { + mGpsLocation = location; + updateRow(); + } + + public void searchPlaces(Location location) { + if (mLastSearchLocation != null && location.distanceTo(mLastSearchLocation) < 200) { + return; + } + mLastSearchLocation = location; + if (mSearching) { + mSearching = false; + new OkHttpClient().dispatcher().cancelAll(); + } + + mSearching = true; + + notifyDataSetChanged(); + + PlacesRestClient.getPlacesByLocation(mContext, location.getLatitude(), location.getLongitude(), + 25, new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + Log.e(TAG, "Error getting places:" + e); + mSearching = false; + mList.clear(); + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + notifyDataSetChanged(); + } + }); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException { + mSearching = false; + mList.clear(); + if (response.body() != null) { + Gson gson = new Gson(); + SearchResponse searchResponse = gson.fromJson(response.body().string(), SearchResponse.class); + List list = searchResponse.getResponse().getVenues(); + if (list != null) { + mList.addAll(list); + } + } + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + notifyDataSetChanged(); + } + }); + } + }); + } + + private void updateRow() { + if (mSendLocationRow != null) { + if (mCustomLocation != null) { + mSendLocationRow.setText(mContext.getString(R.string.send_selected_location), String.format(Locale.US, "(%f, %f)", mCustomLocation.getLatitude(), mCustomLocation.getLongitude())); + } + else { + if (mGpsLocation != null) { + mSendLocationRow.setText(mContext.getString(R.string.send_location), mContext.getString(R.string.accurate_to, String.valueOf((int) mGpsLocation.getAccuracy()))); + } + else { + mSendLocationRow.setText(mContext.getString(R.string.send_location), mContext.getString(R.string.loading)); + } + } + } + } + + public Position getVenuesItem(int i) { + if (i > 2 && i < mList.size() + 3) { + VenuesItem item = mList.get(i - 3); + Position position = new Position(item.getLocation().getLat(), + item.getLocation().getLng()); + position.setName(item.getName()); + String address = null; + if (item.getLocation().getAddress() != null) { + address = item.getLocation().getAddress(); + } + else if (item.getLocation().getCity() != null) { + address = item.getLocation().getCity(); + } + else if (item.getLocation().getState() != null) { + address = item.getLocation().getState(); + } + else if (item.getLocation().getCountry() != null) { + address = item.getLocation().getCountry(); + } + position.setAddress(address); + + return position; + } + return null; + } + + @Override + public int getItemViewType(int position) { + if (position == 0) { + return EMPTY; + } + else if (position == 1) { + return SEND_LOCATION; + } + else if (position == 2) { + return GRAY_DIVIDER; + } + else if (mSearching || !mSearching && mList.isEmpty()) { + return LOADING; + } + + return LOCATION; + } + + public boolean isEnabled(RecyclerView.ViewHolder holder) { + int position = holder.getAdapterPosition(); + return !(position == 2 || position == 0 || position == 3 && (mSearching || !mSearching && mList.isEmpty()) || position == mList.size() + 3); + } +} diff --git a/app/src/googleplay/java/org/kontalk/ui/position/adapter/SearchPlacesAdapter.java b/app/src/googleplay/java/org/kontalk/ui/position/adapter/SearchPlacesAdapter.java new file mode 100644 index 000000000..e44e2c7d7 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/adapter/SearchPlacesAdapter.java @@ -0,0 +1,237 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.adapter; + +import java.io.IOException; +import java.util.List; + +import com.google.gson.Gson; + +import android.content.Context; +import android.location.Location; +import android.os.Handler; +import android.os.Looper; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.OkHttpClient; +import okhttp3.Response; + +import org.kontalk.Log; +import org.kontalk.ui.position.LoadingRow; +import org.kontalk.ui.position.LocationRow; +import org.kontalk.position.PlacesRestClient; +import org.kontalk.ui.position.model.Position; +import org.kontalk.ui.position.model.CategoriesItem; +import org.kontalk.ui.position.model.SearchResponse; +import org.kontalk.ui.position.model.VenuesItem; + + +/** + * Search Places adapter + * + * @author andreacappelli + */ +public class SearchPlacesAdapter extends RecyclerView.Adapter + implements ISearchPlacesAdapter { + + static final String TAG = SearchPlacesAdapter.class.getSimpleName(); + + private final static int LOCATION = 0; + private final static int LOADING = 1; + private final static int NO_RESULT = 2; + + private Context mContext; + List mList; + + boolean mSearching; + + public static class ViewHolder extends RecyclerView.ViewHolder { + + public ViewHolder(View itemView) { + super(itemView); + } + + } + + public SearchPlacesAdapter(Context context) { + mContext = context; + } + + @Override + public SearchPlacesAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = null; + switch (viewType) { + case LOADING: + case NO_RESULT: { + LoadingRow loadingRow = new LoadingRow(mContext); + loadingRow.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + loadingRow.setEnabled(false); + view = loadingRow; + break; + } + case LOCATION: { + view = new LocationRow(mContext); + } + } + + + return new SearchPlacesAdapter.ViewHolder(view); + } + + @Override + public void onBindViewHolder(SearchPlacesAdapter.ViewHolder holder, int position) { + switch (holder.getItemViewType()) { + case LOADING: { + ((LoadingRow) holder.itemView).setLoading(true); + break; + } + case NO_RESULT: { + ((LoadingRow) holder.itemView).setLoading(false); + break; + } + case LOCATION: { + VenuesItem item = mList.get(position); + String iconUrl = null; + if (item.getCategories() != null && item.getCategories().size() > 0) { + CategoriesItem category = item.getCategories().get(0); + iconUrl = String.format("%s64%s", category.getIcon().getPrefix(), category.getIcon().getSuffix()); + } + + String address = null; + if (item.getLocation().getAddress() != null) { + address = item.getLocation().getAddress(); + } + else if (item.getLocation().getCity() != null) { + address = item.getLocation().getCity(); + } + else if (item.getLocation().getState() != null) { + address = item.getLocation().getState(); + } + else if (item.getLocation().getCountry() != null) { + address = item.getLocation().getCountry(); + } + + ((LocationRow) holder.itemView).setLocation(iconUrl, item.getName(), address, position != mList.size() - 1); + break; + } + } + } + + @Override + public int getItemCount() { + if (mList == null || mList.isEmpty()) + return 1; + return mList.size(); + } + + @Override + public int getItemViewType(int position) { + if (mSearching) + return LOADING; + + if (mList == null || mList.isEmpty()) + return NO_RESULT; + + return LOCATION; + } + + public Position getVenuesItem(int i) { + if (mList != null && mList.size() > 0) { + VenuesItem item = mList.get(i); + Position position = new Position(item.getLocation().getLat(), + item.getLocation().getLng()); + position.setName(item.getName()); + String address = null; + if (item.getLocation().getAddress() != null) { + address = item.getLocation().getAddress(); + } + else if (item.getLocation().getCity() != null) { + address = item.getLocation().getCity(); + } + else if (item.getLocation().getState() != null) { + address = item.getLocation().getState(); + } + else if (item.getLocation().getCountry() != null) { + address = item.getLocation().getCountry(); + } + position.setAddress(address); + + return position; + } + return null; + } + + public void searchPlacesWithQuery(String query, Location location) { + if (query == null || query.length() == 0) { + mList = null; + notifyDataSetChanged(); + mSearching = false; + return; + } + + if (mSearching) { + mSearching = false; + new OkHttpClient().dispatcher().cancelAll(); + } + + mSearching = true; + + notifyDataSetChanged(); + + PlacesRestClient.getPlacesByQuery(mContext, location.getLatitude(), location.getLongitude(), + query, new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + mSearching = false; + mList = null; + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + notifyDataSetChanged(); + } + }); + Log.e(TAG, e.getLocalizedMessage()); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException { + mSearching = false; + if (response.body() != null) { + Gson gson = new Gson(); + SearchResponse searchResponse = gson.fromJson(response.body().string(), SearchResponse.class); + mList = searchResponse.getResponse().getVenues(); + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + notifyDataSetChanged(); + } + }); + } + } + }); + } + +} + diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/BeenHere.java b/app/src/googleplay/java/org/kontalk/ui/position/model/BeenHere.java new file mode 100644 index 000000000..1fcdc2b53 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/BeenHere.java @@ -0,0 +1,40 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +public class BeenHere { + private int lastCheckinExpiredAt; + + public int getLastCheckinExpiredAt() { + return lastCheckinExpiredAt; + } + + public void setLastCheckinExpiredAt(int lastCheckinExpiredAt) { + this.lastCheckinExpiredAt = lastCheckinExpiredAt; + } + + @Override + public String toString() { + return + "BeenHere{" + + "lastCheckinExpiredAt = '" + lastCheckinExpiredAt + '\'' + + "}"; + } +} + diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/CategoriesItem.java b/app/src/googleplay/java/org/kontalk/ui/position/model/CategoriesItem.java new file mode 100644 index 000000000..9f3bdcb46 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/CategoriesItem.java @@ -0,0 +1,90 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +public class CategoriesItem { + private String pluralName; + private String name; + private Icon icon; + private String id; + private String shortName; + private boolean primary; + + public String getPluralName() { + return pluralName; + } + + public void setPluralName(String pluralName) { + this.pluralName = pluralName; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Icon getIcon() { + return icon; + } + + public void setIcon(Icon icon) { + this.icon = icon; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getShortName() { + return shortName; + } + + public void setShortName(String shortName) { + this.shortName = shortName; + } + + public boolean isPrimary() { + return primary; + } + + public void setPrimary(boolean primary) { + this.primary = primary; + } + + @Override + public String toString() { + return + "CategoriesItem{" + + "pluralName = '" + pluralName + '\'' + + ",name = '" + name + '\'' + + ",icon = '" + icon + '\'' + + ",id = '" + id + '\'' + + ",shortName = '" + shortName + '\'' + + ",primary = '" + primary + '\'' + + "}"; + } +} + diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/Contact.java b/app/src/googleplay/java/org/kontalk/ui/position/model/Contact.java new file mode 100644 index 000000000..6f55899b0 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/Contact.java @@ -0,0 +1,30 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +public class Contact { + + @Override + public String toString() { + return + "Contact{" + + "}"; + } +} + diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/HereNow.java b/app/src/googleplay/java/org/kontalk/ui/position/model/HereNow.java new file mode 100644 index 000000000..0a5b7ac1e --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/HereNow.java @@ -0,0 +1,61 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +import java.util.List; + +public class HereNow { + private String summary; + private int count; + private List groups; + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public List getGroups() { + return groups; + } + + public void setGroups(List groups) { + this.groups = groups; + } + + @Override + public String toString() { + return + "HereNow{" + + "summary = '" + summary + '\'' + + ",count = '" + count + '\'' + + ",groups = '" + groups + '\'' + + "}"; + } +} diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/Icon.java b/app/src/googleplay/java/org/kontalk/ui/position/model/Icon.java new file mode 100644 index 000000000..877f41a4c --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/Icon.java @@ -0,0 +1,50 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +public class Icon { + private String prefix; + private String suffix; + + public String getPrefix() { + return prefix; + } + + public void setPrefix(String prefix) { + this.prefix = prefix; + } + + public String getSuffix() { + return suffix; + } + + public void setSuffix(String suffix) { + this.suffix = suffix; + } + + @Override + public String toString() { + return + "Icon{" + + "prefix = '" + prefix + '\'' + + ",suffix = '" + suffix + '\'' + + "}"; + } +} + diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/LabeledLatLngsItem.java b/app/src/googleplay/java/org/kontalk/ui/position/model/LabeledLatLngsItem.java new file mode 100644 index 000000000..8e66f741e --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/LabeledLatLngsItem.java @@ -0,0 +1,60 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +public class LabeledLatLngsItem { + private double lng; + private String label; + private double lat; + + public double getLng() { + return lng; + } + + public void setLng(double lng) { + this.lng = lng; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public double getLat() { + return lat; + } + + public void setLat(double lat) { + this.lat = lat; + } + + @Override + public String toString() { + return + "LabeledLatLngsItem{" + + "lng = '" + lng + '\'' + + ",label = '" + label + '\'' + + ",lat = '" + lat + '\'' + + "}"; + } +} + diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/Location.java b/app/src/googleplay/java/org/kontalk/ui/position/model/Location.java new file mode 100644 index 000000000..3433a355d --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/Location.java @@ -0,0 +1,151 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +import java.util.List; + +public class Location { + private String cc; + private String country; + private String address; + private List labeledLatLngs; + private double lng; + private int distance; + private List formattedAddress; + private String city; + private String postalCode; + private String state; + private String crossStreet; + private double lat; + + public String getCc() { + return cc; + } + + public void setCc(String cc) { + this.cc = cc; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public List getLabeledLatLngs() { + return labeledLatLngs; + } + + public void setLabeledLatLngs(List labeledLatLngs) { + this.labeledLatLngs = labeledLatLngs; + } + + public double getLng() { + return lng; + } + + public void setLng(double lng) { + this.lng = lng; + } + + public int getDistance() { + return distance; + } + + public void setDistance(int distance) { + this.distance = distance; + } + + public List getFormattedAddress() { + return formattedAddress; + } + + public void setFormattedAddress(List formattedAddress) { + this.formattedAddress = formattedAddress; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getPostalCode() { + return postalCode; + } + + public void setPostalCode(String postalCode) { + this.postalCode = postalCode; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getCrossStreet() { + return crossStreet; + } + + public void setCrossStreet(String crossStreet) { + this.crossStreet = crossStreet; + } + + public double getLat() { + return lat; + } + + public void setLat(double lat) { + this.lat = lat; + } + + @Override + public String toString() { + return + "Location{" + + "cc = '" + cc + '\'' + + ",country = '" + country + '\'' + + ",address = '" + address + '\'' + + ",labeledLatLngs = '" + labeledLatLngs + '\'' + + ",lng = '" + lng + '\'' + + ",distance = '" + distance + '\'' + + ",formattedAddress = '" + formattedAddress + '\'' + + ",city = '" + city + '\'' + + ",postalCode = '" + postalCode + '\'' + + ",state = '" + state + '\'' + + ",crossStreet = '" + crossStreet + '\'' + + ",lat = '" + lat + '\'' + + "}"; + } +} diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/Meta.java b/app/src/googleplay/java/org/kontalk/ui/position/model/Meta.java new file mode 100644 index 000000000..8c8e4d240 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/Meta.java @@ -0,0 +1,50 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +public class Meta { + private int code; + private String requestId; + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + @Override + public String toString() { + return + "Meta{" + + "code = '" + code + '\'' + + ",requestId = '" + requestId + '\'' + + "}"; + } +} + diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/Response.java b/app/src/googleplay/java/org/kontalk/ui/position/model/Response.java new file mode 100644 index 000000000..f0feac498 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/Response.java @@ -0,0 +1,51 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +import java.util.List; + +public class Response { + private boolean confident; + private List venues; + + public boolean isConfident() { + return confident; + } + + public void setConfident(boolean confident) { + this.confident = confident; + } + + public List getVenues() { + return venues; + } + + public void setVenues(List venues) { + this.venues = venues; + } + + @Override + public String toString() { + return + "Response{" + + "confident = '" + confident + '\'' + + ",venues = '" + venues + '\'' + + "}"; + } +} diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/SearchResponse.java b/app/src/googleplay/java/org/kontalk/ui/position/model/SearchResponse.java new file mode 100644 index 000000000..5b2d0b880 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/SearchResponse.java @@ -0,0 +1,50 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +public class SearchResponse { + private Meta meta; + private Response response; + + public Meta getMeta() { + return meta; + } + + public void setMeta(Meta meta) { + this.meta = meta; + } + + public Response getResponse() { + return response; + } + + public void setResponse(Response response) { + this.response = response; + } + + @Override + public String toString() { + return + "SearchResponse{" + + "meta = '" + meta + '\'' + + ",response = '" + response + '\'' + + "}"; + } +} + diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/Specials.java b/app/src/googleplay/java/org/kontalk/ui/position/model/Specials.java new file mode 100644 index 000000000..c4ea3858d --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/Specials.java @@ -0,0 +1,51 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +import java.util.List; + +public class Specials { + private int count; + private List items; + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } + + @Override + public String toString() { + return + "Specials{" + + "count = '" + count + '\'' + + ",items = '" + items + '\'' + + "}"; + } +} diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/Stats.java b/app/src/googleplay/java/org/kontalk/ui/position/model/Stats.java new file mode 100644 index 000000000..4ad6dd2f3 --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/Stats.java @@ -0,0 +1,60 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +public class Stats { + private int checkinsCount; + private int usersCount; + private int tipCount; + + public int getCheckinsCount() { + return checkinsCount; + } + + public void setCheckinsCount(int checkinsCount) { + this.checkinsCount = checkinsCount; + } + + public int getUsersCount() { + return usersCount; + } + + public void setUsersCount(int usersCount) { + this.usersCount = usersCount; + } + + public int getTipCount() { + return tipCount; + } + + public void setTipCount(int tipCount) { + this.tipCount = tipCount; + } + + @Override + public String toString() { + return + "Stats{" + + "checkinsCount = '" + checkinsCount + '\'' + + ",usersCount = '" + usersCount + '\'' + + ",tipCount = '" + tipCount + '\'' + + "}"; + } +} + diff --git a/app/src/googleplay/java/org/kontalk/ui/position/model/VenuesItem.java b/app/src/googleplay/java/org/kontalk/ui/position/model/VenuesItem.java new file mode 100644 index 000000000..bcd96945a --- /dev/null +++ b/app/src/googleplay/java/org/kontalk/ui/position/model/VenuesItem.java @@ -0,0 +1,181 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +import java.util.List; + +public class VenuesItem { + private boolean hasPerk; + private Specials specials; + private String referralId; + private List venueChains; + private boolean verified; + private String url; + private BeenHere beenHere; + private HereNow hereNow; + private boolean venueRatingBlacklisted; + private Stats stats; + private Contact contact; + private String name; + private Location location; + private String id; + private List categories; + + public boolean isHasPerk() { + return hasPerk; + } + + public void setHasPerk(boolean hasPerk) { + this.hasPerk = hasPerk; + } + + public Specials getSpecials() { + return specials; + } + + public void setSpecials(Specials specials) { + this.specials = specials; + } + + public String getReferralId() { + return referralId; + } + + public void setReferralId(String referralId) { + this.referralId = referralId; + } + + public List getVenueChains() { + return venueChains; + } + + public void setVenueChains(List venueChains) { + this.venueChains = venueChains; + } + + public boolean isVerified() { + return verified; + } + + public void setVerified(boolean verified) { + this.verified = verified; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public BeenHere getBeenHere() { + return beenHere; + } + + public void setBeenHere(BeenHere beenHere) { + this.beenHere = beenHere; + } + + public HereNow getHereNow() { + return hereNow; + } + + public void setHereNow(HereNow hereNow) { + this.hereNow = hereNow; + } + + public boolean isVenueRatingBlacklisted() { + return venueRatingBlacklisted; + } + + public void setVenueRatingBlacklisted(boolean venueRatingBlacklisted) { + this.venueRatingBlacklisted = venueRatingBlacklisted; + } + + public Stats getStats() { + return stats; + } + + public void setStats(Stats stats) { + this.stats = stats; + } + + public Contact getContact() { + return contact; + } + + public void setContact(Contact contact) { + this.contact = contact; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Location getLocation() { + return location; + } + + public void setLocation(Location location) { + this.location = location; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public List getCategories() { + return categories; + } + + public void setCategories(List categories) { + this.categories = categories; + } + + @Override + public String toString() { + return + "VenuesItem{" + + "hasPerk = '" + hasPerk + '\'' + + ",specials = '" + specials + '\'' + + ",referralId = '" + referralId + '\'' + + ",venueChains = '" + venueChains + '\'' + + ",verified = '" + verified + '\'' + + ",url = '" + url + '\'' + + ",beenHere = '" + beenHere + '\'' + + ",hereNow = '" + hereNow + '\'' + + ",venueRatingBlacklisted = '" + venueRatingBlacklisted + '\'' + + ",stats = '" + stats + '\'' + + ",contact = '" + contact + '\'' + + ",name = '" + name + '\'' + + ",location = '" + location + '\'' + + ",id = '" + id + '\'' + + ",categories = '" + categories + '\'' + + "}"; + } +} diff --git a/app/src/googleplay/res/layout/fragment_position_google.xml b/app/src/googleplay/res/layout/fragment_position_google.xml new file mode 100644 index 000000000..6ead7b94e --- /dev/null +++ b/app/src/googleplay/res/layout/fragment_position_google.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/googleplay/res/layout/fragment_send_position_google.xml b/app/src/googleplay/res/layout/fragment_send_position_google.xml new file mode 100644 index 000000000..5029cf2c4 --- /dev/null +++ b/app/src/googleplay/res/layout/fragment_send_position_google.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/googleplay/res/values/arrays.xml b/app/src/googleplay/res/values/arrays.xml new file mode 100644 index 000000000..905bbd0de --- /dev/null +++ b/app/src/googleplay/res/values/arrays.xml @@ -0,0 +1,31 @@ + + + + + + + google + osm + + + + @string/pref_maps_google_label + @string/pref_maps_osm_label + + + diff --git a/app/src/googleplay/res/values/config.xml b/app/src/googleplay/res/values/config.xml new file mode 100644 index 000000000..965f9402f --- /dev/null +++ b/app/src/googleplay/res/values/config.xml @@ -0,0 +1,23 @@ + + + + + + google + diff --git a/app/src/googleplay/res/xml/preferences.xml b/app/src/googleplay/res/xml/preferences.xml new file mode 100644 index 000000000..c18d9764a --- /dev/null +++ b/app/src/googleplay/res/xml/preferences.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 41bf9de60..91a50483b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ + + + + + + - - + - + + + - + + + + + + + + + + + + + + + android:icon="@mipmap/ic_launcher" + android:theme="@style/AppTheme" + android:fullBackupContent="false" + android:allowBackup="false" + tools:replace="android:allowBackup"> + android:label="@string/authenticator_name" + tools:ignore="ExportedService"> @@ -61,7 +90,8 @@ android:resource="@xml/authenticator"/> + android:label="@string/sync_service_name" + tools:ignore="ExportedService"> @@ -69,19 +99,47 @@ - + android:label="@string/download_service" + android:permission="android.permission.BIND_JOB_SERVICE"/> + + + + + + + + + + + + + + @@ -95,43 +153,62 @@ - - - + + + + + + - + + + + + + + android:parentActivityName=".ui.ConversationsActivity" + tools:targetApi="jelly_bean"> + - + android:windowSoftInputMode="stateHidden" + android:parentActivityName=".ui.ConversationsActivity" + tools:targetApi="jelly_bean"> + + android:scheme="content" + android:host="com.android.contacts" + tools:ignore="AppLinkUrlError"/> @@ -164,50 +241,139 @@ + + - + + + + + + + + + + + + + + + + + + + + + + + + + - + android:launchMode="singleInstance"/> + android:launchMode="singleInstance"/> + android:parentActivityName=".ui.ConversationsActivity" + tools:targetApi="jelly_bean"> + + + android:parentActivityName=".ui.ConversationsActivity" + tools:targetApi="jelly_bean"> + + + android:parentActivityName=".ui.ConversationsActivity" + tools:targetApi="jelly_bean"> + + + android:parentActivityName=".ui.ConversationsActivity" + tools:targetApi="jelly_bean"> + + + + + + + + + + + + + + + + - + android:theme="@style/AppTheme.QuickReplyDialog"/> diff --git a/app/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java b/app/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java new file mode 100644 index 000000000..db42f8b76 --- /dev/null +++ b/app/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java @@ -0,0 +1,316 @@ +package com.amulyakhare.textdrawable; + +import android.graphics.*; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; +import android.graphics.drawable.shapes.RectShape; +import android.graphics.drawable.shapes.RoundRectShape; + +/** + * @author amulya + * @datetime 14 Oct 2014, 3:53 PM + */ +public class TextDrawable extends ShapeDrawable { + + private final Paint textPaint; + private final Paint borderPaint; + private static final float SHADE_FACTOR = 0.9f; + private final String text; + private final int color; + private final RectShape shape; + private final int height; + private final int width; + private final int fontSize; + private final float radius; + private final int borderThickness; + + private TextDrawable(Builder builder) { + super(builder.shape); + + // shape properties + shape = builder.shape; + height = builder.height; + width = builder.width; + radius = builder.radius; + + // text and color + text = builder.toUpperCase ? builder.text.toUpperCase() : builder.text; + color = builder.color; + + // text paint settings + fontSize = builder.fontSize; + textPaint = new Paint(); + textPaint.setColor(builder.textColor); + textPaint.setAntiAlias(true); + textPaint.setFakeBoldText(builder.isBold); + textPaint.setStyle(Paint.Style.FILL); + textPaint.setTypeface(builder.font); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setStrokeWidth(builder.borderThickness); + + // border paint settings + borderThickness = builder.borderThickness; + borderPaint = new Paint(); + borderPaint.setColor(getDarkerShade(color)); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setStrokeWidth(borderThickness); + + // drawable paint color + Paint paint = getPaint(); + paint.setColor(color); + + } + + private int getDarkerShade(int color) { + return Color.rgb((int)(SHADE_FACTOR * Color.red(color)), + (int)(SHADE_FACTOR * Color.green(color)), + (int)(SHADE_FACTOR * Color.blue(color))); + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + Rect r = getBounds(); + + + // draw border + if (borderThickness > 0) { + drawBorder(canvas); + } + + int count = canvas.save(); + canvas.translate(r.left, r.top); + + // draw text + int width = this.width < 0 ? r.width() : this.width; + int height = this.height < 0 ? r.height() : this.height; + int fontSize = this.fontSize < 0 ? (Math.min(width, height) / 2) : this.fontSize; + textPaint.setTextSize(fontSize); + canvas.drawText(text, width / 2, height / 2 - ((textPaint.descent() + textPaint.ascent()) / 2), textPaint); + + canvas.restoreToCount(count); + + } + + private void drawBorder(Canvas canvas) { + RectF rect = new RectF(getBounds()); + rect.inset(borderThickness/2, borderThickness/2); + + if (shape instanceof OvalShape) { + canvas.drawOval(rect, borderPaint); + } + else if (shape instanceof RoundRectShape) { + canvas.drawRoundRect(rect, radius, radius, borderPaint); + } + else { + canvas.drawRect(rect, borderPaint); + } + } + + @Override + public void setAlpha(int alpha) { + textPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(ColorFilter cf) { + textPaint.setColorFilter(cf); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public int getIntrinsicWidth() { + return width; + } + + @Override + public int getIntrinsicHeight() { + return height; + } + + public static IShapeBuilder builder() { + return new Builder(); + } + + public static class Builder implements IConfigBuilder, IShapeBuilder, IBuilder { + + private String text; + + private int color; + + private int borderThickness; + + private int width; + + private int height; + + private Typeface font; + + private RectShape shape; + + public int textColor; + + private int fontSize; + + private boolean isBold; + + private boolean toUpperCase; + + public float radius; + + private Builder() { + text = ""; + color = Color.GRAY; + textColor = Color.WHITE; + borderThickness = 0; + width = -1; + height = -1; + shape = new RectShape(); + font = Typeface.create("sans-serif-light", Typeface.NORMAL); + fontSize = -1; + isBold = false; + toUpperCase = false; + } + + public IConfigBuilder width(int width) { + this.width = width; + return this; + } + + public IConfigBuilder height(int height) { + this.height = height; + return this; + } + + public IConfigBuilder textColor(int color) { + this.textColor = color; + return this; + } + + public IConfigBuilder withBorder(int thickness) { + this.borderThickness = thickness; + return this; + } + + public IConfigBuilder useFont(Typeface font) { + this.font = font; + return this; + } + + public IConfigBuilder fontSize(int size) { + this.fontSize = size; + return this; + } + + public IConfigBuilder bold() { + this.isBold = true; + return this; + } + + public IConfigBuilder toUpperCase() { + this.toUpperCase = true; + return this; + } + + @Override + public IConfigBuilder beginConfig() { + return this; + } + + @Override + public IShapeBuilder endConfig() { + return this; + } + + @Override + public IBuilder rect() { + this.shape = new RectShape(); + return this; + } + + @Override + public IBuilder round() { + this.shape = new OvalShape(); + return this; + } + + @Override + public IBuilder roundRect(int radius) { + this.radius = radius; + float[] radii = {radius, radius, radius, radius, radius, radius, radius, radius}; + this.shape = new RoundRectShape(radii, null, null); + return this; + } + + @Override + public TextDrawable buildRect(String text, int color) { + rect(); + return build(text, color); + } + + @Override + public TextDrawable buildRoundRect(String text, int color, int radius) { + roundRect(radius); + return build(text, color); + } + + @Override + public TextDrawable buildRound(String text, int color) { + round(); + return build(text, color); + } + + @Override + public TextDrawable build(String text, int color) { + this.color = color; + this.text = text; + return new TextDrawable(this); + } + } + + public interface IConfigBuilder { + public IConfigBuilder width(int width); + + public IConfigBuilder height(int height); + + public IConfigBuilder textColor(int color); + + public IConfigBuilder withBorder(int thickness); + + public IConfigBuilder useFont(Typeface font); + + public IConfigBuilder fontSize(int size); + + public IConfigBuilder bold(); + + public IConfigBuilder toUpperCase(); + + public IShapeBuilder endConfig(); + } + + public static interface IBuilder { + + public TextDrawable build(String text, int color); + } + + public static interface IShapeBuilder { + + public IConfigBuilder beginConfig(); + + public IBuilder rect(); + + public IBuilder round(); + + public IBuilder roundRect(int radius); + + public TextDrawable buildRect(String text, int color); + + public TextDrawable buildRoundRect(String text, int color, int radius); + + public TextDrawable buildRound(String text, int color); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/amulyakhare/textdrawable/util/ColorGenerator.java b/app/src/main/java/com/amulyakhare/textdrawable/util/ColorGenerator.java new file mode 100644 index 000000000..198c18fab --- /dev/null +++ b/app/src/main/java/com/amulyakhare/textdrawable/util/ColorGenerator.java @@ -0,0 +1,69 @@ +package com.amulyakhare.textdrawable.util; + +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +/** + * @author amulya + * @datetime 14 Oct 2014, 5:20 PM + */ +public class ColorGenerator { + + public static ColorGenerator DEFAULT; + + public static ColorGenerator MATERIAL; + + static { + DEFAULT = create(Arrays.asList( + 0xfff16364, + 0xfff58559, + 0xfff9a43e, + 0xffe4c62e, + 0xff67bf74, + 0xff59a2be, + 0xff2093cd, + 0xffad62a7, + 0xff805781 + )); + MATERIAL = create(Arrays.asList( + 0xffe57373, + 0xfff06292, + 0xffba68c8, + 0xff9575cd, + 0xff7986cb, + 0xff64b5f6, + 0xff4fc3f7, + 0xff4dd0e1, + 0xff4db6ac, + 0xff81c784, + 0xffaed581, + 0xffff8a65, + 0xffd4e157, + 0xffffd54f, + 0xffffb74d, + 0xffa1887f, + 0xff90a4ae + )); + } + + private final List mColors; + private final Random mRandom; + + public static ColorGenerator create(List colorList) { + return new ColorGenerator(colorList); + } + + private ColorGenerator(List colorList) { + mColors = colorList; + mRandom = new Random(System.currentTimeMillis()); + } + + public int getRandomColor() { + return mColors.get(mRandom.nextInt(mColors.size())); + } + + public int getColor(Object key) { + return mColors.get(Math.abs(key.hashCode()) % mColors.size()); + } +} diff --git a/app/src/main/java/com/android/contacts/common/list/ContactsSectionIndexer.java b/app/src/main/java/com/android/contacts/common/list/ContactsSectionIndexer.java new file mode 100644 index 000000000..be5978050 --- /dev/null +++ b/app/src/main/java/com/android/contacts/common/list/ContactsSectionIndexer.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.list; + +import android.text.TextUtils; +import android.widget.SectionIndexer; + +import java.util.Arrays; + +/** + * A section indexer that is configured with precomputed section titles and + * their respective counts. + */ +public class ContactsSectionIndexer implements SectionIndexer { + + private String[] mSections; + private int[] mPositions; + private int mCount; + private static final String BLANK_HEADER_STRING = " "; + + /** + * Constructor. + * + * @param sections a non-null array + * @param counts a non-null array of the same size as sections + */ + public ContactsSectionIndexer(String[] sections, int[] counts) { + if (sections == null || counts == null) { + throw new NullPointerException(); + } + + if (sections.length != counts.length) { + throw new IllegalArgumentException( + "The sections and counts arrays must have the same length"); + } + + // TODO process sections/counts based on current locale and/or specific section titles + + this.mSections = sections; + mPositions = new int[counts.length]; + int position = 0; + for (int i = 0; i < counts.length; i++) { + if (TextUtils.isEmpty(mSections[i])) { + mSections[i] = BLANK_HEADER_STRING; + } else if (!mSections[i].equals(BLANK_HEADER_STRING)) { + mSections[i] = mSections[i].trim(); + } + + mPositions[i] = position; + position += counts[i]; + } + mCount = position; + } + + public Object[] getSections() { + return mSections; + } + + public int getPositionForSection(int section) { + if (section < 0 || section >= mSections.length) { + return -1; + } + + return mPositions[section]; + } + + public int getSectionForPosition(int position) { + if (position < 0 || position >= mCount) { + return -1; + } + + int index = Arrays.binarySearch(mPositions, position); + + /* + * Consider this example: section positions are 0, 3, 5; the supplied + * position is 4. The section corresponding to position 4 starts at + * position 3, so the expected return value is 1. Binary search will not + * find 4 in the array and thus will return -insertPosition-1, i.e. -3. + * To get from that number to the expected value of 1 we need to negate + * and subtract 2. + */ + return index >= 0 ? index : -index - 2; + } + +} diff --git a/app/src/main/java/com/android/providers/contacts/ContactLocaleUtils.java b/app/src/main/java/com/android/providers/contacts/ContactLocaleUtils.java new file mode 100644 index 000000000..c05df71b9 --- /dev/null +++ b/app/src/main/java/com/android/providers/contacts/ContactLocaleUtils.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.providers.contacts; + +import java.lang.Character.UnicodeBlock; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + + +/** + * This utility class provides specialized handling for locale specific + * information: labels, name lookup keys. + * Modified to work with IBM ICU4J. + */ +public class ContactLocaleUtils { + public static final String TAG = "ContactLocale"; + + /** + * This class is the default implementation and should be the base class + * for other locales. + * + * sortKey: same as name + * nameLookupKeys: none + * labels: uses ICU AlphabeticIndex for labels and extends by labeling + * phone numbers "#". Eg English labels are: [A-Z], #, " " + */ + private static class ContactLocaleUtilsBase { + private static final String EMPTY_STRING = ""; + private static final String NUMBER_STRING = "#"; + + public String getBucketLabel(String name) { + if (name.length() == 0) + return EMPTY_STRING; + + boolean prefixIsNumeric = false; + final int length = name.length(); + int offset = 0; + while (offset < length) { + int codePoint = Character.codePointAt(name, offset); + // Ignore standard phone number separators and identify any + // string that otherwise starts with a number. + if (Character.isDigit(codePoint)) { + prefixIsNumeric = true; + break; + } else if (!Character.isSpaceChar(codePoint) && + codePoint != '+' && codePoint != '(' && + codePoint != ')' && codePoint != '.' && + codePoint != '-' && codePoint != '#') { + break; + } + offset += Character.charCount(codePoint); + } + if (prefixIsNumeric) { + return NUMBER_STRING; + } + + return name.substring(0, name.offsetByCodePoints(0, 1)); + } + } + + /** + * Japanese specific locale overrides. + * + * sortKey: unchanged (same as name) + * nameLookupKeys: unchanged (none) + * labels: extends default labels by labeling unlabeled CJ characters + * with the Japanese character 他 ("misc"). Japanese labels are: + * あ, か, さ, た, な, は, ま, や, ら, わ, 他, [A-Z], #, " " + */ + private static class JapaneseContactUtils extends ContactLocaleUtilsBase { + // \u4ed6 is Japanese character 他 ("misc") + private static final String JAPANESE_MISC_LABEL = "\u4ed6"; + + public JapaneseContactUtils() { + super(); + } + + // Set of UnicodeBlocks for unified CJK (Chinese) characters and + // Japanese characters. This includes all code blocks that might + // contain a character used in Japanese (which is why unified CJK + // blocks are included but Korean Hangul and jamo are not). + private static final Set CJ_BLOCKS; + static { + Set set = new HashSet<>(); + set.add(UnicodeBlock.HIRAGANA); + set.add(UnicodeBlock.KATAKANA); + set.add(UnicodeBlock.KATAKANA_PHONETIC_EXTENSIONS); + set.add(UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS); + set.add(UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS); + set.add(UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A); + set.add(UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B); + set.add(UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION); + set.add(UnicodeBlock.CJK_RADICALS_SUPPLEMENT); + set.add(UnicodeBlock.CJK_COMPATIBILITY); + set.add(UnicodeBlock.CJK_COMPATIBILITY_FORMS); + set.add(UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS); + set.add(UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT); + CJ_BLOCKS = Collections.unmodifiableSet(set); + } + + /** + * Helper routine to identify unlabeled Chinese or Japanese characters + * to put in a 'misc' bucket. + * + * @return true if the specified Unicode code point is Chinese or + * Japanese + */ + private static boolean isChineseOrJapanese(int codePoint) { + return CJ_BLOCKS.contains(UnicodeBlock.of(codePoint)); + } + + @Override + public String getBucketLabel(String name) { + if (!isChineseOrJapanese(Character.codePointAt(name, 0))) + return JAPANESE_MISC_LABEL; + return super.getBucketLabel(name); + } + } + + /** + * Simplified Chinese specific locale overrides. Uses ICU Transliterator + * for generating pinyin transliteration. + * + * sortKey: unchanged (same as name) + * nameLookupKeys: adds additional name lookup keys + * - Chinese character's pinyin and pinyin's initial character. + * - Latin word and initial character. + * labels: unchanged + * Simplified Chinese labels are the same as English: [A-Z], #, " " + */ + private static class SimplifiedChineseContactUtils + extends ContactLocaleUtilsBase { + public SimplifiedChineseContactUtils() { + super(); + } + } + + private static final String JAPANESE_LANGUAGE = Locale.JAPANESE.getLanguage().toLowerCase(); + + private static ContactLocaleUtils sSingleton; + + private final LocaleSet mLocales; + private final ContactLocaleUtilsBase mUtils; + + private ContactLocaleUtils(LocaleSet locales) { + if (locales == null) { + mLocales = LocaleSet.getDefault(); + } else { + mLocales = locales; + } + if (mLocales.isPrimaryLanguage(JAPANESE_LANGUAGE)) { + mUtils = new JapaneseContactUtils(); + } else if (mLocales.isPrimaryLocaleSimplifiedChinese()) { + mUtils = new SimplifiedChineseContactUtils(); + } else { + mUtils = new ContactLocaleUtilsBase(); + } + } + + public boolean isLocale(LocaleSet locales) { + return mLocales.equals(locales); + } + + public static synchronized ContactLocaleUtils getInstance() { + if (sSingleton == null) { + sSingleton = new ContactLocaleUtils(LocaleSet.getDefault()); + } + return sSingleton; + } + + public static synchronized void setLocale(Locale locale) { + setLocales(new LocaleSet(locale)); + } + + public static synchronized void setLocales(LocaleSet locales) { + if (sSingleton == null || !sSingleton.isLocale(locales)) { + sSingleton = new ContactLocaleUtils(locales); + } + } + + public String getLabel(String name) { + return mUtils.getBucketLabel(name); + } +} diff --git a/app/src/main/java/com/android/providers/contacts/FastScrollingIndexCache.java b/app/src/main/java/com/android/providers/contacts/FastScrollingIndexCache.java new file mode 100644 index 000000000..5c7ac5004 --- /dev/null +++ b/app/src/main/java/com/android/providers/contacts/FastScrollingIndexCache.java @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.providers.contacts; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import androidx.preference.PreferenceManager; +import android.text.TextUtils; +import org.kontalk.Log; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import org.kontalk.provider.MyUsers; + +/** + * Cache for the "fast scrolling index". + * + * It's a cache from "keys" and "bundles" (see {@link #mCache} for what they are). The cache + * content is also persisted in the shared preferences, so it'll survive even if the process + * is killed or the device reboots. + * + * All the content will be invalidated when the provider detects an operation that could potentially + * change the index. + * + * There's no maximum number for cached entries. It's okay because we store keys and values in + * a compact form in both the in-memory cache and the preferences. Also the query in question + * (the query for contact lists) has relatively low number of variations. + * + * This class is thread-safe. + */ +public class FastScrollingIndexCache { + private static final String TAG = "LetterCountCache"; + + private static final String PREFERENCE_KEY = "LetterCountCache"; + + /** + * Separator used for in-memory structure. + */ + private static final String SEPARATOR = "\u0001"; + private static final Pattern SEPARATOR_PATTERN = Pattern.compile(SEPARATOR); + + /** + * Separator used for serializing values for preferences. + */ + private static final String SAVE_SEPARATOR = "\u0002"; + private static final Pattern SAVE_SEPARATOR_PATTERN = Pattern.compile(SAVE_SEPARATOR); + + private final SharedPreferences mPrefs; + + private boolean mPreferenceLoaded; + + /** + * In-memory cache. + * + * It's essentially a map from keys, which are query parameters passed to {@link #get}, to + * values, which are {@link Bundle}s that will be appended to a {@link Cursor} as extras. + * + * However, in order to save memory, we store stringified keys and values in the cache. + * Key strings are generated by {@link #buildCacheKey} and values are generated by + * {@link #buildCacheValue}. + * + * We store those strings joined with {@link #SAVE_SEPARATOR} as the separator when saving + * to shared preferences. + */ + private final Map mCache = new HashMap<>(); + + private static FastScrollingIndexCache sSingleton; + + public static FastScrollingIndexCache getInstance(Context context) { + if (sSingleton == null) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + sSingleton = new FastScrollingIndexCache(prefs); + } + return sSingleton; + } + + private FastScrollingIndexCache(SharedPreferences prefs) { + mPrefs = prefs; + } + + /** + * Append a {@link String} to a {@link StringBuilder}. + * + * Unlike the original {@link StringBuilder#append}, it does *not* append the string "null" if + * {@code value} is null. + */ + private static void appendIfNotNull(StringBuilder sb, Object value) { + if (value != null) { + sb.append(value.toString()); + } + } + + private static String buildCacheKey(Uri queryUri, String selection, String[] selectionArgs, + String sortOrder, String countExpression) { + final StringBuilder sb = new StringBuilder(); + + appendIfNotNull(sb, queryUri); + appendIfNotNull(sb, SEPARATOR); + appendIfNotNull(sb, selection); + appendIfNotNull(sb, SEPARATOR); + appendIfNotNull(sb, sortOrder); + appendIfNotNull(sb, SEPARATOR); + appendIfNotNull(sb, countExpression); + + if (selectionArgs != null) { + for (String arg : selectionArgs) { + appendIfNotNull(sb, SEPARATOR); + appendIfNotNull(sb, arg); + } + } + return sb.toString(); + } + + private static String buildCacheValue(String[] titles, int[] counts) { + final StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < titles.length; i++) { + if (i > 0) { + appendIfNotNull(sb, SEPARATOR); + } + appendIfNotNull(sb, titles[i]); + appendIfNotNull(sb, SEPARATOR); + appendIfNotNull(sb, Integer.toString(counts[i])); + } + + return sb.toString(); + } + + /** + * Creates and returns a {@link Bundle} that is appended to a {@link Cursor} as extras. + */ + public static Bundle buildExtraBundle(String[] titles, int[] counts) { + Bundle bundle = new Bundle(); + bundle.putStringArray(MyUsers.Users.EXTRA_INDEX_TITLES, titles); + bundle.putIntArray(MyUsers.Users.EXTRA_INDEX_COUNTS, counts); + return bundle; + } + + private static Bundle buildExtraBundleFromValue(String value) { + final String[] values; + if (TextUtils.isEmpty(value)) { + values = new String[0]; + } else { + values = SEPARATOR_PATTERN.split(value); + } + + if ((values.length) % 2 != 0) { + return null; // malformed + } + + try { + final int numTitles = values.length / 2; + final String[] titles = new String[numTitles]; + final int[] counts = new int[numTitles]; + + for (int i = 0; i < numTitles; i++) { + titles[i] = values[i * 2]; + counts[i] = Integer.parseInt(values[i * 2 + 1]); + } + + return buildExtraBundle(titles, counts); + } catch (RuntimeException e) { + Log.w(TAG, "Failed to parse cached value", e); + return null; // malformed + } + } + + public Bundle get(Uri queryUri, String selection, String[] selectionArgs, String sortOrder, + String countExpression) { + synchronized (mCache) { + ensureLoaded(); + final String key = buildCacheKey(queryUri, selection, selectionArgs, sortOrder, + countExpression); + final String value = mCache.get(key); + if (value == null) { + return null; + } + + final Bundle b = buildExtraBundleFromValue(value); + if (b == null) { + // Value was malformed for whatever reason. + mCache.remove(key); + save(); + } + return b; + } + } + + /** + * Put a {@link Bundle} into the cache. {@link Bundle} MUST be built with + * {@link #buildExtraBundle(String[], int[])}. + */ + public void put(Uri queryUri, String selection, String[] selectionArgs, String sortOrder, + String countExpression, Bundle bundle) { + synchronized (mCache) { + ensureLoaded(); + final String key = buildCacheKey(queryUri, selection, selectionArgs, sortOrder, + countExpression); + mCache.put(key, buildCacheValue( + bundle.getStringArray(MyUsers.Users.EXTRA_INDEX_TITLES), + bundle.getIntArray(MyUsers.Users.EXTRA_INDEX_COUNTS))); + save(); + } + } + + @SuppressLint("ApplySharedPref") + public void invalidate() { + synchronized (mCache) { + mPrefs.edit().remove(PREFERENCE_KEY).commit(); + mCache.clear(); + mPreferenceLoaded = true; + } + } + + /** + * Store the cache to the preferences. + * + * We concatenate all key+value pairs into one string and save it. + */ + private void save() { + final StringBuilder sb = new StringBuilder(); + for (String key : mCache.keySet()) { + if (sb.length() > 0) { + appendIfNotNull(sb, SAVE_SEPARATOR); + } + appendIfNotNull(sb, key); + appendIfNotNull(sb, SAVE_SEPARATOR); + appendIfNotNull(sb, mCache.get(key)); + } + mPrefs.edit().putString(PREFERENCE_KEY, sb.toString()).apply(); + } + + private void ensureLoaded() { + if (mPreferenceLoaded) return; + + // Even when we fail to load, don't retry loading again. + mPreferenceLoaded = true; + + boolean successfullyLoaded = false; + try { + final String savedValue = mPrefs.getString(PREFERENCE_KEY, null); + + if (!TextUtils.isEmpty(savedValue)) { + + final String[] keysAndValues = SAVE_SEPARATOR_PATTERN.split(savedValue); + + if ((keysAndValues.length % 2) != 0) { + return; // malformed + } + + for (int i = 1; i < keysAndValues.length; i += 2) { + final String key = keysAndValues[i - 1]; + final String value = keysAndValues[i]; + + mCache.put(key, value); + } + } + successfullyLoaded = true; + } catch (RuntimeException e) { + Log.w(TAG, "Failed to load from preferences", e); + // But don't crash apps! + } finally { + if (!successfullyLoaded) { + invalidate(); + } + } + } +} diff --git a/app/src/main/java/com/android/providers/contacts/LocaleSet.java b/app/src/main/java/com/android/providers/contacts/LocaleSet.java new file mode 100644 index 000000000..7018be575 --- /dev/null +++ b/app/src/main/java/com/android/providers/contacts/LocaleSet.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.providers.contacts; + +import java.util.Locale; + +import android.os.Build; +import androidx.core.text.ICUCompat; +import android.text.TextUtils; + +public class LocaleSet { + private static final String CHINESE_LANGUAGE = Locale.CHINESE.getLanguage().toLowerCase(); + + private static class LocaleWrapper { + private final Locale mLocale; + private final String mLanguage; + + public LocaleWrapper(Locale locale) { + mLocale = locale; + if (mLocale != null) { + mLanguage = mLocale.getLanguage().toLowerCase(); + } else { + mLanguage = null; + } + } + + public boolean hasLocale() { + return mLocale != null; + } + + public Locale getLocale() { + return mLocale; + } + + public boolean isLocale(Locale locale) { + return mLocale == null ? (locale == null) : mLocale.equals(locale); + } + + public boolean isLanguage(String language) { + return mLanguage == null ? (language == null) + : mLanguage.equalsIgnoreCase(language); + } + + public String toString() { + return mLocale != null ? toBcp47Language(mLocale) : "(null)"; + } + } + + public static LocaleSet getDefault() { + return new LocaleSet(Locale.getDefault()); + } + + public LocaleSet(Locale locale) { + this(locale, null); + } + + private final LocaleWrapper mPrimaryLocale; + private final LocaleWrapper mSecondaryLocale; + + public LocaleSet(Locale primaryLocale, Locale secondaryLocale) { + mPrimaryLocale = new LocaleWrapper(primaryLocale); + mSecondaryLocale = new LocaleWrapper( + mPrimaryLocale.equals(secondaryLocale) ? null : secondaryLocale); + } + + public boolean hasSecondaryLocale() { + return mSecondaryLocale.hasLocale(); + } + + public Locale getPrimaryLocale() { + return mPrimaryLocale.getLocale(); + } + + public Locale getSecondaryLocale() { + return mSecondaryLocale.getLocale(); + } + + public boolean isPrimaryLocale(Locale locale) { + return mPrimaryLocale.isLocale(locale); + } + + public boolean isSecondaryLocale(Locale locale) { + return mSecondaryLocale.isLocale(locale); + } + + private static final String SCRIPT_SIMPLIFIED_CHINESE = "Hans"; + + public static boolean isLocaleSimplifiedChinese(Locale locale) { + // language must match + if (locale == null || !TextUtils.equals(locale.getLanguage(), CHINESE_LANGUAGE)) { + return false; + } + + // script is optional but if present must match + if (!TextUtils.isEmpty(ICUCompat.maximizeAndGetScript(locale))) { + return ICUCompat.maximizeAndGetScript(locale).equals(SCRIPT_SIMPLIFIED_CHINESE); + } + // if no script, must match known country + return locale.equals(Locale.SIMPLIFIED_CHINESE); + } + + public boolean isPrimaryLocaleSimplifiedChinese() { + return isLocaleSimplifiedChinese(getPrimaryLocale()); + } + + public boolean isSecondaryLocaleSimplifiedChinese() { + return isLocaleSimplifiedChinese(getSecondaryLocale()); + } + + public boolean isPrimaryLanguage(String language) { + return mPrimaryLocale.isLanguage(language); + } + + @Override + public boolean equals(Object object) { + if (object == this) { + return true; + } + if (object instanceof LocaleSet) { + final LocaleSet other = (LocaleSet) object; + return other.isPrimaryLocale(mPrimaryLocale.getLocale()) + && other.isSecondaryLocale(mSecondaryLocale.getLocale()); + } + return false; + } + + @Override + public final String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(mPrimaryLocale.toString()); + if (hasSecondaryLocale()) { + builder.append(";"); + builder.append(mSecondaryLocale.toString()); + } + return builder.toString(); + } + + /** + * Modified from: + * https://github.com/apache/cordova-plugin-globalization/blob/master/src/android/Globalization.java + * + * Returns a well-formed ITEF BCP 47 language tag representing this locale string + * identifier for the client's current locale + * + * @return String: The BCP 47 language tag for the current locale + */ + private static String toBcp47Language(Locale loc) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return loc.toLanguageTag(); + } + + // we will use a dash as per BCP 47 + final char SEP = '-'; + String language = loc.getLanguage(); + String region = loc.getCountry(); + String variant = loc.getVariant(); + + // special case for Norwegian Nynorsk since "NY" cannot be a variant as per BCP 47 + // this goes before the string matching since "NY" wont pass the variant checks + if (language.equals("no") && region.equals("NO") && variant.equals("NY")) { + language = "nn"; + region = "NO"; + variant = ""; + } + + if (language.isEmpty() || !language.matches("\\p{Alpha}{2,8}")) { + language = "und"; // Follow the Locale#toLanguageTag() implementation + // which says to return "und" for Undetermined + } else if (language.equals("iw")) { + language = "he"; // correct deprecated "Hebrew" + } else if (language.equals("in")) { + language = "id"; // correct deprecated "Indonesian" + } else if (language.equals("ji")) { + language = "yi"; // correct deprecated "Yiddish" + } + + // ensure valid country code, if not well formed, it's omitted + if (!region.matches("\\p{Alpha}{2}|\\p{Digit}{3}")) { + region = ""; + } + + // variant subtags that begin with a letter must be at least 5 characters long + if (!variant.matches("\\p{Alnum}{5,8}|\\p{Digit}\\p{Alnum}{3}")) { + variant = ""; + } + + StringBuilder bcp47Tag = new StringBuilder(language); + if (!region.isEmpty()) { + bcp47Tag.append(SEP).append(region); + } + if (!variant.isEmpty()) { + bcp47Tag.append(SEP).append(variant); + } + + return bcp47Tag.toString(); + } +} diff --git a/app/src/main/java/org/kontalk/Kontalk.java b/app/src/main/java/org/kontalk/Kontalk.java index 616a0ebcc..b0dd66fb9 100644 --- a/app/src/main/java/org/kontalk/Kontalk.java +++ b/app/src/main/java/org/kontalk/Kontalk.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,22 +18,41 @@ package org.kontalk; +import java.io.IOException; +import java.io.OutputStream; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; + +import org.bouncycastle.openpgp.PGPException; + import android.accounts.Account; import android.accounts.AccountManager; import android.accounts.OnAccountsUpdateListener; -import android.app.Application; import android.content.ComponentName; import android.content.Context; +import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.PackageManager; -import android.preference.PreferenceManager; -import android.util.Log; +import android.net.ConnectivityManager; +import android.os.Build; +import android.os.Handler; +import android.text.TextUtils; + +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; +import androidx.annotation.RequiresApi; +import androidx.multidex.MultiDexApplication; import org.kontalk.authenticator.Authenticator; +import org.kontalk.authenticator.MyAccount; +import org.kontalk.client.EndpointServer; +import org.kontalk.client.ServerList; import org.kontalk.crypto.PGP; -import org.kontalk.crypto.PRNGFixes; import org.kontalk.crypto.PersonalKey; -import org.kontalk.provider.MessagesProvider; +import org.kontalk.data.Contact; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.reporting.ReportingManager; import org.kontalk.service.DownloadService; import org.kontalk.service.NetworkStateReceiver; import org.kontalk.service.ServerListUpdater; @@ -42,113 +61,154 @@ import org.kontalk.service.msgcenter.IPushService; import org.kontalk.service.msgcenter.MessageCenterService; import org.kontalk.service.msgcenter.PushServiceManager; +import org.kontalk.service.msgcenter.SecureConnectionManager; import org.kontalk.sync.SyncAdapter; import org.kontalk.ui.ComposeMessage; +import org.kontalk.ui.ConversationsFragment; import org.kontalk.ui.MessagingNotification; import org.kontalk.ui.SearchActivity; +import org.kontalk.util.CustomSimpleXmppStringprep; +import org.kontalk.util.DataUtils; import org.kontalk.util.Preferences; - -import org.spongycastle.openpgp.PGPException; - -import java.io.IOException; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; -import java.security.cert.CertificateException; +import org.kontalk.util.Showcase; +import org.kontalk.util.SystemUtils; /** * The Application. * @author Daniele Ricci */ -public class Kontalk extends Application { +public class Kontalk extends MultiDexApplication { public static final String TAG = Kontalk.class.getSimpleName(); - private SharedPreferences.OnSharedPreferenceChangeListener mPrefListener; - - private PersonalKey mDefaultKey; - /** - * Passphrase to decrypt the personal private key. - * This should be asked to the user and stored in memory - otherwise use - * a dummy password if user doesn't want to remember it (or optionally do - * not encrypt the private key). - * For the moment, this is random-generated and stored as the account - * password in Android Account Manager. + * The singleton instance. + * Used to use {@link #getApplicationContext()} to retrieve this instance, + * but apparently there are places where it doesn't return a Kontalk object. */ - private String mKeyPassphrase; + private static Kontalk sInstance; + + private MyAccount mDefaultAccount; /** * Keep-alive reference counter. * This is used throughout the activities to keep track of application * usage. Please note that this is not to be confused with - * {@link MessageCenterService#IdleConnectionHandler} reference counter, + * {@link MessageCenterService.IdleConnectionHandler} reference counter, * since this counter here is used only by {@link NetworkStateReceiver} and * a few others to check if the Message Center should be started or not.
* Call {@link #hold} to increment the counter, {@link #release} to * decrement it. */ + @SuppressWarnings("JavadocReference") private int mRefCounter; + /** Messages controller singleton instance. */ + private MessagesController mMessagesController; + + private final SharedPreferences.OnSharedPreferenceChangeListener mPrefListener = + new SharedPreferences.OnSharedPreferenceChangeListener() { + @Override + public void onSharedPreferenceChanged(SharedPreferences prefs, String key) { + // debug log + if ("pref_debug_log".equals(key)) { + Log.init(Kontalk.this); + } + // foreground service + else if ("pref_foreground_service".equals(key)) { + MessageCenterService.start(Kontalk.this); + } + // reporting opt-in + else if ("pref_reporting".equals(key)) { + if (Preferences.isReportingEnabled(Kontalk.this)) { + ReportingManager.register(Kontalk.this); + } + else { + ReportingManager.unregister(Kontalk.this); + } + } + // UI theme + else if ("pref_ui_theme".equals(key)) { + Preferences.applyTheme(Kontalk.this); + } + // actions requiring an account + else if (getDefaultAccount() != null) { + // manual server address + if ("pref_network_uri".equals(key)) { + // just restart the message center for now + Log.w(TAG, "network address changed"); + MessageCenterService.restart(Kontalk.this); + } + // hide presence flag / encrypt user data flag + else if ("pref_hide_presence".equals(key) || "pref_encrypt_userdata".equals(key)) { + MessageCenterService.updateStatus(Preferences.getStatusMessage()); + } + // changing remove prefix + else if ("pref_remove_prefix".equals(key)) { + SyncAdapter.requestSync(Kontalk.this, true); + } + } + } + }; + @Override public void onCreate() { super.onCreate(); - - // register security provider - PGP.registerProvider(); - - // apply RNG fixes - PRNGFixes.apply(); + sInstance = this; // init preferences + // This must be done before registering the reporting manager + // because we need access to the reporting opt-in preference. + // However this call will not be reported if it crashes Preferences.init(this); + // init logging system + // done after preferences because we need to access debug log preference + Log.init(this); + + // register reporting manager + if (Preferences.isReportingEnabled(this)) + ReportingManager.register(this); + + // hacks + CustomSimpleXmppStringprep.setup(); + disableHintsForUpgrade(); + + // register security provider + SecureConnectionManager.init(this); + try { + PGP.registerProvider(); + } + catch (PGP.PRNGFixException e) { + ReportingManager.logException(e); + Log.w(TAG, "Unable to install PRNG fix - ignoring", e); + } + + // init contacts + Contact.init(this, new Handler()); + // init notification system MessagingNotification.init(this); - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - mPrefListener = new SharedPreferences.OnSharedPreferenceChangeListener() { - @Override - public void onSharedPreferenceChanged(SharedPreferences prefs, String key) { - // no account - abort - if (Authenticator.getDefaultAccount(Kontalk.this) == null) - return; - - // manual server address - if ("pref_network_uri".equals(key)) { - // temporary measure for users coming from old betas - // this is triggered because manual server address is cleared - if (Authenticator.getDefaultServer(getApplicationContext()) != null) { - // just restart the message center for now - Log.w(TAG, "network address changed"); - MessageCenterService.restart(Kontalk.this); - } - } + // init the messages controller + initMessagesController(); - // hide presence flag / encrypt user data flag - else if ("pref_hide_presence".equals(key) || "pref_encrypt_userdata".equals(key)) { - MessageCenterService.updateStatus(Kontalk.this); - } + // register network state receiver manually + if (!SystemUtils.isReceivingNetworkStateChanges()) { + registerNetworkStateReceiver(); + } - // changing remove prefix - else if ("pref_remove_prefix".equals(key)) { - SyncAdapter.requestSync(getApplicationContext(), true); - } - } - }; + // init UI settings + Preferences.initUI(this); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); prefs.registerOnSharedPreferenceChangeListener(mPrefListener); // TODO listen for changes to phone numbers AccountManager am = AccountManager.get(this); - Account account = Authenticator.getDefaultAccount(am); + MyAccount account = getDefaultAccount(); if (account != null) { - if (!Authenticator.hasPersonalKey(am, account)) - xmppUpgrade(); - - // update notifications from locally unread messages - MessagingNotification.updateMessagesNotification(this, false); - // register account change listener final OnAccountsUpdateListener listener = new OnAccountsUpdateListener() { @Override @@ -170,10 +230,10 @@ public void onAccountsUpdated(Account[] accounts) { setServicesEnabled(Kontalk.this, false); // unregister from push notifications IPushService pushMgr = PushServiceManager.getInstance(Kontalk.this); - if (pushMgr.isServiceAvailable()) + if (pushMgr != null && pushMgr.isServiceAvailable()) pushMgr.unregister(PushServiceManager.getDefaultListener()); // delete all messages - MessagesProvider.deleteDatabase(Kontalk.this); + MessagesProviderClient.deleteDatabase(Kontalk.this); // invalidate cached personal key invalidatePersonalKey(); } @@ -182,61 +242,112 @@ public void onAccountsUpdated(Account[] accounts) { // register listener to handle account removal am.addOnAccountsUpdatedListener(listener, null, true); + + // TODO remove after a few release iterations + if (Authenticator.getDefaultServiceTermsURL(this) == null) { + // default service terms url + am.setUserData(account.getSystemAccount(), + Authenticator.DATA_SERVICE_TERMS_URL, + getString(R.string.help_default_KPN_service_terms_url)); + } + + // TODO remove after a few release iterations + if (Authenticator.getDefaultServerList(this) == null) { + // default server list + ServerList list = ServerListUpdater.getCurrentList(this); + am.setUserData(account.getSystemAccount(), + Authenticator.DATA_SERVER_LIST, + DataUtils.serializeProperties(list.toProperties())); + } } else { // ensure everything is cleared up - MessagesProvider.deleteDatabase(Kontalk.this); + MessagesProviderClient.deleteDatabase(Kontalk.this); } // enable/disable components setServicesEnabled(this, account != null); + + // disable backend services in offline mode (helps after installs) + if (account != null && Preferences.getOfflineMode()) + setBackendEnabled(this, false); } - private void xmppUpgrade() { - // delete custom server - Preferences.setServerURI(this, null); - // delete cached server list - ServerListUpdater.deleteCachedList(this); + /** + * @deprecated To be removed after the first release it gets into. + */ + @Deprecated + private void disableHintsForUpgrade() { + if (getDefaultAccount() != null) { + boolean showingHints = Preferences + .getShowcaseShowed(ConversationsFragment.class.getName(), "fab"); + Showcase.disableAllHints(); + if (!showingHints) { + // show off the dark theme! + Preferences.setShowcaseShowed(ConversationsFragment.class.getName(), "dark_theme", false); + } + } } - public PersonalKey getPersonalKey() throws PGPException, IOException, CertificateException { - try { - if (mDefaultKey == null) - mDefaultKey = Authenticator.loadDefaultPersonalKey(this, getCachedPassphrase()); + @Nullable + public MyAccount getDefaultAccount() { + if (mDefaultAccount == null) { + mDefaultAccount = Authenticator.getDefaultAccount(this); } - catch (NoSuchProviderException e) { - // this shouldn't happen, so crash the application - throw new RuntimeException("no such crypto provider!?", e); + return mDefaultAccount; + } + + public PersonalKey getPersonalKey() throws PGPException, IOException, CertificateException { + MyAccount account = getDefaultAccount(); + return account != null ? account.getPersonalKey() : null; + } + + /** + * Returns a random server from the cached list or the user-defined server. + * @deprecated Server should only be retrieved from account user data + */ + @Deprecated + public EndpointServer getEndpointServer() { + String customUri = Preferences.getServerURI(); + if (!TextUtils.isEmpty(customUri)) { + try { + return new EndpointServer(customUri); + } + catch (Exception e) { + // custom is not valid - take one from list + } } - return mDefaultKey; + // return server stored in the default account + MyAccount account = getDefaultAccount(); + return account != null ? account.getServer() : null; } - public void exportPersonalKey(String exportPassphrase) + public void exportPersonalKey(OutputStream out, String exportPassphrase) throws CertificateException, PGPException, IOException, - NoSuchProviderException, KeyStoreException, NoSuchAlgorithmException { + KeyStoreException, NoSuchAlgorithmException { - Authenticator.exportDefaultPersonalKey(this, getCachedPassphrase(), exportPassphrase, true); + Authenticator.exportDefaultPersonalKey(this, out, exportPassphrase, true); } /** Invalidates the cached personal key. */ public void invalidatePersonalKey() { - mDefaultKey = null; - mKeyPassphrase = null; + mDefaultAccount = null; } - private void ensureCachedPassphrase() { - if (mKeyPassphrase == null) { - AccountManager am = AccountManager.get(this); - Account account = Authenticator.getDefaultAccount(am); - // cache passphrase from account - mKeyPassphrase = am.getPassword(account); - } + private void initMessagesController() { + mMessagesController = new MessagesController(this); } - public String getCachedPassphrase() { - ensureCachedPassphrase(); - return mKeyPassphrase; + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private void registerNetworkStateReceiver() { + registerReceiver(new NetworkStateReceiver(), + new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } + + /** Returns the messages controller singleton instance. */ + public MessagesController getMessagesController() { + return mMessagesController; } /** Returns true if we are using a two-panes UI. */ @@ -245,8 +356,8 @@ public static boolean hasTwoPanesUI(Context context) { } /** Returns the singleton {@link Kontalk} instance. */ - public static Kontalk get(Context context) { - return (Kontalk) context.getApplicationContext(); + public static Kontalk get() { + return sInstance; } /** Enable/disable application components when account is added or removed. */ @@ -254,8 +365,18 @@ public static void setServicesEnabled(Context context, boolean enabled) { PackageManager pm = context.getPackageManager(); enableService(context, pm, ComposeMessage.class, enabled); enableService(context, pm, SearchActivity.class, enabled); + setBackendEnabled(context, enabled); + } + + /** Enable/disable backend application components when account is added or removed. */ + public static void setBackendEnabled(Context context, boolean enabled) { + PackageManager pm = context.getPackageManager(); enableService(context, pm, MessageCenterService.class, enabled); - enableService(context, pm, DownloadService.class, enabled); + // check for backward compatibility + if (enabled) { + // DownloadService can and should be used at any time if needed + enableService(context, pm, DownloadService.class, true); + } enableService(context, pm, UploadService.class, enabled); enableService(context, pm, SystemBootStartup.class, enabled); enableService(context, pm, NetworkStateReceiver.class, enabled); diff --git a/app/src/main/java/org/kontalk/Log.java b/app/src/main/java/org/kontalk/Log.java index bb0f4c2e7..d50dcb49c 100644 --- a/app/src/main/java/org/kontalk/Log.java +++ b/app/src/main/java/org/kontalk/Log.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,147 +18,385 @@ package org.kontalk; +import java.io.File; +import java.io.IOException; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import android.content.Context; + +import org.kontalk.util.DataUtils; +import org.kontalk.util.Preferences; +import org.kontalk.util.RotatingFileWriter; + /** - * isLoggable-aware wrapper around {@link android.util.Log}. + * isLoggable-aware wrapper around {@link android.util.Log} plus some minor + * enhancements such as formatted strings and dump to file. * @author Daniele Ricci */ public final class Log { + private static final String LOG_DIRECTORY = "debug"; + private static final String LOG_FILENAME = "kontalk-android.log"; + private static DateFormat DATE_FORMAT; // created on demand + + /** A lock to fight race conditions when switching the debug log on and off. */ + private static final Object sDebugLock = new Object(); + + private static RotatingFileWriter sLogFileWriter; + private static File sLogFile; + + public static void init(Context context) { + try { + synchronized (sDebugLock) { + if (Preferences.isDebugLogEnabled(context)) { + DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US); + File logDir = new File(context.getCacheDir(), LOG_DIRECTORY); + logDir.mkdirs(); + sLogFile = new File(logDir, LOG_FILENAME); + sLogFileWriter = new RotatingFileWriter(sLogFile); + } + else { + if (sLogFileWriter != null) { + sLogFileWriter.abort(); + sLogFileWriter = null; + } + DATE_FORMAT = null; + } + } + } + catch (IOException e) { + // TODO notify to user via Toast? + } + } + + public static File getLogFile() { + return sLogFile; + } + + public static boolean isDebug() { + return BuildConfig.DEBUG || sLogFileWriter != null; + } + + private static String buildLog(String tag, int level, String msg) { + String strLevel; + switch (level) { + case android.util.Log.VERBOSE: + strLevel = "V"; + break; + case android.util.Log.DEBUG: + strLevel = "D"; + break; + case android.util.Log.INFO: + strLevel = "I"; + break; + case android.util.Log.WARN: + strLevel = "W"; + break; + case android.util.Log.ERROR: + strLevel = "E"; + break; + default: + strLevel = "?"; + break; + } + String timestamp; + synchronized (DATE_FORMAT) { + timestamp = DATE_FORMAT.format(new Date()); + } + return timestamp + " " + strLevel + "/" + tag + ": " + msg; + } + + private static void log(String tag, int level, Throwable tr) { + synchronized (sDebugLock) { + if (sLogFileWriter != null && tr != null) { + log(tag, level, android.util.Log.getStackTraceString(tr)); + } + } + } + + private static void log(String tag, int level, String msg) { + synchronized (sDebugLock) { + if (sLogFileWriter != null) { + try { + sLogFileWriter.println(buildLog(tag, level, msg)); + sLogFileWriter.flush(); + } + catch (IOException e) { + // disable logging but keep the file + DataUtils.close(sLogFileWriter); + sLogFileWriter = null; + } + } + } + } + + private static String makeTag(String tag) { + // does not work even on Android > 7.0 + // return (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N && tag.length() > 23) ? tag.substring(0, 23) : tag; + return tag.length() > 23 ? tag.substring(0, 23) : tag; + } + /** - * Send a {@link #VERBOSE} log message. + * Send a {@link android.util.Log#VERBOSE} log message. * @param tag Used to identify the source of a log message. It usually identifies * the class or activity where the log call occurs. * @param msg The message you would like logged. */ public static int v(String tag, String msg) { - if (android.util.Log.isLoggable(tag, android.util.Log.VERBOSE)) - return android.util.Log.v(tag, msg); + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.VERBOSE)) { + log(tag, android.util.Log.VERBOSE, msg); + return android.util.Log.v(_tag, msg); + } return 0; } /** - * Send a {@link #VERBOSE} log message and log the exception. + * Send a {@link android.util.Log#VERBOSE} log message and log the exception. * @param tag Used to identify the source of a log message. It usually identifies * the class or activity where the log call occurs. * @param msg The message you would like logged. * @param tr An exception to log */ public static int v(String tag, String msg, Throwable tr) { - if (android.util.Log.isLoggable(tag, android.util.Log.VERBOSE)) - return android.util.Log.v(tag, msg, tr); + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.VERBOSE)) { + log(tag, android.util.Log.VERBOSE, msg); + log(tag, android.util.Log.VERBOSE, tr); + return android.util.Log.v(_tag, msg, tr); + } + return 0; + } + + /** + * Send a {@link android.util.Log#VERBOSE} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param fmt The message you would like logged. + * @param args Arguments for formatting the message. + */ + public static int v(String tag, String fmt, Object... args) { + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.VERBOSE)) { + String msg = String.format(fmt, args); + log(tag, android.util.Log.VERBOSE, msg); + return android.util.Log.v(_tag, msg); + } return 0; } /** - * Send a {@link #DEBUG} log message. + * Send a {@link android.util.Log#DEBUG} log message. * @param tag Used to identify the source of a log message. It usually identifies * the class or activity where the log call occurs. * @param msg The message you would like logged. */ public static int d(String tag, String msg) { - if (android.util.Log.isLoggable(tag, android.util.Log.DEBUG)) - return android.util.Log.d(tag, msg); + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.DEBUG)) { + log(tag, android.util.Log.DEBUG, msg); + return android.util.Log.d(_tag, msg); + } return 0; } /** - * Send a {@link #DEBUG} log message and log the exception. + * Send a {@link android.util.Log#DEBUG} log message and log the exception. * @param tag Used to identify the source of a log message. It usually identifies * the class or activity where the log call occurs. * @param msg The message you would like logged. * @param tr An exception to log */ public static int d(String tag, String msg, Throwable tr) { - if (android.util.Log.isLoggable(tag, android.util.Log.DEBUG)) - return android.util.Log.d(tag, msg, tr); + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.DEBUG)) { + log(tag, android.util.Log.DEBUG, msg); + log(tag, android.util.Log.DEBUG, tr); + return android.util.Log.d(_tag, msg, tr); + } return 0; } /** - * Send an {@link #INFO} log message. + * Send a {@link android.util.Log#DEBUG} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param fmt The message you would like logged. + * @param args Arguments for formatting the message. + */ + public static int d(String tag, String fmt, Object... args) { + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.DEBUG)) { + String msg = String.format(fmt, args); + log(tag, android.util.Log.DEBUG, msg); + return android.util.Log.d(_tag, msg); + } + return 0; + } + + /** + * Send an {@link android.util.Log#INFO} log message. * @param tag Used to identify the source of a log message. It usually identifies * the class or activity where the log call occurs. * @param msg The message you would like logged. */ public static int i(String tag, String msg) { - if (android.util.Log.isLoggable(tag, android.util.Log.INFO)) - return android.util.Log.i(tag, msg); + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.INFO)) { + log(tag, android.util.Log.INFO, msg); + return android.util.Log.i(_tag, msg); + } return 0; } /** - * Send a {@link #INFO} log message and log the exception. + * Send a {@link android.util.Log#INFO} log message and log the exception. * @param tag Used to identify the source of a log message. It usually identifies * the class or activity where the log call occurs. * @param msg The message you would like logged. * @param tr An exception to log */ public static int i(String tag, String msg, Throwable tr) { - if (android.util.Log.isLoggable(tag, android.util.Log.INFO)) - return android.util.Log.i(tag, msg, tr); + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.INFO)) { + log(tag, android.util.Log.INFO, msg); + log(tag, android.util.Log.INFO, tr); + return android.util.Log.i(_tag, msg, tr); + } + return 0; + } + + /** + * Send a {@link android.util.Log#INFO} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param fmt The message you would like logged. + * @param args Arguments for formatting the message. + */ + public static int i(String tag, String fmt, Object... args) { + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.INFO)) { + String msg = String.format(fmt, args); + log(tag, android.util.Log.INFO, msg); + return android.util.Log.i(_tag, msg); + } return 0; } /** - * Send a {@link #WARN} log message. + * Send a {@link android.util.Log#WARN} log message. * @param tag Used to identify the source of a log message. It usually identifies * the class or activity where the log call occurs. * @param msg The message you would like logged. */ public static int w(String tag, String msg) { - if (android.util.Log.isLoggable(tag, android.util.Log.WARN)) - return android.util.Log.w(tag, msg); + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.WARN)) { + log(tag, android.util.Log.WARN, msg); + return android.util.Log.w(_tag, msg); + } return 0; } /** - * Send a {@link #WARN} log message and log the exception. + * Send a {@link android.util.Log#WARN} log message and log the exception. * @param tag Used to identify the source of a log message. It usually identifies * the class or activity where the log call occurs. * @param msg The message you would like logged. * @param tr An exception to log */ public static int w(String tag, String msg, Throwable tr) { - if (android.util.Log.isLoggable(tag, android.util.Log.WARN)) - return android.util.Log.w(tag, msg, tr); + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.WARN)) { + log(tag, android.util.Log.WARN, msg); + log(tag, android.util.Log.WARN, tr); + return android.util.Log.w(_tag, msg, tr); + } return 0; } /** - * Send a {@link #WARN} log message and log the exception. + * Send a {@link android.util.Log#WARN} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param fmt The message you would like logged. + * @param args Arguments for formatting the message. + */ + public static int w(String tag, String fmt, Object... args) { + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.WARN)) { + String msg = String.format(fmt, args); + log(tag, android.util.Log.WARN, msg); + return android.util.Log.w(_tag, msg); + } + return 0; + } + + /** + * Send a {@link android.util.Log#WARN} log message and log the exception. * @param tag Used to identify the source of a log message. It usually identifies * the class or activity where the log call occurs. * @param tr An exception to log */ public static int w(String tag, Throwable tr) { - if (android.util.Log.isLoggable(tag, android.util.Log.WARN)) - return android.util.Log.w(tag, tr); + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.WARN)) { + log(tag, android.util.Log.WARN, tr); + return android.util.Log.w(_tag, tr); + } return 0; } /** - * Send an {@link #ERROR} log message. + * Send an {@link android.util.Log#ERROR} log message. * @param tag Used to identify the source of a log message. It usually identifies * the class or activity where the log call occurs. * @param msg The message you would like logged. */ public static int e(String tag, String msg) { - if (android.util.Log.isLoggable(tag, android.util.Log.ERROR)) - return android.util.Log.e(tag, msg); + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.ERROR)) { + log(tag, android.util.Log.ERROR, msg); + return android.util.Log.e(_tag, msg); + } return 0; } /** - * Send a {@link #ERROR} log message and log the exception. + * Send a {@link android.util.Log#ERROR} log message and log the exception. * @param tag Used to identify the source of a log message. It usually identifies * the class or activity where the log call occurs. * @param msg The message you would like logged. * @param tr An exception to log */ public static int e(String tag, String msg, Throwable tr) { - if (android.util.Log.isLoggable(tag, android.util.Log.ERROR)) - return android.util.Log.e(tag, msg, tr); + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.ERROR)) { + log(tag, android.util.Log.ERROR, msg); + log(tag, android.util.Log.ERROR, tr); + return android.util.Log.e(_tag, msg, tr); + } + return 0; + } + + /** + * Send a {@link android.util.Log#ERROR} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param fmt The message you would like logged. + * @param args Arguments for formatting the message. + */ + public static int e(String tag, String fmt, Object... args) { + String _tag = makeTag(tag); + if (isDebug() || android.util.Log.isLoggable(_tag, android.util.Log.ERROR)) { + String msg = String.format(fmt, args); + log(tag, android.util.Log.ERROR, msg); + return android.util.Log.e(_tag, msg); + } return 0; } diff --git a/app/src/main/java/org/kontalk/MessagesController.java b/app/src/main/java/org/kontalk/MessagesController.java new file mode 100644 index 000000000..acfe781f8 --- /dev/null +++ b/app/src/main/java/org/kontalk/MessagesController.java @@ -0,0 +1,884 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk; + +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; + +import org.greenrobot.eventbus.Subscribe; +import org.jxmpp.jid.BareJid; +import org.jxmpp.jid.impl.JidCreate; + +import android.content.BroadcastReceiver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.database.Cursor; +import android.database.sqlite.SQLiteConstraintException; +import android.database.sqlite.SQLiteDiskIOException; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; +import androidx.annotation.WorkerThread; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.widget.Toast; + +import org.kontalk.crypto.Coder; +import org.kontalk.data.Conversation; +import org.kontalk.message.AttachmentComponent; +import org.kontalk.message.AudioComponent; +import org.kontalk.message.CompositeMessage; +import org.kontalk.message.GroupCommandComponent; +import org.kontalk.message.GroupComponent; +import org.kontalk.message.ImageComponent; +import org.kontalk.message.InReplyToComponent; +import org.kontalk.message.LocationComponent; +import org.kontalk.message.MessageComponent; +import org.kontalk.message.VCardComponent; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.KontalkGroupCommands; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.provider.MyMessages; +import org.kontalk.provider.MyUsers; +import org.kontalk.provider.UsersProvider; +import org.kontalk.service.DownloadService; +import org.kontalk.service.MediaService; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.service.msgcenter.PrivacyCommand; +import org.kontalk.service.msgcenter.event.ConnectedEvent; +import org.kontalk.service.msgcenter.event.DisconnectedEvent; +import org.kontalk.service.msgcenter.event.GroupCreatedEvent; +import org.kontalk.service.msgcenter.event.RosterLoadedEvent; +import org.kontalk.service.msgcenter.event.SendDeliveryReceiptRequest; +import org.kontalk.service.msgcenter.event.SendMessageRequest; +import org.kontalk.service.msgcenter.event.SetUserPrivacyRequest; +import org.kontalk.service.msgcenter.event.UploadAttachmentRequest; +import org.kontalk.service.msgcenter.event.UploadServiceFoundEvent; +import org.kontalk.service.msgcenter.event.UserSubscribedEvent; +import org.kontalk.service.msgcenter.group.KontalkGroupController; +import org.kontalk.ui.MessagingNotification; +import org.kontalk.util.MediaStorage; +import org.kontalk.util.MessageUtils; +import org.kontalk.util.Preferences; + + +/** + * Utility class to handle message transmission transactions. + * + * @author Daniele Ricci + */ +public class MessagesController { + private static final String TAG = MessagesController.class.getSimpleName(); + + private static final String[] RESEND_PROJECTION = new String[] { + MyMessages.Messages._ID, + MyMessages.Messages.THREAD_ID, + MyMessages.Messages.PEER, + MyMessages.Messages.BODY_MIME, + MyMessages.Messages.ATTACHMENT_LOCAL_URI, + MyMessages.Messages.ATTACHMENT_FETCH_URL, + MyMessages.Groups.GROUP_JID, + }; + + private final Context mContext; + private final MessageQueueThread mWorker; + + /** True if the message center reported being connected. Only used for a few checks */ + private boolean mConnected; + + /** True if the message center reported upload services were found. */ + private boolean mUploadServiceFound; + + MessagesController(Context context) { + mContext = context; + new MessageCenterListener(context); + + mWorker = new MessageQueueThread(); + mWorker.start(); + + MessageCenterService.bus().register(this); + } + + public Future sendTextMessage(final Conversation conv, final String text, final long inReplyTo) { + return sendTextMessage(conv, text, inReplyTo, false); + } + + /** For use by direct reply. Wakes up the message center if necessary if you set wakeService to true. */ + public Future sendTextMessage(final Conversation conv, final String text, final long inReplyTo, final boolean wakeService) { + FutureTask result = new FutureTask<>(new Callable() { + @Override + public Uri call() throws Exception { + boolean encrypted = MessageUtils.sendEncrypted(mContext, conv.isEncryptionEnabled()); + + String msgId = MessageUtils.messageId(); + String userId = conv.isGroupChat() ? conv.getGroupJid() : conv.getRecipient(); + + // save to local storage + Uri newMsg = MessagesProviderClient.newOutgoingMessage(mContext, + msgId, userId, text, encrypted, inReplyTo); + if (newMsg != null) { + // wake the message center if necessary + if (wakeService) + MessageCenterService.start(mContext); + + // send message! + MessageCenterService.bus() + .post(new SendMessageRequest(ContentUris.parseId(newMsg))); + + return newMsg; + } + else { + throw new SQLiteDiskIOException(); + } + } + }); + mWorker.postAction(result); + return result; + } + + // TODO go through the message queue and return a promise + public Uri sendLocationMessage(Conversation conv, String text, double lat, double lon, + String geoText, String geoStreet) { + boolean encrypted = MessageUtils.sendEncrypted(mContext, conv.isEncryptionEnabled()); + + String msgId = MessageUtils.messageId(); + String userId = conv.isGroupChat() ? conv.getGroupJid() : conv.getRecipient(); + + // save to local storage + Uri newMsg = MessagesProviderClient.newOutgoingMessage(mContext, + msgId, userId, text, lat, lon, geoText, geoStreet, encrypted); + if (newMsg != null) { + // send message! + MessageCenterService.bus() + .post(new SendMessageRequest(ContentUris.parseId(newMsg))); + return newMsg; + } + else { + throw new SQLiteDiskIOException(); + } + } + + // TODO go through the message queue and return a promise + public Uri sendBinaryMessage(Conversation conv, Uri uri, String mime, boolean media, + Class> klass) throws SQLiteDiskIOException { + String msgId = MessageCenterService.messageId(); + + boolean encrypted = MessageUtils.sendEncrypted(mContext, conv.isEncryptionEnabled()); + int compress = 0; + if (klass == ImageComponent.class) { + compress = Preferences.getImageCompression(mContext); + } + + // save to database + String userId = conv.isGroupChat() ? conv.getGroupJid() : conv.getRecipient(); + Uri newMsg = MessagesProviderClient.newOutgoingMessage(mContext, msgId, + userId, mime, uri, 0, compress, null, encrypted); + + if (newMsg != null) { + // prepare message and send (thumbnail, compression -> send) + MediaService.prepareMessage(mContext, msgId, ContentUris.parseId(newMsg), uri, mime, media, compress); + return newMsg; + } + else { + throw new SQLiteDiskIOException(); + } + } + + public void retryMessage(final long id, final boolean chatEncryptionEnabled) { + mWorker.postAction(new Runnable() { + @Override + public void run() { + boolean encrypted = Preferences.getEncryptionEnabled(mContext) && chatEncryptionEnabled; + Uri uri = ContentUris.withAppendedId(MyMessages.Messages.CONTENT_URI, id); + MessagesProviderClient.retryMessage(mContext, uri, encrypted); + // TODO we should retry only the requested message(s) + resendPendingMessages(false, false); + } + }); + } + + public void retryMessagesTo(final String to) { + mWorker.postAction(new Runnable() { + @Override + public void run() { + MessagesProviderClient.retryMessagesTo(mContext, to); + // TODO we should retry only message to the request user + resendPendingMessages(false, false); + } + }); + } + + public void retryAllMessages() { + mWorker.postAction(new Runnable() { + @Override + public void run() { + MessagesProviderClient.retryAllMessages(mContext); + resendPendingMessages(false, false); + } + }); + } + + /** Creates a group chat. */ + public Future createGroup(final String[] users, final String groupJid, final String title) { + FutureTask result = new FutureTask<>(new Callable() { + @Override + public Uri call() throws Exception { + long groupThreadId = Conversation.initGroupChat(mContext, + groupJid, title, users, ""); + + // store create group command to outbox + // NOTE: group chats can currently only be created with chat encryption enabled + boolean encrypted = Preferences.getEncryptionEnabled(mContext); + String msgId = MessageCenterService.messageId(); + Uri cmdMsg = KontalkGroupCommands.createGroup(mContext, + groupThreadId, groupJid, users, msgId, encrypted); + + if (cmdMsg != null) { + // send create group command now + MessageCenterService.bus() + .post(new SendMessageRequest(ContentUris.parseId(cmdMsg))); + return cmdMsg; + } + else { + throw new SQLiteDiskIOException(); + } + + } + }); + mWorker.postAction(result); + return result; + } + + /** + * Set the trust level for the given key and, if the trust level is high + * enough, send pending messages to the given user. + * + * @return true if the trust level is high enough to retry messages + */ + public boolean setTrustLevelAndRetryMessages(String jid, String fingerprint, int trustLevel) { + if (fingerprint == null) + throw new NullPointerException("fingerprint"); + + Keyring.setTrustLevel(mContext, jid, fingerprint, trustLevel); + if (trustLevel >= MyUsers.Keys.TRUST_IGNORED) { + retryMessagesTo(jid); + return true; + } + return false; + } + + private void addMembersInternal(GroupCommandComponent group, String[] members) { + ContentValues membersValues = new ContentValues(); + + for (String member : members) { + // do not add ourselves... + if (Kontalk.get().getDefaultAccount().isSelfJID(member)) { + // ...but mark our membership + MessagesProviderClient.setGroupMembership(mContext, + group.getContent().getJid().toString(), MyMessages.Groups.MEMBERSHIP_MEMBER); + continue; + } + + // add member to group + membersValues.put(MyMessages.Groups.PEER, member); + mContext.getContentResolver().insert(MyMessages.Groups + .getMembersUri(group.getContent().getJid()), membersValues); + } + } + + /** + * Process an incoming message. + */ + public Uri incoming(CompositeMessage msg) { + final String sender = msg.getSender(true); + + // save to local storage + ContentValues values = new ContentValues(); + values.put(MyMessages.Messages.MESSAGE_ID, msg.getId()); + values.put(MyMessages.Messages.PEER, sender); + + MessageUtils.fillContentValues(values, msg); + + GroupCommandComponent group = msg.getComponent(GroupCommandComponent.class); + // notify for 1-to-1 messages and group creation and part group commands + boolean notify = (group == null || group.isCreateCommand() || group.isPartCommand()); + + values.put(MyMessages.Messages.STATUS, msg.getStatus()); + // group commands don't get notifications + values.put(MyMessages.Messages.UNREAD, notify); + values.put(MyMessages.Messages.NEW, notify); + values.put(MyMessages.Messages.DIRECTION, MyMessages.Messages.DIRECTION_IN); + values.put(MyMessages.Messages.TIMESTAMP, System.currentTimeMillis()); + + LocationComponent loc = msg.getComponent(LocationComponent.class); + if (loc != null) { + values.put(MyMessages.Messages.BODY_MIME, LocationComponent.MIME_TYPE); + values.put(MyMessages.Messages.GEO_LATITUDE, loc.getLatitude()); + values.put(MyMessages.Messages.GEO_LONGITUDE, loc.getLongitude()); + if (!TextUtils.isEmpty(loc.getText())) + values.put(MyMessages.Messages.GEO_TEXT, loc.getText()); + if (!TextUtils.isEmpty(loc.getStreet())) + values.put(MyMessages.Messages.GEO_STREET, loc.getStreet()); + } + + InReplyToComponent inReplyTo = msg.getComponent(InReplyToComponent.class); + if (inReplyTo != null) { + values.put(MyMessages.Messages.IN_REPLY_TO, inReplyTo.getContent().getId()); + } + + GroupComponent groupInfo = msg.getComponent(GroupComponent.class); + if (groupInfo != null) { + values.put(MyMessages.Groups.GROUP_JID, groupInfo.getContent().getJid().toString()); + values.put(MyMessages.Groups.GROUP_TYPE, KontalkGroupController.GROUP_TYPE); + + String groupSubject = groupInfo.getContent().getSubject(); + if (groupSubject != null) + values.put(MyMessages.Groups.SUBJECT, groupSubject); + } + + if (group != null) { + // the following operations will work because we are operating with + // groups and group_members table directly (that is, no foreign keys) + + String[] added = null, removed = null, existing = null; + + // group creation + if (group.isCreateCommand()) { + added = group.getCreateMembers(); + } + + // add/remove users + else if (group.isAddOrRemoveCommand()) { + added = group.getAddedMembers(); + removed = group.getRemovedMembers(); + existing = group.getExistingMembers(); + } + + if (added != null) { + ContentValues membersValues = new ContentValues(); + if (added.length > 0) { + addMembersInternal(group, added); + } + + if (existing != null && existing.length > 0) { + addMembersInternal(group, existing); + } + + // add owner as member (since the owner is adding us) + membersValues.put(MyMessages.Groups.PEER, group.getContent().getOwner().toString()); + mContext.getContentResolver().insert(MyMessages.Groups + .getMembersUri(group.getContent().getJid()), membersValues); + } + + if (removed != null) { + // remove members from group + MessagesProviderClient.removeGroupMembers(mContext, group.getContent().getJid().toString(), + removed, false); + // set our membership to parted if we were removed from the group + for (String removedJid : removed) { + if (Kontalk.get().getDefaultAccount().isSelfJID(removedJid)) { + MessagesProviderClient.setGroupMembership(mContext, + group.getContent().getJid().toString(), MyMessages.Groups.MEMBERSHIP_KICKED); + break; + } + } + } + + // set subject + if (group.isSetSubjectCommand()) { + ContentValues groupValues = new ContentValues(); + groupValues.put(MyMessages.Groups.SUBJECT, group.getContent().getSubject()); + mContext.getContentResolver().update(MyMessages.Groups + .getUri(group.getContent().getJid()), groupValues, null, null); + } + + // a user is leaving the group + else if (group.isPartCommand()) { + String partMember = group.getFrom(); + // remove member from group + mContext.getContentResolver().delete(MyMessages.Groups.getMembersUri(group.getContent().getJid()) + .buildUpon().appendEncodedPath(partMember).build(), + null, null); + } + } + + // will be null if something went wrong + Uri msgUri = MessagesProviderClient.newIncomingMessage(mContext, values); + + if (groupInfo == null) { + // mark sender as registered in the users database + final Context context = mContext.getApplicationContext(); + new Thread(new Runnable() { + public void run() { + try { + UsersProvider.markRegistered(context, sender); + } catch (SQLiteConstraintException e) { + // this might happen during an online/offline switch + } + } + }).start(); + } + + // fire notification only if message was actually inserted to database + // and the conversation is not open already + String paused = groupInfo != null ? groupInfo.getContent().getJid().toString() : sender; + if (notify && msgUri != null) { + if (!MessagingNotification.isPaused(paused)) { + // update notifications (delayed) + MessagingNotification.delayedUpdateMessagesNotification(mContext.getApplicationContext(), true); + } + else { + // play in-conversation sound + MediaStorage.playNotificationSound(mContext.getApplicationContext(), R.raw.sound_incoming); + } + } + + // check if we need to autodownload + @SuppressWarnings("unchecked") + Class[] tryComponents = new Class[]{ + ImageComponent.class, + VCardComponent.class, + AudioComponent.class, + }; + + for (Class klass : tryComponents) { + AttachmentComponent att = msg.getComponent(klass); + if (att != null && att.getFetchUrl() != null && + Preferences.canAutodownloadMedia(mContext, att.getLength())) { + long databaseId = ContentUris.parseId(msgUri); + String conversation = groupInfo != null ? + groupInfo.getContent().getJid().toString() : msg.getSender(); + DownloadService.start(mContext, databaseId, sender, + att.getMime(), msg.getTimestamp(), + att.getSecurityFlags() != Coder.SECURITY_CLEARTEXT, + att.getFetchUrl(), conversation, false); + + // only one attachment is supported + break; + } + } + + return msgUri; + } + + private final class MessageCenterListener extends BroadcastReceiver { + + MessageCenterListener(Context context) { + IntentFilter filter = new IntentFilter(); + filter.addAction(MediaService.ACTION_MEDIA_READY); + filter.addAction(MediaService.ACTION_MEDIA_FAILED); + LocalBroadcastManager.getInstance(context) + .registerReceiver(this, filter); + } + + @Override + public void onReceive(Context context, final Intent intent) { + String action = intent.getAction(); + switch (action != null ? action : "") { + case MediaService.ACTION_MEDIA_READY: { + final long messageId = intent.getLongExtra("org.kontalk.message.msgId", 0); + mWorker.postAction(new Runnable() { + @Override + public void run() { + readyMedia(messageId); + } + }); + break; + } + case MediaService.ACTION_MEDIA_FAILED: { + Toast.makeText(context, + R.string.err_store_message_failed, + Toast.LENGTH_LONG).show(); + break; + } + } + } + } + + private static final class MessageQueueThread extends HandlerThread { + private Handler mHandler; + + MessageQueueThread() { + super(MessagesController.class.getSimpleName()); + } + + public void postAction(Runnable action) { + mHandler.post(action); + } + + @Override + public synchronized void start() { + super.start(); + // getLooper will block until ready + mHandler = new Handler(getLooper()); + } + } + + @Subscribe + public void onConnected(ConnectedEvent event) { + if (!mConnected) { + mConnected = true; + mUploadServiceFound = false; + } + } + + @Subscribe + public void onDisconnected(DisconnectedEvent event) { + mConnected = false; + mUploadServiceFound = false; + } + + @Subscribe + public void onRosterLoaded(RosterLoadedEvent event) { + mWorker.postAction(new Runnable() { + @Override + public void run() { + readyForMessages(); + } + }); + } + + @Subscribe + public void onGroupCreated(GroupCreatedEvent event) { + mWorker.postAction(new Runnable() { + @Override + public void run() { + groupCreated(); + } + }); + } + + @Subscribe + public void onUserSubscribed(UserSubscribedEvent event) { + final String jid = event.jid.asBareJid().toString(); + mWorker.postAction(new Runnable() { + @Override + public void run() { + subscribed(jid); + } + }); + } + + @Subscribe + public void onUploadServiceFound(UploadServiceFoundEvent event) { + mWorker.postAction(new Runnable() { + @Override + public void run() { + uploadServiceFound(); + } + }); + } + + @WorkerThread + void readyForMessages() { + // send pending subscription replies + sendPendingSubscriptionReplies(); + // resend failed and pending messages + resendPendingMessages(false, false); + // resend failed and pending received receipts + resendPendingReceipts(); + } + + @WorkerThread + void subscribed(String from) { + resendPending(false, true, from); + } + + @WorkerThread + void uploadServiceFound() { + mUploadServiceFound = true; + resendPendingMessages(true, false); + } + + @WorkerThread + void groupCreated() { + resendPending(false, false, null); + } + + @WorkerThread + void readyMedia(long databaseId) { + sendReadyMedia(databaseId); + } + + private void sendPendingSubscriptionReplies() { + Cursor c = mContext.getContentResolver().query(MyMessages.Threads.CONTENT_URI, + new String[] { + MyMessages.Threads.PEER, + MyMessages.Threads.REQUEST_STATUS, + }, + MyMessages.Threads.REQUEST_STATUS + "=" + MyMessages.Threads.REQUEST_REPLY_PENDING_ACCEPT + " OR " + + MyMessages.Threads.REQUEST_STATUS + "=" + MyMessages.Threads.REQUEST_REPLY_PENDING_BLOCK, + null, MyMessages.Threads._ID); + + while (c.moveToNext()) { + String to = c.getString(0); + int reqStatus = c.getInt(1); + + PrivacyCommand action; + + switch (reqStatus) { + case MyMessages.Threads.REQUEST_REPLY_PENDING_ACCEPT: + action = PrivacyCommand.ACCEPT; + break; + + case MyMessages.Threads.REQUEST_REPLY_PENDING_BLOCK: + action = PrivacyCommand.BLOCK; + break; + + case MyMessages.Threads.REQUEST_REPLY_PENDING_UNBLOCK: + action = PrivacyCommand.UNBLOCK; + break; + + default: + // skip this one + continue; + } + + BareJid jid = JidCreate.bareFromOrThrowUnchecked(to); + MessageCenterService.bus() + .post(new SetUserPrivacyRequest(jid, action)); + } + + c.close(); + } + + private void sendReadyMedia(long databaseId) { + Cursor c = mContext.getContentResolver().query(ContentUris + .withAppendedId(MyMessages.Messages.CONTENT_URI, databaseId), RESEND_PROJECTION, null, null, null); + + sendMessages(c); + + c.close(); + } + + + private void resendPending(final boolean retryingMedia, final boolean forcePending, final String to) { + resendPendingMessages(retryingMedia, forcePending, to); + resendPendingReceipts(); + } + + private void resendPendingMessages(boolean retryingMedia, boolean forcePending) { + resendPendingMessages(retryingMedia, forcePending, null); + } + + /** + * Queries for pending messages and send them through. + * + * @param retryingMedia if true, we are retrying to send media messages after + * receiving upload info (non-media messages will be filtered out) + * @param forcePending true to include pending user review messages + * @param to filter by recipient (optional) + */ + private void resendPendingMessages(boolean retryingMedia, boolean forcePending, String to) { + String[] filterArgs = null; + + StringBuilder filter = new StringBuilder() + .append(MyMessages.Messages.DIRECTION) + .append('=') + .append(MyMessages.Messages.DIRECTION_OUT) + .append(" AND ") + .append(MyMessages.Messages.STATUS) + .append("<>") + .append(MyMessages.Messages.STATUS_SENT) + .append(" AND ") + .append(MyMessages.Messages.STATUS) + .append("<>") + .append(MyMessages.Messages.STATUS_RECEIVED) + .append(" AND ") + .append(MyMessages.Messages.STATUS) + .append("<>") + .append(MyMessages.Messages.STATUS_NOTACCEPTED) + .append(" AND ") + .append(MyMessages.Messages.STATUS) + .append("<>") + .append(MyMessages.Messages.STATUS_NOTDELIVERED) + .append(" AND ") + .append(MyMessages.Messages.STATUS) + .append("<>") + .append(MyMessages.Messages.STATUS_QUEUED); + + + // filter out pending messages + if (!forcePending) filter + .append(" AND ") + .append(MyMessages.Messages.STATUS) + .append("<>") + .append(MyMessages.Messages.STATUS_PENDING); + + // include only messages that need to be uploaded + if (retryingMedia) { + filter.append(" AND ") + // null fetch URL: message hasn't been uploaded yet + .append(MyMessages.Messages.ATTACHMENT_FETCH_URL) + .append(" IS NULL AND ") + // non-null local URI: media message + .append(MyMessages.Messages.ATTACHMENT_LOCAL_URI) + .append(" IS NOT NULL"); + } + // include only messages that don't require upload + else { + filter.append(" AND (") + // non-media messages or... + .append(MyMessages.Messages.ATTACHMENT_LOCAL_URI) + .append(" IS NULL OR ") + // ...already uploaded messages + .append(MyMessages.Messages.ATTACHMENT_FETCH_URL) + .append(" IS NOT NULL)"); + } + + if (to != null) { + filter + .append(" AND (") + .append(MyMessages.Messages.PEER) + .append("=? OR EXISTS (SELECT 1 FROM group_members WHERE ") + .append(MyMessages.Groups.GROUP_JID) + .append("=") + .append(MyMessages.Messages.PEER) + .append(" AND ") + .append(MyMessages.Groups.PEER) + .append("=?))"); + filterArgs = new String[]{to, to}; + } + + Cursor c = mContext.getContentResolver().query(MyMessages.Messages.CONTENT_URI, + RESEND_PROJECTION, filter.toString(), filterArgs, MyMessages.Messages._ID); + + sendMessages(c); + + c.close(); + } + + /** A somewhat smart send message procedure. Consumes all rows in cursor. */ + private void sendMessages(Cursor c) { + // this set will cache thread IDs within this cursor with + // pending group commands (i.e. just processed group commands) + // This will be looked up when sending consecutive messages in the group + // and stop them + Set pendingGroupCommandThreads = new HashSet<>(); + + // a list of messages to be deleted + List messagesToDelete = new LinkedList<>(); + + while (c.moveToNext()) { + // TODO constants for column indexes + long id = c.getLong(0); + long threadId = c.getLong(1); + String peer = c.getString(2); + String bodyMime = c.getString(3); + String attFileUri = c.getString(4); + String attFetchUrl = c.getString(5); + String groupJid = c.getString(6); + + if (pendingGroupCommandThreads.contains(threadId)) { + Log.v(TAG, "group message for pending group command - delaying"); + continue; + } + + final boolean isGroupCommand = GroupCommandComponent.supportsMimeType(bodyMime); + if (isGroupCommand) { + if (groupJid == null) { + // orphan group command waiting to be sent + groupJid = peer; + } + else { + // cache the thread -- it will block future messages until + // this command is received by the server + pendingGroupCommandThreads.add(threadId); + } + } + + if (groupJid != null) { + /* + * Huge potential issue here. Selecting all members, regardless of pending flags, + * might e.g. deliver messages to removed users if there is a content message right + * after a remove command. + * However, selecting members with zero flags will make a remove command to be sent + * only to existing members and not to the ones being removed. + */ + String[] groupMembers = MessagesProviderClient.getGroupMembers(mContext, groupJid, -1); + if (groupMembers.length == 0) { + if (threadId == MyMessages.Messages.NO_THREAD) { + // no group member left and ghost thread + // Finally delete the message + Log.d(TAG, "message " + id + " for ghost group with no members - marking for deletion"); + messagesToDelete.add(id); + } + else { + // no group member left - skip message + // This might be a pending message that was queued before we realized there were no members left + // since the group might get populated again, we just skip the message but keep it + Log.d(TAG, "no members in group - skipping message " + id); + } + continue; + } + } + + // media message encountered and no upload service available - delay message + if (attFileUri != null && attFetchUrl == null && !mUploadServiceFound) { + Log.w(TAG, "no upload info received yet, delaying media message"); + continue; + } + + Log.v(TAG, "resending pending message " + id); + + Object event; + // non-uploaded media messages must follow another path + if (attFileUri != null && attFetchUrl == null) { + event = new UploadAttachmentRequest(id); + } + else { + event = new SendMessageRequest(id); + } + + MessageCenterService.bus().post(event); + } + + // very inefficient, but it rarely happens + for (long id : messagesToDelete) { + MessagesProviderClient.deleteMessage(mContext, id); + } + } + + private void resendPendingReceipts() { + Cursor c = mContext.getContentResolver().query(MyMessages.Messages.CONTENT_URI, + new String[]{ MyMessages.Messages._ID }, + MyMessages.Messages.DIRECTION + " = " + MyMessages.Messages.DIRECTION_IN + " AND " + + MyMessages.Messages.STATUS + " = " + MyMessages.Messages.STATUS_INCOMING, + null, MyMessages.Messages._ID); + + while (c.moveToNext()) { + long id = c.getLong(0); + + Log.v(TAG, "resending pending receipt for message " + id); + MessageCenterService.bus() + .post(new SendDeliveryReceiptRequest(id)); + } + + c.close(); + } + +} diff --git a/app/src/main/java/org/kontalk/authenticator/AccountAuthenticatorService.java b/app/src/main/java/org/kontalk/authenticator/AccountAuthenticatorService.java index 556953db1..66ac1aa48 100644 --- a/app/src/main/java/org/kontalk/authenticator/AccountAuthenticatorService.java +++ b/app/src/main/java/org/kontalk/authenticator/AccountAuthenticatorService.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/authenticator/Authenticator.java b/app/src/main/java/org/kontalk/authenticator/Authenticator.java index e0114b2a5..e04dd9afb 100644 --- a/app/src/main/java/org/kontalk/authenticator/Authenticator.java +++ b/app/src/main/java/org/kontalk/authenticator/Authenticator.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,68 +18,47 @@ package org.kontalk.authenticator; -import java.io.ByteArrayOutputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; -import java.security.PrivateKey; import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -import org.spongycastle.bcpg.ArmoredOutputStream; -import org.spongycastle.bcpg.HashAlgorithmTags; -import org.spongycastle.openpgp.PGPEncryptedData; -import org.spongycastle.openpgp.PGPException; -import org.spongycastle.openpgp.PGPSecretKeyRing; -import org.spongycastle.openpgp.operator.KeyFingerPrintCalculator; -import org.spongycastle.openpgp.operator.PBESecretKeyDecryptor; -import org.spongycastle.openpgp.operator.PBESecretKeyEncryptor; -import org.spongycastle.openpgp.operator.PGPDigestCalculator; -import org.spongycastle.openpgp.operator.PGPDigestCalculatorProvider; -import org.spongycastle.openpgp.operator.bc.BcKeyFingerprintCalculator; -import org.spongycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder; -import org.spongycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; -import org.spongycastle.openpgp.operator.jcajce.JcePBESecretKeyEncryptorBuilder; -import org.spongycastle.util.io.pem.PemObject; -import org.spongycastle.util.io.pem.PemWriter; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +import org.bouncycastle.openpgp.PGPException; import android.accounts.AbstractAccountAuthenticator; import android.accounts.Account; import android.accounts.AccountAuthenticatorResponse; import android.accounts.AccountManager; import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; +import android.accounts.AuthenticatorException; import android.accounts.NetworkErrorException; +import android.accounts.OperationCanceledException; import android.content.Context; import android.content.Intent; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import androidx.annotation.Nullable; import android.util.Base64; import android.widget.Toast; import org.kontalk.BuildConfig; import org.kontalk.R; import org.kontalk.client.EndpointServer; +import org.kontalk.client.ServerList; import org.kontalk.crypto.PGP; import org.kontalk.crypto.PersonalKey; -import org.kontalk.crypto.PersonalKeyImporter; -import org.kontalk.crypto.X509Bridge; +import org.kontalk.crypto.PersonalKeyExporter; +import org.kontalk.provider.Keyring; +import org.kontalk.ui.MainActivity; import org.kontalk.ui.NumberValidation; -import org.kontalk.util.MessageUtils; -import org.kontalk.util.XMPPUtils; - -import static org.kontalk.crypto.PersonalKeyImporter.BRIDGE_CERTPACK_FILENAME; -import static org.kontalk.crypto.PersonalKeyImporter.BRIDGE_CERT_FILENAME; -import static org.kontalk.crypto.PersonalKeyImporter.BRIDGE_KEY_FILENAME; -import static org.kontalk.crypto.PersonalKeyImporter.PRIVATE_KEY_FILENAME; -import static org.kontalk.crypto.PersonalKeyImporter.PUBLIC_KEY_FILENAME; +import org.kontalk.util.DataUtils; /** @@ -90,19 +69,17 @@ public class Authenticator extends AbstractAccountAuthenticator { public static final String ACCOUNT_TYPE = BuildConfig.ACCOUNT_TYPE; - public static final String ACCOUNT_TYPE_LEGACY = "org.kontalk.legacy.account"; public static final String DATA_PRIVATEKEY = "org.kontalk.key.private"; public static final String DATA_PUBLICKEY = "org.kontalk.key.public"; public static final String DATA_BRIDGECERT = "org.kontalk.key.bridgeCert"; public static final String DATA_NAME = "org.kontalk.key.name"; public static final String DATA_USER_PASSPHRASE = "org.kontalk.userPassphrase"; public static final String DATA_SERVER_URI = "org.kontalk.server"; + public static final String DATA_SERVER_LIST = "org.kontalk.serverList"; + public static final String DATA_SERVICE_TERMS_URL = "org.kontalk.serviceTermsURL"; - /** @deprecated This was obviously deprecated from the beginning. */ - @Deprecated - public static final String DATA_AUTHTOKEN = "org.kontalk.token"; - - private final Context mContext; + @SuppressWarnings("WeakerAccess") + final Context mContext; private final Handler mHandler; public Authenticator(Context context) { @@ -111,165 +88,146 @@ public Authenticator(Context context) { mHandler = new Handler(Looper.getMainLooper()); } - public static Account getDefaultAccount(Context ctx) { - return getDefaultAccount(AccountManager.get(ctx)); + @Nullable + public static MyAccount getDefaultAccount(Context context) { + AccountManager am = AccountManager.get(context); + Account account = getDefaultSystemAccount(am); + return account != null ? new MyAccount(account, am) : null; } - public static Account getDefaultAccount(AccountManager m) { + @Nullable + private static Account getDefaultSystemAccount(AccountManager m) { Account[] accs = m.getAccountsByType(ACCOUNT_TYPE); return (accs.length > 0) ? accs[0] : null; } - public static String getDefaultAccountName(Context ctx) { - Account acc = getDefaultAccount(ctx); - return (acc != null) ? acc.name : null; + public static MyAccount fromSystemAccount(Context context, Account systemAccount) { + return new MyAccount(systemAccount, AccountManager.get(context)); } - public static boolean isSelfJID(Context ctx, String bareJid) { - String name = getDefaultAccountName(ctx); - return name != null && - XMPPUtils.createLocalJID(ctx, MessageUtils.sha1(name)) - .equalsIgnoreCase(bareJid); + static String getDisplayName(AccountManager am, Account account) { + return account != null ? am.getUserData(account, DATA_NAME) : null; } - public static String getDefaultDisplayName(Context context) { + static EndpointServer getServer(AccountManager am, Account account) { + if (account != null) { + String uri = am.getUserData(account, DATA_SERVER_URI); + return uri != null ? new EndpointServer(uri) : null; + } + return null; + } + + /** @deprecated Still used in {@link org.kontalk.Kontalk#onCreate()}. */ + @Deprecated + public static String getDefaultServiceTermsURL(Context context) { AccountManager am = AccountManager.get(context); - Account account = getDefaultAccount(am); - return getDisplayName(am, account); + Account account = getDefaultSystemAccount(am); + return account != null ? getServiceTermsURL(am, account) : null; } - public static String getDisplayName(AccountManager am, Account account) { - return account != null ? am.getUserData(account, DATA_NAME) : null; + static String getServiceTermsURL(AccountManager am, Account account) { + return am.getUserData(account, DATA_SERVICE_TERMS_URL); } - public static EndpointServer getDefaultServer(Context context) { + /** @deprecated Still used in {@link org.kontalk.Kontalk#onCreate()}. */ + @Deprecated + public static ServerList getDefaultServerList(Context context) { AccountManager am = AccountManager.get(context); - Account account = getDefaultAccount(am); - return getServer(am, account); + Account account = getDefaultSystemAccount(am); + return account != null ? getServerList(am, account) : null; } - public static EndpointServer getServer(AccountManager am, Account account) { - if (account != null) { - String uri = am.getUserData(account, DATA_SERVER_URI); - return uri != null ? new EndpointServer(uri) : null; + static ServerList getServerList(AccountManager am, Account account) { + String serverListData = am.getUserData(account, DATA_SERVER_LIST); + if (serverListData != null) { + Properties props = DataUtils.unserializeProperties(serverListData); + try { + return ServerList.fromProperties(props); + } + catch (IOException e) { + // this isn't right... + return null; + } } return null; } - public static boolean hasPersonalKey(AccountManager am, Account account) { - return account != null && - am.getUserData(account, DATA_PRIVATEKEY) != null && - am.getUserData(account, DATA_PUBLICKEY) != null && - am.getUserData(account, DATA_BRIDGECERT) != null; - } + static PersonalKey loadPersonalKey(AccountManager am, Account account) + throws PGPException, IOException, CertificateException { - public static PersonalKey loadDefaultPersonalKey(Context ctx, String passphrase) - throws PGPException, IOException, CertificateException, NoSuchProviderException { - AccountManager m = AccountManager.get(ctx); - Account acc = getDefaultAccount(m); + String passphrase = am.getPassword(account); + String privKeyData = am.getUserData(account, DATA_PRIVATEKEY); + String pubKeyData = am.getUserData(account, DATA_PUBLICKEY); + String bridgeCertData = am.getUserData(account, DATA_BRIDGECERT); - String privKeyData = m.getUserData(acc, DATA_PRIVATEKEY); - String pubKeyData = m.getUserData(acc, DATA_PUBLICKEY); - String bridgeCertData = m.getUserData(acc, DATA_BRIDGECERT); - - if (privKeyData != null && pubKeyData != null && bridgeCertData != null) + if (privKeyData != null && pubKeyData != null && bridgeCertData != null) { return PersonalKey .load(Base64.decode(privKeyData, Base64.DEFAULT), - Base64.decode(pubKeyData, Base64.DEFAULT), - passphrase, - Base64.decode(bridgeCertData, Base64.DEFAULT) + Base64.decode(pubKeyData, Base64.DEFAULT), + passphrase, + Base64.decode(bridgeCertData, Base64.DEFAULT) ); - - else + } + else { return null; + } } - public static void exportDefaultPersonalKey(Context ctx, String passphrase, String exportPassphrase, boolean bridgeCertificate) - throws CertificateException, NoSuchProviderException, PGPException, + public static void exportDefaultPersonalKey(Context ctx, OutputStream dest, String exportPassphrase, boolean bridgeCertificate) + throws CertificateException, PGPException, IOException, KeyStoreException, NoSuchAlgorithmException { - // TODO move all this stuff to a PersonalKeyExporter - - // put everything in a zip file - ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(PersonalKeyImporter.DEFAULT_KEYPACK)); - AccountManager m = AccountManager.get(ctx); - Account acc = getDefaultAccount(m); + Account acc = getDefaultSystemAccount(m); + String passphrase = m.getPassword(acc); String privKeyData = m.getUserData(acc, DATA_PRIVATEKEY); byte[] privateKey = Base64.decode(privKeyData, Base64.DEFAULT); - - // custom export passphrase -- re-encrypt private key - if (exportPassphrase != null) { - privateKey = PGP.copySecretKeyRingWithNewPassword(privateKey, - passphrase, exportPassphrase) - .getEncoded(); - } - - OutputStream out; - ByteArrayOutputStream stream; + byte[] bridgeCert = null; if (bridgeCertificate) { // bridge certificate is just plain data String bridgeCertData = m.getUserData(acc, DATA_BRIDGECERT); - byte[] bridgeCert = Base64.decode(bridgeCertData, Base64.DEFAULT); - - // export bridge certificate - zip.putNextEntry(new ZipEntry(BRIDGE_CERT_FILENAME)); - stream = new ByteArrayOutputStream(); - PemWriter writer = new PemWriter(new OutputStreamWriter(stream)); - writer.writeObject(new PemObject(X509Bridge.PEM_TYPE_CERTIFICATE, bridgeCert)); - writer.close(); - stream.writeTo(zip); - zip.closeEntry(); - - // export bridge private key - zip.putNextEntry(new ZipEntry(BRIDGE_KEY_FILENAME)); - PrivateKey bridgeKey = PGP.convertPrivateKey(privateKey, exportPassphrase); - stream = new ByteArrayOutputStream(); - writer = new PemWriter(new OutputStreamWriter(stream)); - writer.writeObject(new PemObject(X509Bridge.PEM_TYPE_PRIVATE_KEY, bridgeKey.getEncoded())); - writer.close(); - stream.writeTo(zip); - zip.closeEntry(); - - // certificate pack in PKCS#12 - zip.putNextEntry(new ZipEntry(BRIDGE_CERTPACK_FILENAME)); - X509Certificate certificate = X509Bridge.load(bridgeCert); - KeyStore pkcs12 = X509Bridge.exportCertificate(certificate, bridgeKey); - pkcs12.store(zip, exportPassphrase.toCharArray()); - zip.closeEntry(); + bridgeCert = Base64.decode(bridgeCertData, Base64.DEFAULT); } String pubKeyData = m.getUserData(acc, DATA_PUBLICKEY); byte[] publicKey = Base64.decode(pubKeyData, Base64.DEFAULT); - // export public key - zip.putNextEntry(new ZipEntry(PUBLIC_KEY_FILENAME)); - stream = new ByteArrayOutputStream(); - out = new ArmoredOutputStream(stream); - out.write(publicKey); - out.close(); - stream.writeTo(zip); - zip.closeEntry(); - - // export private key - zip.putNextEntry(new ZipEntry(PRIVATE_KEY_FILENAME)); - stream = new ByteArrayOutputStream(); - out = new ArmoredOutputStream(stream); - out.write(privateKey); - out.close(); - stream.writeTo(zip); - zip.closeEntry(); - - // finalize the zip file - zip.close(); + // trusted keys + Map trustedKeys = Keyring.getTrustedKeys(ctx); + + PersonalKeyExporter exp = new PersonalKeyExporter(); + exp.save(privateKey, publicKey, dest, passphrase, exportPassphrase, bridgeCert, trustedKeys, acc.name); + } + + public static byte[] getPrivateKeyExportData(Context ctx, String passphrase, String exportPassphrase) + throws PGPException, IOException { + AccountManager m = AccountManager.get(ctx); + Account acc = getDefaultSystemAccount(m); + + return getPrivateKeyExportData(m, acc, passphrase, exportPassphrase); + } + + private static byte[] getPrivateKeyExportData(AccountManager m, Account acc, String passphrase, String exportPassphrase) + throws PGPException, IOException { + String privKeyData = m.getUserData(acc, DATA_PRIVATEKEY); + byte[] privateKey = Base64.decode(privKeyData, Base64.DEFAULT); + + // custom export passphrase -- re-encrypt private key + if (exportPassphrase != null) { + privateKey = PGP.copySecretKeyRingWithNewPassword(privateKey, + passphrase, exportPassphrase) + .getEncoded(); + } + + return privateKey; } public static void setDefaultPersonalKey(Context ctx, byte[] publicKeyData, byte[] privateKeyData, byte[] bridgeCertData, String passphrase) { AccountManager am = AccountManager.get(ctx); - Account acc = getDefaultAccount(am); + Account acc = getDefaultSystemAccount(am); // password is optional when updating just the public key if (passphrase != null) @@ -291,47 +249,76 @@ public static void changePassphrase(Context ctx, String oldPassphrase, String ne throws PGPException, IOException { AccountManager am = AccountManager.get(ctx); - Account acc = getDefaultAccount(am); + Account acc = getDefaultSystemAccount(am); // get old secret key ring String privKeyData = am.getUserData(acc, DATA_PRIVATEKEY); byte[] privateKeyData = Base64.decode(privKeyData, Base64.DEFAULT); - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); - PGPSecretKeyRing oldSecRing = new PGPSecretKeyRing(privateKeyData, fpr); - - // old decryptor - PGPDigestCalculatorProvider calcProv = new JcaPGPDigestCalculatorProviderBuilder().build(); - PBESecretKeyDecryptor oldDecryptor = new JcePBESecretKeyDecryptorBuilder(calcProv) - .setProvider(PGP.PROVIDER) - .build(oldPassphrase.toCharArray()); - - // new encryptor - PGPDigestCalculator sha1Calc = new JcaPGPDigestCalculatorProviderBuilder().build().get(HashAlgorithmTags.SHA1); - PBESecretKeyEncryptor newEncryptor = new JcePBESecretKeyEncryptorBuilder(PGPEncryptedData.AES_256, sha1Calc) - .setProvider(PGP.PROVIDER).build(newPassphrase.toCharArray()); - // create new secret key ring - PGPSecretKeyRing newSecRing = PGPSecretKeyRing.copyWithNewPassword(oldSecRing, oldDecryptor, newEncryptor); + byte[] newPrivateKeyData = PGP.changePassphrase(privateKeyData, oldPassphrase, newPassphrase); // replace key data in AccountManager - byte[] newPrivateKeyData = newSecRing.getEncoded(); am.setUserData(acc, DATA_PRIVATEKEY, Base64.encodeToString(newPrivateKeyData, Base64.NO_WRAP)); - am.setUserData(acc, DATA_USER_PASSPHRASE, String.valueOf(fromUser)); // replace password for account am.setPassword(acc, newPassphrase); } + public static void setPassphrase(Context ctx, String passphrase, boolean fromUser) { + AccountManager am = AccountManager.get(ctx); + Account acc = getDefaultSystemAccount(am); + am.setUserData(acc, DATA_USER_PASSPHRASE, String.valueOf(fromUser)); + // replace password for account + am.setPassword(acc, passphrase); + } + public static boolean isUserPassphrase(Context ctx) { AccountManager am = AccountManager.get(ctx); - Account acc = getDefaultAccount(am); + Account acc = getDefaultSystemAccount(am); return Boolean.parseBoolean(am.getUserData(acc, DATA_USER_PASSPHRASE)); } public static void removeDefaultAccount(Context ctx, AccountManagerCallback callback) { AccountManager am = AccountManager.get(ctx); - am.removeAccount(getDefaultAccount(am), callback, null); + Account account = getDefaultSystemAccount(am); + + // there is something wrong with this, isn't it? [cit.] + if (account == null) + return; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + final boolean result = am.removeAccountExplicitly(account); + callback.run(new AccountManagerFuture() { + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return false; + } + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public boolean isDone() { + return true; + } + + @Override + public Boolean getResult() throws OperationCanceledException, IOException, AuthenticatorException { + return result; + } + + @Override + public Boolean getResult(long timeout, TimeUnit unit) throws OperationCanceledException, IOException, AuthenticatorException { + return result; + } + }); + } + else { + am.removeAccount(getDefaultSystemAccount(am), callback, null); + } } @Override @@ -362,19 +349,17 @@ public void run() { /** * System is requesting to confirm our credentials - this usually means that - * something has changed (e.g. new SIM card), so we simply delete the - * account for safety. + * something has changed (e.g. new SIM card). We request the user to insert + * his/her personal key passphrase - which might not have been set, in that + * case that's unfortunate because it's an unrecoverable situation. */ @Override public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException { - // remove account - AccountManager man = AccountManager.get(mContext); - man.removeAccount(account, null, null); - final Bundle bundle = new Bundle(); - bundle.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false); + bundle.putParcelable(AccountManager.KEY_INTENT, + MainActivity.passwordRequest(mContext)); return bundle; } diff --git a/app/src/main/java/org/kontalk/authenticator/LegacyAuthentication.java b/app/src/main/java/org/kontalk/authenticator/LegacyAuthentication.java deleted file mode 100644 index ebe2e38a8..000000000 --- a/app/src/main/java/org/kontalk/authenticator/LegacyAuthentication.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.authenticator; - -import org.jivesoftware.smack.util.StringUtils; - -import org.kontalk.Kontalk; -import org.kontalk.client.EndpointServer; -import org.kontalk.client.ServerList; -import org.kontalk.service.ServerListUpdater; -import org.kontalk.service.msgcenter.MessageCenterService; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.content.Context; - - -/** - * - * @author Daniele Ricci - * @deprecated This class was born to be deprecated. - */ -@Deprecated -public class LegacyAuthentication { - - private static boolean sUpgrading; - - // do not instantiate - private LegacyAuthentication() {} - - /** - * Upgrades the default account for XMPP. - * Account password is moved to an account user data attribute. - */ - public static void doUpgrade(Context context, String name) { - AccountManager am = AccountManager.get(context); - Account account = Authenticator.getDefaultAccount(am); - - if (account != null) { - // start upgrade process - sUpgrading = true; - - boolean upgraded = (am.getUserData(account, Authenticator.DATA_AUTHTOKEN) != null); - if (!upgraded) { - String token = am.getPassword(account); - - // save auth token for later use - am.setUserData(account, Authenticator.DATA_AUTHTOKEN, token); - } - - // save uid name - am.setUserData(account, Authenticator.DATA_NAME, name); - - // set server to first in built-in server list - ServerList list = ServerListUpdater.getCurrentList(context); - EndpointServer server = list.get(0); - am.setUserData(account, Authenticator.DATA_SERVER_URI, server.toString()); - - // setup a new passphrase for the upgrade - String passphrase = StringUtils.randomString(40); - am.setPassword(account, passphrase); - - // invalidate personal key and passphrase - ((Kontalk) context.getApplicationContext()).invalidatePersonalKey(); - - // start key pair generation - MessageCenterService.regenerateKeyPair(context); - } - } - - /** - * Returns the auth token for logging in the first time after upgrading to - * XMPP. - */ - public static String getAuthToken(Context context) { - AccountManager am = AccountManager.get(context); - Account account = Authenticator.getDefaultAccount(am); - - if (account != null) - return am.getUserData(account, Authenticator.DATA_AUTHTOKEN); - - return null; - } - - public static boolean isUpgrading() { - return sUpgrading; - } - - public static void endUpgrade() { - sUpgrading = false; - } - -} diff --git a/app/src/main/java/org/kontalk/authenticator/MyAccount.kt b/app/src/main/java/org/kontalk/authenticator/MyAccount.kt new file mode 100644 index 000000000..b9f65ad97 --- /dev/null +++ b/app/src/main/java/org/kontalk/authenticator/MyAccount.kt @@ -0,0 +1,84 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.authenticator + +import android.accounts.Account +import android.accounts.AccountManager +import org.jxmpp.jid.BareJid +import org.jxmpp.util.XmppStringUtils +import org.kontalk.client.EndpointServer +import org.kontalk.client.ServerList +import org.kontalk.crypto.PersonalKey +import org.kontalk.util.XMPPUtils + + +/** A Kontalk account. */ +class MyAccount (val systemAccount: Account, private val accountManager: AccountManager) { + + val name: String = this.systemAccount.name + + val displayName: String by lazy { + Authenticator.getDisplayName(accountManager, systemAccount) + } + + val server: EndpointServer by lazy { + Authenticator.getServer(accountManager, systemAccount) + } + + val serverList: ServerList by lazy { + Authenticator.getServerList(accountManager, systemAccount) + } + + val serviceTermsURL: String? by lazy { + Authenticator.getServiceTermsURL(accountManager, systemAccount) + } + + val phoneNumber: String = this.systemAccount.name + + val personalKey: PersonalKey by lazy { + Authenticator.loadPersonalKey(accountManager, systemAccount) + } + + val passphrase: String? by lazy { + accountManager.getPassword(systemAccount) + } + + val selfJID: String by lazy { + createLocalJID(XMPPUtils.createLocalpart(name)) + } + + fun isSelfJID(bareJid: String): Boolean = selfJID.equals(bareJid, ignoreCase = true) + + fun isSelfJID(bareJid: BareJid): Boolean = bareJid.equals(selfJID) + + fun isNetworkJID(bareJid: BareJid): Boolean { + return serverList.firstOrNull { it.network.equals(bareJid.domain.toString(), true) } != null + } + + fun createLocalJID(localpart: String): String { + return XmppStringUtils.completeJidFrom(localpart, server.network); + } + + override fun equals(other: Any?): Boolean = this.systemAccount == other + + override fun hashCode(): Int = this.systemAccount.hashCode() + + override fun toString(): String = this.systemAccount.toString() + +} diff --git a/app/src/main/java/org/kontalk/billing/BillingException.java b/app/src/main/java/org/kontalk/billing/BillingException.java deleted file mode 100644 index 25bd91ae3..000000000 --- a/app/src/main/java/org/kontalk/billing/BillingException.java +++ /dev/null @@ -1,44 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - -/** - * Exception thrown when something went wrong with in-app billing. - * An IabException has an associated IabResult (an error). - * To get the IAB result that caused this exception to be thrown, - * call {@link #getResult()}. - */ -@SuppressWarnings("serial") -public class BillingException extends Exception { - BillingResult mResult; - - public BillingException(BillingResult r) { - this(r, null); - } - public BillingException(int response, String message) { - this(new BillingResult(response, message)); - } - public BillingException(BillingResult r, Exception cause) { - super(r.getMessage(), cause); - mResult = r; - } - public BillingException(int response, String message, Exception cause) { - this(new BillingResult(response, message), cause); - } - - /** Returns the billing result (error) that this exception signals. */ - public BillingResult getResult() { return mResult; } -} diff --git a/app/src/main/java/org/kontalk/billing/BillingResult.java b/app/src/main/java/org/kontalk/billing/BillingResult.java deleted file mode 100644 index 6d6b5853c..000000000 --- a/app/src/main/java/org/kontalk/billing/BillingResult.java +++ /dev/null @@ -1,46 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - - -/** - * Represents the result of an in-app billing operation. - * A result is composed of a response code (an integer) and possibly a - * message (String). You can get those by calling - * {@link #getResponse} and {@link #getMessage()}, respectively. You - * can also inquire whether a result is a success or a failure by - * calling {@link #isSuccess()} and {@link #isFailure()}. - */ -public class BillingResult { - int mResponse; - String mMessage; - - public BillingResult(int response, String message) { - mResponse = response; - if (message == null || message.trim().length() == 0) { - mMessage = BillingServiceManager.getResponseDesc(response); - } - else { - mMessage = message + " (response: " + BillingServiceManager.getResponseDesc(response) + ")"; - } - } - public int getResponse() { return mResponse; } - public String getMessage() { return mMessage; } - public boolean isSuccess() { return mResponse == BillingServiceManager.BILLING_RESPONSE_RESULT_OK; } - public boolean isFailure() { return !isSuccess(); } - public String toString() { return "BillingResult: " + getMessage(); } -} - diff --git a/app/src/main/java/org/kontalk/billing/IBillingService.java b/app/src/main/java/org/kontalk/billing/IBillingService.java deleted file mode 100644 index 40493cf5b..000000000 --- a/app/src/main/java/org/kontalk/billing/IBillingService.java +++ /dev/null @@ -1,144 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - -import android.app.Activity; -import android.content.Intent; - -import java.util.List; - - -/** - * On-device billing service interface. - * @author Daniele Ricci - */ -public interface IBillingService { - - /** - * Enables or disable debug logging through LogCat. - */ - public void enableDebugLogging(boolean enable, String tag); - - public void enableDebugLogging(boolean enable); - - /** - * Starts the setup process. This will start up the setup process asynchronously. - * You will be notified through the listener when the setup process is complete. - * This method is safe to call from a UI thread. - * - * @param listener The listener to notify when the setup process is complete. - */ - public void startSetup(OnBillingSetupFinishedListener listener); - - public void launchPurchaseFlow(Activity act, String sku, int requestCode, OnPurchaseFinishedListener listener); - - public void launchPurchaseFlow(Activity act, String sku, int requestCode, - OnPurchaseFinishedListener listener, String extraData); - - public void launchSubscriptionPurchaseFlow(Activity act, String sku, int requestCode, - OnPurchaseFinishedListener listener); - - public void launchSubscriptionPurchaseFlow(Activity act, String sku, int requestCode, - OnPurchaseFinishedListener listener, String extraData); - - /** - * Initiate the UI flow for an in-app purchase. Call this method to initiate an in-app purchase, - * which will involve bringing up the Google Play screen. The calling activity will be paused while - * the user interacts with Google Play, and the result will be delivered via the activity's - * {@link android.app.Activity#onActivityResult} method, at which point you must call - * this object's {@link #handleActivityResult} method to continue the purchase flow. This method - * MUST be called from the UI thread of the Activity. - * - * @param act The calling activity. - * @param sku The sku of the item to purchase. - * @param itemType indicates if it's a product or a subscription (ITEM_TYPE_INAPP or ITEM_TYPE_SUBS) - * @param requestCode A request code (to differentiate from other responses -- - * as in {@link android.app.Activity#startActivityForResult}). - * @param listener The listener to notify when the purchase process finishes - * @param extraData Extra data (developer payload), which will be returned with the purchase data - * when the purchase completes. This extra data will be permanently bound to that purchase - * and will always be returned when the purchase is queried. - */ - public void launchPurchaseFlow(Activity act, String sku, String itemType, int requestCode, - OnPurchaseFinishedListener listener, String extraData); - - /** - * Handles an activity result that's part of the purchase flow in in-app billing. If you - * are calling {@link #launchPurchaseFlow}, then you must call this method from your - * Activity's {@link android.app.Activity@onActivityResult} method. This method - * MUST be called from the UI thread of the Activity. - * - * @param requestCode The requestCode as you received it. - * @param resultCode The resultCode as you received it. - * @param data The data (Intent) as you received it. - * @return Returns true if the result was related to a purchase flow and was handled; - * false if the result was not related to a purchase, in which case you should - * handle it normally. - */ - public boolean handleActivityResult(int requestCode, int resultCode, Intent data); - - public IInventory queryInventory(boolean querySkuDetails, List moreSkus) - throws BillingException; - - /** - * Queries the inventory. This will query all owned items from the server, as well as - * information on additional skus, if specified. This method may block or take long to execute. - * Do not call from a UI thread. For that, use the non-blocking version {@link #queryInventoryAsync}. - * - * @param querySkuDetails if true, SKU details (price, description, etc) will be queried as well - * as purchase information. - * @param moreItemSkus additional PRODUCT skus to query information on, regardless of ownership. - * Ignored if null or if querySkuDetails is false. - * @param moreSubsSkus additional SUBSCRIPTIONS skus to query information on, regardless of ownership. - * Ignored if null or if querySkuDetails is false. - * @throws BillingException if a problem occurs while refreshing the inventory. - */ - public IInventory queryInventory(boolean querySkuDetails, List moreItemSkus, - List moreSubsSkus) throws BillingException; - - /** - * Asynchronous wrapper for inventory query. This will perform an inventory - * query as described in {@link #queryInventory}, but will do so asynchronously - * and call back the specified listener upon completion. This method is safe to - * call from a UI thread. - * - * @param querySkuDetails as in {@link #queryInventory} - * @param moreSkus as in {@link #queryInventory} - * @param listener The listener to notify when the refresh operation completes. - */ - public void queryInventoryAsync(final boolean querySkuDetails, - final List moreSkus, - final QueryInventoryFinishedListener listener); - - public void queryInventoryAsync(QueryInventoryFinishedListener listener); - - public void queryInventoryAsync(boolean querySkuDetails, QueryInventoryFinishedListener listener); - - public void consumeAsync(IPurchase purchase, OnConsumeFinishedListener listener); - - public void endAsyncOperation(); - - /** - * Dispose of object, releasing resources. It's very important to call this - * method when you are done with this object. It will release any resources - * used by it such as service connections. Naturally, once the object is - * disposed of, it can't be used again. - */ - public void dispose(); - - public boolean isDisposed(); - -} diff --git a/app/src/main/java/org/kontalk/billing/IInventory.java b/app/src/main/java/org/kontalk/billing/IInventory.java deleted file mode 100644 index 68976fd2c..000000000 --- a/app/src/main/java/org/kontalk/billing/IInventory.java +++ /dev/null @@ -1,47 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - - -/** - * Represents a block of information about in-app items. - * An Inventory is returned by such methods as {@link IBillingService#queryInventory}. - */ -public interface IInventory { - - /** Returns the listing details for an in-app product. */ - public IProductDetails getSkuDetails(String sku); - - /** Returns purchase information for a given product, or null if there is no purchase. */ - public IPurchase getPurchase(String sku); - - /** Returns whether or not there exists a purchase of the given product. */ - public boolean hasPurchase(String sku); - - /** Return whether or not details about the given product are available. */ - public boolean hasDetails(String sku); - - /** - * Erase a purchase (locally) from the inventory, given its product ID. This just - * modifies the Inventory object locally and has no effect on the server! This is - * useful when you have an existing Inventory object which you know to be up to date, - * and you have just consumed an item successfully, which means that erasing its - * purchase data from the Inventory you already have is quicker than querying for - * a new Inventory. - */ - public void erasePurchase(String sku); - -} diff --git a/app/src/main/java/org/kontalk/billing/IProductDetails.java b/app/src/main/java/org/kontalk/billing/IProductDetails.java deleted file mode 100644 index e23d7108a..000000000 --- a/app/src/main/java/org/kontalk/billing/IProductDetails.java +++ /dev/null @@ -1,30 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - - -/** - * Represents an in-app product's listing details. - */ -public interface IProductDetails { - - public String getId(); - public String getType(); - public String getPrice(); - public String getTitle(); - public String getDescription(); - -} diff --git a/app/src/main/java/org/kontalk/billing/IPurchase.java b/app/src/main/java/org/kontalk/billing/IPurchase.java deleted file mode 100644 index f0996e2ef..000000000 --- a/app/src/main/java/org/kontalk/billing/IPurchase.java +++ /dev/null @@ -1,35 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - - -/** - * Represents an in-app billing purchase. - */ -public interface IPurchase { - - public String getItemType(); - public String getOrderId(); - public String getPackageName(); - public String getProduct(); - public long getPurchaseTime(); - public int getPurchaseState(); - public String getDeveloperPayload(); - public String getToken(); - public String getOriginalJson(); - public String getSignature(); - -} diff --git a/app/src/main/java/org/kontalk/billing/OnBillingSetupFinishedListener.java b/app/src/main/java/org/kontalk/billing/OnBillingSetupFinishedListener.java deleted file mode 100644 index c63b9e178..000000000 --- a/app/src/main/java/org/kontalk/billing/OnBillingSetupFinishedListener.java +++ /dev/null @@ -1,32 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - - -/** - * Callback for setup process. This listener's {@link #onSetupFinished} method - * is called when the setup process is complete. - */ -public interface OnBillingSetupFinishedListener { - - /** - * Called to notify that setup is complete. - * - * @param result The result of the setup process. - */ - public void onSetupFinished(BillingResult result); - -} diff --git a/app/src/main/java/org/kontalk/billing/OnConsumeFinishedListener.java b/app/src/main/java/org/kontalk/billing/OnConsumeFinishedListener.java deleted file mode 100644 index 78721569d..000000000 --- a/app/src/main/java/org/kontalk/billing/OnConsumeFinishedListener.java +++ /dev/null @@ -1,30 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - - -/** - * Callback that notifies when a consumption operation finishes. - */ -public interface OnConsumeFinishedListener { - /** - * Called to notify that a consumption has finished. - * - * @param purchase The purchase that was (or was to be) consumed. - * @param result The result of the consumption operation. - */ - public void onConsumeFinished(IPurchase purchase, BillingResult result); -} diff --git a/app/src/main/java/org/kontalk/billing/OnPurchaseFinishedListener.java b/app/src/main/java/org/kontalk/billing/OnPurchaseFinishedListener.java deleted file mode 100644 index 32d9719d0..000000000 --- a/app/src/main/java/org/kontalk/billing/OnPurchaseFinishedListener.java +++ /dev/null @@ -1,33 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - - -/** - * Callback that notifies when a purchase is finished. - */ -public interface OnPurchaseFinishedListener { - /** - * Called to notify that an in-app purchase finished. If the purchase was successful, - * then the sku parameter specifies which item was purchased. If the purchase failed, - * the sku and extraData parameters may or may not be null, depending on how far the purchase - * process went. - * - * @param result The result of the purchase. - * @param info The purchase information (null if purchase failed) - */ - public void onPurchaseFinished(BillingResult result, IPurchase info); -} diff --git a/app/src/main/java/org/kontalk/billing/QueryInventoryFinishedListener.java b/app/src/main/java/org/kontalk/billing/QueryInventoryFinishedListener.java deleted file mode 100644 index 73c4811f1..000000000 --- a/app/src/main/java/org/kontalk/billing/QueryInventoryFinishedListener.java +++ /dev/null @@ -1,30 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.kontalk.billing; - - -/** - * Listener that notifies when an inventory query operation completes. - */ -public interface QueryInventoryFinishedListener { - /** - * Called to notify that an inventory query operation completed. - * - * @param result The result of the operation. - * @param inv The inventory. - */ - public void onQueryInventoryFinished(BillingResult result, IInventory inv); -} diff --git a/app/src/main/java/org/kontalk/client/ClientHTTPConnection.java b/app/src/main/java/org/kontalk/client/ClientHTTPConnection.java index 98c677c62..03cc0c928 100644 --- a/app/src/main/java/org/kontalk/client/ClientHTTPConnection.java +++ b/app/src/main/java/org/kontalk/client/ClientHTTPConnection.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,8 +21,8 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; -import java.net.Socket; -import java.net.UnknownHostException; +import java.net.HttpURLConnection; +import java.net.URL; import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; @@ -37,41 +37,30 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; -import org.apache.http.Header; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.client.ClientProtocolException; -import org.apache.http.client.HttpClient; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpRequestBase; -import org.apache.http.client.params.ClientPNames; -import org.apache.http.conn.ClientConnectionManager; -import org.apache.http.conn.scheme.PlainSocketFactory; -import org.apache.http.conn.scheme.Scheme; -import org.apache.http.conn.scheme.SchemeRegistry; -import org.apache.http.conn.ssl.SSLSocketFactory; -import org.apache.http.impl.client.DefaultHttpClient; -import org.apache.http.impl.conn.SingleClientConnManager; -import org.apache.http.params.BasicHttpParams; -import org.apache.http.params.HttpParams; -import org.apache.http.util.EntityUtils; +import org.apache.http.conn.ssl.AllowAllHostnameVerifier; +import android.annotation.SuppressLint; +import android.content.Context; +import androidx.annotation.NonNull; + +import info.guardianproject.netcipher.client.TlsOnlySocketFactory; + +import org.kontalk.Log; import org.kontalk.message.CompositeMessage; import org.kontalk.service.DownloadListener; import org.kontalk.util.InternalTrustStore; -import org.kontalk.util.MediaStorage; import org.kontalk.util.Preferences; import org.kontalk.util.ProgressOutputStreamEntity; -import android.content.Context; -import android.util.Log; - /** * FIXME this is actually specific to Kontalk Dropbox server. @@ -83,14 +72,21 @@ public class ClientHTTPConnection { /** Regex used to parse content-disposition headers */ private static final Pattern CONTENT_DISPOSITION_PATTERN = Pattern .compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\""); + /** Minimum delay for progress notification updates in milliseconds. */ + private static final int PROGRESS_PUBLISH_DELAY = 1000; private final Context mContext; private final PrivateKey mPrivateKey; private final X509Certificate mCertificate; - private HttpRequestBase currentRequest; - private HttpClient mConnection; + private HttpURLConnection currentRequest; + private final static int CONNECT_TIMEOUT = 15000; + private final static int READ_TIMEOUT = 40000; + + public ClientHTTPConnection(Context context) { + this(context, null, null); + } public ClientHTTPConnection(Context context, PrivateKey privateKey, X509Certificate bridgeCert) { mContext = context; @@ -99,8 +95,15 @@ public ClientHTTPConnection(Context context, PrivateKey privateKey, X509Certific } public void abort() { - if (currentRequest != null) - currentRequest.abort(); + close(); + } + + public void close() { + try { + currentRequest.disconnect(); + } + catch (Exception ignored) { + } } /** @@ -108,9 +111,39 @@ public void abort() { * @param url URL to download * @return the request object */ - private HttpRequestBase prepareURLDownload(String url) throws IOException { - HttpGet req = new HttpGet(url); - return req; + private HttpURLConnection prepareURLDownload(String url, boolean acceptAnyCertificate) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + try { + setupClient(conn, acceptAnyCertificate); + } + catch (Exception e) { + throw innerException("error setting up SSL connection", e); + } + return conn; + } + + private IOException innerException(String detail, Throwable cause) { + return new IOException(detail, cause); + } + + @SuppressLint("AllowAllHostnameVerifier") + private void setupClient(HttpURLConnection conn, boolean acceptAnyCertificate) + throws CertificateException, UnrecoverableKeyException, + NoSuchAlgorithmException, KeyStoreException, + KeyManagementException, NoSuchProviderException, + IOException { + + // bug caused by Lighttpd + //conn.setRequestProperty("Expect", "100-continue"); + conn.setConnectTimeout(CONNECT_TIMEOUT); + conn.setReadTimeout(READ_TIMEOUT); + conn.setDoInput(true); + if (conn instanceof HttpsURLConnection) { + ((HttpsURLConnection) conn).setSSLSocketFactory(setupSSLSocketFactory(mContext, + mPrivateKey, mCertificate, acceptAnyCertificate)); + if (acceptAnyCertificate) + ((HttpsURLConnection) conn).setHostnameVerifier(new AllowAllHostnameVerifier()); + } } public static SSLSocketFactory setupSSLSocketFactory(Context context, @@ -121,116 +154,110 @@ public static SSLSocketFactory setupSSLSocketFactory(Context context, NoSuchProviderException { // in-memory keystore - KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); - keystore.load(null, null); - keystore.setKeyEntry("private", privateKey, null, new Certificate[] { certificate }); + KeyManager[] km = null; + if (privateKey != null && certificate != null) { + KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); + keystore.load(null, null); + keystore.setKeyEntry("private", privateKey, null, new Certificate[]{certificate}); - // load merged truststore (system + internal) - KeyStore truststore = InternalTrustStore.getTrustStore(context); - - if (acceptAnyCertificate) - return new BlackholeSSLSocketFactory(keystore, null, truststore); - - else - return new SSLSocketFactory(keystore, null, truststore); - } + // key managers + KeyManagerFactory kmFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmFactory.init(keystore, null); + km = kmFactory.getKeyManagers(); + } - /** - * Executes the given request. - * @param request the request - * @return the response - * @throws IOException - */ - private HttpResponse execute(HttpRequestBase request) throws IOException { - // execute! - try { - if (mConnection == null) { - SchemeRegistry registry = new SchemeRegistry(); - try { - registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80)); - - boolean acceptAnyCertificate = Preferences.getAcceptAnyCertificate(mContext); - registry.register(new Scheme("https", setupSSLSocketFactory(mContext, - mPrivateKey, mCertificate, acceptAnyCertificate), 443)); - } - catch (Exception e) { - IOException ie = new IOException("unable to create keystore"); - ie.initCause(e); - throw ie; + // trust managers + TrustManager[] tm; + + if (acceptAnyCertificate) { + tm = new TrustManager[] { + new X509TrustManager() { + @Override + public X509Certificate[] getAcceptedIssuers() { + return null; + } + + @SuppressLint("TrustAllX509TrustManager") + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + } + + @SuppressLint("TrustAllX509TrustManager") + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + } } + }; + } + else { + // load merged truststore (system + internal) + KeyStore trustStore = InternalTrustStore.getTrustStore(context); - HttpParams params = new BasicHttpParams(); - // handle redirects :) - params.setBooleanParameter(ClientPNames.HANDLE_REDIRECTS, true); - // HttpClient bug caused by Lighttpd - params.setBooleanParameter("http.protocol.expect-continue", false); - - // create connection manager - ClientConnectionManager connMgr = new SingleClientConnManager(params, registry); + // builtin keystore + TrustManagerFactory tmFactory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmFactory.init(trustStore); - mConnection = new DefaultHttpClient(connMgr, params); - } - return mConnection.execute(request); - } - catch (ClientProtocolException e) { - IOException ie = new IOException("client protocol error"); - ie.initCause(e); - throw ie; + tm = tmFactory.getTrustManagers(); } + SSLContext ctx = SSLContext.getInstance("TLSv1"); + ctx.init(km, tm, null); + return new TlsOnlySocketFactory(ctx.getSocketFactory(), true); } /** * Downloads to a directory represented by a {@link File} object, * determining the file name from the Content-Disposition header. */ - public void downloadAutofilename(String url, File defaultBase, Date timestamp, DownloadListener listener) throws IOException { - _download(url, defaultBase, timestamp, listener); + public void downloadAutofilename(String url, @NonNull File defaultFile, Date timestamp, DownloadListener listener) throws IOException { + _download(url, defaultFile, timestamp, listener); } - private void _download(String url, File defaultBase, Date timestamp, DownloadListener listener) throws IOException { - currentRequest = prepareURLDownload(url); - HttpResponse response = execute(currentRequest); + private void _download(String url, @NonNull File defaultFile, Date timestamp, DownloadListener listener) throws IOException { + boolean acceptAnyCertificate = Preferences.getAcceptAnyCertificate(mContext); + currentRequest = prepareURLDownload(url, acceptAnyCertificate); - int code = response.getStatusLine().getStatusCode(); + int code = currentRequest.getResponseCode(); // HTTP/1.1 200 OK -- other codes should throw Exceptions if (code == 200) { - HttpEntity _entity = response.getEntity(); - if (_entity != null) { - // use a more suitable filename, taking only the extension - Header contentType = _entity.getContentType(); - File destination = null; - if (contentType != null) { - destination = CompositeMessage.getIncomingFile(contentType.getValue(), - timestamp != null ? timestamp : new Date()); - } + // use a more suitable filename, taking only the extension + String contentType = currentRequest.getContentType(); + File destination = null; + if (contentType != null) { + destination = CompositeMessage.getIncomingFile(mContext, contentType, + timestamp != null ? timestamp : new Date()); + } - // still having problems? - if (destination == null) { - Header disp = response.getFirstHeader("Content-Disposition"); - String name = parseContentDisposition(disp.getValue()); - if (name == null) - // very bad hack to overcome server bad behaviour - name = MediaStorage.UNKNOWN_FILENAME; + // still having problems? + if (destination == null) { + String name = null; + String disp = currentRequest.getHeaderField("Content-Disposition"); + if (disp != null) + name = parseContentDisposition(disp); - destination = new File(defaultBase, name); + if (name != null) { + // combine default file directory with server-provided filename + destination = new File(defaultFile.getParentFile(), name); + } + else { + // fallback to default filename + destination = defaultFile; } - - // we need to wrap the entity to monitor the download progress - ProgressOutputStreamEntity entity = new ProgressOutputStreamEntity(_entity, url, destination, listener); - FileOutputStream out = new FileOutputStream(destination); - entity.writeTo(out); - out.close(); - return; } + + // we need to wrap the entity to monitor the download progress + ProgressOutputStreamEntity entity = + new ProgressOutputStreamEntity(currentRequest, url, destination, listener, PROGRESS_PUBLISH_DELAY); + FileOutputStream out = new FileOutputStream(destination); + entity.writeTo(out); + out.close(); + return; } Log.d(TAG, "invalid response: " + code); - HttpEntity entity = response.getEntity(); - if (entity != null) { - Log.w(TAG, EntityUtils.toString(entity)); - entity.consumeContent(); - } listener.error(url, null, new IOException("invalid response: " + code)); } @@ -253,44 +280,4 @@ private static String parseContentDisposition(String contentDisposition) { return null; } - /** A socket factory for accepting any SSL certificate. */ - private static final class BlackholeSSLSocketFactory extends SSLSocketFactory { - SSLContext sslContext = SSLContext.getInstance("TLS"); - - public BlackholeSSLSocketFactory(KeyStore keystore, String keystorePassword, KeyStore truststore) - throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { - super(keystore, keystorePassword, truststore); - - TrustManager tm = new X509TrustManager() { - public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { - } - - public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { - } - - public X509Certificate[] getAcceptedIssuers() { - return null; - } - }; - - // key managers - KeyManager[] km; - KeyManagerFactory kmFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmFactory.init(keystore, null); - km = kmFactory.getKeyManagers(); - - sslContext.init(km, new TrustManager[] { tm }, null); - } - - @Override - public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException, UnknownHostException { - return sslContext.getSocketFactory().createSocket(socket, host, port, autoClose); - } - - @Override - public Socket createSocket() throws IOException { - return sslContext.getSocketFactory().createSocket(); - } - } - } diff --git a/app/src/main/java/org/kontalk/client/EndpointServer.java b/app/src/main/java/org/kontalk/client/EndpointServer.java index 24d1c6cd6..ee5563d4f 100644 --- a/app/src/main/java/org/kontalk/client/EndpointServer.java +++ b/app/src/main/java/org/kontalk/client/EndpointServer.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,20 +18,27 @@ package org.kontalk.client; +import java.util.Collections; +import java.util.Date; import java.util.regex.Pattern; /** * Defines a server address. + * * @author Daniele Ricci */ public class EndpointServer { - /** Default client port. */ + /** + * Default client port. + */ public static final int DEFAULT_PORT = 5222; - /** Validation pattern. Very basic. */ + /** + * Validation pattern. Very basic. + */ // TODO use this also for parsing - private static final Pattern sPattern = Pattern.compile("^[A-Za-z0-9\\-\\.]+(\\|[A-Za-z0-9\\-\\.]+(:\\d+)?)?$"); + private static final Pattern sPattern = Pattern.compile("^[A-Za-z0-9\\-.]+(\\|[A-Za-z0-9\\-.]+(:\\d+)?)?$"); private String mHost; private int mPort; @@ -59,7 +66,7 @@ public EndpointServer(String network, String host, int port) { @Override public boolean equals(Object o) { - return o != null && o instanceof EndpointServer && + return o instanceof EndpointServer && (mHost == ((EndpointServer) o).mHost || (mHost != null && mHost.equalsIgnoreCase(((EndpointServer) o).mHost))) && (((EndpointServer) o).mNetwork == mNetwork || @@ -74,8 +81,11 @@ public int hashCode() { @Override public String toString() { - if (mHost != null) { - return mNetwork + "|" + mHost + ":" + mPort; + if (mHost != null && (!mNetwork.equalsIgnoreCase(mHost) || mPort != DEFAULT_PORT)) { + String out = mNetwork + "|" + mHost; + if (mPort != DEFAULT_PORT) + out += ":" + mPort; + return out; } else { return mNetwork; @@ -98,30 +108,49 @@ public String getNetwork() { return mNetwork; } - /** Interface for providing a server. */ + /** + * Interface for providing a server. + */ public interface EndpointServerProvider { - /** Returns the next server that hasn't been picked yet. */ - public EndpointServer next(); - /** Resets the provider to its initial state. */ - public void reset(); + /** + * Returns a server list. + */ + ServerList list(); + + /** + * Returns the next server that hasn't been picked yet. + */ + EndpointServer next(); + + /** + * Resets the provider to its initial state. + */ + void reset(); } - /** A basic server provider for a single server. */ + /** + * A basic server provider for a single server. + */ public static class SingleServerProvider implements EndpointServerProvider { private String mUri; + private EndpointServer mProvided; private boolean mCalled; public SingleServerProvider(String uri) { mUri = uri; } + public SingleServerProvider(EndpointServer server) { + mProvided = server; + } + @Override - public EndpointServer next() { - if (mCalled) { - return null; - } - else { - mCalled = true; + public ServerList list() { + return new ServerList(new Date(), Collections.singletonList(getProvidedServer())); + } + + private EndpointServer getProvidedServer() { + if (mProvided == null) { try { return new EndpointServer(mUri); } @@ -130,6 +159,19 @@ public EndpointServer next() { return null; } } + + return mProvided; + } + + @Override + public EndpointServer next() { + if (mCalled) { + return null; + } + else { + mCalled = true; + return getProvidedServer(); + } } @Override @@ -138,7 +180,9 @@ public void reset() { } } - /** Returns true if the input value is a valid endpoint address. */ + /** + * Returns true if the input value is a valid endpoint address. + */ public static boolean validate(String value) { return sPattern.matcher(value).matches(); } diff --git a/app/src/main/java/org/kontalk/client/KontalkConnection.java b/app/src/main/java/org/kontalk/client/KontalkConnection.java index 6145394c2..5a8ca18f5 100644 --- a/app/src/main/java/org/kontalk/client/KontalkConnection.java +++ b/app/src/main/java/org/kontalk/client/KontalkConnection.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,11 +18,17 @@ package org.kontalk.client; +import java.net.InetAddress; +import java.net.UnknownHostException; import java.security.KeyStore; import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; @@ -31,23 +37,27 @@ import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; +import org.apache.http.conn.ssl.AllowAllHostnameVerifier; import org.jivesoftware.smack.ConnectionConfiguration.SecurityMode; import org.jivesoftware.smack.SASLAuthentication; -import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.StanzaListener; import org.jivesoftware.smack.filter.StanzaFilter; import org.jivesoftware.smack.packet.Message; import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.sm.StreamManagementException; import org.jivesoftware.smack.sm.predicates.ForMatchingPredicateOrAfterXStanzas; -import org.jivesoftware.smack.tcp.XMPPTCPConnection; import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration; import org.jivesoftware.smackx.receipts.DeliveryReceipt; import org.jivesoftware.smackx.receipts.DeliveryReceiptRequest; +import org.jxmpp.stringprep.XmppStringprepException; -import android.util.Log; +import android.annotation.SuppressLint; -import org.kontalk.BuildConfig; import org.kontalk.Kontalk; -import org.kontalk.authenticator.LegacyAuthentication; +import org.kontalk.Log; +import org.kontalk.client.smack.XMPPTCPConnection; +import org.kontalk.service.msgcenter.SecureConnectionManager; public class KontalkConnection extends XMPPTCPConnection { @@ -58,19 +68,22 @@ public class KontalkConnection extends XMPPTCPConnection { protected EndpointServer mServer; + /** Actually a copy of the same Smack map, but since we need access to the listeners... */ + private final Map mStanzaIdAcknowledgedListeners = new ConcurrentHashMap<>(); + public KontalkConnection(String resource, EndpointServer server, boolean secure, - boolean acceptAnyCertificate, KeyStore trustStore, String legacyAuthToken) - throws XMPPException { + boolean acceptAnyCertificate, KeyStore trustStore) + throws XmppStringprepException { - this(resource, server, secure, null, null, acceptAnyCertificate, trustStore, legacyAuthToken); + this(resource, server, secure, null, null, acceptAnyCertificate, trustStore); } public KontalkConnection(String resource, EndpointServer server, boolean secure, PrivateKey privateKey, X509Certificate bridgeCert, - boolean acceptAnyCertificate, KeyStore trustStore, String legacyAuthToken) throws XMPPException { + boolean acceptAnyCertificate, KeyStore trustStore) throws XmppStringprepException { super(buildConfiguration(resource, server, secure, - privateKey, bridgeCert, acceptAnyCertificate, trustStore, legacyAuthToken)); + privateKey, bridgeCert, acceptAnyCertificate, trustStore)); mServer = server; @@ -80,23 +93,40 @@ public KontalkConnection(String resource, EndpointServer server, boolean secure, // set custom ack predicate addRequestAckPredicate(AckPredicate.INSTANCE); // set custom packet reply timeout - setPacketReplyTimeout(DEFAULT_PACKET_TIMEOUT); + setReplyTimeout(DEFAULT_PACKET_TIMEOUT); } private static XMPPTCPConnectionConfiguration buildConfiguration(String resource, EndpointServer server, boolean secure, PrivateKey privateKey, X509Certificate bridgeCert, - boolean acceptAnyCertificate, KeyStore trustStore, String legacyAuthToken) { + boolean acceptAnyCertificate, KeyStore trustStore) throws XmppStringprepException { XMPPTCPConnectionConfiguration.Builder builder = XMPPTCPConnectionConfiguration.builder(); + String host = server.getHost(); + InetAddress inetAddress = null; + if (host != null) { + try { + inetAddress = InetAddress.getByName(host); + } + catch (UnknownHostException e) { + Log.w(TAG, "unable to resolve host " + host + ", will try again during connect", e); + } + } + + // connection parameters + if (inetAddress != null) { + builder.setHostAddress(inetAddress); + } + else if (host != null) { + // try a last time through Smack + builder.setHost(host); + } + // else: try with XMPP domain + builder - // connection parameters - .setHost(server.getHost()) .setPort(secure ? server.getSecurePort() : server.getPort()) - .setServiceName(server.getNetwork()) + .setXmppDomain(server.getNetwork()) .setResource(resource) - // the dummy value is not actually used - .setUsernameAndPassword(null, legacyAuthToken != null ? legacyAuthToken : "dummy") // for EXTERNAL .allowEmptyOrNullUsernames() // enable compression @@ -104,11 +134,12 @@ private static XMPPTCPConnectionConfiguration buildConfiguration(String resource // enable encryption .setSecurityMode(secure ? SecurityMode.disabled : SecurityMode.required) // we will send a custom presence - .setSendPresence(false) - // disable session initiation - .setLegacySessionDisabled(true) + .setSendPresence(false); + + if (Log.isDebug()) { // enable debugging - .setDebuggerEnabled(BuildConfig.DEBUG); + builder.enableDefaultDebugger(); + } // setup SSL setupSSL(builder, secure, privateKey, bridgeCert, acceptAnyCertificate, trustStore); @@ -116,10 +147,14 @@ private static XMPPTCPConnectionConfiguration buildConfiguration(String resource return builder.build(); } + @SuppressLint("AllowAllHostnameVerifier") private static void setupSSL(XMPPTCPConnectionConfiguration.Builder builder, - boolean direct, PrivateKey privateKey, X509Certificate bridgeCert, - boolean acceptAnyCertificate, KeyStore trustStore) { + boolean direct, PrivateKey privateKey, X509Certificate bridgeCert, + boolean acceptAnyCertificate, KeyStore trustStore) { try { + // wait for secure connection stuff + SecureConnectionManager.waitForInit(); + SSLContext ctx = SSLContext.getInstance("TLS"); KeyManager[] km = null; @@ -135,11 +170,8 @@ private static void setupSSL(XMPPTCPConnectionConfiguration.Builder builder, km = kmFactory.getKeyManagers(); - // disable PLAIN mechanism if not upgrading from legacy - if (!LegacyAuthentication.isUpgrading()) { - // blacklist PLAIN mechanism - SASLAuthentication.blacklistSASLMechanism("PLAIN"); - } + // blacklist PLAIN mechanism + SASLAuthentication.blacklistSASLMechanism("PLAIN"); } // trust managers @@ -153,17 +185,20 @@ public X509Certificate[] getAcceptedIssuers() { return null; } + @SuppressLint("TrustAllX509TrustManager") @Override public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { } + @SuppressLint("TrustAllX509TrustManager") @Override public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { } } }; + builder.setHostnameVerifier(new AllowAllHostnameVerifier()); } else { @@ -187,9 +222,77 @@ public void checkClientTrusted(X509Certificate[] chain, String authType) } } + @Override + protected void processStanza(Stanza packet) throws InterruptedException { + boolean isMessage = packet instanceof Message; + if (isMessage) { + /* + * We are receiving a message. Suspend SM ack replies because we + * want to wait for our message listener to be invoked and have time + * to store the message to the database. + */ + suspendSmAck(); + } + + super.processStanza(packet); + + if (isMessage) { + /* Resume SM ack replies now. */ + try { + resumeSmAck(); + } + catch (SmackException ignored) { + } + } + } + + public EndpointServer getServer() { + return mServer; + } + + @Override + public StanzaListener addStanzaIdAcknowledgedListener(String id, StanzaListener listener) throws StreamManagementException.StreamManagementNotEnabledException { + AckMultiListener multi = mStanzaIdAcknowledgedListeners.get(id); + StanzaListener old = null; + if (multi == null) { + multi = new AckMultiListener(); + mStanzaIdAcknowledgedListeners.put(id, multi); + old = super.addStanzaIdAcknowledgedListener(id, multi); + } + + multi.addListener(listener); + return old; + } + + @Override + public StanzaListener removeStanzaIdAcknowledgedListener(String id) { + mStanzaIdAcknowledgedListeners.remove(id); + return super.removeStanzaIdAcknowledgedListener(id); + } + + /** An ack listener for handling multiple listeners for a given stanza ID. */ + static final class AckMultiListener implements StanzaListener { + private Collection mListeners = new ConcurrentLinkedQueue<>(); + + @Override + public void processStanza(Stanza packet) throws SmackException.NotConnectedException, InterruptedException, SmackException.NotLoggedInException { + for (StanzaListener l : mListeners) { + l.processStanza(packet); + } + } + + void addListener(StanzaListener listener) { + mListeners.add(listener); + } + + void removeListener(StanzaListener listener) { + mListeners.remove(listener); + } + } + /** * A custom ack predicate that allows ack after a message with a delivery - * receipt, a receipt request or a body, or after 5 stanzas. + * receipt, a receipt request, an out-of-band extension or a body, or after 5 stanzas. */ private static final class AckPredicate extends ForMatchingPredicateOrAfterXStanzas { @@ -202,7 +305,8 @@ public boolean accept(Stanza packet) { return (packet instanceof Message && (((Message) packet).getBody() != null || DeliveryReceipt.from((Message) packet) != null || - DeliveryReceiptRequest.from(packet) != null)); + DeliveryReceiptRequest.from(packet) != null || + packet.hasExtension(OutOfBandData.ELEMENT_NAME, OutOfBandData.NAMESPACE))); } }, 5); } diff --git a/app/src/main/java/org/kontalk/client/NumberValidator.java b/app/src/main/java/org/kontalk/client/NumberValidator.java deleted file mode 100644 index 9381eafe8..000000000 --- a/app/src/main/java/org/kontalk/client/NumberValidator.java +++ /dev/null @@ -1,670 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.client; - -import java.io.IOException; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import java.util.List; -import java.util.Locale; - -import com.google.i18n.phonenumbers.NumberParseException; -import com.google.i18n.phonenumbers.PhoneNumberUtil; -import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat; -import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; - -import org.jivesoftware.smack.AbstractXMPPConnection; -import org.jivesoftware.smack.SmackException; -import org.jivesoftware.smack.StanzaListener; -import org.jivesoftware.smack.XMPPConnection; -import org.jivesoftware.smack.XMPPException; -import org.jivesoftware.smack.filter.StanzaIdFilter; -import org.jivesoftware.smack.packet.IQ; -import org.jivesoftware.smack.packet.Stanza; -import org.jivesoftware.smack.packet.XMPPError; -import org.jivesoftware.smackx.iqregister.packet.Registration; -import org.jivesoftware.smackx.xdata.Form; -import org.jivesoftware.smackx.xdata.FormField; -import org.jivesoftware.smackx.xdata.packet.DataForm; -import org.spongycastle.openpgp.PGPException; - -import android.content.Context; -import android.os.Handler; -import android.os.HandlerThread; -import android.telephony.TelephonyManager; -import android.text.TextUtils; -import android.util.Base64; -import android.util.Log; - -import org.kontalk.crypto.PGP.PGPKeyPairRing; -import org.kontalk.crypto.PersonalKey; -import org.kontalk.crypto.X509Bridge; -import org.kontalk.service.XMPPConnectionHelper; -import org.kontalk.service.XMPPConnectionHelper.ConnectionHelperListener; -import org.kontalk.service.msgcenter.PGPKeyPairRingProvider; -import org.kontalk.util.MessageUtils; - - -/** - * A basic worker thread for doing number validation procedures. - * It handles all the steps defined in phone number validation, from the - * validation request to the received SMS and finally the authentication token - * request. - * @author Daniele Ricci - * @version 1.0 - */ -public class NumberValidator implements Runnable, ConnectionHelperListener { - private static final String TAG = NumberValidator.class.getSimpleName(); - - /** Initialization */ - public static final int STEP_INIT = 0; - /** Validation step (sending phone number and waiting for SMS) */ - public static final int STEP_VALIDATION = 1; - /** Requesting authentication token */ - public static final int STEP_AUTH_TOKEN = 2; - /** Login test for imported key */ - public static final int STEP_LOGIN_TEST = 3; - - public static final int ERROR_THROTTLING = 1; - public static final int ERROR_USER_EXISTS = 2; - - private final EndpointServer.EndpointServerProvider mServerProvider; - private final String mName; - private final String mPhone; - private boolean mForce; - private PersonalKey mKey; - private PGPKeyPairRing mKeyRing; - private X509Certificate mBridgeCert; - private String mPassphrase; - private final Object mKeyLock = new Object(); - - private byte[] mImportedPrivateKey; - private byte[] mImportedPublicKey; - - private final XMPPConnectionHelper mConnector; - private NumberValidatorListener mListener; - private volatile int mStep; - private CharSequence mValidationCode; - - private Thread mThread; - - private HandlerThread mServiceHandler; - private Handler mInternalHandler; - - public NumberValidator(Context context, EndpointServer.EndpointServerProvider serverProvider, - String name, String phone, PersonalKey key, String passphrase) { - mServerProvider = serverProvider; - mName = name; - mPhone = phone; - mKey = key; - mPassphrase = passphrase; - - mConnector = new XMPPConnectionHelper(context.getApplicationContext(), mServerProvider.next(), true); - mConnector.setRetryEnabled(false); - - configure(); - } - - private void configure() { - SmackInitializer.initializeRegistration(); - } - - private void unconfigure() { - SmackInitializer.deinitializeRegistration(); - } - - public void setKey(PersonalKey key) { - synchronized (mKeyLock) { - mKey = key; - mKeyLock.notifyAll(); - } - } - - public void setForce(boolean mForce) { - this.mForce = mForce; - } - - @Override - public PGPKeyPairRingProvider getKeyPairRingProvider() { - // not supported - return null; - } - - public void importKey(byte[] privateKeyData, byte[] publicKeyData) { - mImportedPrivateKey = privateKeyData; - mImportedPublicKey = publicKeyData; - } - - public EndpointServer getServer() { - return mConnector.getServer(); - } - - public synchronized void start() { - if (mThread != null) throw new IllegalArgumentException("already started"); - - // internal handler - mServiceHandler = new HandlerThread(NumberValidator.class.getSimpleName()) { - @Override - protected void onLooperPrepared() { - mInternalHandler = new Handler(getLooper()); - } - }; - mServiceHandler.start(); - - // validator thread - mThread = new Thread(this); - mThread.start(); - } - - @Override - public void run() { - try { - // begin! - if (mStep == STEP_INIT) { - synchronized (mKeyLock) { - if (mKey == null && (mImportedPrivateKey == null || mImportedPublicKey == null)) { - Log.v(TAG, "waiting for key generator"); - try { - // wait endlessly? - mKeyLock.wait(); - } - catch (InterruptedException e) { - mStep = STEP_INIT; - return; - } - Log.v(TAG, "key generation completed " + mKey); - } - } - - // request number validation via sms - mStep = STEP_VALIDATION; - try { - initConnection(); - } - catch (Exception e) { - EndpointServer server = mServerProvider.next(); - if (server != null) { - Log.w(TAG, "connection error - trying next server in list", e); - // run again with new server - mStep = STEP_INIT; - mConnector.setServer(server); - run(); - return; - } - else { - // last server to try, no chance for connection - throw e; - } - } - - final AbstractXMPPConnection conn = mConnector.getConnection(); - - Stanza form = createRegistrationForm(); - - // setup listener for form response - conn.addAsyncStanzaListener(new StanzaListener() { - public void processPacket(Stanza packet) { - int reason = 0; - IQ iq = (IQ) packet; - - // whatever we received, close the connection now - conn.disconnect(); - - if (iq.getType() == IQ.Type.result) { - DataForm response = iq.getExtension("x", "jabber:x:data"); - if (response != null) { - // ok! message will be sent - List iter = response.getFields(); - for (FormField field : iter) { - if (field.getVariable().equals("from")) { - String smsFrom = field.getValues().get(0); - Log.d(TAG, "using sms sender id: " + smsFrom); - mListener.onValidationRequested(NumberValidator.this, smsFrom); - - // prevent error handling - return; - } - } - } - } - - else if (iq.getType() == IQ.Type.error) { - XMPPError error = iq.getError(); - - if (error.getCondition() == XMPPError.Condition.conflict) { - reason = ERROR_USER_EXISTS; - - } - - else if (error.getCondition() == XMPPError.Condition.service_unavailable) { - - if (error.getType() == XMPPError.Type.WAIT) { - reason = ERROR_THROTTLING; - - } - - else { - // no registration support - try the next server - - EndpointServer server = mServerProvider.next(); - if (server != null) { - // run again with new server - mStep = STEP_INIT; - mConnector.setServer(server); - run(); - return; - } - else { - // last server to try, no chance for registration - mListener.onServerCheckFailed(NumberValidator.this); - // onValidationFailed will not be called - reason = -1; - } - } - } - - } - - // validation failed :( - if (reason >= 0) - mListener.onValidationFailed(NumberValidator.this, reason); - - mStep = STEP_INIT; - } - }, new StanzaIdFilter(form.getStanzaId())); - - // send registration form - conn.sendStanza(form); - } - - // sms received, request authentication token - else if (mStep == STEP_AUTH_TOKEN) { - Log.d(TAG, "requesting authentication token"); - - // generate keyring immediately - // needed for connection - if (mKey != null) { - String userId = MessageUtils.sha1(mPhone); - mKeyRing = mKey.storeNetwork(userId, mConnector.getNetwork(), - mName, mPassphrase); - } - else { - mKeyRing = PGPKeyPairRing.load(mImportedPrivateKey, mImportedPublicKey); - } - - // bridge certificate for connection - mBridgeCert = X509Bridge.createCertificate(mKeyRing.publicKey, - mKeyRing.secretKey.getSecretKey(), mPassphrase, null); - - // connect to server - initConnection(); - - // prepare final verification form - Stanza form = createValidationForm(); - - XMPPConnection conn = mConnector.getConnection(); - conn.addAsyncStanzaListener(new StanzaListener() { - public void processPacket(Stanza packet) { - IQ iq = (IQ) packet; - if (iq.getType() == IQ.Type.result) { - DataForm response = iq.getExtension("x", "jabber:x:data"); - if (response != null) { - String publicKey = null; - - // ok! message will be sent - List iter = response.getFields(); - for (FormField field : iter) { - if ("publickey".equals(field.getVariable())) { - publicKey = field.getValues().get(0); - } - } - - if (!TextUtils.isEmpty(publicKey)) { - byte[] publicKeyData; - byte[] privateKeyData; - try { - publicKeyData = Base64.decode(publicKey, Base64.DEFAULT); - privateKeyData = mKeyRing.secretKey.getEncoded(); - } - catch (Exception e) { - // TODO that easy? - publicKeyData = null; - privateKeyData = null; - } - - mListener.onAuthTokenReceived(NumberValidator.this, privateKeyData, publicKeyData); - - // prevent error handling - return; - } - } - } - - // validation failed :( - // TODO check for service-unavailable errors (meaning - // we must call onServerCheckFailed() - mListener.onAuthTokenFailed(NumberValidator.this, -1); - mStep = STEP_INIT; - } - }, new StanzaIdFilter(form.getStanzaId())); - - // send registration form - conn.sendStanza(form); - } - - // try imported key by performing a login test - else if (mStep == STEP_LOGIN_TEST) { - if (mImportedPrivateKey == null || mImportedPublicKey == null) - throw new AssertionError("requesting a login test with no imported key!"); - - // generate keyring immediately - // needed for connection - mKeyRing = PGPKeyPairRing.load(mImportedPrivateKey, mImportedPublicKey); - - // bridge certificate for connection - mBridgeCert = X509Bridge.createCertificate(mKeyRing.publicKey, - mKeyRing.secretKey.getSecretKey(), mPassphrase, null); - - try { - // connect to server - initConnection(); - } - catch (Exception e) { - // login test failed, run again normally - mStep = STEP_INIT; - // mark server as dirty - mConnector.setServer(mConnector.getServer()); - run(); - return; - } - - // login successful!!! - if (mListener != null) - mListener.onAuthTokenReceived(this, - mKeyRing.secretKey.getEncoded(), mKeyRing.publicKey.getEncoded()); - } - } - catch (Throwable e) { - if (mListener != null) - mListener.onError(this, e); - - mStep = STEP_INIT; - } - } - - /** - * Shuts down this thread gracefully. - */ - public synchronized void shutdown() { - Log.w(TAG, "shutting down"); - try { - if (mThread != null) { - mInternalHandler.post(new Runnable() { - public void run() { - try { - mConnector.getConnection().disconnect(); - mServiceHandler.quit(); - // null everything - mServiceHandler = null; - mInternalHandler = null; - } - catch (Exception e) { - // ignored - } - } - }); - mThread.interrupt(); - mThread.join(); - mThread = null; - } - - unconfigure(); - } - catch (Exception e) { - // ignored - } - Log.w(TAG, "exiting"); - } - - /** Forcibly inputs the validation code. */ - public void manualInput(CharSequence code) { - mValidationCode = code; - mStep = STEP_AUTH_TOKEN; - // next start call will trigger the next condition - mThread = null; - } - - public void testImport() { - mStep = STEP_LOGIN_TEST; - // next start call will trigger the next condition - mThread = null; - } - - private void initConnection() throws XMPPException, SmackException, - PGPException, KeyStoreException, NoSuchProviderException, - NoSuchAlgorithmException, CertificateException, - IOException { - - if (!mConnector.isConnected() || mConnector.isServerDirty()) { - mConnector.setListener(this); - PersonalKey key = null; - if (mImportedPrivateKey != null && mImportedPublicKey != null) { - PGPKeyPairRing ring = PGPKeyPairRing.load(mImportedPrivateKey, mImportedPublicKey); - key = PersonalKey.load(ring.secretKey, ring.publicKey, mPassphrase, mBridgeCert); - } - else if (mKey != null) { - key = mKey.copy(mBridgeCert); - } - - mConnector.connectOnce(key, mStep == STEP_LOGIN_TEST); - } - } - - private Stanza createRegistrationForm() { - Registration iq = new Registration(); - iq.setType(IQ.Type.set); - iq.setTo(mConnector.getConnection().getServiceName()); - Form form = new Form(DataForm.Type.submit); - - FormField type = new FormField("FORM_TYPE"); - type.setType(FormField.Type.hidden); - type.addValue(Registration.NAMESPACE); - form.addField(type); - - FormField phone = new FormField("phone"); - phone.setLabel("Phone number"); - phone.setType(FormField.Type.text_single); - phone.addValue(mPhone); - form.addField(phone); - - if (mForce) { - FormField force = new FormField("force"); - force.setLabel("Phone number"); - force.setType(FormField.Type.bool); - force.addValue(String.valueOf(mForce)); - form.addField(force); - } - - iq.addExtension(form.getDataFormToSend()); - return iq; - } - - private Stanza createValidationForm() throws IOException { - Registration iq = new Registration(); - iq.setType(IQ.Type.set); - iq.setTo(mConnector.getConnection().getServiceName()); - Form form = new Form(DataForm.Type.submit); - - FormField type = new FormField("FORM_TYPE"); - type.setType(FormField.Type.hidden); - type.addValue("http://kontalk.org/protocol/register#code"); - form.addField(type); - - FormField code = new FormField("code"); - code.setLabel("Validation code"); - code.setType(FormField.Type.text_single); - code.addValue(mValidationCode.toString()); - form.addField(code); - - iq.addExtension(form.getDataFormToSend()); - return iq; - } - - public synchronized void setListener(NumberValidatorListener listener) { - mListener = listener; - } - - public abstract interface NumberValidatorListener { - /** Called if an exception get thrown. */ - public void onError(NumberValidator v, Throwable e); - - /** Called if the server doesn't support registration/auth tokens. */ - public void onServerCheckFailed(NumberValidator v); - - /** Called on confirmation that the validation SMS is being sent. */ - public void onValidationRequested(NumberValidator v, String sender); - - /** Called if phone number validation failed. */ - public void onValidationFailed(NumberValidator v, int reason); - - /** Called on receiving of authentication token. */ - public void onAuthTokenReceived(NumberValidator v, byte[] privateKey, byte[] publicKey); - - /** Called if validation code has not been verified. */ - public void onAuthTokenFailed(NumberValidator v, int reason); - } - - /** - * Converts pretty much any phone number into E.164 format. - * @param myNumber used to take the country code if not found in the number - * @param lastResortCc manual country code last resort - * @throws IllegalArgumentException if no country code is available. - */ - public static String fixNumber(Context context, String number, String myNumber, int lastResortCc) - throws NumberParseException { - - final TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); - String myRegionCode = tm.getSimCountryIso(); - if (myRegionCode != null) - myRegionCode = myRegionCode.toUpperCase(Locale.US); - - PhoneNumberUtil util = PhoneNumberUtil.getInstance(); - try { - if (myNumber != null) { - PhoneNumber myNum = util.parse(myNumber, myRegionCode); - // use region code found in my number - myRegionCode = util.getRegionCodeForNumber(myNum); - } - } - catch (NumberParseException e) { - // ehm :) - } - - PhoneNumber parsedNum; - try { - parsedNum = util.parse(number, myRegionCode); - } - catch (NumberParseException e) { - // parse failed with default region code, try last resort - if (lastResortCc > 0) { - myRegionCode = util.getRegionCodeForCountryCode(lastResortCc); - parsedNum = util.parse(number, myRegionCode); - } - else - throw e; - } - - // a NumberParseException would have been thrown at this point - return util.format(parsedNum, PhoneNumberFormat.E164); - } - - /** Returns the (parsed) number stored in this device SIM card. */ - public static PhoneNumber getMyNumber(Context context) { - try { - final TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); - final String regionCode = tm.getSimCountryIso().toUpperCase(Locale.US); - return PhoneNumberUtil.getInstance().parse(tm.getLine1Number(), regionCode); - } - catch (Exception e) { - return null; - } - } - - /** Returns the localized region name for the given region code. */ - public static String getRegionDisplayName(String regionCode, Locale language) { - return (regionCode == null || regionCode.equals("ZZ") || - regionCode.equals(PhoneNumberUtil.REGION_CODE_FOR_NON_GEO_ENTITY)) - ? "" : new Locale("", regionCode).getDisplayCountry(language); - } - - @Override - public void connectionClosed() { - // not used - } - - @Override - public void connectionClosedOnError(Exception e) { - // not used - } - - @Override - public void reconnectingIn(int seconds) { - // not used - } - - @Override - public void reconnectionFailed(Exception e) { - // not used - } - - @Override - public void reconnectionSuccessful() { - // not used - } - - @Override - public void aborted(Exception e) { - // TODO Auto-generated method stub - - } - - @Override - public void created(XMPPConnection conn) { - // not used - } - - @Override - public void connected(XMPPConnection conn) { - // not used - } - - @Override - public void authenticated(XMPPConnection conn, boolean resumed) { - // not used - } - - @Override - public void authenticationFailed() { - // not used - } - -} diff --git a/app/src/main/java/org/kontalk/client/ServerList.java b/app/src/main/java/org/kontalk/client/ServerList.java index 87b760278..6dfe49a37 100644 --- a/app/src/main/java/org/kontalk/client/ServerList.java +++ b/app/src/main/java/org/kontalk/client/ServerList.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,11 +18,16 @@ package org.kontalk.client; +import java.io.IOException; +import java.text.DateFormat; +import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.LinkedList; import java.util.List; +import java.util.Locale; +import java.util.Properties; import java.util.Random; @@ -32,8 +37,10 @@ */ public class ServerList extends ArrayList { private static final long serialVersionUID = 1L; + private static DateFormat sTimestampFormat = + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US); - protected final Date mDate; + private final Date mDate; private final Random mSeed = new Random(); @@ -57,6 +64,36 @@ public EndpointServer random() { get(mSeed.nextInt(size())) : null; } + public Properties toProperties() { + Properties prop = new Properties(); + Date now = new Date(); + prop.setProperty("timestamp", sTimestampFormat.format(now)); + + for (int i = 0; i < size(); i++) { + String item = get(i).toString(); + prop.setProperty("server" + (i + 1), item); + } + return prop; + } + + public static ServerList fromProperties(Properties props) throws IOException { + try { + Date date = sTimestampFormat.parse(props.getProperty("timestamp")); + ServerList list = new ServerList(date); + int i = 1; + String server; + while ((server = props.getProperty("server" + i)) != null) { + list.add(new EndpointServer(server)); + i++; + } + + return list; + } + catch (Exception e) { + throw new IOException("parse error", e); + } + } + /** A simple server provider backed by a server list. */ public static class ServerListProvider implements EndpointServer.EndpointServerProvider { private ServerList mList; @@ -64,7 +101,15 @@ public static class ServerListProvider implements EndpointServer.EndpointServerP public ServerListProvider(ServerList list) { mList = new ServerList(list.getDate(), list); - mUsed = new LinkedList(); + mUsed = new LinkedList<>(); + } + + @Override + public ServerList list() { + ServerList list = new ServerList(mList.getDate()); + list.addAll(mList); + list.addAll(mUsed); + return list; } @Override diff --git a/app/src/main/java/org/kontalk/client/SmackInitializer.java b/app/src/main/java/org/kontalk/client/SmackInitializer.java index 858de01f7..3ed397072 100644 --- a/app/src/main/java/org/kontalk/client/SmackInitializer.java +++ b/app/src/main/java/org/kontalk/client/SmackInitializer.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,6 +28,7 @@ import org.jivesoftware.smackx.iqregister.provider.RegistrationProvider; import org.jivesoftware.smackx.iqversion.VersionManager; import org.jivesoftware.smackx.xdata.provider.DataFormProvider; +import org.minidns.dnsserverlookup.android21.AndroidUsingLinkProperties; import android.content.Context; @@ -46,6 +47,10 @@ public static void initialize(Context context) { if (!sInitialized) { disableSmackDefault(); + // DNS for Android stuff + // won't be used if not needed/available + AndroidUsingLinkProperties.setup(context); + InputStream is = context.getResources().openRawResource(R.raw.service); ProviderManager.addLoader(new ProviderFileLoader(is)); try { @@ -76,17 +81,20 @@ public static void initializeRegistration() { // not moving these into configuration since they are not loaded often ProviderManager.addIQProvider("query", "jabber:iq:register", new RegistrationProvider()); ProviderManager.addExtensionProvider("x", "jabber:x:data", new DataFormProvider()); + ProviderManager.addExtensionProvider(Account.ELEMENT_NAME, Account.NAMESPACE, new Account.Provider()); } public static void deinitializeRegistration() { ProviderManager.removeIQProvider("query", "jabber:iq:register"); ProviderManager.removeExtensionProvider("x", "jabber:x:data"); + ProviderManager.removeExtensionProvider(Account.ELEMENT_NAME, Account.NAMESPACE); } private static void disableSmackDefault() { // disable extensions and experimental - we will load our own extensions SmackConfiguration.addDisabledSmackClass("org.jivesoftware.smack.extensions.ExtensionsInitializer"); SmackConfiguration.addDisabledSmackClass("org.jivesoftware.smack.experimental.ExperimentalInitializer"); + SmackConfiguration.addDisabledSmackClass("org.jivesoftware.smack.ReconnectionManager"); } } diff --git a/app/src/main/java/org/kontalk/client/smack/AbstractXMPPConnectionWrapper.java b/app/src/main/java/org/kontalk/client/smack/AbstractXMPPConnectionWrapper.java new file mode 100644 index 000000000..930b0b437 --- /dev/null +++ b/app/src/main/java/org/kontalk/client/smack/AbstractXMPPConnectionWrapper.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.client.smack; + +import org.jivesoftware.smack.AbstractXMPPConnection; +import org.jivesoftware.smack.ConnectionConfiguration; + + +/** + * This class exists only to provide access to {@link #asyncGo(Runnable)}. + * Do not use it for other purposes. + */ +public abstract class AbstractXMPPConnectionWrapper extends AbstractXMPPConnection { + protected AbstractXMPPConnectionWrapper(ConnectionConfiguration configuration) { + super(configuration); + } + + public static void asyncGo(Runnable runnable) { + AbstractXMPPConnection.asyncGo(runnable); + } +} diff --git a/app/src/main/java/org/kontalk/client/smack/BundleAndDefer.java b/app/src/main/java/org/kontalk/client/smack/BundleAndDefer.java new file mode 100644 index 000000000..daa0ff651 --- /dev/null +++ b/app/src/main/java/org/kontalk/client/smack/BundleAndDefer.java @@ -0,0 +1,47 @@ +/** + * + * Copyright 2015 Florian Schmaus + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.kontalk.client.smack; + +import java.util.concurrent.atomic.AtomicBoolean; + + +public class BundleAndDefer { + + private final AtomicBoolean isStopped; + + BundleAndDefer(AtomicBoolean isStopped) { + this.isStopped = isStopped; + } + + /** + * Stop the bundle and defer mechanism that was started when this instance of {@link BundleAndDefer} was emitted by + * Smack. + *

+ * It is possible that the defer period already expired when this is invoked. In this case this method is basically + * a no-op. + *

+ */ + public void stopCurrentBundleAndDefer() { + synchronized (isStopped) { + if (isStopped.get()) { + return; + } + isStopped.set(true); + isStopped.notify(); + } + } +} diff --git a/app/src/main/java/org/kontalk/client/smack/BundleAndDeferCallback.java b/app/src/main/java/org/kontalk/client/smack/BundleAndDeferCallback.java new file mode 100644 index 000000000..4ecd7096f --- /dev/null +++ b/app/src/main/java/org/kontalk/client/smack/BundleAndDeferCallback.java @@ -0,0 +1,49 @@ +/** + * + * Copyright 2015 Florian Schmaus + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.kontalk.client.smack; + +/** + * This callback is used to get the current value of the period in which Smack does bundle and defer + * outgoing stanzas. + *

+ * Smack will bundle and defer stanzas if the connection is authenticated + * and if a bundle and defer callback is set, either via + * {@link XMPPTCPConnection#setDefaultBundleAndDeferCallback(BundleAndDeferCallback)} or + * {@link XMPPTCPConnection#setBundleandDeferCallback(BundleAndDeferCallback)}, and + * {@link #getBundleAndDeferMillis(BundleAndDefer)} returns a positive value. In a mobile environment, bundling + * and deferring outgoing stanzas may reduce battery consumption. It heavily depends on the + * environment, but recommend values for the bundle and defer period range from 20-60 seconds. But + * keep in mind that longer periods decrease the realtime aspect of Smack. + *

+ *

+ * Smack will invoke the callback when it needs to know the length of the bundle and defer period. + * If {@link #getBundleAndDeferMillis(BundleAndDefer)} returns 0 or a negative value, then the + * stanzas will send immediately. You can also prematurely abort the bundling of stanzas by calling + * {@link BundleAndDefer#stopCurrentBundleAndDefer()}. + *

+ */ +public interface BundleAndDeferCallback { + + /** + * Return the bundle and defer period used by Smack in milliseconds. + * + * @param bundleAndDefer used to premature abort bundle and defer. + * @return the bundle and defer period in milliseconds. + */ + int getBundleAndDeferMillis(BundleAndDefer bundleAndDefer); + +} diff --git a/app/src/main/java/org/kontalk/client/smack/SmackFuture.java b/app/src/main/java/org/kontalk/client/smack/SmackFuture.java new file mode 100644 index 000000000..d8756ef4c --- /dev/null +++ b/app/src/main/java/org/kontalk/client/smack/SmackFuture.java @@ -0,0 +1,313 @@ +/** + * + * Copyright 2017-2018 Florian Schmaus + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.kontalk.client.smack; + +import java.io.IOException; +import java.net.Socket; +import java.net.SocketAddress; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.logging.Level; +import java.util.logging.Logger; + +import javax.net.SocketFactory; + +import org.jivesoftware.smack.AbstractXMPPConnection; +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.util.CallbackRecipient; +import org.jivesoftware.smack.util.ExceptionCallback; +import org.jivesoftware.smack.util.SuccessCallback; + +public abstract class SmackFuture implements Future, CallbackRecipient { + + private static final Logger LOGGER = Logger.getLogger(SmackFuture.class.getName()); + + private boolean cancelled; + + protected V result; + + protected E exception; + + private SuccessCallback successCallback; + + private ExceptionCallback exceptionCallback; + + @Override + public final synchronized boolean cancel(boolean mayInterruptIfRunning) { + if (isDone()) { + return false; + } + + cancelled = true; + + if (mayInterruptIfRunning) { + notifyAll(); + } + + return true; + } + + @Override + public final synchronized boolean isCancelled() { + return cancelled; + } + + @Override + public final synchronized boolean isDone() { + return result != null; + } + + @Override + public CallbackRecipient onSuccess(SuccessCallback successCallback) { + this.successCallback = successCallback; + maybeInvokeCallbacks(); + return this; + } + + @Override + public CallbackRecipient onError(ExceptionCallback exceptionCallback) { + this.exceptionCallback = exceptionCallback; + maybeInvokeCallbacks(); + return this; + } + + private V getOrThrowExecutionException() throws ExecutionException { + assert (result != null || exception != null || cancelled); + if (result != null) { + return result; + } + if (exception != null) { + throw new ExecutionException(exception); + } + + assert (cancelled); + throw new CancellationException(); + } + + @Override + public final synchronized V get() throws InterruptedException, ExecutionException { + while (result == null && exception == null && !cancelled) { + futureWait(); + } + + return getOrThrowExecutionException(); + } + + public final synchronized V getOrThrow() throws E, InterruptedException { + while (result == null && exception == null && !cancelled) { + futureWait(); + } + + if (exception != null) { + throw exception; + } + + if (cancelled) { + throw new CancellationException(); + } + + assert result != null; + return result; + } + + @Override + public final synchronized V get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + final long deadline = System.currentTimeMillis() + unit.toMillis(timeout); + while (result != null && exception != null) { + final long waitTimeRemaining = deadline - System.currentTimeMillis(); + if (waitTimeRemaining > 0) { + futureWait(waitTimeRemaining); + } + } + + if (cancelled) { + throw new CancellationException(); + } + + if (result == null || exception == null) { + throw new TimeoutException(); + } + + return getOrThrowExecutionException(); + } + + protected final synchronized void maybeInvokeCallbacks() { + if (cancelled) { + return; + } + + if (result != null && successCallback != null) { + AbstractXMPPConnectionWrapper.asyncGo(new Runnable() { + @Override + public void run() { + successCallback.onSuccess(result); + } + }); + } + else if (exception != null && exceptionCallback != null) { + AbstractXMPPConnectionWrapper.asyncGo(new Runnable() { + @Override + public void run() { + exceptionCallback.processException(exception); + } + }); + } + } + + protected final void futureWait() throws InterruptedException { + futureWait(0); + } + + @SuppressWarnings("WaitNotInLoop") + protected void futureWait(long timeout) throws InterruptedException { + wait(timeout); + } + + public static class InternalSmackFuture extends SmackFuture { + public final synchronized void setResult(V result) { + this.result = result; + this.notifyAll(); + + maybeInvokeCallbacks(); + } + + public final synchronized void setException(E exception) { + this.exception = exception; + this.notifyAll(); + + maybeInvokeCallbacks(); + } + } + + public static class SocketFuture extends InternalSmackFuture { + private final Socket socket; + + private final Object wasInterruptedLock = new Object(); + + private boolean wasInterrupted; + + public SocketFuture(SocketFactory socketFactory) throws IOException { + socket = socketFactory.createSocket(); + } + + @Override + protected void futureWait(long timeout) throws InterruptedException { + try { + super.futureWait(timeout); + } catch (InterruptedException interruptedException) { + synchronized (wasInterruptedLock) { + wasInterrupted = true; + if (!socket.isClosed()) { + closeSocket(); + } + } + throw interruptedException; + } + } + + public void connectAsync(final SocketAddress socketAddress, final int timeout) { + AbstractXMPPConnectionWrapper.asyncGo(new Runnable() { + @Override + public void run() { + try { + socket.connect(socketAddress, timeout); + } + catch (IOException e) { + setException(e); + return; + } + synchronized (wasInterruptedLock) { + if (wasInterrupted) { + closeSocket(); + return; + } + } + setResult(socket); + } + }); + } + + private void closeSocket() { + try { + socket.close(); + } + catch (IOException ioException) { + LOGGER.log(Level.WARNING, "Could not close socket", ioException); + } + } + } + + public abstract static class InternalProcessStanzaSmackFuture extends InternalSmackFuture + implements StanzaListener, ExceptionCallback { + + /** + * This method checks if the given exception is not fatal. If this method returns false, + * then the future will automatically set the given exception as failure reason and notify potential waiting + * threads. + * + * @param exception the exception to check. + * @return true if the exception is not fatal, false otherwise. + */ + protected abstract boolean isNonFatalException(E exception); + + protected abstract void handleStanza(Stanza stanza); + + @Override + public final synchronized void processException(E exception) { + if (!isNonFatalException(exception)) { + this.exception = exception; + this.notifyAll(); + + maybeInvokeCallbacks(); + } + } + + /** + * Wrapper method for {@link #handleStanza(Stanza)}. Note that this method is synchronized. + */ + @Override + public final synchronized void processStanza(Stanza stanza) { + handleStanza(stanza); + } + } + + /** + * A simple version of InternalSmackFuture which implements isNonFatalException(E) as always returning + * false method. + * + * @param + */ + public abstract static class SimpleInternalProcessStanzaSmackFuture + extends InternalProcessStanzaSmackFuture { + @Override + protected boolean isNonFatalException(E exception) { + return false; + } + } + + public static SmackFuture from(V result) { + InternalSmackFuture future = new InternalSmackFuture<>(); + future.setResult(result); + return future; + } + +} diff --git a/app/src/main/java/org/kontalk/client/smack/XMPPTCPConnection.java b/app/src/main/java/org/kontalk/client/smack/XMPPTCPConnection.java new file mode 100644 index 000000000..464374586 --- /dev/null +++ b/app/src/main/java/org/kontalk/client/smack/XMPPTCPConnection.java @@ -0,0 +1,2153 @@ +/* + * + * Copyright 2003-2007 Jive Software. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.kontalk.client.smack; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.lang.reflect.Constructor; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.Security; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +import javax.net.SocketFactory; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.PasswordCallback; + +import org.jivesoftware.smack.AbstractConnectionListener; +import org.jivesoftware.smack.AbstractXMPPConnection; +import org.jivesoftware.smack.ConnectionConfiguration; +import org.jivesoftware.smack.ConnectionConfiguration.DnssecMode; +import org.jivesoftware.smack.ConnectionConfiguration.SecurityMode; +import org.jivesoftware.smack.SmackConfiguration; +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.SmackException.AlreadyConnectedException; +import org.jivesoftware.smack.SmackException.AlreadyLoggedInException; +import org.jivesoftware.smack.SmackException.ConnectionException; +import org.jivesoftware.smack.SmackException.NoResponseException; +import org.jivesoftware.smack.SmackException.NotConnectedException; +import org.jivesoftware.smack.SmackException.NotLoggedInException; +import org.jivesoftware.smack.SmackException.SecurityRequiredByServerException; +import org.jivesoftware.smack.SmackException.SmackWrappedException; +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.SynchronizationPoint; +import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.XMPPException.FailedNonzaException; +import org.jivesoftware.smack.XMPPException.StreamErrorException; +import org.jivesoftware.smack.compress.packet.Compress; +import org.jivesoftware.smack.compress.packet.Compressed; +import org.jivesoftware.smack.compression.XMPPInputOutputStream; +import org.jivesoftware.smack.filter.StanzaFilter; +import org.jivesoftware.smack.packet.Element; +import org.jivesoftware.smack.packet.IQ; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.packet.Nonza; +import org.jivesoftware.smack.packet.Presence; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.packet.StartTls; +import org.jivesoftware.smack.packet.StreamError; +import org.jivesoftware.smack.packet.StreamOpen; +import org.jivesoftware.smack.proxy.ProxyInfo; +import org.jivesoftware.smack.sasl.packet.SaslStreamElements; +import org.jivesoftware.smack.sasl.packet.SaslStreamElements.Challenge; +import org.jivesoftware.smack.sasl.packet.SaslStreamElements.SASLFailure; +import org.jivesoftware.smack.sasl.packet.SaslStreamElements.Success; +import org.jivesoftware.smack.sm.SMUtils; +import org.jivesoftware.smack.sm.StreamManagementException; +import org.jivesoftware.smack.sm.StreamManagementException.StreamIdDoesNotMatchException; +import org.jivesoftware.smack.sm.StreamManagementException.StreamManagementCounterError; +import org.jivesoftware.smack.sm.StreamManagementException.StreamManagementNotEnabledException; +import org.jivesoftware.smack.sm.packet.StreamManagement; +import org.jivesoftware.smack.sm.packet.StreamManagement.AckAnswer; +import org.jivesoftware.smack.sm.packet.StreamManagement.AckRequest; +import org.jivesoftware.smack.sm.packet.StreamManagement.Enable; +import org.jivesoftware.smack.sm.packet.StreamManagement.Enabled; +import org.jivesoftware.smack.sm.packet.StreamManagement.Failed; +import org.jivesoftware.smack.sm.packet.StreamManagement.Resume; +import org.jivesoftware.smack.sm.packet.StreamManagement.Resumed; +import org.jivesoftware.smack.sm.packet.StreamManagement.StreamManagementFeature; +import org.jivesoftware.smack.sm.predicates.Predicate; +import org.jivesoftware.smack.sm.provider.ParseStreamManagement; +import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration; +import org.jivesoftware.smack.util.ArrayBlockingQueueWithShutdown; +import org.jivesoftware.smack.util.Async; +import org.jivesoftware.smack.util.DNSUtil; +import org.jivesoftware.smack.util.PacketParserUtils; +import org.jivesoftware.smack.util.StringUtils; +import org.jivesoftware.smack.util.TLSUtils; +import org.jivesoftware.smack.util.XmlStringBuilder; +import org.jivesoftware.smack.util.dns.HostAddress; +import org.jivesoftware.smack.util.dns.SmackDaneProvider; +import org.jivesoftware.smack.util.dns.SmackDaneVerifier; + +import org.jxmpp.jid.impl.JidCreate; +import org.jxmpp.jid.parts.Resourcepart; +import org.jxmpp.stringprep.XmppStringprepException; +import org.jxmpp.util.XmppStringUtils; +import org.minidns.dnsname.DnsName; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +/** + * Creates a socket connection to an XMPP server. This is the default connection + * to an XMPP server and is specified in the XMPP Core (RFC 6120). + * + * @see XMPPConnection + * @author Matt Tucker + */ +public class XMPPTCPConnection extends AbstractXMPPConnection { + + private static final int QUEUE_SIZE = 500; + private static final Logger LOGGER = Logger.getLogger(XMPPTCPConnection.class.getName()); + + /** + * The socket which is used for this connection. + */ + private Socket socket; + + /** + * + */ + private boolean disconnectedButResumeable = false; + + private SSLSocket secureSocket; + + private final Semaphore readerWriterSemaphore = new Semaphore(2); + + /** + * Protected access level because of unit test purposes + */ + protected final PacketWriter packetWriter = new PacketWriter(); + + /** + * Protected access level because of unit test purposes + */ + protected final PacketReader packetReader = new PacketReader(); + + private final SynchronizationPoint initialOpenStreamSend = new SynchronizationPoint<>( + this, "initial open stream element send to server"); + + /** + * + */ + private final SynchronizationPoint maybeCompressFeaturesReceived = new SynchronizationPoint( + this, "stream compression feature"); + + /** + * + */ + private final SynchronizationPoint compressSyncPoint = new SynchronizationPoint<>( + this, "stream compression"); + + /** + * A synchronization point which is successful if this connection has received the closing + * stream element from the remote end-point, i.e. the server. + */ + private final SynchronizationPoint closingStreamReceived = new SynchronizationPoint<>( + this, "stream closing element received"); + + /** + * The default bundle and defer callback, used for new connections. + * @see #bundleAndDeferCallback + */ + private static BundleAndDeferCallback defaultBundleAndDeferCallback; + + /** + * The used bundle and defer callback. + *

+ * Although this field may be set concurrently, the 'volatile' keyword was deliberately not added, in order to avoid + * having a 'volatile' read within the writer threads loop. + *

+ */ + private BundleAndDeferCallback bundleAndDeferCallback = defaultBundleAndDeferCallback; + + private static boolean useSmDefault = true; + + private static boolean useSmResumptionDefault = true; + + /** + * The stream ID of the stream that is currently resumable, ie. the stream we hold the state + * for in {@link #clientHandledStanzasCount}, {@link #serverHandledStanzasCount} and + * {@link #unacknowledgedStanzas}. + */ + private String smSessionId; + + private final SynchronizationPoint smResumedSyncPoint = new SynchronizationPoint<>( + this, "stream resumed element"); + + private final SynchronizationPoint smEnabledSyncPoint = new SynchronizationPoint<>( + this, "stream enabled element"); + + /** + * The client's preferred maximum resumption time in seconds. + */ + private int smClientMaxResumptionTime = -1; + + /** + * The server's preferred maximum resumption time in seconds. + */ + private int smServerMaxResumptionTime = -1; + + /** + * Indicates whether Stream Management (XEP-198) should be used if it's supported by the server. + */ + private boolean useSm = useSmDefault; + private boolean useSmResumption = useSmResumptionDefault; + + /** + * The counter that the server sends the client about it's current height. For example, if the server sends + * {@code }, then this will be set to 42 (while also handling the {@link #unacknowledgedStanzas} queue). + */ + private long serverHandledStanzasCount = 0; + + /** + * The counter for stanzas handled ("received") by the client. + *

+ * Note that we don't need to synchronize this counter. Although JLS 17.7 states that reads and writes to longs are + * not atomic, it guarantees that there are at most 2 separate writes, one to each 32-bit half. And since + * {@link SMUtils#incrementHeight(long)} masks the lower 32 bit, we only operate on one half of the long and + * therefore have no concurrency problem because the read/write operations on one half are guaranteed to be atomic. + *

+ */ + private long clientHandledStanzasCount = 0; + + /** + * Whether we have a pending ack request waiting for a reply. + */ + private boolean ackPending; + + /** + * Object used to synchronize access to {@link #smAckSuspend} et al. + */ + private final Object clientHandledStanzasCountLock = new Object(); + + private BlockingQueue unacknowledgedStanzas; + + /** + * Set to true if Stream Management was at least once enabled for this connection. + */ + private boolean smWasEnabledAtLeastOnce = false; + + /** + * This listeners are invoked for every stanza that got acknowledged. + *

+ * We use a {@link ConcurrentLinkedQueue} here in order to allow the listeners to remove + * themselves after they have been invoked. + *

+ */ + private final Collection stanzaAcknowledgedListeners = new ConcurrentLinkedQueue<>(); + + /** + * These listeners are invoked for every stanza that got dropped. + *

+ * We use a {@link ConcurrentLinkedQueue} here in order to allow the listeners to remove + * themselves after they have been invoked. + *

+ */ + private final Collection stanzaDroppedListeners = new ConcurrentLinkedQueue<>(); + + /** + * This listeners are invoked for a acknowledged stanza that has the given stanza ID. They will + * only be invoked once and automatically removed after that. + */ + private final Map stanzaIdAcknowledgedListeners = new ConcurrentHashMap<>(); + + /** + * Predicates that determine if an stream management ack should be requested from the server. + *

+ * We use a linked hash set here, so that the order how the predicates are added matches the + * order in which they are invoked in order to determine if an ack request should be send or not. + *

+ */ + private final Set requestAckPredicates = new LinkedHashSet<>(); + + @SuppressWarnings("HidingField") + private final XMPPTCPConnectionConfiguration config; + + /** + * If true, ack packets to the server are suspended until further notice. + */ + private volatile boolean smAckSuspend; + + /** + * Creates a new XMPP connection over TCP (optionally using proxies). + *

+ * Note that XMPPTCPConnection constructors do not establish a connection to the server + * and you must call {@link #connect()}. + *

+ * + * @param config the connection configuration. + */ + public XMPPTCPConnection(XMPPTCPConnectionConfiguration config) { + super(config); + this.config = config; + addConnectionListener(new AbstractConnectionListener() { + @Override + public void connectionClosedOnError(Exception e) { + if (e instanceof XMPPException.StreamErrorException || e instanceof StreamManagementException) { + dropSmState(); + } + } + }); + } + + /** + * Creates a new XMPP connection over TCP. + *

+ * Note that {@code jid} must be the bare JID, e.g. "user@example.org". More fine-grained control over the + * connection settings is available using the {@link #XMPPTCPConnection(XMPPTCPConnectionConfiguration)} + * constructor. + *

+ * + * @param jid the bare JID used by the client. + * @param password the password or authentication token. + * @throws XmppStringprepException + */ + public XMPPTCPConnection(CharSequence jid, String password) throws XmppStringprepException { + this(XmppStringUtils.parseLocalpart(jid.toString()), password, XmppStringUtils.parseDomain(jid.toString())); + } + + /** + * Creates a new XMPP connection over TCP. + *

+ * This is the simplest constructor for connecting to an XMPP server. Alternatively, + * you can get fine-grained control over connection settings using the + * {@link #XMPPTCPConnection(XMPPTCPConnectionConfiguration)} constructor. + *

+ * @param username + * @param password + * @param serviceName + * @throws XmppStringprepException + */ + public XMPPTCPConnection(CharSequence username, String password, String serviceName) throws XmppStringprepException { + this(XMPPTCPConnectionConfiguration.builder().setUsernameAndPassword(username, password).setXmppDomain( + JidCreate.domainBareFrom(serviceName)).build()); + } + + @Override + protected void throwNotConnectedExceptionIfAppropriate() throws NotConnectedException { + if (packetWriter == null) { + throw new NotConnectedException(); + } + packetWriter.throwNotConnectedExceptionIfDoneAndResumptionNotPossible(); + } + + @Override + protected void throwAlreadyConnectedExceptionIfAppropriate() throws AlreadyConnectedException { + if (isConnected() && !disconnectedButResumeable) { + throw new AlreadyConnectedException(); + } + } + + @Override + protected void throwAlreadyLoggedInExceptionIfAppropriate() throws AlreadyLoggedInException { + if (isAuthenticated() && !disconnectedButResumeable) { + throw new AlreadyLoggedInException(); + } + } + + @Override + protected void afterSuccessfulLogin(final boolean resumed) throws NotConnectedException, InterruptedException { + // Reset the flag in case it was set + disconnectedButResumeable = false; + super.afterSuccessfulLogin(resumed); + } + + @Override + protected synchronized void loginInternal(String username, String password, Resourcepart resource) throws XMPPException, + SmackException, IOException, InterruptedException { + // Authenticate using SASL + SSLSession sslSession = secureSocket != null ? secureSocket.getSession() : null; + saslAuthentication.authenticate(username, password, config.getAuthzid(), sslSession); + + // Wait for stream features after the authentication. + // TODO: The name of this synchronization point "maybeCompressFeaturesReceived" is not perfect. It should be + // renamed to "streamFeaturesAfterAuthenticationReceived". + maybeCompressFeaturesReceived.checkIfSuccessOrWait(); + + // If compression is enabled then request the server to use stream compression. XEP-170 + // recommends to perform stream compression before resource binding. + maybeEnableCompression(); + + if (isSmResumptionPossible()) { + smResumedSyncPoint.sendAndWaitForResponse(new Resume(clientHandledStanzasCount, smSessionId)); + if (smResumedSyncPoint.wasSuccessful()) { + // We successfully resumed the stream, be done here + afterSuccessfulLogin(true); + return; + } + // SM resumption failed, what Smack does here is to report success of + // lastFeaturesReceived in case of sm resumption was answered with 'failed' so that + // normal resource binding can be tried. + LOGGER.fine("Stream resumption failed, continuing with normal stream establishment process"); + } + + List previouslyUnackedStanzas = new LinkedList(); + if (unacknowledgedStanzas != null) { + // There was a previous connection with SM enabled but that was either not resumable or + // failed to resume. Make sure that we (re-)send the unacknowledged stanzas. + // HACK this line below was commented out to avoid re-sending lost stanzas automatically + // unacknowledgedStanzas.drainTo(previouslyUnackedStanzas); + // Reset unacknowledged stanzas to 'null' to signal that we never send 'enable' in this + // XMPP session (There maybe was an enabled in a previous XMPP session of this + // connection instance though). This is used in writePackets to decide if stanzas should + // be added to the unacknowledged stanzas queue, because they have to be added right + // after the 'enable' stream element has been sent. + dropSmState(); + } + + // Now bind the resource. It is important to do this *after* we dropped an eventually + // existing Stream Management state. As otherwise and may end up in + // unacknowledgedStanzas and become duplicated on reconnect. See SMACK-706. + bindResourceAndEstablishSession(resource); + + if (isSmAvailable() && useSm) { + // Remove what is maybe left from previously stream managed sessions + serverHandledStanzasCount = 0; + // XEP-198 3. Enabling Stream Management. If the server response to 'Enable' is 'Failed' + // then this is a non recoverable error and we therefore throw an exception. + smEnabledSyncPoint.sendAndWaitForResponseOrThrow(new Enable(useSmResumption, smClientMaxResumptionTime)); + synchronized (requestAckPredicates) { + if (requestAckPredicates.isEmpty()) { + // Assure that we have at lest one predicate set up that so that we request acks + // for the server and eventually flush some stanzas from the unacknowledged + // stanza queue + requestAckPredicates.add(Predicate.forMessagesOrAfter5Stanzas()); + } + } + } + // Inform client about failed resumption if possible, resend stanzas otherwise + // Process the stanzas synchronously so a client can re-queue them for transmission + // before it is informed about connection success + if (!stanzaDroppedListeners.isEmpty()) { + for (Stanza stanza : previouslyUnackedStanzas) { + for (StanzaListener listener : stanzaDroppedListeners) { + try { + listener.processStanza(stanza); + } + catch (InterruptedException | NotConnectedException | NotLoggedInException e) { + LOGGER.log(Level.FINER, "StanzaDroppedListener received exception", e); + } + } + } + } else { + for (Stanza stanza : previouslyUnackedStanzas) { + sendStanzaInternal(stanza); + } + } + + afterSuccessfulLogin(false); + } + + @Override + public boolean isSecureConnection() { + return secureSocket != null; + } + + /** + * Shuts the current connection down. After this method returns, the connection must be ready + * for re-use by connect. + */ + @Override + protected void shutdown() { + if (isSmEnabled() && !smAckSuspend) { + try { + // Try to send a last SM Acknowledgement. Most servers won't find this information helpful, as the SM + // state is dropped after a clean disconnect anyways. OTOH it doesn't hurt much either. + sendSmAcknowledgementInternal(); + } catch (InterruptedException | NotConnectedException e) { + LOGGER.log(Level.FINE, "Can not send final SM ack as connection is not connected", e); + } + } + shutdown(false); + } + + @Override + public synchronized void instantShutdown() { + shutdown(true); + } + + private void shutdown(boolean instant) { + // First shutdown the writer, this will result in a closing stream element getting send to + // the server + LOGGER.finer("PacketWriter shutdown()"); + packetWriter.shutdown(instant); + LOGGER.finer("PacketWriter has been shut down"); + + if (!instant) { + try { + // After we send the closing stream element, check if there was already a + // closing stream element sent by the server or wait with a timeout for a + // closing stream element to be received from the server. + @SuppressWarnings("unused") + Exception res = closingStreamReceived.checkIfSuccessOrWait(); + } catch (InterruptedException | NoResponseException e) { + LOGGER.log(Level.INFO, "Exception while waiting for closing stream element from the server " + this, e); + } + } + + LOGGER.finer("PacketReader shutdown()"); + packetReader.shutdown(); + LOGGER.finer("PacketReader has been shut down"); + + final Socket socket = this.socket; + if (socket != null && socket.isConnected()) { + try { + socket.close(); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "shutdown", e); + } + } + + setWasAuthenticated(); + + // Wait for reader and writer threads to be terminated. + readerWriterSemaphore.acquireUninterruptibly(2); + readerWriterSemaphore.release(2); + + if (disconnectedButResumeable) { + return; + } + + // If we are able to resume the stream, then don't set + // connected/authenticated/usingTLS to false since we like behave like we are still + // connected (e.g. sendStanza should not throw a NotConnectedException). + if (isSmResumptionPossible() && instant) { + disconnectedButResumeable = true; + } else { + disconnectedButResumeable = false; + // Reset the stream management session id to null, since if the stream is cleanly closed, i.e. sending a closing + // stream tag, there is no longer a stream to resume. + smSessionId = null; + // Note that we deliberately do not reset authenticatedConnectionInitiallyEstablishedTimestamp here, so that the + // information is available in the connectionClosedOnError() listeners. + } + authenticated = false; + connected = false; + secureSocket = null; + reader = null; + writer = null; + + initState(); + } + + @Override + protected void initState() { + super.initState(); + maybeCompressFeaturesReceived.init(); + compressSyncPoint.init(); + smResumedSyncPoint.init(); + smEnabledSyncPoint.init(); + initialOpenStreamSend.init(); + } + + @Override + public void sendNonza(Nonza element) throws NotConnectedException, InterruptedException { + packetWriter.sendStreamElement(element); + } + + @Override + protected void sendStanzaInternal(Stanza packet) throws NotConnectedException, InterruptedException { + packetWriter.sendStreamElement(packet); + if (isSmEnabled()) { + for (StanzaFilter requestAckPredicate : requestAckPredicates) { + if (requestAckPredicate.accept(packet)) { + requestSmAcknowledgementInternal(); + break; + } + } + } + } + + private void connectUsingConfiguration() throws ConnectionException, IOException, InterruptedException { + List failedAddresses = populateHostAddresses(); + SocketFactory socketFactory = config.getSocketFactory(); + ProxyInfo proxyInfo = config.getProxyInfo(); + int timeout = config.getConnectTimeout(); + if (socketFactory == null) { + socketFactory = SocketFactory.getDefault(); + } + for (HostAddress hostAddress : hostAddresses) { + Iterator inetAddresses; + String host = hostAddress.getHost(); + int port = hostAddress.getPort(); + if (proxyInfo == null) { + inetAddresses = hostAddress.getInetAddresses().iterator(); + assert (inetAddresses.hasNext()); + + innerloop: while (inetAddresses.hasNext()) { + // Create a *new* Socket before every connection attempt, i.e. connect() call, since Sockets are not + // re-usable after a failed connection attempt. See also SMACK-724. + SmackFuture.SocketFuture socketFuture = new SmackFuture.SocketFuture(socketFactory); + + final InetAddress inetAddress = inetAddresses.next(); + final InetSocketAddress inetSocketAddress = new InetSocketAddress(inetAddress, port); + LOGGER.finer("Trying to establish TCP connection to " + inetSocketAddress); + socketFuture.connectAsync(inetSocketAddress, timeout); + + try { + socket = socketFuture.getOrThrow(); + } catch (IOException e) { + hostAddress.setException(inetAddress, e); + if (inetAddresses.hasNext()) { + continue innerloop; + } else { + break innerloop; + } + } + LOGGER.finer("Established TCP connection to " + inetSocketAddress); + // We found a host to connect to, return here + this.host = host; + this.port = port; + return; + } + failedAddresses.add(hostAddress); + } else { + socket = socketFactory.createSocket(); + StringUtils.requireNotNullOrEmpty(host, "Host of HostAddress " + hostAddress + " must not be null when using a Proxy"); + final String hostAndPort = host + " at port " + port; + LOGGER.finer("Trying to establish TCP connection via Proxy to " + hostAndPort); + try { + proxyInfo.getProxySocketConnection().connect(socket, host, port, timeout); + } catch (IOException e) { + hostAddress.setException(e); + failedAddresses.add(hostAddress); + continue; + } + LOGGER.finer("Established TCP connection to " + hostAndPort); + // We found a host to connect to, return here + this.host = host; + this.port = port; + return; + } + } + // There are no more host addresses to try + // throw an exception and report all tried + // HostAddresses in the exception + throw ConnectionException.from(failedAddresses); + } + + /** + * Initializes the connection by creating a stanza reader and writer and opening a + * XMPP stream to the server. + * + * @throws XMPPException if establishing a connection to the server fails. + * @throws SmackException if the server fails to respond back or if there is anther error. + * @throws IOException + * @throws InterruptedException + */ + private void initConnection() throws IOException, InterruptedException { + compressionHandler = null; + + // Set the reader and writer instance variables + initReaderAndWriter(); + + int availableReaderWriterSemaphorePermits = readerWriterSemaphore.availablePermits(); + if (availableReaderWriterSemaphorePermits < 2) { + Object[] logObjects = new Object[] { + this, + availableReaderWriterSemaphorePermits, + }; + LOGGER.log(Level.FINE, "Not every reader/writer threads where terminated on connection re-initializtion of {0}. Available permits {1}", logObjects); + } + readerWriterSemaphore.acquire(2); + // Start the writer thread. This will open an XMPP stream to the server + packetWriter.init(); + // Start the reader thread. The startup() method will block until we + // get an opening stream packet back from server + packetReader.init(); + } + + private void initReaderAndWriter() throws IOException { + InputStream is = socket.getInputStream(); + OutputStream os = socket.getOutputStream(); + if (compressionHandler != null) { + is = compressionHandler.getInputStream(is); + os = compressionHandler.getOutputStream(os); + } + // OutputStreamWriter is already buffered, no need to wrap it into a BufferedWriter + writer = new OutputStreamWriter(os, "UTF-8"); + reader = new BufferedReader(new InputStreamReader(is, "UTF-8")); + + // If debugging is enabled, we open a window and write out all network traffic. + initDebugger(); + } + + /** + * The server has indicated that TLS negotiation can start. We now need to secure the + * existing plain connection and perform a handshake. This method won't return until the + * connection has finished the handshake or an error occurred while securing the connection. + * @throws IOException + * @throws CertificateException + * @throws NoSuchAlgorithmException + * @throws NoSuchProviderException + * @throws KeyStoreException + * @throws UnrecoverableKeyException + * @throws KeyManagementException + * @throws SmackException + * @throws Exception if an exception occurs. + */ + @SuppressWarnings("LiteralClassName") + private void proceedTLSReceived() throws NoSuchAlgorithmException, CertificateException, IOException, KeyStoreException, NoSuchProviderException, UnrecoverableKeyException, KeyManagementException, SmackException { + SmackDaneVerifier daneVerifier = null; + + if (config.getDnssecMode() == DnssecMode.needsDnssecAndDane) { + SmackDaneProvider daneProvider = DNSUtil.getDaneProvider(); + if (daneProvider == null) { + throw new UnsupportedOperationException("DANE enabled but no SmackDaneProvider configured"); + } + daneVerifier = daneProvider.newInstance(); + if (daneVerifier == null) { + throw new IllegalStateException("DANE requested but DANE provider did not return a DANE verifier"); + } + } + + SSLContext context = this.config.getCustomSSLContext(); + KeyStore ks = null; + PasswordCallback pcb = null; + + if (context == null) { + final String keyStoreType = config.getKeystoreType(); + final CallbackHandler callbackHandler = config.getCallbackHandler(); + final String keystorePath = config.getKeystorePath(); + if ("PKCS11".equals(keyStoreType)) { + try { + Constructor c = Class.forName("sun.security.pkcs11.SunPKCS11").getConstructor(InputStream.class); + String pkcs11Config = "name = SmartCard\nlibrary = " + config.getPKCS11Library(); + ByteArrayInputStream config = new ByteArrayInputStream(pkcs11Config.getBytes(StringUtils.UTF8)); + Provider p = (Provider) c.newInstance(config); + Security.addProvider(p); + ks = KeyStore.getInstance("PKCS11",p); + pcb = new PasswordCallback("PKCS11 Password: ",false); + callbackHandler.handle(new Callback[] {pcb}); + ks.load(null,pcb.getPassword()); + } + catch (Exception e) { + LOGGER.log(Level.WARNING, "Exception", e); + ks = null; + } + } + else if ("Apple".equals(keyStoreType)) { + ks = KeyStore.getInstance("KeychainStore","Apple"); + ks.load(null,null); + // pcb = new PasswordCallback("Apple Keychain",false); + // pcb.setPassword(null); + } + else if (keyStoreType != null) { + ks = KeyStore.getInstance(keyStoreType); + if (callbackHandler != null && StringUtils.isNotEmpty(keystorePath)) { + try { + pcb = new PasswordCallback("Keystore Password: ", false); + callbackHandler.handle(new Callback[] { pcb }); + ks.load(new FileInputStream(keystorePath), pcb.getPassword()); + } + catch (Exception e) { + LOGGER.log(Level.WARNING, "Exception", e); + ks = null; + } + } else { + ks.load(null, null); + } + } + + KeyManager[] kms = null; + + if (ks != null) { + String keyManagerFactoryAlgorithm = KeyManagerFactory.getDefaultAlgorithm(); + KeyManagerFactory kmf = null; + try { + kmf = KeyManagerFactory.getInstance(keyManagerFactoryAlgorithm); + } + catch (NoSuchAlgorithmException e) { + LOGGER.log(Level.FINE, "Could get the default KeyManagerFactory for the '" + + keyManagerFactoryAlgorithm + "' algorithm", e); + } + if (kmf != null) { + try { + if (pcb == null) { + kmf.init(ks, null); + } + else { + kmf.init(ks, pcb.getPassword()); + pcb.clearPassword(); + } + kms = kmf.getKeyManagers(); + } + catch (NullPointerException npe) { + LOGGER.log(Level.WARNING, "NullPointerException", npe); + } + } + } + + // If the user didn't specify a SSLContext, use the default one + context = SSLContext.getInstance("TLS"); + + final SecureRandom secureRandom = new java.security.SecureRandom(); + X509TrustManager customTrustManager = config.getCustomX509TrustManager(); + + if (daneVerifier != null) { + // User requested DANE verification. + daneVerifier.init(context, kms, customTrustManager, secureRandom); + } else { + TrustManager[] customTrustManagers = null; + if (customTrustManager != null) { + customTrustManagers = new TrustManager[] { customTrustManager }; + } + context.init(kms, customTrustManagers, secureRandom); + } + } + + Socket plain = socket; + // Secure the plain connection + socket = context.getSocketFactory().createSocket(plain, + config.getXMPPServiceDomain().toString(), plain.getPort(), true); + + final SSLSocket sslSocket = (SSLSocket) socket; + // Immediately set the enabled SSL protocols and ciphers. See SMACK-712 why this is + // important (at least on certain platforms) and it seems to be a good idea anyways to + // prevent an accidental implicit handshake. + TLSUtils.setEnabledProtocolsAndCiphers(sslSocket, config.getEnabledSSLProtocols(), config.getEnabledSSLCiphers()); + + // Initialize the reader and writer with the new secured version + initReaderAndWriter(); + + // Proceed to do the handshake + sslSocket.startHandshake(); + + if (daneVerifier != null) { + daneVerifier.finish(sslSocket); + } + + final HostnameVerifier verifier = getConfiguration().getHostnameVerifier(); + if (verifier == null) { + throw new IllegalStateException("No HostnameVerifier set. Use connectionConfiguration.setHostnameVerifier() to configure."); + } + + final String verifierHostname; + { + DnsName xmppServiceDomainDnsName = getConfiguration().getXmppServiceDomainAsDnsNameIfPossible(); + // Try to convert the XMPP service domain, which potentially includes Unicode characters, into ASCII + // Compatible Encoding (ACE) to match RFC3280 dNSname IA5String constraint. + // See also: https://bugzilla.mozilla.org/show_bug.cgi?id=280839#c1 + if (xmppServiceDomainDnsName != null) { + verifierHostname = xmppServiceDomainDnsName.ace; + } + else { + LOGGER.log(Level.WARNING, "XMPP service domain name '" + getXMPPServiceDomain() + + "' can not be represented as DNS name. TLS X.509 certificate validiation may fail."); + verifierHostname = getXMPPServiceDomain().toString(); + } + } + + final boolean verificationSuccessful; + // Verify the TLS session. + verificationSuccessful = verifier.verify(verifierHostname, sslSocket.getSession()); + if (!verificationSuccessful) { + throw new CertificateException( + "Hostname verification of certificate failed. Certificate does not authenticate " + + getXMPPServiceDomain()); + } + + // Set that TLS was successful + secureSocket = sslSocket; + } + + /** + * Returns the compression handler that can be used for one compression methods offered by the server. + * + * @return a instance of XMPPInputOutputStream or null if no suitable instance was found + * + */ + private static XMPPInputOutputStream maybeGetCompressionHandler(Compress.Feature compression) { + for (XMPPInputOutputStream handler : SmackConfiguration.getCompressionHandlers()) { + String method = handler.getCompressionMethod(); + if (compression.getMethods().contains(method)) + return handler; + } + return null; + } + + @Override + public boolean isUsingCompression() { + return compressionHandler != null && compressSyncPoint.wasSuccessful(); + } + + /** + *

+ * Starts using stream compression that will compress network traffic. Traffic can be + * reduced up to 90%. Therefore, stream compression is ideal when using a slow speed network + * connection. However, the server and the client will need to use more CPU time in order to + * un/compress network data so under high load the server performance might be affected. + *

+ *

+ * Stream compression has to have been previously offered by the server. Currently only the + * zlib method is supported by the client. Stream compression negotiation has to be done + * before authentication took place. + *

+ * + * @throws NotConnectedException + * @throws SmackException + * @throws NoResponseException + * @throws InterruptedException + */ + private void maybeEnableCompression() throws SmackException, InterruptedException { + if (!config.isCompressionEnabled()) { + return; + } + + Compress.Feature compression = getFeature(Compress.Feature.ELEMENT, Compress.NAMESPACE); + if (compression == null) { + // Server does not support compression + return; + } + // If stream compression was offered by the server and we want to use + // compression then send compression request to the server + if ((compressionHandler = maybeGetCompressionHandler(compression)) != null) { + compressSyncPoint.sendAndWaitForResponseOrThrow(new Compress(compressionHandler.getCompressionMethod())); + } else { + LOGGER.warning("Could not enable compression because no matching handler/method pair was found"); + } + } + + /** + * Establishes a connection to the XMPP server. It basically + * creates and maintains a socket connection to the server. + *

+ * Listeners will be preserved from a previous connection if the reconnection + * occurs after an abrupt termination. + *

+ * + * @throws XMPPException if an error occurs while trying to establish the connection. + * @throws SmackException + * @throws IOException + * @throws InterruptedException + */ + @Override + protected void connectInternal() throws SmackException, IOException, XMPPException, InterruptedException { + closingStreamReceived.init(); + // Establishes the TCP connection to the server and does setup the reader and writer. Throws an exception if + // there is an error establishing the connection + connectUsingConfiguration(); + + // We connected successfully to the servers TCP port + initConnection(); + + // TLS handled will be successful either if TLS was established, or if it was not mandatory. + tlsHandled.checkIfSuccessOrWaitOrThrow(); + + // Wait with SASL auth until the SASL mechanisms have been received + saslFeatureReceived.checkIfSuccessOrWaitOrThrow(); + } + + /** + * Sends out a notification that there was an error with the connection + * and closes the connection. Also prints the stack trace of the given exception + * + * @param e the exception that causes the connection close event. + */ + private void notifyConnectionError(final Exception e) { + ASYNC_BUT_ORDERED.performAsyncButOrdered(this, new Runnable() { + @Override + public void run() { + // Listeners were already notified of the exception, return right here. + if (packetReader.done || packetWriter.done()) return; + + // Report the failure outside the synchronized block, so that a thread waiting within a synchronized + // function like connect() throws the wrapped exception. + SmackWrappedException smackWrappedException = new SmackWrappedException(e); + tlsHandled.reportGenericFailure(smackWrappedException); + saslFeatureReceived.reportGenericFailure(smackWrappedException); + maybeCompressFeaturesReceived.reportGenericFailure(smackWrappedException); + lastFeaturesReceived.reportGenericFailure(smackWrappedException); + + synchronized (XMPPTCPConnection.this) { + // Within this synchronized block, either *both* reader and writer threads must be terminated, or + // none. + assert ((packetReader.done && packetWriter.done()) + || (!packetReader.done && !packetWriter.done())); + + // Closes the connection temporary. A reconnection is possible + // Note that a connection listener of XMPPTCPConnection will drop the SM state in + // case the Exception is a StreamErrorException. + instantShutdown(); + } + + Async.go(new Runnable() { + @Override + public void run() { + // Notify connection listeners of the error. + callConnectionClosedOnErrorListener(e); + } + }, XMPPTCPConnection.this + " callConnectionClosedOnErrorListener()"); + } + }); + } + + /** + * For unit testing purposes + * + * @param writer + */ + protected void setWriter(Writer writer) { + this.writer = writer; + } + + @Override + protected void afterFeaturesReceived() throws NotConnectedException, InterruptedException, SecurityRequiredByServerException { + StartTls startTlsFeature = getFeature(StartTls.ELEMENT, StartTls.NAMESPACE); + if (startTlsFeature != null) { + if (startTlsFeature.required() && config.getSecurityMode() == SecurityMode.disabled) { + SecurityRequiredByServerException smackException = new SecurityRequiredByServerException(); + tlsHandled.reportFailure(smackException); + throw smackException; + } + + if (config.getSecurityMode() != ConnectionConfiguration.SecurityMode.disabled) { + sendNonza(new StartTls()); + } else { + tlsHandled.reportSuccess(); + } + } else { + tlsHandled.reportSuccess(); + } + + if (getSASLAuthentication().authenticationSuccessful()) { + // If we have received features after the SASL has been successfully completed, then we + // have also *maybe* received, as it is an optional feature, the compression feature + // from the server. + maybeCompressFeaturesReceived.reportSuccess(); + } + } + + /** + * Resets the parser using the latest connection's reader. Resetting the parser is necessary + * when the plain connection has been secured or when a new opening stream element is going + * to be sent by the server. + * + * @throws SmackException if the parser could not be reset. + * @throws InterruptedException + */ + void openStream() throws SmackException, InterruptedException { + // If possible, provide the receiving entity of the stream open tag, i.e. the server, as much information as + // possible. The 'to' attribute is *always* available. The 'from' attribute if set by the user and no external + // mechanism is used to determine the local entity (user). And the 'id' attribute is available after the first + // response from the server (see e.g. RFC 6120 § 9.1.1 Step 2.) + CharSequence to = getXMPPServiceDomain(); + CharSequence from = null; + CharSequence localpart = config.getUsername(); + if (localpart != null) { + from = XmppStringUtils.completeJidFrom(localpart, to); + } + String id = getStreamId(); + sendNonza(new StreamOpen(to, from, id)); + try { + packetReader.parser = PacketParserUtils.newXmppParser(reader); + } + catch (XmlPullParserException e) { + throw new SmackException(e); + } + } + + protected class PacketReader { + + private final String threadName = "Smack Reader (" + getConnectionCounter() + ')'; + + XmlPullParser parser; + + private volatile boolean done; + + /** + * Initializes the reader in order to be used. The reader is initialized during the + * first connection and when reconnecting due to an abruptly disconnection. + */ + void init() { + done = false; + + Async.go(new Runnable() { + @Override + public void run() { + LOGGER.finer(threadName + " start"); + try { + parsePackets(); + } finally { + LOGGER.finer(threadName + " exit"); + XMPPTCPConnection.this.readerWriterSemaphore.release(); + } + } + }, threadName); + } + + /** + * Shuts the stanza reader down. This method simply sets the 'done' flag to true. + */ + void shutdown() { + done = true; + } + + /** + * Parse top-level packets in order to process them further. + */ + private void parsePackets() { + try { + initialOpenStreamSend.checkIfSuccessOrWait(); + int eventType = parser.getEventType(); + while (!done) { + switch (eventType) { + case XmlPullParser.START_TAG: + final String name = parser.getName(); + switch (name) { + case Message.ELEMENT: + case IQ.IQ_ELEMENT: + case Presence.ELEMENT: + try { + parseAndProcessStanza(parser); + } finally { + clientHandledStanzasCount = SMUtils.incrementHeight(clientHandledStanzasCount); + } + break; + case "stream": + // We found an opening stream. + if ("jabber:client".equals(parser.getNamespace(null))) { + streamId = parser.getAttributeValue("", "id"); + String reportedServerDomain = parser.getAttributeValue("", "from"); + assert (config.getXMPPServiceDomain().equals(reportedServerDomain)); + } + break; + case "error": + StreamError streamError = PacketParserUtils.parseStreamError(parser); + saslFeatureReceived.reportFailure(new StreamErrorException(streamError)); + // Mark the tlsHandled sync point as success, we will use the saslFeatureReceived sync + // point to report the error, which is checked immediately after tlsHandled in + // connectInternal(). + tlsHandled.reportSuccess(); + throw new StreamErrorException(streamError); + case "features": + parseFeatures(parser); + break; + case "proceed": + try { + // Secure the connection by negotiating TLS + proceedTLSReceived(); + // Send a new opening stream to the server + openStream(); + } + catch (Exception e) { + SmackException smackException = new SmackException(e); + tlsHandled.reportFailure(smackException); + throw e; + } + break; + case "failure": + String namespace = parser.getNamespace(null); + switch (namespace) { + case "urn:ietf:params:xml:ns:xmpp-tls": + // TLS negotiation has failed. The server will close the connection + // TODO Parse failure stanza + throw new SmackException("TLS negotiation has failed"); + case "http://jabber.org/protocol/compress": + // Stream compression has been denied. This is a recoverable + // situation. It is still possible to authenticate and + // use the connection but using an uncompressed connection + // TODO Parse failure stanza + compressSyncPoint.reportFailure(new SmackException( + "Could not establish compression")); + break; + case SaslStreamElements.NAMESPACE: + // SASL authentication has failed. The server may close the connection + // depending on the number of retries + final SASLFailure failure = PacketParserUtils.parseSASLFailure(parser); + getSASLAuthentication().authenticationFailed(failure); + break; + } + break; + case Challenge.ELEMENT: + // The server is challenging the SASL authentication made by the client + String challengeData = parser.nextText(); + getSASLAuthentication().challengeReceived(challengeData); + break; + case Success.ELEMENT: + Success success = new Success(parser.nextText()); + // We now need to bind a resource for the connection + // Open a new stream and wait for the response + openStream(); + // The SASL authentication with the server was successful. The next step + // will be to bind the resource + getSASLAuthentication().authenticated(success); + break; + case Compressed.ELEMENT: + // Server confirmed that it's possible to use stream compression. Start + // stream compression + // Initialize the reader and writer with the new compressed version + initReaderAndWriter(); + // Send a new opening stream to the server + openStream(); + // Notify that compression is being used + compressSyncPoint.reportSuccess(); + break; + case Enabled.ELEMENT: + Enabled enabled = ParseStreamManagement.enabled(parser); + if (enabled.isResumeSet()) { + smSessionId = enabled.getId(); + if (StringUtils.isNullOrEmpty(smSessionId)) { + SmackException xmppException = new SmackException("Stream Management 'enabled' element with resume attribute but without session id received"); + smEnabledSyncPoint.reportFailure(xmppException); + throw xmppException; + } + smServerMaxResumptionTime = enabled.getMaxResumptionTime(); + } else { + // Mark this a non-resumable stream by setting smSessionId to null + smSessionId = null; + } + clientHandledStanzasCount = 0; + ackPending = false; + smAckSuspend = false; + smWasEnabledAtLeastOnce = true; + smEnabledSyncPoint.reportSuccess(); + LOGGER.fine("Stream Management (XEP-198): successfully enabled"); + break; + case Failed.ELEMENT: + Failed failed = ParseStreamManagement.failed(parser); + FailedNonzaException xmppException = new FailedNonzaException(failed, failed.getStanzaErrorCondition()); + // If only XEP-198 would specify different failure elements for the SM + // enable and SM resume failure case. But this is not the case, so we + // need to determine if this is a 'Failed' response for either 'Enable' + // or 'Resume'. + if (smResumedSyncPoint.requestSent()) { + smResumedSyncPoint.reportFailure(xmppException); + } + else { + if (!smEnabledSyncPoint.requestSent()) { + throw new IllegalStateException("Failed element received but SM was not previously enabled"); + } + smEnabledSyncPoint.reportFailure(new SmackException(xmppException)); + // Report success for last lastFeaturesReceived so that in case a + // failed resumption, we can continue with normal resource binding. + // See text of XEP-198 5. below Example 11. + lastFeaturesReceived.reportSuccess(); + } + break; + case Resumed.ELEMENT: + Resumed resumed = ParseStreamManagement.resumed(parser); + if (!smSessionId.equals(resumed.getPrevId())) { + throw new StreamIdDoesNotMatchException(smSessionId, resumed.getPrevId()); + } + // Mark SM as enabled + smEnabledSyncPoint.reportSuccess(); + // First, drop the stanzas already handled by the server + processHandledCount(resumed.getHandledCount()); + // Then re-send what is left in the unacknowledged queue + List stanzasToResend = new ArrayList<>(unacknowledgedStanzas.size()); + unacknowledgedStanzas.drainTo(stanzasToResend); + for (Stanza stanza : stanzasToResend) { + sendStanzaInternal(stanza); + } + // If there where stanzas resent, then request a SM ack for them. + // Writer's sendStreamElement() won't do it automatically based on + // predicates. + if (!stanzasToResend.isEmpty()) { + requestSmAcknowledgementInternal(); + } + // Mark SM resumption as successful + smResumedSyncPoint.reportSuccess(); + LOGGER.fine("Stream Management (XEP-198): Stream resumed"); + break; + case AckAnswer.ELEMENT: + AckAnswer ackAnswer = ParseStreamManagement.ackAnswer(parser); + processHandledCount(ackAnswer.getHandledCount()); + break; + case AckRequest.ELEMENT: + ParseStreamManagement.ackRequest(parser); + if (smEnabledSyncPoint.wasSuccessful()) { + synchronized (clientHandledStanzasCountLock) { + if (!smAckSuspend) { + sendSmAcknowledgementInternal(); + } + else { + ackPending = true; + } + } + } + else { + LOGGER.warning("SM Ack Request received while SM is not enabled"); + } + break; + default: + LOGGER.warning("Unknown top level stream element: " + name); + break; + } + break; + case XmlPullParser.END_TAG: + final String endTagName = parser.getName(); + if ("stream".equals(endTagName)) { + if (!parser.getNamespace().equals("http://etherx.jabber.org/streams")) { + LOGGER.warning(XMPPTCPConnection.this + " but different namespace " + parser.getNamespace()); + break; + } + + // Check if the queue was already shut down before reporting success on closing stream tag + // received. This avoids a race if there is a disconnect(), followed by a connect(), which + // did re-start the queue again, causing this writer to assume that the queue is not + // shutdown, which results in a call to disconnect(). + final boolean queueWasShutdown = packetWriter.queue.isShutdown(); + closingStreamReceived.reportSuccess(); + + if (queueWasShutdown) { + // We received a closing stream element *after* we initiated the + // termination of the session by sending a closing stream element to + // the server first + return; + } else { + // We received a closing stream element from the server without us + // sending a closing stream element first. This means that the + // server wants to terminate the session, therefore disconnect + // the connection + LOGGER.info(XMPPTCPConnection.this + + " received closing element." + + " Server wants to terminate the connection, calling disconnect()"); + ASYNC_BUT_ORDERED.performAsyncButOrdered(XMPPTCPConnection.this, new Runnable() { + @Override + public void run() { + disconnect(); + }}); + } + } + break; + case XmlPullParser.END_DOCUMENT: + // END_DOCUMENT only happens in an error case, as otherwise we would see a + // closing stream element before. + throw new SmackException( + "Parser got END_DOCUMENT event. This could happen e.g. if the server closed the connection without sending a closing stream element"); + } + eventType = parser.next(); + } + } + catch (Exception e) { + closingStreamReceived.reportFailure(e); + // The exception can be ignored if the the connection is 'done' + // or if the it was caused because the socket got closed + if (!(done || packetWriter.queue.isShutdown())) { + // Close the connection and notify connection listeners of the + // error. + notifyConnectionError(e); + } + } + } + } + + protected class PacketWriter { + public static final int QUEUE_SIZE = XMPPTCPConnection.QUEUE_SIZE; + + private final String threadName = "Smack Writer (" + getConnectionCounter() + ')'; + + private final ArrayBlockingQueueWithShutdown queue = new ArrayBlockingQueueWithShutdown<>( + QUEUE_SIZE, true); + + /** + * Needs to be protected for unit testing purposes. + */ + protected SynchronizationPoint shutdownDone = new SynchronizationPoint<>( + XMPPTCPConnection.this, "shutdown completed"); + + /** + * If set, the stanza writer is shut down + */ + protected volatile Long shutdownTimestamp = null; + + private volatile boolean instantShutdown; + + /** + * True if some preconditions are given to start the bundle and defer mechanism. + *

+ * This will likely get set to true right after the start of the writer thread, because + * {@link #nextStreamElement()} will check if {@link #queue} is empty, which is probably the case, and then set + * this field to true. + *

+ */ + private boolean shouldBundleAndDefer; + + /** + * Initializes the writer in order to be used. It is called at the first connection and also + * is invoked if the connection is disconnected by an error. + */ + void init() { + shutdownDone.init(); + shutdownTimestamp = null; + + if (unacknowledgedStanzas != null) { + // It's possible that there are new stanzas in the writer queue that + // came in while we were disconnected but resumable, drain those into + // the unacknowledged queue so that they get resent now + drainWriterQueueToUnacknowledgedStanzas(); + } + + queue.start(); + Async.go(new Runnable() { + @Override + public void run() { + LOGGER.finer(threadName + " start"); + try { + writePackets(); + } finally { + LOGGER.finer(threadName + " exit"); + XMPPTCPConnection.this.readerWriterSemaphore.release(); + } + } + }, threadName); + } + + private boolean done() { + return shutdownTimestamp != null; + } + + protected void throwNotConnectedExceptionIfDoneAndResumptionNotPossible() throws NotConnectedException { + final boolean done = done(); + if (done) { + final boolean smResumptionPossible = isSmResumptionPossible(); + // Don't throw a NotConnectedException is there is an resumable stream available + if (!smResumptionPossible) { + throw new NotConnectedException(XMPPTCPConnection.this, "done=" + done + + " smResumptionPossible=" + smResumptionPossible); + } + } + } + + /** + * Sends the specified element to the server. + * + * @param element the element to send. + * @throws NotConnectedException + * @throws InterruptedException + */ + protected void sendStreamElement(Element element) throws NotConnectedException, InterruptedException { + throwNotConnectedExceptionIfDoneAndResumptionNotPossible(); + try { + queue.put(element); + } + catch (InterruptedException e) { + // put() may throw an InterruptedException for two reasons: + // 1. If the queue was shut down + // 2. If the thread was interrupted + // so we have to check which is the case + throwNotConnectedExceptionIfDoneAndResumptionNotPossible(); + // If the method above did not throw, then the sending thread was interrupted + throw e; + } + } + + /** + * Shuts down the stanza writer. Once this method has been called, no further + * packets will be written to the server. + * @throws InterruptedException + */ + void shutdown(boolean instant) { + instantShutdown = instant; + queue.shutdown(); + shutdownTimestamp = System.currentTimeMillis(); + if (shutdownDone.isNotInInitialState()) { + try { + shutdownDone.checkIfSuccessOrWait(); + } catch (NoResponseException | InterruptedException e) { + LOGGER.log(Level.WARNING, "shutdownDone was not marked as successful by the writer thread", e); + } + } + } + + /** + * Maybe return the next available element from the queue for writing. If the queue is shut down or a + * spurious interrupt occurs, null is returned. So it is important to check the 'done' condition in + * that case. + * + * @return the next element for writing or null. + */ + private Element nextStreamElement() { + // It is important the we check if the queue is empty before removing an element from it + if (queue.isEmpty()) { + shouldBundleAndDefer = true; + } + Element packet = null; + try { + packet = queue.take(); + } + catch (InterruptedException e) { + if (!queue.isShutdown()) { + // Users shouldn't try to interrupt the packet writer thread + LOGGER.log(Level.WARNING, "Writer thread was interrupted. Don't do that. Use disconnect() instead.", e); + } + } + return packet; + } + + private void writePackets() { + Exception writerException = null; + try { + openStream(); + initialOpenStreamSend.reportSuccess(); + // Write out packets from the queue. + while (!done()) { + Element element = nextStreamElement(); + if (element == null) { + continue; + } + + // Get a local version of the bundle and defer callback, in case it's unset + // between the null check and the method invocation + final BundleAndDeferCallback localBundleAndDeferCallback = bundleAndDeferCallback; + // If the preconditions are given (e.g. bundleAndDefer callback is set, queue is + // empty), then we could wait a bit for further stanzas attempting to decrease + // our energy consumption + if (localBundleAndDeferCallback != null && isAuthenticated() && shouldBundleAndDefer) { + // Reset shouldBundleAndDefer to false, nextStreamElement() will set it to true once the + // queue is empty again. + shouldBundleAndDefer = false; + final AtomicBoolean bundlingAndDeferringStopped = new AtomicBoolean(); + final int bundleAndDeferMillis = localBundleAndDeferCallback.getBundleAndDeferMillis(new BundleAndDefer( + bundlingAndDeferringStopped)); + if (bundleAndDeferMillis > 0) { + long remainingWait = bundleAndDeferMillis; + final long waitStart = System.currentTimeMillis(); + synchronized (bundlingAndDeferringStopped) { + while (!bundlingAndDeferringStopped.get() && remainingWait > 0) { + bundlingAndDeferringStopped.wait(remainingWait); + remainingWait = bundleAndDeferMillis + - (System.currentTimeMillis() - waitStart); + } + } + } + } + + Stanza packet = null; + if (element instanceof Stanza) { + packet = (Stanza) element; + } + else if (element instanceof Enable) { + // The client needs to add messages to the unacknowledged stanzas queue + // right after it sent 'enabled'. Stanza will be added once + // unacknowledgedStanzas is not null. + unacknowledgedStanzas = new ArrayBlockingQueue<>(QUEUE_SIZE); + } + maybeAddToUnacknowledgedStanzas(packet); + + CharSequence elementXml = element.toXML(StreamOpen.CLIENT_NAMESPACE); + if (elementXml instanceof XmlStringBuilder) { + ((XmlStringBuilder) elementXml).write(writer, StreamOpen.CLIENT_NAMESPACE); + } + else { + writer.write(elementXml.toString()); + } + + if (queue.isEmpty()) { + writer.flush(); + } + if (packet != null) { + firePacketSendingListeners(packet); + } + } + if (!instantShutdown) { + // Flush out the rest of the queue. + try { + while (!queue.isEmpty()) { + Element packet = queue.remove(); + if (packet instanceof Stanza) { + Stanza stanza = (Stanza) packet; + maybeAddToUnacknowledgedStanzas(stanza); + } + writer.write(packet.toXML(null).toString()); + } + writer.flush(); + } + catch (Exception e) { + LOGGER.log(Level.WARNING, + "Exception flushing queue during shutdown, ignore and continue", + e); + } + + // Close the stream. + try { + writer.write(""); + writer.flush(); + } + catch (Exception e) { + LOGGER.log(Level.WARNING, "Exception writing closing stream element", e); + } + + // Delete the queue contents (hopefully nothing is left). + queue.clear(); + } else if (instantShutdown && isSmEnabled()) { + // This was an instantShutdown and SM is enabled, drain all remaining stanzas + // into the unacknowledgedStanzas queue + drainWriterQueueToUnacknowledgedStanzas(); + } + // Do *not* close the writer here, as it will cause the socket + // to get closed. But we may want to receive further stanzas + // until the closing stream tag is received. The socket will be + // closed in shutdown(). + } + catch (Exception e) { + // The exception can be ignored if the the connection is 'done' + // or if the it was caused because the socket got closed + if (!(done() || queue.isShutdown())) { + writerException = e; + } else { + LOGGER.log(Level.FINE, "Ignoring Exception in writePackets()", e); + } + } finally { + LOGGER.fine("Reporting shutdownDone success in writer thread"); + shutdownDone.reportSuccess(); + } + // Delay notifyConnectionError after shutdownDone has been reported in the finally block. + if (writerException != null) { + notifyConnectionError(writerException); + } + } + + private void drainWriterQueueToUnacknowledgedStanzas() { + List elements = new ArrayList<>(queue.size()); + queue.drainTo(elements); + for (int i = 0; i < elements.size(); i++) { + Element element = elements.get(i); + // If the unacknowledgedStanza queue is full, then bail out with a warning message. See SMACK-844. + if (unacknowledgedStanzas.remainingCapacity() == 0) { + StreamManagementException.UnacknowledgedQueueFullException exception = StreamManagementException.UnacknowledgedQueueFullException + .newWith(i, elements, unacknowledgedStanzas); + LOGGER.log(Level.WARNING, + "Some stanzas may be lost as not all could be drained to the unacknowledged stanzas queue", exception); + return; + } + if (element instanceof Stanza) { + unacknowledgedStanzas.add((Stanza) element); + } + } + } + + private void maybeAddToUnacknowledgedStanzas(Stanza stanza) throws IOException { + // Check if the stream element should be put to the unacknowledgedStanza + // queue. Note that we can not do the put() in sendStanzaInternal() and the + // packet order is not stable at this point (sendStanzaInternal() can be + // called concurrently). + if (unacknowledgedStanzas != null && stanza != null) { + // If the unacknowledgedStanza queue is nearly full, request an new ack + // from the server in order to drain it + if (unacknowledgedStanzas.size() == 0.8 * XMPPTCPConnection.QUEUE_SIZE) { + writer.write(AckRequest.INSTANCE.toXML(null).toString()); + writer.flush(); + } + try { + // It is important the we put the stanza in the unacknowledged stanza + // queue before we put it on the wire + unacknowledgedStanzas.put(stanza); + } + catch (InterruptedException e) { + throw new IllegalStateException(e); + } + } + } + } + + /** + * Set if Stream Management should be used by default for new connections. + * + * @param useSmDefault true to use Stream Management for new connections. + */ + public static void setUseStreamManagementDefault(boolean useSmDefault) { + XMPPTCPConnection.useSmDefault = useSmDefault; + } + + /** + * Set if Stream Management resumption should be used by default for new connections. + * + * @param useSmResumptionDefault true to use Stream Management resumption for new connections. + * @deprecated use {@link #setUseStreamManagementResumptionDefault(boolean)} instead. + */ + @Deprecated + public static void setUseStreamManagementResumptiodDefault(boolean useSmResumptionDefault) { + setUseStreamManagementResumptionDefault(useSmResumptionDefault); + } + + /** + * Set if Stream Management resumption should be used by default for new connections. + * + * @param useSmResumptionDefault true to use Stream Management resumption for new connections. + */ + public static void setUseStreamManagementResumptionDefault(boolean useSmResumptionDefault) { + if (useSmResumptionDefault) { + // Also enable SM is resumption is enabled + setUseStreamManagementDefault(useSmResumptionDefault); + } + XMPPTCPConnection.useSmResumptionDefault = useSmResumptionDefault; + } + + /** + * Set if Stream Management should be used if supported by the server. + * + * @param useSm true to use Stream Management. + */ + public void setUseStreamManagement(boolean useSm) { + this.useSm = useSm; + } + + /** + * Set if Stream Management resumption should be used if supported by the server. + * + * @param useSmResumption true to use Stream Management resumption. + */ + public void setUseStreamManagementResumption(boolean useSmResumption) { + if (useSmResumption) { + // Also enable SM is resumption is enabled + setUseStreamManagement(useSmResumption); + } + this.useSmResumption = useSmResumption; + } + + /** + * Set the preferred resumption time in seconds. + * @param resumptionTime the preferred resumption time in seconds + */ + public void setPreferredResumptionTime(int resumptionTime) { + smClientMaxResumptionTime = resumptionTime; + } + + /** + * Add a predicate for Stream Management acknowledgment requests. + *

+ * Those predicates are used to determine when a Stream Management acknowledgement request is send to the server. + * Some pre-defined predicates are found in the org.jivesoftware.smack.sm.predicates package. + *

+ *

+ * If not predicate is configured, the {@link Predicate#forMessagesOrAfter5Stanzas()} will be used. + *

+ * + * @param predicate the predicate to add. + * @return if the predicate was not already active. + */ + public boolean addRequestAckPredicate(StanzaFilter predicate) { + synchronized (requestAckPredicates) { + return requestAckPredicates.add(predicate); + } + } + + /** + * Remove the given predicate for Stream Management acknowledgment request. + * @param predicate the predicate to remove. + * @return true if the predicate was removed. + */ + public boolean removeRequestAckPredicate(StanzaFilter predicate) { + synchronized (requestAckPredicates) { + return requestAckPredicates.remove(predicate); + } + } + + /** + * Remove all predicates for Stream Management acknowledgment requests. + */ + public void removeAllRequestAckPredicates() { + synchronized (requestAckPredicates) { + requestAckPredicates.clear(); + } + } + + /** + * Send an unconditional Stream Management acknowledgement request to the server. + * + * @throws StreamManagementNotEnabledException if Stream Management is not enabled. + * @throws NotConnectedException if the connection is not connected. + * @throws InterruptedException + */ + public void requestSmAcknowledgement() throws StreamManagementNotEnabledException, NotConnectedException, InterruptedException { + if (!isSmEnabled()) { + throw new StreamManagementException.StreamManagementNotEnabledException(); + } + requestSmAcknowledgementInternal(); + } + + private void requestSmAcknowledgementInternal() throws NotConnectedException, InterruptedException { + packetWriter.sendStreamElement(AckRequest.INSTANCE); + } + + /** + * Send a unconditional Stream Management acknowledgment to the server. + *

+ * See XEP-198: Stream Management § 4. Acks: + * "Either party MAY send an <a/> element at any time (e.g., after it has received a certain number of stanzas, + * or after a certain period of time), even if it has not received an <r/> element from the other party." + *

+ * + * @throws StreamManagementNotEnabledException if Stream Management is not enabled. + * @throws NotConnectedException if the connection is not connected. + * @throws InterruptedException + */ + public void sendSmAcknowledgement() throws StreamManagementNotEnabledException, NotConnectedException, InterruptedException { + if (!isSmEnabled()) { + throw new StreamManagementException.StreamManagementNotEnabledException(); + } + sendSmAcknowledgementInternal(); + } + + private void sendSmAcknowledgementInternal() throws NotConnectedException, InterruptedException { + packetWriter.sendStreamElement(new AckAnswer(clientHandledStanzasCount)); + } + + /** + * Add a Stanza acknowledged listener. + *

+ * Those listeners will be invoked every time a Stanza has been acknowledged by the server. The will not get + * automatically removed. Consider using {@link #addStanzaIdAcknowledgedListener(String, StanzaListener)} when + * possible. + *

+ * + * @param listener the listener to add. + */ + public void addStanzaAcknowledgedListener(StanzaListener listener) { + stanzaAcknowledgedListeners.add(listener); + } + + /** + * Remove the given Stanza acknowledged listener. + * + * @param listener the listener. + * @return true if the listener was removed. + */ + public boolean removeStanzaAcknowledgedListener(StanzaListener listener) { + return stanzaAcknowledgedListeners.remove(listener); + } + + /** + * Remove all stanza acknowledged listeners. + */ + public void removeAllStanzaAcknowledgedListeners() { + stanzaAcknowledgedListeners.clear(); + } + + /** + * Add a Stanza dropped listener. + *

+ * Those listeners will be invoked every time a Stanza has been dropped due to a failed SM resume. They will not get + * automatically removed. If at least one StanzaDroppedListener is configured, no attempt will be made to retransmit + * the Stanzas. + *

+ * + * @param listener the listener to add. + * @since 4.3.3 + */ + public void addStanzaDroppedListener(StanzaListener listener) { + stanzaDroppedListeners.add(listener); + } + + /** + * Remove the given Stanza dropped listener. + * + * @param listener the listener. + * @return true if the listener was removed. + * @since 4.3.3 + */ + public boolean removeStanzaDroppedListener(StanzaListener listener) { + return stanzaDroppedListeners.remove(listener); + } + + /** + * Add a new Stanza ID acknowledged listener for the given ID. + *

+ * The listener will be invoked if the stanza with the given ID was acknowledged by the server. It will + * automatically be removed after the listener was run. + *

+ * + * @param id the stanza ID. + * @param listener the listener to invoke. + * @return the previous listener for this stanza ID or null. + * @throws StreamManagementNotEnabledException if Stream Management is not enabled. + */ + @SuppressWarnings("FutureReturnValueIgnored") + public StanzaListener addStanzaIdAcknowledgedListener(final String id, StanzaListener listener) throws StreamManagementNotEnabledException { + // Prevent users from adding callbacks that will never get removed + if (!smWasEnabledAtLeastOnce) { + throw new StreamManagementException.StreamManagementNotEnabledException(); + } + // Remove the listener after max. 3 hours + final int removeAfterSeconds = Math.min(getMaxSmResumptionTime(), 3 * 60 * 60); + schedule(new Runnable() { + @Override + public void run() { + stanzaIdAcknowledgedListeners.remove(id); + } + }, removeAfterSeconds, TimeUnit.SECONDS); + return stanzaIdAcknowledgedListeners.put(id, listener); + } + + /** + * Remove the Stanza ID acknowledged listener for the given ID. + * + * @param id the stanza ID. + * @return true if the listener was found and removed, false otherwise. + */ + public StanzaListener removeStanzaIdAcknowledgedListener(String id) { + return stanzaIdAcknowledgedListeners.remove(id); + } + + /** + * Removes all Stanza ID acknowledged listeners. + */ + public void removeAllStanzaIdAcknowledgedListeners() { + stanzaIdAcknowledgedListeners.clear(); + } + + /** + * Returns true if Stream Management is supported by the server. + * + * @return true if Stream Management is supported by the server. + */ + public boolean isSmAvailable() { + return hasFeature(StreamManagementFeature.ELEMENT, StreamManagement.NAMESPACE); + } + + /** + * Returns true if Stream Management was successfully negotiated with the server. + * + * @return true if Stream Management was negotiated. + */ + public boolean isSmEnabled() { + return smEnabledSyncPoint.wasSuccessful(); + } + + /** + * Returns true if the stream was successfully resumed with help of Stream Management. + * + * @return true if the stream was resumed. + */ + public boolean streamWasResumed() { + return smResumedSyncPoint.wasSuccessful(); + } + + /** + * Returns true if the connection is disconnected by a Stream resumption via Stream Management is possible. + * + * @return true if disconnected but resumption possible. + */ + public boolean isDisconnectedButSmResumptionPossible() { + return disconnectedButResumeable && isSmResumptionPossible(); + } + + /** + * Returns true if the stream is resumable. + * + * @return true if the stream is resumable. + */ + public boolean isSmResumptionPossible() { + // There is no resumable stream available + if (smSessionId == null) + return false; + + final Long shutdownTimestamp = packetWriter.shutdownTimestamp; + // Seems like we are already reconnected, report true + if (shutdownTimestamp == null) { + return true; + } + + // See if resumption time is over + long current = System.currentTimeMillis(); + long maxResumptionMillies = ((long) getMaxSmResumptionTime()) * 1000; + if (current > shutdownTimestamp + maxResumptionMillies) { + // Stream resumption is *not* possible if the current timestamp is greater then the greatest timestamp where + // resumption is possible + return false; + } else { + return true; + } + } + + /** + * Drop the stream management state. Sets {@link #smSessionId} and + * {@link #unacknowledgedStanzas} to null. + */ + private void dropSmState() { + // clientHandledCount and serverHandledCount will be reset on and + // respective. No need to reset them here. + smSessionId = null; + unacknowledgedStanzas = null; + } + + /** + * Get the maximum resumption time in seconds after which a managed stream can be resumed. + *

+ * This method will return {@link Integer#MAX_VALUE} if neither the client nor the server specify a maximum + * resumption time. Be aware of integer overflows when using this value, e.g. do not add arbitrary values to it + * without checking for overflows before. + *

+ * + * @return the maximum resumption time in seconds or {@link Integer#MAX_VALUE} if none set. + */ + public int getMaxSmResumptionTime() { + int clientResumptionTime = smClientMaxResumptionTime > 0 ? smClientMaxResumptionTime : Integer.MAX_VALUE; + int serverResumptionTime = smServerMaxResumptionTime > 0 ? smServerMaxResumptionTime : Integer.MAX_VALUE; + return Math.min(clientResumptionTime, serverResumptionTime); + } + + private void processHandledCount(long handledCount) throws StreamManagementCounterError { + long ackedStanzasCount = SMUtils.calculateDelta(handledCount, serverHandledStanzasCount); + final List ackedStanzas = new ArrayList<>( + ackedStanzasCount <= Integer.MAX_VALUE ? (int) ackedStanzasCount + : Integer.MAX_VALUE); + for (long i = 0; i < ackedStanzasCount; i++) { + Stanza ackedStanza = unacknowledgedStanzas.poll(); + // If the server ack'ed a stanza, then it must be in the + // unacknowledged stanza queue. There can be no exception. + if (ackedStanza == null) { + throw new StreamManagementCounterError(handledCount, serverHandledStanzasCount, + ackedStanzasCount, ackedStanzas); + } + ackedStanzas.add(ackedStanza); + } + + boolean atLeastOneStanzaAcknowledgedListener = false; + if (!stanzaAcknowledgedListeners.isEmpty()) { + // If stanzaAcknowledgedListeners is not empty, the we have at least one + atLeastOneStanzaAcknowledgedListener = true; + } + else { + // Otherwise we look for a matching id in the stanza *id* acknowledged listeners + for (Stanza ackedStanza : ackedStanzas) { + String id = ackedStanza.getStanzaId(); + if (id != null && stanzaIdAcknowledgedListeners.containsKey(id)) { + atLeastOneStanzaAcknowledgedListener = true; + break; + } + } + } + + // Only spawn a new thread if there is a chance that some listener is invoked + if (atLeastOneStanzaAcknowledgedListener) { + asyncGo(new Runnable() { + @Override + public void run() { + for (Stanza ackedStanza : ackedStanzas) { + for (StanzaListener listener : stanzaAcknowledgedListeners) { + try { + listener.processStanza(ackedStanza); + } + catch (InterruptedException | NotConnectedException | NotLoggedInException e) { + LOGGER.log(Level.FINER, "Received exception", e); + } + } + String id = ackedStanza.getStanzaId(); + if (StringUtils.isNullOrEmpty(id)) { + continue; + } + StanzaListener listener = stanzaIdAcknowledgedListeners.remove(id); + if (listener != null) { + try { + listener.processStanza(ackedStanza); + } + catch (InterruptedException | NotConnectedException | NotLoggedInException e) { + LOGGER.log(Level.FINER, "Received exception", e); + } + } + } + } + }); + } + + serverHandledStanzasCount = handledCount; + } + + /** + * Set the default bundle and defer callback used for new connections. + * + * @param defaultBundleAndDeferCallback + * @see BundleAndDeferCallback + * @since 4.1 + */ + public static void setDefaultBundleAndDeferCallback(BundleAndDeferCallback defaultBundleAndDeferCallback) { + XMPPTCPConnection.defaultBundleAndDeferCallback = defaultBundleAndDeferCallback; + } + + /** + * Set the bundle and defer callback used for this connection. + *

+ * You can use null as argument to reset the callback. Outgoing stanzas will then + * no longer get deferred. + *

+ * + * @param bundleAndDeferCallback the callback or null. + * @see BundleAndDeferCallback + * @since 4.1 + */ + public void setBundleandDeferCallback(BundleAndDeferCallback bundleAndDeferCallback) { + this.bundleAndDeferCallback = bundleAndDeferCallback; + } + + /** Suspends outgoing ack packets until further notice. */ + public void suspendSmAck() { + synchronized (clientHandledStanzasCountLock) { + smAckSuspend = true; + } + } + + /** Resumes outgoing ack packets and sends one right away. */ + public void resumeSmAck() throws NotConnectedException, StreamManagementNotEnabledException, InterruptedException { + synchronized (clientHandledStanzasCountLock) { + if (smAckSuspend) { + smAckSuspend = false; + if (ackPending) { + sendSmAcknowledgement(); + ackPending = false; + } + } + } + } + +} diff --git a/app/src/main/java/org/kontalk/crypto/Coder.java b/app/src/main/java/org/kontalk/crypto/Coder.java index acf8c1b0f..637cb09ad 100644 --- a/app/src/main/java/org/kontalk/crypto/Coder.java +++ b/app/src/main/java/org/kontalk/crypto/Coder.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,7 +21,9 @@ import java.io.InputStream; import java.io.OutputStream; import java.security.GeneralSecurityException; +import java.util.Date; import java.util.List; +import java.util.concurrent.TimeUnit; /** @@ -76,6 +78,8 @@ public abstract class Coder { /** Basic encryption (e.g. PGP). */ public static final int SECURITY_BASIC = SECURITY_BASIC_ENCRYPTED | SECURITY_BASIC_SIGNED; + /** How much time to consider a message timestamp drifted (and thus compromised). */ + public static final long TIMEDIFF_THRESHOLD = TimeUnit.DAYS.toMillis(1); /** Encrypts a string. */ public abstract byte[] encryptText(CharSequence text) throws GeneralSecurityException; @@ -83,10 +87,9 @@ public abstract class Coder { /** Encrypts a stanza. */ public abstract byte[] encryptStanza(CharSequence xml) throws GeneralSecurityException; - /** Decrypts a byte array which should content text. */ - public abstract void decryptText(byte[] encrypted, boolean verify, - StringBuilder out, StringBuilder mime, List errors) - throws GeneralSecurityException; + /** Decrypts a byte array which should contain text. */ + public abstract DecryptOutput decryptText(byte[] encrypted, boolean verify) + throws GeneralSecurityException; /** Encrypts a file. */ public abstract void encryptFile(InputStream input, OutputStream output) throws GeneralSecurityException; @@ -95,6 +98,9 @@ public abstract void decryptText(byte[] encrypted, boolean verify, public abstract void decryptFile(InputStream input, boolean verify, OutputStream output, List errors) throws GeneralSecurityException; + /** Verifies a byte array which should contain text. */ + public abstract Coder.VerifyOutput verifyText(byte[] signed, boolean verify) throws GeneralSecurityException; + /** Returns true if the given security flags has some error bit on. */ public static boolean isError(int securityFlags) { return (securityFlags & SECURITY_ERROR_INVALID_SIGNATURE) != 0 || @@ -107,4 +113,31 @@ public static boolean isError(int securityFlags) { (securityFlags & SECURITY_ERROR_PUBLIC_KEY_UNAVAILABLE) != 0; } + public static class DecryptOutput { + public final String mime; + public final String cleartext; + public final Date timestamp; + public final List errors; + + DecryptOutput(String cleartext, String mime, Date timestamp, List errors) { + this.cleartext = cleartext; + this.mime = mime; + this.timestamp = timestamp; + this.errors = errors; + } + } + + public static class VerifyOutput { + public final String cleartext; + public final Date timestamp; + public final List errors; + + VerifyOutput(String cleartext, Date timestamp, List errors) { + this.cleartext = cleartext; + this.timestamp = timestamp; + this.errors = errors; + } + + } + } diff --git a/app/src/main/java/org/kontalk/crypto/DecryptException.java b/app/src/main/java/org/kontalk/crypto/DecryptException.java index 576069d2c..a8d127c5d 100644 --- a/app/src/main/java/org/kontalk/crypto/DecryptException.java +++ b/app/src/main/java/org/kontalk/crypto/DecryptException.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/crypto/PGP.java b/app/src/main/java/org/kontalk/crypto/PGP.java index de974fae8..2ab234fe5 100644 --- a/app/src/main/java/org/kontalk/crypto/PGP.java +++ b/app/src/main/java/org/kontalk/crypto/PGP.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,13 +20,14 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.security.InvalidAlgorithmParameterException; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; import java.security.PrivateKey; +import java.security.Provider; import java.security.PublicKey; import java.security.Security; import java.security.SignatureException; @@ -35,48 +36,56 @@ import java.util.Iterator; import java.util.Locale; -import org.kontalk.util.MessageUtils; - +import org.jivesoftware.smack.util.StringUtils; import org.jxmpp.util.XmppStringUtils; -import org.spongycastle.bcpg.ArmoredInputStream; -import org.spongycastle.bcpg.HashAlgorithmTags; -import org.spongycastle.jce.provider.BouncyCastleProvider; -import org.spongycastle.openpgp.PGPEncryptedData; -import org.spongycastle.openpgp.PGPException; -import org.spongycastle.openpgp.PGPKeyPair; -import org.spongycastle.openpgp.PGPKeyRingGenerator; -import org.spongycastle.openpgp.PGPObjectFactory; -import org.spongycastle.openpgp.PGPPrivateKey; -import org.spongycastle.openpgp.PGPPublicKey; -import org.spongycastle.openpgp.PGPPublicKeyRing; -import org.spongycastle.openpgp.PGPSecretKey; -import org.spongycastle.openpgp.PGPSecretKeyRing; -import org.spongycastle.openpgp.PGPSignature; -import org.spongycastle.openpgp.PGPSignatureGenerator; -import org.spongycastle.openpgp.PGPSignatureSubpacketGenerator; -import org.spongycastle.openpgp.PGPSignatureSubpacketVector; -import org.spongycastle.openpgp.PGPUtil; -import org.spongycastle.openpgp.operator.KeyFingerPrintCalculator; -import org.spongycastle.openpgp.operator.PBESecretKeyDecryptor; -import org.spongycastle.openpgp.operator.PBESecretKeyEncryptor; -import org.spongycastle.openpgp.operator.PGPDigestCalculator; -import org.spongycastle.openpgp.operator.PGPDigestCalculatorProvider; -import org.spongycastle.openpgp.operator.bc.BcKeyFingerprintCalculator; -import org.spongycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; -import org.spongycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder; -import org.spongycastle.openpgp.operator.jcajce.JcaPGPKeyConverter; -import org.spongycastle.openpgp.operator.jcajce.JcaPGPKeyPair; -import org.spongycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; -import org.spongycastle.openpgp.operator.jcajce.JcePBESecretKeyEncryptorBuilder; +import org.bouncycastle.bcpg.ArmoredInputStream; +import org.bouncycastle.bcpg.BCPGInputStream; +import org.bouncycastle.bcpg.BCPGKey; +import org.bouncycastle.bcpg.DSASecretBCPGKey; +import org.bouncycastle.bcpg.ECSecretBCPGKey; +import org.bouncycastle.bcpg.ElGamalSecretBCPGKey; +import org.bouncycastle.bcpg.HashAlgorithmTags; +import org.bouncycastle.bcpg.PublicKeyPacket; +import org.bouncycastle.bcpg.RSASecretBCPGKey; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openpgp.PGPEncryptedData; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPKeyPair; +import org.bouncycastle.openpgp.PGPKeyRingGenerator; +import org.bouncycastle.openpgp.PGPObjectFactory; +import org.bouncycastle.openpgp.PGPPrivateKey; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSecretKey; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.PGPSignatureGenerator; +import org.bouncycastle.openpgp.PGPSignatureSubpacketGenerator; +import org.bouncycastle.openpgp.PGPSignatureSubpacketVector; +import org.bouncycastle.openpgp.PGPUtil; +import org.bouncycastle.openpgp.operator.KeyFingerPrintCalculator; +import org.bouncycastle.openpgp.operator.PBESecretKeyDecryptor; +import org.bouncycastle.openpgp.operator.PBESecretKeyEncryptor; +import org.bouncycastle.openpgp.operator.PGPDigestCalculator; +import org.bouncycastle.openpgp.operator.PGPDigestCalculatorProvider; +import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPKeyConverter; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPKeyPair; +import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyEncryptorBuilder; import android.os.Parcel; +import org.kontalk.reporting.ReportingManager; + /** Some PGP utility method, mainly for use by {@link PersonalKey}. */ public class PGP { - /** Security provider: Spongy Castle. */ - public static final String PROVIDER = "SC"; + /** Security provider: Bouncy Castle. */ + public static Provider PROVIDER; /** Default EC curve used. */ private static final String EC_CURVE = "P-256"; @@ -87,6 +96,9 @@ public class PGP { /** Singleton for converting a PGP key to a JCA key. */ private static JcaPGPKeyConverter sKeyConverter; + static KeyFingerPrintCalculator sFingerprintCalculator = + new BcKeyFingerprintCalculator(); + private PGP() { } @@ -115,42 +127,95 @@ public static final class PGPKeyPairRing { } public static PGPKeyPairRing load(byte[] privateKeyData, byte[] publicKeyData) + throws IOException, PGPException { + PGPPublicKeyRing publicKey = new PGPPublicKeyRing(publicKeyData, sFingerprintCalculator); + PGPSecretKeyRing secretKey = new PGPSecretKeyRing(privateKeyData, sFingerprintCalculator); + return new PGPKeyPairRing(publicKey, secretKey); + } + + public static PGPKeyPairRing load(InputStream privateKeyData, InputStream publicKeyData) + throws IOException, PGPException { + PGPPublicKeyRing publicKey = new PGPPublicKeyRing(publicKeyData, sFingerprintCalculator); + PGPSecretKeyRing secretKey = new PGPSecretKeyRing(privateKeyData, sFingerprintCalculator); + return new PGPKeyPairRing(publicKey, secretKey); + } + + public static PGPKeyPairRing loadArmored(byte[] privateKeyData, byte[] publicKeyData) throws IOException, PGPException { - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); ArmoredInputStream inPublic = new ArmoredInputStream(new ByteArrayInputStream(publicKeyData)); - PGPPublicKeyRing publicKey = new PGPPublicKeyRing(inPublic, fpr); + PGPPublicKeyRing publicKey = new PGPPublicKeyRing(inPublic, sFingerprintCalculator); ArmoredInputStream inPrivate = new ArmoredInputStream(new ByteArrayInputStream(privateKeyData)); - PGPSecretKeyRing secretKey = new PGPSecretKeyRing(inPrivate, fpr); + PGPSecretKeyRing secretKey = new PGPSecretKeyRing(inPrivate, sFingerprintCalculator); + return new PGPKeyPairRing(publicKey, secretKey); + } + + public static PGPKeyPairRing loadArmored(InputStream privateKeyData, InputStream publicKeyData) + throws IOException, PGPException { + ArmoredInputStream inPublic = new ArmoredInputStream(publicKeyData); + PGPPublicKeyRing publicKey = new PGPPublicKeyRing(inPublic, sFingerprintCalculator); + ArmoredInputStream inPrivate = new ArmoredInputStream(privateKeyData); + PGPSecretKeyRing secretKey = new PGPSecretKeyRing(inPrivate, sFingerprintCalculator); return new PGPKeyPairRing(publicKey, secretKey); } } public static void registerProvider() { - // register spongy castle provider - Security.insertProviderAt(new BouncyCastleProvider(), 1); + // create Bouncy Castle provider + PROVIDER = new BouncyCastleProvider(); + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME); + Security.addProvider(PROVIDER); + try { + // apply RNG fixes + PRNGFixes.apply(); + } + catch (Exception e) { + throw new PRNGFixException("Unable to apply PRNG fix", e); + } + } + + public static final class PRNGFixException extends SecurityException { + PRNGFixException(String message, Throwable cause) { + super(message, cause); + } } /** Creates an ECDSA/ECDH key pair. */ - public static PGPDecryptedKeyPairRing create() - throws NoSuchAlgorithmException, NoSuchProviderException, PGPException, InvalidAlgorithmParameterException { + public static PGPDecryptedKeyPairRing create(Date timestamp) + throws NoSuchAlgorithmException, PGPException, InvalidAlgorithmParameterException { - KeyPairGenerator gen; PGPKeyPair authKp, encryptKp, signKp; - gen = KeyPairGenerator.getInstance("RSA", PROVIDER); - gen.initialize(RSA_KEY_LENGTH); + KeyPairGenerator authGen = KeyPairGenerator.getInstance("RSA", PROVIDER); + authGen.initialize(RSA_KEY_LENGTH); - authKp = new JcaPGPKeyPair(PGPPublicKey.RSA_GENERAL, gen.generateKeyPair(), new Date()); + authKp = new JcaPGPKeyPair(PGPPublicKey.RSA_GENERAL, authGen.generateKeyPair(), timestamp); - gen = KeyPairGenerator.getInstance("ECDH", PROVIDER); - gen.initialize(new ECGenParameterSpec(EC_CURVE)); + KeyPairGenerator encryptGen; + try { + encryptGen = KeyPairGenerator.getInstance("ECDH", PROVIDER); + encryptGen.initialize(new ECGenParameterSpec(EC_CURVE)); + } + catch (Throwable e) { + ReportingManager.logException(e); + encryptGen = KeyPairGenerator.getInstance("RSA", PROVIDER); + encryptGen.initialize(RSA_KEY_LENGTH); + } - encryptKp = new JcaPGPKeyPair(PGPPublicKey.ECDH, gen.generateKeyPair(), new Date()); + encryptKp = new JcaPGPKeyPair(PGPPublicKey.ECDH, encryptGen.generateKeyPair(), timestamp); - gen = KeyPairGenerator.getInstance("ECDSA", PROVIDER); - gen.initialize(new ECGenParameterSpec(EC_CURVE)); + KeyPairGenerator signGen; - signKp = new JcaPGPKeyPair(PGPPublicKey.ECDSA, gen.generateKeyPair(), new Date()); + try { + signGen = KeyPairGenerator.getInstance("ECDSA", PROVIDER); + signGen.initialize(new ECGenParameterSpec(EC_CURVE)); + } + catch (Throwable e) { + ReportingManager.logException(e); + signGen = KeyPairGenerator.getInstance("RSA", PROVIDER); + signGen.initialize(RSA_KEY_LENGTH); + } + + signKp = new JcaPGPKeyPair(PGPPublicKey.ECDSA, signGen.generateKeyPair(), timestamp); return new PGPDecryptedKeyPairRing(authKp, signKp, encryptKp); } @@ -159,7 +224,7 @@ public static PGPDecryptedKeyPairRing create() public static PGPKeyPairRing store(PGPDecryptedKeyPairRing pair, String id, String passphrase) - throws PGPException { + throws PGPException, IOException { PGPSignatureSubpacketGenerator sbpktGen; @@ -167,17 +232,19 @@ public static PGPKeyPairRing store(PGPDecryptedKeyPairRing pair, sbpktGen = new PGPSignatureSubpacketGenerator(); // the master key is used for authentication and certification sbpktGen.setKeyFlags(false, PGPKeyFlags.CAN_AUTHENTICATE | PGPKeyFlags.CAN_CERTIFY); + sbpktGen.setPrimaryUserID(false, true); - PGPDigestCalculator sha1Calc = new JcaPGPDigestCalculatorProviderBuilder().build().get(HashAlgorithmTags.SHA1); + PGPDigestCalculator digestCalc = new JcaPGPDigestCalculatorProviderBuilder().build().get(HashAlgorithmTags.SHA1); PGPKeyRingGenerator keyRingGen = new PGPKeyRingGenerator(PGPSignature.POSITIVE_CERTIFICATION, pair.authKey, - id, sha1Calc, sbpktGen.generate(), null, - new JcaPGPContentSignerBuilder(pair.authKey.getPublicKey().getAlgorithm(), HashAlgorithmTags.SHA1), - new JcePBESecretKeyEncryptorBuilder(PGPEncryptedData.AES_256, sha1Calc) + id, digestCalc, sbpktGen.generate(), null, + new JcaPGPContentSignerBuilder(pair.authKey.getPublicKey().getAlgorithm(), HashAlgorithmTags.SHA256), + new JcePBESecretKeyEncryptorBuilder(PGPEncryptedData.AES_256, digestCalc) .setProvider(PROVIDER).build(passphrase.toCharArray())); // add signing subkey sbpktGen = new PGPSignatureSubpacketGenerator(); sbpktGen.setKeyFlags(false, PGPKeyFlags.CAN_SIGN); + sbpktGen.setEmbeddedSignature(false, crossCertify(pair.signKey, pair.authKey.getPublicKey())); keyRingGen.addSubKey(pair.signKey, sbpktGen.generate(), null); // add encryption subkey @@ -191,6 +258,15 @@ public static PGPKeyPairRing store(PGPDecryptedKeyPairRing pair, return new PGPKeyPairRing(pubRing, secRing); } + /** Generates a cross-certification for a subkey. */ + private static PGPSignature crossCertify(PGPKeyPair signer, PGPPublicKey key) throws PGPException { + PGPSignatureGenerator sGen = new PGPSignatureGenerator( + new JcaPGPContentSignerBuilder(signer.getPublicKey().getAlgorithm(), + PGPUtil.SHA256).setProvider(PROVIDER)); + sGen.init(PGPSignature.PRIMARYKEY_BINDING, signer.getPrivateKey()); + return sGen.generateCertification(key); + } + /** Revokes the given key. */ public static PGPPublicKey revokeKey(PGPKeyPair secret) throws PGPException, IOException, SignatureException { @@ -200,159 +276,200 @@ public static PGPPublicKey revokeKey(PGPKeyPair secret) PGPSignatureGenerator sGen = new PGPSignatureGenerator( new JcaPGPContentSignerBuilder(secret.getPublicKey().getAlgorithm(), - PGPUtil.SHA1).setProvider(PROVIDER)); + PGPUtil.SHA256).setProvider(PROVIDER)); sGen.init(PGPSignature.KEY_REVOCATION, pgpPrivKey); return PGPPublicKey.addCertification(pgpPubKey, sGen.generateCertification(pgpPubKey)); } - public static PGPDecryptedKeyPairRing fromParcel(Parcel in) throws PGPException { - ensureKeyConverter(); - - // TODO read byte data - - PrivateKey privAuth = (PrivateKey) in.readSerializable(); - PublicKey pubAuth = (PublicKey) in.readSerializable(); - int algoAuth = in.readInt(); - Date dateAuth = new Date(in.readLong()); - - PGPPublicKey pubKeyAuth = sKeyConverter.getPGPPublicKey(algoAuth, pubAuth, dateAuth); - PGPPrivateKey privKeyAuth = sKeyConverter.getPGPPrivateKey(pubKeyAuth, privAuth); - PGPKeyPair authKp = new PGPKeyPair(pubKeyAuth, privKeyAuth); - - PrivateKey privSign = (PrivateKey) in.readSerializable(); - PublicKey pubSign = (PublicKey) in.readSerializable(); - int algoSign = in.readInt(); - Date dateSign = new Date(in.readLong()); - - PGPPublicKey pubKeySign = sKeyConverter.getPGPPublicKey(algoSign, pubSign, dateSign); - PGPPrivateKey privKeySign = sKeyConverter.getPGPPrivateKey(pubKeySign, privSign); - PGPKeyPair signKp = new PGPKeyPair(pubKeySign, privKeySign); - - PrivateKey privEnc = (PrivateKey) in.readSerializable(); - PublicKey pubEnc = (PublicKey) in.readSerializable(); - int algoEnc = in.readInt(); - Date dateEnc = new Date(in.readLong()); - - PGPPublicKey pubKeyEnc = sKeyConverter.getPGPPublicKey(algoEnc, pubEnc, dateEnc); - PGPPrivateKey privKeyEnc = sKeyConverter.getPGPPrivateKey(pubKeyEnc, privEnc); - PGPKeyPair encryptKp = new PGPKeyPair(pubKeyEnc, privKeyEnc); - + public static PGPDecryptedKeyPairRing fromParcel(Parcel in) throws PGPException, IOException { + PGPKeyPair authKp = readKeyPairFromParcel(in); + PGPKeyPair signKp = readKeyPairFromParcel(in); + PGPKeyPair encryptKp = readKeyPairFromParcel(in); return new PGPDecryptedKeyPairRing(authKp, signKp, encryptKp); } public static void toParcel(PGPDecryptedKeyPairRing pair, Parcel dest) - throws NoSuchProviderException, PGPException { - - PrivateKey privAuth = convertPrivateKey(pair.authKey.getPrivateKey()); - PublicKey pubAuth = convertPublicKey(pair.authKey.getPublicKey()); - int algoAuth = pair.authKey.getPrivateKey().getPublicKeyPacket().getAlgorithm(); - Date dateAuth = pair.authKey.getPrivateKey().getPublicKeyPacket().getTime(); + throws IOException { + writeKeyPairToParcel(pair.authKey, dest); + writeKeyPairToParcel(pair.signKey, dest); + writeKeyPairToParcel(pair.encryptKey, dest); + } + + private static void writeKeyPairToParcel(PGPKeyPair keypair, Parcel dest) throws IOException { + // write private key + PGPPrivateKey priv = keypair.getPrivateKey(); + // key ID + dest.writeLong(priv.getKeyID()); + // private key data packet + byte[] privData = priv.getPrivateKeyDataPacket().getEncoded(); + dest.writeInt(privData.length); + dest.writeByteArray(privData); + // public key packet + byte[] privPubKey = priv.getPublicKeyPacket().getEncoded(); + dest.writeInt(privPubKey.length); + dest.writeByteArray(privPubKey); + + // write public key + PGPPublicKey pub = keypair.getPublicKey(); + // whole public key + byte[] pubPacket = pub.getEncoded(); + dest.writeInt(pubPacket.length); + dest.writeByteArray(pubPacket); + } + + private static PGPKeyPair readKeyPairFromParcel(Parcel in) throws IOException, PGPException { + BCPGInputStream reader = null; + long privID; + BCPGKey privData; + PublicKeyPacket privPubKey; + + // rebuild private key + + try { + // key ID + privID = in.readLong(); + + // private key data packet + int privDataLength = in.readInt(); + byte[] privDataData = new byte[privDataLength]; + in.readByteArray(privDataData); + // object will be read later + + // public key packet + int privPubKeyLength = in.readInt(); + byte[] privPubKeyData = new byte[privPubKeyLength]; + in.readByteArray(privPubKeyData); + + reader = new BCPGInputStream(new ByteArrayInputStream(privPubKeyData)); + privPubKey = (PublicKeyPacket) reader.readPacket(); + reader.close(); + + reader = new BCPGInputStream(new ByteArrayInputStream(privDataData)); + + // from PGPSecretKey + // apparently there is no way to make this dynamic + switch (privPubKey.getAlgorithm()) + { + case PGPPublicKey.RSA_ENCRYPT: + case PGPPublicKey.RSA_GENERAL: + case PGPPublicKey.RSA_SIGN: + privData = new RSASecretBCPGKey(reader); + break; + case PGPPublicKey.DSA: + privData = new DSASecretBCPGKey(reader); + break; + case PGPPublicKey.ELGAMAL_ENCRYPT: + case PGPPublicKey.ELGAMAL_GENERAL: + privData = new ElGamalSecretBCPGKey(reader); + break; + case PGPPublicKey.ECDH: + case PGPPublicKey.ECDSA: + privData = new ECSecretBCPGKey(reader); + break; + default: + throw new PGPException("unknown public key algorithm encountered"); + } - PrivateKey privSign = convertPrivateKey(pair.signKey.getPrivateKey()); - PublicKey pubSign = convertPublicKey(pair.signKey.getPublicKey()); - int algoSign = pair.signKey.getPrivateKey().getPublicKeyPacket().getAlgorithm(); - Date dateSign = pair.signKey.getPrivateKey().getPublicKeyPacket().getTime(); + reader.close(); + } + finally { + try { + if (reader != null) + reader.close(); + } + catch (IOException ignored) { + } + } - PrivateKey privEnc = convertPrivateKey(pair.encryptKey.getPrivateKey()); - PublicKey pubEnc = convertPublicKey(pair.encryptKey.getPublicKey()); - int algoEnc = pair.encryptKey.getPrivateKey().getPublicKeyPacket().getAlgorithm(); - Date dateEnc = pair.encryptKey.getPrivateKey().getPublicKeyPacket().getTime(); + PGPPrivateKey priv = new PGPPrivateKey(privID, privPubKey, privData); - dest.writeSerializable(privAuth); - dest.writeSerializable(pubAuth); - dest.writeInt(algoAuth); - dest.writeLong(dateAuth.getTime()); + // rebuild public key + // public key data + int pubKeyLength = in.readInt(); + byte[] pubKeyData = new byte[pubKeyLength]; + in.readByteArray(pubKeyData); - dest.writeSerializable(privSign); - dest.writeSerializable(pubSign); - dest.writeInt(algoSign); - dest.writeLong(dateSign.getTime()); + PGPObjectFactory f = new PGPObjectFactory(pubKeyData, sFingerprintCalculator); + PGPPublicKeyRing pubring = (PGPPublicKeyRing) f.nextObject(); + PGPPublicKey pub = pubring.getPublicKey(); - dest.writeSerializable(privEnc); - dest.writeSerializable(pubEnc); - dest.writeInt(algoEnc); - dest.writeLong(dateEnc.getTime()); + return new PGPKeyPair(pub, priv); } public static void serialize(PGPDecryptedKeyPairRing pair, ObjectOutputStream dest) throws PGPException, IOException { PrivateKey privAuth = convertPrivateKey(pair.authKey.getPrivateKey()); - PublicKey pubAuth = convertPublicKey(pair.authKey.getPublicKey()); - int algoAuth = pair.authKey.getPrivateKey().getPublicKeyPacket().getAlgorithm(); - Date dateAuth = pair.authKey.getPrivateKey().getPublicKeyPacket().getTime(); + byte[] pubAuth = pair.authKey.getPublicKey().getEncoded(); PrivateKey privSign = convertPrivateKey(pair.signKey.getPrivateKey()); - PublicKey pubSign = convertPublicKey(pair.signKey.getPublicKey()); - int algoSign = pair.signKey.getPrivateKey().getPublicKeyPacket().getAlgorithm(); - Date dateSign = pair.signKey.getPrivateKey().getPublicKeyPacket().getTime(); + byte[] pubSign = pair.signKey.getPublicKey().getEncoded(); PrivateKey privEnc = convertPrivateKey(pair.encryptKey.getPrivateKey()); - PublicKey pubEnc = convertPublicKey(pair.encryptKey.getPublicKey()); - int algoEnc = pair.encryptKey.getPrivateKey().getPublicKeyPacket().getAlgorithm(); - Date dateEnc = pair.encryptKey.getPrivateKey().getPublicKeyPacket().getTime(); + byte[] pubEnc = pair.encryptKey.getPublicKey().getEncoded(); dest.writeObject(privAuth); dest.writeObject(pubAuth); - dest.writeInt(algoAuth); - dest.writeLong(dateAuth.getTime()); dest.writeObject(privSign); dest.writeObject(pubSign); - dest.writeInt(algoSign); - dest.writeLong(dateSign.getTime()); dest.writeObject(privEnc); dest.writeObject(pubEnc); - dest.writeInt(algoEnc); - dest.writeLong(dateEnc.getTime()); } public static PGPDecryptedKeyPairRing unserialize(ObjectInputStream in) throws IOException, ClassNotFoundException, PGPException { ensureKeyConverter(); - // TODO read byte data - PrivateKey privAuth = (PrivateKey) in.readObject(); - PublicKey pubAuth = (PublicKey) in.readObject(); - int algoAuth = in.readInt(); - Date dateAuth = new Date(in.readLong()); + byte[] pubAuthData = (byte[]) in.readObject(); + PGPObjectFactory pubAuthIn = new PGPObjectFactory(pubAuthData, sFingerprintCalculator); - PGPPublicKey pubKeyAuth = sKeyConverter.getPGPPublicKey(algoAuth, pubAuth, dateAuth); + PGPPublicKey pubKeyAuth = extractPublicKey(pubAuthIn); PGPPrivateKey privKeyAuth = sKeyConverter.getPGPPrivateKey(pubKeyAuth, privAuth); PGPKeyPair authKp = new PGPKeyPair(pubKeyAuth, privKeyAuth); PrivateKey privSign = (PrivateKey) in.readObject(); - PublicKey pubSign = (PublicKey) in.readObject(); - int algoSign = in.readInt(); - Date dateSign = new Date(in.readLong()); + byte[] pubSignData = (byte[]) in.readObject(); + PGPObjectFactory pubSignIn = new PGPObjectFactory(pubSignData, sFingerprintCalculator); - PGPPublicKey pubKeySign = sKeyConverter.getPGPPublicKey(algoSign, pubSign, dateSign); + PGPPublicKey pubKeySign = extractPublicKey(pubSignIn); PGPPrivateKey privKeySign = sKeyConverter.getPGPPrivateKey(pubKeySign, privSign); PGPKeyPair signKp = new PGPKeyPair(pubKeySign, privKeySign); PrivateKey privEnc = (PrivateKey) in.readObject(); - PublicKey pubEnc = (PublicKey) in.readObject(); - int algoEnc = in.readInt(); - Date dateEnc = new Date(in.readLong()); + byte[] pubEncData = (byte[]) in.readObject(); + PGPObjectFactory pubEncIn = new PGPObjectFactory(pubEncData, sFingerprintCalculator); - PGPPublicKey pubKeyEnc = sKeyConverter.getPGPPublicKey(algoEnc, pubEnc, dateEnc); + PGPPublicKey pubKeyEnc = extractPublicKey(pubEncIn); PGPPrivateKey privKeyEnc = sKeyConverter.getPGPPrivateKey(pubKeyEnc, privEnc); PGPKeyPair encryptKp = new PGPKeyPair(pubKeyEnc, privKeyEnc); return new PGPDecryptedKeyPairRing(authKp, signKp, encryptKp); } + private static PGPPublicKey extractPublicKey(PGPObjectFactory in) throws IOException, PGPException { + Object object = in.nextObject(); + if (object instanceof PGPPublicKeyRing) { + return ((PGPPublicKeyRing) object).getPublicKey(); + } + else if (object instanceof PGPPublicKey) { + return (PGPPublicKey) object; + } + else { + throw new PGPException("Invalid key data"); + } + } + public static String getFingerprint(PGPPublicKey publicKey) { - return MessageUtils.bytesToHex(publicKey.getFingerprint()).toUpperCase(Locale.US); + return StringUtils.encodeHex(publicKey.getFingerprint()).toUpperCase(Locale.US); } public static String getFingerprint(byte[] publicKeyring) throws IOException, PGPException { - PGPPublicKey pk = getMasterKey(publicKeyring); - return MessageUtils.bytesToHex(pk.getFingerprint()).toUpperCase(Locale.US); + return getFingerprint(getMasterKey(publicKeyring)); } // FIXME very ugly method @@ -379,7 +496,6 @@ public static String createFingerprintURI(String fingerprint) { public static String getUserId(PGPPublicKey key, String host) { String first = null; - @SuppressWarnings("unchecked") Iterator uids = key.getUserIDs(); while (uids.hasNext()) { String uid = uids.next(); @@ -432,7 +548,7 @@ public static int getKeyFlags(PGPPublicKey key) { PGPSignature sig = sigs.next(); if (sig != null) { PGPSignatureSubpacketVector subpackets = sig.getHashedSubPackets(); - if (subpackets != null && subpackets.isPrimaryUserID()) { + if (subpackets != null) { return subpackets.getKeyFlags(); } } @@ -442,7 +558,6 @@ public static int getKeyFlags(PGPPublicKey key) { /** Returns the first master key found in the given public keyring. */ public static PGPPublicKey getMasterKey(PGPPublicKeyRing publicKeyring) { - @SuppressWarnings("unchecked") Iterator iter = publicKeyring.getPublicKeys(); while (iter.hasNext()) { PGPPublicKey pk = iter.next(); @@ -458,8 +573,21 @@ public static PGPPublicKey getMasterKey(byte[] publicKeyring) throws IOException return getMasterKey(readPublicKeyring(publicKeyring)); } + public static PGPPublicKey getAuthenticationKey(PGPPublicKeyRing publicKeyring) { + Iterator iter = publicKeyring.getPublicKeys(); + while (iter.hasNext()) { + PGPPublicKey pk = iter.next(); + if (pk.isMasterKey()) { + int keyFlags = getKeyFlags(pk); + if ((keyFlags & PGPKeyFlags.CAN_AUTHENTICATE) == PGPKeyFlags.CAN_AUTHENTICATE) + return pk; + } + } + + return null; + } + public static PGPPublicKey getSigningKey(PGPPublicKeyRing publicKeyring) { - @SuppressWarnings("unchecked") Iterator iter = publicKeyring.getPublicKeys(); while (iter.hasNext()) { PGPPublicKey pk = iter.next(); @@ -475,7 +603,6 @@ public static PGPPublicKey getSigningKey(PGPPublicKeyRing publicKeyring) { } public static PGPPublicKey getEncryptionKey(PGPPublicKeyRing publicKeyring) { - @SuppressWarnings("unchecked") Iterator iter = publicKeyring.getPublicKeys(); while (iter.hasNext()) { PGPPublicKey pk = iter.next(); @@ -492,7 +619,6 @@ public static PGPPublicKey getEncryptionKey(PGPPublicKeyRing publicKeyring) { } private static PGPPublicKey getLegacyEncryptionKey(PGPPublicKeyRing publicKeyring) { - @SuppressWarnings("unchecked") Iterator iter = publicKeyring.getPublicKeys(); while (iter.hasNext()) { PGPPublicKey pk = iter.next(); @@ -504,7 +630,6 @@ private static PGPPublicKey getLegacyEncryptionKey(PGPPublicKeyRing publicKeyrin } private static PGPPublicKey getLegacySigningKey(PGPPublicKeyRing publicKeyring) { - @SuppressWarnings("unchecked") Iterator iter = publicKeyring.getPublicKeys(); while (iter.hasNext()) { PGPPublicKey pk = iter.next(); @@ -516,7 +641,7 @@ private static PGPPublicKey getLegacySigningKey(PGPPublicKeyRing publicKeyring) } public static PGPPublicKeyRing readPublicKeyring(byte[] publicKeyring) throws IOException, PGPException { - PGPObjectFactory reader = new PGPObjectFactory(publicKeyring); + PGPObjectFactory reader = new PGPObjectFactory(publicKeyring, sFingerprintCalculator); Object o = reader.nextObject(); while (o != null) { if (o instanceof PGPPublicKeyRing) @@ -538,18 +663,16 @@ public static PrivateKey convertPrivateKey(PGPPrivateKey key) throws PGPExceptio return sKeyConverter.getPrivateKey(key); } - @SuppressWarnings("unchecked") public static PrivateKey convertPrivateKey(byte[] privateKeyData, String passphrase) throws PGPException, IOException { - PGPDigestCalculatorProvider sha1Calc = new JcaPGPDigestCalculatorProviderBuilder().build(); - PBESecretKeyDecryptor decryptor = new JcePBESecretKeyDecryptorBuilder(sha1Calc) + PGPDigestCalculatorProvider digestCalc = new JcaPGPDigestCalculatorProviderBuilder().build(); + PBESecretKeyDecryptor decryptor = new JcePBESecretKeyDecryptorBuilder(digestCalc) .setProvider(PGP.PROVIDER) .build(passphrase.toCharArray()); // load the secret key ring - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); - PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, fpr); + PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, sFingerprintCalculator); // search and decrypt the master (signing key) // secret keys @@ -570,12 +693,33 @@ public static PublicKey convertPublicKey(PGPPublicKey key) throws PGPException { return sKeyConverter.getPublicKey(key); } + public static byte[] changePassphrase(byte[] privateKeyData, String oldPassphrase, String newPassphrase) + throws IOException, PGPException { + KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); + PGPSecretKeyRing oldSecRing = new PGPSecretKeyRing(privateKeyData, fpr); + + // old decryptor + PGPDigestCalculatorProvider calcProv = new JcaPGPDigestCalculatorProviderBuilder().build(); + PBESecretKeyDecryptor oldDecryptor = new JcePBESecretKeyDecryptorBuilder(calcProv) + .setProvider(PGP.PROVIDER) + .build(oldPassphrase.toCharArray()); + + // new encryptor + PGPDigestCalculator sha1Calc = new JcaPGPDigestCalculatorProviderBuilder().build().get(HashAlgorithmTags.SHA1); + PBESecretKeyEncryptor newEncryptor = new JcePBESecretKeyEncryptorBuilder(PGPEncryptedData.AES_256, sha1Calc) + .setProvider(PGP.PROVIDER).build(newPassphrase.toCharArray()); + + // create new secret key ring + PGPSecretKeyRing newSecRing = PGPSecretKeyRing.copyWithNewPassword(oldSecRing, oldDecryptor, newEncryptor); + + return newSecRing.getEncoded(); + } + public static PGPSecretKeyRing copySecretKeyRingWithNewPassword(byte[] privateKeyData, String oldPassphrase, String newPassphrase) throws PGPException, IOException { // load the secret key ring - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); - PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, fpr); + PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, sFingerprintCalculator); return copySecretKeyRingWithNewPassword(secRing, oldPassphrase, newPassphrase); } @@ -583,13 +727,13 @@ public static PGPSecretKeyRing copySecretKeyRingWithNewPassword(byte[] privateKe public static PGPSecretKeyRing copySecretKeyRingWithNewPassword(PGPSecretKeyRing secRing, String oldPassphrase, String newPassphrase) throws PGPException { - PGPDigestCalculatorProvider sha1CalcProv = new JcaPGPDigestCalculatorProviderBuilder().build(); - PBESecretKeyDecryptor decryptor = new JcePBESecretKeyDecryptorBuilder(sha1CalcProv) + PGPDigestCalculatorProvider digestCalcProv = new JcaPGPDigestCalculatorProviderBuilder().build(); + PBESecretKeyDecryptor decryptor = new JcePBESecretKeyDecryptorBuilder(digestCalcProv) .setProvider(PGP.PROVIDER) .build(oldPassphrase.toCharArray()); - PGPDigestCalculator sha1Calc = new JcaPGPDigestCalculatorProviderBuilder().build().get(HashAlgorithmTags.SHA1); - PBESecretKeyEncryptor encryptor = new JcePBESecretKeyEncryptorBuilder(PGPEncryptedData.AES_256, sha1Calc) + PGPDigestCalculator digestCalc = new JcaPGPDigestCalculatorProviderBuilder().build().get(HashAlgorithmTags.SHA256); + PBESecretKeyEncryptor encryptor = new JcePBESecretKeyEncryptorBuilder(PGPEncryptedData.AES_256, digestCalc) .setProvider(PROVIDER).build(newPassphrase.toCharArray()); return PGPSecretKeyRing.copyWithNewPassword(secRing, decryptor, encryptor); diff --git a/app/src/main/java/org/kontalk/crypto/PGPCoder.java b/app/src/main/java/org/kontalk/crypto/PGPCoder.java index 6e892a0ee..5127115fd 100644 --- a/app/src/main/java/org/kontalk/crypto/PGPCoder.java +++ b/app/src/main/java/org/kontalk/crypto/PGPCoder.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -27,40 +27,41 @@ import java.security.SecureRandom; import java.security.SignatureException; import java.text.ParseException; +import java.util.ArrayList; import java.util.Date; import java.util.Iterator; import java.util.List; -import org.spongycastle.bcpg.HashAlgorithmTags; -import org.spongycastle.openpgp.PGPCompressedData; -import org.spongycastle.openpgp.PGPCompressedDataGenerator; -import org.spongycastle.openpgp.PGPEncryptedData; -import org.spongycastle.openpgp.PGPEncryptedDataGenerator; -import org.spongycastle.openpgp.PGPEncryptedDataList; -import org.spongycastle.openpgp.PGPException; -import org.spongycastle.openpgp.PGPLiteralData; -import org.spongycastle.openpgp.PGPLiteralDataGenerator; -import org.spongycastle.openpgp.PGPObjectFactory; -import org.spongycastle.openpgp.PGPOnePassSignature; -import org.spongycastle.openpgp.PGPOnePassSignatureList; -import org.spongycastle.openpgp.PGPPrivateKey; -import org.spongycastle.openpgp.PGPPublicKeyEncryptedData; -import org.spongycastle.openpgp.PGPPublicKeyRing; -import org.spongycastle.openpgp.PGPSignature; -import org.spongycastle.openpgp.PGPSignatureGenerator; -import org.spongycastle.openpgp.PGPSignatureList; -import org.spongycastle.openpgp.PGPSignatureSubpacketGenerator; -import org.spongycastle.openpgp.operator.KeyFingerPrintCalculator; -import org.spongycastle.openpgp.operator.bc.BcKeyFingerprintCalculator; -import org.spongycastle.openpgp.operator.bc.BcPGPContentSignerBuilder; -import org.spongycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider; -import org.spongycastle.openpgp.operator.bc.BcPGPDataEncryptorBuilder; -import org.spongycastle.openpgp.operator.bc.BcPublicKeyDataDecryptorFactory; -import org.spongycastle.openpgp.operator.bc.BcPublicKeyKeyEncryptionMethodGenerator; +import org.bouncycastle.bcpg.HashAlgorithmTags; +import org.bouncycastle.openpgp.PGPCompressedData; +import org.bouncycastle.openpgp.PGPCompressedDataGenerator; +import org.bouncycastle.openpgp.PGPEncryptedData; +import org.bouncycastle.openpgp.PGPEncryptedDataGenerator; +import org.bouncycastle.openpgp.PGPEncryptedDataList; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPLiteralData; +import org.bouncycastle.openpgp.PGPLiteralDataGenerator; +import org.bouncycastle.openpgp.PGPObjectFactory; +import org.bouncycastle.openpgp.PGPOnePassSignature; +import org.bouncycastle.openpgp.PGPOnePassSignatureList; +import org.bouncycastle.openpgp.PGPPrivateKey; +import org.bouncycastle.openpgp.PGPPublicKeyEncryptedData; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.PGPSignatureGenerator; +import org.bouncycastle.openpgp.PGPSignatureList; +import org.bouncycastle.openpgp.PGPSignatureSubpacketGenerator; +import org.bouncycastle.openpgp.operator.KeyFingerPrintCalculator; +import org.bouncycastle.openpgp.operator.bc.BcPGPContentSignerBuilder; +import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider; +import org.bouncycastle.openpgp.operator.bc.BcPGPDataEncryptorBuilder; +import org.bouncycastle.openpgp.operator.bc.BcPublicKeyDataDecryptorFactory; +import org.bouncycastle.openpgp.operator.bc.BcPublicKeyKeyEncryptionMethodGenerator; import org.kontalk.client.EndpointServer; import org.kontalk.message.TextComponent; import org.kontalk.util.CPIMMessage; +import org.kontalk.util.DataUtils; import org.kontalk.util.XMPPUtils; import static org.kontalk.crypto.DecryptException.DECRYPT_EXCEPTION_INTEGRITY_CHECK; @@ -71,6 +72,9 @@ import static org.kontalk.crypto.DecryptException.DECRYPT_EXCEPTION_PRIVATE_KEY_NOT_FOUND; import static org.kontalk.crypto.DecryptException.DECRYPT_EXCEPTION_VERIFICATION_FAILED; +import static org.kontalk.crypto.VerifyException.VERIFY_EXCEPTION_INVALID_DATA; +import static org.kontalk.crypto.VerifyException.VERIFY_EXCEPTION_VERIFICATION_FAILED; + /** * PGP coder implementation. @@ -79,7 +83,7 @@ public class PGPCoder extends Coder { private static final KeyFingerPrintCalculator sFingerprintCalculator = - new BcKeyFingerprintCalculator(); + PGP.sFingerprintCalculator; /** Buffer size. It should always be a power of 2. */ private static final int BUFFER_SIZE = 1 << 8; @@ -131,7 +135,7 @@ public byte[] encryptStanza(CharSequence xml) throws GeneralSecurityException { xml + ""; - return encryptData(XMPPUtils.XML_XMPP_TYPE, xmlWrapper.toString()); + return encryptData(XMPPUtils.XML_XMPP_TYPE, xmlWrapper); } catch (PGPException e) { @@ -147,13 +151,12 @@ private byte[] encryptData(String mime, CharSequence data) throws PGPException, IOException, SignatureException { String from = mKey.getUserId(mServer.getNetwork()); - StringBuilder to = new StringBuilder(); - for (PGPPublicKeyRing rcpt : mRecipients) - to.append(PGP.getUserId(PGP.getMasterKey(rcpt), mServer.getNetwork())) - .append("; "); + String[] to = new String[mRecipients.length]; + for (int i = 0; i < to.length; i++) + to[i] = PGP.getUserId(PGP.getMasterKey(mRecipients[i]), mServer.getNetwork()); // secure the message against the most basic attacks using Message/CPIM - CPIMMessage cpim = new CPIMMessage(from, to.toString(), new Date(), mime, data); + CPIMMessage cpim = new CPIMMessage(from, to, new Date(), mime, data); byte[] plainText = cpim.toByteArray(); ByteArrayOutputStream out = new ByteArrayOutputStream(); @@ -178,7 +181,7 @@ private byte[] encryptData(String mime, CharSequence data) // setup signature generator PGPSignatureGenerator sigGen = new PGPSignatureGenerator (new BcPGPContentSignerBuilder(mKey.getSignKeyPair() - .getPublicKey().getAlgorithm(), HashAlgorithmTags.SHA1)); + .getPublicKey().getAlgorithm(), HashAlgorithmTags.SHA256)); sigGen.init(PGPSignature.BINARY_DOCUMENT, mKey.getSignKeyPair().getPrivateKey()); PGPSignatureSubpacketGenerator spGen = new PGPSignatureSubpacketGenerator(); @@ -219,9 +222,14 @@ private byte[] encryptData(String mime, CharSequence data) @SuppressWarnings("unchecked") @Override - public void decryptText(byte[] encrypted, boolean verify, - StringBuilder out, StringBuilder mime, List errors) - throws GeneralSecurityException { + public DecryptOutput decryptText(byte[] encrypted, boolean verify) + throws GeneralSecurityException { + + List errors = new ArrayList<>(); + String mime = null; + Date timestamp = null; + String out = null; + InputStream cDataIn = null; try { PGPObjectFactory pgpF = new PGPObjectFactory(encrypted, sFingerprintCalculator); @@ -238,7 +246,7 @@ public void decryptText(byte[] encrypted, boolean verify, } // check if secret key matches - Iterator it = enc.getEncryptedDataObjects(); + Iterator it = enc.getEncryptedDataObjects(); PGPPrivateKey sKey = null; PGPPublicKeyEncryptedData pbe = null; @@ -246,7 +254,7 @@ public void decryptText(byte[] encrypted, boolean verify, long ourKeyID = mKey.getEncryptKeyPair().getPrivateKey().getKeyID(); while (sKey == null && it.hasNext()) { - pbe = it.next(); + pbe = (PGPPublicKeyEncryptedData) it.next(); if (pbe.getKeyID() == ourKeyID) sKey = mKey.getEncryptKeyPair().getPrivateKey(); @@ -269,7 +277,8 @@ public void decryptText(byte[] encrypted, boolean verify, if (message instanceof PGPCompressedData) { PGPCompressedData cData = (PGPCompressedData) message; - PGPObjectFactory pgpFact = new PGPObjectFactory(cData.getDataStream(), sFingerprintCalculator); + cDataIn = cData.getDataStream(); + PGPObjectFactory pgpFact = new PGPObjectFactory(cDataIn, sFingerprintCalculator); message = pgpFact.nextObject(); @@ -277,7 +286,20 @@ public void decryptText(byte[] encrypted, boolean verify, if (message instanceof PGPOnePassSignatureList) { if (verify && mSender != null) { ops = ((PGPOnePassSignatureList) message).get(0); - ops.init(new BcPGPContentVerifierBuilderProvider(), PGP.getSigningKey(mSender)); + try { + ops.init(new BcPGPContentVerifierBuilderProvider(), PGP.getSigningKey(mSender)); + } + catch (ClassCastException e) { + try { + // workaround for backward compatibility + ops.init(new BcPGPContentVerifierBuilderProvider(), PGP.getMasterKey(mSender)); + } + catch (ClassCastException e2) { + // peer used new ECC key to sign, but we still have the old RSA one + // no verification is possible + ops = null; + } + } } message = pgpFact.nextObject(); @@ -300,7 +322,7 @@ public void decryptText(byte[] encrypted, boolean verify, } if (verify) { - if (ops == null && errors != null) { + if (ops == null) { errors.add(new DecryptException( DECRYPT_EXCEPTION_VERIFICATION_FAILED, "No signature list found")); @@ -313,14 +335,13 @@ public void decryptText(byte[] encrypted, boolean verify, if (message instanceof PGPSignatureList) { PGPSignature signature = ((PGPSignatureList) message).get(0); if (!ops.verify(signature)) { - if (errors != null) - errors.add(new DecryptException( - DECRYPT_EXCEPTION_VERIFICATION_FAILED, - "Signature verification failed")); + errors.add(new DecryptException( + DECRYPT_EXCEPTION_VERIFICATION_FAILED, + "Signature verification failed")); } } - else if (errors != null) { + else { errors.add(new DecryptException( DECRYPT_EXCEPTION_INVALID_DATA, "Invalid signature packet")); @@ -354,8 +375,7 @@ else if (errors != null) { // parse and check Message/CPIM CPIMMessage msg = CPIMMessage.parse(data); - if (mime != null) - mime.append(msg.getMime()); + mime = msg.getMime(); msgData = msg.getBody(); @@ -372,8 +392,18 @@ else if (errors != null) { } // check that the recipient matches the full uid of the personal key + boolean foundTo = false; String myUid = mKey.getUserId(mServer.getNetwork()); - if (!myUid.equals(msg.getTo()) && errors != null) { + String[] msgTo = msg.getTo(); + if (msgTo != null) { + for (String to : msgTo) { + if (myUid.equals(to)) { + foundTo = true; + break; + } + } + } + if (!foundTo) { errors.add(new DecryptException( DECRYPT_EXCEPTION_INVALID_RECIPIENT, "Destination does not match personal key")); @@ -382,25 +412,36 @@ else if (errors != null) { // check that the sender matches the full uid of the sender's key if (mSender != null) { String otherUid = PGP.getUserId(PGP.getMasterKey(mSender), mServer.getNetwork()); - if (!otherUid.equals(msg.getFrom()) && errors != null) { + if (!otherUid.equals(msg.getFrom())) { errors.add(new DecryptException( DECRYPT_EXCEPTION_INVALID_SENDER, "Sender does not match sender's key")); } } - else if (verify && errors != null) { + else { errors.add(new DecryptException( DECRYPT_EXCEPTION_VERIFICATION_FAILED, "No public key available to verify sender")); } - if (msg.getDate() == null && errors != null) { + timestamp = msg.getDate(); + if (timestamp == null) { errors.add(new DecryptException( DECRYPT_EXCEPTION_INVALID_TIMESTAMP, "Invalid timestamp")); } - // TODO check DateTime (possibly compare it with ) + // check DateTime (plain text only, is left to the caller) + if (TextComponent.MIME_TYPE.equalsIgnoreCase(msg.getMime())) { + long time = msg.getDate().getTime(); + long now = System.currentTimeMillis(); + long diff = Math.abs(now - time); + if (diff > TIMEDIFF_THRESHOLD) { + errors.add(new DecryptException( + DECRYPT_EXCEPTION_INVALID_TIMESTAMP, + "Drifted timestamp")); + } + } } } @@ -408,7 +449,7 @@ else if (verify && errors != null) { // return data as-is msgData = data; - if (verify && errors != null) { + if (verify) { // verification requested: invalid CPIM data errors.add(new DecryptException( DECRYPT_EXCEPTION_INVALID_DATA, pe, @@ -417,13 +458,12 @@ else if (verify && errors != null) { } catch (DecryptException de) { - if (errors != null) - errors.add(de); + errors.add(de); } finally { if (msgData != null) - out.append(msgData); + out = String.valueOf(msgData); } } @@ -454,6 +494,11 @@ else if (verify && errors != null) { throw new DecryptException(DECRYPT_EXCEPTION_INVALID_DATA, pe); } + finally { + DataUtils.close(cDataIn); + } + + return new DecryptOutput(out, mime, timestamp, errors); } @Override @@ -478,7 +523,7 @@ public void encryptFile(InputStream input, OutputStream output) throws GeneralSe // setup signature generator PGPSignatureGenerator sigGen = new PGPSignatureGenerator (new BcPGPContentSignerBuilder(mKey.getSignKeyPair() - .getPublicKey().getAlgorithm(), HashAlgorithmTags.SHA1)); + .getPublicKey().getAlgorithm(), HashAlgorithmTags.SHA256)); sigGen.init(PGPSignature.BINARY_DOCUMENT, mKey.getSignKeyPair().getPrivateKey()); PGPSignatureSubpacketGenerator spGen = new PGPSignatureSubpacketGenerator(); @@ -527,6 +572,8 @@ public void encryptFile(InputStream input, OutputStream output) throws GeneralSe public void decryptFile(InputStream input, boolean verify, OutputStream output, List errors) throws GeneralSecurityException { + + InputStream cDataIn = null; try { PGPObjectFactory pgpF = new PGPObjectFactory(input, sFingerprintCalculator); PGPEncryptedDataList enc; @@ -542,7 +589,7 @@ public void decryptFile(InputStream input, boolean verify, } // check if secret key matches - Iterator it = enc.getEncryptedDataObjects(); + Iterator it = enc.getEncryptedDataObjects(); PGPPrivateKey sKey = null; PGPPublicKeyEncryptedData pbe = null; @@ -550,7 +597,7 @@ public void decryptFile(InputStream input, boolean verify, long ourKeyID = mKey.getEncryptKeyPair().getPrivateKey().getKeyID(); while (sKey == null && it.hasNext()) { - pbe = it.next(); + pbe = (PGPPublicKeyEncryptedData) it.next(); if (pbe.getKeyID() == ourKeyID) sKey = mKey.getEncryptKeyPair().getPrivateKey(); @@ -569,13 +616,14 @@ public void decryptFile(InputStream input, boolean verify, if (message instanceof PGPCompressedData) { PGPCompressedData cData = (PGPCompressedData) message; - PGPObjectFactory pgpFact = new PGPObjectFactory(cData.getDataStream(), sFingerprintCalculator); + cDataIn = cData.getDataStream(); + PGPObjectFactory pgpFact = new PGPObjectFactory(cDataIn, sFingerprintCalculator); message = pgpFact.nextObject(); PGPOnePassSignature ops = null; if (message instanceof PGPOnePassSignatureList) { - if (verify) { + if (verify && mSender != null) { ops = ((PGPOnePassSignatureList) message).get(0); ops.init(new BcPGPContentVerifierBuilderProvider(), PGP.getSigningKey(mSender)); } @@ -675,6 +723,129 @@ public void decryptFile(InputStream input, boolean verify, catch (PGPException pe) { throw new DecryptException(DECRYPT_EXCEPTION_INVALID_DATA, pe); } + + finally { + DataUtils.close(cDataIn); + } + } + + @Override + public VerifyOutput verifyText(byte[] signed, boolean verify) throws GeneralSecurityException { + List errors = new ArrayList<>(); + Date timestamp; + String out; + + try { + PGPObjectFactory plainFact = new PGPObjectFactory(signed, sFingerprintCalculator); + + Object message = plainFact.nextObject(); + + if (message instanceof PGPCompressedData) { + PGPCompressedData cData = (PGPCompressedData) message; + PGPObjectFactory pgpFact = new PGPObjectFactory(cData.getDataStream(), sFingerprintCalculator); + + message = pgpFact.nextObject(); + + PGPOnePassSignature ops = null; + if (message instanceof PGPOnePassSignatureList) { + if (verify && mSender != null) { + ops = ((PGPOnePassSignatureList) message).get(0); + try { + ops.init(new BcPGPContentVerifierBuilderProvider(), PGP.getSigningKey(mSender)); + } + catch (ClassCastException e) { + try { + // workaround for backward compatibility + ops.init(new BcPGPContentVerifierBuilderProvider(), PGP.getMasterKey(mSender)); + } + catch (ClassCastException e2) { + // peer used new ECC key to sign, but we still have the old RSA one + // no verification is possible + ops = null; + } + } + } + + message = pgpFact.nextObject(); + } + + if (message instanceof PGPLiteralData) { + PGPLiteralData ld = (PGPLiteralData) message; + + timestamp = ld.getModificationTime(); + InputStream unc = ld.getInputStream(); + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + + byte[] buf = new byte[4096]; + int num; + + while ((num = unc.read(buf)) >= 0) { + bout.write(buf, 0, num); + + if (ops != null) + ops.update(buf, 0, num); + } + + if (verify) { + if (ops == null) { + errors.add(new VerifyException( + VERIFY_EXCEPTION_VERIFICATION_FAILED, + "No signature list found")); + } + + message = pgpFact.nextObject(); + + if (ops != null) { + + if (message instanceof PGPSignatureList) { + PGPSignature signature = ((PGPSignatureList) message).get(0); + if (!ops.verify(signature)) { + errors.add(new VerifyException( + VERIFY_EXCEPTION_VERIFICATION_FAILED, + "Signature verification failed")); + } + } + + else { + errors.add(new VerifyException( + VERIFY_EXCEPTION_INVALID_DATA, + "Invalid signature packet")); + } + + } + + } + + out = bout.toString(); + } + else { + // invalid or unknown packet + throw new VerifyException( + VERIFY_EXCEPTION_INVALID_DATA, + "Unknown packet type " + message.getClass().getName()); + } + + } + + else { + throw new VerifyException( + VERIFY_EXCEPTION_INVALID_DATA, + "Compressed data packet expected"); + } + + } + + // unrecoverable situations + + catch (IOException ioe) { + throw new VerifyException(VERIFY_EXCEPTION_INVALID_DATA, ioe); + } + + catch (PGPException pe) { + throw new VerifyException(VERIFY_EXCEPTION_INVALID_DATA, pe); + } + + return new VerifyOutput(out, timestamp, errors); } } diff --git a/app/src/main/java/org/kontalk/crypto/PGPKeyFlags.java b/app/src/main/java/org/kontalk/crypto/PGPKeyFlags.java index 47e183b0c..f7551aa67 100644 --- a/app/src/main/java/org/kontalk/crypto/PGPKeyFlags.java +++ b/app/src/main/java/org/kontalk/crypto/PGPKeyFlags.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,7 +23,7 @@ * PGP key flags not supported by Bouncy Castle. * @author Daniele */ -public class PGPKeyFlags implements org.spongycastle.openpgp.PGPKeyFlags { +public class PGPKeyFlags implements org.bouncycastle.openpgp.PGPKeyFlags { public static final int CAN_AUTHENTICATE = 0x20; // This key may be used for authentication. diff --git a/app/src/main/java/org/kontalk/crypto/PGPLazyPublicKeyRingLoader.java b/app/src/main/java/org/kontalk/crypto/PGPLazyPublicKeyRingLoader.java new file mode 100644 index 000000000..2a630ab89 --- /dev/null +++ b/app/src/main/java/org/kontalk/crypto/PGPLazyPublicKeyRingLoader.java @@ -0,0 +1,64 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.crypto; + +import java.io.IOException; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; + + +/** + * PGP public keyring that loads its data only when first requested. + * @author Daniele + */ +public class PGPLazyPublicKeyRingLoader { + + private byte[] mData; + + private PGPPublicKeyRing mKeyRing; + private String mFingerprint; + + public PGPLazyPublicKeyRingLoader(byte[] encoding) { + mData = encoding; + } + + public PGPPublicKeyRing getPublicKeyRing() throws PGPException, IOException { + if (mKeyRing == null) { + mKeyRing = PGP.readPublicKeyring(mData); + // we don't need input data anymore + mData = null; + } + return mKeyRing; + } + + public String getFingerprint() throws PGPException, IOException { + if (mFingerprint == null) { + PGPPublicKeyRing key = getPublicKeyRing(); + if (key != null) { + PGPPublicKey pk = PGP.getMasterKey(key); + if (pk != null) + mFingerprint = PGP.getFingerprint(pk); + } + } + return mFingerprint; + } + +} diff --git a/app/src/main/java/org/kontalk/crypto/PGPUidMismatchException.java b/app/src/main/java/org/kontalk/crypto/PGPUidMismatchException.java new file mode 100644 index 000000000..220b7805f --- /dev/null +++ b/app/src/main/java/org/kontalk/crypto/PGPUidMismatchException.java @@ -0,0 +1,40 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.crypto; + +import org.bouncycastle.openpgp.PGPException; + + +/** + * Exception thrown when a uid in a public key has unexpected values. + * @author Daniele Ricci + */ +public class PGPUidMismatchException extends PGPException { + + private static final long serialVersionUID = -3210114563479741397L; + + public PGPUidMismatchException(String message) { + super(message); + } + + public PGPUidMismatchException(String message, Exception underlying) { + super(message, underlying); + } + +} diff --git a/app/src/main/java/org/kontalk/crypto/PGPUserID.java b/app/src/main/java/org/kontalk/crypto/PGPUserID.java index f23dd7334..0ff09a086 100644 --- a/app/src/main/java/org/kontalk/crypto/PGPUserID.java +++ b/app/src/main/java/org/kontalk/crypto/PGPUserID.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/crypto/PRNGFixes.java b/app/src/main/java/org/kontalk/crypto/PRNGFixes.java index 594b07fc3..ef768a408 100644 --- a/app/src/main/java/org/kontalk/crypto/PRNGFixes.java +++ b/app/src/main/java/org/kontalk/crypto/PRNGFixes.java @@ -16,10 +16,6 @@ package org.kontalk.crypto; -import android.os.Build; -import android.os.Process; -import android.util.Log; - import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; @@ -35,6 +31,11 @@ import java.security.SecureRandomSpi; import java.security.Security; +import android.os.Build; +import android.os.Process; + +import org.kontalk.Log; + /** * Fixes for the output of the default PRNG having low entropy. * @@ -141,35 +142,45 @@ private static void installLinuxPRNGSecureRandom() // default, if not yet installed. Provider[] secureRandomProviders = Security.getProviders("SecureRandom.SHA1PRNG"); - if ((secureRandomProviders == null) + + // Insert and check the provider atomically. + // The official Android Java libraries use synchronized methods for + // insertProviderAt, etc., so synchronizing on the class should + // make things more stable, and prevent race conditions with other + // versions of this code. + synchronized (java.security.Security.class) { + + if ((secureRandomProviders == null) || (secureRandomProviders.length < 1) || (!LinuxPRNGSecureRandomProvider.class.equals( - secureRandomProviders[0].getClass()))) { - Security.insertProviderAt(new LinuxPRNGSecureRandomProvider(), 1); - } + secureRandomProviders[0].getClass()))) { + Security.insertProviderAt(new LinuxPRNGSecureRandomProvider(), 1); + } - // Assert that new SecureRandom() and - // SecureRandom.getInstance("SHA1PRNG") return a SecureRandom backed - // by the Linux PRNG-based SecureRandom implementation. - SecureRandom rng1 = new SecureRandom(); - if (!LinuxPRNGSecureRandomProvider.class.equals( + // Assert that new SecureRandom() and + // SecureRandom.getInstance("SHA1PRNG") return a SecureRandom backed + // by the Linux PRNG-based SecureRandom implementation. + SecureRandom rng1 = new SecureRandom(); + if (!LinuxPRNGSecureRandomProvider.class.equals( rng1.getProvider().getClass())) { - throw new SecurityException( + throw new SecurityException( "new SecureRandom() backed by wrong Provider: " - + rng1.getProvider().getClass()); - } + + rng1.getProvider().getClass()); + } - SecureRandom rng2; - try { - rng2 = SecureRandom.getInstance("SHA1PRNG"); - } catch (NoSuchAlgorithmException e) { - throw new SecurityException("SHA1PRNG not available", e); - } - if (!LinuxPRNGSecureRandomProvider.class.equals( + SecureRandom rng2; + try { + rng2 = SecureRandom.getInstance("SHA1PRNG"); + } + catch (NoSuchAlgorithmException e) { + throw new SecurityException("SHA1PRNG not available", e); + } + if (!LinuxPRNGSecureRandomProvider.class.equals( rng2.getProvider().getClass())) { - throw new SecurityException( + throw new SecurityException( "SecureRandom.getInstance(\"SHA1PRNG\") backed by wrong" - + " Provider: " + rng2.getProvider().getClass()); + + " Provider: " + rng2.getProvider().getClass()); + } } } diff --git a/app/src/main/java/org/kontalk/crypto/PersonalKey.java b/app/src/main/java/org/kontalk/crypto/PersonalKey.java index dbc8cc430..661314cc7 100644 --- a/app/src/main/java/org/kontalk/crypto/PersonalKey.java +++ b/app/src/main/java/org/kontalk/crypto/PersonalKey.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,56 +18,51 @@ package org.kontalk.crypto; -import android.accounts.Account; -import android.accounts.AccountManager; -import android.content.Context; -import android.os.Parcel; -import android.os.Parcelable; -import android.util.Base64; -import android.util.Base64InputStream; -import android.util.Base64OutputStream; -import android.util.Log; - -import org.kontalk.Kontalk; -import org.kontalk.authenticator.Authenticator; -import org.kontalk.crypto.PGP.PGPDecryptedKeyPairRing; -import org.kontalk.crypto.PGP.PGPKeyPairRing; - -import org.spongycastle.openpgp.PGPException; -import org.spongycastle.openpgp.PGPKeyPair; -import org.spongycastle.openpgp.PGPPrivateKey; -import org.spongycastle.openpgp.PGPPublicKey; -import org.spongycastle.openpgp.PGPPublicKeyRing; -import org.spongycastle.openpgp.PGPSecretKey; -import org.spongycastle.openpgp.PGPSecretKeyRing; -import org.spongycastle.openpgp.operator.KeyFingerPrintCalculator; -import org.spongycastle.openpgp.operator.PBESecretKeyDecryptor; -import org.spongycastle.openpgp.operator.PGPDigestCalculatorProvider; -import org.spongycastle.openpgp.operator.bc.BcKeyFingerprintCalculator; -import org.spongycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder; -import org.spongycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; -import org.spongycastle.operator.OperatorCreationException; - import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; import java.security.PrivateKey; import java.security.SignatureException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.Date; import java.util.Iterator; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPKeyPair; +import org.bouncycastle.openpgp.PGPPrivateKey; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSecretKey; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.operator.KeyFingerPrintCalculator; +import org.bouncycastle.openpgp.operator.PBESecretKeyDecryptor; +import org.bouncycastle.openpgp.operator.PGPDigestCalculatorProvider; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; + +import android.os.Parcel; +import android.os.Parcelable; +import android.util.Base64; +import android.util.Base64InputStream; +import android.util.Base64OutputStream; + +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.crypto.PGP.PGPDecryptedKeyPairRing; +import org.kontalk.crypto.PGP.PGPKeyPairRing; + /** Personal asymmetric encryption key. */ public class PersonalKey implements Parcelable { private static final String TAG = Kontalk.TAG; + private static final KeyFingerPrintCalculator sFingerprintCalculator = + PGP.sFingerprintCalculator; + public static final int MIN_PASSPHRASE_LENGTH = 4; /** Decrypted key pair (for direct usage). */ @@ -111,7 +106,7 @@ public PrivateKey getBridgePrivateKey() throws PGPException { } public PGPPublicKeyRing getPublicKeyRing() throws IOException { - return new PGPPublicKeyRing(getEncodedPublicKeyRing(), new BcKeyFingerprintCalculator()); + return new PGPPublicKeyRing(getEncodedPublicKeyRing(), sFingerprintCalculator); } public byte[] getEncodedPublicKeyRing() throws IOException { @@ -131,11 +126,11 @@ public String getFingerprint() { return PGP.getFingerprint(mPair.authKey.getPublicKey()); } - public PGPKeyPairRing storeNetwork(String userId, String network, String name, String passphrase) throws PGPException { + public PGPKeyPairRing storeNetwork(String userId, String network, String name, String passphrase) throws PGPException, IOException { return store(name, userId + '@' + network, null, passphrase); } - public PGPKeyPairRing store(String name, String email, String comment, String passphrase) throws PGPException { + public PGPKeyPairRing store(String name, String email, String comment, String passphrase) throws PGPException, IOException { // name[ (comment)] <[email]> StringBuilder userid = new StringBuilder(name); @@ -157,10 +152,14 @@ public PGPKeyPairRing store(String name, String email, String comment, String pa * @return the public keyring. */ public PGPPublicKeyRing update(byte[] keyData) throws IOException { - PGPPublicKeyRing ring = new PGPPublicKeyRing(keyData, new BcKeyFingerprintCalculator()); - // FIXME should loop through the ring and check for master/subkey - mPair.authKey = new PGPKeyPair(ring.getPublicKey(), mPair.authKey.getPrivateKey()); - return ring; + PGPPublicKeyRing ring = new PGPPublicKeyRing(keyData, sFingerprintCalculator); + PGPPublicKey authKey = PGP.getAuthenticationKey(ring); + PGPPublicKey encKey = PGP.getEncryptionKey(ring); + PGPPublicKey signKey = PGP.getSigningKey(ring); + mPair.authKey = new PGPKeyPair(authKey, mPair.authKey.getPrivateKey()); + mPair.encryptKey = new PGPKeyPair(encKey, mPair.encryptKey.getPrivateKey()); + mPair.signKey = new PGPKeyPair(signKey, mPair.signKey.getPrivateKey()); + return getPublicKeyRing(); } public PersonalKey copy(X509Certificate bridgeCert) { @@ -221,11 +220,10 @@ public static PersonalKey fromBase64(String data) { /** Checks that the given personal key data is correct. */ public static PGPKeyPairRing test(InputStream privateKeyData, InputStream publicKeyData, String passphrase, InputStream bridgeCertData) - throws PGPException, IOException, CertificateException, NoSuchProviderException { + throws PGPException, IOException, CertificateException { - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); - PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, fpr); - PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, fpr); + PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, sFingerprintCalculator); + PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, sFingerprintCalculator); // X.509 bridge certificate X509Certificate bridgeCert = (bridgeCertData != null) ? @@ -236,11 +234,10 @@ public static PGPKeyPairRing test(InputStream privateKeyData, InputStream public /** Checks that the given personal key data is correct. */ public static PGPKeyPairRing test(byte[] privateKeyData, byte[] publicKeyData, String passphrase, byte[] bridgeCertData) - throws PGPException, IOException, CertificateException, NoSuchProviderException { + throws PGPException, IOException, CertificateException { - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); - PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, fpr); - PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, fpr); + PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, sFingerprintCalculator); + PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, sFingerprintCalculator); // X.509 bridge certificate X509Certificate bridgeCert = (bridgeCertData != null) ? @@ -250,7 +247,7 @@ public static PGPKeyPairRing test(byte[] privateKeyData, byte[] publicKeyData, S } private static PGPKeyPairRing test(PGPSecretKeyRing secRing, PGPPublicKeyRing pubRing, String passphrase, X509Certificate bridgeCert) - throws PGPException, IOException, CertificateException, NoSuchProviderException { + throws PGPException { // for now we just do a test load load(secRing, pubRing, passphrase, bridgeCert); @@ -260,11 +257,10 @@ private static PGPKeyPairRing test(PGPSecretKeyRing secRing, PGPPublicKeyRing pu /** Creates a {@link PersonalKey} from private and public key input streams. */ public static PersonalKey load(InputStream privateKeyData, InputStream publicKeyData, String passphrase, InputStream bridgeCertData) - throws PGPException, IOException, CertificateException, NoSuchProviderException { + throws PGPException, IOException, CertificateException { - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); - PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, fpr); - PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, fpr); + PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, sFingerprintCalculator); + PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, sFingerprintCalculator); // X.509 bridge certificate X509Certificate bridgeCert = (bridgeCertData != null) ? @@ -275,11 +271,10 @@ public static PersonalKey load(InputStream privateKeyData, InputStream publicKey /** Creates a {@link PersonalKey} from private and public key byte buffers. */ public static PersonalKey load(byte[] privateKeyData, byte[] publicKeyData, String passphrase, byte[] bridgeCertData) - throws PGPException, IOException, CertificateException, NoSuchProviderException { + throws PGPException, IOException, CertificateException { - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); - PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, fpr); - PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, fpr); + PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, sFingerprintCalculator); + PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, sFingerprintCalculator); // X.509 bridge certificate X509Certificate bridgeCert = (bridgeCertData != null) ? @@ -290,18 +285,16 @@ public static PersonalKey load(byte[] privateKeyData, byte[] publicKeyData, Stri /** Creates a {@link PersonalKey} from private and public key byte buffers. */ public static PersonalKey load(byte[] privateKeyData, byte[] publicKeyData, String passphrase, X509Certificate bridgeCert) - throws PGPException, IOException, CertificateException, NoSuchProviderException { + throws PGPException, IOException { - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); - PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, fpr); - PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, fpr); + PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, sFingerprintCalculator); + PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, sFingerprintCalculator); return load(secRing, pubRing, passphrase, bridgeCert); } - @SuppressWarnings("unchecked") public static PersonalKey load(PGPSecretKeyRing secRing, PGPPublicKeyRing pubRing, String passphrase, X509Certificate bridgeCert) - throws PGPException, IOException, CertificateException, NoSuchProviderException { + throws PGPException { PGPDigestCalculatorProvider sha1Calc = new JcaPGPDigestCalculatorProviderBuilder().build(); PBESecretKeyDecryptor decryptor = new JcePBESecretKeyDecryptorBuilder(sha1Calc) @@ -391,15 +384,18 @@ public static PersonalKey load(PGPSecretKeyRing secRing, PGPPublicKeyRing pubRin throw new PGPException("invalid key data"); } - public static PersonalKey create() throws IOException { + public static PersonalKey withBridgeCert(PersonalKey key, X509Certificate bridgeCert) { + return new PersonalKey(key.getAuthKeyPair(), + key.getSignKeyPair(), key.getEncryptKeyPair(), bridgeCert); + } + + public static PersonalKey create(Date timestamp) throws IOException { try { - PGPDecryptedKeyPairRing kp = PGP.create(); + PGPDecryptedKeyPairRing kp = PGP.create(timestamp); return new PersonalKey(kp, null); } catch (Exception e) { - IOException io = new IOException("unable to generate keypair"); - io.initCause(e); - throw io; + throw new IOException("unable to generate keypair", e); } } @@ -419,30 +415,6 @@ public PGPPublicKey revoke(boolean store) return revoked; } - /** Stores the public keyring to the system {@link AccountManager}. */ - public void updateAccountManager(Context context) - throws IOException, InvalidKeyException, - IllegalStateException, NoSuchAlgorithmException, SignatureException, - CertificateException, NoSuchProviderException, PGPException, OperatorCreationException { - - AccountManager am = (AccountManager) context.getSystemService(Context.ACCOUNT_SERVICE); - Account account = Authenticator.getDefaultAccount(am); - - if (account != null) { - PGPPublicKeyRing pubRing = getPublicKeyRing(); - - // regenerate bridge certificate - byte[] bridgeCertData = X509Bridge.createCertificate(pubRing, - mPair.authKey.getPrivateKey(), null).getEncoded(); - byte[] publicKeyData = pubRing.getEncoded(); - - am.setUserData(account, Authenticator.DATA_PUBLICKEY, - Base64.encodeToString(publicKeyData, Base64.NO_WRAP)); - am.setUserData(account, Authenticator.DATA_BRIDGECERT, - Base64.encodeToString(bridgeCertData, Base64.NO_WRAP)); - } - } - @Override public int describeContents() { return 0; @@ -450,7 +422,6 @@ public int describeContents() { @Override public void writeToParcel(Parcel dest, int flags) { - // TODO write byte arrays try { PGP.toParcel(mPair, dest); } diff --git a/app/src/main/java/org/kontalk/crypto/PersonalKeyExporter.java b/app/src/main/java/org/kontalk/crypto/PersonalKeyExporter.java new file mode 100644 index 000000000..466bf9c20 --- /dev/null +++ b/app/src/main/java/org/kontalk/crypto/PersonalKeyExporter.java @@ -0,0 +1,137 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.crypto; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Map; +import java.util.Properties; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; + +import org.kontalk.provider.Keyring; + + +/** + * Exporter for a personal key. + * @author Daniele Ricci + */ +public class PersonalKeyExporter implements PersonalKeyPack { + + public void save(byte[] privateKey, byte[] publicKey, OutputStream dest, String passphrase, String exportPassphrase, byte[] bridgeCert, + Map trustedKeys, String phoneNumber) + throws PGPException, IOException, CertificateException, KeyStoreException, NoSuchAlgorithmException { + + // put everything in a zip file + ZipOutputStream zip = new ZipOutputStream(dest); + + // custom export passphrase -- re-encrypt private key + if (exportPassphrase != null) { + privateKey = PGP.copySecretKeyRingWithNewPassword(privateKey, + passphrase, exportPassphrase) + .getEncoded(); + } + else { + // use provided passphrase for export + exportPassphrase = passphrase; + } + + OutputStream out; + ByteArrayOutputStream stream; + + if (bridgeCert != null) { + // export bridge certificate + zip.putNextEntry(new ZipEntry(BRIDGE_CERT_FILENAME)); + stream = new ByteArrayOutputStream(); + PemWriter writer = new PemWriter(new OutputStreamWriter(stream)); + writer.writeObject(new PemObject(X509Bridge.PEM_TYPE_CERTIFICATE, bridgeCert)); + writer.close(); + stream.writeTo(zip); + zip.closeEntry(); + + // export bridge private key + zip.putNextEntry(new ZipEntry(BRIDGE_KEY_FILENAME)); + PrivateKey bridgeKey = PGP.convertPrivateKey(privateKey, exportPassphrase); + stream = new ByteArrayOutputStream(); + writer = new PemWriter(new OutputStreamWriter(stream)); + writer.writeObject(new PemObject(X509Bridge.PEM_TYPE_PRIVATE_KEY, bridgeKey.getEncoded())); + writer.close(); + stream.writeTo(zip); + zip.closeEntry(); + + // certificate pack in PKCS#12 + zip.putNextEntry(new ZipEntry(BRIDGE_CERTPACK_FILENAME)); + X509Certificate certificate = X509Bridge.load(bridgeCert); + KeyStore pkcs12 = X509Bridge.exportCertificate(certificate, bridgeKey); + pkcs12.store(zip, exportPassphrase.toCharArray()); + zip.closeEntry(); + } + + // export public key + zip.putNextEntry(new ZipEntry(PUBLIC_KEY_FILENAME)); + stream = new ByteArrayOutputStream(); + out = new ArmoredOutputStream(stream); + out.write(publicKey); + out.close(); + stream.writeTo(zip); + zip.closeEntry(); + + // export private key + zip.putNextEntry(new ZipEntry(PRIVATE_KEY_FILENAME)); + stream = new ByteArrayOutputStream(); + out = new ArmoredOutputStream(stream); + out.write(privateKey); + out.close(); + stream.writeTo(zip); + zip.closeEntry(); + + if (trustedKeys != null) { + // export trusted keys + Properties prop = new Properties(); + for (Map.Entry tk : trustedKeys.entrySet()) + prop.put(tk.getKey(), tk.getValue().toString()); + zip.putNextEntry(new ZipEntry(TRUSTED_KEYS_FILENAME)); + prop.store(zip, null); + zip.closeEntry(); + } + + // export account info + Properties info = new Properties(); + info.setProperty("phoneNumber", phoneNumber); + zip.putNextEntry(new ZipEntry(ACCOUNT_INFO_FILENAME)); + info.store(zip, null); + zip.closeEntry(); + + // finalize the zip file + zip.close(); + } +} diff --git a/app/src/main/java/org/kontalk/crypto/PersonalKeyImporter.java b/app/src/main/java/org/kontalk/crypto/PersonalKeyImporter.java index e7ef16cc7..49c0b1947 100644 --- a/app/src/main/java/org/kontalk/crypto/PersonalKeyImporter.java +++ b/app/src/main/java/org/kontalk/crypto/PersonalKeyImporter.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,47 +18,46 @@ package org.kontalk.crypto; -import android.os.Environment; - -import org.kontalk.crypto.PGP.PGPKeyPairRing; -import org.kontalk.util.MessageUtils; -import org.spongycastle.bcpg.ArmoredInputStream; -import org.spongycastle.openpgp.PGPException; - -import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; +import java.security.SignatureException; import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; +import org.bouncycastle.bcpg.ArmoredInputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.operator.OperatorCreationException; + +import org.kontalk.crypto.PGP.PGPKeyPairRing; +import org.kontalk.provider.Keyring; +import org.kontalk.util.ByteArrayInOutStream; +import org.kontalk.util.MessageUtils; + /** * Importer for a personal key pack. * @author Daniele Ricci */ -public class PersonalKeyImporter { +public class PersonalKeyImporter implements PersonalKeyPack { private static final long MAX_KEY_SIZE = 102400; // 100 KB - public static final String PUBLIC_KEY_FILENAME = "kontalk-public.asc"; - public static final String PRIVATE_KEY_FILENAME = "kontalk-private.asc"; - public static final String BRIDGE_CERT_FILENAME = "kontalk-login.crt"; - public static final String BRIDGE_KEY_FILENAME = "kontalk-login.key"; - public static final String BRIDGE_CERTPACK_FILENAME = "kontalk-login.p12"; - - public static final String KEYPACK_FILENAME = "kontalk-keys.zip"; - - public static final File DEFAULT_KEYPACK = new File(Environment - .getExternalStorageDirectory(), KEYPACK_FILENAME); - private ZipInputStream mKeyPack; private String mPassphrase; - private ByteArrayOutputStream mPublicKey; - private ByteArrayOutputStream mPrivateKey; + private ByteArrayInOutStream mPublicKey; + private ByteArrayInOutStream mPrivateKey; + private ByteArrayInOutStream mTrustedKeys; + private ByteArrayInOutStream mAccountInfo; public PersonalKeyImporter(ZipInputStream keypack, String passphrase) { mKeyPack = keypack; @@ -66,7 +65,8 @@ public PersonalKeyImporter(ZipInputStream keypack, String passphrase) { } public void load() throws IOException { - ByteArrayOutputStream publicKey = null, privateKey = null; + ByteArrayInOutStream publicKey = null, privateKey = null, + trustedKeys = null, accountInfo = null; IOException zipException = null; ZipEntry entry; @@ -85,6 +85,18 @@ else if (PRIVATE_KEY_FILENAME.equals(entry.getName())) { privateKey = MessageUtils.readFully(mKeyPack, MAX_KEY_SIZE); } + // trusted keys + else if (TRUSTED_KEYS_FILENAME.equals(entry.getName())) { + // I don't really know if this is good... + trustedKeys = MessageUtils.readFully(mKeyPack, MAX_KEY_SIZE); + } + + // account info + else if (ACCOUNT_INFO_FILENAME.equals(entry.getName())) { + // I don't really know if this is good... + accountInfo = MessageUtils.readFully(mKeyPack, MAX_KEY_SIZE); + } + } } catch (IOException e) { @@ -94,13 +106,13 @@ else if (PRIVATE_KEY_FILENAME.equals(entry.getName())) { } if (privateKey == null || publicKey == null) { - IOException e = new IOException("invalid data"); - e.initCause(zipException); - throw e; + throw new IOException("invalid data", zipException); } mPrivateKey = privateKey; mPublicKey = publicKey; + mTrustedKeys = trustedKeys; + mAccountInfo = accountInfo; } /** Releases all resources of imported data. */ @@ -110,22 +122,23 @@ public void close() throws IOException { } /** Creates a {@link PersonalKey} out of the imported data, if possible. */ - public PersonalKey createPersonalKey() throws PGPException, NoSuchProviderException, - CertificateException, IOException { - if (mPrivateKey != null && mPublicKey != null) - return PersonalKey.load( - new ArmoredInputStream(new ByteArrayInputStream(mPrivateKey.toByteArray())), - new ArmoredInputStream(new ByteArrayInputStream(mPublicKey.toByteArray())), - mPassphrase, null); + public PersonalKey createPersonalKey(ByteArrayOutputStream privateKeyBuf, ByteArrayOutputStream publicKeyBuf) + throws PGPException, NoSuchProviderException, CertificateException, + IOException, OperatorCreationException, NoSuchAlgorithmException, + InvalidKeyException, SignatureException { + if (mPrivateKey != null && mPublicKey != null) { + return importPersonalKey(mPrivateKey.getInputStream(), + mPublicKey.getInputStream(), mPassphrase, privateKeyBuf, publicKeyBuf); + } return null; } - public PGPKeyPairRing createKeyPairRing() throws PGPException, NoSuchProviderException, + public PGPKeyPairRing createKeyPairRing() throws PGPException, CertificateException, IOException { if (mPrivateKey != null && mPublicKey != null) return PersonalKey.test( - new ArmoredInputStream(new ByteArrayInputStream(mPrivateKey.toByteArray())), - new ArmoredInputStream(new ByteArrayInputStream(mPublicKey.toByteArray())), + new ArmoredInputStream(mPrivateKey.getInputStream()), + new ArmoredInputStream(mPublicKey.getInputStream()), mPassphrase, null); return null; } @@ -138,4 +151,86 @@ public byte[] getPublicKeyData() { return mPublicKey != null ? mPublicKey.toByteArray() : null; } + @SuppressWarnings("unchecked") + public Map getTrustedKeys() throws IOException { + if (mTrustedKeys != null) { + Properties prop = new Properties(); + prop.load(mTrustedKeys.getInputStream()); + + try { + return Keyring.fromTrustedFingerprintMap((Map) prop); + } + catch (Exception ex) { + throw new IOException("invalid trusted keys file", ex); + } + } + + return null; + } + + @SuppressWarnings("unchecked") + public Map getAccountInfo() throws IOException { + if (mAccountInfo != null) { + Properties prop = new Properties(); + prop.load(mAccountInfo.getInputStream()); + return new HashMap<>((Map) prop); + } + + return null; + } + + public static PersonalKey importPersonalKey(byte[] privateKeyData, byte[] publicKeyData, String passphrase, + ByteArrayOutputStream privateKeyBuf, ByteArrayOutputStream publicKeyBuf) + throws PGPException, IOException, CertificateException, NoSuchAlgorithmException, + OperatorCreationException, SignatureException, NoSuchProviderException, InvalidKeyException { + PGP.PGPKeyPairRing ring; + try { + ring = PGP.PGPKeyPairRing.loadArmored(privateKeyData, publicKeyData); + } + catch (IOException e) { + // try not armored + ring = PGP.PGPKeyPairRing.load(privateKeyData, publicKeyData); + } + + // bridge certificate for connection + X509Certificate bridgeCert = X509Bridge.createCertificate(ring.publicKey, + ring.secretKey.getSecretKey(), passphrase); + + PersonalKey key = PersonalKey.load(ring.secretKey, ring.publicKey, + passphrase, bridgeCert); + privateKeyBuf.write(ring.secretKey.getEncoded()); + publicKeyBuf.write(ring.publicKey.getEncoded()); + return key; + } + + public static PersonalKey importPersonalKey(InputStream privateKeyData, InputStream publicKeyData, String passphrase, + ByteArrayOutputStream privateKeyBuf, ByteArrayOutputStream publicKeyBuf) + throws PGPException, IOException, CertificateException, NoSuchAlgorithmException, + OperatorCreationException, SignatureException, NoSuchProviderException, InvalidKeyException { + PGP.PGPKeyPairRing ring; + try { + ring = PGP.PGPKeyPairRing.loadArmored(privateKeyData, publicKeyData); + } + catch (IOException e) { + // try not armored + ring = PGP.PGPKeyPairRing.load(privateKeyData, publicKeyData); + } + + PersonalKey key = importPersonalKey(ring, passphrase); + privateKeyBuf.write(ring.secretKey.getEncoded()); + publicKeyBuf.write(ring.publicKey.getEncoded()); + return key; + } + + private static PersonalKey importPersonalKey(PGP.PGPKeyPairRing ring, String passphrase) + throws CertificateException, NoSuchAlgorithmException, IOException, + NoSuchProviderException, SignatureException, PGPException, InvalidKeyException, OperatorCreationException { + // bridge certificate for connection + X509Certificate bridgeCert = X509Bridge.createCertificate(ring.publicKey, + ring.secretKey.getSecretKey(), passphrase); + + return PersonalKey.load(ring.secretKey, ring.publicKey, + passphrase, bridgeCert); + } + } diff --git a/app/src/main/java/org/kontalk/crypto/PersonalKeyPack.java b/app/src/main/java/org/kontalk/crypto/PersonalKeyPack.java new file mode 100644 index 000000000..e17fca8cb --- /dev/null +++ b/app/src/main/java/org/kontalk/crypto/PersonalKeyPack.java @@ -0,0 +1,42 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.crypto; + +import java.io.File; + +import android.os.Environment; + + +public interface PersonalKeyPack { + + String PUBLIC_KEY_FILENAME = "kontalk-public.asc"; + String PRIVATE_KEY_FILENAME = "kontalk-private.asc"; + String BRIDGE_CERT_FILENAME = "kontalk-login.crt"; + String BRIDGE_KEY_FILENAME = "kontalk-login.key"; + String BRIDGE_CERTPACK_FILENAME = "kontalk-login.p12"; + String TRUSTED_KEYS_FILENAME = "trusted.properties"; + String ACCOUNT_INFO_FILENAME = "account-info.properties"; + + String KEYPACK_FILENAME = "kontalk-keys.zip"; + String KEYPACK_MIME = "application/zip"; + + File DEFAULT_KEYPACK = new File(Environment + .getExternalStorageDirectory(), KEYPACK_FILENAME); + +} diff --git a/app/src/main/java/org/kontalk/crypto/SubjectPGPPublicKeyInfo.java b/app/src/main/java/org/kontalk/crypto/SubjectPGPPublicKeyInfo.java index da0819c16..50e416456 100644 --- a/app/src/main/java/org/kontalk/crypto/SubjectPGPPublicKeyInfo.java +++ b/app/src/main/java/org/kontalk/crypto/SubjectPGPPublicKeyInfo.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,11 +20,11 @@ import java.io.IOException; -import org.spongycastle.asn1.ASN1Encodable; -import org.spongycastle.asn1.ASN1Object; -import org.spongycastle.asn1.ASN1ObjectIdentifier; -import org.spongycastle.asn1.ASN1Primitive; -import org.spongycastle.asn1.DERBitString; +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1Object; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.ASN1Primitive; +import org.bouncycastle.asn1.DERBitString; /** diff --git a/app/src/main/java/org/kontalk/crypto/VerifyException.java b/app/src/main/java/org/kontalk/crypto/VerifyException.java new file mode 100644 index 000000000..754732340 --- /dev/null +++ b/app/src/main/java/org/kontalk/crypto/VerifyException.java @@ -0,0 +1,63 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.crypto; + +import java.security.GeneralSecurityException; + + +/** + * Exception during verification. + * @author Daniele Ricci + */ +public class VerifyException extends GeneralSecurityException { + + private static final long serialVersionUID = -3210114563479741397L; + + /** Signature verification failed. */ + public static final int VERIFY_EXCEPTION_VERIFICATION_FAILED = DecryptException.DECRYPT_EXCEPTION_VERIFICATION_FAILED; + /** Invalid packet data. */ + public static final int VERIFY_EXCEPTION_INVALID_DATA = DecryptException.DECRYPT_EXCEPTION_INVALID_DATA; + + private final int mCode; + + public VerifyException(int code) { + super(); + mCode = code; + } + + public VerifyException(int code, String message) { + super(message); + mCode = code; + } + + public VerifyException(int code, Throwable cause) { + super(cause); + mCode = code; + } + + public VerifyException(int code, Throwable cause, String message) { + super(message, cause); + mCode = code; + } + + public int getCode() { + return mCode; + } + +} diff --git a/app/src/main/java/org/kontalk/crypto/X509Bridge.java b/app/src/main/java/org/kontalk/crypto/X509Bridge.java index 0a1920d7e..e00f0c697 100644 --- a/app/src/main/java/org/kontalk/crypto/X509Bridge.java +++ b/app/src/main/java/org/kontalk/crypto/X509Bridge.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -36,47 +36,50 @@ import java.security.cert.X509Certificate; import java.util.Date; import java.util.Iterator; - -import org.spongycastle.asn1.misc.MiscObjectIdentifiers; -import org.spongycastle.asn1.misc.NetscapeCertType; -import org.spongycastle.asn1.x500.X500Name; -import org.spongycastle.asn1.x500.X500NameBuilder; -import org.spongycastle.asn1.x500.style.BCStyle; -import org.spongycastle.asn1.x509.AlgorithmIdentifier; -import org.spongycastle.asn1.x509.AuthorityKeyIdentifier; -import org.spongycastle.asn1.x509.BasicConstraints; -import org.spongycastle.asn1.x509.Extension; -import org.spongycastle.asn1.x509.GeneralName; -import org.spongycastle.asn1.x509.GeneralNames; -import org.spongycastle.asn1.x509.KeyUsage; -import org.spongycastle.asn1.x509.SubjectKeyIdentifier; -import org.spongycastle.asn1.x509.SubjectPublicKeyInfo; -import org.spongycastle.cert.X509CertificateHolder; -import org.spongycastle.cert.X509v3CertificateBuilder; -import org.spongycastle.cert.jcajce.JcaX509CertificateConverter; -import org.spongycastle.cert.jcajce.JcaX509ExtensionUtils; -import org.spongycastle.crypto.params.AsymmetricKeyParameter; -import org.spongycastle.crypto.util.PrivateKeyFactory; -import org.spongycastle.openpgp.PGPException; -import org.spongycastle.openpgp.PGPPrivateKey; -import org.spongycastle.openpgp.PGPPublicKey; -import org.spongycastle.openpgp.PGPPublicKeyRing; -import org.spongycastle.openpgp.PGPSecretKey; -import org.spongycastle.openpgp.PGPSecretKeyRing; -import org.spongycastle.openpgp.operator.KeyFingerPrintCalculator; -import org.spongycastle.openpgp.operator.PBESecretKeyDecryptor; -import org.spongycastle.openpgp.operator.PGPDigestCalculatorProvider; -import org.spongycastle.openpgp.operator.bc.BcKeyFingerprintCalculator; -import org.spongycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder; -import org.spongycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; -import org.spongycastle.operator.ContentSigner; -import org.spongycastle.operator.DefaultDigestAlgorithmIdentifierFinder; -import org.spongycastle.operator.DefaultSignatureAlgorithmIdentifierFinder; -import org.spongycastle.operator.OperatorCreationException; -import org.spongycastle.operator.bc.BcContentSignerBuilder; -import org.spongycastle.operator.bc.BcDSAContentSignerBuilder; -import org.spongycastle.operator.bc.BcRSAContentSignerBuilder; - +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; + +import org.bouncycastle.asn1.misc.MiscObjectIdentifiers; +import org.bouncycastle.asn1.misc.NetscapeCertType; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.X500NameBuilder; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.asn1.x509.SubjectKeyIdentifier; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.crypto.params.AsymmetricKeyParameter; +import org.bouncycastle.crypto.util.PrivateKeyFactory; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPrivateKey; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSecretKey; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.operator.KeyFingerPrintCalculator; +import org.bouncycastle.openpgp.operator.PBESecretKeyDecryptor; +import org.bouncycastle.openpgp.operator.PGPDigestCalculatorProvider; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.DefaultDigestAlgorithmIdentifierFinder; +import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.bc.BcContentSignerBuilder; +import org.bouncycastle.operator.bc.BcDSAContentSignerBuilder; +import org.bouncycastle.operator.bc.BcRSAContentSignerBuilder; + +import android.os.Build; import android.os.Parcel; @@ -88,6 +91,9 @@ */ public class X509Bridge { + private static final KeyFingerPrintCalculator sFingerprintCalculator = + PGP.sFingerprintCalculator; + public static final String PEM_TYPE_PRIVATE_KEY = "RSA PRIVATE KEY"; public static final String PEM_TYPE_CERTIFICATE = "CERTIFICATE"; @@ -96,19 +102,18 @@ public class X509Bridge { private X509Bridge() { } - public static X509Certificate createCertificate(byte[] publicKeyData, PGPSecretKey secretKey, String passphrase, String subjectAltName) + public static X509Certificate createCertificate(byte[] publicKeyData, PGPSecretKey secretKey, String passphrase) throws PGPException, InvalidKeyException, IllegalStateException, NoSuchAlgorithmException, SignatureException, CertificateException, NoSuchProviderException, IOException, OperatorCreationException { - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); - PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, fpr); + PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, sFingerprintCalculator); - return createCertificate(pubRing, secretKey, passphrase, subjectAltName); + return createCertificate(pubRing, secretKey, passphrase); } - public static X509Certificate createCertificate(PGPPublicKeyRing publicKeyring, PGPSecretKey secretKey, String passphrase, String subjectAltName) + public static X509Certificate createCertificate(PGPPublicKeyRing publicKeyring, PGPSecretKey secretKey, String passphrase) throws PGPException, InvalidKeyException, IllegalStateException, NoSuchAlgorithmException, SignatureException, CertificateException, NoSuchProviderException, IOException, OperatorCreationException { @@ -120,17 +125,16 @@ public static X509Certificate createCertificate(PGPPublicKeyRing publicKeyring, .build(passphrase.toCharArray()); PGPPrivateKey privateKey = secretKey.extractPrivateKey(decryptor); - return createCertificate(publicKeyring, privateKey, subjectAltName); + return createCertificate(publicKeyring, privateKey); } - public static X509Certificate createCertificate(byte[] privateKeyData, byte[] publicKeyData, String passphrase, String subjectAltName) + public static X509Certificate createCertificate(byte[] privateKeyData, byte[] publicKeyData, String passphrase) throws PGPException, IOException, InvalidKeyException, IllegalStateException, NoSuchAlgorithmException, SignatureException, CertificateException, NoSuchProviderException, OperatorCreationException { - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); - PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, fpr); - PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, fpr); + PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, sFingerprintCalculator); + PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, sFingerprintCalculator); PGPDigestCalculatorProvider sha1Calc = new JcaPGPDigestCalculatorProviderBuilder().build(); PBESecretKeyDecryptor decryptor = new JcePBESecretKeyDecryptorBuilder(sha1Calc) @@ -140,20 +144,19 @@ public static X509Certificate createCertificate(byte[] privateKeyData, byte[] pu // secret key PGPSecretKey secKey = secRing.getSecretKey(); - return createCertificate(pubRing, secKey.extractPrivateKey(decryptor), subjectAltName); + return createCertificate(pubRing, secKey.extractPrivateKey(decryptor)); } - public static X509Certificate createCertificate(byte[] publicKeyData, PGPPrivateKey privateKey, String subjectAltName) + public static X509Certificate createCertificate(byte[] publicKeyData, PGPPrivateKey privateKey) throws InvalidKeyException, IllegalStateException, NoSuchAlgorithmException, SignatureException, CertificateException, NoSuchProviderException, PGPException, IOException, OperatorCreationException { - KeyFingerPrintCalculator fpr = new BcKeyFingerprintCalculator(); - PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, fpr); + PGPPublicKeyRing pubRing = new PGPPublicKeyRing(publicKeyData, sFingerprintCalculator); - return createCertificate(pubRing, privateKey, subjectAltName); + return createCertificate(pubRing, privateKey); } - public static X509Certificate createCertificate(PGPPublicKeyRing publicKeyRing, PGPPrivateKey privateKey, String subjectAltName) + static X509Certificate createCertificate(PGPPublicKeyRing publicKeyRing, PGPPrivateKey privateKey) throws InvalidKeyException, IllegalStateException, NoSuchAlgorithmException, SignatureException, CertificateException, NoSuchProviderException, PGPException, IOException, OperatorCreationException { @@ -168,7 +171,6 @@ public static X509Certificate createCertificate(PGPPublicKeyRing publicKeyRing, PGPPublicKey publicKey = null; - @SuppressWarnings("unchecked") Iterator iter = publicKeyRing.getPublicKeys(); while (iter.hasNext()) { PGPPublicKey pk = iter.next(); @@ -181,9 +183,14 @@ public static X509Certificate createCertificate(PGPPublicKeyRing publicKeyRing, if (publicKey == null) throw new IllegalArgumentException("no master key found"); - for (@SuppressWarnings("unchecked") Iterator it = publicKey.getUserIDs(); it.hasNext();) { - Object attrib = it.next(); - x500NameBuilder.addRDN(BCStyle.CN, attrib.toString()); + List xmppAddrs = new LinkedList<>(); + for (Iterator it = publicKey.getUserIDs(); it.hasNext();) { + String attrib = it.next(); + x500NameBuilder.addRDN(BCStyle.CN, attrib); + // extract email for the subjectAltName + PGPUserID uid = PGPUserID.parse(attrib); + if (uid != null && uid.getEmail() != null) + xmppAddrs.add(uid.getEmail()); } X500Name x509name = x500NameBuilder.build(); @@ -208,7 +215,7 @@ public static X509Certificate createCertificate(PGPPublicKeyRing publicKeyRing, PGP.convertPrivateKey(privateKey), x509name, creationTime, validTo, - subjectAltName, + xmppAddrs, publicKeyRing.getEncoded()); } @@ -232,13 +239,13 @@ public static X509Certificate createCertificate(PGPPublicKeyRing publicKeyRing, * @param endDate * date until which the certificate will be valid * (defaults to start date and time if null) - * @param subjectAltName + * @param subjectAltNames * URI to be placed in subjectAltName * @return self-signed certificate */ private static X509Certificate createCertificate(PublicKey pubKey, PrivateKey privKey, X500Name subject, - Date startDate, Date endDate, String subjectAltName, byte[] publicKeyData) + Date startDate, Date endDate, List subjectAltNames, byte[] publicKeyData) throws InvalidKeyException, IllegalStateException, NoSuchAlgorithmException, SignatureException, CertificateException, NoSuchProviderException, IOException, OperatorCreationException { @@ -295,6 +302,7 @@ else if (pubKeyAlgorithm.equals("RSA")) { BigInteger.ONE, startDate, endDate, + Locale.US, subject, /* * Sets the public-key to embed in this certificate. @@ -342,11 +350,14 @@ false, new NetscapeCertType( /* * Adds the subject alternative-name extension. */ - if (subjectAltName != null) { - GeneralNames subjectAltNames = new GeneralNames(new GeneralName( - GeneralName.otherName, subjectAltName)); + if (subjectAltNames != null && subjectAltNames.size() > 0) { + GeneralName[] names = new GeneralName[subjectAltNames.size()]; + for (int i = 0; i < names.length; i++) + names[i] = new GeneralName(GeneralName.otherName, + new XmppAddrIdentifier(subjectAltNames.get(i))); + certBuilder.addExtension(Extension.subjectAlternativeName, - false, subjectAltNames); + false, new GeneralNames(names)); } /* @@ -366,8 +377,15 @@ false, new NetscapeCertType( /* * Checks that this certificate has indeed been correctly signed. */ - X509Certificate cert = new JcaX509CertificateConverter().getCertificate(holder); - cert.verify(pubKey); + X509Certificate cert = new JcaX509CertificateConverter() + .setProvider(PGP.PROVIDER) + .getCertificate(holder); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + cert.verify(pubKey, PGP.PROVIDER); + } + else { + cert.verify(pubKey); + } return cert; } @@ -378,19 +396,19 @@ public static X509Certificate fromParcel(Parcel in) throws PGPException { } public static X509Certificate load(byte[] certData) - throws CertificateException, NoSuchProviderException { + throws CertificateException { return load(new ByteArrayInputStream(certData)); } public static X509Certificate load(InputStream certData) - throws CertificateException, NoSuchProviderException { + throws CertificateException { CertificateFactory certFactory = CertificateFactory.getInstance("X.509", PGP.PROVIDER); return (X509Certificate) certFactory.generateCertificate(certData); } public static KeyStore exportCertificate(X509Certificate certificate, PrivateKey privateKey) - throws KeyStoreException, NoSuchProviderException, NoSuchAlgorithmException, CertificateException, IOException { + throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException { KeyStore store = KeyStore.getInstance("PKCS12", PGP.PROVIDER); diff --git a/app/src/main/java/org/kontalk/crypto/XmppAddrIdentifier.java b/app/src/main/java/org/kontalk/crypto/XmppAddrIdentifier.java new file mode 100644 index 000000000..c6cbaaae5 --- /dev/null +++ b/app/src/main/java/org/kontalk/crypto/XmppAddrIdentifier.java @@ -0,0 +1,42 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.crypto; + +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.DERUTF8String; +import org.bouncycastle.asn1.DLSequence; + + +/** + * xmppAddr X.509 identifier. + * http://xmpp.org/rfcs/rfc6120.html#security-certificates-generation-xmppaddr + */ +public class XmppAddrIdentifier extends DLSequence { + + public static final ASN1ObjectIdentifier OID = new ASN1ObjectIdentifier("1.3.6.1.5.5.7.8.5"); + + public XmppAddrIdentifier(String jid) { + super(new ASN1Encodable[] { + OID, + new DERUTF8String(jid) + }); + } + +} diff --git a/app/src/main/java/org/kontalk/data/Contact.java b/app/src/main/java/org/kontalk/data/Contact.java index f92676327..90c260cb4 100644 --- a/app/src/main/java/org/kontalk/data/Contact.java +++ b/app/src/main/java/org/kontalk/data/Contact.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,29 +20,54 @@ import java.io.IOException; import java.io.InputStream; - +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import com.amulyakhare.textdrawable.TextDrawable; +import com.amulyakhare.textdrawable.util.ColorGenerator; + +import org.bouncycastle.util.Arrays; +import org.jxmpp.jid.impl.JidCreate; +import org.jxmpp.stringprep.XmppStringprepException; import org.jxmpp.util.XmppStringUtils; -import org.spongycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPPublicKeyRing; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; +import android.content.res.Resources; +import android.database.ContentObserver; import android.database.Cursor; +import android.database.MatrixCursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.net.Uri; +import android.os.Handler; import android.provider.ContactsContract; import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.PhoneLookup; -import android.support.v4.util.LruCache; -import android.util.Log; -import org.kontalk.crypto.PGP; +import androidx.annotation.NonNull; +import androidx.collection.LruCache; + +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.crypto.PGPLazyPublicKeyRingLoader; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.MessagesProviderClient; import org.kontalk.provider.MyUsers.Keys; import org.kontalk.provider.MyUsers.Users; +import org.kontalk.util.MediaStorage; +import org.kontalk.util.MessageUtils; +import org.kontalk.util.Permissions; +import org.kontalk.util.Preferences; /** @@ -50,7 +75,7 @@ * @author Daniele Ricci */ public class Contact { - private final static String TAG = Contact.class.getSimpleName(); + final static String TAG = Contact.class.getSimpleName(); private final static String[] ALL_CONTACTS_PROJECTION = { Users._ID, @@ -61,9 +86,8 @@ public class Contact { Users.JID, Users.REGISTERED, Users.STATUS, - Users.FINGERPRINT, Users.BLOCKED, - Keys.TRUSTED_PUBLIC_KEY, + //GROUP -- injected by queryRecentThreads into the cursor }; public static final int COLUMN_ID = 0; @@ -74,9 +98,11 @@ public class Contact { public static final int COLUMN_JID = 5; public static final int COLUMN_REGISTERED = 6; public static final int COLUMN_STATUS = 7; - public static final int COLUMN_FINGERPRINT = 8; - public static final int COLUMN_BLOCKED = 9; - public static final int COLUMN_TRUSTED_PUBLIC_KEY = 10; + public static final int COLUMN_BLOCKED = 8; + /** + * Injected by {@link #queryRecentThreads} into the cursor. + */ + public static final int COLUMN_GROUP = 9; /** The aggregated Contact id identified by this object. */ private final long mContactId; @@ -91,20 +117,46 @@ public class Contact { private String mStatus; private boolean mBlocked; + private boolean mGroup; - private BitmapDrawable mAvatar; + private Drawable mAvatar; private byte [] mAvatarData; private String mFingerprint; - private PGPPublicKeyRing mTrustedKeyRing; + private PGPLazyPublicKeyRingLoader mTrustedKeyRing; + // trust level for the above trusted keyring + private int mTrustedLevel; /** Timestamp the user was last seen. Not coming from the database. */ private long mLastSeen; + /** Cached name information from system contacts. It will override our internal name. */ + private StructuredName mStructuredName; + + private static final class StructuredName { + public final String displayName; + public final String givenName; + public final String middleName; + public final String familyName; + + public StructuredName(String displayName, String givenName, String middleName, String familyName) { + this.displayName = displayName; + this.givenName = givenName; + this.middleName = middleName; + this.familyName = familyName; + } + } + public interface ContactCallback { - public void avatarLoaded(Contact contact, Drawable avatar); + void avatarLoaded(Contact contact, Drawable avatar); } + public interface ContactChangeListener { + void onContactInvalidated(String userId); + } + + private static final Set sListeners = new HashSet<>(); + /** * Contact cache. * @author Daniele Ricci @@ -140,11 +192,15 @@ else if (numberHint != null) { long cid = cur.getLong(2); c = new Contact(cid, lookupKey, name, numberHint, userId, false); + Uri contactUri = c.getUri(); + if (contactUri != null) { + c.loadStructuredNameAsync(context); + } + put(userId, c); // insert result into users database immediately - ContentValues values = new ContentValues(6); - values.put(Users.HASH, XmppStringUtils.parseLocalpart(userId)); + ContentValues values = new ContentValues(5); values.put(Users.NUMBER, numberHint); values.put(Users.DISPLAY_NAME, name); values.put(Users.JID, userId); @@ -162,7 +218,105 @@ else if (numberHint != null) { private final static ContactCache cache = new ContactCache(); - private Contact(long contactId, String lookupKey, String name, String number, String jid, boolean blocked) { + /** Stores volatile and connection-time information about a contact. */ + private static final class ContactState { + private final String mJID; + private boolean mTyping; + /** Version information. Not coming from the database. */ + private String mVersion; + + ContactState(String jid) { + mJID = jid; + } + + public boolean isTyping() { + return mTyping; + } + + public void setTyping(boolean typing) { + mTyping = typing; + } + + public String getVersion() { + return mVersion; + } + + public void setVersion(String version) { + mVersion = version; + } + + @Override + public boolean equals(Object o) { + return o == this || + (o instanceof ContactState && + ((ContactState) o).mJID.equals(mJID)); + } + + @Override + public int hashCode() { + return mJID.hashCode(); + } + } + + // keys is the full JID because typing is not a global but a device state + private static final Map sStates = new HashMap<>(); + private static boolean sInitialized; + private static Handler sHandler; + + public static void init(Context context) { + if (sHandler == null) + throw new IllegalStateException("Call init(Context, Handler) first!"); + + if (!sInitialized && Permissions.canReadContacts(context)) { + context.getContentResolver().registerContentObserver(Contacts.CONTENT_URI, false, + new ContentObserver(sHandler) { + @Override + public void onChange(boolean selfChange) { + invalidate(); + } + } + ); + sInitialized = true; + } + } + + public static void init(Context context, Handler handler) { + sHandler = handler; + init(context); + } + + private static ContactState getContactState(String jid) { + ContactState state = sStates.get(jid); + if (state == null) { + state = new ContactState(jid); + sStates.put(jid, state); + } + return state; + } + + public static void setTyping(String jid, boolean typing) { + getContactState(jid).setTyping(typing); + } + + public static boolean isTyping(String jid) { + ContactState state = sStates.get(jid); + return state != null && state.isTyping(); + } + + public static void setVersion(String jid, String version) { + getContactState(jid).setVersion(version); + } + + public static String getVersion(String jid) { + ContactState state = sStates.get(jid); + return state != null ? state.getVersion() : null; + } + + public static void clearState(String jid) { + sStates.remove(jid); + } + + Contact(long contactId, String lookupKey, String name, String number, String jid, boolean blocked) { mContactId = contactId; mLookupKey = lookupKey; mName = name; @@ -174,10 +328,12 @@ private Contact(long contactId, String lookupKey, String name, String number, St /** Returns the {@link Contacts} {@link Uri} identified by this object. */ public Uri getUri() { if (mContactUri == null) { - if (mLookupKey != null) + if (mLookupKey != null) { mContactUri = ContactsContract.Contacts.getLookupUri(mContactId, mLookupKey); - else + } + else if (mContactId > 0) { mContactUri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, mContactId); + } } return mContactUri; } @@ -191,7 +347,90 @@ public String getNumber() { } public String getName() { - return mName; + return mStructuredName != null && mStructuredName.displayName != null ? + mStructuredName.displayName : mName; + } + + /** Returns a visible and readable name that can be used across the UI. */ + public String getDisplayName() { + String name = getName(); + if (name != null && name.length() > 0) + return name; + if (mNumber != null && mNumber.length() > 0) + return mNumber; + return mJID; + } + + /** + * Return a visible and readable name in a short form (e.g. given name). + * @return the short name (e.g. given name). If not available, return {@link #getDisplayName()}. + */ + public String getShortDisplayName() { + if (mStructuredName != null && mStructuredName.givenName != null) + return mStructuredName.givenName; + return getDisplayName(); + } + + private void loadStructuredNameAsync(final Context context) { + // avoid keeping a local context from an unrelated thread + final Context globalContext = context.getApplicationContext(); + new Thread(new Runnable() { + @Override + public void run() { + mStructuredName = loadStructuredName(globalContext, getUri()); + if (mStructuredName != null && mStructuredName.displayName != null && + !mStructuredName.displayName.equals(mName)) { + + // name changed, update user immediately + mName = mStructuredName.displayName; + + ContentValues values = new ContentValues(1); + values.put(Users.DISPLAY_NAME, mName); + globalContext.getContentResolver().update(Users.CONTENT_URI, + values, Users.JID + "=?", new String[] { mJID }); + + Contact.invalidate(mJID); + } + } + }).start(); + } + + private static StructuredName loadStructuredName(Context context, Uri uri) { + Cursor nameQuery = null; + try { + nameQuery = context.getContentResolver().query(uri.buildUpon() + .appendPath(Contacts.Data.CONTENT_DIRECTORY).build(), new String[] { + ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, + ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, + ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME, + ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, + }, + ContactsContract.Data.MIMETYPE + "=? AND " + Contacts.DISPLAY_NAME_PRIMARY + "=" + + ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, + new String[] { ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE }, + null); + if (nameQuery != null && nameQuery.moveToFirst()) { + String displayName = nameQuery.getString(0); + String givenName = nameQuery.getString(1); + String middleName = nameQuery.getString(2); + String familyName = nameQuery.getString(3); + return new StructuredName(displayName, givenName, middleName, familyName); + } + } + catch (Exception e) { + Log.e("CONTACT", "error loading contact data", e); + } + finally { + if (nameQuery != null) { + try { + nameQuery.close(); + } + catch (Exception ignored) { + } + } + } + + return null; } public String getJID() { @@ -210,14 +449,54 @@ public boolean isBlocked() { return mBlocked; } + public boolean isGroup() { + return mGroup; + } + public PGPPublicKeyRing getTrustedPublicKeyRing() { - return mTrustedKeyRing; + try { + if (mTrustedKeyRing != null) + return mTrustedKeyRing.getPublicKeyRing(); + } + catch (Exception e) { + // ignored for now + Log.w(TAG, "unable to load public keyring", e); + } + return null; + } + + public String getTrustedFingerprint() { + try { + if (mTrustedKeyRing != null) + return mTrustedKeyRing.getFingerprint(); + } + catch (Exception e) { + // ignored for now + Log.w(TAG, "unable to load public keyring", e); + } + return null; + } + + public int getTrustedLevel() { + return mTrustedLevel; } public String getFingerprint() { return mFingerprint; } + /** Returns true if the key is unknown, i.e. no key was trusted yet. */ + public boolean isKeyUnknown() { + return mTrustedKeyRing == null; + } + + /** Returns true if the key has changed and not approved yet. */ + public boolean isKeyChanged() { + String trustedFingerprint = getTrustedFingerprint(); + return (trustedFingerprint == null || mFingerprint == null) || + !mFingerprint.equals(trustedFingerprint); + } + public long getLastSeen() { return mLastSeen; } @@ -226,6 +505,30 @@ public void setLastSeen(long lastSeen) { mLastSeen = lastSeen; } + public boolean isSelf() { + try { + return Kontalk.get().getDefaultAccount().isSelfJID(JidCreate.bareFrom(mJID)); + } + catch (XmppStringprepException e) { + return false; + } + } + + @NonNull + private static Drawable generateRandomAvatar(Context context, Contact contact) { + String letter = (contact.mName != null && contact.mName.length() > 0) ? + contact.mName : contact.mJID; + int size = context.getResources().getDimensionPixelSize(R.dimen.avatar_size); + + return TextDrawable.builder() + .beginConfig() + .width(size) + .height(size) + .endConfig() + .buildRect(letter.substring(0, 1).toUpperCase(Locale.US), + ColorGenerator.MATERIAL.getColor(contact.mJID)); + } + public void getAvatarAsync(final Context context, final ContactCallback callback) { if (mAvatar != null) { callback.avatarLoaded(this, mAvatar); @@ -235,7 +538,7 @@ public void getAvatarAsync(final Context context, final ContactCallback callback new Thread(new Runnable() { public void run() { try { - Drawable avatar = getAvatar(context, null); + Drawable avatar = getAvatar(context); callback.avatarLoaded(Contact.this, avatar); } catch (Exception e) { @@ -247,17 +550,92 @@ public void run() { } } - public synchronized Drawable getAvatar(Context context, Drawable defaultValue) { + public synchronized Drawable getAvatar(Context context) { if (mAvatar == null) { - if (mAvatarData == null) - mAvatarData = loadAvatarData(context, getUri()); - - if (mAvatarData != null) { - Bitmap b = BitmapFactory.decodeByteArray(mAvatarData, 0, mAvatarData.length); + Bitmap b = loadAvatarBitmap(context); + if (b != null) { mAvatar = new BitmapDrawable(context.getResources(), b); } } - return mAvatar != null ? mAvatar : defaultValue; + + if (mAvatar == null) + mAvatar = generateRandomAvatar(context, this); + + return mAvatar; + } + + private synchronized Bitmap loadAvatarBitmap(Context context) { + if (mAvatarData == null) { + Uri uri = getUri(); + if (uri != null) + mAvatarData = loadAvatarData(context, uri); + } + + if (mAvatarData != null) + return BitmapFactory.decodeByteArray(mAvatarData, 0, mAvatarData.length); + + return null; + } + + /** + * Public version of {@link #loadAvatarBitmap} which includes the random + * avatar generation. + * @param context a context + * @param resizeForNotification true for resizing the avatar to the large icon size defined by system + * @return a newly-allocated {@link Bitmap} + */ + @NonNull + public synchronized Bitmap getAvatarBitmap(Context context, boolean resizeForNotification) { + Bitmap avatar = loadAvatarBitmap(context); + if (avatar == null) { + Drawable d = generateRandomAvatar(context, this); + avatar = MessageUtils.drawableToBitmap(d); + } + + if (resizeForNotification) { + // Contact bitmaps are 96x96 so we have to scale 'em up to 128x128 to fill the whole notification large icon. + // inspired by the AOSP Mms app + final Resources res = context.getResources(); + final int idealIconHeight = + res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height); + final int idealIconWidth = + res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width); + if (avatar.getHeight() != idealIconHeight || avatar.getWidth() != idealIconWidth) { + // Scale this image to fit the intended size + Bitmap scaledAvatar = Bitmap.createScaledBitmap( + avatar, idealIconWidth, idealIconHeight, true); + if (scaledAvatar != null) { + if (scaledAvatar != avatar) + avatar.recycle(); + avatar = scaledAvatar; + } + } + } + + if (isRoundedAvatars()) { + Bitmap circleAvatar = MediaStorage.createRoundBitmap(avatar); + if (circleAvatar != avatar) + avatar.recycle(); + avatar = circleAvatar; + } + + return avatar; + } + + /** Roughly the Android version when the rounded avatar concept was introduced. */ + public static boolean isRoundedAvatars() { + return android.os.Build.VERSION.SDK_INT >= + android.os.Build.VERSION_CODES.LOLLIPOP; + } + + /** + * Public version of {@link #loadAvatarBitmap} which includes the random + * avatar generation. + * @return a newly-allocated {@link Bitmap} + */ + @NonNull + public synchronized Bitmap getAvatarBitmap(Context context) { + return getAvatarBitmap(context, false); } private void clear() { @@ -266,10 +644,12 @@ private void clear() { public static void invalidate(String userId) { cache.remove(userId); + fireContactInvalidated(userId); } public static void invalidate() { cache.evictAll(); + fireContactInvalidated(null); } /** Invalidates cached data for all contacts. Does not delete contact information. */ @@ -279,6 +659,31 @@ public static void invalidateData() { c.clear(); } } + // invalidate contact state + sStates.clear(); + } + + /** Invalidates cached data for the given contact. Does not delete contact information. */ + public static void invalidateData(String userId) { + Contact c = cache.get(XmppStringUtils.parseBareJid(userId)); + if (c != null) + c.clear(); + // invalidate contact state + clearState(userId); + } + + public static void registerContactChangeListener(ContactChangeListener l) { + sListeners.add(l); + } + + public static void unregisterContactChangeListener(ContactChangeListener l) { + sListeners.remove(l); + } + + private static void fireContactInvalidated(String userId) { + for (ContactChangeListener l : sListeners) { + l.onContactInvalidated(userId); + } } /** Builds a contact from a UsersProvider cursor. */ @@ -294,23 +699,27 @@ public static Contact fromUsersCursor(Context context, Cursor cursor) { final String number = cursor.getString(COLUMN_NUMBER); final boolean registered = (cursor.getInt(COLUMN_REGISTERED) != 0); final String status = cursor.getString(COLUMN_STATUS); - final String fingerprint = cursor.getString(COLUMN_FINGERPRINT); final boolean blocked = (cursor.getInt(COLUMN_BLOCKED) != 0); - final byte[] trustedKeyring = cursor.getBlob(COLUMN_TRUSTED_PUBLIC_KEY); + boolean group = false; + try { + group = (cursor.getInt(COLUMN_GROUP) != 0); + } + catch (Exception ignored) { + } + c = new Contact(contactId, key, name, number, jid, blocked); c.mRegistered = registered; c.mStatus = status; - c.mFingerprint = fingerprint; - try { - if (trustedKeyring != null) - c.mTrustedKeyRing = PGP.readPublicKeyring(trustedKeyring); - } - catch (Exception e) { - // ignored for now - Log.w(TAG, "unable to load public keyring", e); + c.mGroup = group; + + Uri uri = c.getUri(); + if (uri != null) { + c.loadStructuredNameAsync(context); } + retrieveKeyInfo(context, c); + cache.put(jid, c); } return c; @@ -335,15 +744,37 @@ public static String numberByUserId(Context context, String userId) { return null; } - public static Contact findByUserId(Context context, String userId) { + public static Contact findByUserId(Context context, @NonNull String userId) { return findByUserId(context, userId, null); } - public static Contact findByUserId(Context context, String userId, String numberHint) { - return cache.get(context, userId, numberHint); + @NonNull + public static Contact findByUserId(Context context, @NonNull String userId, String numberHint) { + String normalizedUserId = XmppStringUtils.parseBareJid(userId); + Contact c = cache.get(context, normalizedUserId, numberHint); + // build dummy contact if not found + if (c == null) { + c = new Contact(-1, null, normalizedUserId, numberHint, userId, false); + // try to retrieve the key from the keyring + // We may find one for pending subscription users which have + // disappeared from the users table after a resync + retrieveKeyInfo(context, c); + } + return c; + } + + private static void retrieveKeyInfo(Context context, Contact c) { + // trusted key + Keyring.TrustedPublicKeyData trustedKeyring = Keyring.getPublicKeyData(context, c.getJID(), Keys.TRUST_IGNORED); + // latest (possibly unknown) fingerprint + c.mFingerprint = Keyring.getFingerprint(context, c.getJID(), Keys.TRUST_UNKNOWN); + if (trustedKeyring != null) { + c.mTrustedKeyRing = new PGPLazyPublicKeyRingLoader(trustedKeyring.keyData); + c.mTrustedLevel = trustedKeyring.trustLevel; + } } - private static Contact _findByUserId(Context context, String userId) { + static Contact _findByUserId(Context context, String userId) { ContentResolver cres = context.getContentResolver(); Cursor c = cres.query(Uri.withAppendedPath(Users.CONTENT_URI, userId), new String[] { @@ -353,9 +784,7 @@ private static Contact _findByUserId(Context context, String userId) { Users.CONTACT_ID, Users.REGISTERED, Users.STATUS, - Users.FINGERPRINT, Users.BLOCKED, - Keys.TRUSTED_PUBLIC_KEY, }, null, null, null); if (c.moveToFirst()) { @@ -365,24 +794,20 @@ private static Contact _findByUserId(Context context, String userId) { final long cid = c.getLong(3); final boolean registered = (c.getInt(4) != 0); final String status = c.getString(5); - final String fingerprint = c.getString(6); - final boolean blocked = (c.getInt(7) != 0); - final byte[] trustedKeyring = c.getBlob(8); + final boolean blocked = (c.getInt(6) != 0); c.close(); Contact contact = new Contact(cid, key, name, number, userId, blocked); contact.mRegistered = registered; contact.mStatus = status; - contact.mFingerprint = fingerprint; - try { - if (trustedKeyring != null) - contact.mTrustedKeyRing = PGP.readPublicKeyring(trustedKeyring); - } - catch (Exception e) { - // ignored for now - Log.w(TAG, "unable to load public keyring", e); + + Uri uri = contact.getUri(); + if (uri != null) { + contact.loadStructuredNameAsync(context); } + retrieveKeyInfo(context, contact); + return contact; } c.close(); @@ -392,21 +817,30 @@ private static Contact _findByUserId(Context context, String userId) { private static byte[] loadAvatarData(Context context, Uri contactUri) { byte[] data = null; - Uri uri; + InputStream avatarDataStream; try { - long cid = ContentUris.parseId(contactUri); - uri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, cid); + avatarDataStream = Contacts.openContactPhotoInputStream( + context.getContentResolver(), contactUri); } catch (Exception e) { - uri = contactUri; + // fallback to old behaviour + try { + long cid = ContentUris.parseId(contactUri); + Uri uri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, cid); + avatarDataStream = Contacts.openContactPhotoInputStream( + context.getContentResolver(), uri); + } + catch (Exception ignored) { + // no way of getting avatar, sorry + return null; + } + } - InputStream avatarDataStream = Contacts.openContactPhotoInputStream( - context.getContentResolver(), uri); if (avatarDataStream != null) { try { - data = new byte[avatarDataStream.available()]; - avatarDataStream.read(data, 0, data.length); + data = new byte[avatarDataStream.available()]; + avatarDataStream.read(data, 0, data.length); } catch (IOException e) { Log.e(TAG, "cannot retrieve contact avatar", e); @@ -415,7 +849,8 @@ private static byte[] loadAvatarData(Context context, Uri contactUri) { try { avatarDataStream.close(); } - catch (IOException e) {} + catch (IOException ignored) { + } } } @@ -423,8 +858,75 @@ private static byte[] loadAvatarData(Context context, Uri contactUri) { } public static Cursor queryContacts(Context context) { - return context.getContentResolver().query(Users.CONTENT_URI, ALL_CONTACTS_PROJECTION, - Users.REGISTERED + " <> 0", null, Users.DISPLAY_NAME); + String selection = Users.REGISTERED + " <> 0"; + if (!Preferences.getShowBlockedUsers(context)) { + selection += " AND " + Users.BLOCKED + " = 0"; + } + + return context.getContentResolver().query(Users.CONTENT_URI.buildUpon() + .appendQueryParameter(Users.EXTRA_INDEX, "true").build(), + ALL_CONTACTS_PROJECTION, + selection, null, + Users.DISPLAY_NAME + " COLLATE NOCASE," + Users.NUMBER + " COLLATE NOCASE"); + } + + /** + * Query recent threads from {@link org.kontalk.provider.MessagesProvider}. + * Although this class should handle only the users provider, this cursor + * will have a {@link org.kontalk.provider.UsersProvider} compatible cursor. + */ + public static Cursor queryRecentThreads(Context context, int limit) { + MatrixCursor recents = new MatrixCursor(Arrays + .append(ALL_CONTACTS_PROJECTION, "group")); + + Cursor cursor = MessagesProviderClient.getLatestThreads(context, true, limit); + try { + while (cursor.moveToNext()) { + String userId = cursor.getString(MessagesProviderClient.LATEST_THREADS_COLUMN_PEER); + String groupJid = cursor.getString(MessagesProviderClient.LATEST_THREADS_COLUMN_GROUP_JID); + if (groupJid != null) { + // group chat + recents.addRow(new Object[] { + 0, + 0, + null, + cursor.getString(MessagesProviderClient.LATEST_THREADS_COLUMN_GROUP_SUBJECT), + null, + groupJid, + 1, + null, + 0, + 1 // group + }); + } + else { + // private chat + // Unfortunately we have to do one more query because + // user information is in another database + Contact contact = Contact.findByUserId(context, userId); + + recents.addRow(new Object[] { + contact.getId(), + contact.mContactId, + contact.mLookupKey, + contact.getDisplayName(), + contact.getNumber(), + contact.getJID(), + contact.isRegistered() ? 1 : 0, + contact.getStatus(), + contact.isBlocked() ? 1: 0, + 0 // group + }); + } + } + } + finally { + if (cursor != null) { + cursor.close(); + } + } + + return recents; } } diff --git a/app/src/main/java/org/kontalk/data/Conversation.java b/app/src/main/java/org/kontalk/data/Conversation.java index bacdbd707..4eccc9a15 100644 --- a/app/src/main/java/org/kontalk/data/Conversation.java +++ b/app/src/main/java/org/kontalk/data/Conversation.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,24 +18,31 @@ package org.kontalk.data; -import org.kontalk.provider.MessagesProvider; -import org.kontalk.provider.MyMessages.Threads; -import org.kontalk.ui.MessagingNotification; - import android.content.AsyncQueryHandler; import android.content.ContentUris; import android.content.Context; import android.database.Cursor; +import android.net.Uri; + +import org.kontalk.provider.KontalkGroupCommands; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.provider.MyMessages.Groups; +import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.provider.MyMessages.Threads; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.service.msgcenter.event.SendMessageRequest; +import org.kontalk.service.msgcenter.group.GroupControllerFactory; +import org.kontalk.ui.MessagingNotification; +import org.kontalk.util.MessageUtils; /** * A class represeting a conversation thread. * @author Daniele Ricci - * @version 1.0 */ public class Conversation { - private static final String[] ALL_THREADS_PROJECTION = { + public static final String[] PROJECTION = { Threads._ID, Threads.PEER, Threads.COUNT, @@ -47,25 +54,40 @@ public class Conversation { Threads.ENCRYPTED, Threads.DRAFT, Threads.REQUEST_STATUS, + Threads.STICKY, + Threads.ENCRYPTION, + Groups.GROUP_JID, + Groups.SUBJECT, + Groups.GROUP_TYPE, + Groups.MEMBERSHIP, }; - private final int COLUMN_ID = 0; - private final int COLUMN_PEER = 1; - private final int COLUMN_COUNT = 2; - private final int COLUMN_UNREAD = 3; - private final int COLUMN_MIME = 4; - private final int COLUMN_CONTENT = 5; - private final int COLUMN_TIMESTAMP = 6; - private final int COLUMN_STATUS = 7; - private final int COLUMN_ENCRYPTED = 8; - private final int COLUMN_DRAFT = 9; - private final int COLUMN_REQUEST_STATUS = 10; - - private final Context mContext; - - private long mThreadId; + private static final int COLUMN_ID = 0; + private static final int COLUMN_PEER = 1; + private static final int COLUMN_COUNT = 2; + private static final int COLUMN_UNREAD = 3; + private static final int COLUMN_MIME = 4; + private static final int COLUMN_CONTENT = 5; + private static final int COLUMN_TIMESTAMP = 6; + private static final int COLUMN_STATUS = 7; + private static final int COLUMN_ENCRYPTED = 8; + private static final int COLUMN_DRAFT = 9; + private static final int COLUMN_REQUEST_STATUS = 10; + private static final int COLUMN_STICKY = 11; + private static final int COLUMN_ENCRYPTION = 12; + private static final int COLUMN_GROUP_JID = 13; + private static final int COLUMN_GROUP_SUBJECT = 14; + private static final int COLUMN_GROUP_TYPE = 15; + private static final int COLUMN_GROUP_MEMBERSHIP = 16; + + @SuppressWarnings("WeakerAccess") + final Context mContext; + + @SuppressWarnings("WeakerAccess") + long mThreadId; private Contact mContact; + // for group chats it will be the group JID private String mRecipient; private long mDate; private int mMessageCount; @@ -77,10 +99,21 @@ public class Conversation { private String mNumberHint; private boolean mEncrypted; private int mRequestStatus; + private boolean mSticky; + // set encryption disabled for this chat + private boolean mEncryption; + + // from groups table + private String mGroupJid; + private String[] mGroupPeers; + private String mGroupSubject; + private String mGroupType; + private int mGroupMembership; private Conversation(Context context) { mContext = context; mThreadId = 0; + mEncryption = true; } private Conversation(Context context, Cursor c) { @@ -99,11 +132,25 @@ private Conversation(Context context, Cursor c) { mEncrypted = c.getInt(COLUMN_ENCRYPTED) != 0; mDraft = c.getString(COLUMN_DRAFT); mRequestStatus = c.getInt(COLUMN_REQUEST_STATUS); + mSticky = c.getInt(COLUMN_STICKY) != 0; + mEncryption = c.getInt(COLUMN_ENCRYPTION) != 0; + + mGroupJid = c.getString(COLUMN_GROUP_JID); + mGroupSubject = c.getString(COLUMN_GROUP_SUBJECT); + mGroupType = c.getString(COLUMN_GROUP_TYPE); + mGroupMembership = c.getInt(COLUMN_GROUP_MEMBERSHIP); + // group peers are loaded on demand loadContact(); } } + /** Used as an archived chats count placeholder. */ + Conversation(int count) { + mContext = null; + mMessageCount = count; + } + public static Conversation createNew(Context context) { return new Conversation(context); } @@ -115,7 +162,7 @@ public static Conversation createFromCursor(Context context, Cursor cursor) { public static Conversation loadFromUserId(Context context, String userId) { Conversation cv = null; Cursor cp = context.getContentResolver().query(Threads.CONTENT_URI, - ALL_THREADS_PROJECTION, Threads.PEER + " = ?", new String[] { userId }, null); + PROJECTION, Threads.PEER + " = ?", new String[] { userId }, null); if (cp.moveToFirst()) cv = createFromCursor(context, cp); @@ -127,7 +174,7 @@ public static Conversation loadFromId(Context context, long id) { Conversation cv = null; Cursor cp = context.getContentResolver().query( ContentUris.withAppendedId(Threads.CONTENT_URI, id), - ALL_THREADS_PROJECTION, null, null, null); + PROJECTION, null, null, null); if (cp.moveToFirst()) cv = createFromCursor(context, cp); @@ -135,8 +182,40 @@ public static Conversation loadFromId(Context context, long id) { return cv; } + public static long getMessageId(Cursor cursor) { + return cursor.getLong(COLUMN_ID); + } + + public static void deleteFromCursor(Context context, Cursor cursor, boolean leaveGroup) { + String groupJid = cursor.getString(COLUMN_GROUP_JID); + String[] groupPeers = null; + String groupType = null; + if (groupJid != null) { + groupType = cursor.getString(COLUMN_GROUP_TYPE); + groupPeers = loadGroupPeersInternal(context, groupJid); + } + boolean encrypted = MessageUtils.sendEncrypted(context, cursor.getInt(COLUMN_ENCRYPTED) != 0); + deleteInternal(context, cursor.getLong(COLUMN_ID), groupJid, groupPeers, groupType, leaveGroup, encrypted); + } + + public static void deleteAll(Context context, boolean leaveGroups, boolean archived) { + Cursor c = context.getContentResolver().query(Threads.CONTENT_URI, + PROJECTION, Threads.ARCHIVED + " = " + (archived ? "1" : "0"), null, null); + while (c.moveToNext()) { + deleteFromCursor(context, c, leaveGroups); + } + c.close(); + } + private void loadContact() { - mContact = Contact.findByUserId(mContext, mRecipient, mNumberHint); + if (isGroupChat()) + mContact = null; + else + mContact = Contact.findByUserId(mContext, mRecipient, mNumberHint); + } + + public boolean isCountOnly() { + return mContext == null; } public Contact getContact() { @@ -147,8 +226,8 @@ public long getDate() { return mDate; } - public void setDate(long mDate) { - this.mDate = mDate; + public void setDate(long date) { + this.mDate = date; } public String getMime() { @@ -159,16 +238,12 @@ public String getSubject() { return mSubject; } - public void setSubject(String mSubject) { - this.mSubject = mSubject; - } - public String getRecipient() { return mRecipient; } - public void setRecipient(String mRecipient) { - this.mRecipient = mRecipient; + public void setRecipient(String recipient) { + mRecipient = recipient; // reload contact loadContact(); } @@ -201,10 +276,18 @@ public int getRequestStatus() { return mRequestStatus; } + public boolean isSticky() { + return mSticky; + } + public String getNumberHint() { return mNumberHint; } + public boolean isEncryptionEnabled() { + return mEncryption; + } + /** * Sets a phone number hint that will be used if there is no match in the * users database. @@ -213,28 +296,235 @@ public void setNumberHint(String numberHint) { mNumberHint = numberHint; } + public String getGroupJid() { + return mGroupJid; + } + + public String[] getGroupPeers() { + return getGroupPeers(false); + } + + public String[] getGroupPeers(boolean force) { + loadGroupPeers(force); + return mGroupPeers; + } + + /** + * Special version of {@link #getGroupPeers} that returns members with any flags. + * An ugly hack for the message center. + */ + public String[] getGroupPeersForSending() { + return MessagesProviderClient.getGroupMembers(mContext, mGroupJid, -1); + } + + public boolean isGroupChat() { + loadGroupPeers(false); + return mGroupJid != null; + } + + public String getGroupSubject() { + return mGroupSubject; + } + + public int getGroupMembership() { + return mGroupMembership; + } + + public void leaveGroup() { + // it makes sense to leave a group if we have someone to tell about it + loadGroupPeers(false); + if (mGroupJid != null) { + boolean encrypted = MessageUtils.sendEncrypted(mContext, mEncryption); + + // send the command if there is someone to talk to + boolean actuallySend = GroupControllerFactory.canSendCommandsWithEmptyGroup(mGroupType) || + getGroupPeers().length > 0; + + String msgId = MessageCenterService.messageId(); + Uri cmdMsg = KontalkGroupCommands + .leaveGroup(mContext, mThreadId, mGroupJid, msgId, encrypted, !actuallySend); + // TODO check for null + + // mark group as parted + MessagesProviderClient.setGroupMembership(mContext, mGroupJid, Groups.MEMBERSHIP_PARTED); + + if (actuallySend) { + MessageCenterService.bus() + .post(new SendMessageRequest(ContentUris.parseId(cmdMsg))); + } + } + } + + public void delete(boolean leaveGroup) { + loadGroupPeers(false); + deleteInternal(mContext, mThreadId, mGroupJid, mGroupPeers, mGroupType, leaveGroup, mEncryption); + } + + private static void deleteInternal(Context context, long threadId, String groupJid, + String[] groupPeers, String groupType, boolean leaveGroup, boolean encrypted) { + // it makes sense to leave a group if we have someone to tell about it + boolean groupChat = groupJid != null; + boolean actuallySend = groupChat && + (GroupControllerFactory.canSendCommandsWithEmptyGroup(groupType) || groupPeers.length > 0); + + boolean groupCreateSent = false; + if (groupChat && actuallySend && leaveGroup) { + // retrieve status of the group creation message + // otherwise don't send the leave message at all + groupCreateSent = KontalkGroupCommands.isGroupCreatedSent(context, threadId); + } + + // delete messages and thread + MessagesProviderClient.deleteThread(context, threadId, groupChat && !leaveGroup); + + // send leave message only if the group was created in the first place + if (groupChat && leaveGroup) { + if (groupCreateSent) { + String msgId = MessageCenterService.messageId(); + Uri cmdMsg = KontalkGroupCommands + .leaveGroup(context, Messages.NO_THREAD, groupJid, msgId, encrypted, false); + // TODO check for null + + MessageCenterService.bus() + .post(new SendMessageRequest(ContentUris.parseId(cmdMsg))); + } + else { + // delete group immediately (members will cascade) + context.getContentResolver() + .delete(Groups.getUri(groupJid), null, null); + } + } + } + + public void archive() { + if (mThreadId > 0) + MessagesProviderClient.setArchived(mContext, mThreadId, true); + } + + public void unarchive() { + if (mThreadId > 0) + MessagesProviderClient.setArchived(mContext, mThreadId, false); + } + + public void setSticky(boolean sticky) { + mSticky = sticky; + if (mThreadId > 0) + MessagesProviderClient.setThreadSticky(mContext, mThreadId, sticky); + } + + private void loadGroupPeers(boolean force) { + if (mGroupJid != null && (mGroupPeers == null || force)) { + mGroupPeers = loadGroupPeersInternal(mContext, mGroupJid); + } + } + + private static String[] loadGroupPeersInternal(Context context, String groupJid) { + return MessagesProviderClient.getGroupMembers(context, groupJid, 0); + } + + @Deprecated public static void startQuery(AsyncQueryHandler handler, int token) { // cancel previous operations handler.cancelOperation(token); handler.startQuery(token, null, Threads.CONTENT_URI, - ALL_THREADS_PROJECTION, null, null, Threads.DEFAULT_SORT_ORDER); + PROJECTION, null, null, Threads.DEFAULT_SORT_ORDER); } public static void startQuery(AsyncQueryHandler handler, int token, long threadId) { // cancel previous operations handler.cancelOperation(token); handler.startQuery(token, null, Threads.CONTENT_URI, - ALL_THREADS_PROJECTION, Threads._ID + " = " + threadId, null, Threads.DEFAULT_SORT_ORDER); + PROJECTION, Threads._ID + " = " + threadId, null, null); } - public static Cursor startQuery(Context context) { + @Deprecated + public static Cursor startQuery(Context context, boolean archived) { return context.getContentResolver().query(Threads.CONTENT_URI, - ALL_THREADS_PROJECTION, null, null, Threads.DEFAULT_SORT_ORDER); + PROJECTION, Threads.ARCHIVED + " = " + (archived ? "1" : "0"), + null, Threads.DEFAULT_SORT_ORDER); } + @Deprecated public static Cursor startQuery(Context context, long threadId) { return context.getContentResolver().query(Threads.CONTENT_URI, - ALL_THREADS_PROJECTION, Threads._ID + " = " + threadId, null, Threads.DEFAULT_SORT_ORDER); + PROJECTION, Threads._ID + " = " + threadId, null, null); + } + + /** + * Creates a new group chat. + * @return a newly created thread ID. + */ + public static long initGroupChat(Context context, String groupJid, String subject, String[] members, String draft) { + return MessagesProviderClient.createGroupThread(context, groupJid, subject, members, draft); + } + + public void addUsers(String[] members) { + if (!isGroupChat()) + throw new UnsupportedOperationException("Not a group chat conversation"); + + // add members to the group + MessagesProviderClient.addGroupMembers(mContext, mGroupJid, members, true); + + // store add group member command to outbox + boolean encrypted = MessageUtils.sendEncrypted(mContext, mEncryption); + String msgId = MessageCenterService.messageId(); + Uri cmdMsg = KontalkGroupCommands + .addGroupMembers(mContext, getThreadId(), mGroupJid, members, msgId, encrypted); + // TODO check for null + + // send add group member command now + MessageCenterService.bus() + .post(new SendMessageRequest(ContentUris.parseId(cmdMsg))); + } + + public void removeUsers(String[] members) { + if (!isGroupChat()) + throw new UnsupportedOperationException("Not a group chat conversation"); + + // remove members to the group + MessagesProviderClient.removeGroupMembers(mContext, mGroupJid, members, true); + + // store remove group member command to outbox + boolean encrypted = MessageUtils.sendEncrypted(mContext, mEncryption); + String msgId = MessageCenterService.messageId(); + Uri cmdMsg = KontalkGroupCommands + .removeGroupMembers(mContext, getThreadId(), mGroupJid, members, msgId, encrypted); + // TODO check for null + + // send remove group member command now + MessageCenterService.bus() + .post(new SendMessageRequest(ContentUris.parseId(cmdMsg))); + } + + public void setGroupSubject(String subject) { + if (!isGroupChat()) + throw new UnsupportedOperationException("Not a group chat conversation"); + + // set group subject + MessagesProviderClient.setGroupSubject(mContext, mGroupJid, subject); + + // send the command if there is someone to talk to + boolean actuallySend = GroupControllerFactory.canSendCommandsWithEmptyGroup(mGroupType) || + getGroupPeers().length > 0; + + // store set group subject command to outbox + boolean encrypted = MessageUtils.sendEncrypted(mContext, mEncryption); + String msgId = MessageCenterService.messageId(); + Uri cmdMsg = KontalkGroupCommands + .setGroupSubject(mContext, getThreadId(), mGroupJid, subject, msgId, encrypted, !actuallySend); + // TODO check for null + + if (actuallySend) { + // send set group subject command now + MessageCenterService.bus() + .post(new SendMessageRequest(ContentUris.parseId(cmdMsg))); + } + } + + public void setEncryptionEnabled(boolean encryptionEnabled) { + mEncryption = encryptionEnabled; + if (mThreadId > 0) + MessagesProviderClient.setEncryption(mContext, mThreadId, encryptionEnabled); } public void markAsRead() { @@ -242,8 +532,7 @@ public void markAsRead() { new Thread(new Runnable() { @Override public void run() { - MessagesProvider.markThreadAsRead(mContext, mThreadId); - + MessagesProviderClient.markThreadAsRead(mContext, mThreadId); MessagingNotification.updateMessagesNotification(mContext.getApplicationContext(), false); } }).start(); diff --git a/app/src/main/java/org/kontalk/data/ConversationsDataSource.java b/app/src/main/java/org/kontalk/data/ConversationsDataSource.java new file mode 100644 index 000000000..5c3d5c64b --- /dev/null +++ b/app/src/main/java/org/kontalk/data/ConversationsDataSource.java @@ -0,0 +1,163 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.data; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import androidx.paging.PositionalDataSource; +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; +import android.os.Handler; +import androidx.annotation.NonNull; + +import org.kontalk.provider.MyMessages; + + +/** + * Data source for conversations (threads table) + */ +public class ConversationsDataSource extends PositionalDataSource { + + private static final String[] COUNT_PROJECTION = new String[] { + MyMessages.Threads._COUNT, + }; + + private final Context mContext; + private final ContentResolver mContentResolver; + private final boolean mArchived; + + public ConversationsDataSource(Context context, boolean archived, Handler handler) { + mContext = context.getApplicationContext(); + mArchived = archived; + mContentResolver = mContext.getContentResolver(); + mContentResolver.registerContentObserver(MyMessages.Threads.CONTENT_URI, + true, new ChangeObserver(handler)); + } + + @Override + public void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback callback) { + final int totalCount = countItems(mArchived); + final int archivedCount = !mArchived ? countItems(true) : 0; + if (totalCount == 0) { + // archived count only + if (archivedCount > 0) { + List list = new ArrayList<>(1); + list.add(new Conversation(archivedCount)); + callback.onResult(list, 0, 1); + return; + } + + callback.onResult(Collections.emptyList(), 0, 0); + return; + } + + // bound the size requested, based on known count + final int firstLoadPosition = computeInitialLoadPosition(params, totalCount); + final int firstLoadSize = computeInitialLoadSize(params, firstLoadPosition, totalCount); + + List list = loadRange(firstLoadPosition, firstLoadSize); + if (list != null && list.size() == firstLoadSize) { + int reportedCount = totalCount; + // take into account the footer item if we have archived conversations + if (archivedCount > 0) { + // add the footer item + list.add(new Conversation(archivedCount)); + reportedCount++; + } + callback.onResult(list, firstLoadPosition, reportedCount); + } + else { + // null list, or size doesn't match request - DB modified between count and load + invalidate(); + } + } + + @Override + public void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback callback) { + List list = loadRange(params.startPosition, params.loadSize); + if (list != null) { + callback.onResult(list); + } + else { + invalidate(); + } + + } + + private int countItems(boolean archived) { + Cursor cursor = mContentResolver.query(MyMessages.Threads.CONTENT_URI, + COUNT_PROJECTION, MyMessages.Threads.ARCHIVED + " = " + (archived ? "1" : "0"), + null, null); + try { + if (cursor.moveToFirst()) { + return cursor.getInt(0); + } + return 0; + } + finally { + cursor.close(); + } + } + + private List getConversations(boolean archived, int limit, int offset) { + Cursor cursor = mContentResolver.query(MyMessages.Threads.CONTENT_URI.buildUpon() + .appendQueryParameter("limit", offset + "," + limit).build(), + Conversation.PROJECTION, MyMessages.Threads.ARCHIVED + " = " + (archived ? "1" : "0"), + null, MyMessages.Threads.DEFAULT_SORT_ORDER); + + List conversations = new ArrayList<>(); + try { + while (cursor.moveToNext()) { + conversations.add(Conversation.createFromCursor(mContext, cursor)); + } + } + finally { + cursor.close(); + } + return conversations; + } + + /** + * Return the rows from startPos to startPos + loadCount + */ + private List loadRange(int startPosition, int loadCount) { + return getConversations(mArchived, loadCount, startPosition); + } + + class ChangeObserver extends ContentObserver { + public ChangeObserver(Handler handler) { + super(handler); + } + + @Override + public boolean deliverSelfNotifications() { + return true; + } + + @Override + public void onChange(boolean selfChange) { + invalidate(); + } + } + +} diff --git a/app/src/main/java/org/kontalk/data/ConversationsDataSourceFactory.java b/app/src/main/java/org/kontalk/data/ConversationsDataSourceFactory.java new file mode 100644 index 000000000..8a17f37e7 --- /dev/null +++ b/app/src/main/java/org/kontalk/data/ConversationsDataSourceFactory.java @@ -0,0 +1,42 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.data; + +import androidx.paging.DataSource; +import android.content.Context; +import android.os.Handler; + + +public class ConversationsDataSourceFactory extends DataSource.Factory { + + private final Context mContext; + private final boolean mArchived; + private final Handler mHandler; + + public ConversationsDataSourceFactory(Context context, boolean archived) { + mContext = context.getApplicationContext(); + mArchived = archived; + mHandler = new Handler(); + } + + @Override + public DataSource create() { + return new ConversationsDataSource(mContext, mArchived, mHandler); + } +} diff --git a/app/src/main/java/org/kontalk/data/GroupInfo.java b/app/src/main/java/org/kontalk/data/GroupInfo.java new file mode 100644 index 000000000..9d7420e61 --- /dev/null +++ b/app/src/main/java/org/kontalk/data/GroupInfo.java @@ -0,0 +1,56 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.data; + +import org.jxmpp.jid.Jid; + + +/** + * Group chat information. + * @author Daniele Ricci + */ +public class GroupInfo { + private final Jid mJid; + private final String mSubject; + private final String mType; + private final int mMembership; + + public GroupInfo(Jid jid, String subject, String type, int membership) { + mJid = jid; + mSubject = subject; + mType = type; + mMembership = membership; + } + + public Jid getJid() { + return mJid; + } + + public String getSubject() { + return mSubject; + } + + public String getType() { + return mType; + } + + public int getMembership() { + return mMembership; + } +} diff --git a/app/src/main/java/org/kontalk/data/SearchItem.java b/app/src/main/java/org/kontalk/data/SearchItem.java index 1a052a89d..70c3cd669 100644 --- a/app/src/main/java/org/kontalk/data/SearchItem.java +++ b/app/src/main/java/org/kontalk/data/SearchItem.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,9 +20,11 @@ import org.kontalk.provider.MyMessages.Messages; import org.kontalk.provider.MyMessages.Messages.Fulltext; +import org.kontalk.reporting.ReportingManager; import android.content.Context; import android.database.Cursor; +import android.database.sqlite.SQLiteException; /** @@ -30,27 +32,22 @@ * @author Daniele Ricci */ public class SearchItem { - public static final String[] SEARCH_PROJECTION = { + private static final String[] SEARCH_PROJECTION = { Fulltext._ID + " AS " + Messages._ID, Fulltext.THREAD_ID, Fulltext.CONTENT }; - protected final long mId; - protected final long mThreadId; - protected String mUserId; - protected final String mText; - protected Contact mContact; + private final long mId; + private final long mThreadId; + private final String mText; + private final Conversation mConversation; private SearchItem(Context context, long id, long threadId, String text) { mId = id; mThreadId = threadId; mText = text; - Conversation conv = Conversation.loadFromId(context, threadId); - if (conv != null) { - mUserId = conv.getRecipient(); - mContact = conv.getContact(); - } + mConversation = Conversation.loadFromId(context, threadId); } public long getMessageId() { @@ -61,18 +58,28 @@ public long getThreadId() { return mThreadId; } - public String getUserId() { - return mUserId; + public String getUserDisplayName() { + if (mConversation != null) { + if (mConversation.isGroupChat()) { + return mConversation.getGroupSubject(); + } + else { + final Contact contact = mConversation.getContact(); + String name; + if (contact != null) + name = contact.getName() + " <" + contact.getNumber() + ">"; + else + name = mConversation.getRecipient(); + return name; + } + } + return null; } public String getText() { return mText; } - public Contact getContact() { - return mContact; - } - public static SearchItem fromCursor(Context context, Cursor cursor) { long id = cursor.getLong(0); long threadId = cursor.getLong(1); @@ -81,9 +88,15 @@ public static SearchItem fromCursor(Context context, Cursor cursor) { } public static Cursor query(Context context, String query) { - // TODO enhanced queries? - return context.getContentResolver().query(Fulltext.CONTENT_URI - .buildUpon().appendQueryParameter("pattern", query + "*").build(), - SEARCH_PROJECTION, null, null, null); + try { + // TODO enhanced queries? + return context.getContentResolver().query(Fulltext.CONTENT_URI + .buildUpon().appendQueryParameter("pattern", query).build(), + SEARCH_PROJECTION, null, null, Fulltext.DEFAULT_SORT_ORDER); + } + catch (SQLiteException e) { + ReportingManager.logException(e); + return null; + } } } diff --git a/app/src/main/java/org/kontalk/message/Attachment.java b/app/src/main/java/org/kontalk/message/Attachment.java index d561b325f..e2007d0c0 100644 --- a/app/src/main/java/org/kontalk/message/Attachment.java +++ b/app/src/main/java/org/kontalk/message/Attachment.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/message/AttachmentComponent.java b/app/src/main/java/org/kontalk/message/AttachmentComponent.java index 727e3e4e0..1987f8a0b 100644 --- a/app/src/main/java/org/kontalk/message/AttachmentComponent.java +++ b/app/src/main/java/org/kontalk/message/AttachmentComponent.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/message/AudioComponent.java b/app/src/main/java/org/kontalk/message/AudioComponent.java index 40392d841..87bcb6328 100644 --- a/app/src/main/java/org/kontalk/message/AudioComponent.java +++ b/app/src/main/java/org/kontalk/message/AudioComponent.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -27,19 +27,21 @@ import org.kontalk.util.MediaStorage; + /** * Audio component. * @author Andrea Cappelli + * TODO create subclass for audio recordings */ - public class AudioComponent extends AttachmentComponent { - static Map MIME_TYPES = new HashMap(); + static Map MIME_TYPES = new HashMap<>(); static { MIME_TYPES.put("audio/3gpp", "3gp"); MIME_TYPES.put("audio/mpeg", "mp3"); MIME_TYPES.put("audio/mp4", "mp4"); + MIME_TYPES.put("audio/m4a", "m4a"); MIME_TYPES.put("audio/wav", "wav"); MIME_TYPES.put("audio/aac", "aac"); MIME_TYPES.put("audio/flac", "flac"); diff --git a/app/src/main/java/org/kontalk/message/CompositeMessage.java b/app/src/main/java/org/kontalk/message/CompositeMessage.java index 32b013935..e09c5301a 100644 --- a/app/src/main/java/org/kontalk/message/CompositeMessage.java +++ b/app/src/main/java/org/kontalk/message/CompositeMessage.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,6 +24,8 @@ import java.util.Date; import java.util.List; +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; import org.jxmpp.util.XmppStringUtils; import android.content.AsyncQueryHandler; @@ -32,10 +34,19 @@ import android.database.Cursor; import android.net.Uri; import android.os.Parcelable; - +import androidx.annotation.NonNull; + +import org.kontalk.BuildConfig; +import org.kontalk.Log; +import org.kontalk.client.GroupExtension; +import org.kontalk.data.GroupInfo; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.provider.MyMessages; +import org.kontalk.provider.MyMessages.Groups; import org.kontalk.provider.MyMessages.Messages; import org.kontalk.provider.MyMessages.Threads.Conversations; import org.kontalk.util.MediaStorage; +import org.kontalk.util.MessageUtils; /** @@ -45,15 +56,14 @@ * @version 1.0 */ public class CompositeMessage { - - public static final int USERID_LENGTH = 40; - public static final int USERID_LENGTH_RESOURCE = 48; + private static final String TAG = CompositeMessage.class.getSimpleName(); @SuppressWarnings("unchecked") private static final Class[] TRY_COMPONENTS = new Class[] { ImageComponent.class, AudioComponent.class, VCardComponent.class, + LocationComponent.class, }; private static final String[] MESSAGE_LIST_PROJECTION = { @@ -77,6 +87,15 @@ public class CompositeMessage { Messages.ATTACHMENT_LENGTH, Messages.ATTACHMENT_ENCRYPTED, Messages.ATTACHMENT_SECURITY_FLAGS, + Messages.GEO_LATITUDE, + Messages.GEO_LONGITUDE, + Messages.GEO_TEXT, + Messages.GEO_STREET, + Messages.IN_REPLY_TO, + Groups.GROUP_JID, + Groups.SUBJECT, + Groups.GROUP_TYPE, + Groups.MEMBERSHIP, }; // these indexes matches MESSAGE_LIST_PROJECTION @@ -100,15 +119,15 @@ public class CompositeMessage { public static final int COLUMN_ATTACHMENT_LENGTH = 17; public static final int COLUMN_ATTACHMENT_ENCRYPTED = 18; public static final int COLUMN_ATTACHMENT_SECURITY_FLAGS = 19; - - public static final String MSG_ID = "org.kontalk.message.id"; - public static final String MSG_SENDER = "org.kontalk.message.sender"; - public static final String MSG_MIME = "org.kontalk.message.mime"; - public static final String MSG_CONTENT = "org.kontalk.message.content"; - public static final String MSG_RECIPIENTS = "org.kontalk.message.recipients"; - public static final String MSG_GROUP = "org.kontalk.message.group"; - public static final String MSG_TIMESTAMP = "org.kontalk.message.timestamp"; - public static final String MSG_ENCRYPTED = "org.kontalk.message.encrypted"; + public static final int COLUMN_GEO_LATITUDE = 20; + public static final int COLUMN_GEO_LONGITUDE = 21; + public static final int COLUMN_GEO_TEXT = 22; + public static final int COLUMN_GEO_STREET = 23; + public static final int COLUMN_IN_REPLY_TO = 24; + public static final int COLUMN_GROUP_JID = 25; + public static final int COLUMN_GROUP_SUBJECT = 26; + public static final int COLUMN_GROUP_TYPE = 27; + public static final int COLUMN_GROUP_MEMBERSHIP = 28; private static final int SUFFIX_LENGTH = "Component".length(); @@ -122,24 +141,20 @@ public class CompositeMessage { protected int mStatus; protected boolean mEncrypted; protected int mSecurityFlags; + protected long mInReplyTo; - /** - * Recipients (outgoing) - will contain one element for incoming - */ - protected List mRecipients; + /** Recipient (outgoing) */ + protected String mRecipient; /** Message components. */ protected List> mComponents; /** Creates a new composite message. */ public CompositeMessage(Context context, String id, long timestamp, String sender, boolean encrypted, int securityFlags) { - this(); - - mContext = context; + this(context); mId = id; mSender = sender; - mRecipients = new ArrayList(); // will be updated if necessary mTimestamp = System.currentTimeMillis(); mServerTimestamp = timestamp; @@ -149,8 +164,9 @@ public CompositeMessage(Context context, String id, long timestamp, String sende } /** Empty constructor for local use. */ - private CompositeMessage() { - mComponents = new ArrayList>(); + private CompositeMessage(Context context) { + mContext = context; + mComponents = new ArrayList<>(); } public String getId() { @@ -161,6 +177,15 @@ public void setId(String id) { mId = id; } + public String getPeer() { + if (isIncoming()) { + return getSender(true); + } + else { + return getRecipient(true); + } + } + public String getSender(boolean generic) { return generic && XmppStringUtils.isFullJID(mSender) ? XmppStringUtils.parseBareJid(mSender) : mSender; @@ -170,12 +195,13 @@ public String getSender() { return getSender(false); } - public List getRecipients() { - return mRecipients; + public String getRecipient(boolean generic) { + return generic && XmppStringUtils.isFullJID(mRecipient) ? + XmppStringUtils.parseBareJid(mRecipient) : mRecipient; } - public void addRecipient(String userId) { - mRecipients.add(userId); + public String getRecipient() { + return getRecipient(false); } public long getTimestamp() { @@ -198,10 +224,30 @@ public long getServerTimestamp() { return mServerTimestamp; } + // for internal use only. + public void setStatus(int status) { + mStatus = status; + } + public int getStatus() { return mStatus; } + /** Generates a text content for this message. Useful for exporting a message to a text stream. */ + public String toTextContent() { + // loop until the first known component is found + for (MessageComponent cmp : mComponents) { + if (cmp instanceof TextComponent) { + return ((TextComponent) cmp).getContent(); + } + else if (cmp instanceof AttachmentComponent) { + return getSampleTextContent(((AttachmentComponent) cmp).getMime()); + } + } + + return null; + } + @Override public String toString() { // FIXME include components @@ -213,6 +259,14 @@ public int getDirection() { Messages.DIRECTION_IN : Messages.DIRECTION_OUT; } + public boolean isIncoming() { + return getDirection() == Messages.DIRECTION_IN; + } + + public boolean isOutgoing() { + return getDirection() == Messages.DIRECTION_OUT; + } + public void setDatabaseId(long databaseId) { mDatabaseId = databaseId; } @@ -245,11 +299,16 @@ public void clearComponents() { mComponents.clear(); } + public > boolean hasComponent(Class type) { + return getComponent(type) != null; + } + /** Returns the first component of the given type. */ - public MessageComponent getComponent(Class> type) { + @SuppressWarnings("unchecked") + public > T getComponent(Class type) { for (MessageComponent cmp : mComponents) { if (type.isInstance(cmp)) - return cmp; + return (T) cmp; } return null; @@ -266,17 +325,18 @@ private void populateFromCursor(Cursor c) { mTimestamp = c.getLong(COLUMN_TIMESTAMP); mStatusChanged = c.getLong(COLUMN_STATUS_CHANGED); mStatus = c.getInt(COLUMN_STATUS); - mRecipients = new ArrayList(); + mRecipient = null; mEncrypted = (c.getShort(COLUMN_ENCRYPTED) > 0); mSecurityFlags = c.getInt(COLUMN_SECURITY); mServerTimestamp = c.getLong(COLUMN_SERVER_TIMESTAMP); + mInReplyTo = c.getLong(COLUMN_IN_REPLY_TO); String peer = c.getString(COLUMN_PEER); int direction = c.getInt(COLUMN_DIRECTION); if (direction == Messages.DIRECTION_OUT) { // we are the origin mSender = null; - mRecipients.add(peer); + mRecipient = peer; } else { mSender = peer; @@ -294,15 +354,76 @@ private void populateFromCursor(Cursor c) { else { String mime = c.getString(COLUMN_BODY_MIME); + String groupJidStr = c.getString(COLUMN_GROUP_JID); + String groupSubject = c.getString(COLUMN_GROUP_SUBJECT); + String groupType = c.getString(COLUMN_GROUP_TYPE); + int groupMembership = c.getInt(COLUMN_GROUP_MEMBERSHIP); + Jid groupJid = groupJidStr != null ? + JidCreate.fromOrThrowUnchecked(groupJidStr) : null; if (body != null) { + // remove trailing zero + String bodyText = MessageUtils.toString(body); // text data if (TextComponent.supportsMimeType(mime)) { - TextComponent txt = new TextComponent(new String(body)); + TextComponent txt = new TextComponent(bodyText); addComponent(txt); } + else if (LocationComponent.supportsMimeType(mime)) { + double lat = c.getDouble(COLUMN_GEO_LATITUDE); + double lon = c.getDouble(COLUMN_GEO_LONGITUDE); + String text = c.getString(COLUMN_GEO_TEXT); + String street = c.getString(COLUMN_GEO_STREET); + + // send text along + if (bodyText.length() > 0) { + addComponent(new HiddenTextComponent(bodyText)); + } + + LocationComponent location = new LocationComponent(lat, lon, text, street); + addComponent(location); + } + + // group command + else if (GroupCommandComponent.supportsMimeType(mime)) { + if (groupJid == null) { + // impossible + throw new IllegalStateException("Trying to parse a group command without a group!?"); + } + + String groupId = groupJid.getLocalpartOrNull().toString(); + Jid groupOwner = JidCreate.fromOrThrowUnchecked(groupJid.getDomain()); + GroupExtension ext = null; + + String subject; + String[] createMembers; + if ((createMembers = GroupCommandComponent.getCreateCommandMembers(bodyText)) != null) { + ext = new GroupExtension(groupId, groupOwner, GroupExtension.Type.CREATE, + groupSubject, GroupCommandComponent.membersFromJIDs(createMembers)); + } + else if (GroupCommandComponent.COMMAND_PART.equals(bodyText)) { + ext = new GroupExtension(groupId, groupOwner, GroupExtension.Type.PART); + } + else if ((subject = GroupCommandComponent.getSubjectCommand(bodyText)) != null) { + ext = new GroupExtension(groupId, groupOwner, GroupExtension.Type.SET, subject); + } + else { + String[] addMembers = GroupCommandComponent.getAddCommandMembers(bodyText); + String[] removeMembers = GroupCommandComponent.getRemoveCommandMembers(bodyText); + if (addMembers != null || removeMembers != null) { + ext = new GroupExtension(groupId, groupOwner, GroupExtension.Type.SET, + groupSubject, GroupCommandComponent + // TODO what about existing members here? + .membersFromJIDs(null, addMembers, removeMembers)); + } + } + + if (ext != null) + addComponent(new GroupCommandComponent(ext, peer)); + } + // unknown data else { RawComponent raw = new RawComponent(body, false, mSecurityFlags); @@ -321,7 +442,7 @@ private void populateFromCursor(Cursor c) { boolean attEncrypted = c.getInt(COLUMN_ATTACHMENT_ENCRYPTED) > 0; int attSecurityFlags = c.getInt(COLUMN_ATTACHMENT_SECURITY_FLAGS); - AttachmentComponent att = null; + AttachmentComponent att; File previewFile = (attPreview != null) ? new File(attPreview) : null; Uri localUri = (attLocal != null) ? Uri.parse(attLocal) : null; @@ -343,13 +464,32 @@ else if (AudioComponent.supportsMimeType(attMime)) { attLength, attEncrypted, attSecurityFlags); } + else { + att = new DefaultAttachmentComponent(attMime, + localUri, attFetch, + attLength, attEncrypted, attSecurityFlags); + } + // TODO other type of attachments - if (att != null) { - att.populateFromCursor(mContext, c); - addComponent(att); - } + att.populateFromCursor(mContext, c); + addComponent(att); + } + // in reply to + long inReplyToId = c.getLong(COLUMN_IN_REPLY_TO); + if (inReplyToId > 0) { + // load the referenced message + ReferencedMessage referencedMsg = ReferencedMessage.load(mContext, inReplyToId); + // a null message is allowed, meaning that it was not found + addComponent(new InReplyToComponent(referencedMsg)); + } + + // group information + if (groupJid != null) { + GroupInfo groupInfo = new GroupInfo(groupJid, + groupSubject, groupType, groupMembership); + addComponent(new GroupComponent(groupInfo)); } } @@ -368,21 +508,77 @@ protected void clear() { mStatus = 0; mEncrypted = false; mSecurityFlags = 0; + mInReplyTo = 0; + clearComponents(); } /** Builds an instance from a {@link Cursor} row. */ public static CompositeMessage fromCursor(Context context, Cursor cursor) { - CompositeMessage msg = new CompositeMessage(); + CompositeMessage msg = new CompositeMessage(context); msg.populateFromCursor(cursor); // TODO return msg; } - public static void startQuery(AsyncQueryHandler handler, int token, long threadId) { + /** Holder for message delete information. */ + public static final class DeleteMessageHolder { + long id; + File[] mediaFiles; + + public DeleteMessageHolder(Cursor cursor) { + id = cursor.getLong(COLUMN_ID); + String filePath = cursor.getString(COLUMN_ATTACHMENT_LOCAL_URI); + if (filePath != null) { + Uri fileUri = Uri.parse(filePath); + if ("file".equals(fileUri.getScheme())) { + mediaFiles = new File[] { new File(fileUri.getPath()) }; + } + } + } + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + public static void deleteMessage(Context context, DeleteMessageHolder holder) { + MessagesProviderClient.deleteMessage(context, holder.id); + if (holder.mediaFiles != null) { + // try to delete the original files, ignoring result + for (File media : holder.mediaFiles) { + try { + media.delete(); + } + catch (Exception e) { + Log.v(TAG, "unable to delete original media: " + media); + } + } + } + } + + public static CompositeMessage loadMessage(Context context, long id) { + Cursor c = context.getContentResolver().query(ContentUris + .withAppendedId(MyMessages.Messages.CONTENT_URI, id), + MESSAGE_LIST_PROJECTION, null, null, null); + try { + if (c.moveToFirst()) { + return fromCursor(context, c); + } + return null; + } + finally { + c.close(); + } + } + + public static void startQuery(AsyncQueryHandler handler, int token, long threadId, long count, long lastId) { + Uri.Builder builder = ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId) + .buildUpon() + .appendQueryParameter("count", String.valueOf(count)); + if (lastId > 0) { + builder.appendQueryParameter("last", String.valueOf(lastId)); + } + // cancel previous operations handler.cancelOperation(token); - handler.startQuery(token, null, - ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId), + handler.startQuery(token, lastId > 0 ? "append" : null, builder.build(), MESSAGE_LIST_PROJECTION, null, null, Messages.DEFAULT_SORT_ORDER); } @@ -391,13 +587,14 @@ public static String getSampleTextContent(String mime) { Class klass = getSupportingComponent(mime); if (klass != null) { String cname = klass.getSimpleName(); - return cname.substring(0, cname.length() - SUFFIX_LENGTH) + - ": " + mime; + String text = cname.substring(0, cname.length() - SUFFIX_LENGTH); + return BuildConfig.DEBUG ? (text + ": " + mime) : text; } // no supporting component - return mime // TODO i18n - return "Unknown: " + mime; + String text = "Unknown"; + return BuildConfig.DEBUG ? (text + ": " + mime) : text; } private static Class getSupportingComponent(String mime) { @@ -425,23 +622,40 @@ private static Class getSupportingComponent(String mime) { * @param mime MIME type of the incoming attachment * @param timestamp timestamp of the message */ - public static File getIncomingFile(String mime, Date timestamp) { - Class klass = getSupportingComponent(mime); - if (klass != null) { - if (klass.isAssignableFrom(ImageComponent.class)) { + public static File getIncomingFile(Context context, String mime, @NonNull Date timestamp) { + if (mime != null) { + if (ImageComponent.supportsMimeType(mime)) { String ext = ImageComponent.getFileExtension(mime); - return MediaStorage.getIncomingImageFile(timestamp, ext); + return MediaStorage.getIncomingImageFile(context, timestamp, ext); } - else if (klass.isAssignableFrom(AudioComponent.class)) { + else if (AudioComponent.supportsMimeType(mime)) { String ext = AudioComponent.getFileExtension(mime); - return MediaStorage.getIncomingAudioFile(timestamp, ext); + return MediaStorage.getIncomingAudioFile(context, timestamp, ext); } + // TODO maybe other file types? + } + return null; + } + + /** + * Returns a correct file name for the given MIME. + * @param mime MIME type of the incoming attachment + * @param timestamp timestamp of the message + */ + public static String getFilename(String mime, @NonNull Date timestamp) { + if (ImageComponent.supportsMimeType(mime)) { + String ext = ImageComponent.getFileExtension(mime); + return MediaStorage.getOutgoingPictureFilename(timestamp, ext); + } + else if (AudioComponent.supportsMimeType(mime)) { + String ext = AudioComponent.getFileExtension(mime); + return MediaStorage.getOutgoingAudioFilename(timestamp, ext); } return null; } - /** Still unused. + /* Still unused. public static void startQuery(AsyncQueryHandler handler, int token, String peer) { // cancel previous operations handler.cancelOperation(token); diff --git a/app/src/main/java/org/kontalk/message/DefaultAttachmentComponent.java b/app/src/main/java/org/kontalk/message/DefaultAttachmentComponent.java new file mode 100644 index 000000000..062841365 --- /dev/null +++ b/app/src/main/java/org/kontalk/message/DefaultAttachmentComponent.java @@ -0,0 +1,41 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.message; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +/** + * Generic component used for unknown MIME types. + * @author Daniele Ricci + */ + +public class DefaultAttachmentComponent extends AttachmentComponent { + + public DefaultAttachmentComponent(String mime, Uri localUri, String fetchUrl, long length, boolean encrypted, int securityFlags) { + super(mime, null, localUri, fetchUrl, length, encrypted, securityFlags); + } + + @Override + protected void populateFromCursor(Context context, Cursor c) { + // Nothing to do here + } + +} diff --git a/app/src/main/java/org/kontalk/message/GroupCommandComponent.java b/app/src/main/java/org/kontalk/message/GroupCommandComponent.java new file mode 100644 index 000000000..6ae8e6168 --- /dev/null +++ b/app/src/main/java/org/kontalk/message/GroupCommandComponent.java @@ -0,0 +1,270 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.message; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; + +import android.content.Context; +import android.database.Cursor; + +import org.kontalk.R; +import org.kontalk.client.GroupExtension; + + +/** + * Component for group commands. + * @author Daniele Ricci + */ +public class GroupCommandComponent extends MessageComponent { + + /** MIME type for command messages. */ + public static final String MIME_TYPE = "application/x-kontalk-group"; + + // create group + public static final String COMMAND_CREATE = "create"; + // leave group + public static final String COMMAND_PART = "part"; + // set subject + public static final String COMMAND_SUBJECT = "subject"; + // add members + public static final String COMMAND_ADD = "add"; + // remove members + public static final String COMMAND_REMOVE = "remove"; + + private final String mFrom; + + public GroupCommandComponent(GroupExtension content, String from) { + super(content, 0, false, 0); + mFrom = from; + } + + public String getFrom() { + return mFrom; + } + + public static boolean supportsMimeType(String mime) { + return MIME_TYPE.equalsIgnoreCase(mime); + } + + public boolean isCreateCommand() { + return mContent.getType() == GroupExtension.Type.CREATE; + } + + public boolean isPartCommand() { + return mContent.getType() == GroupExtension.Type.PART; + } + + public boolean isAddOrRemoveCommand() { + return mContent.getType() == GroupExtension.Type.SET && + mContent.getMembers().size() > 0; + } + + public boolean isSetSubjectCommand() { + return mContent.getType() == GroupExtension.Type.SET && + mContent.getMembers().size() == 0; + } + + public String[] getCreateMembers() { + if (!isCreateCommand()) + throw new UnsupportedOperationException("Not a create command"); + return flattenOperationMembers(GroupExtension.Member.Operation.NONE, true); + } + + public String[] getExistingMembers() { + return flattenOperationMembers(GroupExtension.Member.Operation.NONE, true); + } + + public String[] getAddedMembers() { + if (!isAddOrRemoveCommand()) + throw new UnsupportedOperationException("Not an add or remove command"); + return flattenOperationMembers(GroupExtension.Member.Operation.ADD, false); + } + + public String[] getRemovedMembers() { + if (!isAddOrRemoveCommand()) + throw new UnsupportedOperationException("Not an add or remove command"); + return flattenOperationMembers(GroupExtension.Member.Operation.REMOVE, false); + } + + private String[] flattenOperationMembers(GroupExtension.Member.Operation operation, boolean includeOwner) { + List members = mContent.getMembers(); + Set output = new HashSet<>(); + if (includeOwner) + output.add(mContent.getOwner().toString()); + + for (int i = 0; i < members.size(); i++) { + GroupExtension.Member user = members.get(i); + // exclude own JID from the list + if (user.operation == operation) + output.add(user.jid.toString()); + } + return output.toArray(new String[0]); + } + + /** Returns the text to be used as the body content in the database. */ + public String getTextContent() { + if (isCreateCommand()) + return getCreateBodyContent(getCreateMembers()); + else if (isPartCommand()) + return COMMAND_PART; + else if (isAddOrRemoveCommand()) { + return getAddMembersBodyContent(getAddedMembers()) + + getRemoveMembersBodyContent(getRemovedMembers()); + } + else if (isSetSubjectCommand()) { + return getSetSubjectCommandBodyContent(mContent.getSubject()); + } + + // TODO + throw new UnsupportedOperationException("Unsupported group command"); + } + + public static String getCreateBodyContent(String[] members) { + StringBuilder out = new StringBuilder(COMMAND_CREATE).append(":"); + for (String m : members) + out.append(m).append(";"); + return out.toString(); + } + + public static String getSetSubjectCommandBodyContent(String subject) { + return COMMAND_SUBJECT + ":" + subject; + } + + public static String getLeaveCommandBodyContent() { + return COMMAND_PART; + } + + public static String getAddMembersBodyContent(String[] members) { + String prefix = GroupCommandComponent.COMMAND_ADD + ":"; + StringBuilder out = new StringBuilder(); + for (String m : members) + out.append(prefix).append(m).append(";"); + return out.toString(); + } + + public static String getRemoveMembersBodyContent(String[] members) { + String prefix = GroupCommandComponent.COMMAND_REMOVE + ":"; + StringBuilder out = new StringBuilder(); + for (String m : members) + out.append(prefix).append(m).append(";"); + return out.toString(); + } + + public static String[] getCreateCommandMembers(String body) { + String prefix = GroupCommandComponent.COMMAND_CREATE + ":"; + if (body.startsWith(prefix)) { + String[] params = body.substring(prefix.length()).split(";"); + Set members = new HashSet<>(); + for (String m : params) { + if (m.length() > 0) + members.add(m); + } + return members.toArray(new String[0]); + } + return null; + } + + private static String[] getPrefixCommandMembers(String body, String prefix) { + String[] cmdParams = body.split(";"); + Set members = new HashSet<>(); + for (String param : cmdParams) { + if (param.startsWith(prefix)) + members.add(param.substring(prefix.length())); + } + return (members.size() > 0) ? + members.toArray(new String[0]) : null; + } + + public static String[] getAddCommandMembers(String body) { + return getPrefixCommandMembers(body, "add:"); + } + + public static String[] getRemoveCommandMembers(String body) { + return getPrefixCommandMembers(body, "remove:"); + } + + public static String getSubjectCommand(String body) { + String prefix = GroupCommandComponent.COMMAND_SUBJECT + ":"; + if (body.startsWith(prefix)) { + return body.substring(prefix.length()); + } + return null; + } + + public static String getTextContent(Context context, String bodyContent, boolean incoming) { + if (bodyContent.startsWith(COMMAND_CREATE)) { + return context.getString(R.string.group_command_text_create); + } + else if (bodyContent.startsWith(COMMAND_PART)) { + return context.getString(incoming ? + R.string.group_command_text_part : R.string.group_command_text_part_self); + } + else if (bodyContent.startsWith(COMMAND_SUBJECT)) { + return context.getString(R.string.group_command_text_subject); + } + else if (bodyContent.contains(COMMAND_ADD) || bodyContent.contains(COMMAND_REMOVE)) { + return context.getString(R.string.group_command_text_add_remove); + } + + // this shouldn't happen + throw new UnsupportedOperationException("Unknown group command: " + bodyContent); + } + + public static List membersFromJIDs(String[] members) { + return membersFromJIDs(members, null, null); + } + + public static List membersFromJIDs(String[] members, String[] added, String[] removed) { + List list = new ArrayList<>( + (members != null ? members.length : 0) + + (added != null ? added.length : 0) + + (removed != null ? removed.length : 0)); + if (members != null) { + for (String m : members) { + Jid jid = JidCreate.fromOrThrowUnchecked(m); + list.add(new GroupExtension.Member(jid, GroupExtension.Member.Operation.NONE)); + } + } + if (added != null) { + for (String m : added) { + Jid jid = JidCreate.fromOrThrowUnchecked(m); + list.add(new GroupExtension.Member(jid, GroupExtension.Member.Operation.ADD)); + } + } + if (removed != null) { + for (String m : removed) { + Jid jid = JidCreate.fromOrThrowUnchecked(m); + list.add(new GroupExtension.Member(jid, GroupExtension.Member.Operation.REMOVE)); + } + } + return list; + } + + /** Returns true if the given cursor is a group command message. */ + public static boolean isCursor(Cursor cursor) { + return MIME_TYPE.equals(cursor.getString(CompositeMessage.COLUMN_BODY_MIME)); + } + +} diff --git a/app/src/main/java/org/kontalk/message/GroupComponent.java b/app/src/main/java/org/kontalk/message/GroupComponent.java new file mode 100644 index 000000000..4e3d2bf1b --- /dev/null +++ b/app/src/main/java/org/kontalk/message/GroupComponent.java @@ -0,0 +1,34 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.message; + +import org.kontalk.data.GroupInfo; + + +/** + * Virtual component for group information. Not rendered in the UI. + * @author Daniele Ricci + */ +public class GroupComponent extends MessageComponent { + + public GroupComponent(GroupInfo content) { + super(content, 0, false, 0); + } + +} diff --git a/app/src/main/java/org/kontalk/message/HiddenTextComponent.java b/app/src/main/java/org/kontalk/message/HiddenTextComponent.java new file mode 100644 index 000000000..0d8dff5cf --- /dev/null +++ b/app/src/main/java/org/kontalk/message/HiddenTextComponent.java @@ -0,0 +1,34 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.message; + +import org.kontalk.crypto.Coder; + + +/** + * Hidden text component. Used to add a body to the message but hidden from UI. + * @author Daniele Ricci + */ +public class HiddenTextComponent extends MessageComponent { + + public HiddenTextComponent(String text) { + super(text, text.length(), false, Coder.SECURITY_CLEARTEXT); + } + +} diff --git a/app/src/main/java/org/kontalk/message/ImageComponent.java b/app/src/main/java/org/kontalk/message/ImageComponent.java index 7bfa287aa..c7b8f6c71 100644 --- a/app/src/main/java/org/kontalk/message/ImageComponent.java +++ b/app/src/main/java/org/kontalk/message/ImageComponent.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,19 +19,14 @@ package org.kontalk.message; import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import org.kontalk.Kontalk; -import org.kontalk.util.MediaStorage; +import org.jivesoftware.smack.util.StringUtils; import android.content.Context; import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.net.Uri; -import android.util.Log; + +import org.kontalk.util.MediaStorage; /** @@ -39,7 +34,6 @@ * @author Daniele Ricci */ public class ImageComponent extends AttachmentComponent { - private static final String TAG = Kontalk.TAG; private static final String[][] MIME_TYPES = { { "image/png", "png" }, @@ -49,37 +43,18 @@ public class ImageComponent extends AttachmentComponent { { "image/jpg", "jpg" } }; - private Bitmap mBitmap; - public ImageComponent(String mime, File previewFile, Uri localUri, String fetchUrl, long length, boolean encrypted, int securityFlags) { super(mime, previewFile, localUri, fetchUrl, length, encrypted, securityFlags); } public static boolean supportsMimeType(String mime) { - for (int i = 0; i < MIME_TYPES.length; i++) - if (MIME_TYPES[i][0].equalsIgnoreCase(mime)) + for (String[] MIME_TYPE : MIME_TYPES) + if (MIME_TYPE[0].equalsIgnoreCase(mime)) return true; return false; } - private BitmapFactory.Options bitmapOptions() { - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inPreferredConfig = Bitmap.Config.RGB_565; - return options; - } - - private void loadPreview(File previewFile) throws IOException { - InputStream in = new FileInputStream(previewFile); - BitmapFactory.Options options = bitmapOptions(); - mBitmap = BitmapFactory.decodeStream(in, null, options); - in.close(); - } - - public Bitmap getBitmap() { - return mBitmap; - } - /** FIXME not used yet */ public boolean isValidMedia(Context context) { Uri localUri = mContent.getLocalUri(); @@ -97,53 +72,17 @@ public boolean isValidMedia(Context context) { @Override protected void populateFromCursor(Context context, Cursor c) { - - /* - * local_uri is used for referencing the original media. - * preview_uri is used to load the media thumbnail. - * If preview_uri is null or cannot be found, a thumbnail is - * generated on the fly from local_uri - if possible. - */ - - File previewFile = mContent.getPreviewFile(); - Uri localUri = mContent.getLocalUri(); - try { - // preview path - if (previewFile != null) { - // load from file - we know it's a file uri - loadPreview(previewFile); - } - } - catch (Exception e) { - Log.w(TAG, "unable to load thumbnail, generating one"); - - try { - /* - * unable to load preview - generate thumbnail - * Of course a thumbnail can be generated only if the image has - * already been downloaded. - */ - if (localUri != null) { - // FIXME using a false file extension - MediaStorage.cacheThumbnail(context, localUri, previewFile, false); - loadPreview(previewFile); - } - } - catch (Exception e1) { - Log.e(TAG, "unable to generate thumbnail", e1); - } - } } - public static String buildMediaFilename(String id, String mime) { - return "image" + id.substring(id.length() - 5) + "." + getFileExtension(mime); + public static String buildMediaFilename(String mime) { + return "image" + StringUtils.randomString(5) + "." + getFileExtension(mime); } /** Returns the file extension from the mime type. */ public static String getFileExtension(String mime) { - for (int i = 0; i < MIME_TYPES.length; i++) - if (MIME_TYPES[i][0].equalsIgnoreCase(mime)) - return MIME_TYPES[i][1]; + for (String[] MIME_TYPE : MIME_TYPES) + if (MIME_TYPE[0].equalsIgnoreCase(mime)) + return MIME_TYPE[1]; return null; } diff --git a/app/src/main/java/org/kontalk/message/InReplyToComponent.java b/app/src/main/java/org/kontalk/message/InReplyToComponent.java new file mode 100644 index 000000000..8af6ca08b --- /dev/null +++ b/app/src/main/java/org/kontalk/message/InReplyToComponent.java @@ -0,0 +1,36 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.message; + +import org.kontalk.crypto.Coder; + + +/** + * A reference to a message that we are replying to. + * @author Daniele Ricci + */ +public class InReplyToComponent extends MessageComponent { + + public InReplyToComponent(ReferencedMessage message) { + super(message, 0, false, Coder.SECURITY_CLEARTEXT); + } + + // no supportsMimeType here + +} diff --git a/app/src/main/java/org/kontalk/message/LegacyAbstractMessage.java b/app/src/main/java/org/kontalk/message/LegacyAbstractMessage.java index 9c05396f1..930374fd4 100644 --- a/app/src/main/java/org/kontalk/message/LegacyAbstractMessage.java +++ b/app/src/main/java/org/kontalk/message/LegacyAbstractMessage.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -134,7 +134,7 @@ public LegacyAbstractMessage(Context context, String id, long timestamp, String this.id = id; this.sender = sender; - this.recipients = new ArrayList(); + this.recipients = new ArrayList<>(); // will be updated if necessary this.timestamp = System.currentTimeMillis(); this.serverTimestamp = timestamp; @@ -312,7 +312,7 @@ protected void populateFromCursor(Cursor c) { timestamp = c.getLong(COLUMN_TIMESTAMP); statusChanged = c.getLong(COLUMN_STATUS_CHANGED); status = c.getInt(COLUMN_STATUS); - recipients = new ArrayList(); + recipients = new ArrayList<>(); encrypted = (c.getShort(COLUMN_ENCRYPTED) > 0); security = c.getInt(COLUMN_SECURITY); serverTimestamp = c.getLong(COLUMN_SERVER_TIMESTAMP); @@ -376,7 +376,7 @@ public static String buildMediaFilename(LegacyAbstractMessage msg) { return null; } - /** Still unused. + /* Still unused. public static void startQuery(AsyncQueryHandler handler, int token, String peer) { // cancel previous operations handler.cancelOperation(token); diff --git a/app/src/main/java/org/kontalk/message/LegacyPlainTextMessage.java b/app/src/main/java/org/kontalk/message/LegacyPlainTextMessage.java index 747fa76a1..d15bbec10 100644 --- a/app/src/main/java/org/kontalk/message/LegacyPlainTextMessage.java +++ b/app/src/main/java/org/kontalk/message/LegacyPlainTextMessage.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/message/Location.java b/app/src/main/java/org/kontalk/message/Location.java new file mode 100644 index 000000000..68dbfe746 --- /dev/null +++ b/app/src/main/java/org/kontalk/message/Location.java @@ -0,0 +1,57 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.message; + + +/** + * Location data. + * @author andreacappelli + */ + +public class Location { + + private final double mLatitude; + private final double mLongitude; + private final String mText; + private final String mStreet; + + + public Location(double lat, double lon, String text, String street) { + mLatitude = lat; + mLongitude = lon; + mText = text; + mStreet = street; + } + + public double getLatitude() { + return mLatitude; + } + + public double getLongitude() { + return mLongitude; + } + + public String getText() { + return mText; + } + + public String getStreet() { + return mStreet; + } +} diff --git a/app/src/main/java/org/kontalk/message/LocationComponent.java b/app/src/main/java/org/kontalk/message/LocationComponent.java new file mode 100644 index 000000000..f4030a6bd --- /dev/null +++ b/app/src/main/java/org/kontalk/message/LocationComponent.java @@ -0,0 +1,58 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.message; + +import org.kontalk.crypto.Coder; + + +/** + * A location message component. + * + * @author Daniele Ricci + * @author Andrea Cappelli + */ +public class LocationComponent extends MessageComponent { + + public static final String MIME_TYPE = "text/plain+geoloc"; + + public LocationComponent(double lat, double lon, String text, String street) { + super(new Location(lat, lon, text, street), 0, false, Coder.SECURITY_CLEARTEXT); + } + + public double getLatitude() { + return mContent.getLatitude(); + } + + public double getLongitude() { + return mContent.getLongitude(); + } + + public String getText() { + return mContent.getText(); + } + + public String getStreet() { + return mContent.getStreet(); + } + + public static boolean supportsMimeType(String mime) { + return MIME_TYPE.equalsIgnoreCase(mime); + } + +} diff --git a/app/src/main/java/org/kontalk/message/MessageComponent.java b/app/src/main/java/org/kontalk/message/MessageComponent.java index 32c9da7dd..dddb234cc 100644 --- a/app/src/main/java/org/kontalk/message/MessageComponent.java +++ b/app/src/main/java/org/kontalk/message/MessageComponent.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/message/RawComponent.java b/app/src/main/java/org/kontalk/message/RawComponent.java index 6e167bff3..3352129db 100644 --- a/app/src/main/java/org/kontalk/message/RawComponent.java +++ b/app/src/main/java/org/kontalk/message/RawComponent.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/message/ReferencedMessage.java b/app/src/main/java/org/kontalk/message/ReferencedMessage.java new file mode 100644 index 000000000..cd2716392 --- /dev/null +++ b/app/src/main/java/org/kontalk/message/ReferencedMessage.java @@ -0,0 +1,145 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.message; + +import android.content.Context; +import android.database.Cursor; + +import org.kontalk.util.MessageUtils; + +import static org.kontalk.provider.MyMessages.*; + + +/** + * A simple message object used in contextual reply. + * Only text messages are supported. + * @author Daniele Ricci + * TODO refactor {@link CompositeMessage} to implement an interface that we can use in this class. + */ +public class ReferencedMessage { + + private static final String[] MESSAGE_PROJECTION = { + Messages._ID, + Messages.MESSAGE_ID, + Messages.PEER, + Messages.DIRECTION, + Messages.TIMESTAMP, + Messages.BODY_MIME, + Messages.BODY_CONTENT, + //Messages.BODY_LENGTH, + }; + + // these indexes matches MESSAGE_PROJECTION + private static final int COLUMN_ID = 0; + private static final int COLUMN_MESSAGE_ID = 1; + private static final int COLUMN_PEER = 2; + private static final int COLUMN_DIRECTION = 3; + private static final int COLUMN_TIMESTAMP = 4; + private static final int COLUMN_BODY_MIME = 5; + private static final int COLUMN_BODY_CONTENT = 6; + //private static final int COLUMN_BODY_LENGTH = 7; + + private long mId; + private String mMessageId; + private String mPeer; + private int mDirection; + private long mTimestamp; + private String mTextContent; + + public ReferencedMessage(long id, String messageId, String peer, int direction, long timestamp, String textContent) { + mId = id; + mMessageId = messageId; + mPeer = peer; + mDirection = direction; + mTimestamp = timestamp; + mTextContent = textContent; + } + + public long getId() { + return mId; + } + + public String getMessageId() { + return mMessageId; + } + + public String getPeer() { + return mPeer; + } + + public int getDirection() { + return mDirection; + } + + public long getTimestamp() { + return mTimestamp; + } + + public String getTextContent() { + return mTextContent; + } + + private static ReferencedMessage fromCursor(Cursor c) { + String mime = c.getString(COLUMN_BODY_MIME); + if (!TextComponent.supportsMimeType(mime)) + throw new IllegalArgumentException("Only text messages are supported"); + + long id = c.getLong(COLUMN_ID); + String msgId = c.getString(COLUMN_MESSAGE_ID); + String peer = c.getString(COLUMN_PEER); + int direction = c.getInt(COLUMN_DIRECTION); + long timestamp = c.getLong(COLUMN_TIMESTAMP); + byte[] body = c.getBlob(COLUMN_BODY_CONTENT); + + // remove trailing zero + String bodyText = MessageUtils.toString(body); + return new ReferencedMessage(id, msgId, peer, direction, timestamp, bodyText); + } + + public static ReferencedMessage load(Context context, long id) { + Cursor c = null; + try { + c = context.getContentResolver().query(Messages.getUri(id), + MESSAGE_PROJECTION, null, null, null); + return (c != null && c.moveToNext()) ? + fromCursor(c) : null; + } + finally { + if (c != null) + c.close(); + } + + } + + public static ReferencedMessage load(Context context, String messageId) { + Cursor c = null; + try { + c = context.getContentResolver().query(Messages.getUri(messageId), + MESSAGE_PROJECTION, null, null, null); + return (c != null && c.moveToNext()) ? + fromCursor(c) : null; + } + finally { + if (c != null) + c.close(); + } + + } + +} diff --git a/app/src/main/java/org/kontalk/message/TextComponent.java b/app/src/main/java/org/kontalk/message/TextComponent.java index 893aa02b8..c86490c2a 100644 --- a/app/src/main/java/org/kontalk/message/TextComponent.java +++ b/app/src/main/java/org/kontalk/message/TextComponent.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/message/VCardComponent.java b/app/src/main/java/org/kontalk/message/VCardComponent.java index b36ee6830..e1d54ec07 100644 --- a/app/src/main/java/org/kontalk/message/VCardComponent.java +++ b/app/src/main/java/org/kontalk/message/VCardComponent.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -45,9 +45,10 @@ protected void populateFromCursor(Context context, Cursor cursor) { } public static boolean supportsMimeType(String mime) { - for (int i = 0; i < MIME_TYPES.length; i++) - if (MIME_TYPES[i].equalsIgnoreCase(mime)) + for (String mimeType : MIME_TYPES) { + if (mimeType.equalsIgnoreCase(mime)) return true; + } return false; } diff --git a/app/src/main/java/org/kontalk/position/OsmStaticUrlBuilder.java b/app/src/main/java/org/kontalk/position/OsmStaticUrlBuilder.java new file mode 100644 index 000000000..2005bd16b --- /dev/null +++ b/app/src/main/java/org/kontalk/position/OsmStaticUrlBuilder.java @@ -0,0 +1,69 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.position; + + +/** + * @author Andrea Cappelli + */ +public class OsmStaticUrlBuilder { + + private static final String URL = "http://staticmap.openstreetmap.de/staticmap.php"; + + private String mCenter; + private int mZoom = 15; + private String mSize = "600x300"; + private String mMarker; + + public OsmStaticUrlBuilder setCenter(double lat, double lon) { + mCenter = lat + "," + lon; + return this; + } + + public OsmStaticUrlBuilder setZoom(Integer zoom) { + if (zoom != null) + mZoom = zoom; + return this; + } + + public OsmStaticUrlBuilder setSize(int width, int height) { + mSize = width + "x" + height; + return this; + } + + public OsmStaticUrlBuilder setMarker(double lat, double lon) { + mMarker = lat + "," + lon; + return this; + } + + @Override + public String toString() { + return URL + + "?center=" + + mCenter + + "&zoom=" + + mZoom + + "&size=" + + mSize + + "&markers=" + + mMarker + + ",ol-marker"; + } + +} diff --git a/app/src/main/java/org/kontalk/position/OsmUrlBuilder.java b/app/src/main/java/org/kontalk/position/OsmUrlBuilder.java new file mode 100644 index 000000000..1cd757b22 --- /dev/null +++ b/app/src/main/java/org/kontalk/position/OsmUrlBuilder.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.position; + +import java.util.Locale; + + +/** + * OpenStreetMap URL builder. + * @author Daniele Ricci + */ +public class OsmUrlBuilder { + private static final String URL = "https://www.openstreetmap.org/#map=15/%1$,.6f/%2$,.6f"; + + private OsmUrlBuilder() { + } + + public static String build(double lat, double lon) { + return String.format(Locale.US, URL, lat, lon); + } +} diff --git a/app/src/main/java/org/kontalk/position/RequestDetails.java b/app/src/main/java/org/kontalk/position/RequestDetails.java new file mode 100644 index 000000000..9970fcb0a --- /dev/null +++ b/app/src/main/java/org/kontalk/position/RequestDetails.java @@ -0,0 +1,36 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.position; + +import com.bumptech.glide.load.model.Headers; + + +public class RequestDetails { + public final String url; + public final Headers headers; + + public RequestDetails(String url) { + this(url, Headers.DEFAULT); + } + + public RequestDetails(String url, Headers headers) { + this.url = url; + this.headers = headers; + } +} diff --git a/app/src/main/java/org/kontalk/provider/Keyring.java b/app/src/main/java/org/kontalk/provider/Keyring.java new file mode 100644 index 000000000..0a32553f7 --- /dev/null +++ b/app/src/main/java/org/kontalk/provider/Keyring.java @@ -0,0 +1,358 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.provider; + +import java.io.IOException; +import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import androidx.annotation.VisibleForTesting; +import android.text.TextUtils; + +import org.kontalk.client.EndpointServer; +import org.kontalk.crypto.Coder; +import org.kontalk.crypto.PGP; +import org.kontalk.crypto.PGPCoder; +import org.kontalk.crypto.PersonalKey; + + +/** + * Keyring management. + * @author Daniele Ricci + */ +public class Keyring { + + /** + * Special value used in the fingerprint column so the first key that comes + * in is automatically trusted. + */ + @VisibleForTesting + static final String VALUE_AUTOTRUST = ""; + + private Keyring() { + } + + /** Returns a {@link Coder} instance for encrypting data. */ + public static Coder getEncryptCoder(Context context, EndpointServer server, PersonalKey key, String[] recipients) { + // get recipients public keys from users database + PGPPublicKeyRing[] keys = new PGPPublicKeyRing[recipients.length]; + for (int i = 0; i < recipients.length; i++) { + PGPPublicKeyRing ring = getPublicKey(context, recipients[i], MyUsers.Keys.TRUST_UNKNOWN); + if (ring == null) + throw new IllegalArgumentException("public key not found for user " + recipients[i]); + + keys[i] = ring; + } + + return new PGPCoder(server, key, keys); + } + + /** Returns a {@link Coder} instance for decrypting data. */ + public static Coder getDecryptCoder(Context context, EndpointServer server, PersonalKey key, String sender) { + PGPPublicKeyRing senderKey = getPublicKey(context, sender, MyUsers.Keys.TRUST_IGNORED); + return new PGPCoder(server, key, senderKey); + } + + /** Returns a {@link Coder} instance for verifying data. */ + public static Coder getVerifyCoder(Context context, EndpointServer server, String sender) { + PGPPublicKeyRing senderKey = getPublicKey(context, sender, MyUsers.Keys.TRUST_UNKNOWN); + return new PGPCoder(server, null, senderKey); + } + + /** Adds/updates a public key. */ + public static void setKey(Context context, String jid, byte[] keydata) + throws IOException, PGPException { + setKey(context, jid, keydata, -1); + } + + /** Adds/updates a public key. */ + public static void setKey(Context context, String jid, byte[] keydata, int trustLevel) + throws IOException, PGPException { + PGPPublicKey pk = PGP.getMasterKey(keydata); + String fingerprint = PGP.getFingerprint(pk); + Date date = pk.getCreationTime(); + + int autoTrustedLevel = getAutoTrustedLevel(context, jid); + + ContentValues values = new ContentValues(3); + values.put(MyUsers.Keys.PUBLIC_KEY, keydata); + values.put(MyUsers.Keys.TIMESTAMP, date.getTime()); + if (trustLevel >= 0) { + values.put(MyUsers.Keys.TRUST_LEVEL, trustLevel); + } + else if (autoTrustedLevel >= 0) { + values.put(MyUsers.Keys.TRUST_LEVEL, autoTrustedLevel); + } + context.getContentResolver().insert(MyUsers.Keys.getUri(jid, fingerprint), values); + + if (autoTrustedLevel >= 0) { + // delete the autotrust entry + context.getContentResolver().delete(MyUsers.Keys.getUri(jid, VALUE_AUTOTRUST), null, null); + } + } + + /** Updates the fingerprint and the date (for fingerprint in presence). */ + public static void setKey(Context context, String jid, String fingerprint, Date date) { + if (fingerprint == null) + throw new NullPointerException("fingerprint"); + + setKey(context, jid, fingerprint, date, -1); + } + + /** Updates the fingerprint and the date (for fingerprint in presence). */ + public static void setKey(Context context, String jid, String fingerprint, Date date, int trustLevel) { + if (fingerprint == null) + throw new NullPointerException("fingerprint"); + + ContentValues values = new ContentValues(2); + values.put(MyUsers.Keys.TIMESTAMP, date.getTime()); + if (trustLevel >= 0) + values.put(MyUsers.Keys.TRUST_LEVEL, trustLevel); + context.getContentResolver().insert(MyUsers.Keys.getUri(jid, fingerprint) + // since we are handling data from a presence, insert only if it doesn't exist + .buildUpon().appendQueryParameter(MyUsers.Keys.INSERT_ONLY, "true").build(), values); + } + + /** Sets the trust level for the given key. */ + public static void setTrustLevel(Context context, String jid, String fingerprint, int trustLevel) { + if (fingerprint == null) + throw new NullPointerException("fingerprint"); + + ContentValues values = new ContentValues(2); + values.put(MyUsers.Keys.TRUST_LEVEL, trustLevel); + values.put(MyUsers.Keys.MANUAL_TRUST, true); + context.getContentResolver().insert(MyUsers.Keys.getUri(jid, fingerprint), values); + } + + public static void setAutoTrustLevel(Context context, String jid, int trustLevel) { + ContentValues values = new ContentValues(1); + values.put(MyUsers.Keys.TRUST_LEVEL, trustLevel); + context.getContentResolver().insert(MyUsers.Keys.getUri(jid, VALUE_AUTOTRUST), values); + } + + /** + * Retrieves the latest fingerprint with the minimum given trust level. + * @param trustLevel the minimum trust level to consider + */ + public static String getFingerprint(Context context, String jid, int trustLevel) { + String fingerprint = null; + + Cursor c = queryLatestWithMinimumTrustLevel(context, jid, trustLevel, MyUsers.Keys.FINGERPRINT); + if (c.moveToFirst() && !c.isNull(0)) + fingerprint = c.getString(0); + c.close(); + + return fingerprint; + } + + /** + * Retrieves the latest public key with the minimum given trust level. + * @param trustLevel the minimum trust level to consider + */ + public static PGPPublicKeyRing getPublicKey(Context context, String jid, int trustLevel) { + TrustedPublicKeyData key = getPublicKeyData(context, jid, trustLevel); + + try { + return PGP.readPublicKeyring(key.keyData); + } + catch (Exception e) { + // ignored + } + + return null; + } + + /** + * Retrieves the latest public key with the minimum given trust level. + * @param trustLevel the minimum trust level to consider + */ + public static TrustedPublicKeyData getPublicKeyData(Context context, String jid, int trustLevel) { + TrustedPublicKeyData data = null; + + Cursor c = queryLatestWithMinimumTrustLevel(context, jid, trustLevel, + MyUsers.Keys.PUBLIC_KEY, MyUsers.Keys.TRUST_LEVEL, MyUsers.Keys.MANUAL_TRUST); + if (c.moveToFirst()) { + byte[] keydata = c.getBlob(0); + int trustStatus = c.getInt(1); + boolean manualTrust = c.getInt(2) != 0; + if (keydata != null) + data = new TrustedPublicKeyData(keydata, trustStatus, manualTrust); + } + + c.close(); + + return data; + } + + /** + * Returns true if the contact is in advanced mode, i.e. the user has + * manually verified one of the user's key. + * This is called advanced mode and will enable stricter key checks. + */ + public static boolean isAdvancedMode(Context context, String jid) { + boolean result; + Cursor c = context.getContentResolver().query(MyUsers.Keys.getUri(jid), + new String[] { MyUsers.Keys.TRUST_LEVEL } , + MyUsers.Keys.FINGERPRINT + " <> ? AND " + + MyUsers.Keys.MANUAL_TRUST + " <> 0", + new String[] { VALUE_AUTOTRUST }, + null); + result = c.moveToFirst(); + c.close(); + return result; + } + + private static Cursor queryLatestWithMinimumTrustLevel(Context context, String jid, int trustLevel, String... columns) { + return context.getContentResolver().query(MyUsers.Keys.getUri(jid), columns, + MyUsers.Keys.TRUST_LEVEL + " >= " + trustLevel + " AND "+ + MyUsers.Keys.FINGERPRINT + " <> ?", + new String[] { VALUE_AUTOTRUST }, MyUsers.Keys.TIMESTAMP + " DESC"); + } + + private static int getAutoTrustedLevel(Context context, String jid) { + int result = -1; + Cursor c = context.getContentResolver().query(MyUsers.Keys.getUri(jid), + new String[] { MyUsers.Keys.TRUST_LEVEL }, + MyUsers.Keys.FINGERPRINT + " = ?", + new String[] { VALUE_AUTOTRUST }, null); + if (c != null) { + if (c.moveToNext()) { + result = c.getInt(0); + } + c.close(); + } + return result; + } + + /** Sets the trusted keys, deleting all previous entries. */ + public static int setTrustedKeys(Context context, Map trustedKeys) { + ContentValues[] values = new ContentValues[trustedKeys.size()]; + Iterator> entries = trustedKeys.entrySet().iterator(); + for (int i = 0; i < values.length; i++) { + Map.Entry e = entries.next(); + values[i] = new ContentValues(2); + values[i].put(MyUsers.Keys.JID, e.getKey()); + values[i].put(MyUsers.Keys.FINGERPRINT, e.getValue().fingerprint); + values[i].put(MyUsers.Keys.TRUST_LEVEL, e.getValue().trustLevel); + } + return context.getContentResolver().bulkInsert(MyUsers.Keys.CONTENT_URI, values); + } + + /** Returns a JID-fingerprint map of trusted keys. */ + public static Map getTrustedKeys(Context context) { + Cursor c = context.getContentResolver().query(MyUsers.Keys.CONTENT_URI, new String[] { + MyUsers.Keys.JID, + MyUsers.Keys.FINGERPRINT, + MyUsers.Keys.TRUST_LEVEL, + }, + MyUsers.Keys.FINGERPRINT + " <> ?", new String[] { VALUE_AUTOTRUST }, + null); + + Map list = new HashMap<>(c.getCount()); + while (c.moveToNext()) { + String fpr = c.getString(1); + if (fpr != null) + list.put(c.getString(0), new TrustedFingerprint(fpr, c.getInt(2))); + } + + c.close(); + return list; + } + + /** Converts a raw-string trusted fingerprint map to a {@link TrustedFingerprint} map. */ + public static Map fromTrustedFingerprintMap(Map props) { + Map keys = new HashMap<>(props.size()); + for (Map.Entry e : props.entrySet()) { + TrustedFingerprint fingerprint = TrustedFingerprint + .fromString((String) e.getValue()); + if (fingerprint != null) { + keys.put((String) e.getKey(), fingerprint); + } + } + return keys; + } + + /** Converts a {@link TrustedFingerprint} map to a raw-string trusted fingerprint map. */ + public static Map toTrustedFingerprintMap(Map props) { + Map keys = new HashMap<>(props.size()); + for (Map.Entry e : props.entrySet()) { + TrustedFingerprint fingerprint = e.getValue(); + if (fingerprint != null) { + keys.put(e.getKey(), e.toString()); + } + } + return keys; + } + + public static final class TrustedFingerprint { + public final String fingerprint; + public final int trustLevel; + + TrustedFingerprint(String fingerprint, int trustLevel) { + this.fingerprint = fingerprint; + this.trustLevel = trustLevel; + } + + @Override + public String toString() { + return fingerprint + "|" + trustLevel; + } + + public static TrustedFingerprint fromString(String value) { + if (!TextUtils.isEmpty(value)) { + String[] parsed = value.split("\\|"); + String fingerprint = parsed[0]; + int trustLevel = MyUsers.Keys.TRUST_UNKNOWN; + if (parsed.length > 1) { + String _trustLevel = parsed[1]; + try { + trustLevel = Integer.parseInt(_trustLevel); + } + catch (NumberFormatException ignored) { + } + } + + return new TrustedFingerprint(fingerprint, trustLevel); + } + return null; + } + } + + public static final class TrustedPublicKeyData { + public final byte[] keyData; + public final int trustLevel; + public final boolean manualTrust; + + private TrustedPublicKeyData(byte[] keyData, int trustLevel, boolean manualTrust) { + this.keyData = keyData; + this.trustLevel = trustLevel; + this.manualTrust = manualTrust; + } + } + +} diff --git a/app/src/main/java/org/kontalk/provider/KontalkGroupCommands.java b/app/src/main/java/org/kontalk/provider/KontalkGroupCommands.java new file mode 100644 index 000000000..6ee03811a --- /dev/null +++ b/app/src/main/java/org/kontalk/provider/KontalkGroupCommands.java @@ -0,0 +1,160 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.provider; + +import org.jxmpp.util.XmppStringUtils; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +import org.kontalk.crypto.Coder; +import org.kontalk.message.GroupCommandComponent; + + +/** + * Utility methods for Kontalk group chat commands stored as messages. + * @author Daniele Ricci + */ +public class KontalkGroupCommands { + + private KontalkGroupCommands() { + } + + public static String createGroupJid(String groupId, String groupOwner) { + return XmppStringUtils.completeJidFrom(groupId, groupOwner); + } + + public static Uri createGroup(Context context, long threadId, + String groupJid, String[] members, String msgId, boolean encrypted) { + ContentValues values = new ContentValues(); + values.put(MyMessages.Messages.THREAD_ID, threadId); + values.put(MyMessages.Messages.MESSAGE_ID, msgId); + values.put(MyMessages.Messages.PEER, groupJid); + values.put(MyMessages.Messages.BODY_MIME, GroupCommandComponent.MIME_TYPE); + values.put(MyMessages.Messages.BODY_CONTENT, GroupCommandComponent.getCreateBodyContent(members).getBytes()); + values.put(MyMessages.Messages.BODY_LENGTH, 0); + values.put(MyMessages.Messages.UNREAD, false); + values.put(MyMessages.Messages.DIRECTION, MyMessages.Messages.DIRECTION_OUT); + values.put(MyMessages.Messages.TIMESTAMP, System.currentTimeMillis()); + values.put(MyMessages.Messages.STATUS, MyMessages.Messages.STATUS_SENDING); + // of course outgoing messages are not encrypted in database + values.put(MyMessages.Messages.ENCRYPTED, false); + values.put(MyMessages.Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + return context.getContentResolver().insert(MyMessages.Messages.CONTENT_URI, values); + } + + public static Uri addGroupMembers(Context context, long threadId, String groupJid, String[] members, String msgId, boolean encrypted) { + ContentValues values = new ContentValues(); + values.put(MyMessages.Messages.THREAD_ID, threadId); + values.put(MyMessages.Messages.MESSAGE_ID, msgId); + values.put(MyMessages.Messages.PEER, groupJid); + values.put(MyMessages.Messages.BODY_MIME, GroupCommandComponent.MIME_TYPE); + values.put(MyMessages.Messages.BODY_CONTENT, GroupCommandComponent.getAddMembersBodyContent(members).getBytes()); + values.put(MyMessages.Messages.BODY_LENGTH, 0); + values.put(MyMessages.Messages.UNREAD, false); + values.put(MyMessages.Messages.DIRECTION, MyMessages.Messages.DIRECTION_OUT); + values.put(MyMessages.Messages.TIMESTAMP, System.currentTimeMillis()); + values.put(MyMessages.Messages.STATUS, MyMessages.Messages.STATUS_SENDING); + // of course outgoing messages are not encrypted in database + values.put(MyMessages.Messages.ENCRYPTED, false); + values.put(MyMessages.Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + return context.getContentResolver().insert(MyMessages.Messages.CONTENT_URI, values); + } + + public static Uri removeGroupMembers(Context context, long threadId, String groupJid, String[] members, String msgId, boolean encrypted) { + ContentValues values = new ContentValues(); + values.put(MyMessages.Messages.THREAD_ID, threadId); + values.put(MyMessages.Messages.MESSAGE_ID, msgId); + values.put(MyMessages.Messages.PEER, groupJid); + values.put(MyMessages.Messages.BODY_MIME, GroupCommandComponent.MIME_TYPE); + values.put(MyMessages.Messages.BODY_CONTENT, GroupCommandComponent.getRemoveMembersBodyContent(members).getBytes()); + values.put(MyMessages.Messages.BODY_LENGTH, 0); + values.put(MyMessages.Messages.UNREAD, false); + values.put(MyMessages.Messages.DIRECTION, MyMessages.Messages.DIRECTION_OUT); + values.put(MyMessages.Messages.TIMESTAMP, System.currentTimeMillis()); + values.put(MyMessages.Messages.STATUS, MyMessages.Messages.STATUS_SENDING); + // of course outgoing messages are not encrypted in database + values.put(MyMessages.Messages.ENCRYPTED, false); + values.put(MyMessages.Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + return context.getContentResolver().insert(MyMessages.Messages.CONTENT_URI, values); + } + + public static Uri setGroupSubject(Context context, long threadId, String groupJid, String subject, String msgId, boolean encrypted, boolean fakeSend) { + ContentValues values = new ContentValues(); + values.put(MyMessages.Messages.THREAD_ID, threadId); + values.put(MyMessages.Messages.MESSAGE_ID, msgId); + values.put(MyMessages.Messages.PEER, groupJid); + values.put(MyMessages.Messages.BODY_MIME, GroupCommandComponent.MIME_TYPE); + values.put(MyMessages.Messages.BODY_CONTENT, GroupCommandComponent.getSetSubjectCommandBodyContent(subject).getBytes()); + values.put(MyMessages.Messages.BODY_LENGTH, 0); + values.put(MyMessages.Messages.UNREAD, false); + values.put(MyMessages.Messages.DIRECTION, MyMessages.Messages.DIRECTION_OUT); + values.put(MyMessages.Messages.TIMESTAMP, System.currentTimeMillis()); + values.put(MyMessages.Messages.STATUS, fakeSend ? MyMessages.Messages.STATUS_SENT : MyMessages.Messages.STATUS_SENDING); + // of course outgoing messages are not encrypted in database + values.put(MyMessages.Messages.ENCRYPTED, false); + values.put(MyMessages.Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + return context.getContentResolver().insert(MyMessages.Messages.CONTENT_URI, values); + } + + public static Uri leaveGroup(Context context, long threadId, String groupJid, String msgId, boolean encrypted, boolean fakeSend) { + ContentValues values = new ContentValues(); + values.put(MyMessages.Messages.THREAD_ID, threadId); + values.put(MyMessages.Messages.MESSAGE_ID, msgId); + values.put(MyMessages.Messages.PEER, groupJid); + values.put(MyMessages.Messages.BODY_MIME, GroupCommandComponent.MIME_TYPE); + values.put(MyMessages.Messages.BODY_CONTENT, GroupCommandComponent.getLeaveCommandBodyContent().getBytes()); + values.put(MyMessages.Messages.BODY_LENGTH, 0); + values.put(MyMessages.Messages.UNREAD, false); + values.put(MyMessages.Messages.DIRECTION, MyMessages.Messages.DIRECTION_OUT); + values.put(MyMessages.Messages.TIMESTAMP, System.currentTimeMillis()); + values.put(MyMessages.Messages.STATUS, fakeSend ? MyMessages.Messages.STATUS_SENT : MyMessages.Messages.STATUS_SENDING); + // of course outgoing messages are not encrypted in database + values.put(MyMessages.Messages.ENCRYPTED, false); + values.put(MyMessages.Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + return context.getContentResolver().insert(MyMessages.Messages.CONTENT_URI, values); + } + + /** Returns true if the create group command for the given thread has been sent. */ + public static boolean isGroupCreatedSent(Context context, long threadId) { + Cursor c = context.getContentResolver().query(MyMessages.Messages.CONTENT_URI, + new String[] { MyMessages.Messages._ID }, + MyMessages.Messages.THREAD_ID + "=" + threadId + " AND " + + MyMessages.Messages.BODY_MIME + "=? AND " + + "(" + MyMessages.Messages.BODY_CONTENT + " LIKE ? OR " + + MyMessages.Messages.BODY_CONTENT + " LIKE ?) AND " + + MyMessages.Messages.STATUS + " IN (" + + MyMessages.Messages.STATUS_SENT + ", " + MyMessages.Messages.STATUS_RECEIVED + "," + + MyMessages.Messages.STATUS_INCOMING + ", " + MyMessages.Messages.STATUS_CONFIRMED + ")", + new String[] { + GroupCommandComponent.MIME_TYPE, + GroupCommandComponent.COMMAND_CREATE + ":%", + GroupCommandComponent.COMMAND_ADD + ":%" + }, + null); + try { + return c.moveToNext(); + } + finally { + c.close(); + } + } +} diff --git a/app/src/main/java/org/kontalk/provider/MessagesProvider.java b/app/src/main/java/org/kontalk/provider/MessagesProvider.java index bd1e8c9a4..31743bc7b 100644 --- a/app/src/main/java/org/kontalk/provider/MessagesProvider.java +++ b/app/src/main/java/org/kontalk/provider/MessagesProvider.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,23 +18,12 @@ package org.kontalk.provider; +import java.io.File; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Random; -import org.kontalk.BuildConfig; -import org.kontalk.client.EndpointServer; -import org.kontalk.client.ServerList; -import org.kontalk.crypto.Coder; -import org.kontalk.message.CompositeMessage; -import org.kontalk.provider.MyMessages.CommonColumns; -import org.kontalk.provider.MyMessages.Messages; -import org.kontalk.provider.MyMessages.Threads; -import org.kontalk.provider.MyMessages.Messages.Fulltext; -import org.kontalk.provider.MyMessages.Threads.Conversations; -import org.kontalk.service.ServerListUpdater; - -import android.annotation.TargetApi; import android.content.ContentProvider; import android.content.ContentResolver; import android.content.ContentUris; @@ -44,25 +33,52 @@ import android.database.Cursor; import android.database.DatabaseUtils; import android.database.SQLException; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.database.sqlite.SQLiteQueryBuilder; +import android.database.sqlite.SQLiteConstraintException; +import android.database.sqlite.SQLiteDatabaseLockedException; +import android.database.sqlite.SQLiteException; import android.net.Uri; -import android.util.Log; +import android.provider.BaseColumns; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import io.requery.android.database.sqlite.SQLiteDatabase; +import io.requery.android.database.sqlite.SQLiteOpenHelper; + +import org.kontalk.BuildConfig; +import org.kontalk.Log; +import org.kontalk.message.GroupCommandComponent; +import org.kontalk.message.LocationComponent; +import org.kontalk.message.TextComponent; +import org.kontalk.provider.MyMessages.Groups; +import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.provider.MyMessages.Messages.Fulltext; +import org.kontalk.provider.MyMessages.Threads; +import org.kontalk.provider.MyMessages.Threads.Conversations; +import org.kontalk.util.DataUtils; /** * The message storage provider. + * TODO this class needs serious refactoring. There are many tricks and workarounds that should be reworked. * @author Daniele Ricci */ public class MessagesProvider extends ContentProvider { - private static final String TAG = MessagesProvider.class.getSimpleName(); + static final String TAG = MessagesProvider.class.getSimpleName(); public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".messages"; private static final String TABLE_MESSAGES = "messages"; + private static final String TABLE_MESSAGES_GROUPS = "messages_groups"; private static final String TABLE_FULLTEXT = "fulltext"; private static final String TABLE_THREADS = "threads"; + private static final String TABLE_GROUPS = "groups"; + private static final String TABLE_GROUP_MEMBERS = "group_members"; + + private static final String TABLE_THREADS_GROUPS = TABLE_THREADS + + " LEFT OUTER JOIN " + TABLE_GROUPS + " ON " + + TABLE_THREADS + "." + Threads._ID + "=" + + TABLE_GROUPS + "." + Groups.THREAD_ID; private static final int THREADS = 1; private static final int THREADS_ID = 2; @@ -72,19 +88,32 @@ public class MessagesProvider extends ContentProvider { private static final int MESSAGES_SERVERID = 6; private static final int CONVERSATIONS_ID = 7; private static final int CONVERSATIONS_ALL_ID = 8; - private static final int FULLTEXT_ID = 9; - private static final int REQUESTS = 10; + private static final int GROUPS = 9; + private static final int GROUPS_ID = 10; + private static final int GROUPS_MEMBERS = 11; + private static final int GROUPS_MEMBERS_ID = 12; + private static final int FULLTEXT_ID = 13; + private static final int REQUESTS = 14; + private static final int IMPORT_LOCK = 15; + private static final int IMPORT_UNLOCK = 16; + private static final int RELOAD = 17; private DatabaseHelper dbHelper; private static final UriMatcher sUriMatcher; private static HashMap messagesProjectionMap; private static HashMap threadsProjectionMap; private static HashMap fulltextProjectionMap; + private static HashMap groupsMembersProjectionMap; + private static HashMap groupsProjectionMap; - private static class DatabaseHelper extends SQLiteOpenHelper { - private static final int DATABASE_VERSION = 8; - private static final String DATABASE_NAME = "messages.db"; + @VisibleForTesting + static class DatabaseHelper extends SQLiteOpenHelper { + @VisibleForTesting + static final int DATABASE_VERSION = 20; + @VisibleForTesting + static final String DATABASE_NAME = "messages.db"; + // TODO UNIQUE index on msg_id + direction private static final String _SCHEMA_MESSAGES = "(" + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + "thread_id INTEGER NOT NULL, " + @@ -116,7 +145,11 @@ private static class DatabaseHelper extends SQLiteOpenHelper { "att_encrypted INTEGER NOT NULL DEFAULT 0," + "att_security_flags INTEGER NOT NULL DEFAULT 0," + - // TODO geo_lat, geo_lon, ... + // location data + "geo_lat NUMBER," + + "geo_lon NUMBER," + + "geo_text TEXT," + + "geo_street TEXT," + // whole content encrypted "encrypted INTEGER NOT NULL DEFAULT 0, " + @@ -124,7 +157,10 @@ private static class DatabaseHelper extends SQLiteOpenHelper { "security_flags INTEGER NOT NULL DEFAULT 0," + // timestamp declared by server for incoming messages // timestamp of message accepted by server for outgoing messages - "server_timestamp INTEGER" + + "server_timestamp INTEGER," + + + // reference to message id we are replying to + "in_reply_to INTEGER" + ")"; /** This table will contain all the messages .*/ @@ -148,17 +184,59 @@ private static class DatabaseHelper extends SQLiteOpenHelper { "status INTEGER," + "encrypted INTEGER NOT NULL DEFAULT 0, " + "draft TEXT," + - "request_status INTEGER NOT NULL DEFAULT 0" + + "request_status INTEGER NOT NULL DEFAULT 0," + + "sticky INTEGER NOT NULL DEFAULT 0," + + "encryption INTEGER NOT NULL DEFAULT 1," + + "archived INTEGER NOT NULL DEFAULT 0" + ")"; /** This table will contain the latest message from each conversation. */ private static final String SCHEMA_THREADS = "CREATE TABLE " + TABLE_THREADS + " " + _SCHEMA_THREADS; + private static final String _SCHEMA_GROUPS = "(" + + "group_jid TEXT NOT NULL PRIMARY KEY, " + + "thread_id INTEGER NOT NULL," + + "group_type TEXT NOT NULL," + + "subject TEXT," + + "membership INTEGER NOT NULL DEFAULT 1" + + ")"; + + /** This table will contain the groups definitions.*/ + private static final String SCHEMA_GROUPS = + "CREATE TABLE " + TABLE_GROUPS + " " + _SCHEMA_GROUPS; + + private static final String _SCHEMA_GROUP_MEMBERS = "(" + + "group_jid TEXT NOT NULL, " + + "group_peer TEXT NOT NULL, " + + "pending INTEGER NOT NULL DEFAULT 0," + + "PRIMARY KEY (group_jid, group_peer)" + + ")"; + + /** This table will contain the groups participants .*/ + private static final String SCHEMA_GROUPS_MEMBERS = + "CREATE TABLE " + TABLE_GROUP_MEMBERS + " " + _SCHEMA_GROUP_MEMBERS; + + /** A view to link messages and groups. */ + private static final String SCHEMA_MESSAGES_GROUPS = + "CREATE VIEW " + TABLE_MESSAGES_GROUPS + " AS " + + "SELECT " + TABLE_MESSAGES + ".*," + + TABLE_GROUPS + "." + Groups.GROUP_JID + "," + + TABLE_GROUPS + "." + Groups.SUBJECT + "," + + TABLE_GROUPS + "." + Groups.GROUP_TYPE + "," + + TABLE_GROUPS + "." + Groups.MEMBERSHIP + + " FROM " + TABLE_MESSAGES + " LEFT JOIN " + TABLE_THREADS + + " ON " + TABLE_MESSAGES + "." + Messages.THREAD_ID + "=" + TABLE_THREADS + "." + Threads._ID + + " LEFT OUTER JOIN " + TABLE_GROUPS + " ON " + + TABLE_THREADS + "." + Threads._ID + "=" + + TABLE_GROUPS + "." + Groups.THREAD_ID; + /** This table will contain every text message to speed-up full text searches. */ private static final String SCHEMA_FULLTEXT = "CREATE VIRTUAL TABLE " + TABLE_FULLTEXT + " USING fts3 (" + + "msg_id INTEGER PRIMARY KEY," + "thread_id INTEGER NOT NULL, " + + "timestamp INTEGER NOT NULL," + "content TEXT" + ")"; @@ -170,6 +248,10 @@ private static class DatabaseHelper extends SQLiteOpenHelper { "CREATE INDEX IF NOT EXISTS timestamp_message ON " + TABLE_MESSAGES + " (timestamp)"; + private static final String SCHEMA_MESSAGES_THREAD_ID_IDX = + "CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON " + TABLE_MESSAGES + + "(" + Messages.THREAD_ID + ")"; + /** Updates the thread messages count. */ private static final String UPDATE_MESSAGES_COUNT_NEW = "UPDATE " + TABLE_THREADS + " SET count = (" + @@ -180,25 +262,17 @@ private static class DatabaseHelper extends SQLiteOpenHelper { "SELECT COUNT(_id) FROM " + TABLE_MESSAGES + " WHERE thread_id = old.thread_id" + ") WHERE _id = old.thread_id"; - /** Updates the thread unread count. */ + /** Updates the thread unread/new count. */ private static final String UPDATE_UNREAD_COUNT_NEW = - "UPDATE " + TABLE_THREADS + " SET unread = (" + - "SELECT COUNT(_id) FROM " + TABLE_MESSAGES + " WHERE thread_id = new.thread_id " + - "AND unread <> 0) WHERE _id = new.thread_id"; + "UPDATE " + TABLE_THREADS + " SET " + + "unread = (SELECT COUNT(_id) FROM " + TABLE_MESSAGES + " WHERE thread_id = new.thread_id AND unread <> 0), " + + "\"new\" = (SELECT COUNT(_id) FROM " + TABLE_MESSAGES + " WHERE thread_id = new.thread_id AND \"new\" <> 0) " + + "WHERE _id = new.thread_id"; private static final String UPDATE_UNREAD_COUNT_OLD = - "UPDATE " + TABLE_THREADS + " SET unread = (" + - "SELECT COUNT(_id) FROM " + TABLE_MESSAGES + " WHERE thread_id = old.thread_id " + - "AND unread <> 0) WHERE _id = old.thread_id"; - - /** Updates the thread new count. */ - private static final String UPDATE_NEW_COUNT_NEW = - "UPDATE " + TABLE_THREADS + " SET \"new\" = (" + - "SELECT COUNT(_id) FROM " + TABLE_MESSAGES + " WHERE thread_id = new.thread_id " + - "AND \"new\" <> 0) WHERE _id = new.thread_id"; - private static final String UPDATE_NEW_COUNT_OLD = - "UPDATE " + TABLE_THREADS + " SET \"new\" = (" + - "SELECT COUNT(_id) FROM " + TABLE_MESSAGES + " WHERE thread_id = old.thread_id " + - "AND \"new\" <> 0) WHERE _id = old.thread_id"; + "UPDATE " + TABLE_THREADS + " SET " + + "unread = (SELECT COUNT(_id) FROM " + TABLE_MESSAGES + " WHERE thread_id = old.thread_id AND unread <> 0), " + + "\"new\" = (SELECT COUNT(_id) FROM " + TABLE_MESSAGES + " WHERE thread_id = old.thread_id AND \"new\" <> 0) " + + "WHERE _id = old.thread_id"; /** Updates the thread status reflected by the latest message. */ /* @@ -218,20 +292,20 @@ private static class DatabaseHelper extends SQLiteOpenHelper { " BEGIN " + UPDATE_MESSAGES_COUNT_NEW + ";" + UPDATE_UNREAD_COUNT_NEW + ";" + - UPDATE_NEW_COUNT_NEW + ";" + UPDATE_STATUS_NEW + ";" + "END"; /** This trigger will update the threads table counters on UPDATE. */ private static final String TRIGGER_THREADS_UPDATE_COUNT = - "CREATE TRIGGER update_thread_on_update AFTER UPDATE ON " + TABLE_MESSAGES + + "CREATE TRIGGER update_thread_on_update AFTER UPDATE OF " + + Messages.STATUS + " ON " + TABLE_MESSAGES + " BEGIN " + - UPDATE_MESSAGES_COUNT_NEW + ";" + - UPDATE_UNREAD_COUNT_NEW + ";" + - UPDATE_NEW_COUNT_NEW + ";" + UPDATE_STATUS_NEW + ";" + "END"; + /** Delete group members linked to thread. */ + private static final String DELETE_GROUP_MEMBERS = "DELETE FROM " + TABLE_GROUP_MEMBERS + " WHERE " + + Groups.GROUP_JID + "=old." + Groups.GROUP_JID; /** This trigger will update the threads table counters on DELETE. */ private static final String TRIGGER_THREADS_DELETE_COUNT = @@ -239,91 +313,264 @@ private static class DatabaseHelper extends SQLiteOpenHelper { " BEGIN " + UPDATE_MESSAGES_COUNT_OLD + ";" + UPDATE_UNREAD_COUNT_OLD + ";" + - UPDATE_NEW_COUNT_OLD + ";" + // do not call this here -- UPDATE_STATUS_OLD + ";" + "END"; - private static final String[] SCHEMA_UPGRADE_V4 = { - // create temporary messages tables without msg_id UNIQUE constraint - "CREATE TABLE " + TABLE_MESSAGES + "_new " + _SCHEMA_MESSAGES, - // create temporary threads tables without msg_id UNIQUE constraint - "CREATE TABLE " + TABLE_THREADS + "_new " + _SCHEMA_THREADS, - // copy contents of messages table - "INSERT INTO " + TABLE_MESSAGES + "_new SELECT " + - "_id, thread_id, msg_id, SUBSTR(peer, 1, ?) || '@' || ?, direction, unread, 0, timestamp, status_changed, status, 'text/plain', " + - "CASE WHEN mime <> 'text/plain' THEN NULL ELSE content END, "+ - "CASE WHEN mime <> 'text/plain' THEN 0 ELSE length(content) END, " + - "CASE WHEN mime <> 'text/plain' THEN mime ELSE NULL END, preview_path, fetch_url, local_uri, length, 0, 0, 0, encrypted, " + - "CASE WHEN encrypt_key IS NOT NULL THEN " + Coder.SECURITY_LEGACY_ENCRYPTED + " ELSE " + Coder.SECURITY_CLEARTEXT + " END, "+ - "strftime('%s', server_timestamp)*1000" + - " FROM " + TABLE_MESSAGES + " WHERE encrypted = 0", - // copy contents of threads table - "INSERT INTO " + TABLE_THREADS + "_new SELECT " + - "_id, msg_id, SUBSTR(peer, 1, ?) || '@' || ?, direction, count, unread, 0, 'text/plain', content, timestamp, status_changed, status, 0, draft, 0" + - " FROM " + TABLE_THREADS, - // drop table messages - "DROP TABLE " + TABLE_MESSAGES, - // drop table threads - "DROP TABLE " + TABLE_THREADS, - // rename messages_new to messages - "ALTER TABLE " + TABLE_MESSAGES + "_new RENAME TO " + TABLE_MESSAGES, - // rename threads_new to threads - "ALTER TABLE " + TABLE_THREADS + "_new RENAME TO " + TABLE_THREADS, - // unique message index - SCHEMA_MESSAGES_INDEX, - // timestamp message index (for sorting) - SCHEMA_MESSAGES_TIMESTAMP_IDX, - // triggers - TRIGGER_THREADS_INSERT_COUNT, - TRIGGER_THREADS_UPDATE_COUNT, - TRIGGER_THREADS_DELETE_COUNT + /** This trigger will delete group members when a group is deleted. */ + private static final String TRIGGER_GROUPS_DELETE_MEMBERS = + "CREATE TRIGGER delete_groups_on_delete AFTER DELETE ON " + TABLE_GROUPS + + " BEGIN " + + DELETE_GROUP_MEMBERS + ";" + + "END"; + + // -- schema upgrades -- + // the number in the constant name is the version we are upgrading *from* + + private static final String[] SCHEMA_UPGRADE_V8 = { + "CREATE TABLE groups (" + + "group_jid TEXT NOT NULL PRIMARY KEY, " + + "thread_id INTEGER NOT NULL," + + "group_type TEXT NOT NULL," + + "subject TEXT" + + ")", + "CREATE TABLE group_members (" + + "group_jid TEXT NOT NULL, " + + "group_peer TEXT NOT NULL, " + + "pending INTEGER NOT NULL DEFAULT 0," + + "PRIMARY KEY (group_jid, group_peer)" + + ")", + "CREATE VIEW messages_groups AS " + + "SELECT messages.*," + + "groups.group_jid," + + "groups.subject," + + "groups.group_type" + + " FROM messages LEFT JOIN threads" + + " ON messages.thread_id=threads._id" + + " LEFT OUTER JOIN groups ON " + + "threads._id=groups.thread_id", + "CREATE TRIGGER delete_groups_on_delete AFTER DELETE ON groups" + + " BEGIN " + + "DELETE FROM group_members WHERE group_jid=old.group_jid;" + + "END", + }; + + private static final String[] SCHEMA_UPGRADE_V9 = { + "ALTER TABLE groups ADD COLUMN membership INTEGER NOT NULL DEFAULT 1", + "DROP VIEW messages_groups", + "CREATE VIEW messages_groups AS " + + "SELECT messages.*," + + "groups.group_jid," + + "groups.subject," + + "groups.group_type," + + "groups.membership" + + " FROM messages LEFT JOIN threads" + + " ON messages.thread_id=threads._id" + + " LEFT OUTER JOIN groups ON " + + "threads._id=groups.thread_id", + }; + + private static final String[] SCHEMA_UPGRADE_V10 = { + "ALTER TABLE threads ADD COLUMN sticky INTEGER NOT NULL DEFAULT 0", + }; + + private static final String SCHEMA_UPGRADE_V11 = + "ALTER TABLE threads ADD COLUMN encryption INTEGER NOT NULL DEFAULT 1"; + + private static final String[] SCHEMA_UPGRADE_V12 = { + "ALTER TABLE messages ADD COLUMN geo_lat NUMBER", + "ALTER TABLE messages ADD COLUMN geo_lon NUMBER", + "ALTER TABLE messages ADD COLUMN geo_text TEXT", + "ALTER TABLE messages ADD COLUMN geo_street TEXT", + }; + + private static final String[] SCHEMA_UPGRADE_V13 = { + "DELETE FROM messages WHERE thread_id < 0", + }; + + private static final String[] SCHEMA_UPGRADE_V14 = { + "CREATE INDEX idx_messages_thread_id ON messages(thread_id)", + }; + + private static final String[] SCHEMA_UPGRADE_V15 = { + "ALTER TABLE messages ADD COLUMN in_reply_to INTEGER", }; - private Context mContext; + private static final String[] SCHEMA_UPGRADE_V16 = { + "DROP TRIGGER IF EXISTS update_thread_on_insert", + "CREATE TRIGGER update_thread_on_insert AFTER INSERT ON messages" + + " BEGIN " + + "UPDATE threads SET count = (" + + "SELECT COUNT(_id) FROM messages WHERE thread_id = new.thread_id" + + ") WHERE _id = new.thread_id;" + + "UPDATE threads SET " + + "unread = (SELECT COUNT(_id) FROM messages WHERE thread_id = new.thread_id AND unread <> 0), " + + "\"new\" = (SELECT COUNT(_id) FROM messages WHERE thread_id = new.thread_id AND \"new\" <> 0) " + + "WHERE _id = new.thread_id;" + + "UPDATE threads SET status = (" + + "SELECT status FROM messages WHERE thread_id = new.thread_id ORDER BY timestamp DESC LIMIT 1)" + + " WHERE _id = new.thread_id;" + + "END", + "DROP TRIGGER IF EXISTS update_thread_on_update", + "CREATE TRIGGER update_thread_on_update AFTER UPDATE OF " + + "status ON messages" + + " BEGIN " + + "UPDATE threads SET status = (" + + "SELECT status FROM messages WHERE thread_id = new.thread_id ORDER BY timestamp DESC LIMIT 1)" + + " WHERE _id = new.thread_id;" + + "END", + "DROP TRIGGER IF EXISTS update_thread_on_delete", + "CREATE TRIGGER update_thread_on_delete AFTER DELETE ON messages" + + " BEGIN " + + "UPDATE threads SET count = (" + + "SELECT COUNT(_id) FROM messages WHERE thread_id = old.thread_id" + + ") WHERE _id = old.thread_id;" + + "UPDATE threads SET " + + "unread = (SELECT COUNT(_id) FROM messages WHERE thread_id = old.thread_id AND unread <> 0), " + + "\"new\" = (SELECT COUNT(_id) FROM messages WHERE thread_id = old.thread_id AND \"new\" <> 0) " + + "WHERE _id = old.thread_id;" + + "END", + }; + + private static final String[] SCHEMA_UPGRADE_V17 = { + "ALTER TABLE threads ADD COLUMN archived INTEGER NOT NULL DEFAULT 0", + }; + + private static final String[] SCHEMA_UPGRADE_V18 = { + "CREATE VIRTUAL TABLE fulltext_timestamp USING fts3 (" + + "msg_id INTEGER PRIMARY KEY," + + "thread_id INTEGER NOT NULL," + + "timestamp INTEGER NOT NULL," + + "content TEXT)", + "INSERT INTO fulltext_timestamp" + + " SELECT _id, thread_id, timestamp, CAST(body_content AS TEXT)" + + " FROM messages" + + " WHERE body_mime = 'text/plain' AND" + + " encrypted = 0", + "DROP TABLE fulltext", + "ALTER TABLE fulltext_timestamp RENAME TO fulltext", + }; - protected DatabaseHelper(Context context) { + // fill missing timestamps in fulltext + private static final String[] SCHEMA_UPGRADE_V19 = { + "UPDATE fulltext\n" + + "SET timestamp = (SELECT timestamp FROM messages WHERE _id = fulltext.msg_id) \n" + + "where EXISTS (SELECT timestamp FROM messages WHERE _id = fulltext.msg_id)", + }; + + /** If true, fail all operations. */ + private boolean mLocked; + + DatabaseHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); - mContext = context; } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(SCHEMA_MESSAGES); db.execSQL(SCHEMA_THREADS); + db.execSQL(SCHEMA_GROUPS); + db.execSQL(SCHEMA_GROUPS_MEMBERS); + db.execSQL(SCHEMA_MESSAGES_GROUPS); db.execSQL(SCHEMA_FULLTEXT); db.execSQL(SCHEMA_MESSAGES_INDEX); db.execSQL(SCHEMA_MESSAGES_TIMESTAMP_IDX); + db.execSQL(SCHEMA_MESSAGES_THREAD_ID_IDX); db.execSQL(TRIGGER_THREADS_INSERT_COUNT); db.execSQL(TRIGGER_THREADS_UPDATE_COUNT); db.execSQL(TRIGGER_THREADS_DELETE_COUNT); + db.execSQL(TRIGGER_GROUPS_DELETE_MEMBERS); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - if (oldVersion != 4 && oldVersion < 8) { + if (oldVersion < 8) { // unsupported version - throw new SQLException("database can only be upgraded from version 4 or versions greather than 7"); + throw new SQLException("database can only be upgraded from versions greater than 7"); } - if (oldVersion == 4) { - // take the first server from the builtin list - ServerList list = ServerListUpdater.getCurrentList(mContext); - EndpointServer server = list.get(0); - String host = server.getNetwork(); - - for (String sql : SCHEMA_UPGRADE_V4) { - if (sql.startsWith("INSERT ")) { - db.execSQL(sql, new Object[]{ - CompositeMessage.USERID_LENGTH, - host - }); + switch (oldVersion) { + case 8: + for (String sql : SCHEMA_UPGRADE_V8) { + db.execSQL(sql); } - else { + // fall through + case 9: + for (String sql : SCHEMA_UPGRADE_V9) { db.execSQL(sql); } - } + // fall through + case 10: + for (String sql : SCHEMA_UPGRADE_V10) { + db.execSQL(sql); + } + // fall through + case 11: + db.execSQL(SCHEMA_UPGRADE_V11); + // fall through + case 12: + for (String sql : SCHEMA_UPGRADE_V12) { + db.execSQL(sql); + } + // fall through + case 13: + for (String sql : SCHEMA_UPGRADE_V13) { + db.execSQL(sql); + } + // fall through + case 14: + for (String sql : SCHEMA_UPGRADE_V14) { + db.execSQL(sql); + } + // fall through + case 15: + for (String sql : SCHEMA_UPGRADE_V15) { + db.execSQL(sql); + } + // fall through + case 16: + for (String sql : SCHEMA_UPGRADE_V16) { + db.execSQL(sql); + } + // fall through + case 17: + for (String sql : SCHEMA_UPGRADE_V17) { + db.execSQL(sql); + } + // fall through + case 18: + for (String sql : SCHEMA_UPGRADE_V18) { + db.execSQL(sql); + } + // fall through + case 19: + for (String sql : SCHEMA_UPGRADE_V19) { + db.execSQL(sql); + } + // fall through } } + + public void lock() { + mLocked = true; + } + + public void unlock() { + mLocked = false; + } + + @Override + public SQLiteDatabase getReadableDatabase() { + if (mLocked) + throw new SQLiteDatabaseLockedException("locked by user"); + return super.getReadableDatabase(); + } + + @Override + public SQLiteDatabase getWritableDatabase() { + if (mLocked) + throw new SQLiteDatabaseLockedException("locked by user"); + return super.getWritableDatabase(); + } } @Override @@ -333,47 +580,73 @@ public boolean onCreate() { } @Override - public Cursor query(Uri uri, String[] projection, String selection, + public void shutdown() { + dbHelper.close(); + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { - SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); + SQLitePagedQueryBuilder qb = new SQLitePagedQueryBuilder(); switch (sUriMatcher.match(uri)) { case MESSAGES: - qb.setTables(TABLE_MESSAGES); + qb.setTables(TABLE_MESSAGES_GROUPS); qb.setProjectionMap(messagesProjectionMap); break; case MESSAGES_ID: - qb.setTables(TABLE_MESSAGES); + qb.setTables(TABLE_MESSAGES_GROUPS); qb.setProjectionMap(messagesProjectionMap); qb.appendWhere(Messages._ID + "=" + uri.getPathSegments().get(1)); break; case MESSAGES_SERVERID: - qb.setTables(TABLE_MESSAGES); + qb.setTables(TABLE_MESSAGES_GROUPS); qb.setProjectionMap(messagesProjectionMap); - qb.appendWhere(Messages.MESSAGE_ID + "='" + DatabaseUtils.sqlEscapeString(uri.getPathSegments().get(1)) + "'"); + qb.appendWhere(Messages.MESSAGE_ID + "=" + DatabaseUtils.sqlEscapeString(uri.getPathSegments().get(1))); break; case THREADS: - qb.setTables(TABLE_THREADS); + qb.setTables(TABLE_THREADS_GROUPS); qb.setProjectionMap(threadsProjectionMap); break; case THREADS_ID: - qb.setTables(TABLE_THREADS); + qb.setTables(TABLE_THREADS_GROUPS); qb.setProjectionMap(threadsProjectionMap); qb.appendWhere(Threads._ID + "=" + uri.getPathSegments().get(1)); break; case THREADS_PEER: - qb.setTables(TABLE_THREADS); + qb.setTables(TABLE_THREADS_GROUPS); qb.setProjectionMap(threadsProjectionMap); - qb.appendWhere(Threads.PEER + "='" + DatabaseUtils.sqlEscapeString(uri.getPathSegments().get(1)) + "'"); + qb.appendWhere(Threads.PEER + "=" + DatabaseUtils.sqlEscapeString(uri.getPathSegments().get(1)) + " COLLATE NOCASE"); break; case CONVERSATIONS_ID: - qb.setTables(TABLE_MESSAGES); + // page row count + int count = 0; + // last ID (scrolling cursor) + int lastId = 0; + + try { + lastId = Integer.parseInt(uri.getQueryParameter("last")); + } + catch (Exception ignored) { + } + try { + count = Integer.parseInt(uri.getQueryParameter("count")); + } + catch (Exception ignored) { + } + + // setup page if requested + if (count > 0) { + qb.setPage(count, Messages._ID, lastId); + } + + qb.setTables(TABLE_MESSAGES_GROUPS); qb.setProjectionMap(messagesProjectionMap); qb.appendWhere(Messages.THREAD_ID + "=" + uri.getPathSegments().get(1)); break; @@ -385,23 +658,64 @@ public Cursor query(Uri uri, String[] projection, String selection, selectionArgs = new String[] { uri.getQueryParameter("pattern") }; break; + case GROUPS_ID: + qb.setTables(TABLE_GROUPS); + qb.setProjectionMap(groupsProjectionMap + ); + qb.appendWhere(Groups.GROUP_JID + "=? COLLATE NOCASE"); + if (selectionArgs != null) { + // conditions appended here will get added before the caller-supplied selection + selectionArgs = DataUtils.concatenate(new String[] { uri.getLastPathSegment() }, + selectionArgs); + } + else { + selectionArgs = new String[] { uri.getLastPathSegment() }; + } + break; + + case GROUPS_MEMBERS: + qb.setTables(TABLE_GROUP_MEMBERS); + qb.setProjectionMap(groupsMembersProjectionMap); + qb.appendWhere(Groups.GROUP_JID + "=? COLLATE NOCASE"); + if (selectionArgs != null) { + // conditions appended here will get added before the caller-supplied selection + selectionArgs = DataUtils.concatenate(new String[] { uri.getPathSegments().get(1) }, + selectionArgs); + } + else { + selectionArgs = new String[] { uri.getPathSegments().get(1) }; + } + break; + default: throw new IllegalArgumentException("Unknown URI " + uri); } + String limit = uri.getQueryParameter("limit"); + SQLiteDatabase db = dbHelper.getReadableDatabase(); - Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder); + Cursor c; + if (projection != null && projection.length == 1 && projection[0].equals(BaseColumns._COUNT)) { + c = db.query(qb.getTables(), new String[] { "COUNT(*) AS " + BaseColumns._COUNT }, + selection, selectionArgs, null, null, sortOrder, limit); + } + else { + c = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder, limit); + } c.setNotificationUri(getContext().getContentResolver(), uri); return c; } @Override - public synchronized Uri insert(Uri uri, ContentValues initialValues) { + public Uri insert(@NonNull Uri uri, ContentValues initialValues) { + if (initialValues == null) + throw new IllegalArgumentException("No data"); + // only messages and requests virtual table can be inserted int match = sUriMatcher.match(uri); - if (match != MESSAGES && match != REQUESTS) { throw new IllegalArgumentException("Unknown URI " + uri); } - if (initialValues == null) { throw new IllegalArgumentException("No data"); } + if (match != MESSAGES && match != REQUESTS && match != GROUPS && match != GROUPS_MEMBERS) + throw new IllegalArgumentException("Unknown URI " + uri); // if this column is present, we'll insert the thread only String draft = initialValues.getAsString(Threads.DRAFT); @@ -409,41 +723,56 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) { ContentValues values = new ContentValues(initialValues); SQLiteDatabase db = dbHelper.getWritableDatabase(); - boolean success = false; - List notifications = new ArrayList(); + List notifications = new ArrayList<>(); + + db.beginTransactionNonExclusive(); try { - beginTransaction(db); + switch (match) { + case GROUPS: + // configure thread as group + insertGroup(db, values, notifications); + db.setTransactionSuccessful(); + // no uri needed + return null; + case GROUPS_MEMBERS: + // insert members into group + String groupJid = uri.getPathSegments().get(1); + insertGroupMembers(db, groupJid, values); + db.setTransactionSuccessful(); + // no uri needed + return null; + } // we need to know if there previously was a pending request // so we can decide if we have to fire a notification or not boolean requestExists = false; if (match == REQUESTS) { - requestExists = isRequestPending(db, initialValues.getAsString(CommonColumns.PEER)); + requestExists = isRequestPending(db, initialValues.getAsString(Threads.PEER)); } // create the thread first long threadId = updateThreads(db, values, notifications, match == REQUESTS); + values.put(Messages.THREAD_ID, threadId); - if (draft != null || match == REQUESTS) { + if (threadId != Messages.NO_THREAD && (draft != null || match == REQUESTS)) { // notify thread change notifications.add(ContentUris.withAppendedId(Threads.CONTENT_URI, threadId)); // notify conversation change notifications.add(ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId)); - success = setTransactionSuccessful(db); - - // request only - return conversation - if (match == REQUESTS && !requestExists) - return ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId); - - // draft only - no uri - else - return null; + db.setTransactionSuccessful(); + // draft or request - return conversation + return (draft != null || !requestExists) ? + ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId) : null; } - values.put(Messages.THREAD_ID, threadId); + // remove reserved columns + values.remove(Groups.GROUP_JID); + values.remove(Groups.SUBJECT); + values.remove(Groups.GROUP_TYPE); + values.remove(Threads.ENCRYPTION); // insert the new message now! long rowId = db.insertOrThrow(TABLE_MESSAGES, null, values); @@ -485,40 +814,71 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) { */ if (rowId > 0) { - // update fulltext table - byte[] content = values.getAsByteArray(Messages.BODY_CONTENT); - Boolean encrypted = values.getAsBoolean(Messages.ENCRYPTED); - if (content != null && content.length > 0 && (encrypted == null || !encrypted.booleanValue())) { - updateFulltext(db, rowId, threadId, content); + if (threadId != Messages.NO_THREAD) { + // update fulltext table + byte[] content = values.getAsByteArray(Messages.BODY_CONTENT); + String mime = values.getAsString(Messages.BODY_MIME); + Boolean encrypted = values.getAsBoolean(Messages.ENCRYPTED); + long timestamp = values.getAsLong(Messages.TIMESTAMP); + if (content != null && content.length > 0 && TextComponent.MIME_TYPE.equals(mime) && + (encrypted == null || !encrypted)) { + updateFulltext(db, rowId, threadId, content, timestamp); + } } Uri msgUri = ContentUris.withAppendedId(uri, rowId); notifications.add(msgUri); - // notify thread change - notifications.add(ContentUris.withAppendedId(Threads.CONTENT_URI, threadId)); - // notify conversation change - notifications.add(ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId)); + if (threadId != Messages.NO_THREAD) { + // notify thread change + notifications.add(ContentUris.withAppendedId(Threads.CONTENT_URI, threadId)); + // notify conversation change + notifications.add(ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId)); + } - success = setTransactionSuccessful(db); + db.setTransactionSuccessful(); return msgUri; } throw new SQLException("Failed to insert row into " + uri); } finally { - endTransaction(db, success); + db.endTransaction(); ContentResolver cr = getContext().getContentResolver(); for (Uri nuri : notifications) cr.notifyChange(nuri, null); } } + private void insertGroup(SQLiteDatabase db, ContentValues values, List notifications) { + if (notifications != null) { + long threadId = values.getAsLong(Groups.THREAD_ID); + // notify thread change + notifications.add(ContentUris.withAppendedId(Threads.CONTENT_URI, threadId)); + // notify conversation change + notifications.add(ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId)); + } + + db.insertOrThrow(TABLE_GROUPS, null, values); + } + + private void insertGroupMembers(SQLiteDatabase db, String groupJid, ContentValues values) { + // TODO shouldn't we notify someone? + + try { + values.put(Groups.GROUP_JID, groupJid); + db.insertOrThrow(TABLE_GROUP_MEMBERS, null, values); + } + catch (SQLiteConstraintException e) { + // just ignore dups - it doesn't really matter + } + } + private boolean isRequestPending(SQLiteDatabase db, String peer) { Cursor c = null; try { c = db.query(TABLE_THREADS, new String[] { Threads.REQUEST_STATUS }, - Threads.PEER + "=?", new String[] { peer }, null, null, null); + Threads.PEER + "=? COLLATE NOCASE", new String[] { peer }, null, null, null); return c.moveToFirst() && c.getInt(0) == Threads.REQUEST_WAITING; } catch (Exception e) { @@ -532,7 +892,7 @@ private boolean isRequestPending(SQLiteDatabase db, String peer) { } /** Used to determine content and mime type for a thread. */ - private void setThreadContent(byte[] bodyContent, String bodyMime, String attachmentMime, ContentValues values) { + private void setThreadContent(byte[] bodyContent, String bodyMime, String attachmentMime, String peer, ContentValues values) { String mime; String content; @@ -547,7 +907,9 @@ private void setThreadContent(byte[] bodyContent, String bodyMime, String attach // use body data if there is indeed a mime if (bodyMime != null) { mime = bodyMime; - content = new String(bodyContent); + // do not include content for location messages + content = !LocationComponent.supportsMimeType(mime) ? + new String(bodyContent) : null; } // no mime and no data, nothing to do else { @@ -556,6 +918,13 @@ private void setThreadContent(byte[] bodyContent, String bodyMime, String attach } } + if (peer != null) { + String newContent = peer + ";"; + if (content != null) + newContent += content; + content = newContent; + } + values.put(Threads.CONTENT, content); values.put(Threads.MIME, mime); } @@ -566,15 +935,26 @@ private void setThreadContent(byte[] bodyContent, String bodyMime, String attach * @return the thread id */ private long updateThreads(SQLiteDatabase db, ContentValues initialValues, List notifications, boolean requestOnly) { - ContentValues values = new ContentValues(); - String peer = initialValues.getAsString(CommonColumns.PEER); - long threadId = -1; - if (initialValues.containsKey(Messages.THREAD_ID)) + if (initialValues.containsKey(Messages.THREAD_ID)) { threadId = initialValues.getAsLong(Messages.THREAD_ID); + if (threadId == Messages.NO_THREAD) + return Messages.NO_THREAD; + } + + ContentValues values = new ContentValues(); + // group JID will be the thread peer in this case + String peer; + String groupJid = initialValues.getAsString(Groups.GROUP_JID); + if (groupJid != null) + peer = groupJid; + else + peer = initialValues.getAsString(Threads.PEER); values.put(Threads.PEER, peer); values.put(Threads.TIMESTAMP, initialValues.getAsLong(Messages.TIMESTAMP)); + if (initialValues.containsKey(Threads.ENCRYPTION)) + values.put(Threads.ENCRYPTION, initialValues.getAsBoolean(Threads.ENCRYPTION)); if (requestOnly) { @@ -596,28 +976,50 @@ private long updateThreads(SQLiteDatabase db, ContentValues initialValues, List< if (initialValues.containsKey(Messages.STATUS_CHANGED)) values.put(Threads.STATUS_CHANGED, initialValues.getAsInteger(Messages.STATUS_CHANGED)); // this column is an exception - if (initialValues.containsKey(Threads.DRAFT)) - values.put(Threads.DRAFT, initialValues.getAsString(Threads.DRAFT)); + if (initialValues.containsKey(Threads.DRAFT)) { + String draft = initialValues.getAsString(Threads.DRAFT); + if (draft != null && draft.length() == 0) + values.putNull(Threads.DRAFT); + else + values.put(Threads.DRAFT, draft); + } // unread column will be calculated by the trigger // thread content has a special behaviour + int direction = initialValues.getAsInteger(Messages.DIRECTION); setThreadContent( initialValues.getAsByteArray(Messages.BODY_CONTENT), initialValues.getAsString(Messages.BODY_MIME), initialValues.getAsString(Messages.ATTACHMENT_MIME), + direction == Messages.DIRECTION_IN && groupJid != null ? + initialValues.getAsString(Threads.PEER) : null, values); } + // will reset archived status since we are called for inserting a message + values.put(Threads.ARCHIVED, false); + // insert new thread try { threadId = db.insertOrThrow(TABLE_THREADS, null, values); + // insert group info if needed + if (groupJid != null) { + ContentValues groupValues = new ContentValues(); + groupValues.put(Groups.GROUP_JID, groupJid); + groupValues.put(Groups.THREAD_ID, threadId); + groupValues.put(Groups.SUBJECT, initialValues.getAsString(Groups.SUBJECT)); + groupValues.put(Groups.GROUP_TYPE, initialValues.getAsString(Groups.GROUP_TYPE)); + insertGroup(db, groupValues, null); + } + // notify newly created thread by userid // this will be used for fixing ticket #18 notifications.add(Threads.getUri(peer)); } catch (SQLException e) { + //Log.w(TAG, "error updating thread: " + e.getClass(), e); // clear draft if outgoing message Integer direction = values.getAsInteger(Threads.DIRECTION); if (direction != null && direction == Messages.DIRECTION_OUT) @@ -629,10 +1031,11 @@ private long updateThreads(SQLiteDatabase db, ContentValues initialValues, List< values.remove(Threads.DIRECTION); } - db.update(TABLE_THREADS, values, "peer = ?", new String[] { peer }); + values.remove(Threads.PEER); + db.update(TABLE_THREADS, values, "peer = ? COLLATE NOCASE", new String[] { peer }); // the client did not pass the thread id, query for it manually if (threadId < 0) { - Cursor c = db.query(TABLE_THREADS, new String[] { Threads._ID }, "peer = ?", new String[] { peer }, null, null, null); + Cursor c = db.query(TABLE_THREADS, new String[] { Threads._ID }, "peer = ? COLLATE NOCASE", new String[] { peer }, null, null, null); if (c.moveToFirst()) threadId = c.getLong(0); c.close(); @@ -643,13 +1046,10 @@ private long updateThreads(SQLiteDatabase db, ContentValues initialValues, List< } @Override - public synchronized int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { - if (values == null) { throw new IllegalArgumentException("No data"); } - + public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { String table; String where; String[] args; - String messageId = null; boolean requestOnly = false; switch (sUriMatcher.match(uri)) { @@ -670,8 +1070,8 @@ public synchronized int update(Uri uri, ContentValues values, String selection, break; } - case MESSAGES_SERVERID: - messageId = uri.getPathSegments().get(1); + case MESSAGES_SERVERID: { + String messageId = uri.getPathSegments().get(1); table = TABLE_MESSAGES; where = Messages.MESSAGE_ID + " = ?"; // WARNING selectionArgs is not supported yet @@ -679,6 +1079,7 @@ public synchronized int update(Uri uri, ContentValues values, String selection, where += " AND (" + selection + ")"; args = new String[] { String.valueOf(messageId) }; break; + } case THREADS_ID: { long _id = ContentUris.parseId(uri); @@ -701,43 +1102,131 @@ public synchronized int update(Uri uri, ContentValues values, String selection, break; } + case GROUPS: { + table = TABLE_GROUPS; + where = selection; + args = selectionArgs; + break; + } + + case GROUPS_ID: { + table = TABLE_GROUPS; + String groupId = uri.getLastPathSegment(); + where = Groups.GROUP_JID + " = ? COLLATE NOCASE"; + args = new String[] { groupId }; + if (selection != null) { + where += " AND (" + selection + ")"; + if (selectionArgs != null) + args = DataUtils.concatenate(args, selectionArgs); + } + break; + } + + case GROUPS_MEMBERS: { + String groupId = uri.getPathSegments().get(1); + table = TABLE_GROUP_MEMBERS; + where = Groups.GROUP_JID + " = ? COLLATE NOCASE"; + args = new String[] { groupId }; + if (selection != null) { + where += " AND (" + selection + ")"; + if (selectionArgs != null) + args = DataUtils.concatenate(args, selectionArgs); + } + break; + } + + case GROUPS_MEMBERS_ID: { + table = TABLE_GROUP_MEMBERS; + where = Groups.GROUP_JID + " = ? COLLATE NOCASE AND " + Groups.PEER + " = ? COLLATE NOCASE"; + args = new String[] { uri.getPathSegments().get(1), uri.getLastPathSegment() }; + if (selection != null) { + where += " AND (" + selection + ")"; + if (selectionArgs != null) + args = DataUtils.concatenate(args, selectionArgs); + } + break; + } + + case IMPORT_LOCK: { + dbHelper.lock(); + return 0; + } + + case IMPORT_UNLOCK: { + dbHelper.unlock(); + return 0; + } + + case RELOAD: { + dbHelper.close(); + try { + onCreate(); + dbHelper.getReadableDatabase(); + } + catch (Exception e) { + // restart from scratch + getContext().deleteDatabase(DatabaseHelper.DATABASE_NAME); + onCreate(); + throw new SQLiteException(e.toString()); + } + return 0; + } + default: throw new IllegalArgumentException("Unknown URI " + uri); } - List notifications = new ArrayList(); - boolean success = false; + List notifications = null; SQLiteDatabase db = dbHelper.getWritableDatabase(); + db.beginTransactionNonExclusive(); + try { - beginTransaction(db); + // handle clear pending flags + String pendingFlags = uri.getQueryParameter(Messages.CLEAR_PENDING); + if (pendingFlags != null) { + updatePendingFlags(db, Integer.parseInt(pendingFlags), where, args); + db.setTransactionSuccessful(); + return 0; + } // retrieve old data for notifying. // This was done because of the update call could make the old where // condition not working any more. + boolean skipUpdate = false; String[] msgIdList = null; if (table.equals(TABLE_MESSAGES)) { // preserve a list of the matching messages for notification and // fulltext update later Cursor old = db.query(TABLE_MESSAGES, new String[] { Messages._ID }, where, args, null, null, null); - msgIdList = new String[old.getCount()]; - int i = 0; - while (old.moveToNext()) { - msgIdList[i] = old.getString(0); - i++; + int msgCount = old.getCount(); + if (msgCount > 0) { + msgIdList = new String[msgCount]; + int i = 0; + while (old.moveToNext()) { + msgIdList[i] = old.getString(0); + i++; + } + } + else { + // will skip update if now message was found (what's the point?) + skipUpdate = true; } old.close(); } - int rows = db.update(table, values, where, args); + int rows = 0; + if (!skipUpdate) + rows = db.update(table, values, where, args); // notify change only if rows are actually affected if (rows > 0) { if (requestOnly) uri = Threads.CONTENT_URI; + notifications = new ArrayList<>(); notifications.add(uri); if (table.equals(TABLE_MESSAGES)) { @@ -750,7 +1239,7 @@ public synchronized int update(Uri uri, ContentValues values, String selection, doUpdateFulltext = true; projection = new String[] { Messages.THREAD_ID, Messages._ID, Messages.DIRECTION, Messages.ENCRYPTED, - Messages.BODY_CONTENT }; + Messages.BODY_CONTENT, Messages.TIMESTAMP }; } else { doUpdateFulltext = false; @@ -758,25 +1247,29 @@ public synchronized int update(Uri uri, ContentValues values, String selection, } // build new IN where condition - if (msgIdList.length > 0) { + if (msgIdList != null) { // a non-null array means at least 1 element StringBuilder whereBuilder = new StringBuilder(Messages._ID + " IN (?"); for (int i = 1; i < msgIdList.length; i++) whereBuilder.append(",?"); whereBuilder.append(")"); Cursor c = db.query(TABLE_MESSAGES, projection, - whereBuilder.toString(), msgIdList, null, null, null); + whereBuilder.toString(), msgIdList, null, null, Messages.THREAD_ID); + long oldThreadId = 0; while (c.moveToNext()) { long threadId = c.getLong(0); - updateThreadInfo(db, threadId, notifications); + if (oldThreadId != threadId) { + updateThreadInfo(db, threadId, notifications); + oldThreadId = threadId; + } // update fulltext if necessary if (doUpdateFulltext) { int direction = c.getInt(2); int encrypted = c.getInt(3); - if ((direction == Messages.DIRECTION_IN) ? (encrypted == 0) : true) - updateFulltext(db, c.getLong(1), threadId, c.getBlob(4)); + if (direction != Messages.DIRECTION_IN || encrypted == 0) + updateFulltext(db, c.getLong(1), threadId, c.getBlob(4), c.getLong(5)); } } @@ -798,18 +1291,25 @@ else if (requestOnly) { } } - success = setTransactionSuccessful(db); + db.setTransactionSuccessful(); return rows; } finally { - endTransaction(db, success); - ContentResolver cr = getContext().getContentResolver(); - for (Uri nuri : notifications) - cr.notifyChange(nuri, null); + db.endTransaction(); + if (notifications != null) { + ContentResolver cr = getContext().getContentResolver(); + for (Uri nuri : notifications) + cr.notifyChange(nuri, null); + } } } - private void updateFulltext(SQLiteDatabase db, long id, long threadId, byte[] content) { + /** Updates group status pending flags. */ + private void updatePendingFlags(SQLiteDatabase db, int flags, String where, String[] args) { + db.execSQL("UPDATE " + TABLE_GROUP_MEMBERS + " SET pending = pending & ~("+flags+") WHERE " + where, args); + } + + private void updateFulltext(SQLiteDatabase db, long id, long threadId, byte[] content, long timestamp) { // use the binary content converted to string String text = new String(content); @@ -817,11 +1317,12 @@ private void updateFulltext(SQLiteDatabase db, long id, long threadId, byte[] co fulltext.put(Fulltext._ID, id); fulltext.put(Fulltext.THREAD_ID, threadId); fulltext.put(Fulltext.CONTENT, text); + fulltext.put(Fulltext.TIMESTAMP, timestamp); db.replace(TABLE_FULLTEXT, null, fulltext); } @Override - public synchronized int delete(Uri uri, String selection, String[] selectionArgs) { + public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { String table; String where; String[] args; @@ -839,6 +1340,11 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs _id = ContentUris.parseId(uri); where = "_id = ?"; args = new String[] { String.valueOf(_id) }; + if (selection != null) { + where += " AND (" + selection + ")"; + if (selectionArgs != null) + args = DataUtils.concatenate(args, selectionArgs); + } break; case MESSAGES_SERVERID: @@ -846,6 +1352,11 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs String sid = uri.getPathSegments().get(1); where = "msg_id = ?"; args = new String[] { String.valueOf(sid) }; + if (selection != null) { + where += " AND (" + selection + ")"; + if (selectionArgs != null) + args = DataUtils.concatenate(args, selectionArgs); + } break; case THREADS: @@ -859,17 +1370,50 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs _id = ContentUris.parseId(uri); where = "_id = ?"; args = new String[] { String.valueOf(_id) }; + if (selection != null) { + where += " AND (" + selection + ")"; + if (selectionArgs != null) + args = DataUtils.concatenate(args, selectionArgs); + } break; case THREADS_PEER: table = TABLE_THREADS; - where = "peer = ?"; + where = "peer = ? COLLATE NOCASE"; args = new String[] { uri.getLastPathSegment() }; + if (selection != null) { + where += " AND (" + selection + ")"; + if (selectionArgs != null) + args = DataUtils.concatenate(args, selectionArgs); + } + break; + + case GROUPS_ID: + table = TABLE_GROUPS; + where = Groups.GROUP_JID + "=? COLLATE NOCASE"; + args = new String[] { uri.getLastPathSegment() }; + if (selection != null) { + where += " AND (" + selection + ")"; + if (selectionArgs != null) + args = DataUtils.concatenate(args, selectionArgs); + } + break; + + case GROUPS_MEMBERS_ID: + table = TABLE_GROUP_MEMBERS; + where = Groups.GROUP_JID + " = ? COLLATE NOCASE AND " + Groups.PEER + " = ? COLLATE NOCASE"; + args = new String[] { uri.getPathSegments().get(1), uri.getLastPathSegment() }; + if (selection != null) { + where += " AND (" + selection + ")"; + if (selectionArgs != null) + args = DataUtils.concatenate(args, selectionArgs); + } break; // special case: conversations case CONVERSATIONS_ID: { - int rows = deleteConversation(uri); + boolean keepGroup = Boolean.parseBoolean(uri.getQueryParameter(Messages.KEEP_GROUP)); + int rows = deleteConversation(uri, keepGroup); if (rows > 0) { ContentResolver cr = getContext().getContentResolver(); // first of all, notify conversation @@ -885,22 +1429,26 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs // special case: delete all content case CONVERSATIONS_ALL_ID: { SQLiteDatabase db = dbHelper.getWritableDatabase(); + boolean keepGroup = Boolean.parseBoolean(uri.getQueryParameter(Messages.KEEP_GROUP)); - boolean success = false; - int num = 0; + int num; + db.beginTransactionNonExclusive(); try { - beginTransaction(db); // rows count will be conversations num = db.delete(TABLE_THREADS, null, null); db.delete(TABLE_MESSAGES, null, null); // update fulltext db.delete(TABLE_FULLTEXT, null, null); + if (!keepGroup) { + // delete groups (members will cascade) + db.delete(TABLE_GROUPS, null, null); + } // set transaction successful - success = setTransactionSuccessful(db); + db.setTransactionSuccessful(); } finally { - endTransaction(db, success); + db.endTransaction(); } if (num > 0) { @@ -917,13 +1465,14 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs throw new IllegalArgumentException("Unknown URI " + uri); } - int rows = 0; + int rows; SQLiteDatabase db = dbHelper.getWritableDatabase(); - boolean success = false; - List notifications = new ArrayList(); + List notifications = new ArrayList<>(); + + // let's begin this big transaction :S + db.beginTransactionNonExclusive(); + try { - // let's begin this big transaction :S - beginTransaction(db); long threadId = -1; if (table.equals(TABLE_MESSAGES)) { @@ -944,7 +1493,7 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs // update fulltext int direction = c.getInt(2); int encrypted = c.getInt(3); - if ((direction == Messages.DIRECTION_IN) ? (encrypted == 0) : true) + if (direction != Messages.DIRECTION_IN || encrypted == 0) db.delete(TABLE_FULLTEXT, Fulltext._ID + " = " + c.getLong(1), null); } @@ -960,22 +1509,13 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs notifications.add(uri); if (table.equals(TABLE_MESSAGES)) { - // check for empty threads - if (deleteEmptyThreads(db) > 0) - notifications.add(Threads.CONTENT_URI); - // update thread with latest info and status - if (threadId > 0) { - updateThreadInfo(db, threadId, notifications); - } - else - Log.e(TAG, "unable to update thread metadata (threadId not found)"); - // change notifications get triggered by previous method calls + updateThreadAfterDelete(db, threadId, notifications); } - success = setTransactionSuccessful(db); + db.setTransactionSuccessful(); } finally { - endTransaction(db, success); + db.endTransaction(); ContentResolver cr = getContext().getContentResolver(); for (Uri nuri : notifications) cr.notifyChange(nuri, null); @@ -984,27 +1524,58 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs return rows; } - private int deleteConversation(Uri uri) { + private void updateThreadAfterDelete(SQLiteDatabase db, long threadId, @Nullable List notifications) { + // check for empty threads + if (deleteEmptyThreads(db) > 0 && notifications != null) + notifications.add(Threads.CONTENT_URI); + // update thread with latest info and status + if (threadId > 0) { + updateThreadInfo(db, threadId, notifications); + } + else + Log.e(TAG, "unable to update thread metadata (threadId not found)"); + // change notifications get triggered by previous method calls + } + + private int deleteConversation(Uri uri, boolean keepGroup) { long threadId = ContentUris.parseId(uri); if (threadId > 0) { SQLiteDatabase db = dbHelper.getWritableDatabase(); - boolean success = false; + + db.beginTransactionNonExclusive(); + try { int num = 0; - beginTransaction(db); - num = db.delete(TABLE_THREADS, Threads._ID + " = " + threadId, null); - num += db.delete(TABLE_MESSAGES, Messages.THREAD_ID + " = " + threadId, null); + if (!keepGroup) + num = db.delete(TABLE_THREADS, Threads._ID + " = " + threadId, null); + + // exclude group commands from delete if we are keeping the group + String where = Messages.THREAD_ID + " = " + threadId; + String[] args = null; + if (keepGroup) { + where += " AND (" + Messages.BODY_MIME + " <> ? OR " + Messages.BODY_MIME + " IS NULL)"; + args = new String[] { GroupCommandComponent.MIME_TYPE }; + } + num += db.delete(TABLE_MESSAGES, where, args); + + if (!keepGroup) + // delete group if requested + num += db.delete(TABLE_GROUPS, Groups.THREAD_ID + " = " + threadId, null); + // update fulltext db.delete(TABLE_FULLTEXT, Messages.THREAD_ID + " = " + threadId, null); + // update thread information + updateThreadAfterDelete(db, threadId, null); + // set transaction successful - success = setTransactionSuccessful(db); + db.setTransactionSuccessful(); return num; } finally { - endTransaction(db, success); + db.endTransaction(); } } @@ -1012,8 +1583,8 @@ private int deleteConversation(Uri uri) { } /** Updates metadata of a given thread. */ - private int updateThreadInfo(SQLiteDatabase db, long threadId, List notifications) { - Cursor c = db.query(TABLE_MESSAGES, new String[] { + private void updateThreadInfo(SQLiteDatabase db, long threadId, @Nullable List notifications) { + Cursor c = db.query(TABLE_MESSAGES_GROUPS, new String[] { Messages.MESSAGE_ID, Messages.DIRECTION, Messages.STATUS, @@ -1021,39 +1592,67 @@ private int updateThreadInfo(SQLiteDatabase db, long threadId, List notific Messages.BODY_MIME, Messages.ATTACHMENT_MIME, Messages.TIMESTAMP, + Messages.SERVER_TIMESTAMP, + Messages.PEER, + Groups.GROUP_JID, }, Messages.THREAD_ID + " = ?", new String[] { String.valueOf(threadId) }, null, null, Messages.INVERTED_SORT_ORDER, "1"); - int rc = -1; if (c != null) { + ContentValues v = new ContentValues(); if (c.moveToFirst()) { - ContentValues v = new ContentValues(); + int direction = c.getInt(1); v.put(Threads.MESSAGE_ID, c.getString(0)); - v.put(Threads.DIRECTION, c.getInt(1)); + v.put(Threads.DIRECTION, direction); v.put(Threads.STATUS, c.getInt(2)); - setThreadContent(c.getBlob(3), c.getString(4), c.getString(5), v); + String groupJid = c.getString(9); + String peer = (groupJid != null && direction == Messages.DIRECTION_IN) ? c.getString(8) : null; + setThreadContent(c.getBlob(3), c.getString(4), c.getString(5), peer, v); - v.put(Threads.TIMESTAMP, c.getLong(6)); - rc = db.update(TABLE_THREADS, v, Threads._ID + " = ?", new String[] { String.valueOf(threadId) }); - if (rc > 0) { - notifications.add(ContentUris.withAppendedId(Threads.CONTENT_URI, threadId)); - notifications.add(ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId)); - } + // use server timestamp if present + long ts = c.getLong(7); + v.put(Threads.TIMESTAMP, ts > 0 ? ts : c.getLong(6)); + } + else { + // empty thread data + v.put(Threads.MESSAGE_ID, "draft" + (new Random().nextInt())); + v.put(Threads.DIRECTION, Messages.DIRECTION_OUT); + v.put(Threads.TIMESTAMP, System.currentTimeMillis()); + v.putNull(Threads.STATUS); + setThreadContent(new byte[0], TextComponent.MIME_TYPE, null, null, v); } c.close(); - } - return rc; + // extract counters now + c = db.rawQuery("SELECT SUM(unread), SUM(\"new\") FROM " + TABLE_MESSAGES + " WHERE " + + Messages.THREAD_ID + " = ?", + new String[] { String.valueOf(threadId) }); + if (c != null) { + if (c.moveToFirst()) { + v.put(Threads.UNREAD, c.getLong(0)); + v.put(Threads.NEW, c.getLong(1)); + } + c.close(); + } + + db.update(TABLE_THREADS, v, Threads._ID + "=" + threadId, null); + if (notifications != null) { + notifications.add(ContentUris.withAppendedId(Threads.CONTENT_URI, threadId)); + notifications.add(ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId)); + } + } } private int deleteEmptyThreads(SQLiteDatabase db) { return db.delete(TABLE_THREADS, "\"" + Threads.COUNT + "\"" + " = 0 AND " + - Threads.DRAFT + " IS NULL", null); + Threads.DRAFT + " IS NULL AND " + + "NOT EXISTS (SELECT 1 FROM " + TABLE_GROUPS + + " WHERE "+TABLE_THREADS+"."+Threads._ID+"="+Groups.THREAD_ID+")", null); } @Override - public String getType(Uri uri) { + public String getType(@NonNull Uri uri) { switch (sUriMatcher.match(uri)) { case MESSAGES: case CONVERSATIONS_ID: @@ -1070,178 +1669,26 @@ public String getType(Uri uri) { } } - /* Transactions compatibility layer */ - - @TargetApi(11) - private void beginTransaction(SQLiteDatabase db) { - if (android.os.Build.VERSION.SDK_INT >= 11) - db.beginTransactionNonExclusive(); - else - // this is because API < 11 doesn't have beginTransactionNonExclusive() - db.execSQL("BEGIN IMMEDIATE"); - } - - private boolean setTransactionSuccessful(SQLiteDatabase db) { - if (android.os.Build.VERSION.SDK_INT >= 11) - db.setTransactionSuccessful(); - return true; + public static File getDatabaseUri(Context context) { + return context.getDatabasePath(DatabaseHelper.DATABASE_NAME); } - private void endTransaction(SQLiteDatabase db, boolean success) { - if (android.os.Build.VERSION.SDK_INT >= 11) - db.endTransaction(); - else - db.execSQL(success ? "COMMIT" : "ROLLBACK"); + public static void reload(Context context) { + context.getContentResolver().update(Uri + .parse("content://" + MessagesProvider.AUTHORITY + "/" + Messages.RELOAD), + null, null, null); } - public static boolean deleteDatabase(Context ctx) { - try { - ContentResolver c = ctx.getContentResolver(); - c.delete(Conversations.CONTENT_URI, null, null); - return true; - } - catch (Exception e) { - Log.e(TAG, "error during database delete!", e); - return false; - } + public static void lockForImport(Context context) { + context.getContentResolver().update(Uri + .parse("content://" + MessagesProvider.AUTHORITY + "/" + Messages.IMPORT_LOCK), + null, null, null); } - public static boolean deleteThread(Context ctx, long id) { - ContentResolver c = ctx.getContentResolver(); - return (c.delete(ContentUris.withAppendedId(Conversations.CONTENT_URI, id), null, null) > 0); - } - - /** - * Marks all messages of the given thread as read. - * @param context used to request a {@link ContentResolver} - * @param id the thread id - * @return the number of rows affected in the messages table - */ - public static int markThreadAsRead(Context context, long id) { - ContentResolver c = context.getContentResolver(); - ContentValues values = new ContentValues(2); - values.put(Messages.UNREAD, Boolean.FALSE); - values.put(Messages.NEW, Boolean.FALSE); - return c.update(Messages.CONTENT_URI, values, - Messages.THREAD_ID + " = ? AND " + - Messages.UNREAD + " <> 0 AND " + - Messages.DIRECTION + " = " + Messages.DIRECTION_IN, - new String[] { String.valueOf(id) }); - } - - /** - * Marks all messages as old. - * @param context used to request a {@link ContentResolver} - * @return the number of rows affected in the messages table - */ - public static int markAllThreadsAsOld(Context context) { - ContentResolver c = context.getContentResolver(); - ContentValues values = new ContentValues(1); - values.put(Messages.NEW, Boolean.FALSE); - return c.update(Messages.CONTENT_URI, values, - Messages.NEW + " <> 0 AND " + - Messages.DIRECTION + " = " + Messages.DIRECTION_IN, - null); - } - - public static int getThreadUnreadCount(Context context, long id) { - int count = 0; - ContentResolver res = context.getContentResolver(); - Cursor c = res.query( - ContentUris.withAppendedId(Threads.CONTENT_URI, id), - new String[] { Threads.UNREAD }, - Threads.UNREAD + " > 0", - null, null); - if (c.moveToFirst()) - count = c.getInt(0); - - c.close(); - return count; - } - - private static ContentValues prepareChangeMessageStatus( - int status, long timestamp, long statusChanged) { - ContentValues values = new ContentValues(); - values.put(Messages.STATUS, status); - if (timestamp >= 0) - values.put(Messages.TIMESTAMP, timestamp); - if (statusChanged >= 0) - values.put(Messages.STATUS_CHANGED, statusChanged); - return values; - } - - public static int changeMessageStatus(Context context, Uri uri, int direction, int status) { - return changeMessageStatus(context, uri, direction, status, -1, -1); - } - - public static int changeMessageStatus(Context context, Uri uri, int direction, int status, long timestamp, long statusChanged) { - ContentValues values = prepareChangeMessageStatus(status, timestamp, statusChanged); - return context.getContentResolver().update(uri, values, Messages.DIRECTION + "=" + direction, null); - } - - public static int changeMessageStatus(Context context, long id, int direction, int status) { - return changeMessageStatus(context, id, direction, status, -1, -1); - } - - public static int changeMessageStatus(Context context, long id, int direction, int status, long timestamp, long statusChanged) { - ContentValues values = prepareChangeMessageStatus(status, timestamp, statusChanged); - Uri uri = ContentUris.withAppendedId(Messages.CONTENT_URI, id); - return context.getContentResolver().update(uri, values, Messages.DIRECTION + "=" + direction, null); - } - - /* - public static int changeMessageStatus(Context context, String id, int direction, boolean realId, int status) { - return changeMessageStatus(context, id, direction, realId, status, -1, -1); - } - - public static int changeMessageStatus(Context context, String id, int direction, boolean realId, int status, long timestamp, long statusChanged) { - ContentValues values = prepareChangeMessageStatus(status, timestamp, statusChanged); - - String field = (realId) ? Messages.REAL_ID : Messages.MESSAGE_ID; - return context.getContentResolver().update(Messages.CONTENT_URI, values, - field + " = ? AND " + - Messages.DIRECTION + "=" + direction, - new String[] { id }); - } - */ - - /** Update a message status if old status == whereStatus. */ - /* - public static int changeMessageStatusWhere(Context context, - boolean notEquals, int whereStatus, String id, boolean realId, - int status, long timestamp, long statusChanged) { - ContentValues values = prepareChangeMessageStatus(status, timestamp, statusChanged); - - String field = (realId) ? Messages.REAL_ID : Messages.MESSAGE_ID; - String op = (notEquals) ? "<>" : "="; - return context.getContentResolver().update(Messages.CONTENT_URI, values, - field + " = ? AND " + Messages.STATUS + op + whereStatus, - new String[] { id }); - } - */ - - public static long getConversationByMessage(Context context, long msgId) { - // TODO - return 0; - } - - public static void uploaded(Context context, long msgId, String fetchUrl) { - ContentValues values = new ContentValues(1); - values.put(Messages.ATTACHMENT_FETCH_URL, fetchUrl); - context.getContentResolver().update(Messages.CONTENT_URI, values, - Messages._ID + " = " + msgId, null); - } - - public static boolean exists(Context context, long msgId) { - // check if the message lives :) - boolean b = false; - Cursor c = context.getContentResolver(). - query(ContentUris.withAppendedId(Messages.CONTENT_URI, msgId), - null, null, null, null); - if (c.moveToFirst()) - b = true; - c.close(); - return b; + public static void unlockForImport(Context context) { + context.getContentResolver().update(Uri + .parse("content://" + MessagesProvider.AUTHORITY + "/" + Messages.IMPORT_UNLOCK), + null, null, null); } static { @@ -1254,10 +1701,17 @@ public static boolean exists(Context context, long msgId) { sUriMatcher.addURI(AUTHORITY, TABLE_MESSAGES + "/*", MESSAGES_SERVERID); sUriMatcher.addURI(AUTHORITY, "conversations", CONVERSATIONS_ALL_ID); sUriMatcher.addURI(AUTHORITY, "conversations/#", CONVERSATIONS_ID); + sUriMatcher.addURI(AUTHORITY, TABLE_GROUPS, GROUPS); + sUriMatcher.addURI(AUTHORITY, TABLE_GROUPS + "/*", GROUPS_ID); + sUriMatcher.addURI(AUTHORITY, TABLE_GROUPS + "/*/members", GROUPS_MEMBERS); + sUriMatcher.addURI(AUTHORITY, TABLE_GROUPS + "/*/members/*", GROUPS_MEMBERS_ID); sUriMatcher.addURI(AUTHORITY, TABLE_FULLTEXT, FULLTEXT_ID); sUriMatcher.addURI(AUTHORITY, "requests", REQUESTS); + sUriMatcher.addURI(AUTHORITY, Messages.IMPORT_LOCK, IMPORT_LOCK); + sUriMatcher.addURI(AUTHORITY, Messages.IMPORT_UNLOCK, IMPORT_UNLOCK); + sUriMatcher.addURI(AUTHORITY, Messages.RELOAD, RELOAD); - messagesProjectionMap = new HashMap(); + messagesProjectionMap = new HashMap<>(); messagesProjectionMap.put(Messages._ID, Messages._ID); messagesProjectionMap.put(Messages.THREAD_ID, Messages.THREAD_ID); messagesProjectionMap.put(Messages.MESSAGE_ID, Messages.MESSAGE_ID); @@ -1276,6 +1730,11 @@ public static boolean exists(Context context, long msgId) { messagesProjectionMap.put(Messages.ATTACHMENT_ENCRYPTED, Messages.ATTACHMENT_ENCRYPTED); messagesProjectionMap.put(Messages.ATTACHMENT_SECURITY_FLAGS, Messages.ATTACHMENT_SECURITY_FLAGS); + messagesProjectionMap.put(Messages.GEO_LATITUDE, Messages.GEO_LATITUDE); + messagesProjectionMap.put(Messages.GEO_LONGITUDE, Messages.GEO_LONGITUDE); + messagesProjectionMap.put(Messages.GEO_TEXT, Messages.GEO_TEXT); + messagesProjectionMap.put(Messages.GEO_STREET, Messages.GEO_STREET); + messagesProjectionMap.put(Messages.UNREAD, Messages.UNREAD); messagesProjectionMap.put(Messages.NEW, Messages.NEW); messagesProjectionMap.put(Messages.DIRECTION, Messages.DIRECTION); @@ -1285,8 +1744,13 @@ public static boolean exists(Context context, long msgId) { messagesProjectionMap.put(Messages.ENCRYPTED, Messages.ENCRYPTED); messagesProjectionMap.put(Messages.SECURITY_FLAGS, Messages.SECURITY_FLAGS); messagesProjectionMap.put(Messages.SERVER_TIMESTAMP, Messages.SERVER_TIMESTAMP); + messagesProjectionMap.put(Messages.IN_REPLY_TO, Messages.IN_REPLY_TO); + messagesProjectionMap.put(Groups.GROUP_JID, Groups.GROUP_JID); + messagesProjectionMap.put(Groups.SUBJECT, Groups.SUBJECT); + messagesProjectionMap.put(Groups.GROUP_TYPE, Groups.GROUP_TYPE); + messagesProjectionMap.put(Groups.MEMBERSHIP, Groups.MEMBERSHIP); - threadsProjectionMap = new HashMap(); + threadsProjectionMap = new HashMap<>(); threadsProjectionMap.put(Threads._ID, Threads._ID); threadsProjectionMap.put(Threads.MESSAGE_ID, Threads.MESSAGE_ID); threadsProjectionMap.put(Threads.PEER, Threads.PEER); @@ -1302,9 +1766,30 @@ public static boolean exists(Context context, long msgId) { threadsProjectionMap.put(Threads.ENCRYPTED, Threads.ENCRYPTED); threadsProjectionMap.put(Threads.DRAFT, Threads.DRAFT); threadsProjectionMap.put(Threads.REQUEST_STATUS, Threads.REQUEST_STATUS); - - fulltextProjectionMap = new HashMap(); + threadsProjectionMap.put(Threads.STICKY, Threads.STICKY); + threadsProjectionMap.put(Threads.ENCRYPTION, Threads.ENCRYPTION); + threadsProjectionMap.put(Threads.ARCHIVED, Threads.ARCHIVED); + threadsProjectionMap.put(Groups.GROUP_JID, Groups.GROUP_JID); + threadsProjectionMap.put(Groups.SUBJECT, Groups.SUBJECT); + threadsProjectionMap.put(Groups.GROUP_TYPE, Groups.GROUP_TYPE); + threadsProjectionMap.put(Groups.MEMBERSHIP, Groups.MEMBERSHIP); + + fulltextProjectionMap = new HashMap<>(); + fulltextProjectionMap.put(Fulltext._ID, Fulltext._ID); fulltextProjectionMap.put(Fulltext.THREAD_ID, Fulltext.THREAD_ID); + fulltextProjectionMap.put(Fulltext.TIMESTAMP, Fulltext.TIMESTAMP); fulltextProjectionMap.put(Fulltext.CONTENT, Fulltext.CONTENT); + + groupsProjectionMap = new HashMap<>(); + groupsProjectionMap.put(Groups.GROUP_JID, Groups.GROUP_JID); + groupsProjectionMap.put(Groups.THREAD_ID, Groups.THREAD_ID); + groupsProjectionMap.put(Groups.GROUP_TYPE, Groups.GROUP_TYPE); + groupsProjectionMap.put(Groups.SUBJECT, Groups.SUBJECT); + groupsProjectionMap.put(Groups.MEMBERSHIP, Groups.MEMBERSHIP); + + groupsMembersProjectionMap = new HashMap<>(); + groupsMembersProjectionMap.put(Groups.GROUP_JID, Groups.GROUP_JID); + groupsMembersProjectionMap.put(Groups.PEER, Groups.PEER); + groupsMembersProjectionMap.put(Groups.PENDING, Groups.PENDING); } } diff --git a/app/src/main/java/org/kontalk/provider/MessagesProviderClient.java b/app/src/main/java/org/kontalk/provider/MessagesProviderClient.java new file mode 100644 index 000000000..4f8149c68 --- /dev/null +++ b/app/src/main/java/org/kontalk/provider/MessagesProviderClient.java @@ -0,0 +1,739 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.provider; + +import java.io.File; +import java.util.Random; + +import org.jxmpp.jid.impl.JidCreate; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteConstraintException; +import android.net.Uri; + +import org.kontalk.Log; +import org.kontalk.crypto.Coder; +import org.kontalk.data.GroupInfo; +import org.kontalk.message.LocationComponent; +import org.kontalk.message.TextComponent; +import org.kontalk.provider.MyMessages.Groups; +import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.provider.MyMessages.Threads; +import org.kontalk.service.msgcenter.group.KontalkGroupController; +import org.kontalk.ui.MessagingNotification; +import org.kontalk.util.MediaStorage; +import org.kontalk.util.MessageUtils; +import org.kontalk.util.Preferences; + + +/** + * Utility class for interacting with the {@link MessagesProvider}. + * @author Daniele Ricci + */ +public class MessagesProviderClient { + + private static final String[] LATEST_THREADS_PROJ = { + Threads._ID, + Threads.PEER, + Groups.GROUP_JID, + Groups.SUBJECT, + }; + + public static final int LATEST_THREADS_COLUMN_ID = 0; + public static final int LATEST_THREADS_COLUMN_PEER = 1; + public static final int LATEST_THREADS_COLUMN_GROUP_JID = 2; + public static final int LATEST_THREADS_COLUMN_GROUP_SUBJECT = 3; + + private MessagesProviderClient() { + } + + /** Checks if the message lives. */ + public static boolean exists(Context context, long msgId) { + boolean b = false; + Cursor c = context.getContentResolver(). + query(ContentUris.withAppendedId(Messages.CONTENT_URI, msgId), + null, null, null, null); + if (c.moveToFirst()) + b = true; + c.close(); + return b; + } + + public static Cursor getLatestThreads(Context context, boolean includeGroups, int limit) { + return context.getContentResolver().query(Threads.CONTENT_URI.buildUpon() + .appendQueryParameter("limit", String.valueOf(limit)).build(), + LATEST_THREADS_PROJ, includeGroups ? null : Groups.GROUP_JID + " IS NULL", null, + Threads.DEFAULT_SORT_ORDER); + } + + /** Inserts a new outgoing text message. */ + public static Uri newOutgoingMessage(Context context, String msgId, String userId, + String text, boolean encrypted, long inReplyTo) { + + byte[] bytes = text.getBytes(); + ContentValues values = new ContentValues(11); + // must supply a message ID... + values.put(Messages.MESSAGE_ID, msgId); + values.put(Messages.PEER, userId); + values.put(Messages.BODY_MIME, TextComponent.MIME_TYPE); + values.put(Messages.BODY_CONTENT, bytes); + values.put(Messages.BODY_LENGTH, bytes.length); + values.put(Messages.UNREAD, false); + values.put(Messages.DIRECTION, Messages.DIRECTION_OUT); + values.put(Messages.TIMESTAMP, System.currentTimeMillis()); + values.put(Messages.STATUS, Messages.STATUS_SENDING); + // of course outgoing messages are not encrypted in database + values.put(Messages.ENCRYPTED, false); + values.put(Threads.ENCRYPTION, encrypted); + values.put(Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + if (inReplyTo > 0) + values.put(Messages.IN_REPLY_TO, inReplyTo); + return context.getContentResolver().insert( + Messages.CONTENT_URI, values); + } + + /** Inserts a new outgoing binary message. */ + public static Uri newOutgoingMessage(Context context, String msgId, String userId, + String mime, Uri uri, long length, int compress, File previewFile, boolean encrypted) { + ContentValues values = new ContentValues(13); + values.put(Messages.MESSAGE_ID, msgId); + values.put(Messages.PEER, userId); + + /* TODO one day we'll ask for a text to send with the image + values.put(Messages.BODY_MIME, TextComponent.MIME_TYPE); + values.put(Messages.BODY_CONTENT, content.getBytes()); + values.put(Messages.BODY_LENGTH, content.length()); + */ + + values.put(Messages.UNREAD, false); + // of course outgoing messages are not encrypted in database + values.put(Messages.ENCRYPTED, false); + values.put(Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + values.put(Messages.DIRECTION, Messages.DIRECTION_OUT); + values.put(Messages.TIMESTAMP, System.currentTimeMillis()); + values.put(Messages.STATUS, Messages.STATUS_QUEUED); + + if (previewFile != null) + values.put(Messages.ATTACHMENT_PREVIEW_PATH, previewFile.getAbsolutePath()); + + values.put(Messages.ATTACHMENT_MIME, mime); + values.put(Messages.ATTACHMENT_LOCAL_URI, uri.toString()); + values.put(Messages.ATTACHMENT_LENGTH, length); + values.put(Messages.ATTACHMENT_COMPRESS, compress); + values.put(Messages.ATTACHMENT_SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + + return context.getContentResolver().insert(Messages.CONTENT_URI, values); + } + + /** Inserts a new outgoing location message. */ + public static Uri newOutgoingMessage(Context context, String msgId, String userId, + String text, double lat, double lon, String geoText, String geoStreet, boolean encrypted) { + + byte[] bytes = text.getBytes(); + ContentValues values = new ContentValues(11); + // must supply a message ID... + values.put(Messages.MESSAGE_ID, msgId); + values.put(Messages.PEER, userId); + values.put(Messages.BODY_MIME, LocationComponent.MIME_TYPE); + values.put(Messages.BODY_CONTENT, bytes); + values.put(Messages.BODY_LENGTH, bytes.length); + values.put(Messages.UNREAD, false); + values.put(Messages.DIRECTION, Messages.DIRECTION_OUT); + values.put(Messages.TIMESTAMP, System.currentTimeMillis()); + values.put(Messages.STATUS, Messages.STATUS_SENDING); + values.put(Messages.GEO_LATITUDE, lat); + values.put(Messages.GEO_LONGITUDE, lon); + if (geoText != null) + values.put(Messages.GEO_TEXT, geoText); + if (geoStreet != null) + values.put(Messages.GEO_STREET, geoStreet); + // of course outgoing messages are not encrypted in database + values.put(Messages.ENCRYPTED, false); + values.put(Threads.ENCRYPTION, encrypted); + values.put(Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + return context.getContentResolver().insert( + Messages.CONTENT_URI, values); + } + + public static Uri newIncomingMessage(Context context, ContentValues values) { + try { + return context.getContentResolver().insert(MyMessages.Messages.CONTENT_URI, values); + } catch (SQLiteConstraintException econstr) { + // duplicated message, skip it + return null; + } + } + + public static Uri newChatRequest(Context context, String jid) { + ContentValues values = new ContentValues(2); + values.put(Threads.PEER, jid); + values.put(Threads.TIMESTAMP, System.currentTimeMillis()); + return context.getContentResolver() + .insert(Threads.Requests.CONTENT_URI, values); + } + + /** Returns the thread associated with the given message. */ + public static long getThreadByMessage(Context context, Uri message) { + Cursor c = context.getContentResolver().query(message, + new String[] { Messages.THREAD_ID }, null, null, + null); + try { + if (c.moveToFirst()) + return c.getLong(0); + + return Messages.NO_THREAD; + } + finally { + c.close(); + } + } + + /** Returns the thread associated with the given peer. */ + public static long findThread(Context context, String peer) { + long threadId = 0; + Cursor cp = context.getContentResolver().query(MyMessages.Messages.CONTENT_URI, + new String[] { MyMessages.Messages.THREAD_ID }, MyMessages.Messages.PEER + + " = ? COLLATE NOCASE", new String[] { peer }, null); + if (cp != null) { + if (cp.moveToFirst()) + threadId = cp.getLong(0); + cp.close(); + } + return threadId; + } + + public static int updateDraft(Context context, long threadId, String draft) { + ContentValues values = new ContentValues(1); + if (draft != null && draft.length() > 0) + values.put(Threads.DRAFT, draft); + else + values.putNull(Threads.DRAFT); + return context.getContentResolver().update( + ContentUris.withAppendedId(Threads.CONTENT_URI, threadId), + values, null, null); + } + + public static boolean deleteDatabase(Context ctx) { + try { + ContentResolver c = ctx.getContentResolver(); + c.delete(Threads.Conversations.CONTENT_URI, null, null); + return true; + } + catch (Exception e) { + Log.e(MessagesProvider.TAG, "error during database delete!", e); + return false; + } + } + + /** + * Marks all messages of the given thread as read. + * @param context used to request a {@link ContentResolver} + * @param id the thread id + * @return the number of rows affected in the messages table + */ + public static int markThreadAsRead(Context context, long id) { + ContentResolver c = context.getContentResolver(); + ContentValues values = new ContentValues(2); + values.put(Messages.UNREAD, Boolean.FALSE); + values.put(Messages.NEW, Boolean.FALSE); + return c.update(Messages.CONTENT_URI, values, + Messages.THREAD_ID + " = ? AND " + + Messages.UNREAD + " <> 0 AND " + + Messages.DIRECTION + " = " + Messages.DIRECTION_IN, + new String[] { String.valueOf(id) }); + } + + /** + * Marks all messages of the given thread as read. + * @param context used to request a {@link ContentResolver} + * @param peer the thread peer + * @return the number of rows affected in the messages table + */ + public static int markThreadAsRead(Context context, String peer) { + ContentResolver c = context.getContentResolver(); + ContentValues values = new ContentValues(2); + values.put(Messages.UNREAD, Boolean.FALSE); + values.put(Messages.NEW, Boolean.FALSE); + return c.update(Messages.CONTENT_URI, values, + Messages.PEER + " = ? COLLATE NOCASE AND " + + Messages.UNREAD + " <> 0 AND " + + Messages.DIRECTION + " = " + Messages.DIRECTION_IN, + new String[] { peer }); + } + + /** + * Marks all messages as read. + * @param context used to request a {@link ContentResolver} + * @return the number of rows affected in the messages table + */ + public static int markAllThreadsAsRead(Context context) { + ContentResolver c = context.getContentResolver(); + ContentValues values = new ContentValues(1); + values.put(Messages.NEW, Boolean.FALSE); + values.put(Messages.UNREAD, Boolean.FALSE); + return c.update(Messages.CONTENT_URI, values, + Messages.UNREAD + " <> 0 AND " + + Messages.DIRECTION + " = " + Messages.DIRECTION_IN, + null); + } + + public static int markThreadAsOld(Context context, long id) { + ContentResolver c = context.getContentResolver(); + ContentValues values = new ContentValues(1); + values.put(Messages.NEW, Boolean.FALSE); + return c.update(Messages.CONTENT_URI, values, + Messages.THREAD_ID + " = ? AND " + + Messages.NEW + " <> 0 AND " + + Messages.DIRECTION + " = " + Messages.DIRECTION_IN, + new String[] { String.valueOf(id) }); + } + + /** + * Marks all messages as old. + * @param context used to request a {@link ContentResolver} + * @return the number of rows affected in the messages table + */ + public static int markAllThreadsAsOld(Context context) { + ContentResolver c = context.getContentResolver(); + ContentValues values = new ContentValues(1); + values.put(Messages.NEW, Boolean.FALSE); + return c.update(Messages.CONTENT_URI, values, + Messages.NEW + " <> 0 AND " + + Messages.DIRECTION + " = " + Messages.DIRECTION_IN, + null); + } + + public static final class MessageUpdater { + private Uri mUri; + private Context mContext; + private ContentValues mValues; + private String mWhere; + private int mSound; + private String mCheckPaused; + + public static MessageUpdater forMessage(Context context, long id) { + return new MessageUpdater(context, Messages.getUri(id)); + } + + public static MessageUpdater forMessage(Context context, String id, boolean incoming) { + MessageUpdater u = new MessageUpdater(context, Messages.getUri(id)); + if (incoming) + u.incomingOnly(); + else + u.outgoingOnly(); + return u; + } + + private MessageUpdater(Context context, Uri uri) { + mUri = uri; + mContext = context; + mValues = new ContentValues(); + } + + public MessageUpdater setStatus(int status) { + mValues.put(Messages.STATUS, status); + return this; + } + + public MessageUpdater setStatus(int status, long timestamp) { + setStatus(status); + mValues.put(Messages.STATUS_CHANGED, timestamp); + return this; + } + + public MessageUpdater setServerTimestamp(long timestamp) { + mValues.put(Messages.SERVER_TIMESTAMP, timestamp); + return this; + } + + public MessageUpdater appendWhere(String where) { + mWhere = DatabaseUtils.concatenateWhere(mWhere, where); + return this; + } + + public MessageUpdater outgoingOnly() { + mWhere = Messages.DIRECTION + "=" + Messages.DIRECTION_OUT; + return this; + } + + public MessageUpdater incomingOnly() { + mWhere = Messages.DIRECTION + "=" + Messages.DIRECTION_IN; + return this; + } + + /** + * We will notify of outgoing message handover to the server. + * @param jid we will check if notification are paused for this user + */ + public MessageUpdater notifyOutgoing(String jid) { + mSound = MediaStorage.OUTGOING_MESSAGE_SOUND; + mCheckPaused = jid; + return this; + } + + public void commit() { + if (mSound > 0 && Preferences.getOutgoingSoundEnabled(mContext) && + MessagingNotification.isPaused(mCheckPaused)) + MediaStorage.playNotificationSound(mContext, mSound); + mContext.getContentResolver().update(mUri, mValues, mWhere, null); + } + + public void clear() { + mValues.clear(); + } + } + + /** + * Fills a media message with preview file and local uri, for use e.g. + * after compressing. Also updates the message status to SENDING. + */ + public static void updateMedia(Context context, long id, String previewFile, Uri localUri, long length) { + ContentValues values = new ContentValues(3); + values.put(Messages.ATTACHMENT_PREVIEW_PATH, previewFile); + values.put(Messages.ATTACHMENT_LOCAL_URI, localUri.toString()); + values.put(Messages.ATTACHMENT_LENGTH, length); + values.put(Messages.STATUS, Messages.STATUS_SENDING); + context.getContentResolver().update(ContentUris + .withAppendedId(Messages.CONTENT_URI, id), values, null, null); + } + + /** Set the fetch URL of a media message, marking it as uploaded. */ + public static void uploaded(Context context, long msgId, String fetchUrl) { + ContentValues values = new ContentValues(1); + values.put(Messages.ATTACHMENT_FETCH_URL, fetchUrl); + context.getContentResolver().update(Messages.CONTENT_URI, values, + Messages._ID + " = " + msgId, null); + } + + /** Set the local Uri of a media message, marking it as downloaded. */ + public static void downloaded(Context context, long msgId, Uri localUri, boolean encrypted, long length) { + ContentValues values = new ContentValues(1); + values.put(Messages.ATTACHMENT_LOCAL_URI, localUri.toString()); + values.put(Messages.ATTACHMENT_ENCRYPTED, encrypted); + if (length >= 0) + values.put(Messages.ATTACHMENT_LENGTH, length); + context.getContentResolver().update(ContentUris + .withAppendedId(Messages.CONTENT_URI, msgId), values, null, null); + } + + public static void deleteMessage(Context context, long id) { + context.getContentResolver().delete(ContentUris + .withAppendedId(Messages.CONTENT_URI, id), null, null); + } + + public static boolean deleteThread(Context context, long id, boolean keepGroup) { + ContentResolver c = context.getContentResolver(); + return (c.delete(ContentUris.withAppendedId(Threads.Conversations.CONTENT_URI, id) + .buildUpon().appendQueryParameter(Messages.KEEP_GROUP, String.valueOf(keepGroup)) + .build(), null, null) > 0); + } + + public static void setThreadSticky(Context context, long id, boolean sticky) { + ContentValues values = new ContentValues(1); + values.put(Threads.STICKY, sticky); + context.getContentResolver().update( + ContentUris.withAppendedId(Threads.CONTENT_URI, id), + values, null, null); + } + + /** Marks the given message as SENDING, regardless of its current status. */ + public static int retryMessage(Context context, Uri uri, boolean encrypted) { + ContentValues values = new ContentValues(2); + values.put(Messages.STATUS, Messages.STATUS_SENDING); + values.put(Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + return context.getContentResolver().update(uri, values, null, null); + } + + /** Marks all pending messages to the given recipient as SENDING. */ + public static int retryMessagesTo(Context context, String to) { + Cursor c = context.getContentResolver().query(Messages.CONTENT_URI, + new String[] { Messages._ID }, + Messages.PEER + "=? COLLATE NOCASE AND " + Messages.STATUS + "=" + Messages.STATUS_PENDING, + new String[] { to }, + Messages._ID); + + while (c.moveToNext()) { + long msgID = c.getLong(0); + Uri msgURI = ContentUris.withAppendedId(Messages.CONTENT_URI, msgID); + long threadID = getThreadByMessage(context, msgURI); + if (threadID == Messages.NO_THREAD) + continue; + Uri threadURI = ContentUris.withAppendedId(Threads.CONTENT_URI, threadID); + Cursor cThread = context.getContentResolver().query(threadURI, + new String[] { Threads.ENCRYPTION }, null, null, + null); + if (cThread.moveToFirst()) { + boolean encrypted = MessageUtils.sendEncrypted(context, cThread.getInt(0) != 0); + retryMessage(context, msgURI, encrypted); + } + cThread.close(); + } + + int count = c.getCount(); + c.close(); + return count; + } + + /** Marks all pending messages as SENDING. */ + public static int retryAllMessages(Context context) { + boolean encrypted = Preferences.getEncryptionEnabled(context); + ContentValues values = new ContentValues(2); + values.put(Messages.STATUS, Messages.STATUS_SENDING); + values.put(Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + return context.getContentResolver().update(Messages.CONTENT_URI, values, + Messages.STATUS + "=" + Messages.STATUS_PENDING, + null); + } + + /** Inserts an empty thread (that is, with no messages). */ + public static long insertEmptyThread(Context context, String peer, String draft) { + ContentValues msgValues = new ContentValues(9); + // must supply a message ID... + msgValues.put(Messages.MESSAGE_ID, "draft" + (new Random().nextInt())); + // use group id as the peer + msgValues.put(Messages.PEER, peer); + msgValues.put(Messages.BODY_CONTENT, new byte[0]); + msgValues.put(Messages.BODY_LENGTH, 0); + msgValues.put(Messages.BODY_MIME, TextComponent.MIME_TYPE); + msgValues.put(Messages.DIRECTION, Messages.DIRECTION_OUT); + msgValues.put(Messages.TIMESTAMP, System.currentTimeMillis()); + msgValues.put(Messages.ENCRYPTED, false); + if (draft != null) + msgValues.put(Threads.DRAFT, draft); + Uri newThread = context.getContentResolver().insert(Messages.CONTENT_URI, msgValues); + return newThread != null ? ContentUris.parseId(newThread) : Messages.NO_THREAD; + } + + public static long createGroupThread(Context context, String groupJid, String subject, String[] members, String draft) { + // insert group + ContentValues values = new ContentValues(); + values.put(Groups.GROUP_JID, groupJid); + + // create new conversation + long threadId = insertEmptyThread(context, groupJid, draft); + + values.put(Groups.THREAD_ID, threadId); + values.put(Groups.SUBJECT, subject); + values.put(Groups.GROUP_TYPE, KontalkGroupController.GROUP_TYPE); + context.getContentResolver().insert(Groups.CONTENT_URI, values); + + // remove values not for members table + values.remove(Groups.GROUP_JID); + values.remove(Groups.THREAD_ID); + values.remove(Groups.SUBJECT); + values.remove(Groups.GROUP_TYPE); + + // insert group members + for (String member : members) { + // FIXME turn this into batch operations + values.put(Groups.PEER, member); + context.getContentResolver() + .insert(Groups.getMembersUri(groupJid), values); + } + + return threadId; + } + + public static void addGroupMembers(Context context, String groupJid, String[] members, boolean pending) { + ContentValues values = new ContentValues(); + values.put(Groups.GROUP_JID, groupJid); + for (String member : members) { + // FIXME turn this into batch operations + values.put(Groups.PEER, member); + values.put(Groups.PENDING, pending ? Groups.MEMBER_PENDING_ADDED : 0); + context.getContentResolver() + .insert(Groups.getMembersUri(groupJid), values); + } + } + + public static void removeGroupMembers(Context context, String groupJid, String[] members, boolean pending) { + if (pending) { + ContentValues values = new ContentValues(1); + values.put(Groups.PENDING, Groups.MEMBER_PENDING_REMOVED); + for (String member : members) { + // FIXME turn this into batch operations + context.getContentResolver() + .update(Groups.getMembersUri(groupJid).buildUpon() + .appendPath(member).build(), values, null, null); + } + } + else { + for (String member : members) { + // just beat it! + context.getContentResolver() + .delete(Groups.getMembersUri(groupJid).buildUpon() + .appendPath(member).build(), null, null); + } + } + } + + public static int setGroupSubject(Context context, String groupJid, String subject) { + ContentValues values = new ContentValues(); + if (subject != null) + values.put(Groups.SUBJECT, subject); + else + values.putNull(Groups.SUBJECT); + + return context.getContentResolver().update(Groups.getUri(groupJid), + values, null, null); + } + + public static boolean isGroupExisting(Context context, String groupJid) { + Cursor c = context.getContentResolver().query( + Groups.getUri(groupJid), + new String[] { Groups.GROUP_JID }, null, null, null); + boolean exist = c.moveToFirst(); + c.close(); + return exist; + } + + public static GroupInfo getGroupInfo(Context context, String groupJid) { + Cursor c = context.getContentResolver() + .query(Groups.getUri(groupJid), + new String[] { + Groups.SUBJECT, + Groups.GROUP_TYPE, + Groups.MEMBERSHIP + }, + null, null, null); + + try { + if (c.moveToNext()) { + return new GroupInfo(JidCreate.fromOrThrowUnchecked(groupJid), + c.getString(0), c.getString(1), c.getInt(2)); + } + return null; + } + finally { + c.close(); + } + } + + public static String[] getGroupMembers(Context context, String groupJid, int flags) { + String where; + if (flags > 0) { + where = "(" + Groups.PENDING + " & " + flags + ") = " + flags; + } + else if (flags == 0) { + // handle zero flags special case (means all flags cleared) + where = Groups.PENDING + "=0"; + } + else { + // any flag + where = null; + } + + Cursor c = context.getContentResolver() + .query(Groups.getMembersUri(groupJid), + new String[] { Groups.PEER }, + where, null, null); + + String[] members = new String[c.getCount()]; + int i = 0; + while (c.moveToNext()) { + members[i++] = c.getString(0); + } + c.close(); + return members; + } + + public static int setGroupMembership(Context context, String groupJid, int membership) { + ContentValues values = new ContentValues(1); + values.put(Groups.MEMBERSHIP, membership); + return context.getContentResolver().update(Groups.getUri(groupJid), + values, null, null); + } + + /** Returns the current known membership of a user in a group. */ + public static boolean isGroupMember(Context context, String groupJid, String jid) { + Cursor c = null; + try { + c = context.getContentResolver().query(Groups.getMembersUri(groupJid), + new String[] { Groups.PENDING }, Groups.PEER + "=? COLLATE NOCASE", new String[] { jid }, null); + return c.moveToNext() && c.getInt(0) == 0; + } + finally { + if (c != null) + c.close(); + } + } + + public static final class GroupThreadContent { + public final String sender; + public final String command; + + /** Parse thread content text for a special case: incoming group command. */ + public GroupThreadContent(String sender, String command) { + this.sender = sender; + this.command = command; + } + + public static GroupThreadContent parseIncoming(String content) { + String[] parsed = content.split(";", 2); + if (parsed.length < 2) { + return new GroupThreadContent(null, content); + } + + if (parsed[1].length() == 0) + parsed[1] = null; + + return new GroupThreadContent(parsed[0], parsed[1]); + } + } + + public static int setEncryption(Context context, long threadId, boolean encryption) { + ContentValues values = new ContentValues(1); + values.put(Threads.ENCRYPTION, encryption); + return context.getContentResolver().update( + ContentUris.withAppendedId(Threads.CONTENT_URI, threadId), + values, null, null); + } + + public static int setArchived(Context context, long threadId, boolean archived) { + ContentValues values = new ContentValues(1); + values.put(Threads.ARCHIVED, archived); + return context.getContentResolver().update( + ContentUris.withAppendedId(Threads.CONTENT_URI, threadId), + values, null, null); + } + + public static int getPendingMessagesCount(Context context) { + Cursor c = null; + try { + c = context.getContentResolver().query( + Messages.CONTENT_URI, new String[]{Messages._COUNT}, + Messages.STATUS + " IN (" + Messages.STATUS_SENDING + ", " + + Messages.STATUS_QUEUED + ")", null, null); + if (c != null && c.moveToFirst()) { + return c.getInt(0); + } + return 0; + } + finally { + if (c != null) + c.close(); + } + } + +} diff --git a/app/src/main/java/org/kontalk/provider/MyMessages.java b/app/src/main/java/org/kontalk/provider/MyMessages.java index 94146de6b..61745c0f5 100644 --- a/app/src/main/java/org/kontalk/provider/MyMessages.java +++ b/app/src/main/java/org/kontalk/provider/MyMessages.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,7 +18,7 @@ package org.kontalk.provider; - +import android.content.ContentUris; import android.net.Uri; import android.provider.BaseColumns; @@ -28,19 +28,7 @@ public final class MyMessages { private MyMessages() {} - public interface CommonColumns extends BaseColumns { - public static final String PEER = "peer"; - public static final String DIRECTION = "direction"; - public static final String TIMESTAMP = "timestamp"; - public static final String UNREAD = "unread"; - public static final String NEW = "new"; - public static final String STATUS_CHANGED = "status_changed"; - public static final String STATUS = "status"; - public static final String MESSAGE_ID = "msg_id"; - public static final String ENCRYPTED = "encrypted"; - } - - public static final class Messages implements CommonColumns { + public static final class Messages implements BaseColumns { private Messages() {} public static final Uri CONTENT_URI = Uri.parse("content://" @@ -67,6 +55,17 @@ private Messages() {} public static final int STATUS_NOTDELIVERED = 7; /** Pending user review (e.g. unencryptable message). */ public static final int STATUS_PENDING = 8; + /** Outgoing message, queued for processing (e.g. compression). */ + public static final int STATUS_QUEUED = 9; + + /** + * Builds a message {@link Uri}. + * @param id the message local database id + * @return a new message {@link Uri} + */ + public static Uri getUri(long id) { + return ContentUris.withAppendedId(CONTENT_URI, id); + } /** * Builds a message {@link Uri}. @@ -74,23 +73,39 @@ private Messages() {} * @return a new message {@link Uri} */ public static Uri getUri(String msgId) { - return Uri.parse("content://" - + MessagesProvider.AUTHORITY + "/messages/" + Uri.encode(msgId)); + return CONTENT_URI.buildUpon() + .appendPath(msgId) + .build(); } public static final class Fulltext implements BaseColumns { + private Fulltext() {} + public static final Uri CONTENT_URI = Uri.parse("content://" + MessagesProvider.AUTHORITY + "/fulltext"); - public static final String _ID = "rowid"; + public static final String _ID = "msg_id"; public static final String THREAD_ID = "thread_id"; + public static final String TIMESTAMP = "timestamp"; public static final String CONTENT = "content"; + + public static final String DEFAULT_SORT_ORDER = "timestamp DESC"; } private static final String ITEM_TYPE = BuildConfig.APPLICATION_ID + ".message"; public static final String CONTENT_TYPE = "vnd.android.cursor.dir/" + ITEM_TYPE; public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/" + ITEM_TYPE; + public static final String PEER = "peer"; + public static final String DIRECTION = "direction"; + public static final String TIMESTAMP = "timestamp"; + public static final String UNREAD = "unread"; + public static final String NEW = "new"; + public static final String STATUS_CHANGED = "status_changed"; + public static final String STATUS = "status"; + public static final String MESSAGE_ID = "msg_id"; + public static final String ENCRYPTED = "encrypted"; + public static final String THREAD_ID = "thread_id"; public static final String SERVER_TIMESTAMP = "server_timestamp"; @@ -98,24 +113,51 @@ public static final class Fulltext implements BaseColumns { public static final String BODY_CONTENT = "body_content"; public static final String BODY_LENGTH = "body_length"; + /** Attachment MIME type. */ public static final String ATTACHMENT_MIME = "att_mime"; + /** The path to the local preview file for the attachment. */ public static final String ATTACHMENT_PREVIEW_PATH = "att_preview_path"; + /** URL to fetch the attachment from. */ public static final String ATTACHMENT_FETCH_URL = "att_fetch_url"; + /** Uri to the original media. */ public static final String ATTACHMENT_LOCAL_URI = "att_local_uri"; + /** Attachment size in bytes. */ public static final String ATTACHMENT_LENGTH = "att_length"; + /** Compression level to use for outgoing attachments. */ public static final String ATTACHMENT_COMPRESS = "att_compress"; + /** Encryption flag for outgoing attachments. */ public static final String ATTACHMENT_ENCRYPTED = "att_encrypted"; + /** Security flag for attachments. */ public static final String ATTACHMENT_SECURITY_FLAGS = "att_security_flags"; + public static final String GEO_LATITUDE = "geo_lat"; + public static final String GEO_LONGITUDE = "geo_lon"; + public static final String GEO_TEXT = "geo_text"; + public static final String GEO_STREET = "geo_street"; + public static final String SECURITY_FLAGS = "security_flags"; + public static final String IN_REPLY_TO = "in_reply_to"; + // not DESC here because the listview is reverse-stacked public static final String DEFAULT_SORT_ORDER = _ID; public static final String INVERTED_SORT_ORDER = _ID + " DESC"; + + // used as query parameters + public static final String CLEAR_PENDING = "clear_pending"; + public static final String KEEP_GROUP = "keep_group"; + + // appended as path (commands) + public static final String IMPORT_LOCK = "ilock"; + public static final String IMPORT_UNLOCK = "iunlock"; + public static final String RELOAD = "reload"; + + // special thread_id value for not creating a new thread + public static final long NO_THREAD = -1; } /** Threads are just for conversations metadata. */ - public static final class Threads implements CommonColumns { + public static final class Threads implements BaseColumns { private Threads() {} public static final Uri CONTENT_URI = Uri.parse("content://" @@ -140,6 +182,8 @@ public static final class Conversations implements BaseColumns { /** Request represents a presence subscription request. */ public static final class Requests implements BaseColumns { + private Requests() {} + public static final Uri CONTENT_URI = Uri .parse("content://" + MessagesProvider.AUTHORITY + "/requests"); } @@ -158,13 +202,75 @@ public static Uri getUri(String peer) { public static final String CONTENT_TYPE = "vnd.android.cursor.dir/" + ITEM_TYPE; public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/" + ITEM_TYPE; + public static final String PEER = "peer"; + public static final String DIRECTION = "direction"; + public static final String TIMESTAMP = "timestamp"; + public static final String UNREAD = "unread"; + public static final String NEW = "new"; + public static final String STATUS_CHANGED = "status_changed"; + public static final String STATUS = "status"; + public static final String MESSAGE_ID = "msg_id"; + public static final String ENCRYPTED = "encrypted"; + public static final String MIME = "mime"; public static final String CONTENT = "content"; public static final String COUNT = "count"; public static final String DRAFT = "draft"; public static final String REQUEST_STATUS = "request_status"; + public static final String STICKY = "sticky"; + public static final String ENCRYPTION = "encryption"; + public static final String ARCHIVED = "archived"; - public static final String DEFAULT_SORT_ORDER = "timestamp DESC"; + public static final String DEFAULT_SORT_ORDER = "sticky DESC, timestamp DESC"; public static final String INVERTED_SORT_ORDER = "timestamp"; } + + public static final class Groups { + private Groups() {} + + /** Group subject max length. Like a SMS :) */ + public static final int GROUP_SUBJECT_MAX_LENGTH = 160; + + // flags for group_members.pending + public static final int MEMBER_PENDING_ADDED = 1; + public static final int MEMBER_PENDING_REMOVED = 1 << 1; + + // values for groups.membership + /** You left the group. */ + public static final int MEMBERSHIP_PARTED = 0; + /** You are a current member. */ + public static final int MEMBERSHIP_MEMBER = 1; + /** You are an observer. */ + public static final int MEMBERSHIP_OBSERVER = 2; + /** You were removed from the group. */ + public static final int MEMBERSHIP_KICKED = 3; + + public static final Uri CONTENT_URI = Uri.parse("content://" + + MessagesProvider.AUTHORITY + "/groups"); + + public static Uri getUri(CharSequence jid) { + return Uri.parse("content://" + + MessagesProvider.AUTHORITY + "/groups/" + Uri.encode(jid.toString())); + } + + public static Uri getMembersUri(CharSequence jid) { + return Uri.parse("content://" + + MessagesProvider.AUTHORITY + "/groups/" + Uri.encode(jid.toString()) + "/members"); + } + + public static final String GROUP_JID = "group_jid"; + public static final String PEER = "group_" + Messages.PEER; + public static final String THREAD_ID = Messages.THREAD_ID; + public static final String GROUP_TYPE = "group_type"; + public static final String SUBJECT = "subject"; + public static final String MEMBERSHIP = "membership"; + /** + * Status used for members. It's set on request and cleared back to 0 + * when the command has been confirmed. + * An added user is pending addition until the add member command is + * acknowledged by the server. A removed user is pending removal until + * the remove member command is acknowledged by the server. + */ + public static final String PENDING = "pending"; + } } diff --git a/app/src/main/java/org/kontalk/provider/MyUsers.java b/app/src/main/java/org/kontalk/provider/MyUsers.java index efad8093e..0a9259766 100644 --- a/app/src/main/java/org/kontalk/provider/MyUsers.java +++ b/app/src/main/java/org/kontalk/provider/MyUsers.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,9 +28,7 @@ public class MyUsers { private MyUsers() {} public interface CommonColumns extends BaseColumns { - public static final String JID = "jid"; - public static final String PUBLIC_KEY = "public_key"; - public static final String FINGERPRINT = "fingerprint"; + String JID = "jid"; } public static final class Users implements CommonColumns { @@ -45,7 +43,6 @@ private Users() {} public static final String CONTENT_TYPE = "vnd.android.cursor.dir/" + ITEM_TYPE; public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/" + ITEM_TYPE; - public static final String HASH = "hash"; public static final String NUMBER = "number"; public static final String DISPLAY_NAME = "display_name"; public static final String LOOKUP_KEY = "lookup_key"; @@ -55,8 +52,12 @@ private Users() {} public static final String LAST_SEEN = "last_seen"; public static final String BLOCKED = "blocked"; + // uri parameter for indexed cursor + public static final String EXTRA_INDEX = "org.kontalk.provider.extra.INDEX"; // uri parameter for update: triggers a complete resync public static final String RESYNC = "resync"; + // uri parameter for update: used with resync, use this XMPP domain to generate JID when no account is registered + public static final String XMPP_DOMAIN = "xmppDomain"; // uri parameter for update: used with resync, triggers a complete sync // only if the database is newly created public static final String BOOTSTRAP = "bootstrap"; @@ -68,20 +69,40 @@ private Users() {} // uri parameter for insert: discard name and number when updating // (e.g. update a subscription entry from an existing contact) public static final String DISCARD_NAME = "discardName"; + + // results by EXTRA_INDEX + public static final String EXTRA_INDEX_COUNTS = "org.kontalk.provider.extra.INDEX_COUNTS"; + public static final String EXTRA_INDEX_TITLES = "org.kontalk.provider.extra.INDEX_TITLES"; } public static final class Keys implements CommonColumns { private Keys() {} + public static final String PUBLIC_KEY = "public_key"; + public static final String FINGERPRINT = "fingerprint"; + public static final String TIMESTAMP = "timestamp"; + public static final String TRUST_LEVEL = "trust_level"; + public static final String MANUAL_TRUST = "manual_trust"; + public static final Uri CONTENT_URI = Uri.parse("content://" + UsersProvider.AUTHORITY + "/keys"); - /** Trusted fingerprint of the user. */ - public static final String TRUSTED_FINGERPRINT = "trusted_fingerprint"; - /** Trusted public key of the user. */ - public static final String TRUSTED_PUBLIC_KEY = "trusted_public_key"; + public static Uri getUri(String jid) { + return CONTENT_URI.buildUpon() + .appendPath(jid) + .build(); + } + + public static Uri getUri(String jid, String fingerprint) { + return CONTENT_URI.buildUpon() + .appendPath(jid) + .appendPath(fingerprint).build(); + } + + public static final int TRUST_UNKNOWN = 0; + public static final int TRUST_IGNORED = 1; + public static final int TRUST_VERIFIED = 2; - // uri parameter for insert: trust current key - public static final String TRUST = "trust"; + public static final String INSERT_ONLY = "insertOnly"; } } diff --git a/app/src/main/java/org/kontalk/provider/SQLitePagedQueryBuilder.java b/app/src/main/java/org/kontalk/provider/SQLitePagedQueryBuilder.java new file mode 100644 index 000000000..271df0e62 --- /dev/null +++ b/app/src/main/java/org/kontalk/provider/SQLitePagedQueryBuilder.java @@ -0,0 +1,70 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.provider; + +import io.requery.android.database.sqlite.SQLiteQueryBuilder; + + +/** + * A query builder for paged queries. + * @author Daniele Ricci + */ +public class SQLitePagedQueryBuilder extends SQLiteQueryBuilder { + + private int mCount; + private String mLastColumn; + private int mLastValue; + + public void setPage(int count, String lastColumn, int lastValue) { + mCount = count; + mLastColumn = lastColumn; + mLastValue = lastValue; + } + + @Override + public String buildQuery(String[] projectionIn, String selection, String groupBy, String having, String sortOrder, String limit) { + if (mLastColumn != null) { + // build the inner query with reverse order and row count + // NOTE: passed limit is ignored!! + + StringBuilder newSelection = new StringBuilder(); + if (mLastValue > 0) { + if (selection != null && selection.length() > 0) { + newSelection + .append('(') + .append(selection) + .append(") AND "); + } + + newSelection + .append(mLastColumn) + .append(" < ") + .append(mLastValue); + } + + String query = super.buildQuery(projectionIn, newSelection.toString(), groupBy, having, mLastColumn + " DESC", String.valueOf(mCount)); + // wrap the query with the original sort order + return "SELECT * FROM (" + query + ") ORDER BY " + sortOrder; + } + else { + return super.buildQuery(projectionIn, selection, groupBy, having, sortOrder, limit); + } + } + +} diff --git a/app/src/main/java/org/kontalk/provider/UsersCursor.java b/app/src/main/java/org/kontalk/provider/UsersCursor.java new file mode 100644 index 000000000..05d8aebc6 --- /dev/null +++ b/app/src/main/java/org/kontalk/provider/UsersCursor.java @@ -0,0 +1,44 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.provider; + +import android.database.Cursor; +import android.database.CursorWrapper; +import android.os.Bundle; + + +public class UsersCursor extends CursorWrapper { + + private Bundle mExtras = Bundle.EMPTY; + + public UsersCursor(Cursor cursor) { + super(cursor); + } + + @Override + public Bundle getExtras() { + return mExtras; + } + + @Override + public void setExtras(Bundle extras) { + mExtras = (extras == null) ? Bundle.EMPTY : extras; + } + +} diff --git a/app/src/main/java/org/kontalk/provider/UsersProvider.java b/app/src/main/java/org/kontalk/provider/UsersProvider.java index 258f0daa9..f57b1b623 100644 --- a/app/src/main/java/org/kontalk/provider/UsersProvider.java +++ b/app/src/main/java/org/kontalk/provider/UsersProvider.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,14 +18,17 @@ package org.kontalk.provider; -import java.io.IOException; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.android.providers.contacts.ContactLocaleUtils; +import com.android.providers.contacts.FastScrollingIndexCache; import org.jxmpp.util.XmppStringUtils; -import org.spongycastle.openpgp.PGPException; -import org.spongycastle.openpgp.PGPPublicKeyRing; -import android.annotation.TargetApi; +import android.annotation.SuppressLint; import android.content.ContentProvider; import android.content.ContentResolver; import android.content.ContentUris; @@ -33,43 +36,51 @@ import android.content.Context; import android.content.UriMatcher; import android.database.Cursor; +import android.database.DatabaseUtils; import android.database.SQLException; import android.database.sqlite.SQLiteConstraintException; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.database.sqlite.SQLiteQueryBuilder; -import android.database.sqlite.SQLiteStatement; import android.net.Uri; +import android.os.Bundle; import android.provider.BaseColumns; import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds.Phone; import android.provider.ContactsContract.RawContacts; -import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import io.requery.android.database.sqlite.SQLiteDatabase; +import io.requery.android.database.sqlite.SQLiteOpenHelper; +import io.requery.android.database.sqlite.SQLiteQueryBuilder; +import io.requery.android.database.sqlite.SQLiteStatement; import org.kontalk.BuildConfig; import org.kontalk.Kontalk; -import org.kontalk.R; +import org.kontalk.Log; import org.kontalk.authenticator.Authenticator; -import org.kontalk.client.EndpointServer; -import org.kontalk.client.NumberValidator; -import org.kontalk.crypto.Coder; -import org.kontalk.crypto.PGP; -import org.kontalk.crypto.PGPCoder; +import org.kontalk.authenticator.MyAccount; import org.kontalk.crypto.PersonalKey; import org.kontalk.data.Contact; import org.kontalk.provider.MyUsers.Keys; import org.kontalk.provider.MyUsers.Users; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.service.registration.RegistrationService; import org.kontalk.sync.SyncAdapter; -import org.kontalk.util.MessageUtils; import org.kontalk.util.Preferences; import org.kontalk.util.XMPPUtils; +/** + * The users provider. Also stores the key trust database. + * Fast scrolling cache from Google AOSP. + * @author Daniele Ricci + */ public class UsersProvider extends ContentProvider { public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".users"; - private static final int DATABASE_VERSION = 8; - private static final String DATABASE_NAME = "users.db"; + @VisibleForTesting + static final int DATABASE_VERSION = 14; + @VisibleForTesting + static final String DATABASE_NAME = "users.db"; private static final String TABLE_USERS = "users"; private static final String TABLE_USERS_OFFLINE = "users_offline"; private static final String TABLE_KEYS = "keys"; @@ -78,25 +89,30 @@ public class UsersProvider extends ContentProvider { private static final int USERS_JID = 2; private static final int KEYS = 3; private static final int KEYS_JID = 4; + private static final int KEYS_JID_FINGERPRINT = 5; + + private long mLastResync; + + private FastScrollingIndexCache mFastScrollingIndexCache; + private ContactLocaleUtils mLocaleUtils; private DatabaseHelper dbHelper; private static final UriMatcher sUriMatcher; private static HashMap usersProjectionMap; + private static HashMap keysProjectionMap; - private static class DatabaseHelper extends SQLiteOpenHelper { + @VisibleForTesting + static class DatabaseHelper extends SQLiteOpenHelper { private static final String CREATE_TABLE_USERS = "(" + "_id INTEGER PRIMARY KEY," + - "hash TEXT NOT NULL UNIQUE," + - "number TEXT NOT NULL UNIQUE," + + "jid TEXT NOT NULL UNIQUE," + + "number TEXT," + "display_name TEXT," + - "jid TEXT," + "lookup_key TEXT," + "contact_id INTEGER," + "registered INTEGER NOT NULL DEFAULT 0," + "status TEXT," + "last_seen INTEGER," + - "public_key BLOB," + - "fingerprint TEXT," + "blocked INTEGER NOT NULL DEFAULT 0" + ")"; @@ -108,21 +124,50 @@ private static class DatabaseHelper extends SQLiteOpenHelper { "CREATE TABLE " + TABLE_USERS_OFFLINE + CREATE_TABLE_USERS; private static final String CREATE_TABLE_KEYS = "(" + - "jid TEXT PRIMARY KEY," + + "jid TEXT NOT NULL," + + "fingerprint TEXT NOT NULL," + + "trust_level INTEGER NOT NULL DEFAULT 0," + + "timestamp INTEGER NOT NULL," + // key creation timestamp "public_key BLOB," + - "fingerprint TEXT" + + "manual_trust INTEGER NOT NULL DEFAULT 0," + + "PRIMARY KEY (jid, fingerprint)" + ")"; /** This table will contain keys verified (and trusted) by the user. */ private static final String SCHEMA_KEYS = "CREATE TABLE " + TABLE_KEYS + " " + CREATE_TABLE_KEYS; - private static final String[] SCHEMA_UPGRADE_V7 = { - SCHEMA_KEYS, - "INSERT INTO " + TABLE_KEYS + " SELECT jid, public_key, fingerprint FROM " + TABLE_USERS, + private static final String[] SCHEMA_UPGRADE_V10 = { + "DROP TABLE IF EXISTS users", + SCHEMA_USERS, + "DROP TABLE IF EXISTS users_offline", + SCHEMA_USERS_OFFLINE, + }; + + /** Upgrade: fix null fingerprint bug */ + private static final String[] SCHEMA_UPGRADE_V11 = { + "DELETE FROM keys WHERE fingerprint = 'null'", + }; + + /** Upgrade: introduce manual trust level */ + private static final String[] SCHEMA_UPGRADE_V12 = { + "ALTER TABLE keys ADD COLUMN manual_trust INTEGER NOT NULL DEFAULT 0", + // bring everything to a starting scenario + // no way to know which keys were manually approved and which not + "UPDATE keys SET trust_level = 1", + // mark server key as verified + "UPDATE keys SET trust_level = 2 WHERE jid NOT LIKE '%@%'" + }; + + /** + * Upgrade: fix for missing manual_trust column when upgrading from 4.1.6. + * Errors from this upgrade are ignored. + */ + private static final String[] SCHEMA_UPGRADE_V13 = { + "ALTER TABLE keys ADD COLUMN manual_trust INTEGER NOT NULL DEFAULT 0", }; - // any upgrade - just replace the table + // any upgrade - just re-create all tables private static final String[] SCHEMA_UPGRADE = { "DROP TABLE IF EXISTS " + TABLE_USERS, SCHEMA_USERS, @@ -132,16 +177,16 @@ private static class DatabaseHelper extends SQLiteOpenHelper { SCHEMA_KEYS, }; - private Context mContext; - /** This will be set to true when database is new. */ private boolean mNew; - /** A read-only connection to the database. */ - private SQLiteDatabase dbReader; - protected DatabaseHelper(Context context) { + DatabaseHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); - mContext = context; + } + + @Override + public void onOpen(SQLiteDatabase db) { + db.enableWriteAheadLogging(); } @Override @@ -155,10 +200,27 @@ public void onCreate(SQLiteDatabase db) { @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { switch (oldVersion) { - case 7: - // create keys table and trust anyone - for (String sql : SCHEMA_UPGRADE_V7) + case 10: + for (String sql : SCHEMA_UPGRADE_V10) + db.execSQL(sql); + mNew = true; + // fall through + case 11: + for (String sql : SCHEMA_UPGRADE_V11) + db.execSQL(sql); + // fall through + case 12: + for (String sql : SCHEMA_UPGRADE_V12) db.execSQL(sql); + // fall through + case 13: + try { + for (String sql : SCHEMA_UPGRADE_V13) + db.execSQL(sql); + } + catch (SQLException e) { + Log.i(Kontalk.TAG, "ignoring database fix error", e); + } break; default: for (String sql : SCHEMA_UPGRADE) @@ -167,43 +229,21 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { } } - @Override - public void onOpen(SQLiteDatabase db) { - String path = mContext.getDatabasePath(DATABASE_NAME).getPath(); - dbReader = SQLiteDatabase.openDatabase(path, null, 0); - } - public boolean isNew() { return mNew; } - - @Override - public synchronized void close() { - try { - dbReader.close(); - } - catch (Exception e) { - // ignored - } - dbReader = null; - super.close(); - } - - @Override - public synchronized SQLiteDatabase getReadableDatabase() { - return (dbReader != null) ? dbReader : super.getReadableDatabase(); - } } - @Override public boolean onCreate() { dbHelper = new DatabaseHelper(getContext()); + mFastScrollingIndexCache = FastScrollingIndexCache.getInstance(getContext()); + mLocaleUtils = ContactLocaleUtils.getInstance(); return true; } @Override - public String getType(Uri uri) { + public String getType(@NonNull Uri uri) { switch (sUriMatcher.match(uri)) { case USERS: return Users.CONTENT_TYPE; @@ -215,33 +255,152 @@ public String getType(Uri uri) { } } + private void invalidateFastScrollingIndexCache() { + mFastScrollingIndexCache.invalidate(); + } + + private static final class Counter { + int value; + + public Counter(int start) { + this.value = start; + } + + public void inc() { + value++; + } + } + + /** + * Computes counts by the address book index labels and returns it as {@link Bundle} which + * will be appended to a {@link Cursor} as extras. + */ + private Bundle getFastScrollingIndexExtras(Cursor cursor) { + try { + LinkedHashMap groups = new LinkedHashMap<>(); + int count = cursor.getCount(); + + for (int i = 0; i < count; i++) { + cursor.moveToNext(); + String source = cursor.getString(Contact.COLUMN_DISPLAY_NAME); + // use phone number if we don't have a display name + if (source == null) + source = cursor.getString(Contact.COLUMN_NUMBER); + // use JID if we don't have a phone number + if (source == null) + source = cursor.getString(Contact.COLUMN_JID); + String label = mLocaleUtils.getLabel(source); + Counter counter = groups.get(label); + if (counter == null) { + counter = new Counter(1); + groups.put(label, counter); + } + else { + counter.inc(); + } + } + + int numLabels = groups.size(); + String[] labels = new String[numLabels]; + int[] counts = new int[numLabels]; + int i = 0; + for (Map.Entry entry : groups.entrySet()) { + labels[i] = entry.getKey(); + counts[i] = entry.getValue().value; + i++; + } + + return FastScrollingIndexCache.buildExtraBundle(labels, counts); + } finally { + // reset the cursor + cursor.move(-1); + } + } + + /** + * Add the "fast scrolling index" bundle, generated by {@link #getFastScrollingIndexExtras}, + * to a cursor as extras. It first checks {@link FastScrollingIndexCache} to see if we + * already have a cached result. + */ + @SuppressLint("NewApi") + private void bundleFastScrollingIndexExtras(UsersCursor cursor, Uri queryUri, + final SQLiteDatabase db, SQLiteQueryBuilder qb, String selection, + String[] selectionArgs, String sortOrder, String countExpression) { + + Bundle b; + // Note even though FastScrollingIndexCache is thread-safe, we really need to put the + // put-get pair in a single synchronized block, so that even if multiple-threads request the + // same index at the same time (which actually happens on the phone app) we only execute + // the query once. + // + // This doesn't cause deadlock, because only reader threads get here but not writer + // threads. (Writer threads may call invalidateFastScrollingIndexCache(), but it doesn't + // synchronize on mFastScrollingIndexCache) + // + // All reader and writer threads share the single lock object internally in + // FastScrollingIndexCache, but the lock scope is limited within each put(), get() and + // invalidate() call, so it won't deadlock. + + // Synchronizing on a non-static field is generally not a good idea, but nobody should + // modify mFastScrollingIndexCache once initialized, and it shouldn't be null at this point. + synchronized (mFastScrollingIndexCache) { + b = mFastScrollingIndexCache.get( + queryUri, selection, selectionArgs, sortOrder, countExpression); + + if (b == null) { + // Not in the cache. Generate and put. + b = getFastScrollingIndexExtras(cursor); + + mFastScrollingIndexCache.put(queryUri, selection, selectionArgs, sortOrder, + countExpression, b); + } + } + cursor.setExtras(b); + } + @Override - public Cursor query(Uri uri, String[] projection, String selection, - String[] selectionArgs, String sortOrder) { + public Cursor query(@NonNull Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); boolean offline = Boolean.parseBoolean(uri.getQueryParameter(Users.OFFLINE)); - // use the same table name as an alias - String table = offline ? (TABLE_USERS_OFFLINE + " " + TABLE_USERS) : - TABLE_USERS; - qb.setTables(table + - " LEFT OUTER JOIN " + TABLE_KEYS + " ON " + - TABLE_USERS + "." + Users.JID + "=" + - TABLE_KEYS + "." + Keys.JID); - qb.setProjectionMap(usersProjectionMap); - int match = sUriMatcher.match(uri); + if (match == USERS || match == USERS_JID) { + // use the same table name as an alias + String table = offline ? (TABLE_USERS_OFFLINE + " " + TABLE_USERS) : + TABLE_USERS; + qb.setTables(table); + qb.setProjectionMap(usersProjectionMap); + } + else if (match == KEYS || match == KEYS_JID || match == KEYS_JID_FINGERPRINT) { + qb.setTables(TABLE_KEYS); + qb.setProjectionMap(keysProjectionMap); + } + switch (match) { case USERS: // nothing to do break; - case USERS_JID: + case USERS_JID: { // TODO append to selection String userId = uri.getPathSegments().get(1); - selection = TABLE_USERS + "." + Users.JID + " = ?"; + selection = TABLE_USERS + "." + Users.JID + " = ? COLLATE NOCASE"; selectionArgs = new String[] { userId }; break; + } + + case KEYS: + // nothing to do + break; + + case KEYS_JID: + case KEYS_JID_FINGERPRINT: + String userId = uri.getPathSegments().get(1); + selection = DatabaseUtils.concatenateWhere(selection, Keys.JID + "=? COLLATE NOCASE"); + selectionArgs = DatabaseUtils.appendSelectionArgs(selectionArgs, new String[] { userId }); + // TODO support for fingerprint in Uri + break; default: throw new IllegalArgumentException("Unknown URI " + uri); @@ -249,10 +408,16 @@ public Cursor query(Uri uri, String[] projection, String selection, SQLiteDatabase db = dbHelper.getReadableDatabase(); Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder); - if (c.getCount() == 0) { - // request sync + if ((match == USERS || match == USERS_JID) && c.getCount() == 0 && + (match != USERS_JID || !XMPPUtils.isDomainJID(uri.getPathSegments().get(1)))) { + // empty result set and sync requested SyncAdapter.requestSync(getContext(), false); } + if (Boolean.parseBoolean(uri.getQueryParameter(Users.EXTRA_INDEX)) && c.getCount() > 0) { + UsersCursor uc = new UsersCursor(c); + bundleFastScrollingIndexExtras(uc, uri, db, qb, selection, selectionArgs, sortOrder, null); + c = uc; + } c.setNotificationUri(getContext().getContentResolver(), uri); return c; @@ -277,7 +442,7 @@ private void newRecord(SQLiteDatabase db, String matchHash) { // fix number try { - number = NumberValidator.fixNumber(context, number, + number = RegistrationService.fixNumber(context, number, Authenticator.getDefaultAccountName(context), null); } catch (Exception e) { @@ -287,7 +452,7 @@ private void newRecord(SQLiteDatabase db, String matchHash) { } try { - String hash = MessageUtils.sha1(number); + String hash = SHA1.hex(number); if (hash.equalsIgnoreCase(matchHash)) { ContentValues values = new ContentValues(); values.put(Users.HASH, matchHash); @@ -316,30 +481,51 @@ private void newRecord(SQLiteDatabase db, String matchHash) { */ @Override - public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { - boolean isResync = Boolean.parseBoolean(uri.getQueryParameter(Users.RESYNC)); - boolean bootstrap = Boolean.parseBoolean(uri.getQueryParameter(Users.BOOTSTRAP)); - boolean commit = Boolean.parseBoolean(uri.getQueryParameter(Users.COMMIT)); + public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { + try { + boolean isResync = Boolean.parseBoolean(uri.getQueryParameter(Users.RESYNC)); + boolean bootstrap = Boolean.parseBoolean(uri.getQueryParameter(Users.BOOTSTRAP)); + boolean commit = Boolean.parseBoolean(uri.getQueryParameter(Users.COMMIT)); + + if (isResync) { + // we keep this synchronized to allow for the initial resync by the + // registration activity + synchronized (this) { + long diff = System.currentTimeMillis() - mLastResync; + if (diff > 1000 && (!bootstrap || dbHelper.isNew())) { + if (commit) { + commit(); + return 0; + } + else { + return resync(uri.getQueryParameter(Users.XMPP_DOMAIN)); + } + } - if (isResync) { - if (!bootstrap || dbHelper.isNew()) - return resync(commit); - return 0; - } + mLastResync = System.currentTimeMillis(); + return 0; + } + } - // simple update - int match = sUriMatcher.match(uri); - switch (match) { - case USERS: - case USERS_JID: - return updateUser(values, Boolean.parseBoolean(uri - .getQueryParameter(Users.OFFLINE)), selection, selectionArgs); + // simple update + int match = sUriMatcher.match(uri); + switch (match) { + case USERS: + case USERS_JID: + return updateUser(values, Boolean.parseBoolean(uri + .getQueryParameter(Users.OFFLINE)), selection, selectionArgs); - case KEYS: - return updateKey(values, selection, selectionArgs); + case KEYS: + case KEYS_JID: + case KEYS_JID_FINGERPRINT: + throw new IllegalArgumentException("use insert for keys"); - default: - throw new IllegalArgumentException("Unknown URI " + uri); + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + } + finally { + invalidateFastScrollingIndexCache(); } } @@ -348,57 +534,65 @@ private int updateUser(ContentValues values, boolean offline, String selection, int rc = db.update(offline ? TABLE_USERS_OFFLINE : TABLE_USERS, values, selection, selectionArgs); if (rc == 0) { + ContentValues insertValues = new ContentValues(values); // insert new record - values.put(Users.HASH, XmppStringUtils.parseLocalpart(selectionArgs[0])); - values.put(Users.JID, selectionArgs[0]); - values.put(Users.NUMBER, selectionArgs[0]); + insertValues.put(Users.JID, selectionArgs[0]); + /* if (!values.containsKey(Users.DISPLAY_NAME)) - values.put(Users.DISPLAY_NAME, getContext().getString(R.string.peer_unknown)); - values.put(Users.REGISTERED, true); + insertValues.put(Users.DISPLAY_NAME, selectionArgs[0]); + */ + insertValues.put(Users.REGISTERED, true); - db.insert(offline ? TABLE_USERS_OFFLINE : TABLE_USERS, null, values); - return 1; + try { + db.insert(offline ? TABLE_USERS_OFFLINE : TABLE_USERS, null, insertValues); + return 1; + } + catch (SQLiteConstraintException e) { + // nothing was updated but the row exists + return 0; + } } return rc; } - private int updateKey(ContentValues values, String selection, String[] selectionArgs) { + /** Commits the offline table to the online table. */ + private void commit() { SQLiteDatabase db = dbHelper.getWritableDatabase(); - return db.update(TABLE_KEYS, values, selection, selectionArgs); + + // begin transaction + db.beginTransactionNonExclusive(); + + try { + // copy contents from offline + db.execSQL("DELETE FROM " + TABLE_USERS); + db.execSQL("INSERT INTO " + TABLE_USERS + " SELECT * FROM " + TABLE_USERS_OFFLINE); + db.setTransactionSuccessful(); + } + catch (SQLException e) { + // ops :) + Log.i(SyncAdapter.TAG, "users table commit failed - already committed?", e); + } + finally { + db.endTransaction(); + // time to invalidate contacts cache + Contact.invalidate(); + } } /** Triggers a complete resync of the users database. */ - private int resync(boolean commit) { + private int resync(String xmppDomain) { Context context = getContext(); ContentResolver cr = context.getContentResolver(); SQLiteDatabase db = dbHelper.getWritableDatabase(); // begin transaction - beginTransaction(db); - boolean success = false; + db.beginTransactionNonExclusive(); - if (commit) { - try { - // copy contents from offline - db.execSQL("DELETE FROM " + TABLE_USERS); - db.execSQL("INSERT INTO " + TABLE_USERS + " SELECT * FROM " + TABLE_USERS_OFFLINE); - // time to invalidate contacts cache - Contact.invalidate(); - success = setTransactionSuccessful(db); - } - catch (SQLException e) { - // ops :) - Log.i(SyncAdapter.TAG, "users table commit failed - already committed?", e); - } - finally { - endTransaction(db, success); - } + int count = 0; - return 0; - } - else { - int count = 0; + try { + MyAccount myAccount = Kontalk.get().getDefaultAccount(); // delete old users content try { @@ -411,11 +605,19 @@ private int resync(boolean commit) { // we are trying to be fast here SQLiteStatement stm = db.compileStatement("INSERT INTO " + TABLE_USERS_OFFLINE + - " (hash, number, jid, display_name, lookup_key, contact_id, registered, public_key, fingerprint)" + - " VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)"); + " (number, jid, display_name, lookup_key, contact_id, registered)" + + " VALUES(?, ?, ?, ?, ?, ?)"); + + // these two statements are used to immediately update data in the online table + // even if the data is dummy, it will be soon replaced by sync or by manual request + SQLiteStatement onlineUpd = db.compileStatement("UPDATE " + TABLE_USERS + + " SET number = ?, display_name = ?, lookup_key = ?, contact_id = ? WHERE jid = ?"); + SQLiteStatement onlineIns = db.compileStatement("INSERT INTO " + TABLE_USERS + + " (number, jid, display_name, lookup_key, contact_id, registered)" + + " VALUES(?, ?, ?, ?, ?, ?)"); Cursor phones = null; - String dialPrefix = Preferences.getDialPrefix(context); + String dialPrefix = Preferences.getDialPrefix(); int dialPrefixLen = dialPrefix != null ? dialPrefix.length() : 0; try { @@ -425,16 +627,17 @@ private int resync(boolean commit) { // query for phone numbers phones = cr.query(Phone.CONTENT_URI, - new String[] { Phone.NUMBER, Phone.DISPLAY_NAME, Phone.LOOKUP_KEY, Phone.CONTACT_ID, RawContacts.ACCOUNT_TYPE }, + new String[]{Phone.NUMBER, Phone.DISPLAY_NAME, Phone.LOOKUP_KEY, Phone.CONTACT_ID, RawContacts.ACCOUNT_TYPE}, where + " (" + - // this will filter out RawContacts from Kontalk - RawContacts.ACCOUNT_TYPE + " IS NULL OR " + - RawContacts.ACCOUNT_TYPE + " NOT IN (?, ?))", - new String[] { - Authenticator.ACCOUNT_TYPE, Authenticator.ACCOUNT_TYPE_LEGACY + // this will filter out RawContacts from Kontalk + RawContacts.ACCOUNT_TYPE + " IS NULL OR " + + RawContacts.ACCOUNT_TYPE + " <> ?)", + new String[]{ + Authenticator.ACCOUNT_TYPE }, null); if (phones != null) { + String myNumber = myAccount != null ? myAccount.getPhoneNumber() : null; while (phones.moveToNext()) { String number = phones.getString(0); String name = phones.getString(1); @@ -453,8 +656,8 @@ private int resync(boolean commit) { // fix number try { - number = NumberValidator.fixNumber(context, number, - Authenticator.getDefaultAccountName(context), 0); + number = RegistrationService.fixNumber(context, number, + myNumber, 0); } catch (Exception e) { Log.e(SyncAdapter.TAG, "unable to normalize number: " + number + " - skipping", e); @@ -463,24 +666,18 @@ private int resync(boolean commit) { } try { - String hash = MessageUtils.sha1(number); - - stm.clearBindings(); - stm.bindString(1, hash); - stm.bindString(2, number); - stm.bindString(3, XMPPUtils.createLocalJID(getContext(), hash)); - stm.bindString(4, name); - stm.bindString(5, phones.getString(2)); - stm.bindLong(6, phones.getLong(3)); - stm.bindLong(7, 0); - stm.bindNull(8); - stm.bindNull(9); - stm.executeInsert(); + String hash = XMPPUtils.createLocalpart(number); + String lookupKey = phones.getString(2); + long contactId = phones.getLong(3); + String jid = myAccount != null ? + myAccount.createLocalJID(hash) : + XmppStringUtils.completeJidFrom(hash, xmppDomain); + + addResyncContact(db, stm, onlineUpd, onlineIns, + number, jid, name, + lookupKey, contactId, false); count++; } - catch (IllegalArgumentException iae) { - Log.w(SyncAdapter.TAG, "doing sync with no server?"); - } catch (SQLiteConstraintException sqe) { // skip duplicate number } @@ -501,21 +698,22 @@ private int resync(boolean commit) { null, null, null, null); } catch (Exception e) { - /* - On some phones: - java.lang.NullPointerException - at android.os.Parcel.readException(Parcel.java:1431) - at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:185) - at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:137) - at android.content.ContentProviderProxy.query(ContentProviderNative.java:366) - at android.content.ContentResolver.query(ContentResolver.java:372) - at android.content.ContentResolver.query(ContentResolver.java:315) - */ + /* + On some phones: + java.lang.NullPointerException + at android.os.Parcel.readException(Parcel.java:1431) + at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:185) + at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:137) + at android.content.ContentProviderProxy.query(ContentProviderNative.java:366) + at android.content.ContentResolver.query(ContentResolver.java:372) + at android.content.ContentResolver.query(ContentResolver.java:315) + */ Log.w(SyncAdapter.TAG, "unable to retrieve SIM contacts", e); phones = null; } if (phones != null) { + String myNumber = myAccount != null ? myAccount.getPhoneNumber() : null; while (phones.moveToNext()) { String name = phones.getString(phones.getColumnIndex("name")); String number = phones.getString(phones.getColumnIndex("number")); @@ -533,8 +731,8 @@ private int resync(boolean commit) { // fix number try { - number = NumberValidator.fixNumber(context, number, - Authenticator.getDefaultAccountName(context), 0); + number = RegistrationService.fixNumber(context, number, + myNumber, 0); } catch (Exception e) { Log.e(SyncAdapter.TAG, "unable to normalize number: " + number + " - skipping", e); @@ -543,24 +741,18 @@ private int resync(boolean commit) { } try { - String hash = MessageUtils.sha1(number); - - stm.clearBindings(); - stm.bindString(1, hash); - stm.bindString(2, number); - stm.bindString(3, XMPPUtils.createLocalJID(getContext(), hash)); - stm.bindString(4, name); - stm.bindNull(5); - stm.bindLong(6, phones.getLong(phones.getColumnIndex(BaseColumns._ID))); - stm.bindLong(7, 0); - stm.bindNull(8); - stm.bindNull(9); - stm.executeInsert(); + String hash = XMPPUtils.createLocalpart(number); + String jid = myAccount != null ? + myAccount.createLocalJID(hash) : + XmppStringUtils.completeJidFrom(hash, xmppDomain); + long contactId = phones.getLong(phones.getColumnIndex(BaseColumns._ID)); + + addResyncContact(db, stm, onlineUpd, onlineIns, + number, jid, name, + null, contactId, + false); count++; } - catch (IllegalArgumentException iae) { - Log.w(SyncAdapter.TAG, "doing sync with no server?"); - } catch (SQLiteConstraintException sqe) { // skip duplicate number } @@ -569,13 +761,17 @@ private int resync(boolean commit) { } // try to add account number with display name - String ownNumber = Authenticator.getDefaultAccountName(getContext()); - String ownName = Authenticator.getDefaultDisplayName(getContext()); + if (myAccount == null) { + Log.w(SyncAdapter.TAG, "no account found?!?"); + return 0; + } + + String ownNumber = myAccount.getPhoneNumber(); + String ownName = myAccount.getDisplayName(); String fingerprint = null; byte[] publicKeyData = null; try { - PersonalKey myKey = ((Kontalk) getContext().getApplicationContext()) - .getPersonalKey(); + PersonalKey myKey = Kontalk.get().getPersonalKey(); if (myKey != null) { fingerprint = myKey.getFingerprint(); publicKeyData = myKey.getEncodedPublicKeyRing(); @@ -585,63 +781,147 @@ private int resync(boolean commit) { Log.w(SyncAdapter.TAG, "unable to load personal key", e); } try { - String hash = MessageUtils.sha1(ownNumber); - - stm.clearBindings(); - stm.bindString(1, hash); - stm.bindString(2, ownNumber); - stm.bindString(3, XMPPUtils.createLocalJID(getContext(), hash)); - stm.bindString(4, ownName); - stm.bindNull(5); - stm.bindNull(6); - stm.bindLong(7, 1); - if (fingerprint != null) - stm.bindString(8, fingerprint); - else - stm.bindNull(8); - if (publicKeyData != null) - stm.bindBlob(9, publicKeyData); - else - stm.bindNull(9); - stm.executeInsert(); + String hash = XMPPUtils.createLocalpart(ownNumber); + String jid = myAccount.createLocalJID(hash); + + addResyncContact(db, stm, onlineUpd, onlineIns, + ownNumber, jid, ownName, + null, null, + true); + insertOrUpdateKey(jid, fingerprint, publicKeyData, false); count++; } - catch (IllegalArgumentException iae) { - Log.w(SyncAdapter.TAG, "doing sync with no server?"); - } catch (SQLiteConstraintException sqe) { // skip duplicate number } - success = setTransactionSuccessful(db); + db.setTransactionSuccessful(); + } + catch (SecurityException e) { + Log.w(SyncAdapter.TAG, "no access to contacts. Did you deny the permission?", e); + ReportingManager.logException(e); } finally { - endTransaction(db, success); if (phones != null) phones.close(); stm.close(); + + // time to invalidate contacts cache (because of updates to online) + Contact.invalidate(); } - return count; + } + finally { + db.endTransaction(); + + // time to invalidate contacts cache (because of updates to online) + Contact.invalidate(); + } + + return count; + } + + private void addResyncContact(SQLiteDatabase db, SQLiteStatement stm, SQLiteStatement onlineUpd, SQLiteStatement onlineIns, + String number, String jid, String displayName, String lookupKey, + Long contactId, boolean registered) { + + int i = 0; + + stm.clearBindings(); + stm.bindString(++i, number); + stm.bindString(++i, jid); + if (displayName != null) + stm.bindString(++i, displayName); + else + stm.bindNull(++i); + if (lookupKey != null) + stm.bindString(++i, lookupKey); + else + stm.bindNull(++i); + if (contactId != null) + stm.bindLong(++i, contactId); + else + stm.bindNull(++i); + stm.bindLong(++i, registered ? 1 : 0); + stm.executeInsert(); + + // update online entry + i = 0; + onlineUpd.clearBindings(); + onlineUpd.bindString(++i, number); + if (displayName != null) + onlineUpd.bindString(++i, displayName); + else + onlineUpd.bindNull(++i); + if (lookupKey != null) + onlineUpd.bindString(++i, lookupKey); + else + onlineUpd.bindNull(++i); + if (contactId != null) + onlineUpd.bindLong(++i, contactId); + else + onlineUpd.bindNull(++i); + onlineUpd.bindString(++i, jid); + int rows = onlineUpd.executeUpdateDelete(); + + // no contact found, insert a new dummy one + if (rows <= 0) { + i = 0; + onlineIns.clearBindings(); + onlineIns.bindString(++i, number); + onlineIns.bindString(++i, jid); + if (displayName != null) + onlineIns.bindString(++i, displayName); + else + onlineIns.bindNull(++i); + if (lookupKey != null) + onlineIns.bindString(++i, lookupKey); + else + onlineIns.bindNull(++i); + if (contactId != null) + onlineIns.bindLong(++i, contactId); + else + onlineIns.bindNull(++i); + onlineIns.bindLong(++i, registered ? 1 : 0); + onlineIns.executeInsert(); } } @Override - public Uri insert(Uri uri, ContentValues values) { - int match = sUriMatcher.match(uri); - switch (match) { - case USERS: - case USERS_JID: - return insertUser(values, Boolean.parseBoolean(uri - .getQueryParameter(Users.OFFLINE)), Boolean.parseBoolean(uri - .getQueryParameter(Users.DISCARD_NAME))); + public Uri insert(@NonNull Uri uri, ContentValues values) { + try { + int match = sUriMatcher.match(uri); + switch (match) { + case USERS: + case USERS_JID: + return insertUser(values, Boolean.parseBoolean(uri + .getQueryParameter(Users.OFFLINE)), Boolean.parseBoolean(uri + .getQueryParameter(Users.DISCARD_NAME))); + + case KEYS: + case KEYS_JID: + case KEYS_JID_FINGERPRINT: + List segs = uri.getPathSegments(); + String jid, fingerprint; + if (segs.size() >= 2) { + // Uri-based insert/update + jid = segs.get(1); + fingerprint = segs.get(2); + } + else { + // take jid and fingerprint from values + jid = values.getAsString(Keys.JID); + fingerprint = values.getAsString(Keys.FINGERPRINT); + } - case KEYS: - case KEYS_JID: - return insertKey(values, Boolean.parseBoolean(uri - .getQueryParameter(Keys.TRUST))); + return insertOrUpdateKey(jid, fingerprint, values, + Boolean.parseBoolean(uri.getQueryParameter(Keys.INSERT_ONLY))); - default: - throw new IllegalArgumentException("Unknown URI " + uri); + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + } + finally { + invalidateFastScrollingIndexCache(); } } @@ -655,15 +935,15 @@ private Uri insertUser(ContentValues values, boolean offline, boolean discardNam id = db.insertOrThrow(table, null, values); } catch (SQLException e) { - String hash = values.getAsString(Users.HASH); - if (hash != null) { + String jid = values.getAsString(Users.JID); + if (jid != null) { // discard display_name if requested if (discardName) { values.remove(Users.DISPLAY_NAME); values.remove(Users.NUMBER); } - db.update(table, values, Users.HASH + "=?", new String[] { hash }); + db.update(table, values, Users.JID + "=? COLLATE NOCASE", new String[] { jid }); } } @@ -672,98 +952,120 @@ private Uri insertUser(ContentValues values, boolean offline, boolean discardNam return null; } - private Uri insertKey(ContentValues values, boolean trust) { + private Uri insertOrUpdateKey(String jid, String fingerprint, byte[] keyData, boolean insertOnly) { + if (jid == null || fingerprint == null) + throw new IllegalArgumentException("either JID or fingerprint not provided"); + + ContentValues values = new ContentValues(1); + values.put(Keys.PUBLIC_KEY, keyData); + return insertOrUpdateKey(jid, fingerprint, values, insertOnly); + } + + private Uri insertOrUpdateKey(String jid, String fingerprint, ContentValues values, boolean insertOnly) { SQLiteDatabase db = dbHelper.getWritableDatabase(); - String jid = values.getAsString(Keys.JID); - if (jid == null) - throw new IllegalArgumentException("no JID provided"); + if (jid == null || fingerprint == null) + throw new IllegalArgumentException("either JID or fingerprint not provided"); - int rows; + int rows = 0; - if (trust) { - SQLiteStatement stm = db.compileStatement("INSERT OR REPLACE INTO " + TABLE_KEYS + - " SELECT jid, public_key, fingerprint FROM " + TABLE_USERS + " WHERE jid = ?"); - stm.bindString(1, jid); - stm.executeInsert(); + try { + // try to insert the key with the provided values + ContentValues insertValues = new ContentValues(values); + insertValues.put(Keys.JID, jid); + insertValues.put(Keys.FINGERPRINT, fingerprint); + // use current timestamp if the caller didn't provide any + long timestamp = values.containsKey(Keys.TIMESTAMP) ? + values.getAsLong(Keys.TIMESTAMP) : System.currentTimeMillis(); + insertValues.put(Keys.TIMESTAMP, timestamp); + db.insertOrThrow(TABLE_KEYS, null, insertValues); rows = 1; } - else { - try { - db.insertOrThrow(TABLE_KEYS, null, values); - rows = 1; - } - catch (SQLException e) { - rows = db.update(TABLE_KEYS, values, Keys.JID + "=?", new String[]{jid}); + catch (SQLiteConstraintException e) { + if (!insertOnly) { + // we got a duplicated key, update the requested values + rows = db.update(TABLE_KEYS, values, + Keys.JID + "=? COLLATE NOCASE AND " + Keys.FINGERPRINT + "=?", + new String[]{ jid, fingerprint }); } } if (rows >= 0) - return Keys.CONTENT_URI.buildUpon().appendPath(jid).build(); + return Keys.CONTENT_URI.buildUpon() + .appendPath(jid) + .appendPath(fingerprint) + .build(); return null; } - @Override - public int delete(Uri uri, String selection, String[] selectionArgs) { - throw new SQLException("manual delete from users table not supported."); - } + private int insertKeys(ContentValues[] values) { + SQLiteDatabase db = dbHelper.getWritableDatabase(); - // avoid recreating the same object over and over - private static ContentValues registeredValues; + int rows = 0; + SQLiteStatement stm = db.compileStatement("INSERT OR REPLACE INTO " + + TABLE_KEYS + " (" + Keys.JID + ", " + Keys.FINGERPRINT + ") VALUES(?, ?)"); - /** Marks a user as registered. */ - public static void markRegistered(Context context, String jid) { - if (registeredValues == null) { - registeredValues = new ContentValues(1); - registeredValues.put(Users.REGISTERED, 1); + for (ContentValues v : values) { + try { + stm.bindString(1, v.getAsString(Keys.JID)); + stm.bindString(2, v.getAsString(Keys.FINGERPRINT)); + stm.executeInsert(); + rows++; + } + catch (SQLException e) { + Log.w(SyncAdapter.TAG, "error inserting trusted key [" + v + "]", e); + } } - // TODO Uri.withAppendedPath(Users.CONTENT_URI, msg.getSender(true)) - context.getContentResolver().update(Users.CONTENT_URI, - registeredValues, Users.JID+"=?", new String[] { jid }); + + return rows; } - /** Returns a {@link Coder} instance for encrypting data. */ - public static Coder getEncryptCoder(Context context, EndpointServer server, PersonalKey key, String[] recipients) { - // get recipients public keys from users database - PGPPublicKeyRing keys[] = new PGPPublicKeyRing[recipients.length]; - for (int i = 0; i < recipients.length; i++) { - PGPPublicKeyRing ring = getPublicKey(context, recipients[i], true); - if (ring == null) - throw new IllegalArgumentException("public key not found for user " + recipients[i]); + @Override + public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) { + int match = sUriMatcher.match(uri); + switch (match) { + case KEYS: + return insertKeys(values); - keys[i] = ring; + default: + throw new IllegalArgumentException("Unknown URI " + uri); } - - return new PGPCoder(server, key, keys); } - /** Returns a {@link Coder} instance for decrypting data. */ - public static Coder getDecryptCoder(Context context, EndpointServer server, PersonalKey key, String sender) { - PGPPublicKeyRing senderKey = getPublicKey(context, sender, true); - return new PGPCoder(server, key, senderKey); + private int deleteKeys(String userId, String fingerprint, String selection, String[] selectionArgs) { + SQLiteDatabase db = dbHelper.getWritableDatabase(); + selection = DatabaseUtils.concatenateWhere(selection, Keys.JID + "=? COLLATE NOCASE"); + selection = DatabaseUtils.concatenateWhere(selection, Keys.FINGERPRINT + "=?"); + selectionArgs = DatabaseUtils.appendSelectionArgs(selectionArgs, new String[] { userId, fingerprint }); + return db.delete(TABLE_KEYS, selection, selectionArgs); } - /** Retrieves the trusted public key for a user. */ - public static PGPPublicKeyRing getPublicKey(Context context, String jid, boolean trusted) { - byte[] keydata = null; - ContentResolver res = context.getContentResolver(); - Cursor c = res.query(Users.CONTENT_URI.buildUpon() - .appendPath(jid).build(), new String[] { trusted ? - Keys.TRUSTED_PUBLIC_KEY : Users.PUBLIC_KEY }, - null, null, null); + @Override + public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { + int match = sUriMatcher.match(uri); + switch (match) { + case KEYS_JID_FINGERPRINT: + List segs = uri.getPathSegments(); + String userId = segs.get(1); + String fingerprint = segs.get(2); + return deleteKeys(userId, fingerprint, selection, selectionArgs); - if (c.moveToFirst()) - keydata = c.getBlob(0); + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + } - c.close(); + // avoid recreating the same object over and over + private static ContentValues registeredValues; - try { - return PGP.readPublicKeyring(keydata); - } - catch (Exception e) { - // ignored + /** Marks a user as registered. */ + public static void markRegistered(Context context, String jid) { + if (registeredValues == null) { + registeredValues = new ContentValues(1); + registeredValues.put(Users.REGISTERED, 1); } - - return null; + // TODO Uri.withAppendedPath(Users.CONTENT_URI, msg.getSender(true)) + context.getContentResolver().update(Users.CONTENT_URI, + registeredValues, Users.JID+"=? COLLATE NOCASE", new String[] { jid }); } /** Retrieves the last seen timestamp for a user. */ @@ -787,69 +1089,42 @@ public static void setLastSeen(Context context, String jid, long time) { ContentValues values = new ContentValues(1); values.put(Users.LAST_SEEN, time); context.getContentResolver().update(Users.CONTENT_URI, - values, Users.JID + "=?", new String[] { jid }); - } - - /** Updates a user public key. */ - public static void setUserKey(Context context, String jid, byte[] keydata) - throws IOException, PGPException { - String fingerprint = PGP.getFingerprint(keydata); - ContentValues values = new ContentValues(2); - values.put(Users.FINGERPRINT, fingerprint); - values.put(Users.PUBLIC_KEY, keydata); - context.getContentResolver().update(Users.CONTENT_URI, - values, Users.JID + "=?", new String[]{jid}); - } - - /** Marks the given user fingerprint as trusted. */ - public static void trustUserKey(Context context, String jid) { - ContentValues values = new ContentValues(1); - values.put(Keys.JID, jid); - context.getContentResolver().insert(Keys.CONTENT_URI.buildUpon() - .appendQueryParameter(Keys.TRUST, "true") - .build(), values); - } - - /** Trusts a user public key if trusted fingerprint matches the given key. */ - public static void maybeTrustUserKey(Context context, String jid, byte[] keydata) - throws IOException, PGPException { - String fingerprint = PGP.getFingerprint(keydata); - ContentValues values = new ContentValues(1); - values.put(Keys.PUBLIC_KEY, keydata); - context.getContentResolver().update(Keys.CONTENT_URI, - values, Keys.JID + "=? AND " + Keys.FINGERPRINT + "=?", - new String[] { jid, fingerprint }); + values, Users.JID + "=? COLLATE NOCASE", new String[] { jid }); } public static void setBlockStatus(Context context, String jid, boolean blocked) { ContentValues values = new ContentValues(1); values.put(Users.BLOCKED, blocked); - context.getContentResolver().update(Users.CONTENT_URI.buildUpon() - .appendPath(jid).build(), values, null, null); + context.getContentResolver().update(Users.CONTENT_URI, + values, Users.JID + "=? COLLATE NOCASE", new String[] { jid }); } - /* Transactions compatibility layer */ + // FIXME what is this doing here? Using Messages Uri + public static int setRequestStatus(Context context, String jid, int status) { + ContentValues values = new ContentValues(1); + values.put(MyMessages.Threads.REQUEST_STATUS, status); - @TargetApi(11) - private void beginTransaction(SQLiteDatabase db) { - if (android.os.Build.VERSION.SDK_INT >= 11) - db.beginTransactionNonExclusive(); - else - // this is because API < 11 doesn't have beginTransactionNonExclusive() - db.execSQL("BEGIN IMMEDIATE"); + // FIXME this won't work on new threads + return context.getContentResolver().update(MyMessages.Threads.Requests.CONTENT_URI, + values, MyMessages.Threads.PEER + "=? COLLATE NOCASE", + new String[] { jid }); } - private boolean setTransactionSuccessful(SQLiteDatabase db) { - if (android.os.Build.VERSION.SDK_INT >= 11) - db.setTransactionSuccessful(); - return true; + public static int updateDisplayNameIfEmpty(Context context, String jid, String displayName) { + ContentValues values = new ContentValues(1); + values.put(Users.DISPLAY_NAME, displayName); + return context.getContentResolver().update(Users.CONTENT_URI, + values, Users.JID + " = ? COLLATE NOCASE AND (" + Users.DISPLAY_NAME + " IS NULL OR LENGTH(" + Users.DISPLAY_NAME + ") = 0)", + new String[] { jid }); } - private void endTransaction(SQLiteDatabase db, boolean success) { - if (android.os.Build.VERSION.SDK_INT >= 11) - db.endTransaction(); - else - db.execSQL(success ? "COMMIT" : "ROLLBACK"); + public static int resync(Context context, String xmppDomain) { + // update users database + Uri uri = Users.CONTENT_URI.buildUpon() + .appendQueryParameter(Users.RESYNC, "true") + .appendQueryParameter(Users.XMPP_DOMAIN, xmppDomain) + .build(); + return context.getContentResolver().update(uri, new ContentValues(), null, null); } static { @@ -858,23 +1133,28 @@ private void endTransaction(SQLiteDatabase db, boolean success) { sUriMatcher.addURI(AUTHORITY, TABLE_USERS + "/*", USERS_JID); sUriMatcher.addURI(AUTHORITY, TABLE_KEYS, KEYS); sUriMatcher.addURI(AUTHORITY, TABLE_KEYS + "/*", KEYS_JID); + sUriMatcher.addURI(AUTHORITY, TABLE_KEYS + "/*/*", KEYS_JID_FINGERPRINT); - usersProjectionMap = new HashMap(); + usersProjectionMap = new HashMap<>(); usersProjectionMap.put(Users._ID, Users._ID); - usersProjectionMap.put(Users.HASH, Users.HASH); usersProjectionMap.put(Users.NUMBER, Users.NUMBER); usersProjectionMap.put(Users.DISPLAY_NAME, Users.DISPLAY_NAME); - usersProjectionMap.put(Users.JID, TABLE_USERS + "." + Users.JID); + usersProjectionMap.put(Users.JID, Users.JID); usersProjectionMap.put(Users.LOOKUP_KEY, Users.LOOKUP_KEY); usersProjectionMap.put(Users.CONTACT_ID, Users.CONTACT_ID); usersProjectionMap.put(Users.REGISTERED, Users.REGISTERED); usersProjectionMap.put(Users.STATUS, Users.STATUS); usersProjectionMap.put(Users.LAST_SEEN, Users.LAST_SEEN); - usersProjectionMap.put(Users.PUBLIC_KEY, TABLE_USERS + "." + Users.PUBLIC_KEY); - usersProjectionMap.put(Users.FINGERPRINT, TABLE_USERS + "." + Users.FINGERPRINT); usersProjectionMap.put(Users.BLOCKED, Users.BLOCKED); - usersProjectionMap.put(Keys.TRUSTED_PUBLIC_KEY, TABLE_KEYS + "." + Keys.PUBLIC_KEY); - usersProjectionMap.put(Keys.TRUSTED_FINGERPRINT, TABLE_KEYS + "." + Keys.FINGERPRINT); + + // only for direct access to the keys table (for optimization) + keysProjectionMap = new HashMap<>(); + keysProjectionMap.put(Keys.JID, Keys.JID); + keysProjectionMap.put(Keys.FINGERPRINT, Keys.FINGERPRINT); + keysProjectionMap.put(Keys.PUBLIC_KEY, Keys.PUBLIC_KEY); + keysProjectionMap.put(Keys.TIMESTAMP, Keys.TIMESTAMP); + keysProjectionMap.put(Keys.TRUST_LEVEL, Keys.TRUST_LEVEL); + keysProjectionMap.put(Keys.MANUAL_TRUST, Keys.MANUAL_TRUST); } } diff --git a/app/src/main/java/org/kontalk/service/DatabaseImporterService.java b/app/src/main/java/org/kontalk/service/DatabaseImporterService.java new file mode 100644 index 000000000..27cd62bdd --- /dev/null +++ b/app/src/main/java/org/kontalk/service/DatabaseImporterService.java @@ -0,0 +1,160 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.ref.WeakReference; + +import android.app.Service; +import android.content.Intent; +import android.database.sqlite.SQLiteException; +import android.net.Uri; +import android.os.IBinder; +import android.os.Process; +import androidx.annotation.Nullable; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.kontalk.util.DataUtils; + + +/** + * An abstract service for importing a database from any location to a local + * database. + * @author Daniele Ricci + */ +public abstract class DatabaseImporterService extends Service { + + // actions + public static final String ACTION_IMPORT = "org.kontalk.database.IMPORT"; + + public static final String EXTRA_ERROR = "org.kontalk.database.ERROR"; + + // broadcasts + public static final String ACTION_STARTED = "org.kontalk.database.STARTED"; + public static final String ACTION_FINISH = "org.kontalk.database.FINISH"; + + private LocalBroadcastManager lbm; + private ImporterThread mThread; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (lbm == null) + lbm = LocalBroadcastManager.getInstance(getApplicationContext()); + + String action = intent.getAction(); + if (ACTION_IMPORT.equals(action)) { + Uri databaseFile = intent.getData(); + if (mThread == null) { + mThread = new ImporterThread(this, databaseFile); + mThread.start(); + + broadcastStarted(); + } + } + + return START_REDELIVER_INTENT; + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + protected abstract void lockDestinationDatabase(); + + protected abstract OutputStream getDestinationStream() throws IOException; + + protected abstract void reloadDatabase() throws SQLiteException; + + protected abstract void unlockDestinationDatabase(); + + private void broadcastStarted() { + lbm.sendBroadcast(new Intent(ACTION_STARTED)); + } + + void broadcastFinish(String error) { + Intent i = new Intent(ACTION_FINISH); + i.putExtra(EXTRA_ERROR, error); + lbm.sendBroadcast(i); + } + + static final class ImporterThread extends Thread { + private WeakReference s; + private Uri origin; + + ImporterThread(DatabaseImporterService service, Uri databaseFile) { + s = new WeakReference<>(service); + origin = databaseFile; + } + + @Override + public void run() { + // set a low priority + android.os.Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + + DatabaseImporterService service = s.get(); + if (service != null) { + // first thing to do + service.lockDestinationDatabase(); + + InputStream in = null; + OutputStream out = null; + String error = null; + + try { + // open the databases + in = service.getContentResolver().openInputStream(origin); + out = service.getDestinationStream(); + + if (in == null || out == null) + throw new IOException("unable to open files"); + + // this is it + DataUtils.copy(in, out); + + // reload will return without exceptions if everything is ok + service.reloadDatabase(); + } + catch (FileNotFoundException e) { + error = e.toString(); + } + catch (IOException e) { + error = e.toString(); + } + catch (SQLiteException e) { + // TODO i18n + error = "Invalid database."; + } + finally { + DataUtils.close(in); + DataUtils.close(out); + service.unlockDestinationDatabase(); + service.broadcastFinish(error); + service.stopSelf(); + } + } + } + + } + +} diff --git a/app/src/main/java/org/kontalk/service/DirectShareTargetService.java b/app/src/main/java/org/kontalk/service/DirectShareTargetService.java new file mode 100644 index 000000000..f31d0f67b --- /dev/null +++ b/app/src/main/java/org/kontalk/service/DirectShareTargetService.java @@ -0,0 +1,121 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import android.annotation.TargetApi; +import android.content.ComponentName; +import android.content.IntentFilter; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.drawable.Icon; +import android.os.Build; +import android.os.Bundle; +import android.service.chooser.ChooserTarget; +import android.service.chooser.ChooserTargetService; + +import androidx.core.content.ContextCompat; + +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.ui.ComposeMessage; +import org.kontalk.util.MediaStorage; +import org.kontalk.util.MessageUtils; + + +/** + * Direct share service. + * @author Daniele Ricci + */ +@TargetApi(Build.VERSION_CODES.M) +public class DirectShareTargetService extends ChooserTargetService { + + private static final int MAX_TARGETS = 5; + + private Paint roundPaint; + private RectF bitmapRect; + + /** + * Returns a list of targets available for sharing, in this case, the last {@link #MAX_TARGETS} + * open conversations. + */ + @Override + public List onGetChooserTargets(ComponentName targetActivityName, IntentFilter matchedFilter) { + ComponentName componentName = new ComponentName(getPackageName(), + ComposeMessage.class.getCanonicalName()); + + Cursor cursor = MessagesProviderClient.getLatestThreads(this, true, MAX_TARGETS); + if (cursor.moveToFirst()) { + List targets = new ArrayList<>(MAX_TARGETS); + do { + String userId = cursor.getString(MessagesProviderClient.LATEST_THREADS_COLUMN_PEER); + boolean isGroup = cursor.getString(MessagesProviderClient.LATEST_THREADS_COLUMN_GROUP_JID) != null; + + String displayName; + Icon avatar; + if (!isGroup) { + Contact contact = Contact.findByUserId(this, userId); + displayName = contact.getDisplayName(); + avatar = Icon.createWithBitmap(contact.getAvatarBitmap(this)); + } + else { + displayName = cursor.getString(MessagesProviderClient.LATEST_THREADS_COLUMN_GROUP_SUBJECT); + // groups don't have avatars yet, for now we use the default icon + avatar = getGroupAvatar(); + } + + // ComposeMessage will get an ACTION_SEND intent with a user id extra. + // This will skip the chooseContact() step and go directly to the conversation + Bundle extras = new Bundle(); + extras.putString(ComposeMessage.EXTRA_USERID, userId); + targets.add(new ChooserTarget(displayName, avatar, 0.5f, componentName, extras)); + } while (cursor.moveToNext()); + return targets; + } + + return Collections.emptyList(); + } + + /** + * @deprecated This will go away when groups will have their own avatars and will be + * retrieved by {@link Contact}. + */ + @Deprecated + private Icon getGroupAvatar() { + // optimize for rounded avatars + if (Contact.isRoundedAvatars()) { + Bitmap avatarOriginal = MessageUtils.drawableToBitmap(ContextCompat + .getDrawable(this, R.drawable.ic_default_group)); + Bitmap avatarRounded = MediaStorage.createRoundBitmap(avatarOriginal); + if (avatarRounded != avatarOriginal) + avatarOriginal.recycle(); + return Icon.createWithBitmap(avatarRounded); + } + else { + return Icon.createWithResource(this, R.drawable.ic_default_group); + } + } + +} diff --git a/app/src/main/java/org/kontalk/service/DownloadListener.java b/app/src/main/java/org/kontalk/service/DownloadListener.java index c01fd529b..0348a0716 100644 --- a/app/src/main/java/org/kontalk/service/DownloadListener.java +++ b/app/src/main/java/org/kontalk/service/DownloadListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -29,7 +29,7 @@ public interface DownloadListener { * @param destination * @param length the declared Content-Length */ - public void start(String url, File destination, long length); + void start(String url, File destination, long length); /** * Called now and then while receiving data in. @@ -37,14 +37,14 @@ public interface DownloadListener { * @param destination * @param bytes how many bytes have been downloaded so far */ - public void progress(String url, File destination, long bytes); + void progress(String url, File destination, long bytes); /** * Called when the download has been completed. * @param url * @param destination */ - public void completed(String url, String mime, File destination); + void completed(String url, String mime, File destination); /** * Called if an error occured while download. @@ -52,6 +52,6 @@ public interface DownloadListener { * @param destination * @param exc the exception occured */ - public void error(String url, File destination, Throwable exc); + void error(String url, File destination, Throwable exc); } diff --git a/app/src/main/java/org/kontalk/service/DownloadService.java b/app/src/main/java/org/kontalk/service/DownloadService.java index a7e92b845..1135dc568 100644 --- a/app/src/main/java/org/kontalk/service/DownloadService.java +++ b/app/src/main/java/org/kontalk/service/DownloadService.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,37 +18,35 @@ package org.kontalk.service; -/* - * TODO instead of using a notification ID per type, use a notification ID per - * download. - */ - import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.security.PrivateKey; import java.util.Date; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; -import android.app.IntentService; +import org.greenrobot.eventbus.EventBus; + import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; -import android.content.ContentUris; -import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Bundle; -import android.support.v4.app.NotificationCompat; -import android.util.Log; +import androidx.annotation.NonNull; +import androidx.core.app.JobIntentService; +import androidx.core.app.NotificationCompat; + +import org.kontalk.BuildConfig; import org.kontalk.Kontalk; +import org.kontalk.Log; import org.kontalk.R; import org.kontalk.client.ClientHTTPConnection; import org.kontalk.client.EndpointServer; @@ -56,20 +54,19 @@ import org.kontalk.crypto.DecryptException; import org.kontalk.crypto.PersonalKey; import org.kontalk.message.CompositeMessage; -import org.kontalk.provider.MyMessages.Messages; -import org.kontalk.provider.UsersProvider; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.reporting.ReportingManager; import org.kontalk.service.msgcenter.MessageCenterService; -import org.kontalk.ui.ConversationList; +import org.kontalk.ui.ConversationsActivity; import org.kontalk.ui.MessagingNotification; import org.kontalk.ui.ProgressNotificationBuilder; +import org.kontalk.util.EventBusIndex; import org.kontalk.util.MediaStorage; -import org.kontalk.util.Preferences; -import org.kontalk.util.StepTimer; import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_DOWNLOADING; import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_DOWNLOAD_ERROR; import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_DOWNLOAD_OK; -import static org.kontalk.ui.MessagingNotification.NOTIFICATION_UPDATE_DELAY; /** @@ -77,14 +74,38 @@ * TODO implement multiple downloads in queue or in parallel * @author Daniele Ricci */ -public class DownloadService extends IntentService implements DownloadListener { +public class DownloadService extends JobIntentService implements DownloadListener { private static final String TAG = MessageCenterService.TAG; + private static final int JOB_ID = 1001; + + // used only for events to UI + private static final EventBus BUS; + + static { + BUS = EventBus.builder() + // TODO .logger(...) + .addIndex(new EventBusIndex()) + .throwSubscriberException(BuildConfig.DEBUG) + .logNoSubscriberMessages(BuildConfig.DEBUG) + .build(); + } + /** A map to avoid duplicate downloads. */ - private static final Map sQueue = new LinkedHashMap(); + private static final Map sQueue = new LinkedHashMap<>(); - public static final String ACTION_DOWNLOAD_URL = "org.kontalk.action.DOWNLOAD_URL"; - public static final String ACTION_DOWNLOAD_ABORT = "org.kontalk.action.DOWNLOAD_ABORT"; + private static final String ACTION_DOWNLOAD_URL = "org.kontalk.action.DOWNLOAD_URL"; + private static final String ACTION_DOWNLOAD_ABORT = "org.kontalk.action.DOWNLOAD_ABORT"; + + private static final String EXTRA_MSG_ID = "org.kontalk.download.message.id"; + private static final String EXTRA_MSG_SERVER_ID = "org.kontalk.download.message.serverId"; + private static final String EXTRA_MSG_SENDER = "org.kontalk.message.download.sender"; + private static final String EXTRA_MSG_CONVERSATION = "org.kontalk.message.download.context"; + private static final String EXTRA_MSG_MIME = "org.kontalk.message.download.mime"; + private static final String EXTRA_MSG_TIMESTAMP = "org.kontalk.message.download.timestamp"; + private static final String EXTRA_MSG_ENCRYPTED = "org.kontalk.message.download.encrypted"; + private static final String EXTRA_MSG_COMPRESS = "org.kontalk.message.download.compress"; + private static final String EXTRA_NOTIFY = "org.kontalk.download.notify"; private ProgressNotificationBuilder mNotificationBuilder; private NotificationManager mNotificationManager; @@ -92,45 +113,41 @@ public class DownloadService extends IntentService implements DownloadListener { // data about the download currently being processed private Notification mCurrentNotification; private long mTotalBytes; - /** Step timer for notification updates. */ - private StepTimer mUpdateTimer = new StepTimer(NOTIFICATION_UPDATE_DELAY); private long mMessageId; private String mPeer; + private String mConversation; private boolean mEncrypted; + private boolean mNotify; private ClientHTTPConnection mDownloadClient; private boolean mCanceled; - public DownloadService() { - super(DownloadService.class.getSimpleName()); - } - @Override - public int onStartCommand(Intent intent, int flags, int startId) { + public void onCreate() { + super.onCreate(); if (mNotificationManager == null) mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - - if (ACTION_DOWNLOAD_ABORT.equals(intent.getAction())) { - final Uri uri = intent.getData(); - new Thread(new Runnable() { - @Override - public void run() { - onDownloadAbort(uri); - } - }).start(); - } - - return super.onStartCommand(intent, flags, startId); } @Override - protected void onHandleIntent(Intent intent) { + protected void onHandleWork(@NonNull Intent intent) { String action = intent.getAction(); if (ACTION_DOWNLOAD_URL.equals(action)) { onDownloadURL(intent.getData(), intent.getExtras()); } + else if (ACTION_DOWNLOAD_ABORT.equals(intent.getAction())) { + onDownloadAbort(intent.getData()); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (mDownloadClient != null) { + mDownloadClient.close(); + } } private void onDownloadURL(Uri uri, Bundle args) { @@ -143,46 +160,41 @@ private void onDownloadURL(Uri uri, Bundle args) { startForeground(0); mCanceled = false; - if (mDownloadClient == null) { - PersonalKey key; - PrivateKey privateKey; - try { - key = ((Kontalk) getApplication()).getPersonalKey(); - privateKey = key.getBridgePrivateKey(); - } - catch (Exception e) { - // TODO i18n :) - errorNotification("ERROR", "NAUGHTY BOY/GIRL!"); - return; - } + mNotify = args.getBoolean(EXTRA_NOTIFY, true); - mDownloadClient = new ClientHTTPConnection(this, - privateKey, key.getBridgeCertificate()); + if (mDownloadClient == null) { + mDownloadClient = new ClientHTTPConnection(this); } try { - // check if external storage is available - if (!MediaStorage.isExternalStorageAvailable()) { - errorNotification(getString(R.string.notify_ticker_external_storage), - getString(R.string.notify_text_external_storage)); - return; - } - - // make sure storage directory is present - MediaStorage.MEDIA_ROOT.mkdirs(); + // needed in case the next if triggers + mConversation = args.getString(EXTRA_MSG_CONVERSATION); - mMessageId = args.getLong(CompositeMessage.MSG_ID, 0); - mPeer = args.getString(CompositeMessage.MSG_SENDER); - mEncrypted = args.getBoolean(CompositeMessage.MSG_ENCRYPTED, false); + mMessageId = args.getLong(EXTRA_MSG_ID, 0); + mPeer = args.getString(EXTRA_MSG_SENDER); + mEncrypted = args.getBoolean(EXTRA_MSG_ENCRYPTED, false); sQueue.put(url, mMessageId); - Date date = null; - long timestamp = args.getLong(CompositeMessage.MSG_TIMESTAMP); + Date date; + long timestamp = args.getLong(EXTRA_MSG_TIMESTAMP); if (timestamp > 0) date = new Date(timestamp); + else + date = new Date(); + + String mime = args.getString(EXTRA_MSG_MIME); + + // this will be used if the server doesn't provide one + // if the server provides a filename, only the path will be used + File defaultFile = CompositeMessage.getIncomingFile(this, mime, date); + if (defaultFile == null) { + defaultFile = MediaStorage.getIncomingFile(this, date, "bin"); + } + + BUS.post(new DownloadStarted(mMessageId)); // download content - mDownloadClient.downloadAutofilename(url, MediaStorage.MEDIA_ROOT, date, this); + mDownloadClient.downloadAutofilename(url, defaultFile, date, this); } catch (Exception e) { error(url, null, e); @@ -194,7 +206,7 @@ private void onDownloadURL(Uri uri, Bundle args) { } } - private void onDownloadAbort(Uri uri) { + void onDownloadAbort(Uri uri) { String url = uri.toString(); Long msgId = sQueue.get(url); if (msgId != null) { @@ -213,13 +225,14 @@ public void startForeground(long totalBytes) { Log.d(TAG, "starting foreground progress notification"); mTotalBytes = totalBytes; - Intent ni = new Intent(getApplicationContext(), ConversationList.class); + Intent ni = new Intent(getApplicationContext(), ConversationsActivity.class); // FIXME this intent should actually open the ComposeMessage activity PendingIntent pi = PendingIntent.getActivity(getApplicationContext(), NOTIFICATION_ID_DOWNLOADING, ni, 0); if (mNotificationBuilder == null) { mNotificationBuilder = new ProgressNotificationBuilder(getApplicationContext(), + MessagingNotification.CHANNEL_MEDIA_DOWNLOAD, R.layout.progress_notification, getString(R.string.downloading_attachment), R.drawable.ic_stat_notify, @@ -247,7 +260,6 @@ public void stopForeground() { @Override public void start(String url, File destination, long length) { - mUpdateTimer.reset(); startForeground(length); } @@ -255,7 +267,8 @@ public void start(String url, File destination, long length) { public void completed(String url, String mime, File destination) { Uri uri = Uri.fromFile(destination); - ContentValues values = null; + boolean destinationEncrypted = mEncrypted; + long destinationLength = -1; // encrypted file? if (mEncrypted) { @@ -270,15 +283,15 @@ public void completed(String url, String mime, File destination) { InputStream in = null; OutputStream out = null; try { - EndpointServer server = Preferences.getEndpointServer(this); - PersonalKey key = ((Kontalk) getApplicationContext()).getPersonalKey(); - Coder coder = UsersProvider.getDecryptCoder(this, server, key, mPeer); + EndpointServer server = Kontalk.get().getEndpointServer(); + PersonalKey key = Kontalk.get().getPersonalKey(); + Coder coder = Keyring.getDecryptCoder(this, server, key, mPeer); if (coder != null) { in = new FileInputStream(destination); File outFile = new File(destination + ".new"); out = new FileOutputStream(outFile); - List errors = new LinkedList(); + List errors = new LinkedList<>(); coder.decryptFile(in, true, out, errors); // TODO process errors @@ -288,40 +301,37 @@ public void completed(String url, String mime, File destination) { outFile.renameTo(destination); // save this for later - values = new ContentValues(3); - values.put(Messages.ATTACHMENT_ENCRYPTED, false); - values.put(Messages.ATTACHMENT_LENGTH, destination.length()); + destinationEncrypted = false; + destinationLength = destination.length(); } } catch (Exception e) { Log.e(TAG, "decryption failed!", e); errorNotification(getString(R.string.notify_ticker_download_error), - // TODO i18n - "Decryption failed."); + getString(R.string.notify_text_decryption_error)); return; } finally { try { - in.close(); + if (in != null) + in.close(); } - catch (Exception e) { + catch (IOException e) { // ignored } try { - out.close(); + if (out != null) + out.close(); } - catch (Exception e) { + catch (IOException e) { // ignored } } } - // update messages.localUri - if (values == null) - values = new ContentValues(1); - values.put(Messages.ATTACHMENT_LOCAL_URI, uri.toString()); - getContentResolver().update(ContentUris - .withAppendedId(Messages.CONTENT_URI, mMessageId), values, null, null); + // mark file as downloaded + MessagesProviderClient.downloaded(this, mMessageId, uri, + destinationEncrypted, destinationLength); // update media store MediaStorage.scanFile(this, destination, mime); @@ -330,7 +340,7 @@ public void completed(String url, String mime, File destination) { stopForeground(); // notify only if conversation is not open - if (!mPeer.equals(MessagingNotification.getPaused())) { + if (!MessagingNotification.isPaused(mConversation) && mNotify) { // detect mime type if not available if (mime == null) @@ -338,18 +348,21 @@ public void completed(String url, String mime, File destination) { // create intent for download complete notification Intent i = new Intent(Intent.ACTION_VIEW); + uri = MediaStorage.getWorldReadableUri(this, uri, i); i.setDataAndType(uri, mime); i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent pi = PendingIntent.getActivity(getApplicationContext(), NOTIFICATION_ID_DOWNLOAD_OK, i, 0); // create notification - NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext()) + NotificationCompat.Builder builder = new NotificationCompat + .Builder(getApplicationContext(), MessagingNotification.CHANNEL_MEDIA_DOWNLOAD) .setSmallIcon(R.drawable.ic_stat_notify) .setContentTitle(getString(R.string.notify_title_download_completed)) .setContentText(getString(R.string.notify_text_download_completed)) .setTicker(getString(R.string.notify_ticker_download_completed)) .setContentIntent(pi) + .setPriority(NotificationCompat.PRIORITY_MIN) .setAutoCancel(true); // notify!! @@ -361,24 +374,33 @@ public void completed(String url, String mime, File destination) { public void error(String url, File destination, Throwable exc) { Log.e(TAG, "download error", exc); stopForeground(); - if (!mCanceled) + if (!mCanceled) { + ReportingManager.logException(exc); errorNotification(getString(R.string.notify_ticker_download_error), getString(R.string.notify_text_download_error)); + } } private void errorNotification(String ticker, String text) { + errorNotification(ticker, text, getString(R.string.notify_title_download_error), null); + } + + private void errorNotification(String ticker, String text, String title, Intent baseIntent) { // create intent for download error notification - Intent i = new Intent(this, ConversationList.class); + Intent i = baseIntent != null ? baseIntent : new Intent(this, ConversationsActivity.class); i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent pi = PendingIntent.getActivity(getApplicationContext(), NOTIFICATION_ID_DOWNLOAD_ERROR, i, 0); // create notification - NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext()) + NotificationCompat.Builder builder = new NotificationCompat + .Builder(getApplicationContext(), MessagingNotification.CHANNEL_MEDIA_DOWNLOAD) .setSmallIcon(R.drawable.ic_stat_notify) - .setContentTitle(getString(R.string.notify_title_download_error)) + .setContentTitle(title) .setContentText(text) .setTicker(ticker) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_ERROR) .setContentIntent(pi) .setAutoCancel(true); @@ -388,7 +410,7 @@ private void errorNotification(String ticker, String text) { @Override public void progress(String url, File destination, long bytes) { - if (mCurrentNotification != null && (bytes >= mTotalBytes || mUpdateTimer.isStep())) { + if (mCurrentNotification != null) { int progress = (int) ((100 * bytes) / mTotalBytes); foregroundNotification(progress); // send the updates to the notification manager @@ -396,7 +418,42 @@ public void progress(String url, File destination, long bytes) { } } + public static EventBus bus() { + return BUS; + } + public static boolean isQueued(String url) { return sQueue.containsKey(url); } + + public static void start(Context context, long databaseId, String sender, + String mime, long timestamp, boolean encrypted, String url, String conversation, boolean notify) { + Intent i = new Intent(context, DownloadService.class); + i.setAction(ACTION_DOWNLOAD_URL); + i.putExtra(EXTRA_MSG_ID, databaseId); + i.putExtra(EXTRA_MSG_SENDER, sender); + i.putExtra(EXTRA_MSG_CONVERSATION, conversation); + i.putExtra(EXTRA_MSG_MIME, mime); + i.putExtra(EXTRA_MSG_TIMESTAMP, timestamp); + i.putExtra(EXTRA_MSG_ENCRYPTED, encrypted); + i.putExtra(EXTRA_NOTIFY, notify); + i.setData(Uri.parse(url)); + enqueueWork(context, DownloadService.class, JOB_ID, i); + } + + public static void abort(Context context, Uri uri) { + Intent i = new Intent(context, DownloadService.class); + i.setAction(ACTION_DOWNLOAD_ABORT); + i.setData(uri); + enqueueWork(context, DownloadService.class, JOB_ID, i); + } + + public static class DownloadStarted { + public final long id; + + DownloadStarted(long id) { + this.id = id; + } + } + } diff --git a/app/src/main/java/org/kontalk/service/KeyPairGeneratorService.java b/app/src/main/java/org/kontalk/service/KeyPairGeneratorService.java index 9e6e0e564..5006da7d0 100644 --- a/app/src/main/java/org/kontalk/service/KeyPairGeneratorService.java +++ b/app/src/main/java/org/kontalk/service/KeyPairGeneratorService.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,14 +18,11 @@ package org.kontalk.service; -import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_KEYPAIR_GEN; - import java.io.IOException; import java.lang.ref.WeakReference; +import java.util.Date; -import org.kontalk.R; -import org.kontalk.crypto.PersonalKey; -import org.kontalk.ui.ConversationList; +import com.instacart.library.truetime.TrueTime; import android.app.Notification; import android.app.PendingIntent; @@ -36,9 +33,16 @@ import android.os.Handler; import android.os.IBinder; import android.os.Process; -import android.support.v4.app.NotificationCompat; -import android.support.v4.content.LocalBroadcastManager; -import android.util.Log; +import androidx.core.app.NotificationCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.crypto.PersonalKey; +import org.kontalk.ui.ConversationsActivity; +import org.kontalk.ui.MessagingNotification; + +import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_KEYPAIR_GEN; /** Generates a key pair in the background. */ @@ -59,6 +63,9 @@ public class KeyPairGeneratorService extends Service { public static final String EXTRA_KEY = "org.kontalk.keypair.KEY"; public static final String EXTRA_FOREGROUND = "org.kontalk.keypair.FOREGROUND"; + private static final String NTP_DEFAULT_SERVER = "time.google.com"; + private static final int NTP_MAX_RETRIES = 3; + private GeneratorThread mThread; private volatile PersonalKey mKey; @@ -97,18 +104,20 @@ public IBinder onBind(Intent intent) { } private void startForeground() { - Intent ni = new Intent(getApplicationContext(), ConversationList.class); + Intent ni = new Intent(getApplicationContext(), ConversationsActivity.class); // FIXME this intent should actually open the ComposeMessage activity PendingIntent pi = PendingIntent.getActivity(getApplicationContext(), NOTIFICATION_ID_KEYPAIR_GEN, ni, 0); - Notification no = new NotificationCompat.Builder(this) + Notification no = new NotificationCompat + .Builder(this, MessagingNotification.CHANNEL_OTHER) .setOngoing(true) .setTicker(getString(R.string.notify_gen_keypair_ticker)) .setSmallIcon(R.drawable.ic_stat_notify) .setContentTitle(getString(R.string.notify_gen_keypair_title)) .setContentText(getString(R.string.notify_gen_keypair_text)) .setContentIntent(pi) + .setPriority(NotificationCompat.PRIORITY_MIN) .build(); startForeground(NOTIFICATION_ID_KEYPAIR_GEN, no); @@ -137,8 +146,8 @@ private void keypairGenerated(PersonalKey key) { private static final class GeneratorThread extends Thread { private WeakReference s; - public GeneratorThread(KeyPairGeneratorService service) { - s = new WeakReference(service); + GeneratorThread(KeyPairGeneratorService service) { + s = new WeakReference<>(service); } @Override @@ -148,8 +157,11 @@ public void run() { KeyPairGeneratorService service = s.get(); if (service != null) { + // we need the real time from the Internet + Date timestamp = getRealtime(service); + try { - PersonalKey key = PersonalKey.create(); + PersonalKey key = PersonalKey.create(timestamp); Log.v("KeyPair", "key pair generated: " + key); service.keypairGenerated(key); } @@ -161,10 +173,39 @@ public void run() { service.stopForeground(); } } + + private Date getRealtime(Context context) { + try { + return TrueTime.now(); + } + catch (IllegalStateException e) { + int retryCount = 0; + while (retryCount < NTP_MAX_RETRIES) { + try { + TrueTime.build() + .withSharedPreferences(context) + .withNtpHost(NTP_DEFAULT_SERVER) + .initialize(); + break; + } + catch (IOException ioe) { + retryCount++; + } + } + + try { + return TrueTime.now(); + } + catch (Exception ise) { + Log.w("KeyPair", "unable to retrieve real time from network, using system time"); + return new Date(); + } + } + } } public interface PersonalKeyRunnable { - public void run(PersonalKey key); + void run(PersonalKey key); } public final static class KeyGeneratorReceiver extends BroadcastReceiver { diff --git a/app/src/main/java/org/kontalk/service/MediaService.java b/app/src/main/java/org/kontalk/service/MediaService.java new file mode 100644 index 000000000..814c1801c --- /dev/null +++ b/app/src/main/java/org/kontalk/service/MediaService.java @@ -0,0 +1,153 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service; + +import java.io.File; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.core.app.JobIntentService; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.kontalk.Log; +import org.kontalk.message.ImageComponent; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.provider.MessagesProviderClient.MessageUpdater; +import org.kontalk.provider.MyMessages; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.util.MediaStorage; +import org.kontalk.util.Preferences; + + +/** + * A service to handle background requests to generate thumbnail, compress + * images, videos and the like. + * @author Daniele Ricci + */ +public class MediaService extends JobIntentService { + private static final String TAG = MessageCenterService.TAG; + + private static final int JOB_ID = 1000; + + private static final String ACTION_PREPARE_MESSAGE = "org.kontalk.action.PREPARE_MESSAGE"; + + private static final String EXTRA_MSG_ID = "org.kontalk.media.message.id"; + private static final String EXTRA_MSG_SERVER_ID = "org.kontalk.media.message.serverId"; + private static final String EXTRA_MSG_MIME = "org.kontalk.media.message.mime"; + private static final String EXTRA_MSG_MEDIA = "org.kontalk.media.message.media"; + private static final String EXTRA_MSG_COMPRESS = "org.kontalk.media.message.compress"; + + /** + * Broadcasted when a media message is ready for sending. + * @deprecated We should use the event bus. + */ + @Deprecated + public static final String ACTION_MEDIA_READY = "org.kontalk.action.MEDIA_READY"; + + /** + * Broadcasted when a media message preparation failed. + * @deprecated We should use the event bus. + */ + @Deprecated + public static final String ACTION_MEDIA_FAILED = "org.kontalk.action.MEDIA_FAILED"; + + @Override + protected void onHandleWork(@NonNull Intent intent) { + String action = intent.getAction(); + + if (ACTION_PREPARE_MESSAGE.equals(action)) { + onPrepareMessage(intent.getData(), intent.getExtras()); + } + } + + private void onPrepareMessage(Uri uri, Bundle args) { + long databaseId = args.getLong(EXTRA_MSG_ID); + String mime = args.getString(EXTRA_MSG_MIME); + boolean media = args.getBoolean(EXTRA_MSG_MEDIA, false); + + try { + File previewFile = null; + long length; + + int compress = 0; + // FIXME hard-coded to ImageComponent (how about videos?) + if (ImageComponent.supportsMimeType(mime)) { + compress = Preferences.getImageCompression(this); + + // generate thumbnail + String filename = ImageComponent.buildMediaFilename(MediaStorage.THUMBNAIL_MIME_NETWORK); + previewFile = MediaStorage.cacheThumbnail(this, uri, filename, true); + } + + if (compress > 0) { + File compressed = MediaStorage.resizeImage(this, uri, compress); + length = compressed.length(); + // use the compressed image from now on + uri = Uri.fromFile(compressed); + } + else if (media) { + File copy = MediaStorage.copyOutgoingMedia(this, uri); + length = copy.length(); + uri = Uri.fromFile(copy); + } + else { + length = MediaStorage.getLength(this, uri); + } + + MessagesProviderClient.updateMedia(this, databaseId, + previewFile != null ? previewFile.toString() : null, + uri, length); + + // TODO post event to message center bus + Intent i = new Intent(ACTION_MEDIA_READY); + i.putExtra("org.kontalk.message.msgId", databaseId); + LocalBroadcastManager.getInstance(this).sendBroadcast(i); + } + catch (Exception e) { + Log.w(TAG, "unable to prepare media for sending", e); + ReportingManager.logException(e); + MessageUpdater.forMessage(this, databaseId) + .setStatus(MyMessages.Messages.STATUS_NOTACCEPTED) + .commit(); + + // TODO post event to message center bus + Intent i = new Intent(ACTION_MEDIA_FAILED); + i.putExtra("org.kontalk.message.msgId", databaseId); + LocalBroadcastManager.getInstance(this).sendBroadcast(i); + } + } + + public static void prepareMessage(Context context, String msgId, long databaseId, Uri uri, String mime, boolean media, int compress) { + Intent i = new Intent(context, MediaService.class); + i.setAction(ACTION_PREPARE_MESSAGE); + i.putExtra(EXTRA_MSG_SERVER_ID, msgId); + i.putExtra(EXTRA_MSG_ID, databaseId); + i.putExtra(EXTRA_MSG_MIME, mime); + i.putExtra(EXTRA_MSG_MEDIA, media); + i.putExtra(EXTRA_MSG_COMPRESS, compress); + i.setData(uri); + enqueueWork(context, MediaService.class, JOB_ID, i); + } + +} diff --git a/app/src/main/java/org/kontalk/service/MessagesImporterService.java b/app/src/main/java/org/kontalk/service/MessagesImporterService.java new file mode 100644 index 000000000..c319904b3 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/MessagesImporterService.java @@ -0,0 +1,64 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +import android.content.Context; +import android.content.Intent; +import android.database.sqlite.SQLiteException; +import android.net.Uri; + +import org.kontalk.provider.MessagesProvider; + + +/** + * Imports a messages.db file into the local content provider. + * @author Daniele Ricci + */ +public class MessagesImporterService extends DatabaseImporterService { + + @Override + protected void lockDestinationDatabase() { + MessagesProvider.lockForImport(this); + } + + @Override + protected OutputStream getDestinationStream() throws IOException { + return new FileOutputStream(MessagesProvider.getDatabaseUri(this)); + } + + @Override + protected void reloadDatabase() throws SQLiteException { + MessagesProvider.reload(this); + } + + @Override + protected void unlockDestinationDatabase() { + MessagesProvider.unlockForImport(this); + } + + public static void startImport(Context context, Uri origin) { + context.startService(new Intent(ACTION_IMPORT, origin, + context, MessagesImporterService.class)); + } + +} diff --git a/app/src/main/java/org/kontalk/service/NetworkStateReceiver.java b/app/src/main/java/org/kontalk/service/NetworkStateReceiver.java index c0dedc64b..c86c98965 100644 --- a/app/src/main/java/org/kontalk/service/NetworkStateReceiver.java +++ b/app/src/main/java/org/kontalk/service/NetworkStateReceiver.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,17 +18,17 @@ package org.kontalk.service; -import org.kontalk.Kontalk; -import org.kontalk.service.msgcenter.AdaptiveServerPingManager; -import org.kontalk.service.msgcenter.MessageCenterService; -import org.kontalk.util.Preferences; - import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.net.ConnectivityManager; import android.net.NetworkInfo; -import android.util.Log; + +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.service.msgcenter.AndroidAdaptiveServerPingManager; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.util.Preferences; /** @@ -64,7 +64,7 @@ public void onReceive(Context context, Intent intent) { // start message center serviceAction = ACTION_START; // notify ping manager that connection type has changed - AdaptiveServerPingManager.onConnected(); + AndroidAdaptiveServerPingManager.onConnected(); } } @@ -73,8 +73,12 @@ else if (ConnectivityManager.CONNECTIVITY_ACTION.equals(action)) { // TODO handle FAILOVER_CONNECTION final NetworkInfo info = cm.getActiveNetworkInfo(); - if (info != null) { - Log.w(TAG, "network state changed!"); + if (info == null) { + Log.d(TAG, "no network available!"); + serviceAction = ACTION_STOP; + } + else if (info.isConnected()) { + Log.d(TAG, "connected to network!"); if (info.getType() == ConnectivityManager.TYPE_MOBILE && !shouldReconnect(context)) { @@ -82,24 +86,10 @@ else if (ConnectivityManager.CONNECTIVITY_ACTION.equals(action)) { return; } - switch (info.getState()) { - case CONNECTED: - // test connection or reconnect - serviceAction = ACTION_TEST; - // notify ping manager that connection type has changed - AdaptiveServerPingManager.onConnected(); - break; - case SUSPENDED: - Log.v(TAG, "suspending network traffic"); - break; - default: - serviceAction = ACTION_STOP; - break; - } - } - else { - // no network info available - serviceAction = ACTION_STOP; + // test connection or reconnect + serviceAction = ACTION_TEST; + // notify ping manager that connection type has changed + AndroidAdaptiveServerPingManager.onConnected(); } } @@ -114,7 +104,7 @@ else if (ConnectivityManager.CONNECTIVITY_ACTION.equals(action)) { break; case ACTION_TEST: // connection test - MessageCenterService.test(context); + MessageCenterService.test(context, true); break; } } @@ -122,11 +112,10 @@ else if (ConnectivityManager.CONNECTIVITY_ACTION.equals(action)) { private boolean shouldReconnect(Context context) { // check if some activity is holding to the message center // or there is a pending push notification - if (((Kontalk) context.getApplicationContext()).hasReference() || - Preferences.getLastPushNotification(context) < 0) + if (Kontalk.get().hasReference() || Preferences.getLastPushNotification() > 0) return true; - long lastConnect = Preferences.getLastConnection(context); + long lastConnect = Preferences.getLastConnection(); // no last connection registered if (lastConnect < 0) @@ -134,8 +123,7 @@ private boolean shouldReconnect(Context context) { long now = System.currentTimeMillis(); long diff = Preferences.getWakeupTimeMillis(context, - MessageCenterService.MIN_WAKEUP_TIME, - MessageCenterService.DEFAULT_WAKEUP_TIME); + MessageCenterService.MIN_WAKEUP_TIME); return (now - lastConnect) >= diff; } diff --git a/app/src/main/java/org/kontalk/service/NotificationActionReceiver.java b/app/src/main/java/org/kontalk/service/NotificationActionReceiver.java new file mode 100644 index 000000000..7604a010c --- /dev/null +++ b/app/src/main/java/org/kontalk/service/NotificationActionReceiver.java @@ -0,0 +1,119 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service; + +import android.content.BroadcastReceiver; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import androidx.core.app.RemoteInput; + +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.data.Conversation; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.ui.MessagingNotification; + + +/** + * Broadcast receiver for notification actions. + * @author Daniele Ricci + */ +public class NotificationActionReceiver extends BroadcastReceiver { + private static final String TAG = Kontalk.TAG; + + private static final String KEY_TEXT_REPLY = "key_text_reply"; + + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + + if (MessagingNotification.ACTION_NOTIFICATION_DELETED.equals(action)) { + // mark threads as old + Parcelable[] threads = intent.getParcelableArrayExtra("org.kontalk.datalist"); + if (threads == null) { + Log.w(TAG, "Notification delete action could not be completed because of a firmware bug"); + // mark all threads as read instead + MessagesProviderClient.markAllThreadsAsOld(context); + + ReportingManager.logException(new IllegalArgumentException("notification action data is null")); + } + else { + for (Parcelable uri : threads) + MessagesProviderClient.markThreadAsOld(context, ContentUris.parseId((Uri) uri)); + MessagingNotification.delayedUpdateMessagesNotification(context, false); + } + } + else if (MessagingNotification.ACTION_NOTIFICATION_REPLY.equals(action)) { + // mark threads as read + long threadId = ContentUris.parseId(intent.getData()); + MessagesProviderClient.markThreadAsRead(context, threadId); + + // send reply + Bundle result = RemoteInput.getResultsFromIntent(intent); + if (result != null) { + Conversation conv = Conversation.loadFromId(context, threadId); + CharSequence text = result.getCharSequence(KEY_TEXT_REPLY); + if (text != null) { + Kontalk.get().getMessagesController() + .sendTextMessage(conv, text.toString(), 0, true); + } + else { + // it shouldn't happen, but you know, Android... + Log.w(Kontalk.TAG, "Unable to use direct reply content"); + ReportingManager.logException(new UnsupportedOperationException + ("direct reply content is null")); + } + } + + // TODO show notification with the reply for a short time + // https://developer.android.com/guide/topics/ui/notifiers/notifications.html#direct + + MessagingNotification.delayedUpdateMessagesNotification(context, false); + } + else if (MessagingNotification.ACTION_NOTIFICATION_MARK_READ.equals(action)) { + // do not crash on buggy firmware + if (intent.getData() == null) { + // mark all threads as read instead + MessagesProviderClient.markAllThreadsAsRead(context); + + ReportingManager.logException(new IllegalArgumentException("notification action data is null")); + } + else { + // mark threads as read + MessagesProviderClient.markThreadAsRead(context, ContentUris.parseId(intent.getData())); + } + + MessagingNotification.delayedUpdateMessagesNotification(context, false); + } + } + + public static RemoteInput buildReplyInput(Context context) { + String replyLabel = context.getString(R.string.reply); + return new RemoteInput.Builder(KEY_TEXT_REPLY) + .setLabel(replyLabel) + .build(); + } + +} diff --git a/app/src/main/java/org/kontalk/service/ProgressListener.java b/app/src/main/java/org/kontalk/service/ProgressListener.java index 7ddc0aeae..4b07b9002 100644 --- a/app/src/main/java/org/kontalk/service/ProgressListener.java +++ b/app/src/main/java/org/kontalk/service/ProgressListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,13 +28,13 @@ public interface ProgressListener { /** Called when operation is starting (before writeTo happens). */ - public void start(UploadConnection conn); + void start(UploadConnection conn); /** * Called now and then while processing data. * Useful for checking the upload progress. * @param bytes how many bytes have been uploaded so far */ - public void progress(UploadConnection conn, long bytes); + void progress(UploadConnection conn, long bytes); } diff --git a/app/src/main/java/org/kontalk/service/ServerListUpdater.java b/app/src/main/java/org/kontalk/service/ServerListUpdater.java index 6a1c64ad9..d5896a424 100644 --- a/app/src/main/java/org/kontalk/service/ServerListUpdater.java +++ b/app/src/main/java/org/kontalk/service/ServerListUpdater.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,24 +24,27 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.text.DateFormat; -import java.text.SimpleDateFormat; import java.util.Date; -import java.util.Locale; import java.util.Properties; -import android.content.BroadcastReceiver; +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; + import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.support.v4.content.LocalBroadcastManager; -import android.util.Log; +import org.kontalk.Kontalk; +import org.kontalk.Log; import org.kontalk.R; import org.kontalk.client.EndpointServer; import org.kontalk.client.ServerList; +import org.kontalk.reporting.ReportingManager; import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.service.msgcenter.event.ConnectedEvent; +import org.kontalk.service.msgcenter.event.ServerListEvent; +import org.kontalk.service.msgcenter.event.ServerListRequest; import org.kontalk.util.Preferences; +import org.kontalk.util.SystemUtils; /** @@ -49,23 +52,22 @@ * This class doesn't need to be configured: it hides all the logic of picking * a random server, connecting to it, downloading the server list and saving it * in the application cache. Finally, it restarts the message center. + * * @author Daniele Ricci */ -public class ServerListUpdater extends BroadcastReceiver { +// TODO downloaded list should be saved in account user data +public class ServerListUpdater { private static final String TAG = ServerListUpdater.class.getSimpleName(); private static ServerList sCurrentList; - private static DateFormat sTimestampFormat = - new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US); - private final Context mContext; - private final LocalBroadcastManager mLocalBroadcastManager; private UpdaterListener mListener; + private EventBus mServiceBus = MessageCenterService.bus(); + public ServerListUpdater(Context context) { mContext = context; - mLocalBroadcastManager = LocalBroadcastManager.getInstance(context); } public void setListener(UpdaterListener listener) { @@ -73,13 +75,13 @@ public void setListener(UpdaterListener listener) { } public void start() { - /** + /* * We have a server list - either builtin or cached. Now pick a random * server from the list and contact it for the latest server list. */ - EndpointServer random = Preferences.getEndpointServer(mContext); + EndpointServer random = Kontalk.get().getEndpointServer(); - /** no server found -- notify to user */ + /* no server found -- notify to user */ if (random == null) { Log.i(TAG, "no list to pick a random server from - aborting"); @@ -91,26 +93,22 @@ public void start() { } // check for network - if (!MessageCenterService.isNetworkConnectionAvailable(mContext)) { + if (!SystemUtils.isNetworkConnectionAvailable(mContext)) { if (mListener != null) mListener.networkNotAvailable(); return; } // check for offline mode - if (Preferences.getOfflineMode(mContext)) { + if (Preferences.getOfflineMode()) { if (mListener != null) mListener.offlineModeEnabled(); return; } // register for and request connection status - IntentFilter f = new IntentFilter(); - f.addAction(MessageCenterService.ACTION_CONNECTED); - f.addAction(MessageCenterService.ACTION_SERVERLIST); - mLocalBroadcastManager.registerReceiver(this, f); + mServiceBus.register(this); - MessageCenterService.requestConnectionStatus(mContext); MessageCenterService.start(mContext); } @@ -119,67 +117,61 @@ public void cancel() { } private void unregisterReceiver() { - mLocalBroadcastManager.unregisterReceiver(this); + mServiceBus.unregister(this); } - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); + @Subscribe(sticky = true, threadMode = ThreadMode.BACKGROUND) + public void onConnected(ConnectedEvent event) { + // request serverlist + mServiceBus.post(new ServerListRequest(null)); + } - if (MessageCenterService.ACTION_CONNECTED.equals(action)) { - // request serverlist - MessageCenterService.requestServerList(mContext); - } + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onServerList(ServerListEvent event) { + // we don't need this any more + unregisterReceiver(); - else if (MessageCenterService.ACTION_SERVERLIST.equals(action)) { - // we don't need this any more - unregisterReceiver(); - - String[] items = intent - .getStringArrayExtra(MessageCenterService.EXTRA_JIDLIST); - if (items != null && items.length > 0) { - String network = intent.getStringExtra(MessageCenterService.EXTRA_FROM); - Properties prop = new Properties(); - Date now = new Date(); - ServerList list = new ServerList(now); - prop.setProperty("timestamp", sTimestampFormat.format(now)); - - for (int i = 0; i < items.length; i++) { - String item = network + '|' + items[i]; - prop.setProperty("server" + (i + 1), item); - list.add(new EndpointServer(item)); - } + if (event.servers != null && event.servers.length > 0) { + Date now = new Date(); + ServerList list = new ServerList(now); - OutputStream out = null; - try { - out = new FileOutputStream(getCachedListFile(mContext)); - prop.store(out, null); - out.close(); + for (int i = 0; i < event.servers.length; i++) { + String item = event.servers[i]; + list.add(new EndpointServer(item)); + } + + Properties prop = list.toProperties(); + + OutputStream out = null; + try { + out = new FileOutputStream(getCachedListFile(mContext)); + prop.store(out, null); + out.close(); - // update cached list - sCurrentList = list; + // update cached list + sCurrentList = list; - if (mListener != null) - mListener.updated(list); + if (mListener != null) + mListener.updated(list); + } + catch (IOException e) { + if (mListener != null) + mListener.error(e); + } + finally { + try { + if (out != null) + out.close(); } catch (IOException e) { - if (mListener != null) - mListener.error(e); - } - finally { - try { - out.close(); - } - catch (Exception e) { - // ignored - } + // ignored } } + } - else { - if (mListener != null) - mListener.error(null); - } + else { + if (mListener != null) + mListener.error(null); } } @@ -188,7 +180,9 @@ public static void deleteCachedList(Context context) { file.delete(); } - /** The path to the locally cached downloaded server list. */ + /** + * The path to the locally cached downloaded server list. + */ private static File getCachedListFile(Context context) { return new File(context.getCacheDir(), "serverlist.properties"); } @@ -196,24 +190,7 @@ private static File getCachedListFile(Context context) { private static ServerList parseList(InputStream in) throws IOException { Properties prop = new Properties(); prop.load(in); - - try { - Date date = sTimestampFormat.parse(prop.getProperty("timestamp")); - ServerList list = new ServerList(date); - int i = 1; - String server; - while ((server = prop.getProperty("server" + i)) != null) { - list.add(new EndpointServer(server)); - i++; - } - - return list; - } - catch (Exception e) { - IOException ie = new IOException("parse error"); - ie.initCause(e); - throw ie; - } + return ServerList.fromProperties(prop); } private static ServerList parseBuiltinList(Context context) throws IOException { @@ -249,36 +226,57 @@ private static ServerList parseCachedList(Context context) throws IOException { } } - /** Returns (and loads if necessary) the current server list. */ + /** + * Returns (and loads if necessary) the current server list. + */ public static ServerList getCurrentList(Context context) { if (sCurrentList != null) return sCurrentList; + ServerList builtin = null; try { + builtin = parseBuiltinList(context); sCurrentList = parseCachedList(context); + // use cached list if recent than + if (builtin.getDate().after(sCurrentList.getDate())) + sCurrentList = builtin; } catch (IOException e) { - try { - sCurrentList = parseBuiltinList(context); - } - catch (IOException be) { - Log.w(TAG, "unable to load builtin server list", be); + if (builtin == null) { + ReportingManager.logException(e); + Log.w(TAG, "unable to load builtin server list", e); } + sCurrentList = builtin; } return sCurrentList; } public interface UpdaterListener { - /** Called if either the cached list or the built-in list cannot be loaded.*/ - public void noData(); - /** Called when network is not available. */ - public void networkNotAvailable(); - /** Called when offline mode is active. */ - public void offlineModeEnabled(); - /** Called if an error occurs during update. */ - public void error(Throwable e); - /** Called when list update has finished. */ - public void updated(ServerList list); + /** + * Called if either the cached list or the built-in list cannot be loaded. + */ + void noData(); + + /** + * Called when network is not available. + */ + void networkNotAvailable(); + + /** + * Called when offline mode is active. + */ + void offlineModeEnabled(); + + /** + * Called if an error occurs during update. + */ + + void error(Throwable e); + + /** + * Called when list update has finished. + */ + void updated(ServerList list); } } diff --git a/app/src/main/java/org/kontalk/service/SystemBootStartup.java b/app/src/main/java/org/kontalk/service/SystemBootStartup.java index 7e2861abb..de6567cda 100644 --- a/app/src/main/java/org/kontalk/service/SystemBootStartup.java +++ b/app/src/main/java/org/kontalk/service/SystemBootStartup.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,6 +19,7 @@ package org.kontalk.service; import org.kontalk.Kontalk; +import org.kontalk.ui.MessagingNotification; import android.content.BroadcastReceiver; import android.content.Context; @@ -27,13 +28,18 @@ /** * Receiver for the BOOT_COMPLETED action. - * This is actually just a dummy to allow {@link Kontalk} do its jobs. + * Fires up any unread notifications before the last reboot. * @author Daniele Ricci - * @version 1.0 */ public class SystemBootStartup extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - // nothing to do here + if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + if (Kontalk.get().getDefaultAccount() != null) { + // update notifications from locally unread messages + MessagingNotification + .delayedUpdateMessagesNotification(Kontalk.get(), false); + } + } } } diff --git a/app/src/main/java/org/kontalk/service/UploadService.java b/app/src/main/java/org/kontalk/service/UploadService.java index 1b975b94a..2dff38561 100644 --- a/app/src/main/java/org/kontalk/service/UploadService.java +++ b/app/src/main/java/org/kontalk/service/UploadService.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,35 +22,38 @@ * TODO instead of using a notification ID per type, use a notification ID per * upload. */ -import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_UPLOADING; -import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_UPLOAD_ERROR; -import static org.kontalk.ui.MessagingNotification.NOTIFICATION_UPDATE_DELAY; import java.io.File; import java.util.LinkedHashMap; import java.util.Map; -import org.kontalk.Kontalk; -import org.kontalk.R; -import org.kontalk.crypto.PersonalKey; -import org.kontalk.provider.MessagesProvider; -import org.kontalk.service.msgcenter.MessageCenterService; -import org.kontalk.ui.ConversationList; -import org.kontalk.ui.ProgressNotificationBuilder; -import org.kontalk.upload.KontalkBoxUploadConnection; -import org.kontalk.upload.UploadConnection; -import org.kontalk.util.MediaStorage; -import org.kontalk.util.StepTimer; - -import android.app.IntentService; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.net.Uri; -import android.support.v4.app.NotificationCompat; -import android.util.Log; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.core.app.JobIntentService; +import androidx.core.app.NotificationCompat; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.service.msgcenter.event.SendMessageRequest; +import org.kontalk.ui.ConversationsActivity; +import org.kontalk.ui.MessagingNotification; +import org.kontalk.ui.ProgressNotificationBuilder; +import org.kontalk.upload.HTPPFileUploadConnection; +import org.kontalk.upload.UploadConnection; +import org.kontalk.util.MediaStorage; + +import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_UPLOADING; +import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_UPLOAD_ERROR; /** @@ -58,31 +61,27 @@ * TODO implement multiple concurrent uploads * @author Daniele Ricci */ -public class UploadService extends IntentService implements ProgressListener { +public class UploadService extends JobIntentService implements ProgressListener { private static final String TAG = MessageCenterService.TAG; + private static final int JOB_ID = 1002; + /** A map to avoid duplicate uploads. */ - private static final Map queue = new LinkedHashMap(); + private static final Map queue = new LinkedHashMap<>(); public static final String ACTION_UPLOAD = "org.kontalk.action.UPLOAD"; public static final String ACTION_UPLOAD_ABORT = "org.kontalk.action.UPLOAD_ABORT"; /** Message database ID. Use with ACTION_UPLOAD. */ public static final String EXTRA_DATABASE_ID = "org.kontalk.upload.DATABASE_ID"; - /** Message ID. Use with ACTION_UPLOAD. */ - public static final String EXTRA_MESSAGE_ID = "org.kontalk.upload.MESSAGE_ID"; /** URL to post to. Use with ACTION_UPLOAD. */ public static final String EXTRA_POST_URL = "org.kontalk.upload.POST_URL"; - /** User to send to. */ - public static final String EXTRA_USER = "org.kontalk.upload.USER"; + /** URL to fetch from. Use with ACTION_UPLOAD. */ + public static final String EXTRA_GET_URL = "org.kontalk.upload.GET_URL"; /** Media MIME type. */ public static final String EXTRA_MIME = "org.kontalk.upload.MIME"; - /** Preview file path. */ - public static final String EXTRA_PREVIEW_PATH = "org.kontalk.upload.PREVIEW_PATH"; - /** Encryption flag. */ - public static final String EXTRA_ENCRYPT = "org.kontalk.upload.ENCRYPT"; - /** Compression ratio. */ - public static final String EXTRA_COMPRESS = "org.kontalk.upload.COMPRESS"; + /** Delete local file after sending attempt. */ + public static final String EXTRA_DELETE_ORIGINAL = "org.kontalk.upload.DELETE_ORIGINAL"; // Intent data is the local file Uri private ProgressNotificationBuilder mNotificationBuilder; @@ -91,67 +90,72 @@ public class UploadService extends IntentService implements ProgressListener { // data about the upload currently being processed private Notification mCurrentNotification; private long mTotalBytes; - /** Step timer for notification updates. */ - private StepTimer mUpdateTimer = new StepTimer(NOTIFICATION_UPDATE_DELAY); private long mMessageId; private UploadConnection mConn; private boolean mCanceled; - private File mCompressed; - - public UploadService() { - super(UploadService.class.getSimpleName()); - } @Override - public int onStartCommand(Intent intent, int flags, int startId) { + public void onCreate() { + super.onCreate(); if (mNotificationManager == null) mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + } - if (ACTION_UPLOAD_ABORT.equals(intent.getAction())) { - String filename = intent.getData().toString(); - // TODO check for race conditions on queue - Long msgId = queue.get(filename); - if (msgId != null) { - // interrupt worker if running - if (msgId.longValue() == mMessageId) { - mConn.abort(); - mCanceled = true; + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + // crappy firmware - as per docs, intent can't be null in this case + if (intent != null) { + if (mNotificationManager == null) + mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + + if (ACTION_UPLOAD_ABORT.equals(intent.getAction())) { + String filename = intent.getData().toString(); + // TODO check for race conditions on queue + Long msgId = queue.get(filename); + if (msgId != null) { + // interrupt worker if running + if (msgId == mMessageId) { + mConn.abort(); + mCanceled = true; + } + // remove from queue - will never be processed + else + queue.remove(filename); } - // remove from queue - will never be processed - else - queue.remove(filename); + return START_NOT_STICKY; } - return START_NOT_STICKY; } return super.onStartCommand(intent, flags, startId); } @Override - protected void onHandleIntent(Intent intent) { - // check for unknown action - if (!ACTION_UPLOAD.equals(intent.getAction())) return; + protected void onHandleWork(@NonNull Intent intent) { + String action = intent.getAction(); + if (ACTION_UPLOAD.equals(action)) { + onUploadURL(intent.getData(), intent.getExtras()); + } + else if (ACTION_UPLOAD_ABORT.equals(intent.getAction())) { + onUploadAbort(intent.getData()); + } + + } + + private void onUploadURL(Uri file, Bundle args) { // local file to upload - Uri file = intent.getData(); String filename = file.toString(); // message database id - long databaseId = intent.getLongExtra(EXTRA_DATABASE_ID, 0); - // message id - String msgId = intent.getStringExtra(EXTRA_MESSAGE_ID); + long databaseId = args.getLong(EXTRA_DATABASE_ID, 0); // url to post to - String url = intent.getStringExtra(EXTRA_POST_URL); - // user to send message to - String to = intent.getStringExtra(EXTRA_USER); + String url = args.getString(EXTRA_POST_URL); + // url to fetch from (will be requested to the connection if null) + String fetchUrl = args.getString(EXTRA_GET_URL); // media mime type - String mime = intent.getStringExtra(EXTRA_MIME); - // preview file path - String previewPath = intent.getStringExtra(EXTRA_PREVIEW_PATH); - // encryption flag - boolean encrypt = intent.getBooleanExtra(EXTRA_ENCRYPT, false); - // compress ratio - int compress = intent.getIntExtra(EXTRA_COMPRESS, 0); + String mime = args.getString(EXTRA_MIME); + // delete original + boolean deleteOriginal = args.getBoolean(EXTRA_DELETE_ORIGINAL, false); // check if upload has already been queued if (queue.get(filename) != null) return; @@ -166,45 +170,24 @@ protected void onHandleIntent(Intent intent) { mCanceled = false; - // compress data if needed - if (compress > 0) { - if (mime.startsWith("image/")) { - try { - mCompressed = MediaStorage - .resizeImage(this, file, databaseId, compress); - mTotalBytes = length = mCompressed.length(); - // update mime and file with the new compressed image - file = Uri.fromFile(mCompressed); - mime = MediaStorage.COMPRESS_MIME; - } - catch (Exception e) { - Log.w(TAG, "error compressing image", e); - // what to do now? Should we warn the user or just go on? - // or maybe just a Toast notification? - } - } - } - - if (mConn == null) { - PersonalKey key = ((Kontalk) getApplication()).getPersonalKey(); - // TODO used class here should be decided by the caller - mConn = new KontalkBoxUploadConnection(this, url, - key.getBridgePrivateKey(), key.getBridgeCertificate()); - } + // TODO used class here should be decided by the caller + mConn = new HTPPFileUploadConnection(this, url); mMessageId = databaseId; queue.put(filename, mMessageId); // upload content - String mediaUrl = mConn.upload(file, mime, encrypt, to, this); + String mediaUrl = mConn.upload(file, length, mime, this); + if (mediaUrl == null) + mediaUrl = fetchUrl; Log.d(TAG, "uploaded with media URL: " + mediaUrl); // update message fetch_url - MessagesProvider.uploaded(this, databaseId, mediaUrl); + MessagesProviderClient.uploaded(this, databaseId, mediaUrl); // send message with fetch url to server - MessageCenterService.sendUploadedMedia(this, to, mime, file, length, - previewPath, mediaUrl, encrypt, databaseId, msgId); + MessageCenterService.bus() + .post(new SendMessageRequest(databaseId)); // end operations completed(); @@ -213,23 +196,35 @@ protected void onHandleIntent(Intent intent) { error(url, null, e); } finally { + // only file uri are supported for delete + if (deleteOriginal && "file".equals(file.getScheme())) + new File(file.getPath()).delete(); + queue.remove(filename); mMessageId = 0; - try { - // delete compressed file (if any) - mCompressed.delete(); - } - catch (Exception e) { - // ignored + } + } + + private void onUploadAbort(Uri uri) { + String filename = uri.toString(); + // TODO check for race conditions on queue + Long msgId = queue.get(filename); + if (msgId != null) { + // interrupt worker if running + if (msgId == mMessageId) { + mConn.abort(); + mCanceled = true; } - mCompressed = null; + // remove from queue - will never be processed + else + queue.remove(filename); } } public void startForeground(long totalBytes) { Log.d(TAG, "starting foreground progress notification"); - Intent ni = new Intent(getApplicationContext(), ConversationList.class); + Intent ni = new Intent(getApplicationContext(), ConversationsActivity.class); ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // FIXME this intent should actually open the ComposeMessage activity PendingIntent pi = PendingIntent.getActivity(getApplicationContext(), @@ -237,6 +232,7 @@ public void startForeground(long totalBytes) { if (mNotificationBuilder == null) { mNotificationBuilder = new ProgressNotificationBuilder(getApplicationContext(), + MessagingNotification.CHANNEL_MEDIA_UPLOAD, R.layout.progress_notification, getString(R.string.sending_message), R.drawable.ic_stat_notify, @@ -264,7 +260,6 @@ public void stopForeground() { @Override public void start(UploadConnection conn) { - mUpdateTimer.reset(); startForeground(mTotalBytes); } @@ -279,41 +274,62 @@ public void completed() { public void error(String url, File destination, Throwable exc) { Log.e(TAG, "upload error", exc); stopForeground(); - if (!mCanceled) + if (!mCanceled) { + ReportingManager.logException(exc); errorNotification(getString(R.string.notify_ticker_upload_error), getString(R.string.notify_text_upload_error)); + } } private void errorNotification(String ticker, String text) { + errorNotification(this, mNotificationManager, ticker, text); + } + + public static void genericErrorNotification(Context context) { + errorNotification(context, + context.getString(R.string.notify_ticker_upload_error), + context.getString(R.string.notify_text_upload_error)); + } + + public static void errorNotification(Context context, String ticker, String text) { + errorNotification(context, + ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)), + ticker, text); + } + + private static void errorNotification(Context context, NotificationManager nm, String ticker, String text) { // create intent for upload error notification - Intent i = new Intent(this, ConversationList.class); + Intent i = new Intent(context, ConversationsActivity.class); i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - PendingIntent pi = PendingIntent.getActivity(getApplicationContext(), - NOTIFICATION_ID_UPLOAD_ERROR, i, 0); + PendingIntent pi = PendingIntent.getActivity(context.getApplicationContext(), + NOTIFICATION_ID_UPLOAD_ERROR, i, 0); // create notification - NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext()) + NotificationCompat.Builder builder = new NotificationCompat + .Builder(context.getApplicationContext(), MessagingNotification.CHANNEL_MEDIA_UPLOAD) .setSmallIcon(R.drawable.ic_stat_notify) - .setContentTitle(getString(R.string.notify_title_upload_error)) + .setContentTitle(context.getString(R.string.notify_title_upload_error)) .setContentText(text) .setTicker(ticker) .setContentIntent(pi) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_ERROR) .setAutoCancel(true); // notify!! - mNotificationManager.notify(NOTIFICATION_ID_UPLOAD_ERROR, builder.build()); + nm.notify(NOTIFICATION_ID_UPLOAD_ERROR, builder.build()); } @Override public void progress(UploadConnection conn, long bytes) { - if (mCanceled || !MessagesProvider.exists(this, mMessageId)) { + if (mCanceled || !MessagesProviderClient.exists(this, mMessageId)) { Log.v(TAG, "upload canceled or message deleted - aborting"); mConn.abort(); mCanceled = true; } - if (mCurrentNotification != null && (bytes >= mTotalBytes || mUpdateTimer.isStep())) { + if (mCurrentNotification != null) { int progress = (int) ((100 * bytes) / mTotalBytes); foregroundNotification(progress); // send the updates to the notification manager @@ -324,4 +340,19 @@ public void progress(UploadConnection conn, long bytes) { public static boolean isQueued(String url) { return queue.containsKey(url); } + + public static void start(Context context, Uri mediaUri, + String putUrl, String getUrl, long databaseId, + String mime, boolean deleteOriginal) { + Intent i = new Intent(context, UploadService.class); + i.setData(mediaUri); + i.setAction(UploadService.ACTION_UPLOAD); + i.putExtra(UploadService.EXTRA_POST_URL, putUrl); + i.putExtra(UploadService.EXTRA_GET_URL, getUrl); + i.putExtra(UploadService.EXTRA_DATABASE_ID, databaseId); + i.putExtra(UploadService.EXTRA_MIME, mime); + // delete original (actually it's the encrypted temp file) if we already encrypted it + i.putExtra(UploadService.EXTRA_DELETE_ORIGINAL, deleteOriginal); + enqueueWork(context, UploadService.class, JOB_ID, i); + } } diff --git a/app/src/main/java/org/kontalk/service/XMPPConnectionHelper.java b/app/src/main/java/org/kontalk/service/XMPPConnectionHelper.java index b05af8c89..eebf131b2 100644 --- a/app/src/main/java/org/kontalk/service/XMPPConnectionHelper.java +++ b/app/src/main/java/org/kontalk/service/XMPPConnectionHelper.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,7 +24,6 @@ import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; import java.util.concurrent.TimeUnit; import com.segment.backo.Backo; @@ -32,27 +31,24 @@ import org.jivesoftware.smack.AbstractXMPPConnection; import org.jivesoftware.smack.ConnectionListener; import org.jivesoftware.smack.SmackException; -import org.jivesoftware.smack.SmackException.NotConnectedException; import org.jivesoftware.smack.XMPPConnection; import org.jivesoftware.smack.XMPPException; import org.jivesoftware.smack.sasl.SASLError; import org.jivesoftware.smack.sasl.SASLErrorException; +import org.bouncycastle.openpgp.PGPException; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.provider.Settings; + import org.kontalk.Kontalk; -import org.kontalk.authenticator.LegacyAuthentication; +import org.kontalk.Log; import org.kontalk.client.EndpointServer; import org.kontalk.client.KontalkConnection; -import org.kontalk.crypto.PGP; import org.kontalk.crypto.PersonalKey; -import org.kontalk.crypto.X509Bridge; import org.kontalk.service.msgcenter.MessageCenterService; -import org.kontalk.service.msgcenter.PGPKeyPairRingProvider; import org.kontalk.util.InternalTrustStore; import org.kontalk.util.Preferences; -import org.spongycastle.openpgp.PGPException; - -import android.content.Context; -import android.provider.Settings; -import android.util.Log; /** @@ -66,11 +62,14 @@ public class XMPPConnectionHelper extends Thread { private static final boolean USE_STARTTLS = true; /** Max connection retry count if idle. */ - private static final int MAX_IDLE_BACKOFF = 10; + private static final int MAX_IDLE_BACKOFF = 15; /** Max retries after for authentication error. */ private static final int MAX_AUTH_ERRORS = 3; + /** Max number of retries after stopping ourselves out of desperation. */ + private static final int MAX_RETRY_STRUGGLING = 10; + private final Context mContext; private EndpointServer mServer; private boolean mServerDirty; @@ -92,6 +91,9 @@ public class XMPPConnectionHelper extends Thread { /** Retry enabled flag. */ protected boolean mRetryEnabled = true; + /** Waiting for exponential backoff since this epoch. */ + protected long mBackoff; + /** Connecting flag. */ protected volatile boolean mConnecting; @@ -116,6 +118,11 @@ public XMPPConnectionHelper(Context context, EndpointServer server, boolean limi .build(); } + public XMPPConnectionHelper(Context context, EndpointServer server, boolean limited, KontalkConnection reuseConnection) { + this(context, server, limited); + mConn = reuseConnection; + } + public void setListener(ConnectionHelperListener listener) { mListener = listener; } @@ -136,14 +143,7 @@ public void run() { public void connectOnce(PersonalKey key, boolean forceLogin) throws XMPPException, SmackException, PGPException, KeyStoreException, NoSuchProviderException, - NoSuchAlgorithmException, CertificateException, IOException { - - connectOnce(key, null, forceLogin); - } - - private void connectOnce(PersonalKey key, String token, boolean forceLogin) throws XMPPException, - SmackException, PGPException, IOException, KeyStoreException, - NoSuchProviderException, NoSuchAlgorithmException, CertificateException { + NoSuchAlgorithmException, CertificateException, IOException, InterruptedException { Log.d(TAG, "using server " + mServer.toString()); @@ -159,7 +159,7 @@ private void connectOnce(PersonalKey key, String token, boolean forceLogin) thro } // recreate connection if closed - if (mConn == null || !mConn.isConnected()) { + if (mConn == null) { KeyStore trustStore = null; boolean acceptAnyCertificate = Preferences.getAcceptAnyCertificate(mContext); @@ -170,7 +170,7 @@ private void connectOnce(PersonalKey key, String token, boolean forceLogin) thro if (key == null) { mConn = new KontalkConnection(resource, mServer, !USE_STARTTLS, - acceptAnyCertificate, trustStore, token); + acceptAnyCertificate, trustStore); } else { @@ -178,11 +178,11 @@ private void connectOnce(PersonalKey key, String token, boolean forceLogin) thro key.getBridgePrivateKey(), key.getBridgeCertificate(), acceptAnyCertificate, - trustStore, token); + trustStore); } // apply packet timeout based on retry count - mConn.setPacketReplyTimeout((mRetryCount + 1) * KontalkConnection.DEFAULT_PACKET_TIMEOUT); + mConn.setReplyTimeout((mRetryCount + 1) * KontalkConnection.DEFAULT_PACKET_TIMEOUT); if (mListener != null) mListener.created(mConn); @@ -193,11 +193,12 @@ private void connectOnce(PersonalKey key, String token, boolean forceLogin) thro if (mListener != null) { mConn.addConnectionListener(mListener); + mListener.connected(mConn); } // login - if ((!mLimited || forceLogin) && (key != null || token != null)) + if ((!mLimited || forceLogin) && (key != null)) mConn.login(); } @@ -205,40 +206,14 @@ private void connectOnce(PersonalKey key, String token, boolean forceLogin) thro public void connect() { PersonalKey key = null; - if (LegacyAuthentication.isUpgrading() && mListener != null) { - PGPKeyPairRingProvider keyProv = mListener.getKeyPairRingProvider(); - if (keyProv != null) { - PGP.PGPKeyPairRing keyring = keyProv.getKeyPair(); - if (keyring != null) { - String passphrase = ((Kontalk) mContext.getApplicationContext()).getCachedPassphrase(); - - try { - X509Certificate bridgeCert = X509Bridge.createCertificate(keyring.publicKey, - keyring.secretKey.getSecretKey(), passphrase, null); - - key = PersonalKey.load(keyring.secretKey, keyring.publicKey, - passphrase, bridgeCert); - } - catch (Exception e) { - // this will go crap... - Log.e(TAG, "unable to create temporary personal key - not using SSL", e); - } - } - } + try { + key = Kontalk.get().getPersonalKey(); } - - if (key == null) { - try { - key = ((Kontalk) mContext.getApplicationContext()).getPersonalKey(); - } - catch (Exception e) { - Log.e(TAG, "unable to retrieve personal key - not using SSL", e); - } + catch (Exception e) { + Log.e(TAG, "unable to retrieve personal key - not using SSL", e); } - String token = LegacyAuthentication.getAuthToken(mContext); - - if (key == null && token == null && !mLimited) { + if (key == null && !mLimited) { Log.w(TAG, "no personal key found - exiting"); // unrecoverable error if (mListener != null) @@ -248,7 +223,7 @@ public void connect() { while (mConnecting) { try { - connectOnce(key, token, false); + connectOnce(key, false); // this should be the right moment mRetryCount = 0; @@ -264,8 +239,6 @@ public void connect() { if (mConn != null) { // forcibly close connection, no matter what mConn.instantShutdown(); - // EXTERMINATE!! - mConn = null; } // SASL: not authorized @@ -300,6 +273,7 @@ public void connect() { if (mListener != null) mListener.reconnectingIn((int) time); + mBackoff = System.currentTimeMillis(); Thread.sleep(time); // this is to avoid the exponential backoff counter to be reset continue; @@ -309,6 +283,9 @@ public void connect() { Log.e(TAG, "- interrupted."); break; } + finally { + mBackoff = 0; + } } else { // retry disabled - notify and exit @@ -324,6 +301,7 @@ public void connect() { mConnecting = false; } + @SuppressLint("HardwareIds") private static String getResource(Context context) { return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); } @@ -341,13 +319,21 @@ public boolean isConnecting() { } public boolean isStruggling() { - return mConnecting && mRetryCount > 5; + return mConnecting && mRetryCount > MAX_RETRY_STRUGGLING; } public boolean isServerDirty() { return mServerDirty; } + public boolean isBackingOff() { + return mBackoff > 0; + } + + public boolean isBackingOff(long forMillis) { + return mBackoff > 0 && (System.currentTimeMillis() - mBackoff) >= forMillis; + } + /** Shortcut for {@link EndpointServer#getNetwork()}. */ public String getNetwork() { return mServer.getNetwork(); @@ -364,7 +350,7 @@ public void setServer(EndpointServer server) { } /** Shuts down this client thread gracefully. */ - public void shutdown() throws NotConnectedException { + public void shutdown() { mConnecting = false; interrupt(); @@ -375,13 +361,17 @@ public void shutdown() throws NotConnectedException { public interface ConnectionHelperListener extends ConnectionListener { /** Connection has been created. */ - public void created(XMPPConnection connection); + void created(XMPPConnection connection); /** Connection was aborted and will never be tried again. */ - public void aborted(Exception e); + void aborted(Exception e); - public void authenticationFailed(); + /** + * The connection will retry to reconnect in the specified number of seconds. + * @param seconds remaining seconds before attempting a reconnection. + */ + void reconnectingIn(int seconds); - public PGPKeyPairRingProvider getKeyPairRingProvider(); + void authenticationFailed(); } } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/AbstractAdaptiveServerPingManager.java b/app/src/main/java/org/kontalk/service/msgcenter/AbstractAdaptiveServerPingManager.java new file mode 100644 index 000000000..ad102c3dc --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/AbstractAdaptiveServerPingManager.java @@ -0,0 +1,148 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + +import java.util.logging.Logger; + +import org.jivesoftware.smack.Manager; +import org.jivesoftware.smack.XMPPConnection; + + +/** + * An adaptive ping manager. + * @author Daniele Ricci + */ +public abstract class AbstractAdaptiveServerPingManager extends Manager { + + private static final Logger LOGGER = Logger.getLogger(AbstractAdaptiveServerPingManager.class.getName()); + + protected boolean mEnabled = true; + + // All values are in milliseconds. + + /** + * Current ping interval. + */ + protected long mInterval; + /** + * Timestamp of last ping success. + */ + protected long mLastSuccess; + /** + * Last successful ping interval. + */ + protected long mLastSuccessInterval; + /** + * Interval for the next increase. + */ + protected long mNextIncrease; + /** + * Successful ping streak. Used for computing when the next increase attempt + * will be made. + */ + protected long mPingStreak; + + protected AbstractAdaptiveServerPingManager(XMPPConnection connection) { + super(connection); + } + + public void setEnabled(boolean enabled) { + mEnabled = enabled; + } + + public boolean isEnabled() { + return mEnabled; + } + + public abstract void onConnectionCompleted(); + + public abstract void onConnectivityChanged(); + + /** + * Called by the ping failed listener. + * It will half the interval for the next alarm. + */ + public void pingFailed() { + long interval; + + // ping has failed, reset ping streak + mPingStreak = 0; + + if (mLastSuccessInterval > 0) { + // we were trying an increase, go back to previous value + interval = mLastSuccessInterval; + // better use the previous value for a longer time :) + setNextIncreaseInterval((long) (mNextIncrease * 1.5)); + } + else { + // half interval + interval = mInterval / 2; + } + setupPing(interval); + } + + /** + * Called when a ping has succeeded. + * In order to avoid a too much optimistic approach, we wait for at least + * the supposed ping interval to pass before incrementing back the interval + * for the next ping. + */ + public void pingSuccess() { + long nextAlarm = mInterval; + long now = getElapsedRealtime(); + + // we got a successful ping, increase ping streak + mPingStreak += mInterval; + + if (mLastSuccessInterval > 0) { + // interval increase was successful, reset backoff + setNextIncreaseInterval(mInterval); + + // interval increase was successful + } + // try an increase only if we previously had a successful ping + else if (mLastSuccess > 0) { + if (mPingStreak >= mNextIncrease) { + // we are trying an increase, store the last successful interval + mLastSuccessInterval = mInterval; + + nextAlarm = (long) (mInterval * 1.5); + } + + // do not increase interval for now + } + + // remember last success + mLastSuccess = now; + + setupPing(nextAlarm); + } + + protected abstract void setupPing(long intervalMillis); + + protected abstract long getElapsedRealtime(); + + protected void setNextIncreaseInterval(long interval) { + // reset last successful interval + mLastSuccessInterval = 0; + // set and save next increase + mNextIncrease = interval; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/AdaptiveServerPingManager.java b/app/src/main/java/org/kontalk/service/msgcenter/AdaptiveServerPingManager.java deleted file mode 100644 index 16bce46e6..000000000 --- a/app/src/main/java/org/kontalk/service/msgcenter/AdaptiveServerPingManager.java +++ /dev/null @@ -1,288 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.service.msgcenter; - -import java.util.Iterator; -import java.util.Map; -import java.util.WeakHashMap; -import java.util.logging.Level; -import java.util.logging.Logger; - -import org.jivesoftware.smack.ConnectionCreationListener; -import org.jivesoftware.smack.Manager; -import org.jivesoftware.smack.XMPPConnection; -import org.jivesoftware.smack.XMPPConnectionRegistry; - -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.os.SystemClock; - -import org.kontalk.util.Preferences; -import org.kontalk.util.SystemUtils; - - -/** - * An adaptive ping manager using {@link AlarmManager}.
- * FIXME this class still uses the Manager approach, but doesn't seem to fit in - * any more. - * @author Daniele Ricci - */ -public class AdaptiveServerPingManager extends Manager { - - private static final Logger LOGGER = Logger.getLogger(AdaptiveServerPingManager.class.getName()); - - private static final String PING_ALARM_ACTION = "org.igniterealtime.smackx.ping.ACTION"; - - private static final Map INSTANCES = new WeakHashMap(); - - static { - XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() { - @Override - public void connectionCreated(XMPPConnection connection) { - getInstanceFor(connection); - } - }); - } - - public static AdaptiveServerPingManager getInstanceFor(XMPPConnection connection) { - synchronized (INSTANCES) { - AdaptiveServerPingManager serverPingWithAlarmManager = INSTANCES.get(connection); - if (serverPingWithAlarmManager == null) { - serverPingWithAlarmManager = new AdaptiveServerPingManager(connection); - INSTANCES.put(connection, serverPingWithAlarmManager); - } - return serverPingWithAlarmManager; - } - } - - private boolean mEnabled = true; - - private AdaptiveServerPingManager(XMPPConnection connection) { - super(connection); - } - - public void setEnabled(boolean enabled) { - mEnabled = enabled; - } - - public boolean isEnabled() { - return mEnabled; - } - - private static final BroadcastReceiver ALARM_BROADCAST_RECEIVER = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - LOGGER.fine("Ping Alarm broadcast received"); - synchronized (INSTANCES) { - Iterator it = INSTANCES.keySet().iterator(); - while (it.hasNext()) { - final XMPPConnection connection = it.next(); - if (getInstanceFor(connection).isEnabled()) { - MessageCenterService.ping(context); - // just ping once - break; - } - } - } - } - }; - - private static final int MIN_ALARM_INTERVAL = 90 * 1000; - - private static Context sContext; - private static PendingIntent sPendingIntent; - private static AlarmManager sAlarmManager; - - // All values are in milliseconds. - - /** - * Current ping interval. - */ - private static long sInterval; - /** - * Timestamp of last ping success. - */ - private static long sLastSuccess; - /** - * Last successful ping interval. - */ - private static long sLastSuccessInterval; - /** - * Interval for the next increase. - */ - private static long sNextIncrease; - - /** - * Register a pending intent with the AlarmManager to be broadcasted every - * half hour and register the alarm broadcast receiver to receive this - * intent. The receiver will check all known questions if a ping is - * Necessary when invoked by the alarm intent. - * - * @param context - */ - public static void onCreate(Context context) { - sContext = context; - context.registerReceiver(ALARM_BROADCAST_RECEIVER, new IntentFilter(PING_ALARM_ACTION)); - sAlarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - sPendingIntent = PendingIntent.getBroadcast(context, 0, new Intent(PING_ALARM_ACTION), 0); - onConnected(); - } - - public static void onConnected() { - onConnected(null); - } - - public static void onConnected(XMPPConnection connection) { - if (sContext != null) { - // setup first alarm using last value from preference - setupAlarmManager(connection, Preferences.getPingAlarmInterval(sContext, AlarmManager.INTERVAL_HALF_HOUR)); - // next increase can happen at least at next interval - sNextIncrease = Preferences.getPingAlarmBackoff(sContext, sInterval); - // reset internal variables - sLastSuccess = 0; - sLastSuccessInterval = 0; - } - } - - /** - * Called by the ping failed listener. - * It will half the interval for the next alarm. - */ - public static void pingFailed(XMPPConnection connection) { - long interval; - - if (sLastSuccessInterval > 0) { - // we were trying an increase, go back to previous value - interval = sLastSuccessInterval; - // better use the previous value for a longer time :) - setNextIncreaseInterval((long) (sNextIncrease * 1.5)); - } - else { - // half interval - interval = sInterval / 2; - } - setupAlarmManager(connection, interval); - } - - /** - * Called when a ping has succeeded. - * In order to avoid a too much optimistic approach, we wait for at least - * the supposed ping interval to pass before incrementing back the interval - * for the next ping. - */ - public static void pingSuccess(XMPPConnection connection) { - long nextAlarm = sInterval; - long now = SystemClock.elapsedRealtime(); - - if (sLastSuccessInterval > 0) { - // interval increase was successful, reset backoff - setNextIncreaseInterval(sInterval); - - // interval increase was successful - } - // try an increase only if we previously had a successful ping - else if (sLastSuccess > 0) { - long diff = now - sLastSuccess; - if (diff >= sNextIncrease) { - // we are trying an increase, store the last successful interval - sLastSuccessInterval = sInterval; - - nextAlarm = (long) (sInterval * 1.5); - } - - // do not increase interval for now - } - - // remember last success - sLastSuccess = now; - - setupAlarmManager(connection, nextAlarm); - } - - private static void setupAlarmManager(XMPPConnection connection, long intervalMillis) { - if (sPendingIntent != null) { - sAlarmManager.cancel(sPendingIntent); - sInterval = intervalMillis; - - // do not go beyond 30 minutes... - if (sInterval > AlarmManager.INTERVAL_HALF_HOUR) { - sInterval = AlarmManager.INTERVAL_HALF_HOUR; - } - // ...or less than 90 seconds - else if (sInterval < MIN_ALARM_INTERVAL) { - sInterval = MIN_ALARM_INTERVAL; - } - - // save value to preference for later retrieval - Preferences.setPingAlarmInterval(sContext, sInterval); - - // remove difference from last received stanza - long interval = sInterval; - if (connection != null) { - long now = System.currentTimeMillis(); - long lastStanza = connection.getLastStanzaReceived(); - if (lastStanza > 0) - interval -= (now - lastStanza); - } - - LOGGER.log(Level.WARNING, "Setting alarm for next ping to " + sInterval + " ms (real " + interval + " ms)"); - - if (SystemUtils.isOnWifi(sContext)) { - // when on WiFi we can afford an inexact ping (carrier will not destroy our connection) - sAlarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, - SystemClock.elapsedRealtime() + interval, - interval, sPendingIntent); - } - else { - // when on mobile network, we need exact ping timings - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - sAlarmManager.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, - SystemClock.elapsedRealtime() + interval, - sPendingIntent); - } else { - sAlarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, - SystemClock.elapsedRealtime() + interval, - sPendingIntent); - } - } - } - } - - private static void setNextIncreaseInterval(long interval) { - // reset last successful interval - sLastSuccessInterval = 0; - // set and save next increase - sNextIncrease = interval; - Preferences.setPingAlarmBackoff(sContext, sNextIncrease); - } - - /** - * Unregister the alarm broadcast receiver and cancel the alarm. - */ - public static void onDestroy() { - sContext.unregisterReceiver(ALARM_BROADCAST_RECEIVER); - sAlarmManager.cancel(sPendingIntent); - sPendingIntent = null; - } - -} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/AndroidAdaptiveServerPingManager.java b/app/src/main/java/org/kontalk/service/msgcenter/AndroidAdaptiveServerPingManager.java new file mode 100644 index 000000000..bd3341d79 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/AndroidAdaptiveServerPingManager.java @@ -0,0 +1,243 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + +import java.util.Map; +import java.util.WeakHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.jivesoftware.smack.XMPPConnection; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.SystemClock; +import androidx.core.app.AlarmManagerCompat; + +import org.kontalk.reporting.ReportingManager; +import org.kontalk.util.Preferences; +import org.kontalk.util.SystemUtils; + + +/** + * An adaptive ping manager using {@link AlarmManager}. + * @author Daniele Ricci + */ +public class AndroidAdaptiveServerPingManager extends AbstractAdaptiveServerPingManager { + + private static final Logger LOGGER = Logger.getLogger(AndroidAdaptiveServerPingManager.class.getName()); + + private static final String PING_ALARM_ACTION = "org.igniterealtime.smackx.ping.ACTION"; + + private static final Map INSTANCES = + new WeakHashMap<>(); + + private static AlarmManager sAlarmManager; + + // we don't use the onConnectionCreated static initializer because we need an Android system context + + private static void ensureAlarmManager(Context context) { + sAlarmManager = (AlarmManager) context.getApplicationContext() + .getSystemService(Context.ALARM_SERVICE); + } + + public static AndroidAdaptiveServerPingManager getInstanceFor(XMPPConnection connection, Context context) { + synchronized (INSTANCES) { + AndroidAdaptiveServerPingManager serverPingWithAlarmManager = INSTANCES.get(connection); + if (serverPingWithAlarmManager == null) { + serverPingWithAlarmManager = new AndroidAdaptiveServerPingManager(connection, context); + INSTANCES.put(connection, serverPingWithAlarmManager); + } + return serverPingWithAlarmManager; + } + } + + private AndroidAdaptiveServerPingManager(XMPPConnection connection, Context context) { + super(connection); + mContext = context; + enable(); + onConnectionCompleted(); + } + + private final BroadcastReceiver mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + LOGGER.fine("Ping Alarm broadcast received"); + if (isEnabled()) { + MessageCenterService.ping(context); + } + } + }; + + private static final int MIN_ALARM_INTERVAL = 90 * 1000; + + private Context mContext; + private PendingIntent mPendingIntent; + + private void setupOnConnectionCompleted() { + if (mContext != null) { + // setup first alarm using last value from preference + setupPing(Preferences.getPingAlarmInterval(mContext, AlarmManager.INTERVAL_HALF_HOUR)); + // next increase can happen at least at next interval + mNextIncrease = Preferences.getPingAlarmBackoff(mContext, mInterval); + // reset internal variables + mLastSuccess = 0; + mLastSuccessInterval = 0; + } + } + + @Override + public void onConnectionCompleted() { + setupOnConnectionCompleted(); + mPingStreak = 0; + } + + @Override + public void onConnectivityChanged() { + setupOnConnectionCompleted(); + } + + @Override + public void setEnabled(boolean enabled) { + if (enabled && !isEnabled()) { + enable(); + onConnectionCompleted(); + } + else if (!enabled && isEnabled()) { + disable(); + } + + super.setEnabled(enabled); + } + + private synchronized void enable() { + if (mPendingIntent == null) { + mContext.registerReceiver(mReceiver, new IntentFilter(PING_ALARM_ACTION)); + ensureAlarmManager(mContext); + mPendingIntent = PendingIntent.getBroadcast(mContext, 0, new Intent(PING_ALARM_ACTION), 0); + } + } + + private synchronized void disable() { + if (mPendingIntent != null) { + try { + mContext.unregisterReceiver(mReceiver); + } + catch (IllegalArgumentException e) { + // for some strange reason, this can happen once in a while. + // I can't see how it's possible given the protection of + // mPendingIntent != null and the synchronized clause. + // Could it be Android unregistering on its own? + // Whatever, report the exception as non-fatal + ReportingManager.logException(e); + LOGGER.log(Level.WARNING, "Unable to unregister broadcast receiver", e); + } + ensureAlarmManager(mContext); + sAlarmManager.cancel(mPendingIntent); + mPendingIntent = null; + } + } + + @Override + protected long getElapsedRealtime() { + return SystemClock.elapsedRealtime(); + } + + @Override + protected synchronized void setupPing(long intervalMillis) { + if (mPendingIntent != null) { + sAlarmManager.cancel(mPendingIntent); + mInterval = intervalMillis; + + // do not go beyond 30 minutes... + if (mInterval > AlarmManager.INTERVAL_HALF_HOUR) { + mInterval = AlarmManager.INTERVAL_HALF_HOUR; + } + // ...or less than 90 seconds + else if (mInterval < MIN_ALARM_INTERVAL) { + mInterval = MIN_ALARM_INTERVAL; + } + + // save value to preference for later retrieval + Preferences.setPingAlarmInterval(mContext, mInterval); + + // remove difference from last received stanza + long interval = mInterval; + XMPPConnection connection = connection(); + if (connection != null) { + long now = System.currentTimeMillis(); + long lastStanza = connection.getLastStanzaReceived(); + if (lastStanza > 0) + interval -= (now - lastStanza); + } + + LOGGER.log(Level.WARNING, "Setting alarm for next ping to " + mInterval + " ms (real " + interval + " ms)"); + + if (SystemUtils.isOnWifi(mContext)) { + // when on WiFi we can afford an inexact ping (carrier will not destroy our connection) + sAlarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + interval, + interval, mPendingIntent); + } + else { + // when on mobile network, we need exact ping timings + AlarmManagerCompat.setExactAndAllowWhileIdle(sAlarmManager, + AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + interval, + mPendingIntent); + } + } + } + + @Override + protected void setNextIncreaseInterval(long interval) { + super.setNextIncreaseInterval(interval); + Preferences.setPingAlarmBackoff(mContext, mNextIncrease); + } + + public static void onConnected() { + synchronized (INSTANCES) { + for (Map.Entry + xmppConnectionAndroidAdaptiveServerPingManagerEntry : INSTANCES.entrySet()) { + AndroidAdaptiveServerPingManager instance = xmppConnectionAndroidAdaptiveServerPingManagerEntry + .getValue(); + instance.onConnectivityChanged(); + } + } + } + + /** + * Unregister the alarm broadcast receiver and cancel the alarm. + */ + public static void onDestroy() { + synchronized (INSTANCES) { + for (Map.Entry + xmppConnectionAndroidAdaptiveServerPingManagerEntry : INSTANCES.entrySet()) { + AndroidAdaptiveServerPingManager instance = xmppConnectionAndroidAdaptiveServerPingManagerEntry + .getValue(); + instance.disable(); + } + } + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/DiscoverInfoListener.java b/app/src/main/java/org/kontalk/service/msgcenter/DiscoverInfoListener.java index 8537872d0..9c174225a 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/DiscoverInfoListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/DiscoverInfoListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -26,13 +26,16 @@ import org.jivesoftware.smack.packet.Stanza; import org.jivesoftware.smackx.disco.packet.DiscoverInfo; import org.jivesoftware.smackx.disco.packet.DiscoverItems; + +import org.kontalk.Log; import org.kontalk.client.EndpointServer; +import org.kontalk.client.HTTPFileUpload; import org.kontalk.client.PushRegistration; -import org.kontalk.client.UploadExtension; +import org.kontalk.upload.HTTPFileUploadService; /** - * Packet listener for service discovery. + * Packet listener for service discovery (info). * @author Daniele Ricci */ class DiscoverInfoListener extends MessageCenterPacketListener { @@ -42,7 +45,7 @@ public DiscoverInfoListener(MessageCenterService instance) { } @Override - public void processPacket(Stanza packet) { + public void processStanza(Stanza packet) { XMPPConnection conn = getConnection(); EndpointServer server = getServer(); @@ -78,17 +81,10 @@ public void processPacket(Stanza packet) { * Actually, delay any message from being requeued if at least * 1 media message is present; do the discovery first. */ - else if (UploadExtension.NAMESPACE.equals(feat.getVar())) { - // media upload is available on this server - // request items to check what services are available - DiscoverItems items = new DiscoverItems(); - items.setNode(UploadExtension.NAMESPACE); - items.setTo(server.getNetwork()); - - StanzaFilter filter = new StanzaIdFilter(items.getStanzaId()); - conn.addAsyncStanzaListener(new UploadDiscoverItemsListener(getInstance()), filter); - - sendPacket(items); + else if (HTTPFileUpload.NAMESPACE.equals(feat.getVar())) { + Log.d(MessageCenterService.TAG, "got upload service: " + packet.getFrom()); + addUploadService(new HTTPFileUploadService(conn, packet.getFrom().asBareJid()), 0); + // MessagesController will send pending messages } } } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/DiscoverItemsListener.java b/app/src/main/java/org/kontalk/service/msgcenter/DiscoverItemsListener.java new file mode 100644 index 000000000..20693f04d --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/DiscoverItemsListener.java @@ -0,0 +1,60 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + +import java.util.List; + +import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.filter.StanzaFilter; +import org.jivesoftware.smack.filter.StanzaIdFilter; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smackx.disco.packet.DiscoverInfo; +import org.jivesoftware.smackx.disco.packet.DiscoverItems; + + +/** + * Packet listener for service discovery (items). + * @author Daniele Ricci + */ +class DiscoverItemsListener extends MessageCenterPacketListener { + + public DiscoverItemsListener(MessageCenterService instance) { + super(instance); + } + + @Override + public void processStanza(Stanza packet) { + XMPPConnection conn = getConnection(); + + // we don't need this listener anymore + conn.removeAsyncStanzaListener(this); + + DiscoverItems query = (DiscoverItems) packet; + List items = query.getItems(); + for (DiscoverItems.Item item : items) { + DiscoverInfo info = new DiscoverInfo(); + info.setTo(item.getEntityID()); + + StanzaFilter filter = new StanzaIdFilter(info.getStanzaId()); + conn.addAsyncStanzaListener(new DiscoverInfoListener(getInstance()), filter); + sendPacket(info); + } + } +} + diff --git a/app/src/main/java/org/kontalk/service/msgcenter/GroupCommandAckListener.java b/app/src/main/java/org/kontalk/service/msgcenter/GroupCommandAckListener.java new file mode 100644 index 000000000..13c25ce34 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/GroupCommandAckListener.java @@ -0,0 +1,103 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + +import java.util.List; + +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.packet.Stanza; + +import android.net.Uri; + +import org.kontalk.client.GroupExtension; +import org.kontalk.client.KontalkGroupManager; +import org.kontalk.provider.MyMessages.Groups; +import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.service.msgcenter.event.GroupCreatedEvent; + + +/** + * Packet listener to handle acks for group command messages. + * @author Daniele Ricci + */ +public class GroupCommandAckListener extends MessageCenterPacketListener { + + private final KontalkGroupManager.KontalkGroup mGroup; + private final GroupExtension mExtension; + private final Uri mCommandMessage; + + /** + * Builds a new group command ack listener. + * @param commandMessage if not null, this Uri will be deleted after successful confirmation + */ + public GroupCommandAckListener(MessageCenterService instance, KontalkGroupManager.KontalkGroup group, GroupExtension extension, Uri commandMessage) { + super(instance); + mGroup = group; + mExtension = extension; + mCommandMessage = commandMessage; + } + + @Override + public void processStanza(Stanza packet) throws SmackException.NotConnectedException { + GroupExtension.Type type = mExtension.getType(); + + switch (type) { + case SET: + // clear pending member add/remove + List members = mExtension.getMembers(); + for (GroupExtension.Member m : members) { + if (m.operation == GroupExtension.Member.Operation.ADD) { + // mark user as added (clear pending flag) + getContext().getContentResolver().update(Groups + .getMembersUri(mGroup.getJid()).buildUpon() + .appendPath(m.jid.toString()) + .appendQueryParameter(Messages.CLEAR_PENDING, + String.valueOf(Groups.MEMBER_PENDING_ADDED)) + .build(), null, null, null); + } + else if (m.operation == GroupExtension.Member.Operation.REMOVE) { + // remove the user + getContext().getContentResolver().delete(Groups + .getMembersUri(mGroup.getJid()).buildUpon() + .appendPath(m.jid.toString()) + .build(), null, null); + } + } + case CREATE: + // resend pending stuff -- this will continue the delivery of + // group messages that were waiting for this group command + MessageCenterService.bus() + .post(new GroupCreatedEvent()); + break; + case PART: + if (mCommandMessage != null) { + // delete message only if it's not linked to a thread + if (getContext().getContentResolver() + .delete(mCommandMessage, Messages.THREAD_ID + " < 0", null) > 0) { + // group should be deleted only if the message was not linked to a thread + // that is, no thread exists anymore for this group, so no reason to keep the group + // delete group (members will cascade) + getContext().getContentResolver() + .delete(Groups.getUri(mGroup.getJid()), null, null); + } + } + break; + } + } +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/IPushListener.java b/app/src/main/java/org/kontalk/service/msgcenter/IPushListener.java index 21be9d3da..310e36677 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/IPushListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/IPushListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -27,12 +27,12 @@ */ public interface IPushListener { - public void onRegistered(Context context, String registrationId); + void onRegistered(Context context, String registrationId); - public void onUnregistered(Context context); + void onUnregistered(Context context); - public void onError(Context context, String errorId); + void onError(Context context, String errorId); - public String getSenderId(Context context); + String getSenderId(Context context); } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/IPushService.java b/app/src/main/java/org/kontalk/service/msgcenter/IPushService.java index b6bab777f..2aab9a136 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/IPushService.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/IPushService.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,6 +19,8 @@ package org.kontalk.service.msgcenter; +import java.util.concurrent.TimeUnit; + /** * Interface for a push client service. * @author Daniele Ricci @@ -29,29 +31,27 @@ public interface IPushService { * Default lifespan (7 days) of the {@link #isRegisteredOnServer()} * flag until it is considered expired. */ - // NOTE: cannot use TimeUnit.DAYS because it's not available on API Level 8 - public static final long DEFAULT_ON_SERVER_LIFESPAN_MS = - 1000 * 3600 * 24 * 7; + long DEFAULT_ON_SERVER_LIFESPAN_MS = TimeUnit.DAYS.toMillis(7); /** Begin the registration process to the push service. */ - public void register(IPushListener listener, String senderId); + void register(IPushListener listener, String senderId); /** Begin the unregistration process from the push service. */ - public void unregister(IPushListener listener); + void unregister(IPushListener listener); /** Retry last (un)registration attempt. */ - public void retry(); + void retry(); - /** Returns true if the device is registered with the push service. */ - public boolean isRegistered(); + /** Returns true if the device is registered with the push service. */ + boolean isRegistered(); /** Returns true if the push service is available on the device. */ - public boolean isServiceAvailable(); + boolean isServiceAvailable(); /** * Sets whether the device was successfully registered in the server side. */ - public void setRegisteredOnServer(boolean flag); + void setRegisteredOnServer(boolean flag); /** * Checks whether the device was successfully registered in the server side, @@ -62,7 +62,7 @@ public interface IPushService { * is {@link #DEFAULT_ON_SERVER_LIFESPAN_MS} by default (but can be changed * by {@link #setRegisterOnServerLifespan(long)}). */ - public boolean isRegisteredOnServer(); + boolean isRegisteredOnServer(); /** * Gets how long (in milliseconds) the {@link #isRegistered()} @@ -71,14 +71,14 @@ public interface IPushService { * @return value set by {@link #setRegisteredOnServer(boolean)} or * {@link #DEFAULT_ON_SERVER_LIFESPAN_MS} if not set. */ - public long getRegisterOnServerLifespan(); + long getRegisterOnServerLifespan(); /** * Sets how long (in milliseconds) the {@link #isRegistered()} * flag is valid. */ - public void setRegisterOnServerLifespan(long lifespan); + void setRegisterOnServerLifespan(long lifespan); - public String getRegistrationId(); + String getRegistrationId(); } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/IUploadService.java b/app/src/main/java/org/kontalk/service/msgcenter/IUploadService.java new file mode 100644 index 000000000..6f6f24248 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/IUploadService.java @@ -0,0 +1,36 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + + +/** + * Upload service interface. + * @author Daniele Ricci + */ +public interface IUploadService { + + boolean requiresCertificate(); + + void getPostUrl(String filename, long size, String mime, UrlCallback callback); + + interface UrlCallback { + void callback(String putUrl, String getUrl); + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/ImportKeyPairListener.java b/app/src/main/java/org/kontalk/service/msgcenter/ImportKeyPairListener.java index e87efeb60..f8fb50249 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/ImportKeyPairListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/ImportKeyPairListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,11 +20,12 @@ import android.widget.Toast; +import org.kontalk.Kontalk; import org.kontalk.R; import org.kontalk.crypto.PersonalKeyImporter; import org.jivesoftware.smack.packet.Stanza; -import org.spongycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPException; import java.io.IOException; import java.security.NoSuchProviderException; @@ -67,9 +68,7 @@ public void run() throws CertificateException, SignatureException, // proceed to send the public key to the server for approval // listen for connection events - setupConnectedReceiver(); - // request connection status - MessageCenterService.requestConnectionStatus(getContext()); + registerConnectionEvents(); // CONNECTED listener will do the rest } @@ -81,8 +80,8 @@ public void abort() { // TODO @Override - public void processPacket(Stanza packet) { - super.processPacket(packet); + public void processStanza(Stanza packet) { + super.processStanza(packet); // we are done here endKeyPairImport(); @@ -92,7 +91,7 @@ public void processPacket(Stanza packet) { protected void finish() { runOnUiThread(new Runnable() { public void run() { - Toast.makeText(getApplication(), + Toast.makeText(Kontalk.get(), R.string.msg_import_keypair_complete, Toast.LENGTH_LONG).show(); } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/LastActivityListener.java b/app/src/main/java/org/kontalk/service/msgcenter/LastActivityListener.java index f34d70d55..ae59b0ea0 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/LastActivityListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/LastActivityListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,40 +18,39 @@ package org.kontalk.service.msgcenter; -import static org.kontalk.service.msgcenter.MessageCenterService.ACTION_LAST_ACTIVITY; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_FROM; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_TO; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_PACKET_ID; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_SECONDS; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_TYPE; - +import org.jivesoftware.smack.ExceptionCallback; +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.XMPPException; import org.jivesoftware.smack.packet.Stanza; import org.jivesoftware.smackx.iqlast.packet.LastActivity; +import org.jxmpp.jid.Jid; -import android.content.Intent; +import org.kontalk.service.msgcenter.event.LastActivityEvent; /** * Packet listener for last activity iq. * @author Daniele Ricci */ -class LastActivityListener extends MessageCenterPacketListener { - - public LastActivityListener(MessageCenterService instance) { - super(instance); - } +class LastActivityListener implements StanzaListener, ExceptionCallback { @Override - public void processPacket(Stanza packet) { + public void processStanza(Stanza packet) { LastActivity p = (LastActivity) packet; - Intent i = new Intent(ACTION_LAST_ACTIVITY); - i.putExtra(EXTRA_PACKET_ID, p.getStanzaId()); - - i.putExtra(EXTRA_TYPE, p.getType().toString()); - i.putExtra(EXTRA_FROM, p.getFrom()); - i.putExtra(EXTRA_TO, p.getTo()); - i.putExtra(EXTRA_SECONDS, p.getIdleTime()); + MessageCenterService.bus() + .post(new LastActivityEvent(p.getFrom(), p.getIdleTime(), p.getStanzaId())); + } - sendBroadcast(i); + @Override + public void processException(Exception exception) { + if (exception instanceof XMPPException.XMPPErrorException) { + String id = ((XMPPException.XMPPErrorException) exception) + .getStanzaError().getStanza().getStanzaId(); + Jid jid = ((XMPPException.XMPPErrorException) exception) + .getStanzaError().getStanza().getFrom(); + MessageCenterService.bus() + .post(new LastActivityEvent(exception, jid, id)); + } + // we currently don't handle reply timeouts } } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/MessageAckListener.java b/app/src/main/java/org/kontalk/service/msgcenter/MessageAckListener.java index b4040685d..b01c6d0cd 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/MessageAckListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/MessageAckListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,18 +18,13 @@ package org.kontalk.service.msgcenter; -import java.util.Map; - import org.jivesoftware.smack.packet.Message; import org.jivesoftware.smack.packet.Stanza; import org.jivesoftware.smackx.receipts.DeliveryReceipt; -import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.ContentValues; -import android.net.Uri; - +import org.kontalk.provider.MessagesProviderClient.MessageUpdater; import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.util.WakefulHashSet; /** @@ -38,69 +33,51 @@ */ class MessageAckListener extends MessageCenterPacketListener { - // condition on delivered status in case we receive the receipt before the ack - private static final String selectionOutgoing = Messages.DIRECTION + "=" + Messages.DIRECTION_OUT + " AND " + + private static final String SELECTION_SENT_EXCLUDE = Messages.STATUS + " NOT IN (" + Messages.STATUS_RECEIVED + "," + Messages.STATUS_NOTDELIVERED + ")"; - private static final String selectionIncoming = Messages.DIRECTION + "=" + Messages.DIRECTION_IN; - public MessageAckListener(MessageCenterService instance) { + private final long mDatabaseId; + + public MessageAckListener(MessageCenterService instance, long databaseId) { super(instance); + mDatabaseId = databaseId; } @Override - public void processPacket(Stanza packet) { - if (!(packet instanceof Message)) { - return; - } + public void processStanza(Stanza packet) { + // stanzas coming here are always messages - Map waitingReceipt = getWaitingReceiptList(); + WakefulHashSet waitingReceipt = getWaitingReceiptList(); synchronized (waitingReceipt) { - String id = packet.getStanzaId(); - Long _msgId = waitingReceipt.remove(id); - long msgId = (_msgId != null) ? _msgId : 0; - ContentResolver cr = getContext().getContentResolver(); - long now = System.currentTimeMillis(); DeliveryReceipt receipt = DeliveryReceipt.from((Message) packet); if (receipt != null) { // ack received for outgoing delivery receipt // mark message as confirmed - ContentValues values = new ContentValues(1); - values.put(Messages.STATUS, Messages.STATUS_CONFIRMED); - cr.update(ContentUris.withAppendedId(Messages.CONTENT_URI, msgId), - values, selectionIncoming, null); - - waitingReceipt.remove(id); + MessageUpdater.forMessage(getContext(), mDatabaseId) + .setStatus(Messages.STATUS_CONFIRMED) + .commit(); } - - if (msgId > 0) { + else { // we have a message awaiting ack from server - ContentValues values = new ContentValues(3); - values.put(Messages.STATUS, Messages.STATUS_SENT); - values.put(Messages.STATUS_CHANGED, now); - values.put(Messages.SERVER_TIMESTAMP, now); - cr.update(ContentUris.withAppendedId(Messages.CONTENT_URI, msgId), - values, selectionOutgoing, null); - - // we can now release the message center. Hopefully - // there will be one hold and one matching release. - getIdleHandler().release(); - } - else if (id != null) { - // the user wasn't expecting ack for this message - // so we simply update it using the packet id as key - // FIXME this could lead to fake acks because message IDs are client-generated - Uri msg = Messages.getUri(id); - ContentValues values = new ContentValues(3); - values.put(Messages.STATUS, Messages.STATUS_SENT); - values.put(Messages.STATUS_CHANGED, now); - values.put(Messages.SERVER_TIMESTAMP, now); - cr.update(msg, values, selectionOutgoing, null); + MessageUpdater.forMessage(getContext(), mDatabaseId) + .setStatus(Messages.STATUS_SENT, now) + .setServerTimestamp(now) + .notifyOutgoing(packet.getTo().asBareJid().toString()) + // this will handle receipts that came before the message was acked by the server + .appendWhere(SELECTION_SENT_EXCLUDE) + .commit(); } - } + // remove the packet from the waiting list + // this will also release the wake lock + waitingReceipt.remove(mDatabaseId); + // we can now release the message center. Hopefully + // there will be one hold and one matching release. + release(); + } } } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/MessageCenterPacketListener.java b/app/src/main/java/org/kontalk/service/msgcenter/MessageCenterPacketListener.java index 6898b17eb..07f5aecdc 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/MessageCenterPacketListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/MessageCenterPacketListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,53 +19,53 @@ package org.kontalk.service.msgcenter; import java.lang.ref.WeakReference; -import java.util.HashMap; -import java.util.Map; -import org.jivesoftware.smack.PacketListener; +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.packet.Message; import org.jivesoftware.smack.packet.Stanza; import org.jivesoftware.smack.roster.RosterEntry; - -import org.kontalk.Kontalk; -import org.kontalk.client.EndpointServer; -import org.kontalk.client.KontalkConnection; -import org.kontalk.message.CompositeMessage; -import org.kontalk.service.msgcenter.MessageCenterService.IdleConnectionHandler; +import org.jxmpp.jid.BareJid; +import org.jxmpp.jid.Jid; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.net.Uri; +import androidx.annotation.CheckResult; +import androidx.annotation.Nullable; + +import org.kontalk.client.EndpointServer; +import org.kontalk.client.KontalkConnection; +import org.kontalk.service.msgcenter.MessageCenterService.IdleConnectionHandler; +import org.kontalk.util.WakefulHashSet; /** * Packet listener for the Message Center. * @author Daniele Ricci */ -abstract class MessageCenterPacketListener implements PacketListener { +public abstract class MessageCenterPacketListener implements StanzaListener { + protected static final String TAG = MessageCenterService.TAG; private WeakReference mInstance; + private WeakReference mConnection; MessageCenterPacketListener(MessageCenterService instance) { - mInstance = new WeakReference(instance); + mInstance = new WeakReference<>(instance); + mConnection = new WeakReference<>(instance.mConnection); } protected MessageCenterService getInstance() { return mInstance.get(); } + @Nullable protected Context getContext() { return mInstance.get(); } - protected Kontalk getApplication() { - return (Kontalk) mInstance.get().getApplicationContext(); - } - protected KontalkConnection getConnection() { - MessageCenterService instance = mInstance.get(); - return (instance != null) ? instance.mConnection : null; + return mConnection.get(); } protected EndpointServer getServer() { @@ -78,69 +78,68 @@ protected String getMyUsername() { return (instance != null) ? instance.mMyUsername : null; } - protected RosterEntry getRosterEntry(String jid) { + protected RosterEntry getRosterEntry(Jid jid) { + return getRosterEntry(jid.asBareJid()); + } + + protected RosterEntry getRosterEntry(BareJid jid) { MessageCenterService instance = mInstance.get(); return (instance != null) ? instance.getRosterEntry(jid) : null; } - protected void sendBroadcast(Intent intent) { + protected void queueTask(Runnable task) { MessageCenterService instance = mInstance.get(); if (instance != null) + instance.queueTask(task); + } + + /** @deprecated Use service bus. */ + @Deprecated + protected void sendBroadcast(Intent intent) { + MessageCenterService instance = mInstance.get(); + if (instance != null && instance.isStarted()) instance.mLocalBroadcastManager.sendBroadcast(intent); } protected void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) { MessageCenterService instance = mInstance.get(); - if (instance != null) + if (instance != null && instance.isStarted()) instance.mLocalBroadcastManager.registerReceiver(receiver, filter); } protected void unregisterReceiver(BroadcastReceiver receiver) { MessageCenterService instance = mInstance.get(); - if (instance != null) + if (instance != null && instance.isStarted()) instance.mLocalBroadcastManager.unregisterReceiver(receiver); } - protected void sendPacket(Stanza packet) { + @CheckResult + protected boolean sendPacket(Stanza packet) { MessageCenterService instance = mInstance.get(); - if (instance != null) - instance.sendPacket(packet); + return instance != null && instance.sendPacket(packet); } - protected void sendPacket(Stanza packet, boolean bumpIdle) { + @CheckResult + protected boolean sendPacket(Stanza packet, boolean bumpIdle) { MessageCenterService instance = mInstance.get(); - if (instance != null) - instance.sendPacket(packet, bumpIdle); + return instance != null && instance.sendPacket(packet, bumpIdle); } - protected void initUploadServices() { + protected boolean sendMessage(Message message, long databaseId) { MessageCenterService instance = mInstance.get(); - if (instance != null) { - if (instance.mUploadServices == null) - instance.mUploadServices = new HashMap(); - else - instance.mUploadServices.clear(); - } + return instance != null && instance.sendMessage(message, databaseId); } - protected void setUploadService(String name, String url) { + protected void addUploadService(IUploadService service) { MessageCenterService instance = mInstance.get(); if (instance != null) - instance.mUploadServices.put(name, url); + instance.addUploadService(service); } - protected void resendPendingMessages(boolean retrying) { + protected void addUploadService(IUploadService service, int priority) { MessageCenterService instance = mInstance.get(); if (instance != null) - instance.resendPendingMessages(retrying); - } - - protected void resendPending(boolean retrying) { - MessageCenterService instance = mInstance.get(); - if (instance != null) { - instance.resendPendingMessages(retrying); - instance.resendPendingReceipts(); - } + instance.addUploadService(service, priority); } protected boolean isPushNotificationsEnabled() { @@ -168,27 +167,31 @@ protected void pushRegister() { instance.pushRegister(); } - protected Map getWaitingReceiptList() { + protected WakefulHashSet getWaitingReceiptList() { MessageCenterService instance = mInstance.get(); return (instance != null) ? instance.mWaitingReceipt : null; } - protected Uri incoming(CompositeMessage msg) { + protected IdleConnectionHandler getIdleHandler() { MessageCenterService instance = mInstance.get(); - if (instance != null) - return instance.incoming(msg); + return (instance != null) ? instance.mIdleHandler: null; + } - return null; + protected void hold(boolean activate) { + IdleConnectionHandler handler = getIdleHandler(); + if (handler != null) + handler.hold(activate); } - protected IdleConnectionHandler getIdleHandler() { - MessageCenterService instance = mInstance.get(); - return (instance != null) ? instance.mIdleHandler: null; + protected void release() { + IdleConnectionHandler handler = getIdleHandler(); + if (handler != null) + handler.release(); } protected void runOnUiThread(Runnable action) { MessageCenterService instance = mInstance.get(); - if (instance != null) + if (instance != null && instance.mHandler != null) instance.mHandler.post(action); } @@ -203,5 +206,4 @@ protected void endKeyPairImport() { if (instance != null) instance.endKeyPairImport(); } - } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/MessageCenterService.java b/app/src/main/java/org/kontalk/service/msgcenter/MessageCenterService.java index b6eaf7e41..905d52e31 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/MessageCenterService.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/MessageCenterService.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,62 +20,81 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import java.lang.ref.WeakReference; import java.security.GeneralSecurityException; +import java.util.ArrayList; import java.util.Collection; -import java.util.HashMap; +import java.util.Date; import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.zip.ZipInputStream; -import org.jivesoftware.smack.AbstractXMPPConnection; +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.jivesoftware.smack.ExceptionCallback; import org.jivesoftware.smack.SmackConfiguration; import org.jivesoftware.smack.SmackException; import org.jivesoftware.smack.SmackException.NotConnectedException; import org.jivesoftware.smack.StanzaListener; import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.debugger.AbstractDebugger; +import org.jivesoftware.smack.debugger.SmackDebugger; +import org.jivesoftware.smack.debugger.SmackDebuggerFactory; import org.jivesoftware.smack.filter.StanzaFilter; -import org.jivesoftware.smack.filter.StanzaTypeFilter; import org.jivesoftware.smack.filter.StanzaIdFilter; +import org.jivesoftware.smack.filter.StanzaTypeFilter; import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.packet.StanzaError; import org.jivesoftware.smack.roster.Roster; import org.jivesoftware.smack.roster.RosterEntry; -import org.jivesoftware.smack.roster.RosterLoadedListener; import org.jivesoftware.smack.roster.packet.RosterPacket; +import org.jivesoftware.smack.sm.StreamManagementException; import org.jivesoftware.smack.util.Async; import org.jivesoftware.smack.util.StringUtils; +import org.jivesoftware.smack.util.SuccessCallback; import org.jivesoftware.smackx.caps.packet.CapsExtension; import org.jivesoftware.smackx.chatstates.ChatState; import org.jivesoftware.smackx.chatstates.packet.ChatStateExtension; import org.jivesoftware.smackx.csi.ClientStateIndicationManager; +import org.jivesoftware.smackx.delay.packet.DelayInformation; import org.jivesoftware.smackx.disco.packet.DiscoverInfo; +import org.jivesoftware.smackx.disco.packet.DiscoverItems; +import org.jivesoftware.smackx.forward.packet.Forwarded; import org.jivesoftware.smackx.iqlast.packet.LastActivity; import org.jivesoftware.smackx.iqversion.VersionManager; +import org.jivesoftware.smackx.iqversion.packet.Version; import org.jivesoftware.smackx.ping.PingFailedListener; import org.jivesoftware.smackx.ping.PingManager; import org.jivesoftware.smackx.receipts.DeliveryReceipt; import org.jivesoftware.smackx.receipts.DeliveryReceiptRequest; -import org.jxmpp.util.XmppStringUtils; - -import android.accounts.Account; +import org.jxmpp.jid.BareJid; +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; +import org.jxmpp.jid.parts.Domainpart; +import org.jxmpp.jid.parts.Localpart; +import org.jxmpp.stringprep.XmppStringprepException; +import org.bouncycastle.openpgp.PGPException; + +import android.annotation.SuppressLint; import android.app.AlarmManager; import android.app.PendingIntent; import android.app.Service; import android.content.BroadcastReceiver; -import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.database.Cursor; -import android.database.sqlite.SQLiteConstraintException; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; import android.net.Uri; -import android.os.Bundle; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; @@ -86,107 +105,157 @@ import android.os.PowerManager.WakeLock; import android.os.Process; import android.os.SystemClock; -import android.support.v4.content.LocalBroadcastManager; + +import androidx.annotation.CheckResult; +import androidx.annotation.Nullable; +import androidx.core.app.ServiceCompat; +import androidx.core.content.ContextCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; import android.text.TextUtils; -import android.util.Log; import android.widget.Toast; import org.kontalk.BuildConfig; import org.kontalk.Kontalk; +import org.kontalk.Log; import org.kontalk.R; import org.kontalk.authenticator.Authenticator; -import org.kontalk.authenticator.LegacyAuthentication; +import org.kontalk.authenticator.MyAccount; import org.kontalk.client.BitsOfBinary; import org.kontalk.client.BlockingCommand; import org.kontalk.client.E2EEncryption; import org.kontalk.client.EndpointServer; import org.kontalk.client.KontalkConnection; -import org.kontalk.client.PublicKeyPublish; -import org.kontalk.client.RosterMatch; import org.kontalk.client.OutOfBandData; +import org.kontalk.client.PublicKeyPublish; import org.kontalk.client.PushRegistration; -import org.kontalk.client.RawPacket; +import org.kontalk.client.RosterMatch; import org.kontalk.client.ServerlistCommand; import org.kontalk.client.SmackInitializer; -import org.kontalk.client.VCard4; +import org.kontalk.client.UserLocation; import org.kontalk.crypto.Coder; import org.kontalk.crypto.PersonalKey; import org.kontalk.data.Contact; +import org.kontalk.data.Conversation; +import org.kontalk.data.GroupInfo; +import org.kontalk.message.AttachmentComponent; import org.kontalk.message.CompositeMessage; +import org.kontalk.message.GroupCommandComponent; +import org.kontalk.message.GroupComponent; +import org.kontalk.message.HiddenTextComponent; +import org.kontalk.message.InReplyToComponent; +import org.kontalk.message.LocationComponent; +import org.kontalk.message.ReferencedMessage; import org.kontalk.message.TextComponent; -import org.kontalk.provider.MyMessages.CommonColumns; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.provider.MessagesProviderClient.MessageUpdater; import org.kontalk.provider.MyMessages.Messages; import org.kontalk.provider.MyMessages.Threads; import org.kontalk.provider.MyMessages.Threads.Requests; +import org.kontalk.provider.MyUsers; import org.kontalk.provider.UsersProvider; +import org.kontalk.reporting.ReportingManager; import org.kontalk.service.KeyPairGeneratorService; import org.kontalk.service.UploadService; import org.kontalk.service.XMPPConnectionHelper; import org.kontalk.service.XMPPConnectionHelper.ConnectionHelperListener; +import org.kontalk.service.msgcenter.event.BlocklistEvent; +import org.kontalk.service.msgcenter.event.BlocklistRequest; +import org.kontalk.service.msgcenter.event.ConnectedEvent; +import org.kontalk.service.msgcenter.event.DisconnectedEvent; +import org.kontalk.service.msgcenter.event.LastActivityRequest; +import org.kontalk.service.msgcenter.event.NoPresenceEvent; +import org.kontalk.service.msgcenter.event.PresenceEvent; +import org.kontalk.service.msgcenter.event.PresenceRequest; +import org.kontalk.service.msgcenter.event.PublicKeyRequest; +import org.kontalk.service.msgcenter.event.RosterMatchRequest; +import org.kontalk.service.msgcenter.event.RosterStatusEvent; +import org.kontalk.service.msgcenter.event.RosterStatusRequest; +import org.kontalk.service.msgcenter.event.SendChatStateRequest; +import org.kontalk.service.msgcenter.event.SendDeliveryReceiptRequest; +import org.kontalk.service.msgcenter.event.SendMessageRequest; +import org.kontalk.service.msgcenter.event.ServerListEvent; +import org.kontalk.service.msgcenter.event.ServerListRequest; +import org.kontalk.service.msgcenter.event.SetUserPrivacyRequest; +import org.kontalk.service.msgcenter.event.SubscribeRequest; +import org.kontalk.service.msgcenter.event.UnsubscribeRequest; +import org.kontalk.service.msgcenter.event.UpdateStatusRequest; +import org.kontalk.service.msgcenter.event.UploadAttachmentRequest; +import org.kontalk.service.msgcenter.event.UploadPrivateKeyRequest; +import org.kontalk.service.msgcenter.event.UploadServiceFoundEvent; +import org.kontalk.service.msgcenter.event.UserBlockedEvent; +import org.kontalk.service.msgcenter.event.UserOnlineEvent; +import org.kontalk.service.msgcenter.event.UserUnblockedEvent; +import org.kontalk.service.msgcenter.event.VersionRequest; +import org.kontalk.service.msgcenter.group.AddRemoveMembersCommand; +import org.kontalk.service.msgcenter.group.CreateGroupCommand; +import org.kontalk.service.msgcenter.group.GroupCommand; +import org.kontalk.service.msgcenter.group.GroupController; +import org.kontalk.service.msgcenter.group.GroupControllerFactory; +import org.kontalk.service.msgcenter.group.PartCommand; +import org.kontalk.service.msgcenter.group.SetSubjectCommand; import org.kontalk.ui.MessagingNotification; +import org.kontalk.util.DataUtils; +import org.kontalk.util.EventBusIndex; import org.kontalk.util.MediaStorage; import org.kontalk.util.MessageUtils; import org.kontalk.util.Preferences; import org.kontalk.util.SystemUtils; +import org.kontalk.util.WakefulHashSet; +import org.kontalk.util.XMPPUtils; + +import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_FOREGROUND; /** * The Message Center Service. * Use {@link Intent}s to deliver commands (via {@link Context#startService}). * Service will broadcast intents when certain events occur. + * * @author Daniele Ricci - * @version 4.0 */ public class MessageCenterService extends Service implements ConnectionHelperListener { public static final String TAG = MessageCenterService.class.getSimpleName(); + private static final EventBus BUS; + static { - SmackConfiguration.DEBUG = BuildConfig.DEBUG; + BUS = EventBus.builder() + // TODO .logger(...) + .addIndex(new EventBusIndex()) + .throwSubscriberException(BuildConfig.DEBUG) + .logNoSubscriberMessages(BuildConfig.DEBUG) + .build(); + SmackConfiguration.DEBUG = Log.isDebug(); + // we need our own debugger factory because of our internal logging system + SmackConfiguration.setDefaultSmackDebuggerFactory(new SmackDebuggerFactory() { + @Override + public SmackDebugger create(XMPPConnection connection) throws IllegalArgumentException { + return new AbstractDebugger(connection) { + @Override + protected void log(String logMessage) { + Log.d("SMACK", logMessage); + } + + @Override + protected void log(String logMessage, Throwable throwable) { + Log.d("SMACK", logMessage, throwable); + } + }; + } + }); } - public static final String ACTION_PACKET = "org.kontalk.action.PACKET"; public static final String ACTION_HOLD = "org.kontalk.action.HOLD"; public static final String ACTION_RELEASE = "org.kontalk.action.RELEASE"; public static final String ACTION_RESTART = "org.kontalk.action.RESTART"; public static final String ACTION_TEST = "org.kontalk.action.TEST"; - public static final String ACTION_MESSAGE = "org.kontalk.action.MESSAGE"; public static final String ACTION_PUSH_START = "org.kontalk.push.START"; public static final String ACTION_PUSH_STOP = "org.kontalk.push.STOP"; public static final String ACTION_PUSH_REGISTERED = "org.kontalk.push.REGISTERED"; public static final String ACTION_IDLE = "org.kontalk.action.IDLE"; public static final String ACTION_PING = "org.kontalk.action.PING"; - /** Request the roster. */ - public static final String ACTION_ROSTER = "org.kontalk.action.ROSTER"; - - /** Request roster match. */ - public static final String ACTION_ROSTER_MATCH = "org.kontalk.action.ROSTER_MATCH"; - - /** - * Broadcasted when we are connected and authenticated to the server. - * Send this intent to receive the same as a broadcast if connected. */ - public static final String ACTION_CONNECTED = "org.kontalk.action.CONNECTED"; - - /** - * Broadcasted when the roster has been loaded. - * Send this intent to receive the same as a broadcast if the roster has - * already been loaded. - */ - public static final String ACTION_ROSTER_LOADED = "org.kontalk.action.ROSTER_LOADED"; - - /** - * Broadcasted when a presence stanza is received. - * Send this intent to broadcast presence. - * Send this intent with type="probe" to request a presence in the roster. - */ - public static final String ACTION_PRESENCE = "org.kontalk.action.PRESENCE"; - - /** - * Broadcasted when a last activity iq is received. - * Send this intent to request a last activity. - */ - public static final String ACTION_LAST_ACTIVITY = "org.kontalk.action.LAST_ACTIVITY"; - /** * Commence key pair regeneration. * {@link KeyPairGeneratorService} service will be started to generate the @@ -195,131 +264,73 @@ public class MessageCenterService extends Service implements ConnectionHelperLis * key, it will be installed in the default account. * Broadcasted when key pair regeneration has completed. */ + @Deprecated public static final String ACTION_REGENERATE_KEYPAIR = "org.kontalk.action.REGEN_KEYPAIR"; - /** Commence key pair import. */ - public static final String ACTION_IMPORT_KEYPAIR = "org.kontalk.action.IMPORT_KEYPAIR"; - - /** - * Broadcasted when a presence subscription has been accepted. - * Send this intent to accept a presence subscription. - */ - public static final String ACTION_SUBSCRIBED = "org.kontalk.action.SUBSCRIBED"; - - /** - * Broadcasted when receiving a vCard. - * Send this intent to update your own vCard. - */ - public static final String ACTION_VCARD = "org.kontalk.action.VCARD"; - - /** - * Broadcasted when receiving a public key. - * Send this intent to request a public key. - */ - public static final String ACTION_PUBLICKEY = "org.kontalk.action.PUBLICKEY"; - /** - * Broadcasted when receiving the server list. - * Send this intent to request the server list. + * Commence key pair import. */ - public static final String ACTION_SERVERLIST = "org.kontalk.action.SERVERLIST"; - - /** - * Send this intent to retry to send a pending-user-review message. - */ - public static final String ACTION_RETRY = "org.kontalk.action.RETRY"; - - /** - * Broadcasted when the blocklist is received. - * Send this intent to request the blocklist. - */ - public static final String ACTION_BLOCKLIST = "org.kontalk.action.BLOCKLIST"; - - /** Broadcasted when a block request has ben accepted by the server. */ - public static final String ACTION_BLOCKED = "org.kontalk.action.BLOCKED"; - - /** Broadcasted when an unblock request has ben accepted by the server. */ - public static final String ACTION_UNBLOCKED = "org.kontalk.action.UNBLOCKED"; + @Deprecated + public static final String ACTION_IMPORT_KEYPAIR = "org.kontalk.action.IMPORT_KEYPAIR"; // common parameters - public static final String EXTRA_PACKET_ID = "org.kontalk.packet.id"; - public static final String EXTRA_TYPE = "org.kontalk.packet.type"; - - // use with org.kontalk.action.PACKET - public static final String EXTRA_PACKET = "org.kontalk.packet"; - public static final String EXTRA_PACKET_GROUP = "org.kontalk.packet.group"; - public static final String EXTRA_STAMP = "org.kontalk.packet.delay"; - - // use with org.kontalk.action.PRESENCE/SUBSCRIBED - public static final String EXTRA_FROM = "org.kontalk.stanza.from"; - public static final String EXTRA_TO = "org.kontalk.stanza.to"; - public static final String EXTRA_STATUS = "org.kontalk.presence.status"; - public static final String EXTRA_SHOW = "org.kontalk.presence.show"; - public static final String EXTRA_PRIORITY = "org.kontalk.presence.priority"; - public static final String EXTRA_PRIVACY = "org.kontalk.presence.privacy"; - public static final String EXTRA_FINGERPRINT = "org.kontalk.presence.fingerprint"; - public static final String EXTRA_SUBSCRIBED_FROM = "org.kontalk.presence.subscribed.from"; - public static final String EXTRA_SUBSCRIBED_TO = "org.kontalk.presence.subscribed.to"; - - // use with org.kontalk.action.ROSTER - public static final String EXTRA_JIDLIST = "org.kontalk.roster.JIDList"; - - // use with org.kontalk.action.LAST_ACTIVITY - public static final String EXTRA_SECONDS = "org.kontalk.last.seconds"; - - // use with org.kontalk.action.VCARD - public static final String EXTRA_PUBLIC_KEY = "org.kontalk.vcard.publicKey"; - - // used with org.kontalk.action.BLOCKLIST - public static final String EXTRA_BLOCKLIST = "org.kontalk.blocklist"; + public static final String EXTRA_FOREGROUND = "org.kontalk.foreground"; // used with org.kontalk.action.IMPORT_KEYPAIR public static final String EXTRA_KEYPACK = "org.kontalk.keypack"; public static final String EXTRA_PASSPHRASE = "org.kontalk.passphrase"; - // used for org.kontalk.presence.privacy.action extra - /** Accept subscription. */ - public static final int PRIVACY_ACCEPT = 0; - /** Block user. */ - public static final int PRIVACY_BLOCK = 1; - /** Unblock user. */ - public static final int PRIVACY_UNBLOCK = 2; - /** Reject subscription and block. */ - public static final int PRIVACY_REJECT = 3; - - /** Message URI. */ - public static final String EXTRA_MESSAGE = "org.kontalk.message"; + // used with org.kontalk.action.TEST + public static final String EXTRA_TEST_CHECK_NETWORK = "org.kontalk.test.check_network"; // other public static final String PUSH_REGISTRATION_ID = "org.kontalk.PUSH_REGISTRATION_ID"; private static final String DEFAULT_PUSH_PROVIDER = "gcm"; - /** How much time before a wakeup alarm triggers. */ - public final static int DEFAULT_WAKEUP_TIME = 900000; - /** Minimal wakeup time. */ + /** + * Minimal wakeup time. + */ public final static int MIN_WAKEUP_TIME = 300000; - /** How much time to wait to idle the message center (default 5 mins). */ - private final static int DEFAULT_IDLE_TIME = 5*60*1000; - - /** Normal ping tester timeout. */ + /** + * Normal ping tester timeout. + */ private static final int SLOW_PING_TIMEOUT = 10000; - /** Fast ping tester timeout. */ - private static final int FAST_PING_TIMEOUT = 3000; - /** Minimal interval between connection tests (5 mins). */ - private static final int MIN_TEST_INTERVAL = 5*60*1000; + + /** + * Fast ping tester timeout. + */ + private static final int FAST_PING_TIMEOUT = 5000; + + /** + * Minimal interval between connection tests (2 mins). + */ + private static final int MIN_TEST_INTERVAL = 2 * 60 * 1000; + + /** How long to retain the wakelock to wait for incoming messages. */ + private static final int WAIT_FOR_MESSAGES_DELAY = 5000; static final IPushListener sPushListener = PushServiceManager.getDefaultListener(); - /** Push service instance. */ + /** + * Push service instance. + */ private IPushService mPushService; - /** Push notifications enabled flag. */ + /** + * Push notifications enabled flag. + */ boolean mPushNotifications; - /** Server push sender id. This is static so the {@link IPushListener} can see it. */ + /** + * Server push sender id. This is static so the {@link IPushListener} can see it. + */ static String sPushSenderId; - /** Push registration id. */ - private String mPushRegistrationId; - /** Flag marking a currently ongoing push registration cycle (unregister/register) */ + /** + * Push registration id. + */ + String mPushRegistrationId; + /** + * Flag marking a currently ongoing push registration cycle (unregister/register) + */ boolean mPushRegistrationCycle; // created in onCreate @@ -328,62 +339,106 @@ public class MessageCenterService extends Service implements ConnectionHelperLis LocalBroadcastManager mLocalBroadcastManager; private AlarmManager mAlarmManager; + private LastActivityListener mLastActivityListener; private PingFailedListener mPingFailedListener; - /** Cached last used server. */ + /** + * Cached last used server. + */ EndpointServer mServer; - /** The connection helper instance. */ - private XMPPConnectionHelper mHelper; - /** The connection instance. */ + /** + * The connection helper instance. + */ + XMPPConnectionHelper mHelper; + /** + * The connection instance. + */ KontalkConnection mConnection; - /** My username (account name). */ + /** + * My username (account name). + */ String mMyUsername; - /** Supported upload services. */ - Map mUploadServices; + /** The current network identifier. */ + private String mCurrentNetwork; + + /** + * Supported upload services. + */ + List mUploadServices; - /** Roster store. */ + /** + * Roster store. + */ private SQLiteRosterStore mRosterStore; - /** Service handler. */ + /** + * Service handler. + */ Handler mHandler; + /** + * Task execution pool. Generally used by packet listeners. + */ + private ExecutorService mThreadPool; - /** Idle handler. */ + /** + * Idle handler. + */ IdleConnectionHandler mIdleHandler; - /** Inactive state flag (for CSI). */ + /** + * Inactive state flag (for CSI). + */ private boolean mInactive; - /** Timestamp of last use of {@link #ACTION_TEST}. */ + /** + * Timestamp of last use of {@link #ACTION_TEST}. + */ private long mLastTest; - /** Pending intent for idle signaling. */ + /** + * Pending intent for idle signaling. + */ private PendingIntent mIdleIntent; private boolean mFirstStart = true; - /** Messages waiting for server receipt (packetId: internalStorageId). */ - Map mWaitingReceipt = new HashMap(); + /** + * Messages waiting for server ack (type: internalStorageId). + */ + WakefulHashSet mWaitingReceipt; private RegenerateKeyPairListener mKeyPairRegenerator; private ImportKeyPairListener mKeyPairImporter; static final class IdleConnectionHandler extends Handler implements IdleHandler { - /** Idle signal. */ + /** + * Idle signal. + */ private static final int MSG_IDLE = 1; - /** Inactive signal (for CSI). */ + /** + * Inactive signal (for CSI). + */ private static final int MSG_INACTIVE = 2; - /** Test signal. */ + /** + * Test signal. + */ private static final int MSG_TEST = 3; - /** How much time to wait to enter inactive state. */ + /** + * How much time to wait to enter inactive state. + */ private final static int INACTIVE_TIME = 30000; - /** A reference to the message center. */ - private WeakReference s; - /** Reference counter. */ - private int mRefCount; + /** + * A reference to the message center. + */ + WeakReference s; + /** + * Reference counter. + */ + int mRefCount; public IdleConnectionHandler(MessageCenterService service, int refCount, Looper looper) { super(looper); - s = new WeakReference(service); + s = new WeakReference<>(service); mRefCount = refCount; // set idle handler for the first idle message @@ -413,6 +468,11 @@ public void handleMessage(Message msg) { private boolean handleMessage(MessageCenterService service, Message msg) { if (msg.what == MSG_IDLE) { + if (service.isConnecting()) { + // try again next time + queueInactive(); + } + // push notifications unavailable: set up an alarm for next time if (service.mPushRegistrationId == null) { service.setWakeupAlarm(); @@ -423,22 +483,30 @@ private boolean handleMessage(MessageCenterService service, Message msg) { return true; } - - else if (msg.what == MSG_INACTIVE && !service.isInactive()) { + else if (msg.what == MSG_INACTIVE) { service.inactive(); return true; } - else if (msg.what == MSG_TEST) { long now = System.currentTimeMillis(); if ((now - service.getLastReceivedStanza()) >= FAST_PING_TIMEOUT) { if (!service.fastReply()) { Log.v(TAG, "test ping failed"); - AdaptiveServerPingManager.pingFailed(service.mConnection); + XMPPConnection conn = service.mConnection; + if (conn != null) { + AndroidAdaptiveServerPingManager + .getInstanceFor(conn, service) + .pingFailed(); + } restart(service.getApplicationContext()); } else { - AdaptiveServerPingManager.pingSuccess(service.mConnection); + XMPPConnection conn = service.mConnection; + if (conn != null) { + AndroidAdaptiveServerPingManager + .getInstanceFor(conn, service) + .pingSuccess(); + } } } return true; @@ -447,13 +515,17 @@ else if (msg.what == MSG_TEST) { return false; } - /** Resets the idle timer. */ + /** + * Resets the idle timer. + */ public void reset(int refCount) { mRefCount = refCount; reset(); } - /** Resets the idle timer. */ + /** + * Resets the idle timer. + */ public void reset() { removeMessages(MSG_IDLE); removeMessages(MSG_INACTIVE); @@ -468,12 +540,21 @@ public void idle() { sendMessage(obtainMessage(MSG_IDLE)); } - public void hold() { + public void hold(boolean activate) { mRefCount++; if (mRefCount > 0) { MessageCenterService service = s.get(); - if (service != null && service.isInactive() && service.isConnected()) { - service.active(); + if (service != null) { + if (service.isConnected() && service.isInactive()) { + service.active(activate); + } + /* + * test if the connection helper is waiting for exponential backoff for more time that + * we'd like the user to wait for. In that case, restart the connection immediately + */ + else if (service.mHelper != null && service.mHelper.isBackingOff(SLOW_PING_TIMEOUT)) { + service.quit(true); + } } } post(new Runnable() { @@ -492,7 +573,19 @@ public void run() { removeMessages(MSG_IDLE); removeMessages(MSG_INACTIVE); Looper.myQueue().addIdleHandler(IdleConnectionHandler.this); - queueInactive(); + + // trigger inactive timer only if connected + // the authenticated callback will ensure it will trigger anyway + MessageCenterService service = s.get(); + if (service != null) { + // restart in foreground if needed + // this is used to keep the service alive for the necessary time + startForegroundIfNeeded(service, getStartIntent(service)); + + if (!service.isInactive() && service.isConnected()) { + queueInactive(); + } + } } }); } @@ -500,11 +593,14 @@ public void run() { public void quit() { abortIdle(); + abortTest(); getLooper().quit(); } - /** Aborts any idle message because we are using the service or quitting. */ - private void abortIdle() { + /** + * Aborts any idle message because we are using the service or quitting. + */ + void abortIdle() { Looper.myQueue().removeIdleHandler(IdleConnectionHandler.this); removeMessages(MSG_IDLE); removeMessages(MSG_INACTIVE); @@ -513,11 +609,12 @@ private void abortIdle() { service.cancelIdleAlarm(); } - public void queueInactiveIfNeeded() { + public void forceInactiveIfNeeded() { post(new Runnable() { public void run() { - if (mRefCount <= 0 && !hasMessages(MSG_INACTIVE)) { - queueInactive(); + MessageCenterService service = s.get(); + if (service != null && mRefCount <= 0 && !service.isInactive()) { + forceInactive(); } } }); @@ -525,13 +622,14 @@ public void run() { public void forceInactive() { MessageCenterService service = s.get(); - if (service != null && !service.isInactive()) { + if (service != null) { removeMessages(MSG_INACTIVE); - service.inactive(); + if (service.isConnected()) + service.inactive(); } } - private void queueInactive() { + void queueInactive() { // send inactive state message only if connected MessageCenterService service = s.get(); if (service != null && service.isConnected()) { @@ -539,6 +637,10 @@ private void queueInactive() { } } + public boolean isHeld() { + return mRefCount > 0; + } + public void test() { post(new Runnable() { public void run() { @@ -548,6 +650,10 @@ public void run() { } }); } + + private void abortTest() { + removeMessages(MSG_TEST); + } } private final BroadcastReceiver mInactivityReceiver = new BroadcastReceiver() { @@ -563,24 +669,24 @@ public void onReceive(Context context, Intent intent) { } } } - } ; + }; @Override public void onCreate() { + // configure XMPP client configure(); - // activate ping manager - AdaptiveServerPingManager.onCreate(this); - // create the roster store mRosterStore = new SQLiteRosterStore(this); - // create the global wake lock - PowerManager pwr = (PowerManager) getSystemService(Context.POWER_SERVICE); - mWakeLock = pwr.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, Kontalk.TAG); - mWakeLock.setReferenceCounted(false); - mPingLock = pwr.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, Kontalk.TAG + "-Ping"); - mPingLock.setReferenceCounted(false); + // waiting receipt list + // also used for keeping the device on while waiting for message delivery + mWaitingReceipt = new WakefulHashSet<>(10, this, PowerManager + .PARTIAL_WAKE_LOCK, Kontalk.TAG + "-SEND"); + + // create the global wake locks + mWakeLock = SystemUtils.createPartialWakeLock(this, Kontalk.TAG + "-Connect", false); + mPingLock = SystemUtils.createPartialWakeLock(this, Kontalk.TAG + "-Ping", false); mAlarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); // cancel any pending alarm intent @@ -597,12 +703,21 @@ public void onCreate() { // register screen off listener for manual inactivation registerInactivity(); + + // we can now begin to receive events + BUS.register(this); + } + + void queueTask(Runnable task) { + if (mThreadPool != null) { + mThreadPool.execute(task); + } } private void createIdleHandler() { HandlerThread thread = new HandlerThread("IdleThread", Process.THREAD_PRIORITY_BACKGROUND); thread.start(); - int refCount = Kontalk.get(this).getReferenceCounter(); + int refCount = Kontalk.get().getReferenceCounter(); mIdleHandler = new IdleConnectionHandler(this, refCount, thread.getLooper()); } @@ -616,26 +731,106 @@ private void unregisterInactivity() { unregisterReceiver(mInactivityReceiver); } - void sendPacket(Stanza packet) { - sendPacket(packet, true); + boolean sendMessage(org.jivesoftware.smack.packet.Message message, long databaseId) { + final KontalkConnection conn = mConnection; + if (conn != null) { + if (databaseId > 0) { + // set up an ack listener so we can set message status to sent + try { + conn.addStanzaIdAcknowledgedListener(message.getStanzaId(), + new MessageAckListener(this, databaseId)); + } + catch (StreamManagementException.StreamManagementNotEnabledException e) { + return false; + } + + // matching release is in SM ack listener + mIdleHandler.hold(false); + mWaitingReceipt.add(databaseId); + } + + boolean sent = sendPacket(message); + if (!sent && databaseId > 0) { + // message was not sent, remove from waiting queue + mWaitingReceipt.remove(databaseId); + mIdleHandler.release(); + } + + return sent; + } + return false; + } + + boolean sendPacket(Stanza packet) { + return sendPacket(packet, true); } /** * Sends a packet to the connection if found. + * * @param bumpIdle true if the idle handler must be notified of this event */ - void sendPacket(Stanza packet, boolean bumpIdle) { + boolean sendPacket(Stanza packet, boolean bumpIdle) { // reset idler if requested - if (bumpIdle) mIdleHandler.reset(); + if (bumpIdle && mIdleHandler != null) + mIdleHandler.reset(); - if (mConnection != null) { + final XMPPConnection conn = mConnection; + if (conn != null) { try { - mConnection.sendStanza(packet); + conn.sendStanza(packet); + return true; } catch (NotConnectedException e) { // ignored Log.v(TAG, "not connected. Dropping packet " + packet); } + catch (InterruptedException e) { + // ignored + Log.v(TAG, "interrupted. Dropping packet " + packet); + } + } + + return false; + } + + void sendIqWithReply(IQ packet, boolean bumpIdle, final StanzaListener callback, @Nullable final ExceptionCallback errorCallback) { + // reset idler if requested + if (bumpIdle && mIdleHandler != null) + mIdleHandler.reset(); + + final XMPPConnection conn = mConnection; + if (conn != null) { + conn.sendIqRequestAsync(packet).onSuccess(new SuccessCallback() { + @Override + public void onSuccess(IQ result) { + try { + callback.processStanza(result); + } + catch (NotConnectedException e) { + Log.v(TAG, "not connected. Dropping result packet " + result); + if (errorCallback != null) + errorCallback.processException(e); + } + catch (InterruptedException e) { + Log.v(TAG, "interrupted. Dropping result packet " + result); + if (errorCallback != null) + errorCallback.processException(e); + } + catch (SmackException.NotLoggedInException e) { + Log.v(TAG, "not logged in. Dropping result packet " + result); + if (errorCallback != null) + errorCallback.processException(e); + } + } + }) + .onError(new org.jivesoftware.smack.util.ExceptionCallback() { + @Override + public void processException(Exception exception) { + if (errorCallback != null) + errorCallback.processException(exception); + } + }); } } @@ -659,11 +854,18 @@ public IBinder onBind(Intent intent) { @Override public void onDestroy() { Log.d(TAG, "destroying message center"); + + BUS.unregister(this); + quit(false); + // deactivate ping manager - AdaptiveServerPingManager.onDestroy(); + AndroidAdaptiveServerPingManager.onDestroy(); + // destroy roster store mRosterStore.onDestroy(); + mRosterStore = null; + // unregister screen off listener for manual inactivation unregisterInactivity(); @@ -679,6 +881,36 @@ public void onDestroy() { mPingLock.release(); mPingLock = null; } + + // currently in online mode + // take care of specific situations for rescheduling us + if (!isOfflineMode(this)) { + // shutting down due to missing network connection + // schedule a reconnection for next time we'll have network + // (only on systems on which we do not receive network state changes + // and if we don't have push notifications or pending work) + if (!SystemUtils.isReceivingNetworkStateChanges() && + (!isPushNotificationsAvailable(this) || hasPendingWork()) && + !SystemUtils.isNetworkConnectionAvailable(this)) { + Log.i(TAG, "network absent and no way of resuming, scheduling job for next time"); + scheduleStartJob(); + } + + // if we have pending messages, it means the OS is killing us + // schedule a reconnection for next time we'll have network + /* TODO needs more analysis + else if (mustSetForeground(this) && hasPendingWork()) { + // TODO remember to check with hasPendingWork if we need to foreground + Log.i(TAG, "we have pending work, scheduling foreground job for next time"); + scheduleStartJob(); + } + */ + } + } + + private boolean hasPendingWork() { + return MessagesProviderClient.getPendingMessagesCount(this) > 0 || + Preferences.getLastPushNotification() > 0; } public boolean isStarted() { @@ -690,13 +922,22 @@ private synchronized void quit(boolean restarting) { // quit the idle handler mIdleHandler.quit(); mIdleHandler = null; + // destroy the service handler + // (can't stop it because it's the main thread) + mHandler = null; } else { // reset the reference counter - int refCount = ((Kontalk) getApplicationContext()).getReferenceCounter(); + int refCount = Kontalk.get().getReferenceCounter(); mIdleHandler.reset(refCount); } + // stop all running tasks + if (mThreadPool != null) { + mThreadPool.shutdownNow(); + mThreadPool = null; + } + // disable listeners if (mHelper != null) mHelper.setListener(null); @@ -706,30 +947,61 @@ private synchronized void quit(boolean restarting) { // abort connection helper (if any) if (mHelper != null) { // this is because of NetworkOnMainThreadException - new AbortThread(mHelper).start(); + AbortThread abortThread = new AbortThread(mHelper); + abortThread.start(); mHelper = null; + try { + abortThread.join(500); + // istantShutdown is synchronized + abortThread.interrupt(); + } + catch (InterruptedException ignored) { + } } // disconnect from server (if any) if (mConnection != null) { // disable ping manager - AdaptiveServerPingManager.getInstanceFor(mConnection).setEnabled(false); + AndroidAdaptiveServerPingManager + .getInstanceFor(mConnection, this) + .setEnabled(false); + PingManager.getInstanceFor(mConnection) .unregisterPingFailedListener(mPingFailedListener); + mPingFailedListener = null; + // this is because of NetworkOnMainThreadException - new DisconnectThread(mConnection).start(); - mConnection = null; + DisconnectThread disconnectThread = new DisconnectThread(mConnection); + disconnectThread.start(); + if (!disconnectThread.joinTimeout(500)) { + // we didn't succeed in terminating the connection + // discard it and we'll create a new one + mConnection = null; + } + + // clear the connection only if we are quitting + if (!restarting) + mConnection = null; + } + + if (mUploadServices != null) { + mUploadServices.clear(); + mUploadServices = null; } // clear cached data from contacts Contact.invalidateData(); // stop any key pair regeneration service - if (!LegacyAuthentication.isUpgrading()) - endKeyPairRegeneration(); + endKeyPairRegeneration(); + + BUS.removeAllStickyEvents(); + BUS.post(new DisconnectedEvent()); - // release the wakelock - mWakeLock.release(); + if (!restarting) { + // release the wakelock if not restarting + mWakeLock.release(); + } } private static final class AbortThread extends Thread { @@ -743,6 +1015,7 @@ public AbortThread(XMPPConnectionHelper helper) { public void run() { try { mHelper.shutdown(); + mHelper.interrupt(); } catch (Exception e) { // ignored @@ -751,9 +1024,9 @@ public void run() { } private static final class DisconnectThread extends Thread { - private final AbstractXMPPConnection mConn; + private final KontalkConnection mConn; - public DisconnectThread(AbstractXMPPConnection conn) { + public DisconnectThread(KontalkConnection conn) { mConn = conn; } @@ -763,378 +1036,651 @@ public void run() { mConn.disconnect(); } catch (Exception e) { - // ignored + mConn.instantShutdown(); + } + } + + @CheckResult + public boolean joinTimeout(long millis) { + try { + // we must wait for the connection to actually close + join(millis); + // this won't send the last sm ack, preventing another interruptable zone + mConn.suspendSmAck(); + interrupt(); + return !isAlive(); + } + catch (InterruptedException ignored) { + return false; } } } private void handleIntent(Intent intent) { - // stop immediately - if (isOfflineMode(this)) - stopSelf(); - if (intent != null) { String action = intent.getAction(); // proceed to start only if network is available boolean canConnect = canConnect(); - boolean isConnected = isConnected(); - boolean doConnect = false; + boolean doConnect; - // TODO convert actions to classes + switch (action != null ? action : "") { + case ACTION_HOLD: + doConnect = handleHold(intent); + break; - if (ACTION_PACKET.equals(action)) { - Object data; - String[] group = intent.getStringArrayExtra(EXTRA_PACKET_GROUP); - if (group != null) - data = group; - else - data = intent.getStringExtra(EXTRA_PACKET); + case ACTION_RELEASE: + doConnect = handleRelease(); + break; - try { - for (String pack : group) - sendPacket(new RawPacket(pack)); - } - catch (NullPointerException e) { - sendPacket(new RawPacket((String) data)); - } - } + case ACTION_IDLE: + doConnect = handleIdle(); + break; - else if (ACTION_HOLD.equals(action)) { - if (!mFirstStart) - mIdleHandler.hold(); - doConnect = true; - } + case ACTION_PUSH_START: + doConnect = handlePushStart(); + break; - else if (ACTION_RELEASE.equals(action)) { - mIdleHandler.release(); - } + case ACTION_PUSH_STOP: + doConnect = handlePushStop(); + break; - else if (ACTION_IDLE.equals(action)) { - mIdleHandler.idle(); - } + case ACTION_PUSH_REGISTERED: + doConnect = handlePushRegistered(intent); + break; - else if (ACTION_PUSH_START.equals(action)) { - setPushNotifications(true); - } + case ACTION_REGENERATE_KEYPAIR: + doConnect = handleRegenerateKeyPair(intent); + break; - else if (ACTION_PUSH_STOP.equals(action)) { - setPushNotifications(false); - } + case ACTION_IMPORT_KEYPAIR: + doConnect = handleImportKeyPair(intent); + break; - else if (ACTION_PUSH_REGISTERED.equals(action)) { - String regId = intent.getStringExtra(PUSH_REGISTRATION_ID); - // registration cycle under way - if (regId == null && mPushRegistrationCycle) { - mPushRegistrationCycle = false; - pushRegister(); - } - else - setPushRegistrationId(regId); - } + case ACTION_RESTART: + doConnect = handleRestart(); + break; - else if (ACTION_REGENERATE_KEYPAIR.equals(action)) { - doConnect = true; - beginKeyPairRegeneration(); - } + case ACTION_TEST: + doConnect = handleTest(canConnect, intent); + break; - else if (ACTION_IMPORT_KEYPAIR.equals(action)) { - // zip file with keys - Uri file = intent.getParcelableExtra(EXTRA_KEYPACK); - // passphrase to decrypt files - String passphrase = intent.getStringExtra(EXTRA_PASSPHRASE); - beginKeyPairImport(file, passphrase); - } + case ACTION_PING: + doConnect = handlePing(canConnect); + break; - else if (ACTION_CONNECTED.equals(action)) { - if (isConnected) - broadcast(ACTION_CONNECTED); + default: + // no command means normal service start, connect if not connected + doConnect = true; + break; } - // restart - else if (ACTION_RESTART.equals(action)) { - quit(true); - doConnect = true; + if (isOfflineMode(this)) { + // stop immediately + canConnect = doConnect = false; } - else if (ACTION_TEST.equals(action)) { - if (isConnected()) { - if (canTest()) { - mLastTest = SystemClock.elapsedRealtime(); - mIdleHandler.test(); - } - } - else { - doConnect = canConnect; - } + if (canConnect && doConnect) + createConnection(); + + if (!canConnect && !doConnect && !isConnected() && !isConnecting()) { + // no reason to exist + stopSelf(); + } + else { + // immediately setup (or disable) the foreground notification if requested + setForeground(shouldStartInForeground(this) || + intent.getBooleanExtra(EXTRA_FOREGROUND, false)); } - else if (ACTION_PING.equals(action)) { - if (isConnected()) { - // acquire a wake lock - mPingLock.acquire(); - final XMPPConnection connection = mConnection; - final PingManager pingManager = PingManager.getInstanceFor(connection); - Async.go(new Runnable() { - @Override - public void run() { - try { - if (pingManager.pingMyServer(true, SLOW_PING_TIMEOUT)) { - AdaptiveServerPingManager.pingSuccess(connection); - } - else { - AdaptiveServerPingManager.pingFailed(connection); - } - } - catch (SmackException.NotConnectedException e) { - // ignored - } - finally { - // release the wake lock - if (mPingLock != null) - mPingLock.release(); - } - } - }, "PingServerIfNecessary (" + mConnection.getConnectionCounter() + ')'); - } - else { - doConnect = canConnect; - } - } + mFirstStart = false; + } + else { + Log.v(TAG, "restarting after service crash"); + start(getApplicationContext()); + } + } - else if (ACTION_MESSAGE.equals(action)) { - if (canConnect && isConnected) - sendMessage(intent.getExtras()); - } + // methods below handle single intent commands + // the returned value is assigned to doConnect in onStartCommand() - else if (ACTION_ROSTER.equals(action) || ACTION_ROSTER_MATCH.equals(action)) { - if (canConnect && isConnected) { - Stanza iq; + /** + * For documentation purposes only. Used by the command handler to quickly + * find what command string they handle. + */ + @Retention(value = RetentionPolicy.SOURCE) + @Target(value = ElementType.METHOD) + private @interface CommandHandler { + String[] name(); + } - if (ACTION_ROSTER_MATCH.equals(action)) { - iq = new RosterMatch(); - String[] list = intent.getStringArrayExtra(EXTRA_JIDLIST); + @CommandHandler(name = ACTION_HOLD) + private boolean handleHold(Intent intent) { + if (!mFirstStart) + mIdleHandler.hold(intent.getBooleanExtra("org.kontalk.activate", false)); + return true; + } - for (String item : list) { - ((RosterMatch) iq).addItem(item); - } + @CommandHandler(name = ACTION_RELEASE) + private boolean handleRelease() { + mIdleHandler.release(); + return false; + } - // directed to the probe component - iq.setTo(XmppStringUtils.completeJidFrom("probe", mServer.getNetwork())); - } - else { - iq = new RosterPacket(); - } + @CommandHandler(name = ACTION_IDLE) + private boolean handleIdle() { + mIdleHandler.idle(); + return false; + } - String id = intent.getStringExtra(EXTRA_PACKET_ID); - iq.setStanzaId(id); - // iq default type is get + @CommandHandler(name = ACTION_PUSH_START) + private boolean handlePushStart() { + setPushNotifications(true); + return false; + } + + @CommandHandler(name = ACTION_PUSH_STOP) + private boolean handlePushStop() { + setPushNotifications(false); + return false; + } - sendPacket(iq); + @CommandHandler(name = ACTION_PUSH_REGISTERED) + private boolean handlePushRegistered(Intent intent) { + String regId = intent.getStringExtra(PUSH_REGISTRATION_ID); + // registration cycle under way + if (regId == null && mPushRegistrationCycle) { + mPushRegistrationCycle = false; + pushRegister(); + } + else + setPushRegistrationId(regId); + return false; + } + + @CommandHandler(name = ACTION_REGENERATE_KEYPAIR) + @Deprecated + private boolean handleRegenerateKeyPair(Intent intent) { + beginKeyPairRegeneration(intent.getStringExtra(EXTRA_PASSPHRASE)); + return true; + } + + @CommandHandler(name = ACTION_IMPORT_KEYPAIR) + @Deprecated + private boolean handleImportKeyPair(Intent intent) { + // zip file with keys + Uri file = intent.getParcelableExtra(EXTRA_KEYPACK); + // passphrase to decrypt files + String passphrase = intent.getStringExtra(EXTRA_PASSPHRASE); + beginKeyPairImport(file, passphrase); + return false; + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleUploadPrivateKey(UploadPrivateKeyRequest request) { + if (isConnected()) { + beginUploadPrivateKey(request.passphrase); + } + } + + @CommandHandler(name = ACTION_RESTART) + private boolean handleRestart() { + quit(true); + return true; + } + + @CommandHandler(name = ACTION_TEST) + private boolean handleTest(boolean canConnect, Intent intent) { + if (isConnected()) { + boolean checkNetwork = intent.getBooleanExtra(EXTRA_TEST_CHECK_NETWORK, false); + if (checkNetwork) { + if (mCurrentNetwork == null || !mCurrentNetwork.equals(SystemUtils.getCurrentNetworkName(this))) { + // network type changed - restart immediately + quit(true); + return canConnect; } } - else if (ACTION_ROSTER_LOADED.equals(action)) { - if (isConnected && isRosterLoaded()) { - broadcast(ACTION_ROSTER_LOADED); + if (canTest()) { + mLastTest = SystemClock.elapsedRealtime(); + mIdleHandler.test(); + } + return false; + } + else { + if (mHelper != null && mHelper.isBackingOff()) { + // helper is waiting for backoff - restart immediately + quit(true); + } + return canConnect; + } + } + + @SuppressLint("WakelockTimeout") + @CommandHandler(name = ACTION_PING) + private boolean handlePing(boolean canConnect) { + if (isConnected()) { + // acquire a wake lock + mPingLock.acquire(); + final XMPPConnection connection = mConnection; + final PingManager pingManager = PingManager.getInstanceFor(connection); + final WakeLock pingLock = mPingLock; + Async.go(new Runnable() { + @Override + public void run() { + try { + if (pingManager.pingMyServer(true, SLOW_PING_TIMEOUT)) { + AndroidAdaptiveServerPingManager + .getInstanceFor(connection, MessageCenterService.this) + .pingSuccess(); + } + else { + AndroidAdaptiveServerPingManager + .getInstanceFor(connection, MessageCenterService.this) + .pingFailed(); + } + } + catch (NotConnectedException e) { + // ignored + } + catch (InterruptedException e) { + // ignored + } + finally { + // release the wake lock + if (pingLock != null) + pingLock.release(); + } } + }, "PingServerIfNecessary (" + mConnection.getConnectionCounter() + ')'); + return false; + } + else { + return canConnect; + } + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleUploadAttachment(UploadAttachmentRequest request) { + // take the first available upload service :) + IUploadService uploadService = getUploadService(); + if (uploadService != null) { + CompositeMessage message = CompositeMessage.loadMessage(this, request.databaseId); + if (message == null) { + Log.w(TAG, "message not found: " + request.databaseId); + return; } - else if (ACTION_PRESENCE.equals(action)) { - if (canConnect && isConnected) { - final String id = intent.getStringExtra(EXTRA_PACKET_ID); - String type = intent.getStringExtra(EXTRA_TYPE); - String to = intent.getStringExtra(EXTRA_TO); + AttachmentComponent attachment = message.getComponent(AttachmentComponent.class); + if (attachment == null) { + // this is actually a bug + ReportingManager.logException(new IllegalArgumentException("no attachments in message!")); + Log.w(TAG, "no attachments in message " + request.databaseId); + return; + } - if ("probe".equals(type)) { - // probing is actually looking into the roster - Roster roster = getRoster(); + Uri preMediaUri = attachment.getLocalUri(); + long fileLength; - if (to == null) { - for (RosterEntry entry : roster.getEntries()) { - broadcastPresence(roster, entry, id); - } + try { + // encrypt the file if necessary + if (message.getSecurityFlags() != Coder.SECURITY_CLEARTEXT) { + InputStream in = null; + try { + in = getContentResolver().openInputStream(preMediaUri); + + // retrieve conversation for encrypting + Conversation conv = Conversation + .loadFromUserId(this, message.getRecipient()); + if (conv == null) { + // this is actually a bug + ReportingManager.logException(new IllegalArgumentException("no conversation for message!")); + Log.w(TAG, "unable to load conversation for encrypting message " + request.databaseId); + return; + } - // broadcast our own presence - broadcastMyPresence(id); + String[] encryptTo; + if (conv.isGroupChat()) { + encryptTo = conv.getGroupPeers(); } else { - broadcastPresence(roster, to, id); + encryptTo = new String[] { message.getRecipient() }; } + + File encrypted = MessageUtils.encryptFile(this, in, + DataUtils.toString(encryptTo)); + fileLength = encrypted.length(); + preMediaUri = Uri.fromFile(encrypted); } - else { - String show = intent.getStringExtra(EXTRA_SHOW); - Presence p = new Presence(type != null ? Presence.Type.valueOf(type) : Presence.Type.available); - p.setStanzaId(id); - p.setTo(to); - if (intent.hasExtra(EXTRA_PRIORITY)) - p.setPriority(intent.getIntExtra(EXTRA_PRIORITY, 0)); - p.setStatus(intent.getStringExtra(EXTRA_STATUS)); - if (show != null) - p.setMode(Presence.Mode.valueOf(show)); - - sendPacket(p); + finally { + DataUtils.close(in); } - + } + else { + fileLength = MediaStorage.getLength(this, preMediaUri); } } + catch (Exception e) { + Log.w(TAG, "error preprocessing media: " + preMediaUri, e); + // simulate upload error + UploadService.genericErrorNotification(this); + return; + } - else if (ACTION_LAST_ACTIVITY.equals(action)) { - if (canConnect && isConnected) { - LastActivity p = new LastActivity(); + final Uri mediaUri = preMediaUri; - p.setStanzaId(intent.getStringExtra(EXTRA_PACKET_ID)); - p.setTo(intent.getStringExtra(EXTRA_TO)); + // build a filename + String filename = CompositeMessage.getFilename(attachment.getMime(), new Date()); + if (filename == null) + filename = MediaStorage.UNKNOWN_FILENAME; - sendPacket(p); + // media message - start upload service + final String mime = attachment.getMime(); + final long databaseId = request.databaseId; + final boolean encrypt = message.getSecurityFlags() != Coder.SECURITY_CLEARTEXT; + uploadService.getPostUrl(filename, fileLength, mime, new IUploadService.UrlCallback() { + @Override + public void callback(String putUrl, String getUrl) { + // start upload intent service + // delete original (actually it's the encrypted temp file) if we already encrypted it + UploadService.start(MessageCenterService.this, mediaUri, + putUrl, getUrl, databaseId, mime, encrypt); } - } + }); - else if (ACTION_VCARD.equals(action)) { - if (canConnect && isConnected) { - VCard4 p = new VCard4(); - p.setTo(intent.getStringExtra(EXTRA_TO)); + } + else { + // TODO warn user about this problem + Log.w(TAG, "no upload service - this shouldn't happen!"); + } + } - sendPacket(p); - } - } + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleSendMessage(SendMessageRequest request) { + if (isConnected()) { + sendMessage(request); + } + } - else if (ACTION_PUBLICKEY.equals(action)) { - if (canConnect && isConnected) { - String to = intent.getStringExtra(EXTRA_TO); - if (to != null) { - // request public key for a specific user - PublicKeyPublish p = new PublicKeyPublish(); - p.setStanzaId(intent.getStringExtra(EXTRA_PACKET_ID)); - p.setTo(to); + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleSendChatState(SendChatStateRequest request) { + if (isConnected()) { + sendChatState(request); + } + } - sendPacket(p); - } - else { - // request public keys for the whole roster - Collection buddies = getRoster().getEntries(); - for (RosterEntry buddy : buddies) { - if (isRosterEntrySubscribed(buddy)) { - PublicKeyPublish p = new PublicKeyPublish(); - p.setStanzaId(intent.getStringExtra(EXTRA_PACKET_ID)); - p.setTo(buddy.getUser()); - - sendPacket(p); - } - } + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleRosterMatch(RosterMatchRequest request) { + if (isConnected()) { + RosterMatch iq = new RosterMatch(); - // request our own public key (odd eh?) - PublicKeyPublish p = new PublicKeyPublish(); - p.setStanzaId(intent.getStringExtra(EXTRA_PACKET_ID)); - p.setTo(XmppStringUtils.parseBareJid(mConnection.getUser())); - sendPacket(p); - } - } + for (String item : request.userIds) { + iq.addItem(item); } - else if (ACTION_SERVERLIST.equals(action)) { - if (canConnect && isConnected) { - ServerlistCommand p = new ServerlistCommand(); - p.setTo(XmppStringUtils.completeJidFrom("network", mServer.getNetwork())); - - StanzaFilter filter = new StanzaIdFilter(p.getStanzaId()); - // TODO cache the listener (it shouldn't change) - mConnection.addAsyncStanzaListener(new StanzaListener() { - public void processPacket(Stanza packet) throws NotConnectedException { - Intent i = new Intent(ACTION_SERVERLIST); - List _items = ((ServerlistCommand.ServerlistCommandData) packet) - .getItems(); - if (_items != null && _items.size() != 0 && packet.getError() == null) { - String[] items = new String[_items.size()]; - _items.toArray(items); - - i.putExtra(EXTRA_FROM, packet.getFrom()); - i.putExtra(EXTRA_JIDLIST, items); - } - mLocalBroadcastManager.sendBroadcast(i); - } - }, filter); + // directed to the probe component + try { + iq.setTo(JidCreate.bareFrom(Localpart.from("probe"), + Domainpart.from(mServer.getNetwork()))); + } + catch (XmppStringprepException e) { + // cannot happen + throw new RuntimeException(e); + } - sendPacket(p); - } + iq.setStanzaId(request.id); + // iq default type is get + + RosterMatchListener listener = new RosterMatchListener(iq); + sendIqWithReply(iq, true, listener, listener); + } + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleRosterStatus(RosterStatusRequest request) { + if (mRosterStore != null) { + RosterPacket.Item entry = mRosterStore.getEntry(request.jid); + if (entry != null) { + RosterPacket.ItemType subscriptionType = entry.getItemType(); + boolean subscribedFrom = subscriptionType == RosterPacket.ItemType.both || + subscriptionType == RosterPacket.ItemType.from; + boolean subscribedTo = subscriptionType == RosterPacket.ItemType.both || + subscriptionType == RosterPacket.ItemType.to; + + BUS.post(new RosterStatusEvent(entry.getJid(), entry.getName(), + subscribedFrom, subscribedTo, request.id)); } + } + } - else if (ACTION_SUBSCRIBED.equals(action)) { - if (canConnect && isConnected) { + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handlePresence(PresenceRequest request) { + if (isConnected()) { + final Roster roster = getRoster(); - sendSubscriptionReply(intent.getStringExtra(EXTRA_TO), - intent.getStringExtra(EXTRA_PACKET_ID), - intent.getIntExtra(EXTRA_PRIVACY, PRIVACY_ACCEPT)); + if (request.jid == null) { + for (RosterEntry entry : roster.getEntries()) { + BUS.post(replyPresenceRequest(roster, entry, + entry.getJid(), request.id)); } + + // broadcast our own presence + BUS.post(replyMyPresenceRequest(request.id)); + } + else { + final BareJid jid = request.jid.asBareJid(); + final RosterEntry entry = roster.getEntry(jid); + BUS.post(replyPresenceRequest(roster, entry, jid, request.id)); } + } + } - else if (ACTION_RETRY.equals(action)) { + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleSubscribe(SubscribeRequest request) { + if (isConnected()) { + sendPacket(new Presence(request.jid, Presence.Type.subscribe)); + } + } - Uri msgUri = intent.getParcelableExtra(EXTRA_MESSAGE); + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleUnsubscribe(UnsubscribeRequest request) { + if (isConnected()) { + sendPacket(new Presence(request.jid, Presence.Type.unsubscribe)); + } + } - boolean encrypted = Preferences.getEncryptionEnabled(this); + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleUpdateStatus(UpdateStatusRequest request) { + if (isConnected()) { + sendPacket(new Presence(Presence.Type.available, + request.status, 0, null)); + } + } - ContentValues values = new ContentValues(2); - values.put(Messages.STATUS, Messages.STATUS_SENDING); - values.put(Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); - getContentResolver().update(msgUri, values, null, null); + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleLastActivity(LastActivityRequest request) { + if (isConnected()) { + LastActivity p = new LastActivity(request.jid); + p.setStanzaId(request.id); + sendIqWithReply(p, true, mLastActivityListener, mLastActivityListener); + } + } - // FIXME shouldn't we resend just the above message? + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handlePublicKey(PublicKeyRequest request) { + if (isConnected()) { + if (request.jid != null) { + // request public key for a specific user + PublicKeyPublish p = new PublicKeyPublish(); + p.setTo(request.jid); + p.setStanzaId(request.id); - // already connected: resend pending messages - if (isConnected) - resendPendingMessages(false); + PublicKeyListener listener = new PublicKeyListener(this, p); + sendIqWithReply(p, true, listener, listener); } + else { + // request public keys for the whole roster + Collection buddies = getRoster().getEntries(); + for (RosterEntry buddy : buddies) { + + final PublicKeyPublish p = new PublicKeyPublish(); + p.setTo(buddy.getJid()); + + StanzaListener iqListener; + ExceptionCallback exListener; + final PublicKeyListener listener = new PublicKeyListener(this, p); + + String stanzaId = request.id; + if (stanzaId != null) { + // add a prefix to the ID + // this was done to properly create request/response pairs + // Smack was getting angry at this + final String randomId = StringUtils.randomString(6); + stanzaId = randomId + stanzaId; + + // TODO this class may be useful to others + iqListener = new StanzaListener() { + @Override + public void processStanza(Stanza packet) throws NotConnectedException, InterruptedException, SmackException.NotLoggedInException { + // remove the prefix and call the original listener + String currentId = packet.getStanzaId(); + if (currentId != null && currentId.length() > randomId.length()) + packet.setStanzaId(currentId.substring(randomId.length())); + listener.processStanza(packet); + } + }; + // TODO this class may be useful to others + exListener = new ExceptionCallback() { + @Override + public void processException(Exception exception) { + // remove the prefix and call the original listener + String currentId = p.getStanzaId(); + if (currentId != null && currentId.length() > randomId.length()) + p.setStanzaId(currentId.substring(randomId.length())); + listener.processException(exception); + } + }; + } + else { + iqListener = listener; + exListener = listener; + } - else if (ACTION_BLOCKLIST.equals(action)) { - if (isConnected) - requestBlocklist(); - } + p.setStanzaId(stanzaId); - else { - // no command means normal service start, connect if not connected - doConnect = true; + sendIqWithReply(p, true, iqListener, exListener); + } + + // request our own public key (odd eh?) + PublicKeyPublish p = new PublicKeyPublish(); + p.setStanzaId(request.id); + p.setTo(mConnection.getUser().asBareJid()); + PublicKeyListener listener = new PublicKeyListener(this, p); + sendIqWithReply(p, true, listener, listener); } + } + } - if (canConnect && doConnect) - createConnection(); + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleServerList(ServerListRequest request) { + if (isConnected()) { + ServerlistCommand p = new ServerlistCommand(); + try { + p.setTo(JidCreate.from("network", mServer.getNetwork(), "")); + } + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return; + } + + StanzaFilter filter = new StanzaIdFilter(p.getStanzaId()); + // TODO cache the listener (it shouldn't change) + mConnection.addAsyncStanzaListener(new StanzaListener() { + public void processStanza(Stanza packet) throws NotConnectedException { + if (packet.getError() == null) { + List servers = ((ServerlistCommand.ServerlistCommandData) packet) + .getItems(); + if (servers != null) { + BUS.post(new ServerListEvent(servers + .toArray(new String[0]), packet.getStanzaId())); + } + else { + BUS.post(new ServerListEvent(new XMPPException.XMPPErrorException(packet, StanzaError + .getBuilder(StanzaError.Condition.internal_server_error).build()), packet.getStanzaId())); + } + } + } + }, filter); - // no reason to exist - if (!canConnect && !doConnect && !isConnected && !isConnecting()) - stopSelf(); + sendPacket(p); + } + } - mFirstStart = false; + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleSetUserPrivacy(SetUserPrivacyRequest request) { + if (isConnected()) { + setUserPrivacy(request.jid, request.command); } - else { - Log.v(TAG, "restarting after service crash"); - start(getApplicationContext()); + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleBlocklist(BlocklistRequest request) { + if (isConnected()) { + requestBlocklist(request.id); } } - /** Creates a connection to server if needed. */ - private synchronized void createConnection() { - if (mConnection == null && mHelper == null) { - mConnection = null; + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void handleVersion(VersionRequest request) { + if (isConnected()) { + Version version = new Version(request.jid); + version.setStanzaId(request.id); + sendPacket(version); + } + } + /** + * Creates a connection to server if needed. + */ + @SuppressLint("WakelockTimeout") + private synchronized void createConnection() { + // connection is null or disconnected and no helper is currently running + if ((mConnection == null || !mConnection.isConnected()) && mHelper == null) { // acquire the wakelock mWakeLock.acquire(); + // hold on to the message center + mIdleHandler.hold(false); // reset push notification variable mPushNotifications = Preferences.getPushNotificationsEnabled(this) && - mPushService.isServiceAvailable(); + mPushService != null && mPushService.isServiceAvailable(); // reset waiting messages mWaitingReceipt.clear(); + // setup task execution pool + mThreadPool = Executors.newCachedThreadPool(); + mInactive = false; // retrieve account name - Account acc = Authenticator.getDefaultAccount(this); - mMyUsername = (acc != null) ? acc.name : null; + MyAccount account = Kontalk.get().getDefaultAccount(); + mMyUsername = (account != null) ? account.getName() : null; - // get server from preferences - mServer = Preferences.getEndpointServer(this); + // get server from account or preferences + mServer = Kontalk.get().getEndpointServer(); + + if (mConnection == null) { + mHelper = new XMPPConnectionHelper(this, mServer, false); + } + else { + // reuse connection if the server is the same + KontalkConnection reuseConnection = mServer.equals(mConnection.getServer()) ? + mConnection : null; + mHelper = new XMPPConnectionHelper(this, mServer, false, reuseConnection); + } - mHelper = new XMPPConnectionHelper(this, mServer, false); mHelper.setListener(this); mHelper.start(); } @@ -1148,8 +1694,7 @@ public void connectionClosed() { @Override public void connectionClosedOnError(Exception error) { Log.w(TAG, "connection closed with error", error); - quit(true); - createConnection(); + restart(this); } @Override @@ -1163,18 +1708,13 @@ public void reconnectingIn(int seconds) { // not used } - @Override - public void reconnectionFailed(Exception error) { - // not used - } - - @Override - public void reconnectionSuccessful() { - // not used - } - @Override public void aborted(Exception e) { + if (e != null) { + // we are being called from the connection helper because of + // connection errors. Set up a wake up timer to try again + setWakeupAlarm(); + } // unrecoverable error - exit stopSelf(); } @@ -1186,60 +1726,60 @@ public synchronized void created(final XMPPConnection connection) { // setup version manager final VersionManager verMgr = VersionManager.getInstanceFor(connection); - verMgr.setVersion(getString(R.string.app_name), SystemUtils.getVersionName(this)); + verMgr.setVersion(getString(R.string.app_name), SystemUtils.getVersionFullName(this)); - // setup roster + // setup presence and roster listener + PresenceListener presenceListener = new PresenceListener(this); + RosterListener rosterListener = new RosterListener(this); Roster roster = getRoster(); - roster.addRosterLoadedListener(new RosterLoadedListener() { - @Override - public void onRosterLoaded(Roster roster) { - // resend failed and pending messages - resendPendingMessages(false); - // resend failed and pending received receipts - resendPendingReceipts(); - // roster has been loaded - broadcast(ACTION_ROSTER_LOADED); - } - }); + roster.addRosterLoadedListener(rosterListener); + roster.addRosterListener(rosterListener); roster.setRosterStore(mRosterStore); - - // enable ping manager - AdaptiveServerPingManager.getInstanceFor(connection).setEnabled(true); - mPingFailedListener = new PingFailedListener() { - @Override - public void pingFailed() { - if (isStarted() && mConnection == connection) { - Log.v(TAG, "ping failed, restarting message center"); - // restart message center - restart(getApplicationContext()); - } - } - }; - PingManager pingManager = PingManager.getInstanceFor(connection); - pingManager.registerPingFailedListener(mPingFailedListener); - pingManager.setPingInterval(0); + roster.addSubscribeListener(presenceListener); StanzaFilter filter; filter = new StanzaTypeFilter(Presence.class); - connection.addAsyncStanzaListener(new PresenceListener(this), filter); - - filter = new StanzaTypeFilter(RosterMatch.class); - connection.addAsyncStanzaListener(new RosterMatchListener(this), filter); + connection.addAsyncStanzaListener(presenceListener, filter); filter = new StanzaTypeFilter(org.jivesoftware.smack.packet.Message.class); connection.addSyncStanzaListener(new MessageListener(this), filter); - filter = new StanzaTypeFilter(LastActivity.class); - connection.addAsyncStanzaListener(new LastActivityListener(this), filter); + // this is used as a reply callback + mLastActivityListener = new LastActivityListener(); - filter = new StanzaTypeFilter(PublicKeyPublish.class); - connection.addAsyncStanzaListener(new PublicKeyListener(this), filter); + filter = new StanzaTypeFilter(Version.class); + connection.addAsyncStanzaListener(new VersionListener(), filter); } + /** + * Synchronized with {@link #quit}. + */ @Override - public void connected(XMPPConnection connection) { - // not used. + public synchronized void connected(final XMPPConnection connection) { + // enable ping manager + AndroidAdaptiveServerPingManager + .getInstanceFor(connection, this) + .setEnabled(true); + + // save the network we connected through + mCurrentNetwork = SystemUtils.getCurrentNetworkName(this); + + if (mPingFailedListener == null) { + mPingFailedListener = new PingFailedListener() { + @Override + public void pingFailed() { + if (isStarted() && mConnection == connection) { + Log.v(TAG, "ping failed, restarting message center"); + // restart message center + restart(getApplicationContext()); + } + } + }; + PingManager pingManager = PingManager.getInstanceFor(connection); + pingManager.registerPingFailedListener(mPingFailedListener); + pingManager.setPingInterval(0); + } } @Override @@ -1248,95 +1788,162 @@ public void authenticated(XMPPConnection connection, boolean resumed) { // add message ack listener if (mConnection.isSmEnabled()) { - mConnection.addStanzaAcknowledgedListener(new MessageAckListener(this)); + mConnection.removeAllStanzaIdAcknowledgedListeners(); } else { Log.w(TAG, "stream management not available - disabling delivery receipts"); } + // we can release the message center now + // this must be done before sending the presence since it's based on isHeld() + mIdleHandler.release(); + // send presence - sendPresence(); + sendPresence(mIdleHandler.isHeld() ? Presence.Mode.available : Presence.Mode.away); + // clear upload service + if (mUploadServices != null) + mUploadServices.clear(); // discovery discovery(); - // pending messages and receipts will be sent when roster will be loaded - // send pending subscription replies - sendPendingSubscriptionReplies(); // helper is not needed any more mHelper = null; - broadcast(ACTION_CONNECTED); + BUS.removeStickyEvent(ConnectedEvent.class); + BUS.postSticky(new ConnectedEvent()); // we can now release any pending push notification - Preferences.setLastPushNotification(this, -1); + Preferences.setLastPushNotification(-1); - // queue inactive message if needed - mIdleHandler.queueInactiveIfNeeded(); + // force inactive state if needed + mIdleHandler.forceInactiveIfNeeded(); // update alarm manager - AdaptiveServerPingManager.onConnected(mConnection); + AndroidAdaptiveServerPingManager + .getInstanceFor(connection, this) + .onConnectionCompleted(); - // release the wakelock - mWakeLock.release(); + // request server key if needed + Async.go(new Runnable() { + @Override + public void run() { + final XMPPConnection conn = mConnection; + if (conn != null && conn.isConnected()) { + Jid jid = conn.getXMPPServiceDomain(); + if (Keyring.getPublicKey(MessageCenterService.this, jid.toString(), MyUsers.Keys.TRUST_UNKNOWN) == null) { + BUS.post(new PublicKeyRequest(jid)); + } + } + } + }); + + // re-acquire the wakelock for a limited time to allow for messages to come + // it will then be released automatically + mWakeLock.acquire(WAIT_FOR_MESSAGES_DELAY); } - private void broadcast(String action) { + void broadcast(String action) { broadcast(action, null, null); } - private void broadcast(String action, String extraName, String extraValue) { - Intent i = new Intent(action); - if (extraName != null) - i.putExtra(extraName, extraValue); + void broadcast(String action, String extraName, String extraValue) { + if (mLocalBroadcastManager != null) { + Intent i = new Intent(action); + if (extraName != null) + i.putExtra(extraName, extraValue); - mLocalBroadcastManager.sendBroadcast(i); + mLocalBroadcastManager.sendBroadcast(i); + } } - /** Discovers info and items. */ + /** + * Discovers info and items. + */ private void discovery() { - DiscoverInfo info = new DiscoverInfo(); - info.setTo(mServer.getNetwork()); + StanzaFilter filter; - StanzaFilter filter = new StanzaIdFilter(info.getStanzaId()); + Jid to; + try { + to = JidCreate.domainBareFrom(mServer.getNetwork()); + } + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return; + } + + DiscoverInfo info = new DiscoverInfo(); + info.setTo(to); + filter = new StanzaIdFilter(info.getStanzaId()); mConnection.addAsyncStanzaListener(new DiscoverInfoListener(this), filter); sendPacket(info); + + DiscoverItems items = new DiscoverItems(); + items.setTo(to); + filter = new StanzaIdFilter(items.getStanzaId()); + mConnection.addAsyncStanzaListener(new DiscoverItemsListener(this), filter); + sendPacket(items); } - private void active() { - if (mConnection != null && ClientStateIndicationManager.isSupported(mConnection)) { - Log.d(TAG, "entering active state"); + synchronized void active(boolean available) { + final XMPPConnection connection = mConnection; + if (connection != null) { cancelIdleAlarm(); - try { - ClientStateIndicationManager.active(mConnection); + + if (available) { + if (ClientStateIndicationManager.isSupported(connection)) { + Log.d(TAG, "entering active state"); + try { + ClientStateIndicationManager.active(connection); + } + catch (NotConnectedException e) { + return; + } + catch (InterruptedException e) { + return; + } + } + + sendPresence(Presence.Mode.available); mInactive = false; - // test ping - mIdleHandler.test(); - } - catch (NotConnectedException e) { - // ignored } + // test ping + mIdleHandler.test(); } } - private void inactive() { - if (mConnection != null && ClientStateIndicationManager.isSupported(mConnection)) { - Log.d(TAG, "entering inactive state"); - try { - ClientStateIndicationManager.inactive(mConnection); - setIdleAlarm(); - mInactive = true; - } - catch (NotConnectedException e) { - cancelIdleAlarm(); + synchronized void inactive() { + final XMPPConnection connection = mConnection; + if (connection != null) { + if (!mInactive) { + if (ClientStateIndicationManager.isSupported(connection)) { + Log.d(TAG, "entering inactive state"); + try { + ClientStateIndicationManager.inactive(connection); + } + catch (NotConnectedException e) { + cancelIdleAlarm(); + return; + } + catch (InterruptedException e) { + cancelIdleAlarm(); + return; + } + } + sendPresence(Presence.Mode.away); } + + setIdleAlarm(); + mInactive = true; } } - private boolean isInactive() { + boolean isInactive() { return mInactive; } - private boolean fastReply() { + boolean fastReply() { if (!isConnected()) return false; try { @@ -1346,22 +1953,29 @@ private boolean fastReply() { catch (NotConnectedException e) { return false; } + catch (InterruptedException e) { + return false; + } } - private long getLastReceivedStanza() { + long getLastReceivedStanza() { return mConnection != null ? mConnection.getLastStanzaReceived() : 0; } - /** Sends our initial presence. */ - private void sendPresence() { - sendPacket(createPresence()); + /** + * Sends our initial presence. + */ + private void sendPresence(Presence.Mode mode) { + sendPacket(createPresence(mode)); } - private Presence createPresence() { - String status = Preferences.getStatusMessage(this); + private Presence createPresence(Presence.Mode mode) { + String status = Preferences.getStatusMessage(); Presence p = new Presence(Presence.Type.available); - if (status != null) + if (!TextUtils.isEmpty(status)) p.setStatus(status); + if (mode != null) + p.setMode(mode); // TODO find a place for this p.addExtension(new CapsExtension("http://www.kontalk.org/", "none", "sha-1")); @@ -1369,185 +1983,6 @@ private Presence createPresence() { return p; } - /** - * Queries for pending messages and send them through. - * @param retrying if true, we are retrying to send media messages after - * receiving upload info (non-media messages will be filtered out) - */ - void resendPendingMessages(boolean retrying) { - StringBuilder filter = new StringBuilder() - .append(Messages.DIRECTION) - .append('=') - .append(Messages.DIRECTION_OUT) - .append(" AND ") - .append(Messages.STATUS) - .append("<>") - .append(Messages.STATUS_SENT) - .append(" AND ") - .append(Messages.STATUS) - .append("<>") - .append(Messages.STATUS_RECEIVED) - .append(" AND ") - .append(Messages.STATUS) - .append("<>") - .append(Messages.STATUS_NOTDELIVERED) - .append(" AND ") - .append(Messages.STATUS) - .append("<>") - .append(Messages.STATUS_PENDING); - - // filter out non-media non-uploaded messages - if (retrying) filter - .append(" AND ") - .append(Messages.ATTACHMENT_FETCH_URL) - .append(" IS NULL AND ") - .append(Messages.ATTACHMENT_LOCAL_URI) - .append(" IS NOT NULL"); - - Cursor c = getContentResolver().query(Messages.CONTENT_URI, - new String[] { - Messages._ID, - Messages.MESSAGE_ID, - Messages.PEER, - Messages.BODY_CONTENT, - Messages.SECURITY_FLAGS, - Messages.ATTACHMENT_MIME, - Messages.ATTACHMENT_LOCAL_URI, - Messages.ATTACHMENT_FETCH_URL, - Messages.ATTACHMENT_PREVIEW_PATH, - Messages.ATTACHMENT_LENGTH, - Messages.ATTACHMENT_COMPRESS, - // TODO Messages.ATTACHMENT_SECURITY_FLAGS, - }, - filter.toString(), - null, Messages._ID); - - while (c.moveToNext()) { - long id = c.getLong(0); - String msgId = c.getString(1); - String peer = c.getString(2); - byte[] textContent = c.getBlob(3); - int securityFlags = c.getInt(4); - String attMime = c.getString(5); - String attFileUri = c.getString(6); - String attFetchUrl = c.getString(7); - String attPreviewPath = c.getString(8); - long attLength = c.getLong(9); - int compress = c.getInt(10); - // TODO int attSecurityFlags = c.getInt(11); - - // media message encountered and no upload service available - delay message - if (attFileUri != null && attFetchUrl == null && getUploadService() == null && !retrying) { - Log.w(TAG, "no upload info received yet, delaying media message"); - continue; - } - - Bundle b = new Bundle(); - // mark as retrying - b.putBoolean("org.kontalk.message.retrying", true); - - b.putLong("org.kontalk.message.msgId", id); - b.putString("org.kontalk.message.packetId", msgId); - b.putString("org.kontalk.message.to", peer); - // TODO shouldn't we pass security flags directly here?? - b.putBoolean("org.kontalk.message.encrypt", securityFlags != Coder.SECURITY_CLEARTEXT); - - if (textContent != null) - b.putString("org.kontalk.message.body", new String(textContent)); - - // message has already been uploaded - just send media - if (attFetchUrl != null) { - b.putString("org.kontalk.message.mime", attMime); - b.putString("org.kontalk.message.fetch.url", attFetchUrl); - b.putString("org.kontalk.message.preview.uri", attFileUri); - b.putString("org.kontalk.message.preview.path", attPreviewPath); - } - // check if the message contains some large file to be sent - else if (attFileUri != null) { - b.putString("org.kontalk.message.mime", attMime); - b.putString("org.kontalk.message.media.uri", attFileUri); - b.putString("org.kontalk.message.preview.path", attPreviewPath); - b.putLong("org.kontalk.message.length", attLength); - b.putInt("org.kontalk.message.compress", compress); - } - - Log.v(TAG, "resending pending message " + id); - sendMessage(b); - } - - c.close(); - } - - void resendPendingReceipts() { - Cursor c = getContentResolver().query(Messages.CONTENT_URI, - new String[] { - Messages._ID, - Messages.MESSAGE_ID, - Messages.PEER, - }, - Messages.DIRECTION + " = " + Messages.DIRECTION_IN + " AND " + - Messages.STATUS + " = " + Messages.STATUS_INCOMING, - null, Messages._ID); - - while (c.moveToNext()) { - long id = c.getLong(0); - String msgId = c.getString(1); - String peer = c.getString(2); - - Bundle b = new Bundle(); - - b.putLong("org.kontalk.message.msgId", id); - b.putString("org.kontalk.message.packetId", msgId); - b.putString("org.kontalk.message.to", peer); - b.putString("org.kontalk.message.ack", msgId); - - Log.v(TAG, "resending pending receipt for message " + id); - sendMessage(b); - } - - c.close(); - } - - private void sendPendingSubscriptionReplies() { - Cursor c = getContentResolver().query(Threads.CONTENT_URI, - new String[] { - Threads.PEER, - Threads.REQUEST_STATUS, - }, - Threads.REQUEST_STATUS + "=" + Threads.REQUEST_REPLY_PENDING_ACCEPT + " OR " + - Threads.REQUEST_STATUS + "=" + Threads.REQUEST_REPLY_PENDING_BLOCK, - null, Threads._ID); - - while (c.moveToNext()) { - String to = c.getString(0); - int reqStatus = c.getInt(1); - - int action; - - switch (reqStatus) { - case Threads.REQUEST_REPLY_PENDING_ACCEPT: - action = PRIVACY_ACCEPT; - break; - - case Threads.REQUEST_REPLY_PENDING_BLOCK: - action = PRIVACY_BLOCK; - break; - - case Threads.REQUEST_REPLY_PENDING_UNBLOCK: - action = PRIVACY_UNBLOCK; - break; - - default: - // skip this one - continue; - } - - sendSubscriptionReply(to, null, action); - } - - c.close(); - } - Roster getRoster() { return (mConnection != null) ? Roster.getInstanceFor(mConnection) : null; } @@ -1557,66 +1992,57 @@ private boolean isRosterLoaded() { return roster != null && roster.isLoaded(); } - RosterEntry getRosterEntry(String jid) { + RosterEntry getRosterEntry(BareJid jid) { Roster roster = getRoster(); return (roster != null) ? roster.getEntry(jid) : null; } - private boolean isAuthorized(String jid) { + private boolean isAuthorized(BareJid jid) { + if (Kontalk.get().getDefaultAccount().isSelfJID(jid)) + return true; RosterEntry entry = getRosterEntry(jid); - return (isRosterEntrySubscribed(entry) || Authenticator.isSelfJID(this, jid)); + return entry != null && isAuthorized(entry); + } + + private boolean isAuthorized(RosterEntry entry) { + return (isRosterEntrySubscribed(entry) || Kontalk.get().getDefaultAccount().isSelfJID(entry.getJid())); } private boolean isRosterEntrySubscribed(RosterEntry entry) { return (entry != null && (entry.getType() == RosterPacket.ItemType.to || entry.getType() == RosterPacket.ItemType.both) && - entry.getStatus() != RosterPacket.ItemStatus.SUBSCRIPTION_PENDING); + !entry.isSubscriptionPending()); } - private void broadcastPresence(Roster roster, RosterEntry entry, String id) { - broadcastPresence(roster, entry, entry.getUser(), id); - } + private PresenceEvent replyPresenceRequest(Roster roster, @Nullable RosterEntry entry, BareJid jid, String id) { + PresenceEvent event; - private void broadcastPresence(Roster roster, String jid, String id) { - broadcastPresence(roster, roster.getEntry(jid), jid, id); - } + // asking our own presence + if (Kontalk.get().getDefaultAccount().isSelfJID(jid)) { + return replyMyPresenceRequest(id); + } - private void broadcastPresence(Roster roster, RosterEntry entry, String jid, String id) { - Intent i; // entry present and not pending subscription - if (isRosterEntrySubscribed(entry) || Authenticator.isSelfJID(this, jid)) { - // roster entry found, look for presence + else if (isRosterEntrySubscribed(entry)) { + // roster entry found, send presence probe Presence presence = roster.getPresence(jid); - i = PresenceListener.createIntent(this, presence, entry); + event = PresenceListener.createEvent(this, presence, entry, id); } else { // null type indicates no roster entry found or not authorized - i = new Intent(ACTION_PRESENCE); - i.putExtra(EXTRA_FROM, jid); + event = new NoPresenceEvent(jid, id); } - // to keep track of request-reply - i.putExtra(EXTRA_PACKET_ID, id); - mLocalBroadcastManager.sendBroadcast(i); + return event; } - /** A special method to broadcast our own presence. */ - private void broadcastMyPresence(String id) { - Presence presence = createPresence(); - presence.setFrom(mConnection.getUser()); - - Intent i = PresenceListener.createIntent(this, presence, null); - i.putExtra(EXTRA_FINGERPRINT, getMyFingerprint()); - i.putExtra(EXTRA_SUBSCRIBED_FROM, true); - i.putExtra(EXTRA_SUBSCRIBED_TO, true); - - // to keep track of request-reply - i.putExtra(EXTRA_PACKET_ID, id); - mLocalBroadcastManager.sendBroadcast(i); + private PresenceEvent replyMyPresenceRequest(String id) { + return new UserOnlineEvent(mConnection.getUser(), null, 0, + null, null, null, true, true, getMyFingerprint(), id); } private String getMyFingerprint() { try { - PersonalKey key = Kontalk.get(this).getPersonalKey(); + PersonalKey key = Kontalk.get().getPersonalKey(); return key.getFingerprint(); } catch (Exception e) { @@ -1626,23 +2052,27 @@ private String getMyFingerprint() { } } - private void sendSubscriptionReply(String to, String packetId, int action) { - - if (action == PRIVACY_ACCEPT) { - // standard response: subscribed - Presence p = new Presence(Presence.Type.subscribed); - p.setStanzaId(packetId); - p.setTo(to); - sendPacket(p); - - // send a subscription request anyway - p = new Presence(Presence.Type.subscribe); - p.setTo(to); - sendPacket(p); - } - - else if (action == PRIVACY_BLOCK || action == PRIVACY_UNBLOCK || action == PRIVACY_REJECT) { - sendPrivacyListCommand(to, action); + private void setUserPrivacy(BareJid to, PrivacyCommand command) { + switch (command) { + case ACCEPT: { + // standard response: subscribed + sendPacket(new Presence(to, Presence.Type.subscribed)); + // send a subscription request anyway + sendPacket(new Presence(to, Presence.Type.subscribe)); + break; + } + case REJECT: { + Presence unsub = new Presence(Presence.Type.unsubscribe); + unsub.setTo(to); + sendPacket(unsub); + // will also block + } + case BLOCK: + blockUser(to); + break; + case UNBLOCK: + unblockUser(to); + break; } // clear the request status @@ -1650,119 +2080,505 @@ else if (action == PRIVACY_BLOCK || action == PRIVACY_UNBLOCK || action == PRIVA values.put(Threads.REQUEST_STATUS, Threads.REQUEST_NONE); getContentResolver().update(Requests.CONTENT_URI, - values, CommonColumns.PEER + "=?", new String[]{to}); + values, Threads.PEER + "=?", new String[]{to.toString()}); + } + + private void blockUser(final BareJid jid) { + sendIqWithReply(BlockingCommand.block(jid.toString()), true, new StanzaListener() { + @Override + public void processStanza(Stanza packet) throws NotConnectedException, InterruptedException, SmackException.NotLoggedInException { + if (packet instanceof IQ && ((IQ) packet).getType() == IQ.Type.result) { + UsersProvider.setBlockStatus(MessageCenterService.this, jid.toString(), true); + + // invalidate cached contact + Contact.invalidate(jid.toString()); + + // post result + BUS.post(new UserBlockedEvent(jid)); + } + } + }, null); + } + + private void unblockUser(final BareJid jid) { + sendIqWithReply(BlockingCommand.unblock(jid.toString()), true, new StanzaListener() { + @Override + public void processStanza(Stanza packet) throws NotConnectedException, InterruptedException, SmackException.NotLoggedInException { + if (packet instanceof IQ && ((IQ) packet).getType() == IQ.Type.result) { + UsersProvider.setBlockStatus(MessageCenterService.this, jid.toString(), false); + + // invalidate cached contact + Contact.invalidate(jid.toString()); + + // post result + BUS.post(new UserUnblockedEvent(jid)); + } + } + }, null); + } + + private void requestBlocklist(final String id) { + IQ p = BlockingCommand.blocklist(); + if (id != null) + p.setStanzaId(id); + + sendIqWithReply(p, true, new StanzaListener() { + @Override + public void processStanza(Stanza packet) throws NotConnectedException, InterruptedException, SmackException.NotLoggedInException { + if (packet instanceof BlockingCommand) { + BlockingCommand blocklist = (BlockingCommand) packet; + List blocked = blocklist.getItems(); + BUS.post(new BlocklistEvent(blocked != null ? XMPPUtils + .parseJids(blocked) : new Jid[0], blocklist.getStanzaId())); + } + } + }, new ExceptionCallback() { + @Override + public void processException(Exception exception) { + BUS.post(new BlocklistEvent(exception, id)); + } + }); } - private void sendPrivacyListCommand(final String to, final int action) { - IQ p; + private void sendChatState(SendChatStateRequest request) { + Stanza packet; - if (action == PRIVACY_BLOCK || action == PRIVACY_REJECT) { - // blocking command: block - p = BlockingCommand.block(to); + if (request.group) { + GroupInfo groupInfo = MessagesProviderClient.getGroupInfo(this, request.to.toString()); + if (groupInfo == null) { + Log.w(TAG, "group not found: " + request.to); + return; + } + + Jid[] toGroup = XMPPUtils.parseJids(MessagesProviderClient + .getGroupMembers(this, request.to.toString(), 0)); + + GroupController groupController = GroupControllerFactory + .createController(groupInfo.getType(), mConnection, this); + + // check if we can send messages even with some members with no subscriptipn + if (!groupController.canSendWithNoSubscription()) { + for (Jid jid : toGroup) { + if (!isAuthorized(jid.asBareJid())) { + Log.i(TAG, "not subscribed to " + jid + ", not sending group message"); + return; + } + } + } + + GroupCommand groupCommand = groupController.info(); + // FIXME careful to this, might need abstraction + groupCommand.setMembers(toGroup); + groupCommand.setGroupJid(groupInfo.getJid()); + + packet = groupController.beforeEncryption(groupCommand, null); + + // TODO encryption + + // post-process for group delivery + packet = groupController.afterEncryption(groupCommand, packet, packet); + } + else { + // message stanza + packet = new org.jivesoftware.smack.packet.Message(request.to, + org.jivesoftware.smack.packet.Message.Type.chat); + } + + packet.setStanzaId(request.id); + packet.addExtension(new ChatStateExtension(request.chatState)); + + sendPacket(packet); + } + + private void sendMessage(SendMessageRequest request) { + if (!isRosterLoaded()) { + Log.d(TAG, "roster not loaded yet, not sending message"); + return; + } + + CompositeMessage message = CompositeMessage.loadMessage(this, request.databaseId); + if (message == null) { + Log.w(TAG, "message not found: " + request.databaseId); + return; } - else if (action == PRIVACY_UNBLOCK) { - // blocking command: block - p = BlockingCommand.unblock(to); + Conversation conv = Conversation.loadFromUserId(this, message.getPeer()); + if (conv == null) { + // this is actually a bug + ReportingManager.logException(new IllegalArgumentException("no conversation for message!")); + Log.w(TAG, "unable to load conversation for encrypting message " + request.databaseId); + return; } + Jid to; + // used for verifying isPaused() + Jid convJid; + Jid[] toGroup; + GroupController groupController = null; + GroupInfo groupInfo = null; + + final GroupComponent group = message.getComponent(GroupComponent.class); + if (group != null) { + groupInfo = group.getContent(); + + toGroup = XMPPUtils.parseJids(conv.getGroupPeersForSending()); + convJid = groupInfo.getJid(); + + groupController = GroupControllerFactory + .createController(groupInfo.getType(), mConnection, this); + // the to field will be filled by the group controller + to = null; + + // check if we can send messages even with some members with no subscriptipn + if (!groupController.canSendWithNoSubscription()) { + for (Jid jid : toGroup) { + if (!isAuthorized(jid.asBareJid())) { + Log.i(TAG, "not subscribed to " + jid + ", not sending group message"); + return; + } + } + } + } else { - // unsupported action - throw new IllegalArgumentException("unsupported action: " + action); + to = JidCreate.fromOrThrowUnchecked(message.getPeer()); + toGroup = new Jid[]{to}; + convJid = to; } - if (action == PRIVACY_REJECT) { - // send unsubscribed too - Presence unsub = new Presence(Presence.Type.unsubscribe); - unsub.setTo(to); - sendPacket(unsub); + PersonalKey key; + try { + key = Kontalk.get().getPersonalKey(); + } + catch (Exception pgpe) { + Log.w(TAG, "no personal key available - not allowed to send messages"); + // warn user: message will not be sent + if (MessagingNotification.isPaused(convJid)) { + Toast.makeText(this, R.string.warn_no_personal_key, + Toast.LENGTH_LONG).show(); + } + return; } - // setup packet filter for response - StanzaFilter filter = new StanzaIdFilter(p.getStanzaId()); - StanzaListener listener = new StanzaListener() { - public void processPacket(Stanza packet) { + // check if message is already pending + final long msgId = request.databaseId; + if (mWaitingReceipt.contains(msgId)) { + Log.v(TAG, "message already queued and waiting - dropping"); + return; + } - if (packet instanceof IQ && ((IQ) packet).getType() == IQ.Type.result) { - UsersProvider.setBlockStatus(MessageCenterService.this, - to, action == PRIVACY_BLOCK || action == PRIVACY_REJECT); + final String id = message.getId(); - // invalidate cached contact - Contact.invalidate(to); + // hold on to message center while we send the message + mIdleHandler.hold(false); - // broadcast result - broadcast((action == PRIVACY_BLOCK || action == PRIVACY_REJECT) ? - ACTION_BLOCKED : ACTION_UNBLOCKED, - EXTRA_FROM, to); + Stanza m, originalStanza; + + // pre-process message for group delivery + GroupCommand groupCommand = null; + if (groupController != null) { + GroupCommandComponent groupCmdComponent = message.getComponent(GroupCommandComponent.class); + if (groupCmdComponent != null) { + if (groupCmdComponent.isCreateCommand()) { + groupCommand = groupController.createGroup(); + ((CreateGroupCommand) groupCommand).setSubject(groupInfo.getSubject()); + } + else if (groupCmdComponent.isSetSubjectCommand()) { + groupCommand = groupController.setSubject(); + ((SetSubjectCommand) groupCommand).setSubject(groupInfo.getSubject()); + } + else if (groupCmdComponent.isAddOrRemoveCommand()) { + groupCommand = groupController.addRemoveMembers(); + ((AddRemoveMembersCommand) groupCommand).setSubject(groupInfo.getSubject()); + ((AddRemoveMembersCommand) groupCommand) + .setAddedMembers(XMPPUtils.parseJids(groupCmdComponent.getAddedMembers())); + ((AddRemoveMembersCommand) groupCommand) + .setRemovedMembers(XMPPUtils.parseJids(groupCmdComponent.getRemovedMembers())); + } + else if (groupCmdComponent.isPartCommand()) { + groupCommand = groupController.part(); + ((PartCommand) groupCommand).setDatabaseId(msgId); } + } + if (groupCommand == null) { + groupCommand = groupController.info(); } - }; - mConnection.addAsyncStanzaListener(listener, filter); - // send IQ - sendPacket(p); - } + // FIXME careful to this, might need abstraction + groupCommand.setMembers(toGroup); + groupCommand.setGroupJid(groupInfo.getJid()); + + m = groupController.beforeEncryption(groupCommand, null); + } + else { + // message stanza + m = new org.jivesoftware.smack.packet.Message(); + } + + originalStanza = m; + + // if the group manager doesn't return a (e.g. MUC) + boolean isMessage = (m instanceof org.jivesoftware.smack.packet.Message); + + if (to != null) + m.setTo(to); + + // set message id + m.setStanzaId(id); + + // request ack if we are sending a message to a non-group chat + // TODO this will change when we'll support group chat ack + boolean ackRequest = isMessage && groupController == null; + + if (isMessage) { + org.jivesoftware.smack.packet.Message msg = (org.jivesoftware.smack.packet.Message) m; + msg.setType(org.jivesoftware.smack.packet.Message.Type.chat); + + if (!(request instanceof SendDeliveryReceiptRequest)) { + TextComponent body = message.getComponent(TextComponent.class); + if (body != null) { + msg.setBody(body.getContent()); + } + else { + HiddenTextComponent body2 = message.getComponent(HiddenTextComponent.class); + if (body2 != null) { + msg.setBody(body2.getContent()); + } + } + + // generate preview if needed + AttachmentComponent attachment = message.getComponent(AttachmentComponent.class); + if (attachment != null) { + if (attachment.getLocalUri() != null && attachment.getPreviewFile() != null) { + if (!attachment.getPreviewFile().isFile()) { + try { + MediaStorage.cacheThumbnail(this, attachment.getLocalUri(), + attachment.getPreviewFile(), true); + } + catch (Exception e) { + Log.w(TAG, "unable to generate preview for media", e); + } + } + + msg.addExtension(new BitsOfBinary(MediaStorage.THUMBNAIL_MIME_NETWORK, + attachment.getPreviewFile())); + } + + // add download url if present + if (attachment.getFetchUrl() != null) { + // in this case we will need the length too + msg.addExtension(new OutOfBandData(attachment.getFetchUrl(), + attachment.getMime(), attachment.getLength(), + attachment.getSecurityFlags() != Coder.SECURITY_CLEARTEXT)); + } + } + + // add location data if present + LocationComponent location = message.getComponent(LocationComponent.class); + if (location != null) { + double lat = location.getLatitude(); + double lon = location.getLongitude(); + UserLocation userLocation = new UserLocation(lat, lon, + location.getText(), location.getStreet()); + m.addExtension(userLocation); + } + + // add referenced message if any + InReplyToComponent inReplyTo = message.getComponent(InReplyToComponent.class); + if (inReplyTo != null) { + ReferencedMessage referencedMsg = inReplyTo.getContent(); + if (referencedMsg != null) { + DelayInformation fwdDelay = new DelayInformation(new Date(referencedMsg.getTimestamp())); + + try { + org.jivesoftware.smack.packet.Message fwdMessage = + new org.jivesoftware.smack.packet.Message(referencedMsg.getPeer(), + referencedMsg.getTextContent()); + fwdMessage.setStanzaId(referencedMsg.getMessageId()); + + m.addExtension(new Forwarded(fwdDelay, fwdMessage)); + } + catch (XmppStringprepException e) { + Log.w(TAG, "unable to parse referenced message JID: " + referencedMsg.getPeer(), e); + // this is serious, report it + ReportingManager.logException(e); + } + } + } + } + + if (message.getSecurityFlags() != Coder.SECURITY_CLEARTEXT) { + byte[] toMessage = null; + boolean encryptError = false; + try { + Coder coder = Keyring.getEncryptCoder(this, mServer, key, DataUtils.toString(toGroup)); + if (coder != null) { + + // no extensions, create a simple text version to save space + if (msg.getExtensions().size() == 0) { + if (!(request instanceof SendDeliveryReceiptRequest)) { + // a special case for delivery receipts whom doesn't have a body + // but we want to encrypt it for groups (extensions.size() > 0) + toMessage = coder.encryptText(msg.getBody()); + } + } + + // some extension, encrypt whole stanza just to be sure + else { + toMessage = coder.encryptStanza(msg.toXML(null)); + } - private void requestBlocklist() { - Stanza p = BlockingCommand.blocklist(); - String packetId = p.getStanzaId(); + if (toMessage != null) { + org.jivesoftware.smack.packet.Message encMsg = + new org.jivesoftware.smack.packet.Message(msg.getTo(), msg.getType()); - // listen for response (TODO cache the listener, it shouldn't change) - StanzaFilter idFilter = new StanzaIdFilter(packetId); - mConnection.addAsyncStanzaListener(new StanzaListener() { - public void processPacket(Stanza packet) { - // we don't need this listener anymore - mConnection.removeAsyncStanzaListener(this); + encMsg.setBody(getString(R.string.text_encrypted)); + encMsg.setStanzaId(m.getStanzaId()); + encMsg.addExtension(new E2EEncryption(toMessage)); - if (packet instanceof BlockingCommand) { - BlockingCommand blocklist = (BlockingCommand) packet; + // save the unencrypted stanza for later + originalStanza = msg; + m = encMsg; + } + } + } - Intent i = new Intent(ACTION_BLOCKLIST); + // FIXME there is some very ugly code here + // FIXME notify just once per session (store in Kontalk instance?) - List _list = blocklist.getItems(); - if (_list != null) { - String[] list = new String[_list.size()]; - i.putExtra(EXTRA_BLOCKLIST, _list.toArray(list)); + catch (IllegalArgumentException noPublicKey) { + // warn user: message will be not sent + if (MessagingNotification.isPaused(convJid)) { + Toast.makeText(this, R.string.warn_no_public_key, + Toast.LENGTH_LONG).show(); } + encryptError = true; + } + catch (GeneralSecurityException e) { + // warn user: message will not be sent + if (MessagingNotification.isPaused(convJid)) { + Toast.makeText(this, R.string.warn_encryption_failed, + Toast.LENGTH_LONG).show(); + } + encryptError = true; + } + + if (encryptError) { + // message was not encrypted for some reason, mark it pending user review + MessageUpdater.forMessage(this, msgId) + .setStatus(Messages.STATUS_PENDING) + .commit(); - Log.v(TAG, "broadcasting blocklist: " + i); - mLocalBroadcastManager.sendBroadcast(i); + mIdleHandler.release(); + return; } + } + } + + // post-process for group delivery + if (groupController != null) { + m = groupController.afterEncryption(groupCommand, m, originalStanza); + } + + if (isMessage) { + // received receipt + if (request instanceof SendDeliveryReceiptRequest) { + m.addExtension(new DeliveryReceipt(message.getId())); + } + else { + // add active chat state if this is a message + m.addExtension(new ChatStateExtension(ChatState.active)); + // add receipt if necessary + if (ackRequest) + DeliveryReceiptRequest.addTo((org.jivesoftware.smack.packet.Message) m); } - }, idFilter); - sendPacket(p); + sendMessage((org.jivesoftware.smack.packet.Message) m, msgId); + } + else { + // no wake lock management in this case + sendPacket(m); + } + + // the real sendMessage has its own hold/release pair + mIdleHandler.release(); } + /* + @Deprecated private void sendMessage(Bundle data) { if (!isRosterLoaded()) { Log.d(TAG, "roster not loaded yet, not sending message"); return; } - boolean retrying = data.getBoolean("org.kontalk.message.retrying"); - String to = data.getString("org.kontalk.message.to"); + final String groupJid = data.getString("org.kontalk.message.group.jid"); + Jid to; + // used for verifying isPaused() + Jid convJid; + String[] toGroup; + GroupController group = null; - if (!isAuthorized(to)) { - Log.i(TAG, "not subscribed to " + to + ", not sending message"); - // warn user: message will not be sent - if (!retrying && to.equalsIgnoreCase(MessagingNotification.getPaused())) { - Toast.makeText(this, R.string.warn_not_subscribed, - Toast.LENGTH_LONG).show(); + if (groupJid != null) { + toGroup = data.getStringArray("org.kontalk.message.to"); + try { + // TODO this should be discovered first + to = JidCreate.from("multicast", mConnection.getXMPPServiceDomain(), ""); + convJid = JidCreate.from(groupJid); } - return; + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return; + } + + // TODO take type from data + group = GroupControllerFactory + .createController(KontalkGroupController.GROUP_TYPE, mConnection, this); + + // check if we can send messages even with some members with no subscriptipn + if (!group.canSendWithNoSubscription()) { + for (String jid : toGroup) { + try { + BareJid bareJid = JidCreate.bareFrom(jid); + if (!isAuthorized(bareJid)) { + Log.i(TAG, "not subscribed to " + jid + ", not sending group message"); + return; + } + } + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return; + } + } + } + } + else { + try { + to = JidCreate.from(data.getString("org.kontalk.message.to")); + } + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return; + } + toGroup = new String[]{to.toString()}; + convJid = to; } PersonalKey key; try { - key = ((Kontalk) getApplicationContext()).getPersonalKey(); + key = Kontalk.get().getPersonalKey(); } catch (Exception pgpe) { Log.w(TAG, "no personal key available - not allowed to send messages"); // warn user: message will not be sent - if (to.equalsIgnoreCase(MessagingNotification.getPaused())) { + if (MessagingNotification.isPaused(convJid)) { Toast.makeText(this, R.string.warn_no_personal_key, Toast.LENGTH_LONG).show(); } @@ -1770,250 +2586,407 @@ private void sendMessage(Bundle data) { } // check if message is already pending - long msgId = data.getLong("org.kontalk.message.msgId"); - if (mWaitingReceipt.containsValue(msgId)) { + final long msgId = data.getLong("org.kontalk.message.msgId"); + if (mWaitingReceipt.contains(msgId)) { Log.v(TAG, "message already queued and waiting - dropping"); return; } - String id = data.getString("org.kontalk.message.packetId"); + final String id = data.getString("org.kontalk.message.packetId"); - boolean encrypt = data.getBoolean("org.kontalk.message.encrypt"); - String mime = data.getString("org.kontalk.message.mime"); + final boolean encrypt = data.getBoolean("org.kontalk.message.encrypt"); + final String mime = data.getString("org.kontalk.message.mime"); String _mediaUri = data.getString("org.kontalk.message.media.uri"); if (_mediaUri != null) { // take the first available upload service :) - String postUrl = getUploadService(); - if (postUrl != null) { + IUploadService uploadService = getUploadService(); + if (uploadService != null) { + Uri preMediaUri = Uri.parse(_mediaUri); + final String previewPath = data.getString("org.kontalk.message.preview.path"); + long fileLength; + + try { + // encrypt the file if necessary + if (encrypt) { + InputStream in = null; + try { + in = getContentResolver().openInputStream(preMediaUri); + File encrypted = MessageUtils.encryptFile(this, in, toGroup); + fileLength = encrypted.length(); + preMediaUri = Uri.fromFile(encrypted); + } + finally { + SystemUtils.closeStream(in); + } + } + else { + fileLength = MediaStorage.getLength(this, preMediaUri); + } + } + catch (Exception e) { + Log.w(TAG, "error preprocessing media: " + preMediaUri, e); + // simulate upload error + UploadService.errorNotification(this, + getString(R.string.notify_ticker_upload_error), + getString(R.string.notify_text_upload_error)); + return; + } + + final Uri mediaUri = preMediaUri; + + // build a filename + String filename = CompositeMessage.getFilename(mime, new Date()); + if (filename == null) + filename = MediaStorage.UNKNOWN_FILENAME; + // media message - start upload service - Uri mediaUri = Uri.parse(_mediaUri); - - // preview file path (if any) - String previewPath = data.getString("org.kontalk.message.preview.path"); - // compress ratio (if any) - int compress = data.getInt("org.kontalk.message.compress"); - - // start upload intent service - Intent i = new Intent(this, UploadService.class); - i.setData(mediaUri); - i.setAction(UploadService.ACTION_UPLOAD); - i.putExtra(UploadService.EXTRA_POST_URL, postUrl); - i.putExtra(UploadService.EXTRA_DATABASE_ID, msgId); - i.putExtra(UploadService.EXTRA_MESSAGE_ID, id); - i.putExtra(UploadService.EXTRA_MIME, mime); - i.putExtra(UploadService.EXTRA_ENCRYPT, encrypt); - i.putExtra(UploadService.EXTRA_PREVIEW_PATH, previewPath); - i.putExtra(UploadService.EXTRA_COMPRESS, compress); - i.putExtra(UploadService.EXTRA_USER, to); - startService(i); + final String uploadTo = to.toString(); + final String[] uploadGroupTo = toGroup; + uploadService.getPostUrl(filename, fileLength, mime, new IUploadService.UrlCallback() { + @Override + public void callback(String putUrl, String getUrl) { + // start upload intent service + // TODO move to UploadService.start static method + Intent i = new Intent(MessageCenterService.this, UploadService.class); + i.setData(mediaUri); + i.setAction(UploadService.ACTION_UPLOAD); + i.putExtra(UploadService.EXTRA_POST_URL, putUrl); + i.putExtra(UploadService.EXTRA_GET_URL, getUrl); + i.putExtra(UploadService.EXTRA_DATABASE_ID, msgId); + i.putExtra(UploadService.EXTRA_MESSAGE_ID, id); + i.putExtra(UploadService.EXTRA_MIME, mime); + // this will be used only for out of band data + i.putExtra(UploadService.EXTRA_ENCRYPT, encrypt); + i.putExtra(UploadService.EXTRA_PREVIEW_PATH, previewPath); + // delete original (actually it's the encrypted temp file) if we already encrypted it + i.putExtra(UploadService.EXTRA_DELETE_ORIGINAL, encrypt); + i.putExtra(UploadService.EXTRA_USER, groupJid != null ? uploadGroupTo : uploadTo); + if (groupJid != null) + i.putExtra(UploadService.EXTRA_GROUP, groupJid); + ContextCompat.startForegroundService(MessageCenterService.this, i); + } + }); + } else { // TODO warn user about this problem Log.w(TAG, "no upload service - this shouldn't happen!"); } } - else { // hold on to message center while we send the message - mIdleHandler.hold(); + mIdleHandler.hold(false); + + Stanza m, originalStanza; + + // pre-process message for group delivery + GroupCommand groupCommand = null; + if (group != null) { + int groupCommandId = data.getInt("org.kontalk.message.group.command", 0); + switch (groupCommandId) { + case GROUP_COMMAND_PART: + groupCommand = group.part(); + ((PartCommand) groupCommand).setDatabaseId(msgId); + // FIXME careful to this, might need abstraction + groupCommand.setMembers(toGroup); + groupCommand.setGroupJid(groupJid); + break; + case GROUP_COMMAND_CREATE: { + String subject = data.getString("org.kontalk.message.group.subject"); + groupCommand = group.createGroup(); + ((CreateGroupCommand) groupCommand).setSubject(subject); + groupCommand.setMembers(toGroup); + groupCommand.setGroupJid(groupJid); + break; + } + case GROUP_COMMAND_SUBJECT: { + String subject = data.getString("org.kontalk.message.group.subject"); + groupCommand = group.setSubject(); + ((SetSubjectCommand) groupCommand).setSubject(subject); + // FIXME careful to this, might need abstraction + groupCommand.setMembers(toGroup); + groupCommand.setGroupJid(groupJid); + break; + } + case GROUP_COMMAND_MEMBERS: { + String subject = data.getString("org.kontalk.message.group.subject"); + String[] added = data.getStringArray("org.kontalk.message.group.add"); + String[] removed = data.getStringArray("org.kontalk.message.group.remove"); + groupCommand = group.addRemoveMembers(); + ((AddRemoveMembersCommand) groupCommand).setSubject(subject); + ((AddRemoveMembersCommand) groupCommand).setAddedMembers(added); + ((AddRemoveMembersCommand) groupCommand).setRemovedMembers(removed); + groupCommand.setMembers(toGroup); + groupCommand.setGroupJid(groupJid); + break; + } + default: + groupCommand = group.info(); + // FIXME careful to this, might need abstraction + groupCommand.setMembers(toGroup); + groupCommand.setGroupJid(groupJid); + } - // message stanza - org.jivesoftware.smack.packet.Message m = new org.jivesoftware.smack.packet.Message(); - m.setType(org.jivesoftware.smack.packet.Message.Type.chat); - if (to != null) m.setTo(to); + m = group.beforeEncryption(groupCommand, null); + } + else { + // message stanza + m = new org.jivesoftware.smack.packet.Message(); + } + + originalStanza = m; + boolean isMessage = (m instanceof org.jivesoftware.smack.packet.Message); + + if (to != null) + m.setTo(to); // set message id m.setStanzaId(id); - if (msgId > 0) - mWaitingReceipt.put(id, msgId); - - String body = data.getString("org.kontalk.message.body"); - if (body != null) - m.setBody(body); - - String fetchUrl = data.getString("org.kontalk.message.fetch.url"); - - // generate preview if needed - String _previewUri = data.getString("org.kontalk.message.preview.uri"); - String previewFilename = data.getString("org.kontalk.message.preview.path"); - if (_previewUri != null && previewFilename != null) { - File previewPath = new File(previewFilename); - if (!previewPath.isFile()) { - Uri previewUri = Uri.parse(_previewUri); - try { - MediaStorage.cacheThumbnail(this, previewUri, previewPath, true); - } - catch (IOException e) { - Log.w(TAG, "unable to generate preview for media", e); + + // message server id + String serverId = isMessage ? data.getString("org.kontalk.message.ack") : null; + boolean ackRequest = isMessage && + !data.getBoolean("org.kontalk.message.standalone", false) && + group == null; + + if (isMessage) { + org.jivesoftware.smack.packet.Message msg = (org.jivesoftware.smack.packet.Message) m; + msg.setType(org.jivesoftware.smack.packet.Message.Type.chat); + String body = data.getString("org.kontalk.message.body"); + if (body != null) + msg.setBody(body); + + String fetchUrl = data.getString("org.kontalk.message.fetch.url"); + + // generate preview if needed + String _previewUri = data.getString("org.kontalk.message.preview.uri"); + String previewFilename = data.getString("org.kontalk.message.preview.path"); + if (_previewUri != null && previewFilename != null) { + File previewPath = new File(previewFilename); + if (!previewPath.isFile()) { + Uri previewUri = Uri.parse(_previewUri); + try { + MediaStorage.cacheThumbnail(this, previewUri, previewPath, true); + } + catch (Exception e) { + Log.w(TAG, "unable to generate preview for media", e); + } } + + m.addExtension(new BitsOfBinary(MediaStorage.THUMBNAIL_MIME_NETWORK, previewPath)); } - m.addExtension(new BitsOfBinary(MediaStorage.THUMBNAIL_MIME_NETWORK, previewPath)); - } + // add download url if present + if (fetchUrl != null) { + // in this case we will need the length too + long length = data.getLong("org.kontalk.message.length"); + m.addExtension(new OutOfBandData(fetchUrl, mime, length, encrypt)); + } - ChatState chatState; - try { - chatState = ChatState.valueOf(data.getString("org.kontalk.message.chatState")); - } - catch (Exception e) { - chatState = null; - } + // add location data if present + if (data.containsKey("org.kontalk.message.geo_lat")) { + double lat = data.getDouble("org.kontalk.message.geo_lat"); + double lon = data.getDouble("org.kontalk.message.geo_lon"); + UserLocation userLocation = new UserLocation(lat, lon); - // add download url if present - if (fetchUrl != null) { - // in this case we will need the length too - long length = data.getLong("org.kontalk.message.length"); - m.addExtension(new OutOfBandData(fetchUrl, mime, length, encrypt)); - } + if (data.containsKey("org.kontalk.message.geo_text")) { + String text = data.getString("org.kontalk.message.geo_text"); + userLocation.setText(text); + } + if (data.containsKey("org.kontalk.message.geo_street")) { + String street = data.getString("org.kontalk.message.geo_street"); + userLocation.setStreet(street); + } - if (encrypt) { - byte[] toMessage = null; - try { - Coder coder = UsersProvider.getEncryptCoder(this, mServer, key, new String[] { to }); - if (coder != null) { + m.addExtension(userLocation); + } - // no extensions, create a simple text version to save space - if (m.getExtensions().size() == 0) { - toMessage = coder.encryptText(body); - } + // add referenced message if any + long inReplyToId = data.getLong("org.kontalk.message.inReplyTo", 0); + if (inReplyToId > 0) { + ReferencedMessage referencedMsg = ReferencedMessage.load(this, inReplyToId); + if (referencedMsg != null) { + DelayInformation fwdDelay = new DelayInformation(new Date(referencedMsg.getTimestamp())); - // some extension, encrypt whole stanza just to be sure - else { - toMessage = coder.encryptStanza(m.toXML()); + try { + org.jivesoftware.smack.packet.Message fwdMessage = + new org.jivesoftware.smack.packet.Message(referencedMsg.getPeer(), + referencedMsg.getTextContent()); + fwdMessage.setStanzaId(referencedMsg.getMessageId()); + + m.addExtension(new Forwarded(fwdDelay, fwdMessage)); + } + catch (XmppStringprepException e) { + Log.w(TAG, "unable to parse referenced message JID: " + referencedMsg.getPeer(), e); + // this is serious, report it + ReportingManager.logException(e); } + } + } + + if (encrypt) { + byte[] toMessage = null; + try { + Coder coder = Keyring.getEncryptCoder(this, mServer, key, toGroup); + if (coder != null) { + + // no extensions, create a simple text version to save space + if (m.getExtensions().size() == 0) { + toMessage = coder.encryptText(body); + } + + // some extension, encrypt whole stanza just to be sure + else { + toMessage = coder.encryptStanza(m.toXML(null)); + } - org.jivesoftware.smack.packet.Message encMsg = + org.jivesoftware.smack.packet.Message encMsg = new org.jivesoftware.smack.packet.Message(m.getTo(), - m.getType()); + ((org.jivesoftware.smack.packet.Message) m).getType()); - encMsg.setBody(getString(R.string.text_encrypted)); - encMsg.setStanzaId(m.getStanzaId()); - encMsg.addExtension(new E2EEncryption(toMessage)); + encMsg.setBody(getString(R.string.text_encrypted)); + encMsg.setStanzaId(m.getStanzaId()); + encMsg.addExtension(new E2EEncryption(toMessage)); - m = encMsg; + // save the unencrypted stanza for later + originalStanza = m; + m = encMsg; + } } - } - // FIXME there is some very ugly code here - // FIXME notify just once per session (store in Kontalk instance?) + // FIXME there is some very ugly code here + // FIXME notify just once per session (store in Kontalk instance?) - catch (IllegalArgumentException noPublicKey) { - // warn user: message will be not sent - if (to.equalsIgnoreCase(MessagingNotification.getPaused())) { - Toast.makeText(this, R.string.warn_no_public_key, - Toast.LENGTH_LONG).show(); + catch (IllegalArgumentException noPublicKey) { + // warn user: message will be not sent + if (MessagingNotification.isPaused(convJid)) { + Toast.makeText(this, R.string.warn_no_public_key, + Toast.LENGTH_LONG).show(); + } + } + catch (GeneralSecurityException e) { + // warn user: message will not be sent + if (MessagingNotification.isPaused(convJid)) { + Toast.makeText(this, R.string.warn_encryption_failed, + Toast.LENGTH_LONG).show(); + } } - } - catch (GeneralSecurityException e) { - // warn user: message will not be sent - if (to.equalsIgnoreCase(MessagingNotification.getPaused())) { - Toast.makeText(this, R.string.warn_encryption_failed, - Toast.LENGTH_LONG).show(); + if (toMessage == null) { + // message was not encrypted for some reason, mark it pending user review + MessageUpdater.forMessage(this, msgId) + .setStatus(Messages.STATUS_PENDING) + .commit(); + + mIdleHandler.release(); + return; } } + } - if (toMessage == null) { - // message was not encrypted for some reason, mark it pending user review - ContentValues values = new ContentValues(1); - values.put(Messages.STATUS, Messages.STATUS_PENDING); - getContentResolver().update(ContentUris.withAppendedId - (Messages.CONTENT_URI, msgId), values, null, null); - - // do not send the message - if (msgId > 0) - mWaitingReceipt.remove(id); - mIdleHandler.release(); - return; - } + // post-process for group delivery + if (group != null) { + m = group.afterEncryption(groupCommand, m, originalStanza); } - // message server id - String serverId = data.getString("org.kontalk.message.ack"); - boolean ackRequest = !data.getBoolean("org.kontalk.message.standalone", false); + if (isMessage) { + // received receipt + if (serverId != null) { + m.addExtension(new DeliveryReceipt(serverId)); + } + else { + ChatState chatState; + try { + chatState = ChatState.valueOf(data.getString(EXTRA_CHAT_STATE)); + // add chat state if message is not a received receipt + m.addExtension(new ChatStateExtension(chatState)); + } + catch (Exception ignored) { + } - // received receipt - if (serverId != null) { - m.addExtension(new DeliveryReceipt(serverId)); + // standalone: no receipt + if (ackRequest) + DeliveryReceiptRequest.addTo((org.jivesoftware.smack.packet.Message) m); + } + + sendMessage((org.jivesoftware.smack.packet.Message) m, msgId); } else { - // add chat state if message is not a received receipt - if (chatState != null) - m.addExtension(new ChatStateExtension(chatState)); - - // standalone message: no receipt - if (ackRequest) - DeliveryReceiptRequest.addTo(m); + // no wake lock management in this case + sendPacket(m); } - sendPacket(m); - - // no ack request, release message center immediately - if (!ackRequest) - mIdleHandler.release(); + // the real sendMessage has its own hold/release pair + mIdleHandler.release(); } } + */ - /** Process an incoming message. */ - Uri incoming(CompositeMessage msg) { - final String sender = msg.getSender(true); + private void ensureUploadServices() { + if (mUploadServices == null) + mUploadServices = new ArrayList<>(2); + } - // save to local storage - ContentValues values = new ContentValues(); - values.put(Messages.MESSAGE_ID, msg.getId()); - values.put(Messages.PEER, sender); + void addUploadService(IUploadService service) { + ensureUploadServices(); + mUploadServices.add(service); + BUS.post(new UploadServiceFoundEvent()); + } - MessageUtils.fillContentValues(values, msg); + void addUploadService(IUploadService service, int priority) { + ensureUploadServices(); + mUploadServices.add(priority, service); + BUS.post(new UploadServiceFoundEvent()); + } - values.put(Messages.STATUS, Messages.STATUS_INCOMING); - values.put(Messages.UNREAD, true); - values.put(Messages.NEW, true); - values.put(Messages.DIRECTION, Messages.DIRECTION_IN); - values.put(Messages.TIMESTAMP, System.currentTimeMillis()); + /** + * Returns the first available upload service post URL. + */ + private IUploadService getUploadService() { + return (mUploadServices != null && mUploadServices.size() > 0) ? + mUploadServices.get(0) : null; + } - Uri msgUri = null; - try { - msgUri = getContentResolver().insert(Messages.CONTENT_URI, values); - } - catch (SQLiteConstraintException econstr) { - // duplicated message, skip it - } + /** Dangerous check for push notifications availability. */ + private static boolean isPushNotificationsAvailable(Context context) { + if (!Preferences.getPushNotificationsEnabled(context)) + return false; - // mark sender as registered in the users database - final Context context = getApplicationContext(); - new Thread(new Runnable() { - public void run() { - UsersProvider.markRegistered(context, sender); - } - }).start(); + IPushService psm = PushServiceManager.getInstance(context); + return (psm != null && + (psm.isServiceAvailable() || psm.isRegisteredOnServer() || psm.isRegistered())); + } - // fire notification only if message was actually inserted to database - if (msgUri != null && !sender.equalsIgnoreCase(MessagingNotification.getPaused())) { - // update notifications (delayed) - MessagingNotification.delayedUpdateMessagesNotification(getApplicationContext(), true); - } + /** + * If the user ignored our request to not be optimized, we must become a foreground service. + * @return true if the user decided to battery optimize us + */ + public static boolean mustSetForeground(Context context) { + return !SystemUtils.isIgnoringBatteryOptimizations(context); + } - return msgUri; + /** Return true if the service should be started in foreground. */ + public static boolean shouldStartInForeground(Context context) { + return mustSetForeground(context) || + Preferences.getForegroundServiceEnabled(context); } - /** Returns the first available upload service post URL. */ - private String getUploadService() { - if (mUploadServices != null && mUploadServices.size() > 0) { - Set keys = mUploadServices.keySet(); - for (String key : keys) { - String url = mUploadServices.get(key); - if (url != null) - return url; - } + private void setForeground(boolean enable) { + if (enable) { + startForeground(NOTIFICATION_ID_FOREGROUND, + MessagingNotification.buildForegroundNotification(this)); + } + else { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); } - - return null; } - private void beginKeyPairRegeneration() { + private void beginKeyPairRegeneration(String passphrase) { if (mKeyPairRegenerator == null) { try { - mKeyPairRegenerator = new RegenerateKeyPairListener(this); + // lock message center while doing this + hold(this, true); + mKeyPairRegenerator = new RegenerateKeyPairListener(this, passphrase); mKeyPairRegenerator.run(); } catch (Exception e) { @@ -2029,18 +3002,11 @@ void endKeyPairRegeneration() { if (mKeyPairRegenerator != null) { mKeyPairRegenerator.abort(); mKeyPairRegenerator = null; + // release message center + release(this); } } - /** - * Used by {@link XMPPConnectionHelper} to retrieve the keyring that will - * be used for the next login while upgrading from legacy. - */ - @Override - public PGPKeyPairRingProvider getKeyPairRingProvider() { - return mKeyPairRegenerator; - } - private void beginKeyPairImport(Uri keypack, String passphrase) { if (mKeyPairImporter == null) { try { @@ -2052,7 +3018,8 @@ private void beginKeyPairImport(Uri keypack, String passphrase) { } catch (Exception e) { Log.e(TAG, "unable to initiate keypair import", e); - // TODO warn user + Toast.makeText(this, R.string.err_import_keypair_failed, + Toast.LENGTH_LONG).show(); endKeyPairImport(); } @@ -2066,13 +3033,25 @@ void endKeyPairImport() { } } + private void beginUploadPrivateKey(String exportPasshrase) { + try { + String passphrase = Kontalk.get().getDefaultAccount().getPassphrase(); + byte[] privateKeyData = Authenticator.getPrivateKeyExportData(this, passphrase, exportPasshrase); + PrivateKeyUploadListener uploadListener = new PrivateKeyUploadListener(this, privateKeyData); + uploadListener.uploadAndListen(); + } + catch (PGPException | IOException e) { + Log.e(TAG, "unable to load private key data", e); + } + } + private boolean canTest() { long now = SystemClock.elapsedRealtime(); return ((now - mLastTest) > MIN_TEST_INTERVAL); } public boolean canConnect() { - return isNetworkConnectionAvailable(this) && !isOfflineMode(this); + return SystemUtils.isNetworkConnectionAvailable(this) && !isOfflineMode(this); } public boolean isConnected() { @@ -2083,25 +3062,41 @@ public boolean isConnecting() { return mHelper != null; } - /** Checks for network availability. */ - public static boolean isNetworkConnectionAvailable(Context context) { - final ConnectivityManager cm = (ConnectivityManager) context - .getSystemService(Context.CONNECTIVITY_SERVICE); - if (cm.getBackgroundDataSetting()) { - NetworkInfo info = cm.getActiveNetworkInfo(); - if (info != null && info.getState() == NetworkInfo.State.CONNECTED) - return true; - } + public static boolean isOfflineMode(Context context) { + return Preferences.getOfflineMode(); + } - return false; + public static EventBus bus() { + return BUS; } - private static boolean isOfflineMode(Context context) { - return Preferences.getOfflineMode(context); + private static Intent getBaseIntent(Context context) { + return new Intent(context, MessageCenterService.class); } private static Intent getStartIntent(Context context) { - return new Intent(context, MessageCenterService.class); + return getBaseIntent(context); + } + + private static void startForegroundIfNeeded(Context context, Intent intent) { + if (shouldStartInForeground(context)) { + startForegroundForced(context, intent); + } + else { + try { + context.startService(intent); + } + catch (IllegalStateException e) { + // for some reason we are starting in the background + // but we are not allowed to do so. Just start in foreground. + startForegroundForced(context, intent); + } + } + } + + private static void startForegroundForced(Context context, Intent intent) { + intent.putExtra(EXTRA_FOREGROUND, true); + ContextCompat.startForegroundService(context, intent); } public static void start(Context context) { @@ -2112,53 +3107,58 @@ public static void start(Context context) { } // check for network state - if (isNetworkConnectionAvailable(context)) { + if (SystemUtils.isNetworkConnectionAvailable(context)) { Log.d(TAG, "starting message center"); final Intent intent = getStartIntent(context); - context.startService(intent); + startForegroundIfNeeded(context, intent); } - else + else { Log.d(TAG, "network not available or background data disabled - abort service start"); + } } public static void stop(Context context) { Log.d(TAG, "shutting down message center"); - context.stopService(new Intent(context, MessageCenterService.class)); + context.stopService(getBaseIntent(context)); } public static void restart(Context context) { Log.d(TAG, "restarting message center"); - Intent i = new Intent(context, MessageCenterService.class); + Intent i = getBaseIntent(context); i.setAction(ACTION_RESTART); - context.startService(i); + startForegroundIfNeeded(context, i); } - public static void test(Context context) { + public static void test(Context context, boolean checkNetwork) { Log.d(TAG, "testing message center connection"); - Intent i = new Intent(context, MessageCenterService.class); + Intent i = getBaseIntent(context); i.setAction(ACTION_TEST); - context.startService(i); + i.putExtra(EXTRA_TEST_CHECK_NETWORK, checkNetwork); + startForegroundIfNeeded(context, i); } public static void ping(Context context) { Log.d(TAG, "ping message center connection"); - Intent i = new Intent(context, MessageCenterService.class); + Intent i = getBaseIntent(context); i.setAction(ACTION_PING); - context.startService(i); + startForegroundIfNeeded(context, i); } /** * Tells the message center we are holding on to it, preventing shutdown for * inactivity. + * + * @param activate true to wake up from CSI and send become available. */ - public static void hold(final Context context) { + public static void hold(final Context context, boolean activate) { // increment the application counter - ((Kontalk) context.getApplicationContext()).hold(); + Kontalk.get().hold(); - Intent i = new Intent(context, MessageCenterService.class); + Intent i = getBaseIntent(context); i.setAction(ACTION_HOLD); - context.startService(i); + i.putExtra("org.kontalk.activate", activate); + startForegroundIfNeeded(context, i); } /** @@ -2167,172 +3167,67 @@ public static void hold(final Context context) { */ public static void release(final Context context) { // decrement the application counter - ((Kontalk) context.getApplicationContext()).release(); + Kontalk.get().release(); - Intent i = new Intent(context, MessageCenterService.class); + Intent i = getBaseIntent(context); i.setAction(ACTION_RELEASE); - context.startService(i); - } - - /** Broadcasts our presence to the server. */ - public static void updateStatus(final Context context) { - // FIXME this is what sendPresence already does - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(ACTION_PRESENCE); - i.putExtra(EXTRA_STATUS, Preferences.getStatusMessage(context)); - context.startService(i); - } - - /** Sends a chat state message. */ - public static void sendChatState(final Context context, String to, ChatState state) { - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_MESSAGE); - i.putExtra("org.kontalk.message.to", to); - i.putExtra("org.kontalk.message.chatState", state.name()); - i.putExtra("org.kontalk.message.standalone", true); - context.startService(i); - } - - /** Sends a text message. */ - public static void sendTextMessage(final Context context, String to, String text, boolean encrypt, long msgId, String packetId) { - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_MESSAGE); - i.putExtra("org.kontalk.message.msgId", msgId); - i.putExtra("org.kontalk.message.packetId", packetId); - i.putExtra("org.kontalk.message.mime", TextComponent.MIME_TYPE); - i.putExtra("org.kontalk.message.to", to); - i.putExtra("org.kontalk.message.body", text); - i.putExtra("org.kontalk.message.encrypt", encrypt); - i.putExtra("org.kontalk.message.chatState", ChatState.active.name()); - context.startService(i); - } - - /** Sends a binary message. */ - public static void sendBinaryMessage(final Context context, - String to, - String mime, Uri localUri, long length, String previewPath, - boolean encrypt, int compress, - long msgId, String packetId) { - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_MESSAGE); - i.putExtra("org.kontalk.message.msgId", msgId); - i.putExtra("org.kontalk.message.packetId", packetId); - i.putExtra("org.kontalk.message.mime", mime); - i.putExtra("org.kontalk.message.to", to); - i.putExtra("org.kontalk.message.media.uri", localUri.toString()); - i.putExtra("org.kontalk.message.length", length); - i.putExtra("org.kontalk.message.preview.path", previewPath); - i.putExtra("org.kontalk.message.compress", compress); - i.putExtra("org.kontalk.message.encrypt", encrypt); - i.putExtra("org.kontalk.message.chatState", ChatState.active.name()); - context.startService(i); - } - - public static void sendUploadedMedia(final Context context, String to, - String mime, Uri localUri, long length, String previewPath, String fetchUrl, - boolean encrypt, long msgId, String packetId) { - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_MESSAGE); - i.putExtra("org.kontalk.message.msgId", msgId); - i.putExtra("org.kontalk.message.packetId", packetId); - i.putExtra("org.kontalk.message.mime", mime); - i.putExtra("org.kontalk.message.to", to); - i.putExtra("org.kontalk.message.preview.uri", localUri.toString()); - i.putExtra("org.kontalk.message.length", length); - i.putExtra("org.kontalk.message.preview.path", previewPath); - i.putExtra("org.kontalk.message.fetch.url", fetchUrl); - i.putExtra("org.kontalk.message.encrypt", encrypt); - i.putExtra("org.kontalk.message.chatState", ChatState.active.name()); - context.startService(i); + startForegroundIfNeeded(context, i); } - public static String messageId() { - return StringUtils.randomString(30); + /** + * Broadcasts our presence to the server. + */ + public static void updateStatus(String status) { + BUS.post(new UpdateStatusRequest(status)); } - /** Replies to a presence subscription request. */ - public static void replySubscription(final Context context, String to, int action) { - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_SUBSCRIBED); - i.putExtra(EXTRA_TO, to); - i.putExtra(EXTRA_PRIVACY, action); - context.startService(i); + public static String messageId() { + return StringUtils.randomString(30); } - public static void regenerateKeyPair(final Context context) { - Intent i = new Intent(context, MessageCenterService.class); + @Deprecated + public static void regenerateKeyPair(final Context context, String passphrase) { + Intent i = getBaseIntent(context); i.setAction(MessageCenterService.ACTION_REGENERATE_KEYPAIR); - context.startService(i); + i.putExtra(EXTRA_PASSPHRASE, passphrase); + startForegroundIfNeeded(context, i); } + @Deprecated public static void importKeyPair(final Context context, Uri keypack, String passphrase) { - Intent i = new Intent(context, MessageCenterService.class); + Intent i = getBaseIntent(context); i.setAction(MessageCenterService.ACTION_IMPORT_KEYPAIR); i.putExtra(EXTRA_KEYPACK, keypack); i.putExtra(EXTRA_PASSPHRASE, passphrase); - context.startService(i); - } - - public static void requestConnectionStatus(final Context context) { - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_CONNECTED); - context.startService(i); - } - - public static void requestRosterStatus(final Context context) { - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_ROSTER_LOADED); - context.startService(i); - } - - public static void requestLastActivity(final Context context, String to, String id) { - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_LAST_ACTIVITY); - i.putExtra(EXTRA_TO, to); - i.putExtra(EXTRA_PACKET_ID, id); - context.startService(i); - } - - public static void requestVCard(final Context context, String to) { - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_VCARD); - i.putExtra(EXTRA_TO, to); - context.startService(i); + startForegroundIfNeeded(context, i); } - public static void requestPublicKey(final Context context, String to) { - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_PUBLICKEY); - i.putExtra(EXTRA_TO, to); - context.startService(i); - } - - public static void requestServerList(final Context context) { - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_SERVERLIST); - context.startService(i); - } - - /** Starts the push notifications registration process. */ + /** + * Starts the push notifications registration process. + */ public static void enablePushNotifications(Context context) { - Intent i = new Intent(context, MessageCenterService.class); + Intent i = getBaseIntent(context); i.setAction(ACTION_PUSH_START); - context.startService(i); + startForegroundIfNeeded(context, i); } - /** Starts the push notifications unregistration process. */ + /** + * Starts the push notifications unregistration process. + */ public static void disablePushNotifications(Context context) { - Intent i = new Intent(context, MessageCenterService.class); + Intent i = getBaseIntent(context); i.setAction(ACTION_PUSH_STOP); - context.startService(i); + startForegroundIfNeeded(context, i); } - /** Caches the given registration Id for use with push notifications. */ + /** + * Caches the given registration Id for use with push notifications. + */ public static void registerPushNotifications(Context context, String registrationId) { - Intent i = new Intent(context, MessageCenterService.class); + Intent i = getBaseIntent(context); i.setAction(ACTION_PUSH_REGISTERED); i.putExtra(PUSH_REGISTRATION_ID, registrationId); - context.startService(i); + startForegroundIfNeeded(context, i); } public void setPushNotifications(boolean enabled) { @@ -2348,7 +3243,7 @@ public void setPushNotifications(boolean enabled) { void pushRegister() { if (sPushSenderId != null) { - if (mPushService.isServiceAvailable()) { + if (mPushService != null && mPushService.isServiceAvailable()) { // senderId will be given by serverinfo if any mPushRegistrationId = mPushService.getRegistrationId(); if (TextUtils.isEmpty(mPushRegistrationId)) @@ -2362,12 +3257,14 @@ void pushRegister() { } private void pushUnregister() { - if (mPushService.isRegistered()) - // start unregistration - mPushService.unregister(sPushListener); - else - // force unregistration - setPushRegistrationId(null); + if (mPushService != null) { + if (mPushService.isRegistered()) + // start unregistration + mPushService.unregister(sPushListener); + else + // force unregistration + setPushRegistrationId(null); + } } private void setPushRegistrationId(String regId) { @@ -2386,45 +3283,60 @@ private void setPushRegistrationId(String regId) { private void sendPushRegistration(final String regId) { IQ iq = PushRegistration.register(DEFAULT_PUSH_PROVIDER, regId); - iq.setTo("push@" + mServer.getNetwork()); try { - mConnection.sendIqWithResponseCallback(iq, new StanzaListener() { - @Override - public void processPacket(Stanza packet) throws NotConnectedException { - if (mPushService != null) - mPushService.setRegisteredOnServer(regId != null); - } - }); + iq.setTo(JidCreate.from("push", mServer.getNetwork(), "")); } - catch (NotConnectedException e) { - // ignored + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return; } + + sendIqWithReply(iq, true, new StanzaListener() { + @Override + public void processStanza(Stanza packet) throws NotConnectedException { + if (mPushService != null) + mPushService.setRegisteredOnServer(regId != null); + } + }, null); } private void sendPushUnregistration() { IQ iq = PushRegistration.unregister(DEFAULT_PUSH_PROVIDER); - iq.setTo("push@" + mServer.getNetwork()); try { - mConnection.sendIqWithResponseCallback(iq, new StanzaListener() { - @Override - public void processPacket(Stanza packet) throws NotConnectedException { - if (mPushService != null) - mPushService.setRegisteredOnServer(false); - } - }); + iq.setTo(JidCreate.from("push", mServer.getNetwork(), "")); } - catch (NotConnectedException e) { - // ignored + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return; } + + sendIqWithReply(iq, true, new StanzaListener() { + @Override + public void processStanza(Stanza packet) throws NotConnectedException { + if (mPushService != null) + mPushService.setRegisteredOnServer(false); + } + }, null); } public static String getPushSenderId() { return sPushSenderId; } - private void setWakeupAlarm() { + private void scheduleStartJob() { + if (SystemUtils.supportsJobScheduler()) + StartMessageCenterJob.schedule(this); + // in theory, lack of the job scheduler means old behavior + // so we don't need this because the NetworkStateReceiver will work + } + + void setWakeupAlarm() { long delay = Preferences.getWakeupTimeMillis(this, - MIN_WAKEUP_TIME, DEFAULT_WAKEUP_TIME); + MIN_WAKEUP_TIME); // start message center pending intent PendingIntent pi = PendingIntent.getService( @@ -2435,7 +3347,7 @@ private void setWakeupAlarm() { // since this can happen after the service has begun to stop AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE); am.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, - SystemClock.elapsedRealtime() + delay, pi); + SystemClock.elapsedRealtime() + delay, pi); } private void ensureIdleAlarm() { @@ -2448,13 +3360,19 @@ private void ensureIdleAlarm() { } } - private void cancelIdleAlarm() { - ensureIdleAlarm(); - mAlarmManager.cancel(mIdleIntent); + void cancelIdleAlarm() { + // synchronized access since we might get a call from IdleThread + final AlarmManager alarms = mAlarmManager; + if (alarms != null) { + ensureIdleAlarm(); + alarms.cancel(mIdleIntent); + } } private void setIdleAlarm() { - long delay = Preferences.getIdleTimeMillis(this, 0, DEFAULT_IDLE_TIME); + // even if this is called from IdleThread, we don't need to synchronize + // because at that point mConnection is null so we never get called + long delay = Preferences.getIdleTimeMillis(this, 0); if (delay > 0) { ensureIdleAlarm(); mAlarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, diff --git a/app/src/main/java/org/kontalk/service/msgcenter/MessageListener.java b/app/src/main/java/org/kontalk/service/msgcenter/MessageListener.java index 4dbcc5835..edc3f1403 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/MessageListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/MessageListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,342 +18,756 @@ package org.kontalk.service.msgcenter; -import static org.kontalk.service.msgcenter.MessageCenterService.ACTION_MESSAGE; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_FROM; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_TO; - import java.io.File; import java.io.IOException; import java.util.Date; -import java.util.Map; +import org.jivesoftware.smack.SmackException; import org.jivesoftware.smack.packet.ExtensionElement; +import org.jivesoftware.smack.packet.Message; import org.jivesoftware.smack.packet.Stanza; import org.jivesoftware.smackx.chatstates.ChatState; import org.jivesoftware.smackx.chatstates.packet.ChatStateExtension; -import org.jivesoftware.smackx.delay.packet.DelayInformation; +import org.jivesoftware.smackx.forward.packet.Forwarded; import org.jivesoftware.smackx.receipts.DeliveryReceipt; import org.jivesoftware.smackx.receipts.DeliveryReceiptRequest; +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; +import org.jxmpp.stringprep.XmppStringprepException; + +import android.content.ContentUris; +import android.content.Context; +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.kontalk.Kontalk; +import org.kontalk.Log; import org.kontalk.client.BitsOfBinary; import org.kontalk.client.E2EEncryption; +import org.kontalk.client.EndpointServer; +import org.kontalk.client.GroupExtension; +import org.kontalk.client.KontalkGroupManager; +import org.kontalk.client.OpenPGPSignedMessage; import org.kontalk.client.OutOfBandData; +import org.kontalk.client.UserLocation; import org.kontalk.crypto.Coder; +import org.kontalk.crypto.DecryptException; +import org.kontalk.crypto.PersonalKey; +import org.kontalk.crypto.VerifyException; +import org.kontalk.data.Contact; +import org.kontalk.data.GroupInfo; import org.kontalk.message.AudioComponent; import org.kontalk.message.CompositeMessage; +import org.kontalk.message.DefaultAttachmentComponent; +import org.kontalk.message.GroupCommandComponent; +import org.kontalk.message.GroupComponent; import org.kontalk.message.ImageComponent; +import org.kontalk.message.InReplyToComponent; +import org.kontalk.message.LocationComponent; import org.kontalk.message.MessageComponent; import org.kontalk.message.RawComponent; +import org.kontalk.message.ReferencedMessage; import org.kontalk.message.TextComponent; import org.kontalk.message.VCardComponent; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.provider.MessagesProviderClient.MessageUpdater; +import org.kontalk.provider.MyMessages; import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.service.msgcenter.event.ChatStateEvent; +import org.kontalk.service.msgcenter.group.KontalkGroupController; import org.kontalk.util.MediaStorage; import org.kontalk.util.MessageUtils; +import org.kontalk.util.Preferences; +import org.kontalk.util.XMPPUtils; -import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.ContentValues; -import android.content.Intent; -import android.net.Uri; -import android.util.Log; +import static org.kontalk.crypto.DecryptException.DECRYPT_EXCEPTION_INVALID_TIMESTAMP; /** * Packet listener for message stanzas. * @author Daniele Ricci */ -class MessageListener extends MessageCenterPacketListener { - - private static final String selectionOutgoing = Messages.DIRECTION + "=" + Messages.DIRECTION_OUT; - private static final String selectionIngoing = Messages.DIRECTION + "=" + Messages.DIRECTION_IN; +class MessageListener extends WakefulMessageCenterPacketListener { public MessageListener(MessageCenterService instance) { - super(instance); + super(instance, "RECV"); } - @Override - public void processPacket(Stanza packet) { - Map waitingReceipt = getWaitingReceiptList(); - - org.jivesoftware.smack.packet.Message m = (org.jivesoftware.smack.packet.Message) packet; + private static final class GroupMessageProcessingResult { + public final boolean accept; + public final ChatStateEvent chatStateEvent; - if (m.getType() == org.jivesoftware.smack.packet.Message.Type.chat) { - Intent i = new Intent(ACTION_MESSAGE); - String from = m.getFrom(); - - // check if there is a composing notification - ExtensionElement _chatstate = m.getExtension("http://jabber.org/protocol/chatstates"); - ChatStateExtension chatstate = null; - if (_chatstate != null) { - chatstate = (ChatStateExtension) _chatstate; - i.putExtra("org.kontalk.message.chatState", chatstate.getElementName()); - } + GroupMessageProcessingResult(boolean accept, ChatStateEvent chatStateEvent) { + this.accept = accept; + this.chatStateEvent = chatStateEvent; + } + } - i.putExtra(EXTRA_FROM, from); - i.putExtra(EXTRA_TO, m.getTo()); - sendBroadcast(i); + @NonNull + private GroupMessageProcessingResult processGroupMessage(KontalkGroupManager.KontalkGroup group, + Stanza packet, CompositeMessage msg, @Nullable ChatStateEvent chatStateEvent) { - // non-active notifications are not to be processed as messages - if (chatstate != null && !chatstate.getElementName().equals(ChatState.active.name())) - return; + if (group.checkRequest(packet) && canHandleGroupCommand(packet)) { + GroupExtension ext = GroupExtension.from(packet); + Jid groupJid = JidCreate.fromOrThrowUnchecked(ext.getJid()); + String subject = ext.getSubject(); - // delayed deliver extension is the first the be processed - // because it's used also in delivery receipts - ExtensionElement _delay = m.getExtension("delay", "urn:xmpp:delay"); - if (_delay == null) - _delay = m.getExtension("x", "jabber:x:delay"); + // group information + GroupInfo groupInfo = new GroupInfo(groupJid, subject, + KontalkGroupController.GROUP_TYPE, MyMessages.Groups.MEMBERSHIP_MEMBER); + msg.addComponent(new GroupComponent(groupInfo)); - Date stamp = null; - if (_delay != null) { - if (_delay instanceof DelayInformation) { - stamp = ((DelayInformation) _delay).getStamp(); - } + // group typing information + if (chatStateEvent != null) { + chatStateEvent = new ChatStateEvent(chatStateEvent.from, + groupJid, chatStateEvent.chatState); } - long serverTimestamp; - if (stamp != null) - serverTimestamp = stamp.getTime(); - else - serverTimestamp = System.currentTimeMillis(); - - DeliveryReceipt deliveryReceipt = DeliveryReceipt.from(m); - - // delivery receipt - if (deliveryReceipt != null) { - synchronized (waitingReceipt) { - String id = m.getStanzaId(); - Long _msgId = waitingReceipt.get(id); - long msgId = (_msgId != null) ? _msgId : 0; - ContentResolver cr = getContext().getContentResolver(); - - // message has been delivered: check if we have previously stored the server id - if (msgId > 0) { - ContentValues values = new ContentValues(3); - values.put(Messages.MESSAGE_ID, deliveryReceipt.getId()); - values.put(Messages.STATUS, Messages.STATUS_RECEIVED); - values.put(Messages.STATUS_CHANGED, serverTimestamp); - cr.update(ContentUris.withAppendedId(Messages.CONTENT_URI, msgId), - values, selectionOutgoing, null); - - waitingReceipt.remove(id); - } - else { - // FIXME this could lead to fake delivery receipts because message IDs are client-generated - Uri msg = Messages.getUri(deliveryReceipt.getId()); - ContentValues values = new ContentValues(2); - values.put(Messages.STATUS, Messages.STATUS_RECEIVED); - values.put(Messages.STATUS_CHANGED, serverTimestamp); - cr.update(msg, values, selectionOutgoing, null); - } - } + if (ext.getType() == GroupExtension.Type.CREATE || + ext.getType() == GroupExtension.Type.PART || + ext.getType() == GroupExtension.Type.SET) { + GroupCommandComponent groupCmd = new GroupCommandComponent(ext, + packet.getFrom().asBareJid().toString()); + msg.addComponent(groupCmd); } - // incoming message - else { - String msgId = m.getStanzaId(); - if (msgId == null) - msgId = MessageUtils.messageId(); + return new GroupMessageProcessingResult(true, chatStateEvent); + } - String body = m.getBody(); + // invalid or unauthorized request + return new GroupMessageProcessingResult(false, chatStateEvent); + } - // create message - CompositeMessage msg = new CompositeMessage( - getContext(), - msgId, - serverTimestamp, - from, - false, - Coder.SECURITY_CLEARTEXT - ); + /** Returns true if we can handle this group command (e.g. if we have it in our database). */ + private boolean canHandleGroupCommand(Stanza packet) { + GroupExtension ext = GroupExtension.from(packet); + // creation command + return ext.getType() == GroupExtension.Type.CREATE || + // is the owner adding me to the group? + isAddingMe(ext) || + // check that the sender has valid membership + (isValidMember(ext, packet.getFrom()) && + // all other commands require the group to be present in our database + MessagesProviderClient.isGroupExisting(getContext(), ext.getJid().toString())); + } - ExtensionElement _encrypted = m.getExtension(E2EEncryption.ELEMENT_NAME, E2EEncryption.NAMESPACE); + /** Returns true if the given group command is the owner adding me to the group. */ + private boolean isAddingMe(GroupExtension ext) { + if (ext.getType() == GroupExtension.Type.SET) { + String myself = Kontalk.get().getDefaultAccount().getSelfJID(); + for (GroupExtension.Member m : ext.getMembers()) { + if (m.operation == GroupExtension.Member.Operation.ADD && + m.jid.equals(myself)) + return true; + } + } + return false; + } - if (_encrypted != null && _encrypted instanceof E2EEncryption) { - E2EEncryption mEnc = (E2EEncryption) _encrypted; - byte[] encryptedData = mEnc.getData(); + private boolean isValidMember(GroupExtension ext, Jid from) { + return MessagesProviderClient.isGroupMember(getContext(), + ext.getJid().toString(), from.asBareJid().toString()); + } - // encrypted message - msg.setEncrypted(true); - msg.setSecurityFlags(Coder.SECURITY_BASIC); + @Override + protected void processWakefulStanza(Stanza packet) throws SmackException.NotConnectedException { + org.jivesoftware.smack.packet.Message m = (org.jivesoftware.smack.packet.Message) packet; - if (encryptedData != null) { + if (m.getType() == org.jivesoftware.smack.packet.Message.Type.chat) { + // a preliminary object is created here + // other info will be filled in by processChatMessage + ChatStateEvent chatStateEvent = processChatState(m); - // decrypt message - try { - MessageUtils.decryptMessage(getContext(), - getServer(), msg, encryptedData); - } + // non-active chat states are not to be processed as messages + if (chatStateEvent == null || chatStateEvent.chatState == ChatState.active) { + chatStateEvent = processChatMessage(m, chatStateEvent); + } - catch (Exception exc) { - Log.e(MessageCenterService.TAG, "decryption failed", exc); + if (chatStateEvent != null) { + // we can send the chat state event now + MessageCenterService.bus().post(chatStateEvent); + } + } - // raw component for encrypted data - // reuse security flags - msg.clearComponents(); - msg.addComponent(new RawComponent(encryptedData, true, msg.getSecurityFlags())); - } + // error message + else if (m.getType() == org.jivesoftware.smack.packet.Message.Type.error) { + processErrorMessage(m); + } + } - } - } + /** + * Retrieve the group JID from a message. Must not be encrypted. + * Used mainly for chat states. + */ + private Jid getGroupJid(Message m) { + // group chat + KontalkGroupManager.KontalkGroup group; + try { + group = KontalkGroupManager + .getInstanceFor(getConnection()).getGroup(m); + } + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return null; + } - else { + if (group != null && group.checkRequest(m) && canHandleGroupCommand(m)) { + GroupExtension ext = GroupExtension.from(m); + Jid groupJid = JidCreate.fromOrThrowUnchecked(ext.getJid()); + String subject = ext.getSubject(); - // use message body - if (body != null) - msg.addComponent(new TextComponent(body)); + // group information + GroupInfo groupInfo = new GroupInfo(groupJid, subject, + KontalkGroupController.GROUP_TYPE, MyMessages.Groups.MEMBERSHIP_MEMBER); + return groupInfo.getJid(); + } - } + return null; + } - // TODO duplicated code (MessageUtils#decryptMessage) + private ChatStateEvent processChatState(Message m) { + // check if there is a composing notification + ExtensionElement _chatstate = m.getExtension("http://jabber.org/protocol/chatstates"); + if (_chatstate instanceof ChatStateExtension) { + ChatStateExtension chatstate = (ChatStateExtension) _chatstate; + Jid groupJid = getGroupJid(m); + return new ChatStateEvent(m.getFrom(), groupJid, chatstate.getChatState()); + } + return null; + } - // out of band data - ExtensionElement _media = m.getExtension(OutOfBandData.ELEMENT_NAME, OutOfBandData.NAMESPACE); - if (_media != null && _media instanceof OutOfBandData) { - File previewFile = null; + /** + * Process an incoming message packet. + * @param m the message + * @param chatStateEvent a chat state event that will be returned with missing information (e.g. group info in encrypted message) + * @return a chat state event to be posted, or null + */ + private ChatStateEvent processChatMessage(Message m, @Nullable ChatStateEvent chatStateEvent) + throws SmackException.NotConnectedException { + // delayed deliver extension is the first the be processed + // because it's used also in delivery receipts + Date stamp = XMPPUtils.getStanzaDelay(m); + + long serverTimestamp; + if (stamp != null) + serverTimestamp = stamp.getTime(); + else + serverTimestamp = System.currentTimeMillis(); + + DeliveryReceipt deliveryReceipt = DeliveryReceipt.from(m); + + // delivery receipt + if (deliveryReceipt != null) { + MessageUpdater.forMessage(getContext(), deliveryReceipt.getId(), false) + .setStatus(Messages.STATUS_RECEIVED, System.currentTimeMillis()) + .commit(); + } - OutOfBandData media = (OutOfBandData) _media; - String mime = media.getMime(); - String fetchUrl = media.getUrl(); - long length = media.getLength(); - boolean encrypted = media.isEncrypted(); + // incoming message + else { + String msgId = m.getStanzaId(); + if (msgId == null) + msgId = MessageUtils.messageId(); - // bits-of-binary for preview - ExtensionElement _preview = m.getExtension(BitsOfBinary.ELEMENT_NAME, BitsOfBinary.NAMESPACE); - if (_preview != null && _preview instanceof BitsOfBinary) { - BitsOfBinary preview = (BitsOfBinary) _preview; - String previewMime = preview.getType(); - if (previewMime == null) - previewMime = MediaStorage.THUMBNAIL_MIME_NETWORK; + Jid from = m.getFrom(); - String filename = null; + // check if user opted out of server messages + if (!Preferences.isServerMessagesEnabled(getContext()) && + from.asBareJid().toString().equalsIgnoreCase(getServer().getNetwork())) { + Log.w(TAG, "user opted out of server messages, message will be ignored"); + return null; + } - if (ImageComponent.supportsMimeType(mime)) { - filename = ImageComponent.buildMediaFilename(msgId, previewMime); + String body = m.getBody(); + + // create message + CompositeMessage msg = new CompositeMessage( + getContext(), + msgId, + serverTimestamp, + from.toString(), + false, + Coder.SECURITY_CLEARTEXT + ); + + // ack request might not be encrypted + boolean needAck = m.hasExtension(DeliveryReceiptRequest.ELEMENT, DeliveryReceipt.NAMESPACE); + + ExtensionElement _encrypted = m.getExtension(E2EEncryption.ELEMENT_NAME, E2EEncryption.NAMESPACE); + + if (_encrypted instanceof E2EEncryption) { + E2EEncryption mEnc = (E2EEncryption) _encrypted; + byte[] encryptedData = mEnc.getData(); + + // encrypted message + msg.setEncrypted(true); + msg.setSecurityFlags(Coder.SECURITY_BASIC); + + if (encryptedData != null) { + + // decrypt message + try { + Message innerStanza = decryptMessage(msg, encryptedData); + if (innerStanza != null) { + // copy some attributes over + innerStanza.setTo(m.getTo()); + innerStanza.setFrom(m.getFrom()); + innerStanza.setType(m.getType()); + m = innerStanza; + + if (!needAck) { + // try the decrypted message + needAck = m.hasExtension(DeliveryReceiptRequest.ELEMENT, DeliveryReceipt.NAMESPACE); + } } + } - else if (VCardComponent.supportsMimeType(mime)) { - filename = VCardComponent.buildMediaFilename(msgId, previewMime); - } + catch (Exception exc) { + Log.e(MessageCenterService.TAG, "decryption failed", exc); + + // raw component for encrypted data + // reuse security flags + msg.clearComponents(); + msg.addComponent(new RawComponent(encryptedData, true, msg.getSecurityFlags())); + } + } + } + + else { + + // use message body + if (body != null) + msg.addComponent(new TextComponent(body)); + + // old PGP signature + ExtensionElement _pgpSigned = m.getExtension(OpenPGPSignedMessage.ELEMENT_NAME, OpenPGPSignedMessage.NAMESPACE); + if (_pgpSigned instanceof OpenPGPSignedMessage) { + OpenPGPSignedMessage pgpSigned = (OpenPGPSignedMessage) _pgpSigned; + byte[] signedData = pgpSigned.getData(); + + // signed message + msg.setSecurityFlags(Coder.SECURITY_BASIC_SIGNED); + + if (signedData != null) { + // check signature try { - if (filename != null) previewFile = - MediaStorage.writeInternalMedia(getContext(), - filename, preview.getContents()); + checkSignedMessage(msg, pgpSigned.getData()); + // at this point our message should be filled with the verified body } - catch (IOException e) { - Log.w(MessageCenterService.TAG, "error storing thumbnail", e); + + catch (Exception exc) { + Log.e(MessageCenterService.TAG, "signature check failed", exc); + // TODO what to do here? + msg.setSecurityFlags(msg.getSecurityFlags() | + Coder.SECURITY_ERROR_INVALID_SIGNATURE); } } + } - MessageComponent attachment = null; + } - if (ImageComponent.supportsMimeType(mime)) { - // cleartext only for now - attachment = new ImageComponent(mime, previewFile, null, fetchUrl, length, - encrypted, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + // out of band data + ExtensionElement _media = m.getExtension(OutOfBandData.ELEMENT_NAME, OutOfBandData.NAMESPACE); + if (_media instanceof OutOfBandData) { + File previewFile = null; + + OutOfBandData media = (OutOfBandData) _media; + String mime = media.getMime(); + String fetchUrl = media.getUrl(); + long length = media.getLength(); + boolean encrypted = media.isEncrypted(); + + // bits-of-binary for preview + ExtensionElement _preview = m.getExtension(BitsOfBinary.ELEMENT_NAME, BitsOfBinary.NAMESPACE); + if (_preview instanceof BitsOfBinary) { + BitsOfBinary preview = (BitsOfBinary) _preview; + String previewMime = preview.getType(); + if (previewMime == null) + previewMime = MediaStorage.THUMBNAIL_MIME_NETWORK; + + String filename = null; + + if (ImageComponent.supportsMimeType(previewMime)) { + filename = ImageComponent.buildMediaFilename(previewMime); } - else if (VCardComponent.supportsMimeType(mime)) { - // cleartext only for now - attachment = new VCardComponent(previewFile, null, fetchUrl, length, - encrypted, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + try { + if (filename != null) previewFile = + MediaStorage.writeInternalMedia(getContext(), + filename, preview.getContents()); } - - else if (AudioComponent.supportsMimeType(mime)) { - attachment = new AudioComponent(mime, null, fetchUrl, length, - encrypted, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + catch (IOException e) { + Log.w(MessageCenterService.TAG, "error storing thumbnail", e); + // we are going to need a filename anyway + previewFile = MediaStorage.getInternalMediaFile(getContext(), filename); } + } - // TODO other types + MessageComponent attachment; - if (attachment != null) - msg.addComponent(attachment); + if (mime == null) { + // try to guess MIME from URL + mime = MediaStorage.getType(fetchUrl); + } - // add a dummy body if none was found - /* - if (body == null) { - msg.addComponent(new TextComponent(CompositeMessage - .getSampleTextContent((Class>) - attachment.getClass(), mime))); + if (ImageComponent.supportsMimeType(mime)) { + if (previewFile == null) { + // no bits of binary, generate a filename anyway so the thumbnail will be generated + // from the original file once downloaded + String filename = ImageComponent.buildMediaFilename(mime); + previewFile = MediaStorage.getInternalMediaFile(getContext(), filename); } - */ + msg.clearComponents(); + // cleartext only for now + attachment = new ImageComponent(mime, previewFile, null, fetchUrl, length, + encrypted, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + } + + else if (VCardComponent.supportsMimeType(mime)) { + msg.clearComponents(); + // cleartext only for now + attachment = new VCardComponent(previewFile, null, fetchUrl, length, + encrypted, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); } - Uri msgUri = incoming(msg); + else if (AudioComponent.supportsMimeType(mime)) { + msg.clearComponents(); + attachment = new AudioComponent(mime, null, fetchUrl, length, + encrypted, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); + } - if (m.hasExtension(DeliveryReceiptRequest.ELEMENT, DeliveryReceipt.NAMESPACE)) { - // send ack :) - sendReceipt(msgUri, msgId, from, waitingReceipt); + else { + msg.clearComponents(); + attachment = new DefaultAttachmentComponent(mime, null, fetchUrl, length, + encrypted, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); } + // TODO other types + + msg.addComponent(attachment); + + // add a dummy body if none was found + /* + if (body == null) { + msg.addComponent(new TextComponent(CompositeMessage + .getSampleTextContent((Class>) + attachment.getClass(), mime))); + } + */ } - } - // error message - else if (m.getType() == org.jivesoftware.smack.packet.Message.Type.error) { - DeliveryReceipt deliveryReceipt = DeliveryReceipt.from(m); - - // delivery receipt error - if (deliveryReceipt != null) { - // mark indicated message as incoming and try again - Uri msg = Messages.getUri(deliveryReceipt.getId()); - ContentValues values = new ContentValues(2); - values.put(Messages.STATUS, Messages.STATUS_INCOMING); - values.put(Messages.STATUS_CHANGED, System.currentTimeMillis()); - getContext().getContentResolver() - .update(msg, values, selectionIngoing, null); - - // send receipt again - sendReceipt(null, deliveryReceipt.getId(), m.getFrom(), waitingReceipt); + ExtensionElement _location = m.getExtension(UserLocation.ELEMENT_NAME, UserLocation.NAMESPACE); + if (_location instanceof UserLocation) { + UserLocation location = (UserLocation) _location; + msg.addComponent(new LocationComponent(location.getLatitude(), + location.getLongitude(), location.getText(), location.getStreet())); } - synchronized (waitingReceipt) { - String id = m.getStanzaId(); - Long _msgId = waitingReceipt.get(id); - long msgId = (_msgId != null) ? _msgId : 0; - ContentResolver cr = getContext().getContentResolver(); - - // message has been rejected: mark as error - if (msgId > 0) { - ContentValues values = new ContentValues(2); - values.put(Messages.STATUS, Messages.STATUS_NOTDELIVERED); - values.put(Messages.STATUS_CHANGED, System.currentTimeMillis()); - cr.update(ContentUris.withAppendedId(Messages.CONTENT_URI, msgId), - values, selectionOutgoing, null); - - waitingReceipt.remove(id); - - // we can now release the message center. Hopefully - // there will be one hold and one matching release. - getIdleHandler().release(); + ExtensionElement _fwd = m.getExtension(Forwarded.ELEMENT, Forwarded.NAMESPACE); + if (_fwd instanceof Forwarded) { + // we actually use only the stanza id for looking up the referenced message in our database. + // The forwarded stanza was included for compatibility with other XMPP clients. + // Although technically it's a waste of space, and the replied message will + // not be displayed if it is deleted + Forwarded fwd = (Forwarded) _fwd; + + Stanza fwdMsg = fwd.getForwardedStanza(); + if (fwdMsg != null && fwdMsg.getStanzaId() != null) { + ReferencedMessage referencedMsg = ReferencedMessage + .load(getContext(), fwdMsg.getStanzaId()); + if (referencedMsg != null) + msg.addComponent(new InReplyToComponent(referencedMsg)); } - else if (id != null) { - // FIXME this could lead to fake delivery receipts because message IDs are client-generated - Uri msg = Messages.getUri(id); - ContentValues values = new ContentValues(2); - values.put(Messages.STATUS, Messages.STATUS_NOTDELIVERED); - values.put(Messages.STATUS_CHANGED, System.currentTimeMillis()); - cr.update(msg, values, selectionOutgoing, null); + } + + // group chat + KontalkGroupManager.KontalkGroup group; + try { + group = KontalkGroupManager + .getInstanceFor(getConnection()).getGroup(m); + } + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return null; + } + + if (group != null) { + GroupMessageProcessingResult result = processGroupMessage(group, m, msg, chatStateEvent); + if (!result.accept) { + // invalid group command + Log.w(TAG, "invalid or unauthorized group command"); + return null; + } + else { + chatStateEvent = result.chatStateEvent; } } + + if (msg.getComponents().size() == 0) { + Log.w(TAG, "message has no content, discarding"); + return null; + } + + // 1-to-1 message with a chat state + // set contact as typing if necessary + if (!msg.hasComponent(GroupComponent.class) && chatStateEvent != null) { + Contact.setTyping(from.toString(), chatStateEvent.chatState == ChatState.composing); + } + + msg.setStatus(needAck ? Messages.STATUS_INCOMING : Messages.STATUS_CONFIRMED); + + Uri msgUri = Kontalk.get().getMessagesController().incoming(msg); + + if (needAck) { + // send ack :) + sendReceipt(msgUri, msgId, from); + } } + + return chatStateEvent; } - private void sendReceipt(Uri msgUri, String msgId, String from, Map waitingReceipt) { + private void processErrorMessage(Message m) { + DeliveryReceipt deliveryReceipt = DeliveryReceipt.from(m); + + // delivery receipt error + if (deliveryReceipt != null) { + // mark indicated message as incoming and try again + MessageUpdater.forMessage(getContext(), deliveryReceipt.getId(), true) + .setStatus(Messages.STATUS_INCOMING, System.currentTimeMillis()) + .commit(); + + // send receipt again + sendReceipt(null, deliveryReceipt.getId(), m.getFrom()); + } + + String id = m.getStanzaId(); + if (id != null) { + MessageUpdater.forMessage(getContext(), m.getStanzaId(), false) + .setStatus(Messages.STATUS_NOTDELIVERED, System.currentTimeMillis()) + .commit(); + } + } + + private void sendReceipt(Uri msgUri, String msgId, Jid from) { DeliveryReceipt receipt = new DeliveryReceipt(msgId); org.jivesoftware.smack.packet.Message ack = new org.jivesoftware.smack.packet.Message(from, org.jivesoftware.smack.packet.Message.Type.chat); ack.addExtension(receipt); + long storageId = 0; if (msgUri != null) { - // hold on to message center - getIdleHandler().hold(); - // will mark this message as confirmed - long storageId = ContentUris.parseId(msgUri); - waitingReceipt.put(ack.getStanzaId(), storageId); + // will use this to mark this message as confirmed + storageId = ContentUris.parseId(msgUri); + } + + sendMessage(ack, storageId); + } + + private Message decryptMessage(CompositeMessage msg, byte[] encryptedData) throws Exception { + // message stanza + Message m = null; + + try { + Context context = getContext(); + PersonalKey key = Kontalk.get().getPersonalKey(); + + EndpointServer server = getServer(); + if (server == null) + server = Kontalk.get().getEndpointServer(); + + Coder coder = Keyring.getDecryptCoder(context, server, key, msg.getSender(true)); + + // decrypt + Coder.DecryptOutput result = coder.decryptText(encryptedData, true); + + String contentText; + + if (XMPPUtils.XML_XMPP_TYPE.equalsIgnoreCase(result.mime)) { + m = XMPPUtils.parseMessageStanza(result.cleartext); + + if (result.timestamp != null && !checkDriftedDelay(m, result.timestamp)) + result.errors.add(new DecryptException(DECRYPT_EXCEPTION_INVALID_TIMESTAMP, + "Drifted timestamp")); + + contentText = m.getBody(); + } + else { + contentText = result.cleartext; + } + + // clear components (we are adding new ones) + msg.clearComponents(); + // decrypted text + if (contentText != null) + msg.addComponent(new TextComponent(contentText)); + + if (result.errors.size() > 0) { + + int securityFlags = msg.getSecurityFlags(); + + for (DecryptException err : result.errors) { + + int code = err.getCode(); + switch (code) { + + case DecryptException.DECRYPT_EXCEPTION_INTEGRITY_CHECK: + securityFlags |= Coder.SECURITY_ERROR_INTEGRITY_CHECK; + break; + + case DecryptException.DECRYPT_EXCEPTION_VERIFICATION_FAILED: + securityFlags |= Coder.SECURITY_ERROR_INVALID_SIGNATURE; + break; + + case DecryptException.DECRYPT_EXCEPTION_INVALID_DATA: + securityFlags |= Coder.SECURITY_ERROR_INVALID_DATA; + break; + + case DecryptException.DECRYPT_EXCEPTION_INVALID_SENDER: + securityFlags |= Coder.SECURITY_ERROR_INVALID_SENDER; + break; + + case DecryptException.DECRYPT_EXCEPTION_INVALID_RECIPIENT: + securityFlags |= Coder.SECURITY_ERROR_INVALID_RECIPIENT; + break; + + case DecryptException.DECRYPT_EXCEPTION_INVALID_TIMESTAMP: + securityFlags |= Coder.SECURITY_ERROR_INVALID_TIMESTAMP; + break; + + } + + } + + msg.setSecurityFlags(securityFlags); + } + + msg.setEncrypted(false); + + return m; + } + catch (Exception exc) { + // pass over the message even if encrypted + // UI will warn the user about that and wait + // for user decisions + int securityFlags = msg.getSecurityFlags(); + + if (exc instanceof DecryptException) { + + int code = ((DecryptException) exc).getCode(); + switch (code) { + + case DecryptException.DECRYPT_EXCEPTION_DECRYPT_FAILED: + case DecryptException.DECRYPT_EXCEPTION_PRIVATE_KEY_NOT_FOUND: + securityFlags |= Coder.SECURITY_ERROR_DECRYPT_FAILED; + break; + + case DecryptException.DECRYPT_EXCEPTION_INTEGRITY_CHECK: + securityFlags |= Coder.SECURITY_ERROR_INTEGRITY_CHECK; + break; + + case DecryptException.DECRYPT_EXCEPTION_INVALID_DATA: + securityFlags |= Coder.SECURITY_ERROR_INVALID_DATA; + break; + + } + + msg.setSecurityFlags(securityFlags); + } + + throw exc; } - sendPacket(ack); } + + private void checkSignedMessage(CompositeMessage msg, byte[] signedData) throws Exception { + try { + Context context = getContext(); + EndpointServer server = getServer(); + if (server == null) + server = Kontalk.get().getEndpointServer(); + + // retrieve a coder for verifying against the server key + Coder coder = Keyring.getVerifyCoder(context, server, msg.getSender(true)); + + // decrypt + Coder.VerifyOutput result = coder.verifyText(signedData, true); + String contentText = result.cleartext; + + // clear components (we are adding new ones) + msg.clearComponents(); + // decrypted text + if (contentText != null) + msg.addComponent(new TextComponent(contentText)); + + if (result.errors.size() > 0) { + + int securityFlags = msg.getSecurityFlags(); + + for (VerifyException err : result.errors) { + + int code = err.getCode(); + switch (code) { + + case VerifyException.VERIFY_EXCEPTION_VERIFICATION_FAILED: + securityFlags |= Coder.SECURITY_ERROR_INVALID_SIGNATURE; + break; + + case VerifyException.VERIFY_EXCEPTION_INVALID_DATA: + securityFlags |= Coder.SECURITY_ERROR_INVALID_DATA; + break; + + } + + } + + msg.setSecurityFlags(securityFlags); + } + } + catch (Exception exc) { + // pass over the message even if encrypted + // UI will warn the user about that and wait + // for user decisions + int securityFlags = msg.getSecurityFlags(); + + if (exc instanceof VerifyException) { + + int code = ((VerifyException) exc).getCode(); + switch (code) { + + case VerifyException.VERIFY_EXCEPTION_INVALID_DATA: + securityFlags |= Coder.SECURITY_ERROR_INVALID_DATA; + break; + + } + + msg.setSecurityFlags(securityFlags); + } + + throw exc; + } + } + + private static boolean checkDriftedDelay(Message m, Date expected) { + Date stamp = XMPPUtils.getStanzaDelay(m); + if (stamp != null) { + long time = stamp.getTime(); + long now = expected.getTime(); + long diff = Math.abs(now - time); + return (diff < Coder.TIMEDIFF_THRESHOLD); + } + + // no timestamp found + return true; + } + } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/PGPKeyPairRingProvider.java b/app/src/main/java/org/kontalk/service/msgcenter/PGPKeyPairRingProvider.java deleted file mode 100644 index dc2b11992..000000000 --- a/app/src/main/java/org/kontalk/service/msgcenter/PGPKeyPairRingProvider.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.service.msgcenter; - -import org.kontalk.crypto.PGP.PGPKeyPairRing; - - -/** - * Interface for retrieving a key pair ring. - * @author Daniele Ricci - */ -public interface PGPKeyPairRingProvider { - - public PGPKeyPairRing getKeyPair(); -} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/PresenceListener.java b/app/src/main/java/org/kontalk/service/msgcenter/PresenceListener.java index e18fbf177..62178c8cc 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/PresenceListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/PresenceListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,124 +18,64 @@ package org.kontalk.service.msgcenter; -import java.io.IOException; +import java.util.Date; -import org.jivesoftware.smack.SmackException.NotConnectedException; import org.jivesoftware.smack.packet.ExtensionElement; import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smack.packet.Stanza; import org.jivesoftware.smack.roster.RosterEntry; +import org.jivesoftware.smack.roster.SubscribeListener; import org.jivesoftware.smack.roster.packet.RosterPacket; import org.jivesoftware.smackx.delay.packet.DelayInformation; -import org.jxmpp.util.XmppStringUtils; -import org.spongycastle.openpgp.PGPException; -import org.spongycastle.openpgp.PGPPublicKey; -import org.spongycastle.openpgp.PGPPublicKeyRing; +import org.jxmpp.jid.Jid; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; -import android.content.Intent; -import android.util.Log; +import org.kontalk.Kontalk; +import org.kontalk.Log; import org.kontalk.client.PublicKeyPresence; -import org.kontalk.client.PublicKeyPublish; import org.kontalk.crypto.PGP; import org.kontalk.crypto.PGPUserID; import org.kontalk.data.Contact; -import org.kontalk.provider.MyMessages.CommonColumns; -import org.kontalk.provider.MyMessages.Threads.Requests; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.provider.MyUsers; import org.kontalk.provider.MyUsers.Users; import org.kontalk.provider.UsersProvider; +import org.kontalk.service.msgcenter.event.PresenceEvent; +import org.kontalk.service.msgcenter.event.PublicKeyRequest; +import org.kontalk.service.msgcenter.event.UserOfflineEvent; +import org.kontalk.service.msgcenter.event.UserOnlineEvent; import org.kontalk.ui.MessagingNotification; import org.kontalk.util.Preferences; -import static org.kontalk.service.msgcenter.MessageCenterService.ACTION_PRESENCE; -import static org.kontalk.service.msgcenter.MessageCenterService.ACTION_SUBSCRIBED; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_FROM; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_PACKET_ID; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_PRIORITY; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_SHOW; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_STAMP; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_STATUS; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_TO; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_TYPE; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_FINGERPRINT; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_SUBSCRIBED_FROM; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_SUBSCRIBED_TO; - /** * Packet listener for presence stanzas. * @author Daniele Ricci */ -class PresenceListener extends MessageCenterPacketListener { +class PresenceListener extends MessageCenterPacketListener implements SubscribeListener { public PresenceListener(MessageCenterService instance) { super(instance); } - private Stanza createSubscribed(Presence p) { - ExtensionElement _pkey = p.getExtension(PublicKeyPresence.ELEMENT_NAME, PublicKeyPresence.NAMESPACE); - - try { - - if (_pkey instanceof PublicKeyPresence) { - PublicKeyPresence pkey = (PublicKeyPresence) _pkey; - - byte[] keydata = pkey.getKey(); - // just to ensure it's valid data - PGP.readPublicKeyring(keydata); - - String jid = XmppStringUtils.parseBareJid(p.getFrom()); - // store key to users table - UsersProvider.setUserKey(getContext(), jid, keydata); - // maybe trust the key - UsersProvider.trustUserKey(getContext(), jid); - } - - Presence p2 = new Presence(Presence.Type.subscribed); - p2.setTo(p.getFrom()); - return p2; - - } - catch (Exception e) { - Log.w(MessageCenterService.TAG, "unable add user to whitelist", e); - // TODO should we notify the user about this? - // TODO throw new PGPException(...) - return null; - } - } - @Override - public void processPacket(Stanza packet) { + public void processStanza(Stanza packet) { try { Presence p = (Presence) packet; - // presence subscription request - if (p.getType() == Presence.Type.subscribe) { - - handleSubscribe(p); - - } - - // presence subscription response - else if (p.getType() == Presence.Type.subscribed) { - - handleSubscribed(p); - - } - - /* - else if (p.getType() == Presence.Type.unsubscribed) { - // TODO can this even happen? - } - */ - - else { + if (p.getType() == Presence.Type.available || p.getType() == Presence.Type.unavailable) { + if (p.getType() == Presence.Type.unavailable) { + // clear contact volatile state and cached data + Contact.invalidateData(p.getFrom().toString()); + } handlePresence(p); - } } catch (Exception e) { @@ -143,25 +83,39 @@ else if (p.getType() == Presence.Type.unsubscribed) { } } - private void handleSubscribe(Presence p) - throws NotConnectedException, IOException, PGPException { - + @Override + public SubscribeAnswer processSubscribe(Jid from, Presence subscribeRequest) { Context ctx = getContext(); // auto-accept subscription - if (Preferences.getAutoAcceptSubscriptions(ctx) || isAlreadyTrusted(p)) { - + if (canAutoAcceptSubscription(ctx, subscribeRequest)) { // TODO user database entry should be stored here too - Stanza r = createSubscribed(p); - if (r != null) - getConnection().sendStanza(r); + ExtensionElement _pkey = subscribeRequest.getExtension(PublicKeyPresence.ELEMENT_NAME, PublicKeyPresence.NAMESPACE); + try { + if (_pkey instanceof PublicKeyPresence) { + PublicKeyPresence pkey = (PublicKeyPresence) _pkey; + + byte[] keydata = pkey.getKey(); + // just to ensure it's valid data + PGP.readPublicKeyring(keydata); + + String jid = from.asBareJid().toString(); + // store key to users table + Keyring.setKey(getContext(), jid, keydata, MyUsers.Keys.TRUST_VERIFIED); + } + } + catch (Exception e) { + Log.w(TAG, "invalid public key from " + from, e); + // TODO should we notify the user about this? + } + + return SubscribeAnswer.Approve; } // ask the user else { - /* * Subscription procedure: * 1. update (or insert) users table with the public key just received @@ -169,17 +123,23 @@ private void handleSubscribe(Presence p) * 3. user will either accept or refuse */ - String from = XmppStringUtils.parseBareJid(p.getFrom()); + String fromStr = from.asBareJid().toString(); // extract public key - String name = null, fingerprint = null; + String name = null; byte[] publicKey = null; - ExtensionElement _pkey = p.getExtension(PublicKeyPresence.ELEMENT_NAME, PublicKeyPresence.NAMESPACE); + ExtensionElement _pkey = subscribeRequest.getExtension(PublicKeyPresence.ELEMENT_NAME, PublicKeyPresence.NAMESPACE); if (_pkey instanceof PublicKeyPresence) { PublicKeyPresence pkey = (PublicKeyPresence) _pkey; byte[] _publicKey = pkey.getKey(); // extract the name from the uid - PGPPublicKeyRing ring = PGP.readPublicKeyring(_publicKey); + PGPPublicKeyRing ring = null; + try { + ring = PGP.readPublicKeyring(_publicKey); + } + catch (Exception e) { + Log.w(TAG, "invalid public key from " + fromStr, e); + } if (ring != null) { PGPPublicKey pk = PGP.getMasterKey(ring); if (pk != null) { @@ -187,150 +147,152 @@ private void handleSubscribe(Presence p) PGPUserID uid = PGPUserID.parse(PGP.getUserId(pk, getServer().getNetwork())); if (uid != null) name = uid.getName(); - fingerprint = PGP.getFingerprint(pk); publicKey = _publicKey; } } } ContentResolver cr = ctx.getContentResolver(); - ContentValues values = new ContentValues(7); + ContentValues values = new ContentValues(4); if (name == null) - name = from; + name = fromStr; // insert public key into the users table - values.put(Users.HASH, XmppStringUtils.parseLocalpart(from)); - values.put(Users.JID, from); - values.put(Users.NUMBER, from); - if (publicKey != null && fingerprint != null) { - values.put(Users.FINGERPRINT, fingerprint); - values.put(Users.PUBLIC_KEY, publicKey); - } + values.put(Users.JID, fromStr); + values.put(Users.NUMBER, fromStr); values.put(Users.DISPLAY_NAME, name); values.put(Users.REGISTERED, true); cr.insert(Users.CONTENT_URI.buildUpon() .appendQueryParameter(Users.DISCARD_NAME, "true") .build(), values); + // insert key if any + if (publicKey != null) { + try { + Keyring.setKey(ctx, fromStr, publicKey, MyUsers.Keys.TRUST_UNKNOWN); + } + catch (Exception e) { + Log.w(TAG, "invalid public key from " + fromStr, e); + } + } + // invalidate cache for this user - Contact.invalidate(from); + Contact.invalidate(fromStr); // insert request into the database - values.clear(); - values.put(CommonColumns.PEER, from); - values.put(CommonColumns.TIMESTAMP, System.currentTimeMillis()); - if (cr.insert(Requests.CONTENT_URI, values) != null) { + if (MessagesProviderClient.newChatRequest(ctx, fromStr) != null) { // fire up a notification - MessagingNotification.chatInvitation(ctx, from); + MessagingNotification.chatInvitation(ctx, fromStr); } + + return null; } } + private boolean canAutoAcceptSubscription(Context context, Presence subscribeRequest) { + return Preferences.getAutoAcceptSubscriptions(context) || + Kontalk.get().getDefaultAccount().isNetworkJID(subscribeRequest.getFrom().asBareJid()) || + isAlreadyTrusted(subscribeRequest); + } + private boolean isAlreadyTrusted(Presence p) { RosterEntry entry = getRosterEntry(p.getFrom()); return (entry != null && (entry.getType() == RosterPacket.ItemType.to || entry.getType() == RosterPacket.ItemType.both)); } - private void handleSubscribed(Presence p) { - String from = XmppStringUtils.parseBareJid(p.getFrom()); - - // save last seen (if any) - DelayInformation delay = p.getExtension(DelayInformation.ELEMENT, DelayInformation.NAMESPACE); - if (delay != null) { - UsersProvider.setLastSeen(getContext(), from, delay.getStamp().getTime()); - } - - if (UsersProvider.getPublicKey(getContext(), from, false) == null) { - // public key not found - // assuming the user has allowed us, request it - - PublicKeyPublish pkey = new PublicKeyPublish(); - pkey.setTo(from); - - sendPacket(pkey); - } - - // send a broadcast - Intent i = new Intent(ACTION_SUBSCRIBED); - i.putExtra(EXTRA_TYPE, Presence.Type.subscribed.name()); - i.putExtra(EXTRA_PACKET_ID, p.getStanzaId()); - - i.putExtra(EXTRA_FROM, p.getFrom()); - i.putExtra(EXTRA_TO, p.getTo()); - - sendBroadcast(i); - - // send any pending messages now - resendPending(false); - } + private void handlePresence(final Presence p) { + queueTask(new Runnable() { + @Override + public void run() { + updateUsersDatabase(p); + + // request the new key if fingerprint changed + String newFingerprint = PublicKeyPresence.getFingerprint(p); + if (newFingerprint != null) { + boolean requestKey = false; + String jid = p.getFrom().asBareJid().toString(); + PGPPublicKeyRing pubRing = Keyring.getPublicKey(getContext(), + jid, MyUsers.Keys.TRUST_UNKNOWN); + if (pubRing != null) { + String oldFingerprint = PGP.getFingerprint(PGP.getMasterKey(pubRing)); + if (!newFingerprint.equalsIgnoreCase(oldFingerprint)) { + // key has changed, request new one + requestKey = true; + } + } + else { + // no key available, request one + requestKey = true; + } - private void handlePresence(Presence p) { - updateUsersDatabase(p); - - // request the new key if fingerprint changed - String newFingerprint = PublicKeyPresence.getFingerprint(p); - if (newFingerprint != null) { - String jid = XmppStringUtils.parseBareJid(p.getFrom()); - PGPPublicKeyRing pubRing = UsersProvider.getPublicKey(getContext(), - jid, false); - if (pubRing != null) { - String oldFingerprint = PGP.getFingerprint(PGP.getMasterKey(pubRing)); - if (!newFingerprint.equalsIgnoreCase(oldFingerprint)) { - MessageCenterService.requestPublicKey(getContext(), jid); + if (requestKey) { + MessageCenterService.bus() + .post(new PublicKeyRequest(p.getFrom().asBareJid())); + } } - } - } - Intent i = createIntent(getContext(), p, getRosterEntry(p.getFrom())); - sendBroadcast(i); + MessageCenterService.bus() + .post(createEvent(getContext(), p, getRosterEntry(p.getFrom()), null)); + } + }); } - public static Intent createIntent(Context ctx, Presence p, RosterEntry entry) { - Intent i = new Intent(ACTION_PRESENCE); - Presence.Type type = p.getType(); - i.putExtra(EXTRA_TYPE, type != null ? type.name() : Presence.Type.available.name()); - i.putExtra(EXTRA_PACKET_ID, p.getStanzaId()); - - i.putExtra(EXTRA_FROM, p.getFrom()); - i.putExtra(EXTRA_TO, p.getTo()); - i.putExtra(EXTRA_STATUS, p.getStatus()); - Presence.Mode mode = p.getMode(); - i.putExtra(EXTRA_SHOW, mode != null ? mode.name() : Presence.Mode.available.name()); - i.putExtra(EXTRA_PRIORITY, p.getPriority()); + public static PresenceEvent createEvent(Context ctx, Presence p, RosterEntry entry, String id) { + String jid = p.getFrom().asBareJid().toString(); - long timestamp; + Date delayTime; DelayInformation delay = p.getExtension(DelayInformation.ELEMENT, DelayInformation.NAMESPACE); if (delay != null) { - timestamp = delay.getStamp().getTime(); + delayTime = delay.getStamp(); } else { // try last seen from database - timestamp = UsersProvider.getLastSeen(ctx, XmppStringUtils.parseBareJid(p.getFrom())); + long timestamp = UsersProvider.getLastSeen(ctx, jid); if (timestamp < 0) timestamp = System.currentTimeMillis(); + delayTime = new Date(timestamp); } - i.putExtra(EXTRA_STAMP, timestamp); - // public key fingerprint - i.putExtra(EXTRA_FINGERPRINT, PublicKeyPresence.getFingerprint(p)); + String fingerprint = PublicKeyPresence.getFingerprint(p); + if (fingerprint == null) { + // try untrusted fingerprint from database + fingerprint = Keyring.getFingerprint(ctx, jid, MyUsers.Keys.TRUST_UNKNOWN); + } // subscription information + String rosterName = null; + boolean subscribedFrom = false; + boolean subscribedTo = false; if (entry != null) { + rosterName = entry.getName(); + RosterPacket.ItemType subscriptionType = entry.getType(); - i.putExtra(EXTRA_SUBSCRIBED_FROM, subscriptionType == RosterPacket.ItemType.both || - subscriptionType == RosterPacket.ItemType.from); - i.putExtra(EXTRA_SUBSCRIBED_TO, subscriptionType == RosterPacket.ItemType.both || - subscriptionType == RosterPacket.ItemType.to); + subscribedFrom = subscriptionType == RosterPacket.ItemType.both || + subscriptionType == RosterPacket.ItemType.from; + subscribedTo = subscriptionType == RosterPacket.ItemType.both || + subscriptionType == RosterPacket.ItemType.to; } - return i; + if (p.getType() == Presence.Type.available) { + return new UserOnlineEvent(p.getFrom(), p.getMode(), p.getPriority(), + p.getStatus(), delayTime, rosterName, subscribedFrom, subscribedTo, fingerprint, id); + } + else if (p.getType() == Presence.Type.unavailable) { + return new UserOfflineEvent(p.getFrom(), p.getMode(), p.getPriority(), + p.getStatus(), delayTime, rosterName, subscribedFrom, subscribedTo, fingerprint, id); + } + else { + // TODO other presence types + throw new UnsupportedOperationException("Not implemented: " + p.getType()); + } } - private int updateUsersDatabase(Presence p) { - String jid = XmppStringUtils.parseBareJid(p.getFrom()); + @SuppressWarnings("WeakerAccess") + int updateUsersDatabase(Presence p) { + String jid = p.getFrom().asBareJid().toString(); ContentValues values = new ContentValues(4); values.put(Users.REGISTERED, 1); @@ -345,19 +307,26 @@ private int updateUsersDatabase(Presence p) { // delay long timestamp; DelayInformation delay = p.getExtension(DelayInformation.ELEMENT, DelayInformation.NAMESPACE); - if (delay != null) + if (delay != null) { + // delay from presence (rare) timestamp = delay.getStamp().getTime(); - else + } + else { + // logged in/out now timestamp = System.currentTimeMillis(); + } - values.put(Users.LAST_SEEN, timestamp); + if (timestamp > 0) + values.put(Users.LAST_SEEN, timestamp); // public key extension (for fingerprint) PublicKeyPresence pkey = p.getExtension(PublicKeyPresence.ELEMENT_NAME, PublicKeyPresence.NAMESPACE); if (pkey != null) { String fingerprint = pkey.getFingerprint(); - if (fingerprint != null) - values.put(Users.FINGERPRINT, fingerprint); + if (fingerprint != null) { + // insert new key with empty key data + Keyring.setKey(getContext(), jid, fingerprint, new Date()); + } } return getContext().getContentResolver().update(Users.CONTENT_URI, diff --git a/app/src/main/java/org/kontalk/service/msgcenter/PrivacyCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/PrivacyCommand.java new file mode 100644 index 000000000..1be6ad4e2 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/PrivacyCommand.java @@ -0,0 +1,43 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + + +/** + * User privacy commands used in requests to the message center. + * @author Daniele Ricci + */ +public enum PrivacyCommand { + /** + * Accept subscription. + */ + ACCEPT, + /** + * Block user. + */ + BLOCK, + /** + * Unblock user. + */ + UNBLOCK, + /** + * Reject subscription and block. + */ + REJECT, +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/PrivateKeyUploadListener.java b/app/src/main/java/org/kontalk/service/msgcenter/PrivateKeyUploadListener.java new file mode 100644 index 000000000..fec16939c --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/PrivateKeyUploadListener.java @@ -0,0 +1,151 @@ +/* + * Kontalk Android client + * Copyright (C) 2015 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + +import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.filter.StanzaFilter; +import org.jivesoftware.smack.filter.StanzaIdFilter; +import org.jivesoftware.smack.packet.IQ; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.packet.StanzaError; +import org.jivesoftware.smackx.iqregister.packet.Registration; +import org.jivesoftware.smackx.xdata.Form; +import org.jivesoftware.smackx.xdata.FormField; +import org.jivesoftware.smackx.xdata.packet.DataForm; + +import android.util.Base64; + +import org.kontalk.Kontalk; +import org.kontalk.client.EndpointServer; +import org.kontalk.client.SmackInitializer; +import org.kontalk.service.msgcenter.event.PrivateKeyUploadedEvent; + + +/** + * Uploader and response listener for private key transfer. + * @author Alexander Bikadorov + */ +public class PrivateKeyUploadListener extends MessageCenterPacketListener { + + private final byte[] mPrivateKeyData; + + PrivateKeyUploadListener(MessageCenterService instance, byte[] privateKeyData) { + super(instance); + + mPrivateKeyData = privateKeyData; + } + + private void configure() { + SmackInitializer.initializeRegistration(); + } + + private void unconfigure() { + SmackInitializer.deinitializeRegistration(); + } + + public void uploadAndListen() { + configure(); + + // prepare public key packet + Stanza iq = prepareKeyPacket(); + + // setup packet filter for response + StanzaFilter filter = new StanzaIdFilter(iq.getStanzaId()); + getConnection().addAsyncStanzaListener(this, filter); + + // send the key out + sendPacket(iq); + + // now wait for a response + } + + @Override + public void processStanza(Stanza packet) { + XMPPConnection conn = getConnection(); + conn.removeAsyncStanzaListener(this); + + IQ iq = (IQ) packet; + if (iq.getType() != IQ.Type.result) { + finish(StanzaError.Condition.internal_server_error); + return; + } + + DataForm response = iq.getExtension("x", "jabber:x:data"); + if (response == null) { + finish(StanzaError.Condition.internal_server_error); + return; + } + + String token = null; + FormField field = response.getField("token"); + if (field != null) + token = field.getFirstValue(); + + EndpointServer from = Kontalk.get().getEndpointServer(); + finish(token, from != null ? from.toString() : conn.getXMPPServiceDomain().toString()); + } + + private void finish(String token, String server) { + finish(token, server, null); + } + + private void finish(StanzaError.Condition errorCondition) { + finish(null, null, errorCondition); + } + + private void finish(String token, String server, StanzaError.Condition errorCondition) { + PrivateKeyUploadedEvent event; + if (token != null) { + event = new PrivateKeyUploadedEvent(token, server); + } + else { + event = new PrivateKeyUploadedEvent(errorCondition != null ? + errorCondition : StanzaError.Condition.internal_server_error); + } + + MessageCenterService.bus().post(event); + + unconfigure(); + } + + private Stanza prepareKeyPacket() { + String privatekey = Base64.encodeToString(mPrivateKeyData, Base64.NO_WRAP); + + Registration iq = new Registration(); + iq.setType(IQ.Type.set); + iq.setTo(getConnection().getXMPPServiceDomain()); + Form form = new Form(DataForm.Type.submit); + + // form type: register#privatekey + FormField type = new FormField("FORM_TYPE"); + type.setType(FormField.Type.hidden); + type.addValue("http://kontalk.org/protocol/register#privatekey"); + form.addField(type); + + // private key + FormField fieldKey = new FormField("privatekey"); + fieldKey.setLabel("Private key"); + fieldKey.setType(FormField.Type.text_single); + fieldKey.addValue(privatekey); + form.addField(fieldKey); + + iq.addExtension(form.getDataFormToSend()); + return iq; + } +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/PublicKeyListener.java b/app/src/main/java/org/kontalk/service/msgcenter/PublicKeyListener.java index 3857cec6e..8aa096c12 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/PublicKeyListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/PublicKeyListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,115 +18,111 @@ package org.kontalk.service.msgcenter; +import org.jivesoftware.smack.ExceptionCallback; import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.Stanza; -import org.jxmpp.util.XmppStringUtils; - -import android.content.Intent; -import android.util.Log; +import org.jxmpp.jid.BareJid; +import org.kontalk.Kontalk; +import org.kontalk.Log; import org.kontalk.authenticator.Authenticator; import org.kontalk.client.PublicKeyPublish; +import org.kontalk.crypto.PGP; +import org.kontalk.crypto.PGPUserID; import org.kontalk.crypto.PersonalKey; import org.kontalk.crypto.X509Bridge; import org.kontalk.data.Contact; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.MyUsers; import org.kontalk.provider.UsersProvider; +import org.kontalk.service.msgcenter.event.PublicKeyEvent; import org.kontalk.sync.SyncAdapter; -import org.kontalk.util.MessageUtils; -import org.kontalk.util.XMPPUtils; - -import static org.kontalk.service.msgcenter.MessageCenterService.ACTION_PUBLICKEY; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_FROM; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_PACKET_ID; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_PUBLIC_KEY; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_TO; /** * Packet Listener for public key publish iq stanzas. * @author Daniele Ricci */ -class PublicKeyListener extends MessageCenterPacketListener { +class PublicKeyListener extends MessageCenterPacketListener implements ExceptionCallback { - public PublicKeyListener(MessageCenterService instance) { + private final IQ mRequest; + + public PublicKeyListener(MessageCenterService instance, IQ request) { super(instance); + mRequest = request; } @Override - public void processPacket(Stanza packet) { + public void processStanza(Stanza packet) { PublicKeyPublish p = (PublicKeyPublish) packet; - // will be true if it's our card - boolean myCard = false; byte[] _publicKey = p.getPublicKey(); - // vcard was requested, store but do not broadcast - if (p.getType() == IQ.Type.result) { - - if (_publicKey != null) { + if (_publicKey != null) { + BareJid from = p.getFrom().asBareJid(); + boolean selfJid = Kontalk.get().getDefaultAccount().isSelfJID(from); - String from = XmppStringUtils.parseBareJid(p.getFrom()); + // is this our key? + if (selfJid) { + byte[] bridgeCertData; + try { + PersonalKey key = Kontalk.get().getPersonalKey(); - boolean networkUser = XMPPUtils.isLocalJID(from, getServer().getNetwork()); - // our network - convert to userId - if (networkUser) { - // is this our vCard? - String userId = XmppStringUtils.parseLocalpart(from); - String hash = MessageUtils.sha1(getMyUsername()); - if (userId.equalsIgnoreCase(hash)) - myCard = true; + bridgeCertData = X509Bridge.createCertificate(_publicKey, + key.getAuthKeyPair().getPrivateKey()).getEncoded(); + } + catch (Exception e) { + Log.e(MessageCenterService.TAG, "error decoding key data", e); + bridgeCertData = null; } - if (myCard) { - byte[] bridgeCertData; - try { - PersonalKey key = getApplication().getPersonalKey(); - - // TODO subjectAltName? - bridgeCertData = X509Bridge.createCertificate(_publicKey, - key.getAuthKeyPair().getPrivateKey(), null).getEncoded(); - } - catch (Exception e) { - Log.e(MessageCenterService.TAG, "error decoding key data", e); - bridgeCertData = null; - } - - if (bridgeCertData != null) { - // store key data in AccountManager - Authenticator.setDefaultPersonalKey(getContext(), - _publicKey, null, bridgeCertData, null); - // invalidate cached personal key - getApplication().invalidatePersonalKey(); + if (bridgeCertData != null) { + // store key data in AccountManager + Authenticator.setDefaultPersonalKey(getContext(), + _publicKey, null, bridgeCertData, null); + // invalidate cached personal key + Kontalk.get().invalidatePersonalKey(); - Log.v(MessageCenterService.TAG, "personal key updated."); - } + Log.v(MessageCenterService.TAG, "personal key updated."); } + } + + String id = p.getStanzaId(); - String id = p.getStanzaId(); - // we are syncing and this is a response for the Syncer - if (SyncAdapter.getIQPacketId().equals(id) && SyncAdapter.isActive(getContext())) { - // sync currently active, broadcast the key - Intent i = new Intent(ACTION_PUBLICKEY); - i.putExtra(EXTRA_PACKET_ID, p.getStanzaId()); + // broadcast key update + MessageCenterService.bus() + .post(new PublicKeyEvent(packet.getFrom(), _publicKey, packet.getStanzaId())); - i.putExtra(EXTRA_FROM, p.getFrom()); - i.putExtra(EXTRA_TO, p.getTo()); - i.putExtra(EXTRA_PUBLIC_KEY, _publicKey); + // if we are not syncing and this is not a response for the Syncer + // save the key immediately + if (!SyncAdapter.getIQKeysPacketId().equals(id) || !SyncAdapter.isActive(getContext())) { - sendBroadcast(i); + // updating server key + if (from.isDomainBareJid()) { + Log.v("pubkey", "Updating server key for " + from); + try { + Keyring.setKey(getContext(), from.toString(), _publicKey, + MyUsers.Keys.TRUST_VERIFIED); + } + catch (Exception e) { + // TODO warn user + Log.e(MessageCenterService.TAG, "unable to update user key", e); + } } else { try { - if (networkUser) { - Log.v("pubkey", "Updating key for " + from); - UsersProvider.setUserKey(getContext(), from, _publicKey); - // maybe trust the key - UsersProvider.maybeTrustUserKey(getContext(), from, _publicKey); - - // invalidate cache for this user - Contact.invalidate(from); - } + Log.v("pubkey", "Updating key for " + from); + Keyring.setKey(getContext(), from.toString(), _publicKey, + selfJid ? MyUsers.Keys.TRUST_VERIFIED : -1); + + // update display name with uid (if empty) + PGPUserID keyUid = PGP.parseUserId(_publicKey, getConnection().getXMPPServiceDomain().toString()); + if (keyUid != null && keyUid.getName() != null) + UsersProvider.updateDisplayNameIfEmpty(getContext(), from.toString(), keyUid.getName()); + + // invalidate cache for this user + Contact.invalidate(from.toString()); } catch (Exception e) { // TODO warn user @@ -134,8 +130,13 @@ public void processPacket(Stanza packet) { } } } - } } -} + @Override + public void processException(Exception exception) { + Log.w(TAG, "error processing public key", exception); + MessageCenterService.bus() + .post(new PublicKeyEvent(exception, mRequest.getTo(), mRequest.getStanzaId())); + } +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/PushDiscoverItemsListener.java b/app/src/main/java/org/kontalk/service/msgcenter/PushDiscoverItemsListener.java index 467c33605..18c0beb13 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/PushDiscoverItemsListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/PushDiscoverItemsListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -36,29 +36,30 @@ public PushDiscoverItemsListener(MessageCenterService instance) { } @Override - public void processPacket(Stanza packet) { + public void processStanza(Stanza packet) { // we don't need this listener anymore getConnection().removeAsyncStanzaListener(this); DiscoverItems query = (DiscoverItems) packet; List items = query.getItems(); for (DiscoverItems.Item item : items) { - String jid = item.getEntityID(); + String jid = item.getEntityID().toString(); // google push notifications if (("gcm.push." + getServer().getNetwork()).equals(jid)) { String senderId = item.getNode(); setPushSenderId(senderId); if (isPushNotificationsEnabled()) { - String oldSender = Preferences.getPushSenderId(getContext()); + String oldSender = Preferences.getPushSenderId(); // store the new sender id - Preferences.setPushSenderId(getContext(), senderId); + Preferences.setPushSenderId(senderId); // begin a registration cycle if senderId is different if (oldSender != null && !oldSender.equals(senderId)) { - PushServiceManager.getInstance(getContext()) - .unregister(getPushListener()); + IPushService service = PushServiceManager.getInstance(getContext()); + if (service != null) + service.unregister(getPushListener()); // unregister will see this as an attempt to register again startPushRegistrationCycle(); } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/RegenerateKeyPairListener.java b/app/src/main/java/org/kontalk/service/msgcenter/RegenerateKeyPairListener.java index cd5689868..9507179ad 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/RegenerateKeyPairListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/RegenerateKeyPairListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,31 +18,29 @@ package org.kontalk.service.msgcenter; -import android.accounts.Account; -import android.accounts.AccountManager; +import java.io.IOException; +import java.security.NoSuchProviderException; +import java.security.SignatureException; +import java.security.cert.CertificateException; + +import org.jivesoftware.smack.packet.Stanza; +import org.kontalk.Kontalk; +import org.kontalk.authenticator.MyAccount; +import org.kontalk.util.XMPPUtils; +import org.bouncycastle.openpgp.PGPException; + import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.util.Log; import android.widget.Toast; +import org.kontalk.Log; import org.kontalk.R; -import org.kontalk.authenticator.Authenticator; -import org.kontalk.authenticator.LegacyAuthentication; import org.kontalk.crypto.PersonalKey; import org.kontalk.service.KeyPairGeneratorService; import org.kontalk.service.KeyPairGeneratorService.KeyGeneratorReceiver; import org.kontalk.service.KeyPairGeneratorService.PersonalKeyRunnable; -import org.kontalk.util.MessageUtils; - -import org.jivesoftware.smack.packet.Stanza; -import org.spongycastle.openpgp.PGPException; - -import java.io.IOException; -import java.security.NoSuchProviderException; -import java.security.SignatureException; -import java.security.cert.CertificateException; import static org.kontalk.service.msgcenter.MessageCenterService.ACTION_REGENERATE_KEYPAIR; @@ -51,9 +49,10 @@ class RegenerateKeyPairListener extends RegisterKeyPairListener { private BroadcastReceiver mKeyReceiver; - public RegenerateKeyPairListener(MessageCenterService instance) { - super(instance, null); - mPassphrase = getApplication().getCachedPassphrase(); + public RegenerateKeyPairListener(MessageCenterService instance, String passphrase) { + super(instance, passphrase); + if (passphrase == null) + mPassphrase = Kontalk.get().getDefaultAccount().getPassphrase(); } public void run() throws CertificateException, SignatureException, @@ -78,11 +77,13 @@ public void abort() { } private void generateKeyPair() { - Context ctx = getApplication(); - Intent i = new Intent(ctx, KeyPairGeneratorService.class); - i.setAction(KeyPairGeneratorService.ACTION_GENERATE); - i.putExtra(KeyPairGeneratorService.EXTRA_FOREGROUND, true); - ctx.startService(i); + Context ctx = getContext(); + if (ctx != null) { + Intent i = new Intent(ctx, KeyPairGeneratorService.class); + i.setAction(KeyPairGeneratorService.ACTION_GENERATE); + i.putExtra(KeyPairGeneratorService.EXTRA_FOREGROUND, true); + ctx.startService(i); + } } private void setupKeyPairReceiver() { @@ -97,27 +98,15 @@ public void run(PersonalKey key) { // store the key try { - Context context = getContext(); - AccountManager am = (AccountManager) context.getSystemService(Context.ACCOUNT_SERVICE); - Account acc = Authenticator.getDefaultAccount(am); - String name = Authenticator.getDisplayName(am, acc); + MyAccount account = Kontalk.get().getDefaultAccount(); - String userId = MessageUtils.sha1(acc.name); - mKeyRing = key.storeNetwork(userId, getServer().getNetwork(), name, + String userId = XMPPUtils.createLocalpart(account.getName()); + mKeyRing = key.storeNetwork(userId, getServer().getNetwork(), account.getDisplayName(), // TODO should we ask passphrase to the user? - getApplication().getCachedPassphrase()); + account.getPassphrase()); // listen for connection events - setupConnectedReceiver(); - - if (LegacyAuthentication.isUpgrading()) { - // we need to login with the new certificate - MessageCenterService.restart(context); - } - else { - // request connection status to proceed - MessageCenterService.requestConnectionStatus(context); - } + registerConnectionEvents(); // CONNECTED listener will do the rest } @@ -136,8 +125,8 @@ public void run(PersonalKey key) { } @Override - public void processPacket(Stanza packet) { - super.processPacket(packet); + public void processStanza(Stanza packet) { + super.processStanza(packet); // we are done here endKeyPairRegeneration(); @@ -148,13 +137,14 @@ protected void finish() { sendBroadcast(new Intent(ACTION_REGENERATE_KEYPAIR)); runOnUiThread(new Runnable() { public void run() { - Toast.makeText(getApplication(), - R.string.msg_gen_keypair_complete, - Toast.LENGTH_LONG).show(); + Context context = getContext(); + if (context != null) { + Toast.makeText(context.getApplicationContext(), + R.string.msg_gen_keypair_complete, + Toast.LENGTH_LONG).show(); + } } }); - - LegacyAuthentication.endUpgrade(); } } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/RegisterKeyPairListener.java b/app/src/main/java/org/kontalk/service/msgcenter/RegisterKeyPairListener.java index 135ab44d0..44125d631 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/RegisterKeyPairListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/RegisterKeyPairListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,14 +18,15 @@ package org.kontalk.service.msgcenter; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.text.TextUtils; -import android.util.Base64; -import android.util.Log; +import java.io.IOException; +import java.security.NoSuchProviderException; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.util.List; +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; import org.jivesoftware.smack.filter.StanzaFilter; import org.jivesoftware.smack.filter.StanzaIdFilter; import org.jivesoftware.smack.packet.IQ; @@ -34,42 +35,37 @@ import org.jivesoftware.smackx.xdata.Form; import org.jivesoftware.smackx.xdata.FormField; import org.jivesoftware.smackx.xdata.packet.DataForm; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; + +import android.text.TextUtils; +import android.util.Base64; + +import org.kontalk.Kontalk; +import org.kontalk.Log; import org.kontalk.authenticator.Authenticator; import org.kontalk.client.SmackInitializer; import org.kontalk.crypto.PGP.PGPKeyPairRing; import org.kontalk.crypto.PersonalKey; import org.kontalk.crypto.X509Bridge; -import org.spongycastle.openpgp.PGPException; -import org.spongycastle.openpgp.PGPPublicKey; - -import java.io.IOException; -import java.security.NoSuchProviderException; -import java.security.SignatureException; -import java.security.cert.CertificateException; -import java.util.List; - -import static org.kontalk.service.msgcenter.MessageCenterService.ACTION_CONNECTED; +import org.kontalk.service.msgcenter.event.ConnectedEvent; /** Abstract listener and manager for a key pair registration cycle. */ -abstract class RegisterKeyPairListener extends MessageCenterPacketListener implements PGPKeyPairRingProvider { - private BroadcastReceiver mConnReceiver; +public abstract class RegisterKeyPairListener extends MessageCenterPacketListener { protected PGPKeyPairRing mKeyRing; protected PGPPublicKey mRevoked; /** This is the passphrase that will be used for the new key. */ protected String mPassphrase; + private EventBus mServiceBus = MessageCenterService.bus(); + public RegisterKeyPairListener(MessageCenterService instance, String passphrase) { super(instance); mPassphrase = passphrase; } - @Override - public PGPKeyPairRing getKeyPair() { - return mKeyRing; - } - protected void configure() { SmackInitializer.initializeRegistration(); } @@ -82,14 +78,11 @@ public void run() throws CertificateException, SignatureException, PGPException, IOException, NoSuchProviderException { revokeCurrentKey(); configure(); - setupConnectedReceiver(); + registerConnectionEvents(); } public void abort() { - if (mConnReceiver != null) { - unregisterReceiver(mConnReceiver); - mConnReceiver = null; - } + mServiceBus.unregister(this); unconfigure(); } @@ -100,7 +93,7 @@ private Stanza prepareKeyPacket() { Registration iq = new Registration(); iq.setType(IQ.Type.set); - iq.setTo(getConnection().getServiceName()); + iq.setTo(getConnection().getXMPPServiceDomain()); Form form = new Form(DataForm.Type.submit); // form type: register#key @@ -138,49 +131,46 @@ private Stanza prepareKeyPacket() { return null; } - protected void setupConnectedReceiver() { - if (mConnReceiver == null) { - mConnReceiver = new BroadcastReceiver() { - public void onReceive(Context context, Intent intent) { - // unregister the broadcast receiver - unregisterReceiver(mConnReceiver); - mConnReceiver = null; + protected void registerConnectionEvents() { + if (!mServiceBus.isRegistered(this)) { + mServiceBus.register(this); + } + } - // prepare public key packet - Stanza iq = prepareKeyPacket(); + @Subscribe(sticky = true, threadMode = ThreadMode.BACKGROUND) + public void onConnected(ConnectedEvent event) { + // unregister the broadcast receiver + mServiceBus.unregister(this); - if (iq != null) { + // prepare public key packet + Stanza iq = prepareKeyPacket(); - // setup packet filter for response - StanzaFilter filter = new StanzaIdFilter(iq.getStanzaId()); - getConnection().addAsyncStanzaListener(RegisterKeyPairListener.this, filter); + if (iq != null) { - // send the key out - sendPacket(iq); + // setup packet filter for response + StanzaFilter filter = new StanzaIdFilter(iq.getStanzaId()); + getConnection().addAsyncStanzaListener(RegisterKeyPairListener.this, filter); - // now wait for a response - } + // send the key out + sendPacket(iq); - // TODO else? - } - }; - - IntentFilter filter = new IntentFilter(ACTION_CONNECTED); - registerReceiver(mConnReceiver, filter); + // now wait for a response } + + // TODO else? } /** We do this here so if something goes wrong the old key is still valid. */ protected void revokeCurrentKey() throws CertificateException, PGPException, IOException, SignatureException { - PersonalKey oldKey = getApplication().getPersonalKey(); + PersonalKey oldKey = Kontalk.get().getPersonalKey(); if (oldKey != null) mRevoked = oldKey.revoke(false); } @Override - public void processPacket(Stanza packet) { + public void processStanza(Stanza packet) { IQ iq = (IQ) packet; if (iq.getType() == IQ.Type.result) { DataForm response = iq.getExtension("x", "jabber:x:data"); @@ -191,7 +181,7 @@ public void processPacket(Stanza packet) { List fields = response.getFields(); for (FormField field : fields) { if ("publickey".equals(field.getVariable())) { - publicKey = field.getValues().get(0); + publicKey = field.getFirstValue(); break; } } @@ -204,9 +194,8 @@ public void processPacket(Stanza packet) { publicKeyData = Base64.decode(publicKey, Base64.DEFAULT); privateKeyData = mKeyRing.secretKey.getEncoded(); - // TODO subjectAltName? bridgeCertData = X509Bridge.createCertificate(publicKeyData, - mKeyRing.secretKey.getSecretKey(), mPassphrase, null).getEncoded(); + mKeyRing.secretKey.getSecretKey(), mPassphrase).getEncoded(); } catch (Exception e) { Log.e(MessageCenterService.TAG, "error decoding key data", e); @@ -222,13 +211,13 @@ public void processPacket(Stanza packet) { publicKeyData, privateKeyData, bridgeCertData, mPassphrase); // invalidate cached personal key - getApplication().invalidatePersonalKey(); + Kontalk.get().invalidatePersonalKey(); unconfigure(); finish(); // restart message center - MessageCenterService.restart(getApplication()); + MessageCenterService.restart(Kontalk.get()); } // TODO else? diff --git a/app/src/main/java/org/kontalk/service/msgcenter/RosterListener.java b/app/src/main/java/org/kontalk/service/msgcenter/RosterListener.java new file mode 100644 index 000000000..d447ed312 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/RosterListener.java @@ -0,0 +1,127 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + +import java.lang.ref.WeakReference; +import java.util.Collection; + +import org.jivesoftware.smack.packet.Presence; +import org.jivesoftware.smack.roster.Roster; +import org.jivesoftware.smack.roster.RosterEntry; +import org.jivesoftware.smack.roster.RosterLoadedListener; +import org.jxmpp.jid.Jid; + +import android.os.Handler; + +import org.kontalk.Log; +import org.kontalk.data.Contact; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.MyUsers; +import org.kontalk.service.msgcenter.event.RosterLoadedEvent; +import org.kontalk.service.msgcenter.event.UserSubscribedEvent; + + +/** + * The roster listener. + * @author Daniele Ricci + */ +public class RosterListener implements RosterLoadedListener, org.jivesoftware.smack.roster.RosterListener { + private static final String TAG = RosterListener.class.getSimpleName(); + + private WeakReference mService; + + RosterListener(MessageCenterService service) { + mService = new WeakReference<>(service); + } + + @Override + public void onRosterLoaded(Roster roster) { + final MessageCenterService service = mService.get(); + if (service == null) + return; + + final Handler handler = service.mHandler; + if (handler != null) { + handler.post(new Runnable() { + @Override + public void run() { + // roster has been loaded + MessageCenterService.bus().postSticky(new RosterLoadedEvent()); + } + }); + } + } + + @Override + public void onRosterLoadingFailed(Exception exception) { + // ignored for know + Log.d(TAG, "error loading roster", exception); + } + + @Override + public void entriesAdded(Collection addresses) { + final MessageCenterService service = mService.get(); + for (Jid jid : addresses) { + if (Keyring.getPublicKey(service, jid.toString(), MyUsers.Keys.TRUST_UNKNOWN) == null) { + // autotrust the first key we have + // but set the trust level to ignored because we didn't really verify it + Keyring.setAutoTrustLevel(service, jid.toString(), MyUsers.Keys.TRUST_IGNORED); + } + } + } + + @Override + public void entriesUpdated(Collection addresses) { + final MessageCenterService service = mService.get(); + if (service == null) + return; + + // we got an updated roster entry + // check if it's a subscription "both" + for (Jid jid : addresses) { + RosterEntry e = service.getRosterEntry(jid.asBareJid()); + if (e != null && e.canSeeHisPresence()) { + userSubscribed(jid); + } + } + } + + @Override + public void entriesDeleted(Collection addresses) { + // TODO something to do here? + } + + @Override + public void presenceChanged(Presence presence) { + } + + private void userSubscribed(Jid jid) { + String from = jid.asBareJid().toString(); + + // invalidate cached contact + Contact.invalidate(from); + + // send an event + MessageCenterService.bus() + .post(new UserSubscribedEvent(jid)); + + // MessagesController will send any pending messages + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/RosterMatchListener.java b/app/src/main/java/org/kontalk/service/msgcenter/RosterMatchListener.java index c96c322ad..0e5c32ee9 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/RosterMatchListener.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/RosterMatchListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,47 +20,52 @@ import java.util.List; +import org.jivesoftware.smack.ExceptionCallback; +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.Stanza; - -import android.content.Intent; +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; import org.kontalk.client.RosterMatch; - -import static org.kontalk.service.msgcenter.MessageCenterService.ACTION_ROSTER_MATCH; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_FROM; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_JIDLIST; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_PACKET_ID; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_TO; -import static org.kontalk.service.msgcenter.MessageCenterService.EXTRA_TYPE; +import org.kontalk.service.msgcenter.event.RosterMatchEvent; /** * Packet listener for roster match iq stanzas. * @author Daniele Ricci */ -class RosterMatchListener extends MessageCenterPacketListener { +class RosterMatchListener implements StanzaListener, ExceptionCallback { + + private final IQ mRequest; - public RosterMatchListener(MessageCenterService instance) { - super(instance); + public RosterMatchListener(IQ request) { + mRequest = request; } @Override - public void processPacket(Stanza packet) { - if (packet.getError() == null) { - RosterMatch p = (RosterMatch) packet; - Intent i = new Intent(ACTION_ROSTER_MATCH); - i.putExtra(EXTRA_FROM, p.getFrom()); - i.putExtra(EXTRA_TO, p.getTo()); - i.putExtra(EXTRA_TYPE, p.getType().toString()); - i.putExtra(EXTRA_PACKET_ID, p.getStanzaId()); + public void processStanza(Stanza packet) { + RosterMatch p = (RosterMatch) packet; - List items = p.getItems(); - if (items != null) { - String[] list = new String[items.size()]; - i.putExtra(EXTRA_JIDLIST, items.toArray(list)); + List items = p.getItems(); + Jid[] list; + if (items != null) { + list = new Jid[items.size()]; + for (int i = 0; i < items.size(); i++) { + list[i] = JidCreate.fromOrThrowUnchecked(items.get(i)); } - - sendBroadcast(i); } + else { + list = new Jid[0]; + } + + MessageCenterService.bus() + .post(new RosterMatchEvent(list, packet.getStanzaId())); + } + + @Override + public void processException(Exception exception) { + MessageCenterService.bus() + .post(new RosterMatchEvent(exception, mRequest.getStanzaId())); } } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/SQLiteRosterStore.java b/app/src/main/java/org/kontalk/service/msgcenter/SQLiteRosterStore.java index 3dbe79b37..ad747db80 100644 --- a/app/src/main/java/org/kontalk/service/msgcenter/SQLiteRosterStore.java +++ b/app/src/main/java/org/kontalk/service/msgcenter/SQLiteRosterStore.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -26,8 +26,10 @@ import org.jivesoftware.smack.roster.packet.RosterPacket; import org.jivesoftware.smack.roster.rosterstore.RosterStore; +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; +import org.jxmpp.stringprep.XmppStringprepException; -import android.annotation.TargetApi; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; @@ -45,7 +47,7 @@ */ public class SQLiteRosterStore extends SQLiteOpenHelper implements RosterStore { - private static final int DATABASE_VERSION = 1; + private static final int DATABASE_VERSION = 3; private static final String DATABASE_NAME = "roster.db"; private static final String TABLE_ROSTER = "roster"; @@ -53,31 +55,38 @@ public class SQLiteRosterStore extends SQLiteOpenHelper implements RosterStore { "jid TEXT NOT NULL PRIMARY KEY," + "name TEXT NOT NULL," + "type TEXT NOT NULL," + - "status TEXT,"+ + "pending BOOLEAN NOT NULL,"+ + "approved BOOLEAN NOT NULL,"+ "groups TEXT"+ ")"; private static final String SCHEMA_ROSTER = "CREATE TABLE " + TABLE_ROSTER + " " + CREATE_TABLE_ROSTER; - private final Context mContext; + private static final String[] SCHEMA_UPGRADE = { + "DROP TABLE roster", + SCHEMA_ROSTER, + }; private SQLiteStatement mInsertStatement; private final Object mInsertLock = new Object(); public SQLiteRosterStore(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); - mContext = context; } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(SCHEMA_ROSTER); + setRosterVersion(""); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - // this is the first version + for (String sql : SCHEMA_UPGRADE) { + db.execSQL(sql); + } + setRosterVersion(""); } public void onDestroy() { @@ -86,26 +95,21 @@ public void onDestroy() { private SQLiteStatement prepareInsert(SQLiteDatabase db, RosterPacket.Item item) { if (mInsertStatement == null) { - mInsertStatement = db.compileStatement("INSERT INTO " + TABLE_ROSTER + - " VALUES(?, ?, ?, ?, ?)"); + mInsertStatement = db.compileStatement("INSERT OR REPLACE INTO " + TABLE_ROSTER + + " VALUES(?, ?, ?, ?, ?, ?)"); } else { mInsertStatement.clearBindings(); } int i = 0; - mInsertStatement.bindString(++i, item.getUser()); + mInsertStatement.bindString(++i, item.getJid().toString()); mInsertStatement.bindString(++i, item.getName()); mInsertStatement.bindString(++i, item.getItemType() != null ? item.getItemType().toString() : RosterPacket.ItemType.none.toString()); + mInsertStatement.bindLong(++i, item.isSubscriptionPending() ? 1 : 0); + mInsertStatement.bindLong(++i, item.isApproved() ? 1 : 0); - RosterPacket.ItemStatus status = item.getItemStatus(); - if (status != null) { - mInsertStatement.bindString(++i, status.toString()); - } - else { - mInsertStatement.bindNull(++i); - } Set groups = item.getGroupNames(); if (groups != null) { @@ -119,7 +123,7 @@ private SQLiteStatement prepareInsert(SQLiteDatabase db, RosterPacket.Item item) } @Override - public Collection getEntries() { + public List getEntries() { SQLiteDatabase db = getReadableDatabase(); Cursor c = null; try { @@ -138,6 +142,9 @@ public Collection getEntries() { catch (SQLiteException e) { return null; } + catch (XmppStringprepException e) { + return null; + } finally { if (c != null) { c.close(); @@ -145,20 +152,22 @@ public Collection getEntries() { } } - private RosterPacket.Item fromCursor(Cursor c) { + private RosterPacket.Item fromCursor(Cursor c) throws XmppStringprepException { String user = c.getString(0); String name = c.getString(1); - RosterPacket.Item item = new RosterPacket.Item(user, name); + RosterPacket.Item item = new RosterPacket.Item(JidCreate.bareFrom(user), name); String type = c.getString(2); if (type == null) type = RosterPacket.ItemType.none.toString(); item.setItemType(RosterPacket.ItemType.valueOf(type)); - String status = c.getString(3); - if (status != null) - item.setItemStatus(RosterPacket.ItemStatus.fromString(status)); + boolean pending = c.getInt(3) != 0; + item.setSubscriptionPending(pending); - String groups = c.getString(4); + boolean approved = c.getInt(4) != 0; + item.setApproved(approved); + + String groups = c.getString(5); if (groups != null) { StringTokenizer tokenizer = new StringTokenizer(groups, ","); while (tokenizer.hasMoreTokens()) { @@ -170,12 +179,12 @@ private RosterPacket.Item fromCursor(Cursor c) { } @Override - public RosterPacket.Item getEntry(String bareJid) { + public RosterPacket.Item getEntry(Jid bareJid) { SQLiteDatabase db = getReadableDatabase(); Cursor c = null; try { c = db.query(TABLE_ROSTER, null, - "user = ?", new String[] { bareJid }, + "jid = ?", new String[] { bareJid.toString() }, null, null, null); if (c != null && c.moveToFirst()) { return fromCursor(c); @@ -184,6 +193,9 @@ public RosterPacket.Item getEntry(String bareJid) { catch (SQLiteException e) { return null; } + catch (XmppStringprepException e) { + return null; + } finally { if (c != null) { c.close(); @@ -194,7 +206,9 @@ public RosterPacket.Item getEntry(String bareJid) { @Override public String getRosterVersion() { - return Preferences.getRosterVersion(mContext); + // this will ensure a correct roster version string + getWritableDatabase(); + return Preferences.getRosterVersion(); } private boolean addEntry(SQLiteDatabase db, RosterPacket.Item item) { @@ -218,12 +232,30 @@ public boolean addEntry(RosterPacket.Item item, String version) { return addEntry(db, item) && setRosterVersion(version); } + @Override + public void resetStore() { + SQLiteDatabase db = getWritableDatabase(); + + db.beginTransactionNonExclusive(); + + try { + db.execSQL("DELETE FROM " + TABLE_ROSTER); + + db.setTransactionSuccessful(); + setRosterVersion(""); + } + catch (SQLiteException ignored) { + } + finally { + db.endTransaction(); + } + } + @Override public boolean resetEntries(Collection items, String version) { SQLiteDatabase db = getWritableDatabase(); - beginTransaction(db); - boolean success = false; + db.beginTransactionNonExclusive(); try { db.execSQL("DELETE FROM " + TABLE_ROSTER); @@ -231,26 +263,24 @@ public boolean resetEntries(Collection items, String version) addEntry(db, item); } - success = setTransactionSuccessful(db); - if (success) { - setRosterVersion(version); - } + db.setTransactionSuccessful(); + setRosterVersion(version); } catch (SQLiteException e) { return false; } finally { - endTransaction(db, success); + db.endTransaction(); } return false; } @Override - public boolean removeEntry(String bareJid, String version) { + public boolean removeEntry(Jid bareJid, String version) { SQLiteDatabase db = getWritableDatabase(); try { - db.delete(TABLE_ROSTER, "user = ?", new String[]{bareJid}); + db.delete(TABLE_ROSTER, "jid = ?", new String[]{bareJid.toString()}); return setRosterVersion(version); } catch (SQLiteException e) { @@ -262,28 +292,10 @@ private boolean setRosterVersion(String version) { return Preferences.setRosterVersion(version); } - /* Transactions compatibility layer */ - - @TargetApi(android.os.Build.VERSION_CODES.HONEYCOMB) - private void beginTransaction(SQLiteDatabase db) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB) - db.beginTransactionNonExclusive(); - else - // this is because API < 11 doesn't have beginTransactionNonExclusive() - db.execSQL("BEGIN IMMEDIATE"); - } - - private boolean setTransactionSuccessful(SQLiteDatabase db) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB) - db.setTransactionSuccessful(); - return true; - } - - private void endTransaction(SQLiteDatabase db, boolean success) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB) - db.endTransaction(); - else - db.execSQL(success ? "COMMIT" : "ROLLBACK"); + public static void purge(Context context) { + SQLiteRosterStore store = new SQLiteRosterStore(context); + store.resetStore(); + store.close(); } } diff --git a/app/src/main/java/org/kontalk/service/msgcenter/StartMessageCenterJob.java b/app/src/main/java/org/kontalk/service/msgcenter/StartMessageCenterJob.java new file mode 100644 index 000000000..807d87330 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/StartMessageCenterJob.java @@ -0,0 +1,62 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + +import android.annotation.TargetApi; +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; +import android.content.Context; +import android.os.Build; + + +/** + * Job service for starting the message center when needed. + * @author Daniele Ricci + */ +@TargetApi(Build.VERSION_CODES.LOLLIPOP) +public class StartMessageCenterJob extends JobService { + + private static int JOB_ID = 1000; + + @Override + public boolean onStartJob(JobParameters params) { + MessageCenterService.start(this); + return false; + } + + @Override + public boolean onStopJob(JobParameters params) { + return false; + } + + public static void schedule(Context context) { + JobScheduler scheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); + JobInfo.Builder builder = new JobInfo + .Builder(JOB_ID, new ComponentName(context, StartMessageCenterJob.class)) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + builder.setImportantWhileForeground(true); + } + + scheduler.schedule(builder.build()); + } +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/UploadDiscoverItemsListener.java b/app/src/main/java/org/kontalk/service/msgcenter/UploadDiscoverItemsListener.java deleted file mode 100644 index f85209f11..000000000 --- a/app/src/main/java/org/kontalk/service/msgcenter/UploadDiscoverItemsListener.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.service.msgcenter; - -import java.util.List; - -import org.jivesoftware.smack.XMPPConnection; -import org.jivesoftware.smack.filter.StanzaIdFilter; -import org.jivesoftware.smack.packet.IQ; -import org.jivesoftware.smack.packet.Stanza; -import org.jivesoftware.smackx.disco.packet.DiscoverItems; -import org.jxmpp.util.XmppStringUtils; - -import org.kontalk.client.EndpointServer; -import org.kontalk.client.UploadInfo; - - -/** - * Packet listener for requesting upload services information. - * @author Daniele Ricci - */ -class UploadDiscoverItemsListener extends MessageCenterPacketListener { - - public UploadDiscoverItemsListener(MessageCenterService instance) { - super(instance); - } - - @Override - public void processPacket(Stanza packet) { - XMPPConnection conn = getConnection(); - EndpointServer server = getServer(); - - // we don't need this listener anymore - conn.removeAsyncStanzaListener(this); - - initUploadServices(); - - // store available services - DiscoverItems query = (DiscoverItems) packet; - List items = query.getItems(); - for (DiscoverItems.Item item : items) { - String jid = item.getEntityID(); - if (jid != null && server.getNetwork().equals(XmppStringUtils.parseDomain(jid))) { - setUploadService(item.getNode(), null); - - // request upload url - UploadInfo iq = new UploadInfo(item.getNode()); - iq.setType(IQ.Type.get); - iq.setTo("upload@" + server.getNetwork()); - - conn.addAsyncStanzaListener(new UploadInfoListener(getInstance()), new StanzaIdFilter(iq.getStanzaId())); - sendPacket(iq); - } - } - } -} - diff --git a/app/src/main/java/org/kontalk/service/msgcenter/UploadInfoListener.java b/app/src/main/java/org/kontalk/service/msgcenter/UploadInfoListener.java deleted file mode 100644 index a481f96e6..000000000 --- a/app/src/main/java/org/kontalk/service/msgcenter/UploadInfoListener.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.service.msgcenter; - -import org.jivesoftware.smack.packet.Stanza; - -import org.kontalk.client.UploadInfo; - -import android.util.Log; - - -/** - * Packet listener for upload info responses. - * @author Daniele Ricci - */ -class UploadInfoListener extends MessageCenterPacketListener { - - public UploadInfoListener(MessageCenterService instance) { - super(instance); - } - - @Override - public void processPacket(Stanza packet) { - // we don't need this listener anymore - getConnection().removeAsyncStanzaListener(this); - - UploadInfo info = (UploadInfo) packet; - String node = info.getNode(); - setUploadService(node, info.getUri()); - Log.v(MessageCenterService.TAG, "upload info received, node = " + - node + ", uri = " + info.getUri()); - - // resend pending messages - resendPendingMessages(true); - } -} - diff --git a/app/src/main/java/org/kontalk/service/msgcenter/VersionListener.java b/app/src/main/java/org/kontalk/service/msgcenter/VersionListener.java new file mode 100644 index 000000000..d8fe70b98 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/VersionListener.java @@ -0,0 +1,41 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smackx.iqversion.packet.Version; + +import org.kontalk.service.msgcenter.event.VersionEvent; + + +/** + * Packet listener for version information stanzas. + * @author Daniele Ricci + */ +class VersionListener implements StanzaListener { + + @Override + public void processStanza(Stanza packet) { + Version p = (Version) packet; + MessageCenterService.bus() + .post(new VersionEvent(p.getFrom(), p.getName(), p.getVersion(), p.getStanzaId())); + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/WakefulMessageCenterPacketListener.java b/app/src/main/java/org/kontalk/service/msgcenter/WakefulMessageCenterPacketListener.java new file mode 100644 index 000000000..35d33c5b9 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/WakefulMessageCenterPacketListener.java @@ -0,0 +1,59 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter; + +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.packet.Stanza; + +import android.annotation.SuppressLint; +import android.os.PowerManager; + +import org.kontalk.Kontalk; +import org.kontalk.util.SystemUtils; + + +/** + * Wakeful packet listener for the Message Center. + * @author Daniele Ricci + */ +abstract class WakefulMessageCenterPacketListener extends MessageCenterPacketListener { + + private PowerManager.WakeLock mWakeLock; + + WakefulMessageCenterPacketListener(MessageCenterService instance, String suffix) { + super(instance); + mWakeLock = SystemUtils.createPartialWakeLock(instance, + Kontalk.TAG + "-" + suffix, false); + } + + protected abstract void processWakefulStanza(Stanza packet) + throws SmackException.NotConnectedException, InterruptedException, SmackException.NotLoggedInException; + + @SuppressLint("WakelockTimeout") + @Override + public void processStanza(Stanza packet) throws SmackException.NotConnectedException, InterruptedException, SmackException.NotLoggedInException { + mWakeLock.acquire(); + try { + processWakefulStanza(packet); + } + finally { + mWakeLock.release(); + } + } +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/BlocklistEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/BlocklistEvent.java new file mode 100644 index 000000000..cf319e867 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/BlocklistEvent.java @@ -0,0 +1,42 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Blocklist event, in reply to {@link BlocklistRequest} + * @author Daniele Ricci + */ +public class BlocklistEvent extends ResponseEvent { + + public final Jid[] jids; + + public BlocklistEvent(Jid[] jids, String id) { + super(id); + this.jids = jids; + } + + public BlocklistEvent(Exception error, String id) { + super(id, error); + this.jids = null; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/BlocklistRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/BlocklistRequest.java new file mode 100644 index 000000000..f041b6f6f --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/BlocklistRequest.java @@ -0,0 +1,32 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Blocklist request event. + * @author Daniele Ricci + */ +public class BlocklistRequest extends RequestEvent { + + public BlocklistRequest(String id) { + super(id); + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/ChatStateEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/ChatStateEvent.java new file mode 100644 index 000000000..2f165b957 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/ChatStateEvent.java @@ -0,0 +1,42 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jivesoftware.smackx.chatstates.ChatState; +import org.jxmpp.jid.Jid; + + +/** + * Chat state event. + * @author Daniele Ricci + */ +public class ChatStateEvent { + + public final Jid from; + public final Jid group; + + public final ChatState chatState; + + public ChatStateEvent(Jid from, Jid group, ChatState chatState) { + this.from = from; + this.group = group; + this.chatState = chatState; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/ConnectedEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/ConnectedEvent.java new file mode 100644 index 000000000..88f1bfb40 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/ConnectedEvent.java @@ -0,0 +1,28 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Connection event. + * It should be posted as a sticky event for the duration of the connection. + * @author Daniele Ricci + */ +public class ConnectedEvent { +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/DisconnectedEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/DisconnectedEvent.java new file mode 100644 index 000000000..a804b4372 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/DisconnectedEvent.java @@ -0,0 +1,27 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Disconnection event. + * @author Daniele Ricci + */ +public class DisconnectedEvent { +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/GroupCreatedEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/GroupCreatedEvent.java new file mode 100644 index 000000000..49aa8b44c --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/GroupCreatedEvent.java @@ -0,0 +1,28 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Group created event. + * Posted when group creation has been confirmed by the server. + * @author Daniele Ricci + */ +public class GroupCreatedEvent { +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/LastActivityEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/LastActivityEvent.java new file mode 100644 index 000000000..a66b9cfcd --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/LastActivityEvent.java @@ -0,0 +1,45 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Last activity event, in reply to {@link LastActivityRequest} + * @author Daniele Ricci + */ +public class LastActivityEvent extends ResponseEvent { + + public final Jid jid; + public final long idleTime; + + public LastActivityEvent(Jid jid, long idleTime, String id) { + super(id); + this.jid = jid; + this.idleTime = idleTime; + } + + public LastActivityEvent(Exception error, Jid jid, String id) { + super(id, error); + this.jid = jid; + this.idleTime = -1; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/LastActivityRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/LastActivityRequest.java new file mode 100644 index 000000000..cc9c00970 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/LastActivityRequest.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Last activity request event. + * @author Daniele Ricci + */ +public class LastActivityRequest extends RequestEvent { + + public final Jid jid; + + public LastActivityRequest(String id, Jid jid) { + super(id); + this.jid = jid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/NoPresenceEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/NoPresenceEvent.java new file mode 100644 index 000000000..bf8412ec7 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/NoPresenceEvent.java @@ -0,0 +1,36 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * No presence event - it usually means we don't have a subscription to the user. + * @author Daniele Ricci + */ +public class NoPresenceEvent extends PresenceEvent { + + public NoPresenceEvent(Jid jid, String id) { + super(jid, null, null, 0, + null, null, null, false, false, + null, id); + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/PreapproveSubscriptionRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/PreapproveSubscriptionRequest.java new file mode 100644 index 000000000..3b9e2df76 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/PreapproveSubscriptionRequest.java @@ -0,0 +1,36 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Pre-approve subscription to a user request event. + * @author Daniele Ricci + */ +public class PreapproveSubscriptionRequest { + + public final Jid jid; + + public PreapproveSubscriptionRequest(Jid jid) { + this.jid = jid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/PresenceEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/PresenceEvent.java new file mode 100644 index 000000000..a47e99c55 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/PresenceEvent.java @@ -0,0 +1,66 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import java.util.Date; + +import org.jivesoftware.smack.packet.Presence; +import org.jxmpp.jid.Jid; + + +/** + * Presence event. + * @author Daniele Ricci + */ +public class PresenceEvent extends ResponseEvent { + + public final Jid jid; + + public final Presence.Type type; + public final Presence.Mode mode; + public final int priority; + public final String status; + + public final Date delay; + + public final String rosterName; + + public final boolean subscribedFrom; + public final boolean subscribedTo; + + public final String fingerprint; + + public PresenceEvent(Jid jid, Presence.Type type, Presence.Mode mode, int priority, + String status, Date delay, String rosterName, + boolean subscribedFrom, boolean subscribedTo, + String fingerprint, String id) { + super(id); + this.jid = jid; + this.type = type; + this.mode = mode; + this.priority = priority; + this.status = status; + this.delay = delay; + this.rosterName = rosterName; + this.subscribedFrom = subscribedFrom; + this.subscribedTo = subscribedTo; + this.fingerprint = fingerprint; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/PresenceRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/PresenceRequest.java new file mode 100644 index 000000000..2a3e04105 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/PresenceRequest.java @@ -0,0 +1,47 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; + + +/** + * Presence request event. + * @author Daniele Ricci + */ +public class PresenceRequest extends RequestEvent { + + public final Jid jid; + + public PresenceRequest(Jid jid) { + this(null, jid); + } + + public PresenceRequest(String jid) { + this(null, JidCreate.fromOrThrowUnchecked(jid)); + } + + /** Use null jid to request presence for the whole roster. */ + public PresenceRequest(String id, Jid jid) { + super(id); + this.jid = jid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/PrivateKeyUploadedEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/PrivateKeyUploadedEvent.java new file mode 100644 index 000000000..9ad88f287 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/PrivateKeyUploadedEvent.java @@ -0,0 +1,46 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jivesoftware.smack.packet.StanzaError; + + +/** + * Private key uploaded event, in reply to {@link UploadPrivateKeyRequest} + * @author Daniele Ricci + */ +public class PrivateKeyUploadedEvent { + + public final String token; + public final String server; + public final StanzaError.Condition error; + + public PrivateKeyUploadedEvent(String token, String server) { + this.token = token; + this.server = server; + this.error = null; + } + + public PrivateKeyUploadedEvent(StanzaError.Condition error) { + this.error = error; + this.token = null; + this.server = null; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/PublicKeyEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/PublicKeyEvent.java new file mode 100644 index 000000000..dae35b984 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/PublicKeyEvent.java @@ -0,0 +1,45 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Public key event, in reply to {@link PublicKeyRequest} + * @author Daniele Ricci + */ +public class PublicKeyEvent extends ResponseEvent { + + public final Jid jid; + public final byte[] publicKey; + + public PublicKeyEvent(Jid jid, byte[] publicKey, String id) { + super(id); + this.jid = jid; + this.publicKey = publicKey; + } + + public PublicKeyEvent(Exception error, Jid jid, String id) { + super(id, error); + this.jid = jid; + this.publicKey = null; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/PublicKeyRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/PublicKeyRequest.java new file mode 100644 index 000000000..ef07e11bc --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/PublicKeyRequest.java @@ -0,0 +1,43 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jivesoftware.smack.packet.id.StanzaIdUtil; +import org.jxmpp.jid.Jid; + + +/** + * Public key request event. + * @author Daniele Ricci + */ +public class PublicKeyRequest extends RequestEvent { + + public final Jid jid; + + public PublicKeyRequest(Jid jid) { + this(StanzaIdUtil.newStanzaId(), jid); + } + + /** Use null jid to request public keys for the whole roster. */ + public PublicKeyRequest(String id, Jid jid) { + super(id); + this.jid = jid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/RequestEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/RequestEvent.java new file mode 100644 index 000000000..cb044f913 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/RequestEvent.java @@ -0,0 +1,35 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Base event for requests. + * Includes an ID to keep track of request-response pairs. + * @author Daniele Ricci + */ +public abstract class RequestEvent { + + public final String id; + + protected RequestEvent(String id) { + this.id = id; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/ResponseEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/ResponseEvent.java new file mode 100644 index 000000000..fdf24e088 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/ResponseEvent.java @@ -0,0 +1,53 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.packet.StanzaError; + +/** + * Base event for responses. + * Includes an ID to keep track of request-response pairs. + * @author Daniele Ricci + */ +public abstract class ResponseEvent { + + public final String id; + public final Exception error; + + protected ResponseEvent(String id) { + this(id, null); + } + + protected ResponseEvent(String id, Exception error) { + this.id = id; + this.error = error; + } + + public StanzaError.Condition getStanzaErrorCondition() { + if (error instanceof XMPPException.XMPPErrorException && + ((XMPPException.XMPPErrorException) error).getStanzaError() != null) { + return ((XMPPException.XMPPErrorException) error).getStanzaError() + .getCondition(); + } + return null; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/RosterLoadedEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/RosterLoadedEvent.java new file mode 100644 index 000000000..74be1793f --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/RosterLoadedEvent.java @@ -0,0 +1,28 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Roster loaded event. + * It should be posted as a sticky event for the duration of the connection. + * @author Daniele Ricci + */ +public class RosterLoadedEvent { +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/RosterMatchEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/RosterMatchEvent.java new file mode 100644 index 000000000..befbe4db5 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/RosterMatchEvent.java @@ -0,0 +1,42 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Roster match event, in reply to {@link RosterMatchRequest} + * @author Daniele Ricci + */ +public class RosterMatchEvent extends ResponseEvent { + + public final Jid[] jids; + + public RosterMatchEvent(Jid[] jids, String id) { + super(id); + this.jids = jids; + } + + public RosterMatchEvent(Exception error, String id) { + super(id, error); + this.jids = null; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/RosterMatchRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/RosterMatchRequest.java new file mode 100644 index 000000000..01f5b49db --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/RosterMatchRequest.java @@ -0,0 +1,39 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Roster match request event. + * @author Daniele Ricci + */ +public class RosterMatchRequest extends RequestEvent { + + public final String[] userIds; + + public RosterMatchRequest(String[] userIds) { + this(null, userIds); + } + + public RosterMatchRequest(String id, String[] userIds) { + super(id); + this.userIds = userIds; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/RosterStatusEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/RosterStatusEvent.java new file mode 100644 index 000000000..95737f11a --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/RosterStatusEvent.java @@ -0,0 +1,47 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Roster status event, in reply to {@link RosterStatusRequest} + * @author Daniele Ricci + */ +public class RosterStatusEvent extends ResponseEvent { + + public final Jid jid; + + public final String rosterName; + + public final boolean subscribedFrom; + public final boolean subscribedTo; + + public RosterStatusEvent(Jid jid, String rosterName, + boolean subscribedFrom, boolean subscribedTo, + String id) { + super(id); + this.jid = jid; + this.rosterName = rosterName; + this.subscribedFrom = subscribedFrom; + this.subscribedTo = subscribedTo; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/RosterStatusRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/RosterStatusRequest.java new file mode 100644 index 000000000..78a6c3a89 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/RosterStatusRequest.java @@ -0,0 +1,46 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; + + +/** + * Roster status request event. + * @author Daniele Ricci + */ +public class RosterStatusRequest extends RequestEvent { + + public final Jid jid; + + public RosterStatusRequest(Jid jid) { + this(null, jid); + } + + public RosterStatusRequest(String jid) { + this(null, JidCreate.fromOrThrowUnchecked(jid)); + } + + public RosterStatusRequest(String id, Jid jid) { + super(id); + this.jid = jid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/SendChatStateRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/SendChatStateRequest.java new file mode 100644 index 000000000..89f5e3903 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/SendChatStateRequest.java @@ -0,0 +1,76 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jivesoftware.smackx.chatstates.ChatState; +import org.jxmpp.jid.Jid; + + +/** + * For requesting the message center to send a chat state notification message. + * @author Daniele Ricci + */ +public class SendChatStateRequest extends RequestEvent { + + public final Jid to; + public final boolean group; + + public final ChatState chatState; + // TODO boolean encrypt + + public SendChatStateRequest(String id, Jid to, boolean group, + ChatState chatState) { + super(id); + this.to = to; + this.group = group; + this.chatState = chatState; + } + + public static final class Builder { + private final String id; + private Jid to; + private boolean group; + + private ChatState chatState; + + public Builder(String id) { + this.id = id; + } + + public Builder setTo(Jid jid) { + this.to = jid; + return this; + } + + public Builder setGroup(boolean group) { + this.group = group; + return this; + } + + public Builder setChatState(ChatState chatState) { + this.chatState = chatState; + return this; + } + + public SendChatStateRequest build() { + return new SendChatStateRequest(id, to, group, chatState); + } + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/SendDeliveryReceiptRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/SendDeliveryReceiptRequest.java new file mode 100644 index 000000000..efab53848 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/SendDeliveryReceiptRequest.java @@ -0,0 +1,32 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * For requesting the message center to send a delivery receipt. + * @author Daniele Ricci + */ +public class SendDeliveryReceiptRequest extends SendMessageRequest { + + public SendDeliveryReceiptRequest(long databaseId) { + super(databaseId); + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/SendMessageRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/SendMessageRequest.java new file mode 100644 index 000000000..96a03e8b1 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/SendMessageRequest.java @@ -0,0 +1,34 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * For requesting the message center to send a message from the outbox. + * @author Daniele Ricci + */ +public class SendMessageRequest { + + public final long databaseId; + + public SendMessageRequest(long databaseId) { + this.databaseId = databaseId; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/ServerListEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/ServerListEvent.java new file mode 100644 index 000000000..e38f0687b --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/ServerListEvent.java @@ -0,0 +1,40 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Server list event, in reply to {@link ServerListRequest} + * @author Daniele Ricci + */ +public class ServerListEvent extends ResponseEvent { + + public final String[] servers; + + public ServerListEvent(String[] servers, String id) { + super(id); + this.servers = servers; + } + + public ServerListEvent(Exception error, String id) { + super(id, error); + this.servers = null; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/ServerListRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/ServerListRequest.java new file mode 100644 index 000000000..0685fd72a --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/ServerListRequest.java @@ -0,0 +1,32 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Server list request event. + * @author Daniele Ricci + */ +public class ServerListRequest extends RequestEvent { + + public ServerListRequest(String id) { + super(id); + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/SetUserPrivacyRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/SetUserPrivacyRequest.java new file mode 100644 index 000000000..3d16a19b3 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/SetUserPrivacyRequest.java @@ -0,0 +1,41 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.BareJid; + +import org.kontalk.service.msgcenter.PrivacyCommand; + + +/** + * Set user privacy request event. + * Used to accept a presence subscription as well as block/unblock someone. + * @author Daniele Ricci + */ +public class SetUserPrivacyRequest { + + public final BareJid jid; + public final PrivacyCommand command; + + public SetUserPrivacyRequest(BareJid jid, PrivacyCommand command) { + this.jid = jid; + this.command = command; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/SubscribeRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/SubscribeRequest.java new file mode 100644 index 000000000..7c3cff4fa --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/SubscribeRequest.java @@ -0,0 +1,36 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Subscribe to a user request event. + * @author Daniele Ricci + */ +public class SubscribeRequest { + + public final Jid jid; + + public SubscribeRequest(Jid jid) { + this.jid = jid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/UnsubscribeRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/UnsubscribeRequest.java new file mode 100644 index 000000000..1f46b3c5b --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/UnsubscribeRequest.java @@ -0,0 +1,36 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Unsubscribe from a user request event. + * @author Daniele Ricci + */ +public class UnsubscribeRequest { + + public final Jid jid; + + public UnsubscribeRequest(Jid jid) { + this.jid = jid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/UpdateStatusRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/UpdateStatusRequest.java new file mode 100644 index 000000000..11935ddaa --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/UpdateStatusRequest.java @@ -0,0 +1,34 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Update my status request event. + * @author Daniele Ricci + */ +public class UpdateStatusRequest { + + public final String status; + + public UpdateStatusRequest(String status) { + this.status = status; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/UploadAttachmentRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/UploadAttachmentRequest.java new file mode 100644 index 000000000..ef35dfd55 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/UploadAttachmentRequest.java @@ -0,0 +1,34 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * For requesting the message center to upload an attachment from the outbox. + * @author Daniele Ricci + */ +public class UploadAttachmentRequest { + + public final long databaseId; + + public UploadAttachmentRequest(long databaseId) { + this.databaseId = databaseId; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/UploadPrivateKeyRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/UploadPrivateKeyRequest.java new file mode 100644 index 000000000..f7d2fe11c --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/UploadPrivateKeyRequest.java @@ -0,0 +1,35 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Upload private key request event. + * Used to upload your private key to the server for registering another device. + * @author Daniele Ricci + */ +public class UploadPrivateKeyRequest { + + public final String passphrase; + + public UploadPrivateKeyRequest(String passphrase) { + this.passphrase = passphrase; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/UploadServiceFoundEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/UploadServiceFoundEvent.java new file mode 100644 index 000000000..cff83d45b --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/UploadServiceFoundEvent.java @@ -0,0 +1,28 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + + +/** + * Upload service found event. + * Posted when an upload service has been discovered. + * @author Daniele Ricci + */ +public class UploadServiceFoundEvent { +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/UserBlockedEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/UserBlockedEvent.java new file mode 100644 index 000000000..51b92dc46 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/UserBlockedEvent.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.BareJid; + + +/** + * User was blocked event. + * Posted when a blocking command has been accepted by the server. + * @author Daniele Ricci + */ +public class UserBlockedEvent { + + public final BareJid jid; + + public UserBlockedEvent(BareJid jid) { + this.jid = jid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/UserOfflineEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/UserOfflineEvent.java new file mode 100644 index 000000000..09ca637c2 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/UserOfflineEvent.java @@ -0,0 +1,42 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import java.util.Date; + +import org.jivesoftware.smack.packet.Presence; +import org.jxmpp.jid.Jid; + + +/** + * Presence unavailable event. + * @author Daniele Ricci + */ +public class UserOfflineEvent extends PresenceEvent { + + public UserOfflineEvent(Jid jid, Presence.Mode mode, int priority, + String status, Date delay, String rosterName, + boolean subscribedFrom, boolean subscribedTo, + String fingerprint, String id) { + super(jid, Presence.Type.unavailable, mode, priority, + status, delay, rosterName, subscribedFrom, subscribedTo, + fingerprint, id); + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/UserOnlineEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/UserOnlineEvent.java new file mode 100644 index 000000000..6fb69afb7 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/UserOnlineEvent.java @@ -0,0 +1,42 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import java.util.Date; + +import org.jivesoftware.smack.packet.Presence; +import org.jxmpp.jid.Jid; + + +/** + * Presence available event. + * @author Daniele Ricci + */ +public class UserOnlineEvent extends PresenceEvent { + + public UserOnlineEvent(Jid jid, Presence.Mode mode, int priority, + String status, Date delay, String rosterName, + boolean subscribedFrom, boolean subscribedTo, + String fingerprint, String id) { + super(jid, Presence.Type.available, mode, priority, + status, delay, rosterName, subscribedFrom, subscribedTo, + fingerprint, id); + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/UserSubscribedEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/UserSubscribedEvent.java new file mode 100644 index 000000000..cf9be0260 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/UserSubscribedEvent.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Subscribed to user event. + * Posted when a presence subscription has been accepted. + * @author Daniele Ricci + */ +public class UserSubscribedEvent { + + public final Jid jid; + + public UserSubscribedEvent(Jid jid) { + this.jid = jid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/UserUnblockedEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/UserUnblockedEvent.java new file mode 100644 index 000000000..aeb6c8606 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/UserUnblockedEvent.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.BareJid; + + +/** + * User was unblocked event. + * Posted when an unblocking command has been accepted by the server. + * @author Daniele Ricci + */ +public class UserUnblockedEvent { + + public final BareJid jid; + + public UserUnblockedEvent(BareJid jid) { + this.jid = jid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/VersionEvent.java b/app/src/main/java/org/kontalk/service/msgcenter/event/VersionEvent.java new file mode 100644 index 000000000..1aae3ee21 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/VersionEvent.java @@ -0,0 +1,43 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Version event, in reply to {@link VersionRequest} + * @author Daniele Ricci + */ +public class VersionEvent extends ResponseEvent { + + public final Jid jid; + + public final String name; + public final String version; + + public VersionEvent(Jid jid, String name, String version, + String id) { + super(id); + this.jid = jid; + this.name = name; + this.version = version; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/event/VersionRequest.java b/app/src/main/java/org/kontalk/service/msgcenter/event/VersionRequest.java new file mode 100644 index 000000000..e830e7c4c --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/event/VersionRequest.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.event; + +import org.jxmpp.jid.Jid; + + +/** + * Version request event. + * @author Daniele Ricci + */ +public class VersionRequest extends RequestEvent { + + public final Jid jid; + + public VersionRequest(String id, Jid jid) { + super(id); + this.jid = jid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/AddRemoveMembersCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/AddRemoveMembersCommand.java new file mode 100644 index 000000000..6728c5f32 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/AddRemoveMembersCommand.java @@ -0,0 +1,38 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + +import org.jxmpp.jid.Jid; + + +public interface AddRemoveMembersCommand extends GroupCommand { + + void setSubject(String subject); + + String getSubject(); + + void setAddedMembers(Jid[] members); + + Jid[] getAddedMembers(); + + void setRemovedMembers(Jid[] members); + + Jid[] getRemovedMembers(); + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/CreateGroupCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/CreateGroupCommand.java new file mode 100644 index 000000000..c2424471d --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/CreateGroupCommand.java @@ -0,0 +1,28 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + + +public interface CreateGroupCommand extends GroupCommand { + + void setSubject(String subject); + + String getSubject(); + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/GroupCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/GroupCommand.java new file mode 100644 index 000000000..9ec9a088f --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/GroupCommand.java @@ -0,0 +1,38 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + +import org.jxmpp.jid.Jid; + + +/** + * Group command used by the {@link GroupController}. + * @author Daniele Ricci + */ +public interface GroupCommand { + + void setGroupJid(Jid groupJid); + + Jid getGroupJid(); + + void setMembers(Jid[] members); + + Jid[] getMembers(); + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/GroupController.java b/app/src/main/java/org/kontalk/service/msgcenter/group/GroupController.java new file mode 100644 index 000000000..089af095e --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/GroupController.java @@ -0,0 +1,63 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + +import org.jivesoftware.smack.packet.Stanza; + + +/** + * Interface for group protocol controllers. + * @author Daniele Ricci + */ +public interface GroupController { + + /** Returns the group type. */ + String getGroupType(); + + /** Returns a new stanza with group command information before encrypting it. */ + T beforeEncryption(GroupCommand command, Stanza packet); + + /** Returns a new stanza with group command information after it's been encrypted. */ + T afterEncryption(GroupCommand command, Stanza packet, Stanza original); + + /** Returns a new create group command. */ + CreateGroupCommand createGroup(); + + /** Returns a new set group subject command. */ + SetSubjectCommand setSubject(); + + /** Returns a new leave group command. */ + PartCommand part(); + + /** Returns a new add/remove command members command. */ + AddRemoveMembersCommand addRemoveMembers(); + + /** Returns a new group info command. */ + InfoCommand info(); + + /** Returns true if this group controller should allow sending a command to an empty group. */ + boolean canSendCommandsWithEmptyGroup(); + + /** + * Returns true if this group controller should allow sending messages to + * the group even if not all users have allowed subscription. + */ + boolean canSendWithNoSubscription(); + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/GroupControllerFactory.java b/app/src/main/java/org/kontalk/service/msgcenter/group/GroupControllerFactory.java new file mode 100644 index 000000000..19a6c9bb2 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/GroupControllerFactory.java @@ -0,0 +1,72 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + +import java.util.HashMap; +import java.util.Map; + +import org.jivesoftware.smack.XMPPConnection; + +import org.kontalk.service.msgcenter.MessageCenterService; + +/** + * Factory for creating {@link GroupController}s. + * @author Daniele Ricci + */ +public class GroupControllerFactory { + + private static Map CHECK_INSTANCES; + + private GroupControllerFactory() { + } + + private static GroupController getControllerInstance(String groupType) { + GroupController instance = null; + if (CHECK_INSTANCES == null) { + CHECK_INSTANCES = new HashMap<>(); + } + else { + instance = CHECK_INSTANCES.get(groupType); + } + if (instance == null) { + instance = createController(groupType); + CHECK_INSTANCES.put(groupType, instance); + } + return instance; + } + + private static GroupController createController(String groupType) { + return createController(groupType, null, null); + } + + public static GroupController createController(String groupType, XMPPConnection connection, MessageCenterService instance) { + // do not use reflection EVER :) + + if (groupType.equals(KontalkGroupController.GROUP_TYPE)) { + return new KontalkGroupController(connection, instance); + } + + throw new IllegalArgumentException("Unsupported group type: " + groupType); + } + + public static boolean canSendCommandsWithEmptyGroup(String groupType) { + return getControllerInstance(groupType).canSendCommandsWithEmptyGroup(); + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/InfoCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/InfoCommand.java new file mode 100644 index 000000000..8b0c57013 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/InfoCommand.java @@ -0,0 +1,24 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + + +public interface InfoCommand extends GroupCommand { + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkAddRemoveMembersCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkAddRemoveMembersCommand.java new file mode 100644 index 000000000..ec8f3a040 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkAddRemoveMembersCommand.java @@ -0,0 +1,61 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + + +import org.jxmpp.jid.Jid; + +public class KontalkAddRemoveMembersCommand extends KontalkGroupCommand + implements AddRemoveMembersCommand { + + private String mSubject; + private Jid[] mAdded; + private Jid[] mRemoved; + + @Override + public void setSubject(String subject) { + mSubject = subject; + } + + @Override + public String getSubject() { + return mSubject; + } + + @Override + public void setAddedMembers(Jid[] members) { + mAdded = members; + } + + @Override + public Jid[] getAddedMembers() { + return mAdded; + } + + @Override + public void setRemovedMembers(Jid[] members) { + mRemoved = members; + } + + @Override + public Jid[] getRemovedMembers() { + return mRemoved; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkCreateGroupCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkCreateGroupCommand.java new file mode 100644 index 000000000..635612066 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkCreateGroupCommand.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + + +public class KontalkCreateGroupCommand extends KontalkGroupCommand + implements CreateGroupCommand { + + private String mSubject; + + @Override + public void setSubject(String subject) { + mSubject = subject; + } + + @Override + public String getSubject() { + return mSubject; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkGroupCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkGroupCommand.java new file mode 100644 index 000000000..3dd9862bb --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkGroupCommand.java @@ -0,0 +1,47 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + +import org.jxmpp.jid.Jid; + + +public abstract class KontalkGroupCommand implements GroupCommand { + + private Jid mGroupJid; + private Jid[] mMembers; + + public void setMembers(Jid[] members) { + mMembers = members; + } + + public Jid[] getMembers() { + return mMembers; + } + + @Override + public void setGroupJid(Jid groupJid) { + mGroupJid = groupJid; + } + + @Override + public Jid getGroupJid() { + return mGroupJid; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkGroupController.java b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkGroupController.java new file mode 100644 index 000000000..5fd1eb63c --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkGroupController.java @@ -0,0 +1,227 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + +import java.util.HashSet; +import java.util.Set; + +import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.sm.StreamManagementException; +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; +import org.jxmpp.stringprep.XmppStringprepException; + +import android.net.Uri; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.client.GroupExtension; +import org.kontalk.client.KontalkGroupManager; +import org.kontalk.client.smack.XMPPTCPConnection; +import org.kontalk.provider.MyMessages; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.service.msgcenter.GroupCommandAckListener; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.util.DataUtils; + + +public class KontalkGroupController implements GroupController { + private static final String TAG = KontalkGroupController.class.getSimpleName(); + + /** Group type identifier. */ + public static final String GROUP_TYPE = "kontalk"; + + private final XMPPTCPConnection mConnection; + private final MessageCenterService mInstance; + + public KontalkGroupController(XMPPConnection connection, MessageCenterService instance) { + mConnection = (XMPPTCPConnection) connection; + mInstance = instance; + } + + @Override + public String getGroupType() { + return GROUP_TYPE; + } + + @Override + public Message beforeEncryption(GroupCommand command, Stanza packet) { + Jid groupJid = command.getGroupJid(); + KontalkGroupManager.KontalkGroup group; + try { + group = KontalkGroupManager.getInstanceFor(mConnection) + .getGroup(groupJid); + } + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return null; + } + + if (packet == null) + packet = new Message(); + + if (command instanceof CreateGroupCommand) { + KontalkCreateGroupCommand create = (KontalkCreateGroupCommand) command; + group.create(create.getSubject(), create.getMembers(), packet); + } + else if (command instanceof SetSubjectCommand) { + KontalkSetSubjectCommand setSubject = (KontalkSetSubjectCommand) command; + group.setSubject(setSubject.getSubject(), packet); + } + else if (command instanceof AddRemoveMembersCommand) { + KontalkAddRemoveMembersCommand addRemove = (KontalkAddRemoveMembersCommand) command; + Jid[] added = addRemove.getAddedMembers(); + Jid[] members = addRemove.getMembers(); + Set filteredMembers = new HashSet<>(); + for (Jid member : members) { + // do not include added users in members list + if (added == null || !DataUtils.contains(added, member)) { + filteredMembers.add(member); + } + } + group.addRemoveMembers(addRemove.getSubject(), + filteredMembers.toArray(new Jid[0]), + addRemove.getAddedMembers(), addRemove.getRemovedMembers(), packet); + } + else if (command instanceof PartCommand) { + group.leave(packet); + } + else if (command instanceof InfoCommand) { + group.groupInfo(packet); + } + + // add a fallback body for clients not supporting our protocol + if (!(command instanceof InfoCommand) && ((Message) packet).getBody() == null) { + ((Message) packet).setBody(mInstance.getResources() + .getString(R.string.text_group_command_fallback)); + } + + return (Message) packet; + } + + @Override + public Message afterEncryption(GroupCommand command, Stanza packet, Stanza original) { + if (packet == null) + throw new IllegalArgumentException("packet must be provided"); + if (!(command instanceof KontalkGroupCommand)) + throw new IllegalArgumentException("invalid command"); + + Jid groupJid = command.getGroupJid(); + KontalkGroupManager.KontalkGroup group; + try { + group = KontalkGroupManager.getInstanceFor(mConnection) + .getGroup(groupJid); + } + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return null; + } + + if (command instanceof PartCommand) { + try { + String id = packet.getStanzaId(); + long msgId = ((PartCommand) command).getDatabaseId(); + + // delete the command afterwards (only for part commands) + Uri msgUri = (msgId > 0) ? MyMessages.Messages.getUri(msgId) : null; + // wait for confirmation + mConnection.addStanzaIdAcknowledgedListener(id, + new GroupCommandAckListener(mInstance, group, + GroupExtension.from(original), msgUri)); + } + catch (StreamManagementException.StreamManagementNotEnabledException e) { + Log.e(TAG, "server does not support stream management?!?"); + // weird situation, report it + ReportingManager.logException(e); + } + } + else if (command instanceof CreateGroupCommand || command instanceof AddRemoveMembersCommand) { + try { + String id = packet.getStanzaId(); + + // wait for confirmation + mConnection.addStanzaIdAcknowledgedListener(id, + new GroupCommandAckListener(mInstance, group, + GroupExtension.from(original), null)); + } + catch (StreamManagementException.StreamManagementNotEnabledException e) { + Log.e(TAG, "server does not support stream management?!?"); + // weird situation, report it + ReportingManager.logException(e); + } + } + + try { + // destination is multicast service + // TODO this should be discovered first + packet.setTo(JidCreate.from("multicast", mConnection.getXMPPServiceDomain(), "")); + } + catch (XmppStringprepException e) { + Log.w(TAG, "error parsing JID: " + e.getCausingString(), e); + // report it because it's a big deal + ReportingManager.logException(e); + return null; + } + + KontalkGroupCommand cmd = (KontalkGroupCommand) command; + group.addRouteExtension(cmd.getMembers(), packet); + return (Message) packet; + } + + @Override + public CreateGroupCommand createGroup() { + return new KontalkCreateGroupCommand(); + } + + @Override + public SetSubjectCommand setSubject() { + return new KontalkSetSubjectCommand(); + } + + @Override + public PartCommand part() { + return new KontalkPartCommand(); + } + + public AddRemoveMembersCommand addRemoveMembers() { + return new KontalkAddRemoveMembersCommand(); + } + + @Override + public InfoCommand info() { + return new KontalkGroupInfoCommand(); + } + + @Override + public boolean canSendCommandsWithEmptyGroup() { + return false; + } + + @Override + public boolean canSendWithNoSubscription() { + return true; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkGroupInfoCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkGroupInfoCommand.java new file mode 100644 index 000000000..007aa970e --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkGroupInfoCommand.java @@ -0,0 +1,25 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + + +public class KontalkGroupInfoCommand extends KontalkGroupCommand + implements InfoCommand { + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkPartCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkPartCommand.java new file mode 100644 index 000000000..793be7d3b --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkPartCommand.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + + +public class KontalkPartCommand extends KontalkGroupCommand + implements PartCommand { + + private long mDatabaseId; + + @Override + public void setDatabaseId(long databaseId) { + mDatabaseId = databaseId; + } + + @Override + public long getDatabaseId() { + return mDatabaseId; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkSetSubjectCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkSetSubjectCommand.java new file mode 100644 index 000000000..1c412eecf --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/KontalkSetSubjectCommand.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + + +public class KontalkSetSubjectCommand extends KontalkGroupCommand + implements SetSubjectCommand { + + private String mSubject; + + @Override + public void setSubject(String subject) { + mSubject = subject; + } + + @Override + public String getSubject() { + return mSubject; + } + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/PartCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/PartCommand.java new file mode 100644 index 000000000..70d3497a8 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/PartCommand.java @@ -0,0 +1,33 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + + +public interface PartCommand extends GroupCommand { + + /** + * A message has been stored to process this part command. It needs to be + * deleted after the message has been processed. Give this method the + * database id and the command handler will handle the rest. + */ + void setDatabaseId(long messageId); + + long getDatabaseId(); + +} diff --git a/app/src/main/java/org/kontalk/service/msgcenter/group/SetSubjectCommand.java b/app/src/main/java/org/kontalk/service/msgcenter/group/SetSubjectCommand.java new file mode 100644 index 000000000..990f0bf3a --- /dev/null +++ b/app/src/main/java/org/kontalk/service/msgcenter/group/SetSubjectCommand.java @@ -0,0 +1,28 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.msgcenter.group; + + +public interface SetSubjectCommand extends GroupCommand { + + void setSubject(String subject); + + String getSubject(); + +} diff --git a/app/src/main/java/org/kontalk/service/registration/RegistrationService.java b/app/src/main/java/org/kontalk/service/registration/RegistrationService.java new file mode 100644 index 000000000..87f26b189 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/RegistrationService.java @@ -0,0 +1,1791 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.security.GeneralSecurityException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipInputStream; + +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.filter.StanzaIdFilter; +import org.jivesoftware.smack.packet.ExtensionElement; +import org.jivesoftware.smack.packet.IQ; +import org.jivesoftware.smack.packet.StanzaError; +import org.jivesoftware.smack.util.StringUtils; +import org.jivesoftware.smackx.iqregister.packet.Registration; +import org.jivesoftware.smackx.xdata.Form; +import org.jivesoftware.smackx.xdata.FormField; +import org.jivesoftware.smackx.xdata.packet.DataForm; +import org.jxmpp.util.XmppStringUtils; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.operator.OperatorCreationException; + +import android.Manifest; +import android.accounts.AccountManager; +import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; +import android.annotation.SuppressLint; +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.provider.ContactsContract; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresPermission; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.Base64; + +import org.kontalk.BuildConfig; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.authenticator.Authenticator; +import org.kontalk.authenticator.MyAccount; +import org.kontalk.client.Account; +import org.kontalk.client.EndpointServer; +import org.kontalk.client.ServerList; +import org.kontalk.client.SmackInitializer; +import org.kontalk.crypto.PGP; +import org.kontalk.crypto.PGPUidMismatchException; +import org.kontalk.crypto.PGPUserID; +import org.kontalk.crypto.PersonalKey; +import org.kontalk.crypto.PersonalKeyImporter; +import org.kontalk.crypto.X509Bridge; +import org.kontalk.provider.Keyring; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.service.KeyPairGeneratorService; +import org.kontalk.service.XMPPConnectionHelper; +import org.kontalk.service.msgcenter.SQLiteRosterStore; +import org.kontalk.service.registration.event.AbortRequest; +import org.kontalk.service.registration.event.AcceptTermsRequest; +import org.kontalk.service.registration.event.AccountCreatedEvent; +import org.kontalk.service.registration.event.ChallengeError; +import org.kontalk.service.registration.event.ChallengeRequest; +import org.kontalk.service.registration.event.FallbackVerificationRequest; +import org.kontalk.service.registration.event.ImportKeyError; +import org.kontalk.service.registration.event.ImportKeyRequest; +import org.kontalk.service.registration.event.KeyReceivedEvent; +import org.kontalk.service.registration.event.LoginTestEvent; +import org.kontalk.service.registration.event.PassphraseInputEvent; +import org.kontalk.service.registration.event.RetrieveKeyError; +import org.kontalk.service.registration.event.RetrieveKeyRequest; +import org.kontalk.service.registration.event.ServerCheckError; +import org.kontalk.service.registration.event.TermsAcceptedEvent; +import org.kontalk.service.registration.event.ThrottlingError; +import org.kontalk.service.registration.event.UserConflictError; +import org.kontalk.service.registration.event.VerificationError; +import org.kontalk.service.registration.event.VerificationRequest; +import org.kontalk.service.registration.event.VerificationRequestedEvent; +import org.kontalk.ui.CodeValidation; +import org.kontalk.util.DataUtils; +import org.kontalk.util.EventBusIndex; +import org.kontalk.util.Preferences; +import org.kontalk.util.XMPPUtils; + +import static org.kontalk.ui.MessagingNotification.CHANNEL_OTHER; +import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_REGISTRATION; + + +/** + * A service that manages the registration process. It takes care of: + *
    + *
  • Selecting a server from a provided list of servers
  • + *
  • Requesting registration instructions and support to each one
  • + *
  • Post events for UI reactions
  • + *
  • Store and keep registration state even after application restarts
  • + *
  • Create the account
  • + *
+ * @author Daniele Ricci + */ +public class RegistrationService extends Service implements XMPPConnectionHelper.ConnectionHelperListener { + private static final String TAG = RegistrationService.class.getSimpleName(); + + private static final EventBus BUS; + + static { + BUS = EventBus.builder() + // TODO .logger(...) + .addIndex(new EventBusIndex()) + .throwSubscriberException(BuildConfig.DEBUG) + .logNoSubscriberMessages(BuildConfig.DEBUG) + .build(); + } + + /** + * A list of possible states this service can be. + * Maybe a state a machine here...? + */ + public enum State { + /** Doing nothing. */ + IDLE, + /** Connecting to a server (no key). */ + CONNECTING, + /** Waiting for terms acceptance. */ + WAITING_ACCEPT_TERMS, + /** Requesting verification to a server. */ + REQUESTING_VERIFICATION, + /** Waiting for a challenge (e.g. PIN) from UI. */ + WAITING_CHALLENGE, + /** Waiting for a passphrase from UI. */ + WAITING_PASSPHRASE, + /** Processing an imported key. */ + IMPORTING_KEY, + /** Sending the challenge code to the server. */ + REQUESTING_CHALLENGE, + /** Doing a login test with the key. */ + TESTING_KEY, + /** Creating account. */ + CREATING_ACCOUNT, + } + + /** Possible processes, that is, workflows for states. */ + public enum Workflow { + REGISTRATION, + IMPORT_KEY, + RETRIEVE_KEY, + } + + // constants for choosing a brand image size + public static final int BRAND_IMAGE_VECTOR = 0; + public static final int BRAND_IMAGE_SMALL = 1; + public static final int BRAND_IMAGE_MEDIUM = 2; + public static final int BRAND_IMAGE_LARGE = 3; + public static final int BRAND_IMAGE_HD = 4; + + private static final List BRAND_IMAGE_SIZES = Arrays.asList( + "brand-image-vector", + "brand-image-small", + "brand-image-medium", + "brand-image-large", + "brand-image-hd" + ); + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + BRAND_IMAGE_VECTOR, + BRAND_IMAGE_SMALL, + BRAND_IMAGE_MEDIUM, + BRAND_IMAGE_LARGE, + BRAND_IMAGE_HD + }) + public @interface BrandImageSize {} + + // from Kontalk server code + /** Challenge the user with a verification PIN sent through a SMS or a told through a phone call. */ + public static final String CHALLENGE_PIN = "pin"; + /** Challenge the user with a missed call from a random number and making the user guess the digits. */ + public static final String CHALLENGE_MISSED_CALL = "missedcall"; + /** Challenge the user with the caller ID presented in a user-initiated call to a given phone number. */ + public static final String CHALLENGE_CALLER_ID = "callerid"; + // default requested challenge + private static final String DEFAULT_CHALLENGE = CHALLENGE_PIN; + + /** Registration service will restart in foreground mode. */ + private static final String ACTION_RESTART_FOREGROUND = "org.kontalk.registration.RESTART_FOREGROUND"; + + /** Posted as a sticky event on the bus. */ + public static final class CurrentState { + public Workflow workflow; + public State state; + public EndpointServer server; + public String phoneNumber; + public String displayName; + public String challenge; + public boolean acceptTerms; + public boolean force; + public boolean fallback; + @BrandImageSize + public int brandImageSize; + + // this will be used if available during normal registration flow + public EndpointServer.EndpointServerProvider serverProvider; + + // not saved to state + public String termsUrl; + + // these two are filled with imported key data if available + // otherwise they will be filled with output of PersonalKey.store() + public byte[] privateKey; + public byte[] publicKey; + public PersonalKey key; + public String passphrase; + /** + * Maps to {@link ImportKeyRequest#fallbackVerification}. + * There is no need to persist this because after the registration has + * passed the first step, it will be indistinguishable from a normal + * registration. + */ + public boolean importFallbackVerification; + + public Map trustedKeys; + + /** Will be true if the state was restored from preferences. */ + public boolean restored; + + CurrentState() { + state = State.IDLE; + } + + CurrentState(@NonNull CurrentState cs) { + this.workflow = cs.workflow; + this.state = cs.state; + this.server = cs.server; + this.phoneNumber = cs.phoneNumber; + this.displayName = cs.displayName; + this.challenge = cs.challenge; + this.acceptTerms = cs.acceptTerms; + this.force = cs.force; + this.fallback = cs.fallback; + this.brandImageSize = cs.brandImageSize; + this.serverProvider = cs.serverProvider; + this.termsUrl = cs.termsUrl; + this.privateKey = cs.privateKey; + this.publicKey = cs.publicKey; + this.key = cs.key; + this.passphrase = cs.passphrase; + this.importFallbackVerification = cs.importFallbackVerification; + this.trustedKeys = cs.trustedKeys; + this.restored = cs.restored; + } + } + + public static final class NoPhoneNumberFoundException extends Exception { + } + + private LocalBroadcastManager mLocalBroadcastManager; + + private HandlerThread mServiceHandler; + private Handler mInternalHandler; + + private XMPPConnectionHelper mConnector; + + private KeyPairGeneratorService.KeyGeneratorReceiver mKeyReceiver; + private final Object mKeyLock = new Object(); + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (!BUS.isRegistered(this)) { + BUS.register(this); + } + + if (intent != null && ACTION_RESTART_FOREGROUND.equals(intent.getAction())) { + startForeground(); + } + else { + + // restore state from preferences in case the app was killed mid-registration + CurrentState state = null; + try { + state = restoreState(); + } + catch (Exception e) { + Log.w(TAG, "unable to restore registration progress", e); + clearSavedState(); + } + + if (state == null) { + state = currentState(); + + if (state != null) { + // handling non-persistent states + switch (state.state) { + case WAITING_ACCEPT_TERMS: + BUS.post(new AcceptTermsRequest(state.termsUrl)); + break; + } + } + } + + if (state == null || state.key == null) { + // since it may take some time, we should start key generation just + // in case, even if eventually we won't need it (e.g. import) + startKeyGenerator(); + } + } + + return START_NOT_STICKY; + } + + private void configure() { + SmackInitializer.initializeRegistration(); + } + + private void unconfigure() { + SmackInitializer.deinitializeRegistration(); + } + + @Override + public void onCreate() { + super.onCreate(); + configure(); + + updateState(State.IDLE); + + // internal handler + mServiceHandler = new HandlerThread(RegistrationService.class.getSimpleName()) { + @Override + protected void onLooperPrepared() { + mInternalHandler = new Handler(getLooper()); + } + }; + mServiceHandler.start(); + + mLocalBroadcastManager = LocalBroadcastManager.getInstance(this); + } + + @Override + public void onDestroy() { + stopKeyReceiver(); + BUS.unregister(this); + unconfigure(); + + final HandlerThread serviceHandler = mServiceHandler; + final Handler internalHandler = mInternalHandler; + if (serviceHandler != null && internalHandler != null) { + mInternalHandler.post(new Runnable() { + public void run() { + try { + serviceHandler.quit(); + reset(); + } + catch (Exception e) { + // ignored + } + } + }); + } + + mInternalHandler = null; + mServiceHandler = null; + } + + @Override + public void onRebind(Intent intent) { + stopForeground(true); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public boolean onUnbind(Intent intent) { + CurrentState cstate = currentState(); + if (cstate != null && cstate.state == State.WAITING_CHALLENGE) { + // all activities disconnected while waiting for user challenge + // keep ourselves alive + restartForeground(); + } + return true; + } + + private void startForeground() { + Intent ni = new Intent(getApplicationContext(), CodeValidation.class); + PendingIntent pi = PendingIntent.getActivity(this, + NOTIFICATION_ID_REGISTRATION, ni, PendingIntent.FLAG_UPDATE_CURRENT); + + // build the notification + final Notification notification = new NotificationCompat + .Builder(this, CHANNEL_OTHER) + .setOngoing(true) + .setSmallIcon(R.drawable.ic_stat_notify) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setTicker(getString(R.string.notification_ticker_registration_running)) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.notification_text_registration_running)) + .setContentIntent(pi) + .build(); + + startForeground(NOTIFICATION_ID_REGISTRATION, notification); + } + + private void restartForeground() { + Intent intent = new Intent(this, RegistrationService.class); + intent.setAction(ACTION_RESTART_FOREGROUND); + ContextCompat.startForegroundService(this, intent); + } + + private void startKeyGenerator() { + KeyPairGeneratorService.PersonalKeyRunnable action = new KeyPairGeneratorService.PersonalKeyRunnable() { + @Override + public void run(PersonalKey key) { + if (key != null) { + synchronized (mKeyLock) { + CurrentState state = currentState(); + state.key = key; + mKeyLock.notifyAll(); + } + } + } + }; + mKeyReceiver = new KeyPairGeneratorService.KeyGeneratorReceiver(new Handler(), action); + IntentFilter filter = new IntentFilter(KeyPairGeneratorService.ACTION_GENERATE); + filter.addAction(KeyPairGeneratorService.ACTION_STARTED); + mLocalBroadcastManager.registerReceiver(mKeyReceiver, filter); + + Intent i = new Intent(this, KeyPairGeneratorService.class); + i.setAction(KeyPairGeneratorService.ACTION_GENERATE); + startService(i); + } + + private void stopKeyReceiver() { + if (mKeyReceiver != null) { + mLocalBroadcastManager.unregisterReceiver(mKeyReceiver); + mKeyReceiver = null; + } + } + + private CurrentState restoreState() { + String _state = Preferences.getString("registration_state", null); + String workflow = Preferences.getString("registration_workflow", null); + if (_state != null && workflow != null) { + CurrentState state = updateState(State.valueOf(_state), Workflow.valueOf(workflow)); + + state.displayName = Preferences.getString("registration_name", null); + state.phoneNumber = Preferences.getString("registration_phone", null); + String serverUri = Preferences.getString("registration_server", null); + state.server = serverUri != null ? new EndpointServer(serverUri) : null; + String key = Preferences.getString("registration_key", null); + state.key = !TextUtils.isEmpty(key) ? PersonalKey.fromBase64(key) : null; + state.passphrase = Preferences.getString("registration_passphrase", null); + state.challenge = Preferences.getString("registration_challenge", null); + state.force = Preferences.getBoolean("registration_force", false); + + String privateKey = Preferences.getString("registration_privatekey", null); + state.privateKey = !TextUtils.isEmpty(privateKey) ? Base64.decode(privateKey, Base64.NO_WRAP) : null; + String publicKey = Preferences.getString("registration_publickey", null); + state.publicKey = !TextUtils.isEmpty(publicKey) ? Base64.decode(publicKey, Base64.NO_WRAP) : null; + + String trustedKeys = Preferences.getString("registration_trustedkeys", null); + if (trustedKeys != null) { + ByteArrayInputStream trustedKeysProp = + new ByteArrayInputStream(Base64.decode(trustedKeys, Base64.NO_WRAP)); + try { + Properties prop = new Properties(); + prop.load(trustedKeysProp); + state.trustedKeys = new HashMap<>(prop.size()); + for (Map.Entry e : prop.entrySet()) { + Keyring.TrustedFingerprint fingerprint = + Keyring.TrustedFingerprint.fromString((String) e.getValue()); + if (fingerprint != null) { + state.trustedKeys.put((String) e.getKey(), fingerprint); + } + } + } + catch (IOException ignored) { + } + } + + String serverProviderData = Preferences.getString("registration_serverprovider", null); + Properties serverProviderProps = DataUtils.unserializeProperties(serverProviderData); + try { + state.serverProvider = new ServerList + .ServerListProvider(ServerList.fromProperties(serverProviderProps)); + } + catch (IOException e) { + throw new IllegalStateException("invalid server provider", e); + } + + String sender = Preferences.getString("registration_sender", null); + String brandImage = Preferences.getString("registration_brandimage", null); + String brandLink = Preferences.getString("registration_brandlink", null); + boolean canFallback = Preferences.getBoolean("registration_canfallback", false); + + // send appropriate event to UI + switch (state.workflow) { + case REGISTRATION: { + BUS.post(new VerificationRequestedEvent(sender, state.challenge, + brandImage, brandLink, canFallback)); + break; + } + case IMPORT_KEY: { + state.importFallbackVerification = true; + BUS.post(new VerificationRequestedEvent(sender, state.challenge, + brandImage, brandLink, canFallback)); + break; + } + } + + state.restored = true; + return state; + } + return null; + } + + private static void saveState(String sender, String brandImage, String brandLink, boolean canFallback) { + CurrentState state = currentState(); + if (state != null && state.workflow != null) { + ByteArrayOutputStream trustedKeysOut = null; + if (state.trustedKeys != null) { + trustedKeysOut = new ByteArrayOutputStream(); + Properties prop = new Properties(); + + for (Map.Entry e : state.trustedKeys.entrySet()) { + Keyring.TrustedFingerprint fingerprint = e.getValue(); + if (fingerprint != null) { + prop.put(e.getKey(), fingerprint.toString()); + } + } + + try { + prop.store(trustedKeysOut, null); + } + catch (IOException e) { + // something went wrong + // we can't have IOExceptions from byte buffers anyway + trustedKeysOut = null; + } + } + + Preferences.getInstance().edit() + .putString("registration_state", state.state.toString()) + .putString("registration_workflow", state.workflow.toString()) + .putString("registration_name", state.displayName) + .putString("registration_phone", state.phoneNumber) + .putString("registration_key", state.key != null ? state.key.toBase64() : null) + .putString("registration_passphrase", state.passphrase) + .putString("registration_server", state.server.toString()) + .putString("registration_serverprovider", DataUtils.serializeProperties(state.serverProvider.list().toProperties())) + .putString("registration_sender", sender) + .putString("registration_challenge", state.challenge) + .putString("registration_brandimage", brandImage) + .putString("registration_brandlink", brandLink) + .putBoolean("registration_canfallback", canFallback) + .putBoolean("registration_force", state.force) + .putString("registration_privatekey", state.privateKey != null ? + Base64.encodeToString(state.privateKey, Base64.NO_WRAP) : null) + .putString("registration_publickey", state.privateKey != null ? + Base64.encodeToString(state.publicKey, Base64.NO_WRAP) : null) + .putString("registration_trustedkeys", trustedKeysOut != null ? + Base64.encodeToString(trustedKeysOut.toByteArray(), Base64.NO_WRAP) : null) + .apply(); + } + } + + public static void clearSavedState() { + Preferences.getInstance().edit() + .remove("registration_workflow") + .remove("registration_state") + .remove("registration_name") + .remove("registration_phone") + .remove("registration_key") + .remove("registration_passphrase") + .remove("registration_server") + .remove("registration_serverprovider") + .remove("registration_sender") + .remove("registration_challenge") + .remove("registration_brandimage") + .remove("registration_brandlink") + .remove("registration_canfallback") + .remove("registration_force") + .remove("registration_trustedkeys") + .apply(); + } + + private synchronized CurrentState updateState(State state) { + return updateState(state, null, null); + } + + private synchronized CurrentState updateState(State state, Workflow workflow) { + return updateState(state, workflow, null); + } + + private synchronized CurrentState updateState(State state, Workflow workflow, EndpointServer server) { + CurrentState cstate = currentState(); + // always produce a new object + if (cstate == null) { + cstate = new CurrentState(); + } + else { + cstate = new CurrentState(cstate); + } + + cstate.state = state; + if (workflow != null) { + cstate.workflow = workflow; + } + if (server != null) { + cstate.server = server; + } + + BUS.postSticky(cstate); + return cstate; + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onAbortRequest(AbortRequest request) { + reset(); + synchronized (mKeyLock) { + mKeyLock.notifyAll(); + } + } + + /** Full registration procedure. */ + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onVerificationRequest(VerificationRequest request) { + // begin by requesting instructions for service terms url + reset(); + + synchronized (mKeyLock) { + CurrentState cstate = currentState(); + if (cstate.key == null) { + Log.v(TAG, "waiting for key generator"); + try { + // wait endlessly? + mKeyLock.wait(); + if (cstate.key == null) { + // interrupted by abort request + throw new InterruptedException(); + } + } + catch (InterruptedException e) { + return; + } + Log.v(TAG, "key generation completed"); + } + } + + CurrentState cstate = updateState(State.CONNECTING, Workflow.REGISTRATION, + request.serverProvider.next()); + cstate.serverProvider = request.serverProvider; + cstate.phoneNumber = request.phoneNumber; + cstate.displayName = request.displayName; + cstate.force = request.force; + cstate.brandImageSize = request.brandImageSize; + // random passphrase for now + cstate.passphrase = StringUtils.randomString(40); + + updateState(State.CONNECTING); + + // connect to the provided server + mConnector = new XMPPConnectionHelper(this, cstate.server, true); + mConnector.setRetryEnabled(false); + + Exception lastError = null; + while (cstate.server != null) { + try { + initConnectionAnonymous(); + + // send instructions form + IQ form = createInstructionsForm(); + final XMPPConnection conn = mConnector.getConnection(); + IQ result; + try { + result = conn.createStanzaCollectorAndSend(new StanzaIdFilter(form.getStanzaId()), form) + .nextResultOrThrow(); + } + finally { + disconnect(); + } + + DataForm response = result.getExtension("x", "jabber:x:data"); + if (response != null && response.hasField("accept-terms")) { + FormField termsUrlField = response.getField("terms"); + if (termsUrlField != null) { + String termsUrl = termsUrlField.getFirstValue(); + if (termsUrl != null) { + Log.d(TAG, "server request terms acceptance: " + termsUrl); + cstate = updateState(State.WAITING_ACCEPT_TERMS); + cstate.termsUrl = termsUrl; + BUS.post(new AcceptTermsRequest(termsUrl)); + return; + } + } + } + + // no terms, just proceed + requestRegistration(); + break; + } + catch (Exception e) { + // try the next server + cstate.server = cstate.serverProvider.next(); + if (cstate.server != null) { + mConnector.setServer(cstate.server); + } + else { + lastError = e; + } + } + } + + if (lastError != null) { + Object errorEvent = null; + if (lastError instanceof XMPPException.XMPPErrorException) { + final StanzaError error = ((XMPPException.XMPPErrorException) lastError).getStanzaError(); + if (error.getCondition() == StanzaError.Condition.service_unavailable) { + errorEvent = new ServerCheckError((XMPPException.XMPPErrorException) lastError); + } + } + + if (errorEvent == null) { + errorEvent = new VerificationError(lastError); + } + BUS.post(errorEvent); + } + } + + /** Import existing key from a personal keypack. */ + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onImportKeyRequest(ImportKeyRequest request) { + reset(); + stopKeyReceiver(); + + CurrentState cstate = updateState(State.IMPORTING_KEY, Workflow.IMPORT_KEY); + + // start by validating the key + PersonalKeyImporter importer; + try { + importer = createImporter(request.in, request.passphrase); + importer.load(); + + ByteArrayOutputStream privateKeyBuf = new ByteArrayOutputStream(); + ByteArrayOutputStream publicKeyBuf = new ByteArrayOutputStream(); + + cstate.key = importer.createPersonalKey(privateKeyBuf, publicKeyBuf); + if (cstate.key == null) + throw new PGPException("unable to load imported personal key."); + + Map accountInfo = importer.getAccountInfo(); + if (accountInfo != null) { + String phoneNumber = accountInfo.get("phoneNumber"); + if (!TextUtils.isEmpty(phoneNumber)) { + cstate.phoneNumber = phoneNumber; + } + } + + // personal key corrupted or too old + if (cstate.phoneNumber == null) { + throw new NoPhoneNumberFoundException(); + } + + PGPUserID uid = verifyImportedKey(cstate.key, cstate.phoneNumber); + + // use server from the key only if we didn't set our own + cstate.server = (request.server != null) ? request.server : + new EndpointServer(XmppStringUtils.parseDomain(uid.getEmail())); + + cstate.displayName = uid.getName(); + cstate.passphrase = request.passphrase; + // copy over the parsed keys (imported keys may be armored) + cstate.privateKey = privateKeyBuf.toByteArray(); + cstate.publicKey = publicKeyBuf.toByteArray(); + cstate.importFallbackVerification = request.fallbackVerification; + cstate.brandImageSize = request.brandImageSize; + // we are assuming we are forcing our way in since we are importing + cstate.force = true; + + try { + cstate.trustedKeys = importer.getTrustedKeys(); + } + catch (Exception e) { + // this is not a critical error so we can just ignore it + Log.w(TAG, "unable to load trusted keys from key pack", e); + ReportingManager.logException(e); + } + } + catch (PGPUidMismatchException e) { + Log.w(TAG, "uid mismatch!"); + BUS.post(new ImportKeyError(e)); + return; + } + catch (PGPException e) { + // PGP specific error + BUS.post(new ImportKeyError(e)); + return; + } + catch (GeneralSecurityException e) { + BUS.post(new ImportKeyError(e)); + return; + } + catch (IOException e) { + BUS.post(new ImportKeyError(e)); + return; + } + catch (OperatorCreationException e) { + // serious device problem + throw new RuntimeException(e); + } + catch (NoPhoneNumberFoundException e) { + BUS.post(new ImportKeyError(e)); + return; + } + finally { + try { + request.in.close(); + } + catch (IOException ignored) { + } + } + + updateState(State.CONNECTING); + + // connect to the provided server + mConnector = new XMPPConnectionHelper(this, cstate.server, true); + mConnector.setRetryEnabled(false); + + try { + initConnectionAnonymous(); + + // send instructions form + IQ form = createInstructionsForm(); + final XMPPConnection conn = mConnector.getConnection(); + IQ result; + try { + result = conn.createStanzaCollectorAndSend(new StanzaIdFilter(form.getStanzaId()), form) + .nextResultOrThrow(); + } + finally { + disconnect(); + } + + DataForm response = result.getExtension("x", "jabber:x:data"); + if (response != null && response.hasField("accept-terms")) { + FormField termsUrlField = response.getField("terms"); + if (termsUrlField != null) { + String termsUrl = termsUrlField.getFirstValue(); + if (termsUrl != null) { + Log.d(TAG, "server request terms acceptance: " + termsUrl); + updateState(State.WAITING_ACCEPT_TERMS); + BUS.post(new AcceptTermsRequest(termsUrl)); + return; + } + } + } + + // no terms, just proceed + loginTestWithImportedKey(); + } + catch (Exception e) { + BUS.post(new RetrieveKeyError(e)); + } + } + + /** + * Request a private key to the server. + * Used when registering from another device. + */ + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onRetrieveKeyRequest(RetrieveKeyRequest request) { + reset(); + stopKeyReceiver(); + + updateState(State.CONNECTING, Workflow.RETRIEVE_KEY, request.server); + + // connect to the provided server + mConnector = new XMPPConnectionHelper(this, request.server, true); + mConnector.setRetryEnabled(false); + + try { + initConnectionAnonymous(); + + // prepare private key request form + IQ form = createPrivateKeyRequest(request.privateKeyToken); + + // send request packet + final XMPPConnection conn = mConnector.getConnection(); + IQ result; + try { + result = conn.createStanzaCollectorAndSend(new StanzaIdFilter(form.getStanzaId()), form) + .nextResultOrThrow(); + } + finally { + disconnect(); + } + + ExtensionElement _accountData = result.getExtension(Account.ELEMENT_NAME, Account.NAMESPACE); + if (_accountData instanceof Account) { + Account accountData = (Account) _accountData; + + byte[] privateKeyData = accountData.getPrivateKeyData(); + byte[] publicKeyData = accountData.getPublicKeyData(); + if (privateKeyData != null && privateKeyData.length > 0 && + publicKeyData != null && publicKeyData.length > 0) { + + CurrentState cstate = currentState(); + cstate.privateKey = privateKeyData; + cstate.publicKey = publicKeyData; + cstate.phoneNumber = request.phoneNumber; + + BUS.post(new KeyReceivedEvent(privateKeyData, publicKeyData)); + updateState(State.WAITING_PASSPHRASE); + return; + } + } + + // unexpected response + BUS.post(new RetrieveKeyError(new IllegalStateException("Server did not reply with key."))); + } + catch (Exception e) { + BUS.post(new RetrieveKeyError(e)); + } + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onKeyReceived(KeyReceivedEvent event) { + disconnect(); + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onRetrieveKeyError(RetrieveKeyError event) { + reset(); + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onPassphraseInput(PassphraseInputEvent event) { + CurrentState cstate = currentState(); + cstate.passphrase = event.passphrase; + switch (cstate.workflow) { + case RETRIEVE_KEY: { + if (cstate.state == State.WAITING_PASSPHRASE) { + // proceed with import + loadRetrievedKey(); + } + break; + } + } + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onTermsAccepted(TermsAcceptedEvent event) { + CurrentState cstate = currentState(); + cstate.acceptTerms = true; + switch (cstate.workflow) { + case REGISTRATION: { + requestRegistration(); + break; + } + case IMPORT_KEY: { + loginTestWithImportedKey(); + break; + } + } + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onFallbackVerificationRequest(FallbackVerificationRequest request) { + CurrentState cstate = currentState(); + cstate.fallback = true; + requestRegistration(); + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onChallengeRequest(ChallengeRequest request) { + CurrentState cstate = updateState(State.REQUESTING_CHALLENGE); + + try { + // connect to the provided server + mConnector = new XMPPConnectionHelper(this, cstate.server, true); + mConnector.setRetryEnabled(false); + + PGP.PGPKeyPairRing keyRing; + if (!cstate.importFallbackVerification) { + // generate keyring immediately + // needed for connection + String userId = XMPPUtils.createLocalpart(cstate.phoneNumber); + keyRing = cstate.key.storeNetwork(userId, mConnector.getNetwork(), + cstate.displayName, cstate.passphrase); + } + else { + keyRing = PersonalKey.test(cstate.privateKey, cstate.publicKey, cstate.passphrase, null); + } + + // bridge certificate for connection + X509Certificate bridgeCert = X509Bridge.createCertificate(keyRing.publicKey, + keyRing.secretKey.getSecretKey(), cstate.passphrase); + + cstate.key = PersonalKey.withBridgeCert(cstate.key, bridgeCert); + + initConnection(cstate.key, false); + + // prepare private key request form + IQ form = createChallengeForm(request.code); + + // send request packet + final XMPPConnection conn = mConnector.getConnection(); + IQ result; + try { + result = conn.createStanzaCollectorAndSend(new StanzaIdFilter(form.getStanzaId()), form) + .nextResultOrThrow(); + } + finally { + disconnect(); + } + + DataForm response = result.getExtension("x", "jabber:x:data"); + if (response != null) { + String publicKey = null; + + // ok! message will be sent + List iter = response.getFields(); + for (FormField field : iter) { + if ("publickey".equals(field.getVariable())) { + publicKey = field.getFirstValue(); + } + } + + if (!TextUtils.isEmpty(publicKey)) { + keyRing.publicKey = cstate.key.update(Base64.decode(publicKey, Base64.DEFAULT)); + cstate.publicKey = keyRing.publicKey.getEncoded(); + cstate.privateKey = keyRing.secretKey.getEncoded(); + + // bridge certificate for connection + bridgeCert = X509Bridge.createCertificate(keyRing.publicKey, + keyRing.secretKey.getSecretKey(), cstate.passphrase); + cstate.key = cstate.key.copy(bridgeCert); + + createAccount(); + return; + } + } + + // TODO clarify error + throw new Exception("Invalid response"); + } + catch (Exception e) { + // TODO check for specific errors + BUS.post(new ChallengeError(e)); + } + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + public void onLoginTest(LoginTestEvent event) { + if (event.exception == null) { + createAccount(); + } + } + + private void requestRegistration() { + CurrentState cstate = updateState(State.REQUESTING_VERIFICATION); + + try { + initConnectionAnonymous(); + + // prepare private key request form + IQ form = createRegistrationForm(cstate.phoneNumber, + cstate.acceptTerms, cstate.force, cstate.fallback); + + // send request packet + final XMPPConnection conn = mConnector.getConnection(); + IQ result; + try { + result = conn.createStanzaCollectorAndSend(new StanzaIdFilter(form.getStanzaId()), form) + .nextResultOrThrow(); + } + finally { + disconnect(); + } + + DataForm response = result.getExtension("x", "jabber:x:data"); + if (response != null) { + // ok! message will be sent + String smsFrom = null, challenge = null, + brandLink = null; + boolean canFallback = false; + List iter = response.getFields(); + for (FormField field : iter) { + String fieldName = field.getVariable(); + if ("from".equals(fieldName)) { + smsFrom = field.getFirstValue(); + } + else if ("challenge".equals(fieldName)) { + challenge = field.getFirstValue(); + } + else if ("brand-link".equals(fieldName)) { + brandLink = field.getFirstValue(); + } + else if ("can-fallback".equals(fieldName) && field.getType() == FormField.Type.bool) { + String val = field.getFirstValue(); + canFallback = "1".equals(val) || "true".equalsIgnoreCase(val) || "yes".equalsIgnoreCase(val); + } + } + + // brand image needs some more complex logic + final String brandImage = getBrandImageField(response, cstate.brandImageSize); + + if (smsFrom != null) { + Log.d(TAG, "using sender id: " + smsFrom + ", challenge: " + challenge); + cstate.challenge = challenge; + ReportingManager.logRegister(challenge); + + BUS.post(new VerificationRequestedEvent(smsFrom, challenge, + brandImage, brandLink, canFallback)); + + // time to save state + updateState(State.WAITING_CHALLENGE); + saveState(smsFrom, brandImage, brandLink, canFallback); + return; + } + } + + // TODO clarify error + throw new Exception("Invalid response"); + } + catch (Exception e) { + Object errorEvent = null; + if (e instanceof XMPPException.XMPPErrorException) { + final StanzaError error = ((XMPPException.XMPPErrorException) e).getStanzaError(); + if (error.getCondition() == StanzaError.Condition.service_unavailable) { + if (error.getType() == StanzaError.Type.WAIT) { + errorEvent = new ThrottlingError((XMPPException.XMPPErrorException) e); + } + else { + errorEvent = new ServerCheckError((XMPPException.XMPPErrorException) e); + } + } + else if (error.getCondition() == StanzaError.Condition.conflict) { + errorEvent = new UserConflictError((XMPPException.XMPPErrorException) e); + } + } + + if (errorEvent == null) { + errorEvent = new VerificationError(e); + } + BUS.post(errorEvent); + } + } + + /** + * Finds the appropriate brand image form field from the response to use. + * @param form the response form + * @param brandImageSize the brand image size factor + * @return a brand image URL, or null if none was found + */ + @SuppressWarnings("WeakerAccess") + @Nullable + String getBrandImageField(DataForm form, @BrandImageSize int brandImageSize) { + // logic could be optimized, but it does its job. + // Besides, it's a one-time method. + String preferredSize = getBrandImageAttributeName(brandImageSize); + String result = findField(form, preferredSize); + + if (result == null) { + // preferred attribute not found, look for other ones from the smaller ones + for (int i = brandImageSize - 1; i > BRAND_IMAGE_VECTOR; i--) { + String size = getBrandImageAttributeName(i); + result = findField(form, size); + } + } + + if (result == null) { + // no smaller size found, try a bigger one + for (int i = brandImageSize + 1; i <= BRAND_IMAGE_HD; i++) { + String size = getBrandImageAttributeName(i); + result = findField(form, size); + } + } + + // nothing was found if result is still null + return result; + } + + private String findField(DataForm form, String name) { + FormField field = form.getField(name); + if (field != null) { + List values = field.getValues(); + if (values != null && values.size() > 0) { + CharSequence value = values.get(0); + return value != null && value.toString().trim().length() > 0 ? + value.toString() : null; + } + } + return null; + } + + private String getBrandImageAttributeName(@BrandImageSize int size) { + try { + return BRAND_IMAGE_SIZES.get(size); + } + catch (IndexOutOfBoundsException e) { + throw new IllegalArgumentException("invalid brand image size: " + size); + } + } + + private void loadRetrievedKey() { + CurrentState cstate = updateState(State.IMPORTING_KEY); + + try { + ByteArrayOutputStream privateKeyBuf = new ByteArrayOutputStream(); + ByteArrayOutputStream publicKeyBuf = new ByteArrayOutputStream(); + cstate.key = PersonalKeyImporter.importPersonalKey(cstate.privateKey, + cstate.publicKey, cstate.passphrase, privateKeyBuf, publicKeyBuf); + PGPUserID uid = verifyImportedKey(cstate.key, cstate.phoneNumber); + + // use server from the key only if we didn't set our own + cstate.server = (cstate.server != null) ? cstate.server : + new EndpointServer(XmppStringUtils.parseDomain(uid.getEmail())); + + cstate.displayName = uid.getName(); + // copy over the parsed keys (it should be the same, but you never know...) + cstate.privateKey = privateKeyBuf.toByteArray(); + cstate.publicKey = publicKeyBuf.toByteArray(); + + loginTestWithImportedKey(); + } + catch (PGPUidMismatchException e) { + Log.w(TAG, "uid mismatch!"); + BUS.post(new RetrieveKeyError(e)); + } + catch (PGPException e) { + // PGP specific error + BUS.post(new RetrieveKeyError(e)); + } + catch (GeneralSecurityException e) { + BUS.post(new RetrieveKeyError(e)); + } + catch (IOException e) { + BUS.post(new RetrieveKeyError(e)); + } + catch (OperatorCreationException e) { + // serious device problem + throw new RuntimeException(e); + } + } + + @NonNull + private PGPUserID verifyImportedKey(PersonalKey key, String phoneNumber) throws PGPException { + String uidStr = key.getUserId(null); + PGPUserID uid = PGPUserID.parse(uidStr); + if (uid == null) + throw new PGPException("malformed user ID: " + uidStr); + + // check that uid matches phone number + String email = uid.getEmail(); + String numberHash = XMPPUtils.createLocalpart(phoneNumber); + String localpart = XmppStringUtils.parseLocalpart(email); + if (!numberHash.equalsIgnoreCase(localpart)) + throw new PGPUidMismatchException("email does not match phone number: " + email); + return uid; + } + + private void createAccount() { + CurrentState cstate = updateState(State.CREATING_ACCOUNT); + + // point of no return: we can clear saved state now + clearSavedState(); + + // if we are retrieving a key from the server, delete the custom address + if (cstate.workflow == Workflow.RETRIEVE_KEY) { + Preferences.setServerURI(null); + } + + final android.accounts.Account account = new android.accounts + .Account(cstate.phoneNumber, Authenticator.ACCOUNT_TYPE); + + // workaround for bug in AccountManager (http://stackoverflow.com/a/11698139/1045199) + // procedure will continue in removeAccount callback + try { + AccountManager.get(this).removeAccount(account, + new AccountRemovalCallback(this, account, cstate.passphrase, + cstate.privateKey, cstate.publicKey, cstate.key.getBridgeCertificate().getEncoded(), + cstate.displayName, cstate.server.toString(), cstate.termsUrl, cstate.trustedKeys, + cstate.serverProvider.list()), + null); + } + catch (CertificateEncodingException e) { + // cannot happen + throw new RuntimeException("unable to build X.509 bridge certificate", e); + } + } + + private PersonalKeyImporter createImporter(InputStream in, String passphrase) { + return new PersonalKeyImporter(new ZipInputStream(in), passphrase); + } + + /** Run a test login with the imported key. */ + private void loginTestWithImportedKey() { + try { + CurrentState cstate = updateState(State.TESTING_KEY); + + // connect to server + try { + initConnection(cstate.key, true); + } + finally { + disconnect(); + } + // login successful!!! + BUS.post(new LoginTestEvent()); + } + catch (Exception e) { + CurrentState cstate = currentState(); + if (cstate.importFallbackVerification) { + requestRegistration(); + } + else { + BUS.post(new LoginTestEvent(e)); + } + } + } + + private void initConnectionAnonymous() throws XMPPException, SmackException, + PGPException, KeyStoreException, NoSuchProviderException, + NoSuchAlgorithmException, CertificateException, + IOException, InterruptedException { + + initConnection(null, false); + } + + private void initConnection(PersonalKey key, boolean loginTest) throws XMPPException, SmackException, + PGPException, KeyStoreException, NoSuchProviderException, + NoSuchAlgorithmException, CertificateException, + IOException, InterruptedException { + + if (!mConnector.isConnected() || mConnector.isServerDirty()) { + // this will force the connector to recreate the connection + mConnector.setServer(mConnector.getServer()); + mConnector.setListener(this); + mConnector.connectOnce(key, loginTest); + } + } + + private IQ createInstructionsForm() { + Registration iq = new Registration(); + iq.setTo(mConnector.getConnection().getXMPPServiceDomain()); + iq.setType(IQ.Type.get); + return iq; + } + + private IQ createRegistrationForm(String phoneNumber, boolean acceptTerms, boolean force, boolean fallback) { + Registration iq = new Registration(); + iq.setType(IQ.Type.set); + iq.setTo(mConnector.getConnection().getXMPPServiceDomain()); + Form form = new Form(DataForm.Type.submit); + + FormField type = new FormField("FORM_TYPE"); + type.setType(FormField.Type.hidden); + type.addValue(Registration.NAMESPACE); + form.addField(type); + + FormField phone = new FormField("phone"); + phone.setType(FormField.Type.text_single); + phone.addValue(phoneNumber); + form.addField(phone); + + if (acceptTerms) { + FormField fAcceptTerms = new FormField("accept-terms"); + fAcceptTerms.setType(FormField.Type.bool); + fAcceptTerms.addValue(Boolean.TRUE.toString()); + form.addField(fAcceptTerms); + } + + if (force) { + FormField fForce = new FormField("force"); + fForce.setType(FormField.Type.bool); + fForce.addValue(Boolean.TRUE.toString()); + form.addField(fForce); + } + + if (fallback) { + FormField fFallback = new FormField("fallback"); + fFallback.setType(FormField.Type.bool); + fFallback.addValue(Boolean.TRUE.toString()); + form.addField(fFallback); + } + else { + // not falling back, ask for our preferred challenge + FormField challenge = new FormField("challenge"); + challenge.setType(FormField.Type.text_single); + challenge.addValue(DEFAULT_CHALLENGE); + form.addField(challenge); + } + + iq.addExtension(form.getDataFormToSend()); + return iq; + } + + private IQ createChallengeForm(CharSequence code) { + Registration iq = new Registration(); + iq.setType(IQ.Type.set); + iq.setTo(mConnector.getConnection().getXMPPServiceDomain()); + Form form = new Form(DataForm.Type.submit); + + FormField type = new FormField("FORM_TYPE"); + type.setType(FormField.Type.hidden); + type.addValue("http://kontalk.org/protocol/register#code"); + form.addField(type); + + if (code != null) { + FormField codeField = new FormField("code"); + codeField.setLabel("Validation code"); + codeField.setType(FormField.Type.text_single); + codeField.addValue(code.toString()); + form.addField(codeField); + } + + iq.addExtension(form.getDataFormToSend()); + return iq; + } + + private IQ createPrivateKeyRequest(String privateKeyToken) { + Registration iq = new Registration(); + iq.setType(IQ.Type.get); + iq.setTo(mConnector.getConnection().getXMPPServiceDomain()); + + Account account = new Account(); + account.setPrivateKeyToken(privateKeyToken); + iq.addExtension(account); + + return iq; + } + + synchronized void reset() { + disconnect(); + + // key might be stored before reset + PersonalKey key = currentState().key; + + BUS.removeStickyEvent(CurrentState.class); + CurrentState state = updateState(State.IDLE); + state.key = key; + } + + private void disconnect() { + if (mConnector != null) { + mConnector.shutdown(); + } + } + + // TODO do we really need these listeners? + + @Override + public void created(XMPPConnection connection) { + } + + @Override + public void aborted(Exception e) { + } + + @Override + public void reconnectingIn(int seconds) { + } + + @Override + public void authenticationFailed() { + } + + @Override + public void connected(XMPPConnection connection) { + } + + @Override + public void authenticated(XMPPConnection connection, boolean resumed) { + } + + @Override + public void connectionClosed() { + } + + @Override + public void connectionClosedOnError(Exception e) { + } + + public static EventBus bus() { + return BUS; + } + + public static boolean hasSavedState() { + return Preferences.getString("registration_state", null) != null && + Preferences.getString("registration_workflow", null) != null; + } + + public static CurrentState currentState() { + return BUS.getStickyEvent(CurrentState.class); + } + + public static void start(Context context) { + context.startService(new Intent(context, RegistrationService.class)); + } + + public static void stop(Context context) { + context.stopService(new Intent(context, RegistrationService.class)); + } + + public static boolean isMissedCall(String senderId) { + // very quick way to check if we are using missed call based verification + return senderId != null && senderId.endsWith("???"); + } + + public static int getChallengeLength(String senderId) { + int count = 0; + if (senderId != null) { + for (int i = senderId.length()-1; i >= 0; i--) { + if (senderId.charAt(i) == '?') + count++; + else + break; + } + } + return count; + } + + /** Handles special numbers not handled by libphonenumber. */ + public static boolean isSpecialNumber(Phonenumber.PhoneNumber number) { + if (number.getCountryCode() == 31) { + // handle special M2M numbers: 11 digits starting with 097[0-8] + final Pattern regex = Pattern.compile("^97[0-8][0-9]{8}$"); + Matcher m = regex.matcher(String.valueOf(number.getNationalNumber())); + return m.matches(); + } + return false; + } + + /** + * Handles special cases in a parsed phone number. + * @param phoneNumber the phone number to check. It will be modified in place. + */ + public static void handleSpecialCases(Phonenumber.PhoneNumber phoneNumber) { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + + // Argentina numbering rules + int argCode = util.getCountryCodeForRegion("AR"); + if (phoneNumber.getCountryCode() == argCode) { + // forcibly add the 9 between country code and national number + long nsn = phoneNumber.getNationalNumber(); + if (firstDigit(nsn) != 9) { + phoneNumber.setNationalNumber(addSignificantDigits(nsn, 9)); + } + } + } + + static long addSignificantDigits(long n, int ds) { + final long orig = n; + int count = 1; + while (n < -9 || 9 < n) { + n /= 10; + count++; + } + long power = ds * (long) Math.pow(10, count); + return orig + power; + } + + private static int firstDigit(long n) { + while (n < -9 || 9 < n) n /= 10; + return (int) Math.abs(n); + } + + /** + * Converts pretty much any phone number into E.164 format. + * @param myNumber used to take the country code if not found in the number + * @param lastResortCc manual country code last resort + * @throws IllegalArgumentException if no country code is available. + */ + public static String fixNumber(Context context, String number, String myNumber, int lastResortCc) + throws NumberParseException { + + final TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + String myRegionCode = tm.getSimCountryIso(); + if (myRegionCode != null) + myRegionCode = myRegionCode.toUpperCase(Locale.US); + + return fixNumber(number, myNumber, myRegionCode, lastResortCc); + } + + static String fixNumber(String number, String myNumber, String myRegionCode, int lastResortCc) + throws NumberParseException { + + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + try { + if (myNumber != null) { + Phonenumber.PhoneNumber myNum = util.parse(myNumber, myRegionCode); + // use region code found in my number + myRegionCode = util.getRegionCodeForNumber(myNum); + } + } + catch (NumberParseException e) { + // ehm :) + } + + Phonenumber.PhoneNumber parsedNum; + try { + parsedNum = util.parse(number, myRegionCode); + } + catch (NumberParseException e) { + // parse failed with default region code, try last resort + if (lastResortCc > 0) { + myRegionCode = util.getRegionCodeForCountryCode(lastResortCc); + parsedNum = util.parse(number, myRegionCode); + } + else { + if (BuildConfig.DEBUG) { + return number.startsWith("+") ? number : ("+1" + number); + } + throw e; + } + } + + handleSpecialCases(parsedNum); + + // a NumberParseException would have been thrown at this point + return util.format(parsedNum, PhoneNumberUtil.PhoneNumberFormat.E164); + } + + public static String formatForDisplay(Phonenumber.PhoneNumber phone) { + return PhoneNumberUtil.getInstance() + .format(phone, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL); + } + + public static String formatForDisplay(String phoneNumber) { + try { + return formatForDisplay(PhoneNumberUtil.getInstance() + .parse(phoneNumber, null)); + } + catch (NumberParseException e) { + return phoneNumber; + } + } + + /** Returns the (parsed) number stored in this device SIM card. */ + @SuppressLint({"HardwareIds", "MissingPermission"}) + @RequiresPermission(Manifest.permission.READ_PHONE_STATE) + public static Phonenumber.PhoneNumber getMyNumber(Context context) { + try { + final TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + final String regionCode = tm.getSimCountryIso().toUpperCase(Locale.US); + return PhoneNumberUtil.getInstance().parse(tm.getLine1Number(), regionCode); + } + catch (Exception e) { + return null; + } + } + + /** Returns the localized region name for the given region code. */ + public static String getRegionDisplayName(String regionCode, Locale language) { + return (regionCode == null || regionCode.equals("ZZ") || + regionCode.equals(PhoneNumberUtil.REGION_CODE_FOR_NON_GEO_ENTITY)) + ? "" : new Locale("", regionCode).getDisplayCountry(language); + } + + private static class AccountRemovalCallback implements AccountManagerCallback { + private Context context; + private final android.accounts.Account account; + private final String passphrase; + private final byte[] privateKeyData; + private final byte[] publicKeyData; + private final byte[] bridgeCertData; + private final String name; + private final String serverUri; + private final String serviceTermsUrl; + private final Map trustedKeys; + private final ServerList serverList; + + AccountRemovalCallback(Context context, android.accounts.Account account, + String passphrase, byte[] privateKeyData, byte[] publicKeyData, + byte[] bridgeCertData, String name, String serverUri, String serviceTermsUrl, + Map trustedKeys, ServerList serverList) { + this.context = context.getApplicationContext(); + this.account = account; + this.passphrase = passphrase; + this.privateKeyData = privateKeyData; + this.publicKeyData = publicKeyData; + this.bridgeCertData = bridgeCertData; + this.name = name; + this.serverUri = serverUri; + this.serviceTermsUrl = serviceTermsUrl; + this.trustedKeys = trustedKeys; + this.serverList = serverList; + } + + @Override + public void run(AccountManagerFuture result) { + // store trusted keys + if (trustedKeys != null) { + Keyring.setTrustedKeys(context, trustedKeys); + } + + AccountManager am = (AccountManager) context + .getSystemService(Context.ACCOUNT_SERVICE); + + // account userdata + Bundle data = new Bundle(); + data.putString(Authenticator.DATA_PRIVATEKEY, Base64.encodeToString(privateKeyData, Base64.NO_WRAP)); + data.putString(Authenticator.DATA_PUBLICKEY, Base64.encodeToString(publicKeyData, Base64.NO_WRAP)); + data.putString(Authenticator.DATA_BRIDGECERT, Base64.encodeToString(bridgeCertData, Base64.NO_WRAP)); + data.putString(Authenticator.DATA_NAME, name); + data.putString(Authenticator.DATA_SERVER_URI, serverUri); + data.putString(Authenticator.DATA_SERVICE_TERMS_URL, serviceTermsUrl); + data.putString(Authenticator.DATA_SERVER_LIST, DataUtils.serializeProperties(serverList.toProperties())); + + // this is the password to the private key + am.addAccountExplicitly(account, passphrase, data); + + // put data once more (workaround for Android bug http://stackoverflow.com/a/11698139/1045199) + am.setUserData(account, Authenticator.DATA_PRIVATEKEY, data.getString(Authenticator.DATA_PRIVATEKEY)); + am.setUserData(account, Authenticator.DATA_PUBLICKEY, data.getString(Authenticator.DATA_PUBLICKEY)); + am.setUserData(account, Authenticator.DATA_BRIDGECERT, data.getString(Authenticator.DATA_BRIDGECERT)); + am.setUserData(account, Authenticator.DATA_NAME, data.getString(Authenticator.DATA_NAME)); + am.setUserData(account, Authenticator.DATA_SERVER_URI, data.getString(Authenticator.DATA_SERVER_URI)); + am.setUserData(account, Authenticator.DATA_SERVICE_TERMS_URL, data.getString(Authenticator.DATA_SERVICE_TERMS_URL)); + am.setUserData(account, Authenticator.DATA_SERVER_LIST, data.getString(Authenticator.DATA_SERVER_LIST)); + + // Set contacts sync for this account. + ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true); + ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1); + + // clear old roster information + SQLiteRosterStore.purge(context); + + BUS.post(new AccountCreatedEvent(new MyAccount(account, am))); + } + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/AbortRequest.java b/app/src/main/java/org/kontalk/service/registration/event/AbortRequest.java new file mode 100644 index 000000000..702acf55c --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/AbortRequest.java @@ -0,0 +1,28 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Send this event to abort the registration process at (hopefully) any point. + * @author Daniele Ricci + */ +public class AbortRequest { + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/AcceptTermsRequest.java b/app/src/main/java/org/kontalk/service/registration/event/AcceptTermsRequest.java new file mode 100644 index 000000000..c438fb861 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/AcceptTermsRequest.java @@ -0,0 +1,36 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Event posted by the registration service to warn the user that service terms + * must be accepted before continuing. + * Once the user accepts, you should post a {@link TermsAcceptedEvent}. + * @author Daniele Ricci + */ +public class AcceptTermsRequest { + + public final String termsUrl; + + public AcceptTermsRequest(String termsUrl) { + this.termsUrl = termsUrl; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/AccountCreatedEvent.java b/app/src/main/java/org/kontalk/service/registration/event/AccountCreatedEvent.java new file mode 100644 index 000000000..69115a8fb --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/AccountCreatedEvent.java @@ -0,0 +1,35 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + +import org.kontalk.authenticator.MyAccount; + + +/** + * Event posted by the registration service when the account is created. + * @author Daniele Ricci + */ +public class AccountCreatedEvent { + + public final MyAccount account; + + public AccountCreatedEvent(MyAccount account) { + this.account = account; + } +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/ChallengeError.java b/app/src/main/java/org/kontalk/service/registration/event/ChallengeError.java new file mode 100644 index 000000000..278b3dc90 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/ChallengeError.java @@ -0,0 +1,35 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Event posted by the registration service if it received errors from the + * server while sending the challenge code. + * @author Daniele Ricci + */ +public class ChallengeError { + + public final Exception exception; + + public ChallengeError(Exception exception) { + this.exception = exception; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/ChallengeRequest.java b/app/src/main/java/org/kontalk/service/registration/event/ChallengeRequest.java new file mode 100644 index 000000000..f49cf66b2 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/ChallengeRequest.java @@ -0,0 +1,35 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Send this event to the registration service to send the registration challenge + * to the server (i.e. the verification code). + * @author Daniele Ricci + */ +public class ChallengeRequest { + + public final String code; + + public ChallengeRequest(String code) { + this.code = code; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/FallbackVerificationRequest.java b/app/src/main/java/org/kontalk/service/registration/event/FallbackVerificationRequest.java new file mode 100644 index 000000000..1611b1ee1 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/FallbackVerificationRequest.java @@ -0,0 +1,29 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Request the registration service to fallback on another challenge type on + * the same server it tried in the first place. + * @author Daniele Ricci + */ +public class FallbackVerificationRequest { + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/ImportKeyError.java b/app/src/main/java/org/kontalk/service/registration/event/ImportKeyError.java new file mode 100644 index 000000000..6db079a33 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/ImportKeyError.java @@ -0,0 +1,35 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Event posted by the registration service if it received errors from the + * server while trying to import the private key. + * @author Daniele Ricci + */ +public class ImportKeyError { + + public final Exception exception; + + public ImportKeyError(Exception exception) { + this.exception = exception; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/ImportKeyRequest.java b/app/src/main/java/org/kontalk/service/registration/event/ImportKeyRequest.java new file mode 100644 index 000000000..60ba59ab4 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/ImportKeyRequest.java @@ -0,0 +1,61 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + +import java.io.InputStream; + +import androidx.annotation.Nullable; + +import org.kontalk.client.EndpointServer; +import org.kontalk.service.registration.RegistrationService; + + +/** + * Post this to request to the registration service to import a keypack from + * a zip file. + * @author Daniele Ricci + */ +public class ImportKeyRequest { + + /** Will be null if we are to auto-detect it from the key. */ + public final EndpointServer server; + + /** An input stream from the personal key pack file. */ + public final InputStream in; + + /** The passphrase protecting the personal key. */ + public final String passphrase; + + /** If the server rejects our key, set this to true to proceed to verification. */ + public final boolean fallbackVerification; + + /** Will be needed if we'll fallback to normal verification. */ + @RegistrationService.BrandImageSize + public final int brandImageSize; + + public ImportKeyRequest(@Nullable EndpointServer server, InputStream in, String passphrase, + boolean fallbackVerification, @RegistrationService.BrandImageSize int brandImageSize) { + this.server = server; + this.in = in; + this.passphrase = passphrase; + this.fallbackVerification = fallbackVerification; + this.brandImageSize = brandImageSize; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/KeyReceivedEvent.java b/app/src/main/java/org/kontalk/service/registration/event/KeyReceivedEvent.java new file mode 100644 index 000000000..e456e40d2 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/KeyReceivedEvent.java @@ -0,0 +1,37 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Event posted by the registration service to notify that an account has been + * retrieved from the server. + * @author Daniele Ricci + */ +public class KeyReceivedEvent { + + public final byte[] privateKeyData; + public final byte[] publicKeyData; + + public KeyReceivedEvent(byte[] privateKeyData, byte[] publicKeyData) { + this.privateKeyData = privateKeyData; + this.publicKeyData = publicKeyData; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/LoginTestEvent.java b/app/src/main/java/org/kontalk/service/registration/event/LoginTestEvent.java new file mode 100644 index 000000000..0e5f514db --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/LoginTestEvent.java @@ -0,0 +1,38 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Event posted by the registration service after a login test. + * @author Daniele Ricci + */ +public class LoginTestEvent { + + public final Exception exception; + + public LoginTestEvent() { + this(null); + } + + public LoginTestEvent(Exception exception) { + this.exception = exception; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/PassphraseInputEvent.java b/app/src/main/java/org/kontalk/service/registration/event/PassphraseInputEvent.java new file mode 100644 index 000000000..f498436ba --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/PassphraseInputEvent.java @@ -0,0 +1,35 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Event posted by UI when a passphrase has been typed in by the user. + * Depending on its state, the registration service will act accordingly. + * @author Daniele Ricci + */ +public class PassphraseInputEvent { + + public final String passphrase; + + public PassphraseInputEvent(String passphrase) { + this.passphrase = passphrase; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/RetrieveKeyError.java b/app/src/main/java/org/kontalk/service/registration/event/RetrieveKeyError.java new file mode 100644 index 000000000..6aa241876 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/RetrieveKeyError.java @@ -0,0 +1,35 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Event posted by the registration service if it received errors from the + * server while requesting the private key. + * @author Daniele Ricci + */ +public class RetrieveKeyError { + + public final Exception exception; + + public RetrieveKeyError(Exception exception) { + this.exception = exception; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/RetrieveKeyRequest.java b/app/src/main/java/org/kontalk/service/registration/event/RetrieveKeyRequest.java new file mode 100644 index 000000000..62d811144 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/RetrieveKeyRequest.java @@ -0,0 +1,43 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +import androidx.annotation.NonNull; + +import org.kontalk.client.EndpointServer; + +/** + * Post this event to ask the registration service to obtain a private key from + * the server. + * @author Daniele Ricci + */ +public class RetrieveKeyRequest { + + public final EndpointServer server; + public final String phoneNumber; + public final String privateKeyToken; + + public RetrieveKeyRequest(@NonNull EndpointServer server, @NonNull String phoneNumber, @NonNull String privateKeyToken) { + this.server = server; + this.phoneNumber = phoneNumber; + this.privateKeyToken = privateKeyToken; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/ServerCheckError.java b/app/src/main/java/org/kontalk/service/registration/event/ServerCheckError.java new file mode 100644 index 000000000..78c6745ad --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/ServerCheckError.java @@ -0,0 +1,41 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.packet.StanzaError; + + +/** + * Event posted by the registration service if the server reported that it + * doesn't support registration. The error is posted after all servers were + * tried. + * @author Daniele Ricci + */ +public class ServerCheckError extends VerificationError { + + public ServerCheckError(XMPPException.XMPPErrorException exception) { + super(exception); + } + + public StanzaError getStanzaError() { + return ((XMPPException.XMPPErrorException) exception).getStanzaError(); + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/TermsAcceptedEvent.java b/app/src/main/java/org/kontalk/service/registration/event/TermsAcceptedEvent.java new file mode 100644 index 000000000..2b43be5b9 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/TermsAcceptedEvent.java @@ -0,0 +1,28 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Event posted by the UI to notify that the user accepted the service terms. + * The registration service can therefore continue its job. + * @author Daniele Ricci + */ +public class TermsAcceptedEvent { +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/ThrottlingError.java b/app/src/main/java/org/kontalk/service/registration/event/ThrottlingError.java new file mode 100644 index 000000000..b04bd36f0 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/ThrottlingError.java @@ -0,0 +1,40 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.packet.StanzaError; + + +/** + * Event posted by the registration service if the server reported that it + * the client should refrain from trying again for some time. + * @author Daniele Ricci + */ +public class ThrottlingError extends VerificationError { + + public ThrottlingError(XMPPException.XMPPErrorException exception) { + super(exception); + } + + public StanzaError getStanzaError() { + return ((XMPPException.XMPPErrorException) exception).getStanzaError(); + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/UserConflictError.java b/app/src/main/java/org/kontalk/service/registration/event/UserConflictError.java new file mode 100644 index 000000000..f273a01bc --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/UserConflictError.java @@ -0,0 +1,40 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.packet.StanzaError; + + +/** + * Event posted by the registration service if the server reported that another + * user is already registered with the given phone number. + * @author Daniele Ricci + */ +public class UserConflictError extends VerificationError { + + public UserConflictError(XMPPException.XMPPErrorException exception) { + super(exception); + } + + public StanzaError getStanzaError() { + return ((XMPPException.XMPPErrorException) exception).getStanzaError(); + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/VerificationError.java b/app/src/main/java/org/kontalk/service/registration/event/VerificationError.java new file mode 100644 index 000000000..56a28cc8a --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/VerificationError.java @@ -0,0 +1,35 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Event posted by the registration service if it received errors from the + * server while requesting user registration. + * @author Daniele Ricci + */ +public class VerificationError { + + public final Exception exception; + + public VerificationError(Exception exception) { + this.exception = exception; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/VerificationRequest.java b/app/src/main/java/org/kontalk/service/registration/event/VerificationRequest.java new file mode 100644 index 000000000..d8e482a85 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/VerificationRequest.java @@ -0,0 +1,49 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + +import org.kontalk.client.EndpointServer; + +import static org.kontalk.service.registration.RegistrationService.*; + + +/** + * Phone number verification request event. + * @author Daniele Ricci + */ +public class VerificationRequest { + + public final String phoneNumber; + public final String displayName; + public final EndpointServer.EndpointServerProvider serverProvider; + public final boolean force; + @BrandImageSize + public final int brandImageSize; + + public VerificationRequest(String phoneNumber, String displayName, + EndpointServer.EndpointServerProvider serverProvider, + boolean force, @BrandImageSize int brandImageSize) { + this.phoneNumber = phoneNumber; + this.displayName = displayName; + this.serverProvider = serverProvider; + this.force = force; + this.brandImageSize = brandImageSize; + } + +} diff --git a/app/src/main/java/org/kontalk/service/registration/event/VerificationRequestedEvent.java b/app/src/main/java/org/kontalk/service/registration/event/VerificationRequestedEvent.java new file mode 100644 index 000000000..cefee7ed0 --- /dev/null +++ b/app/src/main/java/org/kontalk/service/registration/event/VerificationRequestedEvent.java @@ -0,0 +1,43 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.service.registration.event; + + +/** + * Event posted by the registration service to notify that the server accepted + * the verification request and is waiting for a challenge by the user. + * @author Daniele Ricci + */ +public class VerificationRequestedEvent { + + public final String sender; + public final String challenge; + public final String brandImageUrl; + public final String brandLink; + public final boolean canFallback; + + public VerificationRequestedEvent(String sender, String challenge, String brandImageUrl, String brandLink, boolean canFallback) { + this.sender = sender; + this.challenge = challenge; + this.brandImageUrl = brandImageUrl; + this.brandLink = brandLink; + this.canFallback = canFallback; + } + +} diff --git a/app/src/main/java/org/kontalk/sync/ContactsSyncAdapterService.java b/app/src/main/java/org/kontalk/sync/ContactsSyncAdapterService.java index b832a7118..8129ea61b 100644 --- a/app/src/main/java/org/kontalk/sync/ContactsSyncAdapterService.java +++ b/app/src/main/java/org/kontalk/sync/ContactsSyncAdapterService.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/sync/SyncAdapter.java b/app/src/main/java/org/kontalk/sync/SyncAdapter.java index be0f34dcf..89eed1d9b 100644 --- a/app/src/main/java/org/kontalk/sync/SyncAdapter.java +++ b/app/src/main/java/org/kontalk/sync/SyncAdapter.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,10 +18,7 @@ package org.kontalk.sync; -import org.kontalk.authenticator.Authenticator; -import org.kontalk.provider.UsersProvider; -import org.kontalk.service.msgcenter.MessageCenterService; -import org.kontalk.util.Preferences; +import java.util.Locale; import android.accounts.Account; import android.accounts.OperationCanceledException; @@ -34,8 +31,14 @@ import android.os.Bundle; import android.os.SystemClock; import android.provider.ContactsContract; -import android.support.v4.content.LocalBroadcastManager; -import android.util.Log; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.authenticator.MyAccount; +import org.kontalk.provider.UsersProvider; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.util.Preferences; /** @@ -71,25 +74,27 @@ public void onPerformSync(Account account, Bundle extras, String authority, // broadcast sync start mBroadcastManager.sendBroadcast(new Intent(ACTION_SYNC_START)); + // ensure preferences are initialized + // Kontalk.onCreate might run later on some devices + Preferences.init(getContext()); + final long startTime = SystemClock.elapsedRealtime(); boolean force = extras.getBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, false); // do not start if offline - if (Preferences.getOfflineMode(mContext)) { + if (Preferences.getOfflineMode()) { Log.d(TAG, "not requesting sync - offline mode"); return; } - // do not start if no server available (limbo state) - if (Preferences.getEndpointServer(mContext) == null) { - Log.d(TAG, "no server available - aborting"); + // do not start if no account available + if (Kontalk.get().getDefaultAccount() == null) { + Log.d(TAG, "no account available - aborting"); return; } if (!force) { - long lastSync = Preferences.getLastSyncTimestamp(mContext); - long diff = (System.currentTimeMillis() - lastSync) / 1000; - if (lastSync >= 0 && diff < MAX_SYNC_DELAY) { + if (isThrottling()) { Log.d(TAG, "not starting sync - throttling"); // TEST do not delay - syncResult.delayUntil = (long) diff; return; @@ -98,14 +103,14 @@ public void onPerformSync(Account account, Bundle extras, String authority, Log.i(TAG, "sync started (authority=" + authority + ")"); // avoid other syncs to get scheduled in the meanwhile - Preferences.setLastSyncTimestamp(mContext, System.currentTimeMillis()); + Preferences.setLastSyncTimestamp(System.currentTimeMillis()); ContentProviderClient usersProvider = getContext().getContentResolver() .acquireContentProviderClient(UsersProvider.AUTHORITY); try { // hold a reference to the message center while syncing - MessageCenterService.hold(mContext); + MessageCenterService.hold(mContext, true); // start sync mSyncer = new Syncer(mContext); mSyncer.performSync(mContext, account, authority, @@ -119,10 +124,11 @@ public void onPerformSync(Account account, Bundle extras, String authority, MessageCenterService.release(mContext); // release user provider usersProvider.release(); + + Preferences.setLastSyncTimestamp(System.currentTimeMillis()); // some stats :) long endTime = SystemClock.elapsedRealtime(); - Preferences.setLastSyncTimestamp(mContext, endTime); - Log.d(TAG, String.format("sync took %.5f seconds", + Log.d(TAG, String.format(Locale.US, "sync took %.5f seconds", ((float) (endTime - startTime)) / 1000)); } } @@ -135,7 +141,9 @@ public void onPerformSync(Account account, Bundle extras, String authority, @Override public void onSyncCanceled() { super.onSyncCanceled(); - mSyncer.onSyncCanceled(); + if (mSyncer != null) { + mSyncer.onSyncCanceled(); + } } /** @@ -143,47 +151,54 @@ public void onSyncCanceled() { * @return true if the sync has been actually requested to the system. */ public static boolean requestSync(Context context, boolean force) { - if (!force) { - long lastSync = Preferences.getLastSyncTimestamp(context); - float diff = (System.currentTimeMillis() - lastSync) / 1000; - if (lastSync >= 0 && diff < MAX_SYNC_DELAY) { - Log.d(TAG, "not requesting sync - throttling"); - return false; - } + if (!force && isThrottling()) { + Log.d(TAG, "not requesting sync - throttling"); + return false; } // do not start if offline - if (Preferences.getOfflineMode(context)) { + if (Preferences.getOfflineMode()) { Log.d(TAG, "not requesting sync - offline mode"); return false; } - Account acc = Authenticator.getDefaultAccount(context); + MyAccount account = Kontalk.get().getDefaultAccount(); + if (account == null) { + Log.d(TAG, "no account found"); + return false; + } + Bundle extra = new Bundle(); // override auto-sync and background data settings extra.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); // put our sync ahead of other sync operations :) extra.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true); - ContentResolver.requestSync(acc, ContactsContract.AUTHORITY, extra); + ContentResolver.requestSync(account.getSystemAccount(), ContactsContract.AUTHORITY, extra); return true; } + public static boolean isThrottling() { + long lastSync = Preferences.getLastSyncTimestamp(); + float diff = (System.currentTimeMillis() - lastSync) / 1000f; + return (lastSync >= 0 && diff < MAX_SYNC_DELAY); + } + public static boolean isPending(Context context) { - Account acc = Authenticator.getDefaultAccount(context); - return ContentResolver.isSyncPending(acc, ContactsContract.AUTHORITY); + MyAccount account = Kontalk.get().getDefaultAccount(); + return account != null && ContentResolver.isSyncPending(account.getSystemAccount(), ContactsContract.AUTHORITY); } public static boolean isActive(Context context) { - Account acc = Authenticator.getDefaultAccount(context); - return ContentResolver.isSyncActive(acc, ContactsContract.AUTHORITY); + MyAccount account = Kontalk.get().getDefaultAccount(); + return account != null && ContentResolver.isSyncActive(account.getSystemAccount(), ContactsContract.AUTHORITY); } public static boolean isError(SyncResult syncResult) { return syncResult.databaseError || syncResult.stats.numIoExceptions > 0; } - public static String getIQPacketId() { - return Syncer.IQ_PACKET_ID; + public static String getIQKeysPacketId() { + return SyncProcedure.IQ_KEYS_PACKET_ID; } } diff --git a/app/src/main/java/org/kontalk/sync/SyncProcedure.java b/app/src/main/java/org/kontalk/sync/SyncProcedure.java new file mode 100644 index 000000000..f579cd165 --- /dev/null +++ b/app/src/main/java/org/kontalk/sync/SyncProcedure.java @@ -0,0 +1,351 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.sync; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.jivesoftware.smack.packet.StanzaError; +import org.jivesoftware.smack.util.StringUtils; +import org.jxmpp.jid.BareJid; +import org.jxmpp.jid.Jid; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.service.msgcenter.event.BlocklistEvent; +import org.kontalk.service.msgcenter.event.BlocklistRequest; +import org.kontalk.service.msgcenter.event.ConnectedEvent; +import org.kontalk.service.msgcenter.event.DisconnectedEvent; +import org.kontalk.service.msgcenter.event.LastActivityEvent; +import org.kontalk.service.msgcenter.event.LastActivityRequest; +import org.kontalk.service.msgcenter.event.PresenceEvent; +import org.kontalk.service.msgcenter.event.PresenceRequest; +import org.kontalk.service.msgcenter.event.PublicKeyEvent; +import org.kontalk.service.msgcenter.event.PublicKeyRequest; +import org.kontalk.service.msgcenter.event.RosterMatchEvent; +import org.kontalk.service.msgcenter.event.RosterMatchRequest; +import org.kontalk.service.msgcenter.event.UnsubscribeRequest; + + +/** + * The sync procedure. + * @author Daniele Ricci + */ +public class SyncProcedure { + // using SyncAdapter tag + private static final String TAG = SyncAdapter.TAG; + + /** Random packet id used for requesting public keys. */ + static final String IQ_KEYS_PACKET_ID = StringUtils.randomString(10); + + /** Random packet id used for requesting the blocklist. */ + @VisibleForTesting + static final String IQ_BLOCKLIST_PACKET_ID = StringUtils.randomString(10); + + /** Max number of items in a roster match request. */ + private static final int MAX_ROSTER_MATCH_SIZE = 500; + + private List mResponse; + private final WeakReference mNotifyTo; + + private final List mJidList; + private int mRosterParts = -1; + private String[] mIqIds; + private String mPresenceId; + + private int mPresenceCount; + private int mPubkeyCount; + private int mRosterCount; + /** Packet id list for not matched contacts (in roster but not matched on server). */ + private Set mNotMatched = new HashSet<>(); + private boolean mNlocklistReceived; + + private EventBus mServiceBus = MessageCenterService.bus(); + + public final static class PresenceItem { + public BareJid from; + public String status; + public String rosterName; + public long timestamp; + public byte[] publicKey; + public boolean blocked; + public boolean presence; + /** True if found during roster match. */ + public boolean matched; + /** Discard this entry: it has not been found on server. */ + public boolean discarded; + + /** Mainly for tests. */ + @Override + public boolean equals(@Nullable Object obj) { + return (obj instanceof PresenceItem) && + ((PresenceItem) obj).from.equals(from) && + StringUtils.nullSafeCharSequenceEquals(((PresenceItem) obj).status, status) && + StringUtils.nullSafeCharSequenceEquals(((PresenceItem) obj).rosterName, rosterName) && + ((PresenceItem) obj).timestamp == timestamp && + // too much? -- ((PresenceItem) obj).publicKey... + ((PresenceItem) obj).blocked == blocked && + ((PresenceItem) obj).presence == presence && + ((PresenceItem) obj).matched == matched && + ((PresenceItem) obj).discarded == discarded; + } + } + + SyncProcedure(List jidList, Object notifyTo) { + mNotifyTo = new WeakReference<>(notifyTo); + mJidList = jidList; + } + + @Subscribe(sticky = true, threadMode = ThreadMode.BACKGROUND) + public synchronized void onConnected(ConnectedEvent event) { + Object w = mNotifyTo.get(); + if (w != null) { + // request a roster match + mRosterParts = getRosterParts(mJidList); + mIqIds = new String[mRosterParts]; + for (int i = 0; i < mRosterParts; i++) { + int end = (i+1)*MAX_ROSTER_MATCH_SIZE; + if (end >= mJidList.size()) + end = mJidList.size(); + List slice = mJidList.subList(i*MAX_ROSTER_MATCH_SIZE, end); + + mIqIds[i] = StringUtils.randomString(6); + + mServiceBus.post(new RosterMatchRequest(mIqIds[i], slice.toArray(new String[0]))); + } + } + } + + @Subscribe(sticky = true, threadMode = ThreadMode.BACKGROUND) + public synchronized void onDisconnected(DisconnectedEvent event) { + mResponse = null; + finish(); + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public synchronized void onPresence(PresenceEvent event) { + // consider only presences received *after* roster response + if (mResponse != null && mPresenceId != null) { + if (event.type != null && mPresenceId.equals(event.id)) { + // update presence item data + BareJid bareJid = event.jid.asBareJid(); + PresenceItem item = getPresenceItem(bareJid); + item.status = event.status; + item.timestamp = event.delay != null ? event.delay.getTime() : -1; + item.rosterName = event.rosterName; + if (!item.presence) { + item.presence = true; + // increment presence count + mPresenceCount++; + // check user existance (only if subscription is "both") + if (!item.matched && event.subscribedFrom && event.subscribedTo) { + // verify actual user existance through last activity + String lastActivityId = StringUtils.randomString(6); + requestLastActivity(item.from, lastActivityId); + mNotMatched.add(lastActivityId); + } + } + } + } + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public synchronized void onRosterMatch(RosterMatchEvent event) { + // TODO handle errors + for (String iqId : mIqIds) { + if (iqId.equals(event.id)) { + // decrease roster parts counter + mRosterParts--; + + if (event.jids != null) { + mRosterCount += event.jids.length; + if (mResponse == null) { + // prepare list to be filled in with presence data + mResponse = new ArrayList<>(mRosterCount); + } + for (Jid jid : event.jids) { + PresenceItem p = new PresenceItem(); + p.from = jid.asBareJid(); + p.matched = true; + mResponse.add(p); + } + } + + if (mRosterParts <= 0) { + // all roster parts received + + if (mRosterCount == 0 && mNlocklistReceived) { + // no roster elements + finish(); + } + else { + Object w = mNotifyTo.get(); + if (w != null) { + // request presence data for the whole roster + mPresenceId = StringUtils.randomString(6); + requestPresenceData(mPresenceId); + // request public keys for the whole roster + requestPublicKeys(IQ_KEYS_PACKET_ID); + // request block list + requestBlocklist(IQ_BLOCKLIST_PACKET_ID); + } + } + } + + // no need to continue + break; + } + } + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public synchronized void onBlocklist(BlocklistEvent event) { + if (IQ_BLOCKLIST_PACKET_ID.equals(event.id)) { + mNlocklistReceived = true; + + if (event.jids != null) { + for (Jid jid : event.jids) { + // see if bare JID is present in roster response + BareJid compare = jid.asBareJid(); + for (PresenceItem item : mResponse) { + if (item.from.equals(compare)) { + item.blocked = true; + break; + } + } + } + } + + // done with presence data and blocklist + if (mPubkeyCount >= mPresenceCount && mNotMatched.size() == 0) + finish(); + } + } + + /** Last activity (for user existance verification). */ + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public synchronized void onLastActivity(LastActivityEvent event) { + if (mNotMatched.contains(event.id)) { + mNotMatched.remove(event.id); + + // consider only item-not-found (404) errors + if (event.getStanzaErrorCondition() == StanzaError.Condition.item_not_found) { + // user does not exist! + // discard entry + discardPresenceItem(event.jid.asBareJid()); + // unsubscribe! + unsubscribe(event.jid.asBareJid()); + + if (mPubkeyCount >= mPresenceCount && mNlocklistReceived && mNotMatched.size() == 0) + finish(); + } + } + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public synchronized void onPublicKey(PublicKeyEvent event) { + if (mResponse != null) { + if (IQ_KEYS_PACKET_ID.equals(event.id)) { + // see if bare JID is present in roster response + BareJid compare = event.jid.asBareJid(); + for (PresenceItem item : mResponse) { + if (item.from.equals(compare)) { + item.publicKey = event.publicKey; + + // increment vcard count + mPubkeyCount++; + break; + } + } + + // done with presence data and blocklist + if (mPubkeyCount == mPresenceCount && mNlocklistReceived && mNotMatched.size() == 0) + finish(); + } + } + } + + private void discardPresenceItem(BareJid jid) { + for (PresenceItem item : mResponse) { + if (item.from.equals(jid)) { + item.discarded = true; + return; + } + } + } + + private PresenceItem getPresenceItem(BareJid jid) { + for (PresenceItem item : mResponse) { + if (item.from.equals(jid)) + return item; + } + + // add item if not found + PresenceItem item = new PresenceItem(); + item.from = jid; + mResponse.add(item); + return item; + } + + private void requestPresenceData(String id) { + mServiceBus.post(new PresenceRequest(id, null)); + } + + private void requestLastActivity(BareJid jid, String id) { + mServiceBus.post(new LastActivityRequest(id, jid)); + } + + private void requestPublicKeys(String id) { + mServiceBus.post(new PublicKeyRequest(id, null)); + } + + private void requestBlocklist(String id) { + mServiceBus.post(new BlocklistRequest(id)); + } + + private void unsubscribe(BareJid jid) { + mServiceBus.post(new UnsubscribeRequest(jid)); + } + + private int getRosterParts(List jidList) { + return (int) Math.ceil((double) jidList.size() / MAX_ROSTER_MATCH_SIZE); + } + + public List getResponse() { + return (mRosterCount >= 0) ? mResponse : null; + } + + private void finish() { + Object w = mNotifyTo.get(); + if (w != null) { + synchronized (w) { + w.notifyAll(); + } + } + } + +} diff --git a/app/src/main/java/org/kontalk/sync/Syncer.java b/app/src/main/java/org/kontalk/sync/Syncer.java index 84b149ac7..ede057f3a 100644 --- a/app/src/main/java/org/kontalk/sync/Syncer.java +++ b/app/src/main/java/org/kontalk/sync/Syncer.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,49 +18,44 @@ package org.kontalk.sync; -import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import org.jivesoftware.smack.packet.Presence; -import org.jivesoftware.smack.util.StringUtils; +import org.greenrobot.eventbus.EventBus; import org.jxmpp.util.XmppStringUtils; -import org.spongycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKey; import android.accounts.Account; import android.accounts.OperationCanceledException; -import android.content.BroadcastReceiver; +import android.annotation.TargetApi; import android.content.ContentProviderClient; import android.content.ContentProviderOperation; import android.content.ContentValues; import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; import android.content.SyncResult; import android.database.Cursor; import android.net.Uri; +import android.os.Build; import android.os.Process; import android.os.RemoteException; import android.provider.ContactsContract; import android.provider.ContactsContract.Data; import android.provider.ContactsContract.RawContacts; -import android.support.v4.content.LocalBroadcastManager; import android.text.TextUtils; -import android.util.Log; -import org.kontalk.BuildConfig; +import org.kontalk.Log; import org.kontalk.R; import org.kontalk.authenticator.Authenticator; -import org.kontalk.client.NumberValidator; +import org.kontalk.authenticator.MyAccount; import org.kontalk.crypto.PGP; import org.kontalk.crypto.PGPUserID; import org.kontalk.data.Contact; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.MyUsers; import org.kontalk.provider.MyUsers.Users; -import org.kontalk.provider.UsersProvider; import org.kontalk.service.msgcenter.MessageCenterService; -import org.kontalk.util.XMPPUtils; /** @@ -88,224 +83,16 @@ public class Syncer { /** {@link RawContacts} column for the JID. */ public static final String RAW_COLUMN_USERID = RawContacts.SYNC3; - /** Random packet id used for requesting public keys. */ - static final String IQ_PACKET_ID = StringUtils.randomString(10); - private volatile boolean mCanceled; private final Context mContext; - private final static class PresenceItem { - public String from; - public String status; - public long timestamp; - public byte[] publicKey; - public boolean blocked; - public boolean presence; - } - - // FIXME this class should handle most recent/available presence stanzas - private static final class PresenceBroadcastReceiver extends BroadcastReceiver { - /** Max number of items in a roster match request. */ - private static final int MAX_ROSTER_MATCH_SIZE = 500; - - private List response; - private final WeakReference notifyTo; - - private final List jidList; - private int rosterParts = -1; - private String[] iq; - private String presenceId; - - private int presenceCount; - private int pubkeyCount; - private int rosterCount; - private boolean blocklistReceived; - - public PresenceBroadcastReceiver(List jidList, Syncer notifyTo) { - this.notifyTo = new WeakReference(notifyTo); - this.jidList = jidList; - } - - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - - if (MessageCenterService.ACTION_PRESENCE.equals(action)) { - - // consider only presences received *after* roster response - if (response != null && presenceId != null) { - - String jid = intent.getStringExtra(MessageCenterService.EXTRA_FROM); - String type = intent.getStringExtra(MessageCenterService.EXTRA_TYPE); - String id = intent.getStringExtra(MessageCenterService.EXTRA_PACKET_ID); - if (type != null && presenceId.equals(id)) { - // update presence item data - String bareJid = XmppStringUtils.parseBareJid(jid); - PresenceItem item = getPresenceItem(bareJid); - item.status = intent.getStringExtra(MessageCenterService.EXTRA_STATUS); - item.timestamp = intent.getLongExtra(MessageCenterService.EXTRA_STAMP, -1); - if (!item.presence) { - item.presence = true; - // increment presence count - presenceCount++; - } - } - } - } - - // roster match result received - else if (MessageCenterService.ACTION_ROSTER_MATCH.equals(action)) { - String id = intent.getStringExtra(MessageCenterService.EXTRA_PACKET_ID); - for (String iqId : iq) { - if (iqId.equals(id)) { - // decrease roster parts counter - rosterParts--; - - String[] list = intent.getStringArrayExtra(MessageCenterService.EXTRA_JIDLIST); - if (list != null) { - rosterCount += list.length; - if (response == null) { - // prepare list to be filled in with presence data - response = new ArrayList(rosterCount); - } - for (String jid : list) { - PresenceItem p = new PresenceItem(); - p.from = jid; - response.add(p); - } - } - - if (rosterParts <= 0) { - // all roster parts received - - if (rosterCount == 0 && blocklistReceived) { - // no roster elements - finish(); - } - else { - Syncer w = notifyTo.get(); - if (w != null) { - // request presence data for the whole roster - presenceId = StringUtils.randomString(6); - w.requestPresenceData(presenceId); - // request public keys for the whole roster - w.requestPublicKeys(); - // request block list - w.requestBlocklist(); - } - } - } - - // no need to continue - break; - } - } - } - - else if (MessageCenterService.ACTION_PUBLICKEY.equals(action)) { - if (response != null) { - String jid = intent.getStringExtra(MessageCenterService.EXTRA_FROM); - // see if bare JID is present in roster response - String compare = XmppStringUtils.parseBareJid(jid); - for (PresenceItem item : response) { - if (XmppStringUtils.parseBareJid(item.from).equalsIgnoreCase(compare)) { - item.publicKey = intent.getByteArrayExtra(MessageCenterService.EXTRA_PUBLIC_KEY); - - // increment vcard count - pubkeyCount++; - break; - } - } - - // done with presence data and blocklist - if (pubkeyCount == presenceCount && blocklistReceived) - finish(); - - } + private final EventBus mServiceBus = MessageCenterService.bus(); - } - - else if (MessageCenterService.ACTION_BLOCKLIST.equals(action)) { - blocklistReceived = true; - - String[] list = intent.getStringArrayExtra(MessageCenterService.EXTRA_BLOCKLIST); - if (list != null) { - - for (String jid : list) { - // see if bare JID is present in roster response - String compare = XmppStringUtils.parseBareJid(jid); - for (PresenceItem item : response) { - if (XmppStringUtils.parseBareJid(item.from).equalsIgnoreCase(compare)) { - item.blocked = true; - - break; - } - } - } - - } - - // done with presence data and blocklist - if (pubkeyCount >= presenceCount) - finish(); - } - - // connected! Retry... - else if (MessageCenterService.ACTION_CONNECTED.equals(action) && rosterParts < 0) { - Syncer w = notifyTo.get(); - if (w != null) { - // request a roster match - rosterParts = getRosterParts(jidList); - iq = new String[rosterParts]; - for (int i = 0; i < rosterParts; i++) { - int end = (i+1)*MAX_ROSTER_MATCH_SIZE; - if (end >= jidList.size()) - end = jidList.size(); - List slice = jidList.subList(i*MAX_ROSTER_MATCH_SIZE, end); - - iq[i] = StringUtils.randomString(6); - w.requestRosterMatch(iq[i], slice); - } - } - } - } - - private PresenceItem getPresenceItem(String jid) { - for (PresenceItem item : response) { - if (XmppStringUtils.parseBareJid(item.from).equalsIgnoreCase(jid)) - return item; - } - - // add item if not found - PresenceItem item = new PresenceItem(); - item.from = jid; - response.add(item); - return item; - } - - private int getRosterParts(List jidList) { - return (int) Math.ceil((double) jidList.size() / MAX_ROSTER_MATCH_SIZE); - } - - public List getResponse() { - return (rosterCount >= 0) ? response : null; - } - - private void finish() { - Syncer w = notifyTo.get(); - if (w != null) { - synchronized (w) { - w.notifyAll(); - } - } - } - } - - public Syncer(Context context) { + Syncer(Context context) { mContext = context; } - public void onSyncCanceled() { + void onSyncCanceled() { mCanceled = true; } @@ -332,11 +119,12 @@ public RawPhoneNumberEntry(String lookupKey, String number, String jid) { * is received, it deletes all the raw contacts created by us and then * recreates only the ones the server has found a match for. */ - public void performSync(Context context, Account account, String authority, + void performSync(Context context, Account account, String authority, ContentProviderClient provider, ContentProviderClient usersProvider, SyncResult syncResult) throws OperationCanceledException { + final MyAccount myAccount = Authenticator.fromSystemAccount(context, account); final Map lookupNumbers = new HashMap<>(); final List jidList = new ArrayList<>(); @@ -352,7 +140,7 @@ public void performSync(Context context, Account account, String authority, int count = usersProvider.update(uri, new ContentValues(), null, null); Log.d(TAG, "users database resynced (" + count + ")"); } - catch (RemoteException e) { + catch (Exception e) { Log.e(TAG, "error resyncing users database - aborting sync", e); syncResult.databaseError = true; return; @@ -365,7 +153,7 @@ public void performSync(Context context, Account account, String authority, new String[] { Users.JID, Users.NUMBER, Users.LOOKUP_KEY }, null, null, null); } - catch (RemoteException e) { + catch (Exception e) { Log.e(TAG, "error querying users database - aborting sync", e); syncResult.databaseError = true; return; @@ -381,21 +169,7 @@ public void performSync(Context context, Account account, String authority, String number = cursor.getString(1); String lookupKey = cursor.getString(2); - // a phone number with less than 4 digits??? - if (number.length() < 4) - continue; - - // fix number - try { - number = NumberValidator.fixNumber(mContext, number, account.name, 0); - } - catch (Exception e) { - Log.e(TAG, "unable to normalize number: " + number + " - skipping", e); - // skip number - continue; - } - - // avoid to send duplicates to server + // avoid to send duplicates to the server if (lookupNumbers.put(XmppStringUtils.parseLocalpart(jid), new RawPhoneNumberEntry(lookupKey, number, jid)) == null) jidList.add(jid); @@ -414,26 +188,22 @@ public void performSync(Context context, Account account, String authority, Log.e(TAG, "contact delete error", e); syncResult.databaseError = true; } + try { + syncResult.stats.numDeletes += deleteProfile(account, provider); + } + catch (Exception e) { + Log.e(TAG, "profile delete error", e); + syncResult.databaseError = true; + } commit(usersProvider, syncResult); } else { - final LocalBroadcastManager lbm = LocalBroadcastManager - .getInstance(mContext); - - // register presence broadcast receiver - PresenceBroadcastReceiver receiver = new PresenceBroadcastReceiver(jidList, this); - IntentFilter f = new IntentFilter(); - f.addAction(MessageCenterService.ACTION_PRESENCE); - f.addAction(MessageCenterService.ACTION_ROSTER_MATCH); - f.addAction(MessageCenterService.ACTION_PUBLICKEY); - f.addAction(MessageCenterService.ACTION_BLOCKLIST); - f.addAction(MessageCenterService.ACTION_CONNECTED); - lbm.registerReceiver(receiver, f); - - // request current connection status - MessageCenterService.requestConnectionStatus(mContext); + // register to events + // registering will request current connection status and proceed + SyncProcedure receiver = new SyncProcedure(jidList, this); + mServiceBus.register(receiver); // wait for the service to complete its job synchronized (this) { @@ -447,18 +217,17 @@ public void performSync(Context context, Account account, String authority, } } - lbm.unregisterReceiver(receiver); + mServiceBus.unregister(receiver); // last chance to quit if (mCanceled) throw new OperationCanceledException(); - List res = receiver.getResponse(); + List res = receiver.getResponse(); if (res != null) { ArrayList operations = - new ArrayList(); + new ArrayList<>(); // TODO operations.size() could be used instead (?) int op = 0; - String ownContactJid = null; // this is the time - delete all Kontalk raw contacts try { @@ -469,20 +238,28 @@ public void performSync(Context context, Account account, String authority, syncResult.databaseError = true; return; } + try { + syncResult.stats.numDeletes += deleteProfile(account, provider); + } + catch (Exception e) { + Log.e(TAG, "profile delete error", e); + syncResult.databaseError = true; + } ContentValues registeredValues = new ContentValues(); registeredValues.put(Users.REGISTERED, 1); for (int i = 0; i < res.size(); i++) { - PresenceItem entry = res.get(i); + SyncProcedure.PresenceItem entry = res.get(i); + if (entry.discarded) + continue; final RawPhoneNumberEntry data = lookupNumbers - .get(XmppStringUtils.parseLocalpart(entry.from)); - if (data != null) { + .get(entry.from.getLocalpartOrThrow().toString()); + if (data != null && data.lookupKey != null) { // add contact addContact(account, getDisplayName(provider, data.lookupKey, data.number), - data.number, data.jid, operations, op); - op++; + data.number, data.jid, operations, op++); } else { syncResult.stats.numSkippedEntries++; @@ -505,15 +282,15 @@ public void performSync(Context context, Account account, String authority, if (entry.publicKey != null) { try { PGPPublicKey pubKey = PGP.getMasterKey(entry.publicKey); - - String fp = PGP.getFingerprint(pubKey); - registeredValues.put(Users.FINGERPRINT, fp); - registeredValues.put(Users.PUBLIC_KEY, entry.publicKey); + // trust our own key blindly + int trustLevel = myAccount.isSelfJID(entry.from) ? + MyUsers.Keys.TRUST_VERIFIED : -1; + // update keys table immediately + Keyring.setKey(mContext, entry.from.toString(), entry.publicKey, trustLevel); // no data from system contacts, use name from public key if (data == null) { - // TODO server - PGPUserID uid = PGP.parseUserId(pubKey, null); + PGPUserID uid = PGP.parseUserId(pubKey, entry.from.getDomain().toString()); if (uid != null) { registeredValues.put(Users.DISPLAY_NAME, uid.getName()); } @@ -524,14 +301,16 @@ public void performSync(Context context, Account account, String authority, } } else { - registeredValues.putNull(Users.FINGERPRINT); - registeredValues.putNull(Users.PUBLIC_KEY); + // use roster name if no contact data available + if (data == null && entry.rosterName != null) { + registeredValues.put(Users.DISPLAY_NAME, entry.rosterName); + } } // blocked status registeredValues.put(Users.BLOCKED, entry.blocked); // user JID as reported by the server - registeredValues.put(Users.JID, entry.from); + registeredValues.put(Users.JID, entry.from.toString()); /* * Since UsersProvider.resync inserted the user row @@ -541,11 +320,13 @@ public void performSync(Context context, Account account, String authority, * in the where condition so we will have a match. */ String origJid; - if (data != null) - origJid = XMPPUtils.createLocalJID(mContext, - XmppStringUtils.parseLocalpart(entry.from)); - else - origJid = entry.from; + if (data != null) { + origJid = myAccount.createLocalJID(XmppStringUtils + .parseLocalpart(entry.from.toString())); + } + else { + origJid = entry.from.toString(); + } usersProvider.update(Users.CONTENT_URI_OFFLINE, registeredValues, Users.JID + " = ?", new String[] { origJid }); @@ -553,10 +334,18 @@ public void performSync(Context context, Account account, String authority, registeredValues.remove(Users.DISPLAY_NAME); // if this is our own contact, trust our own key later - if (Authenticator.isSelfJID(mContext, entry.from)) - ownContactJid = entry.from; + if (myAccount.isSelfJID(entry.from)) { + // register our profile while we're at it + if (data != null) { + // add contact + String displayName = myAccount.getDisplayName(); + addProfile(account, displayName, + data.number, data.jid, + operations, op++); + } + } } - catch (RemoteException e) { + catch (Exception e) { Log.e(TAG, "error updating users database", e); // we shall continue here... } @@ -569,17 +358,17 @@ public void performSync(Context context, Account account, String authority, syncResult.stats.numEntries += op; } catch (Exception e) { - Log.e(TAG, "contact write error", e); - syncResult.stats.numSkippedEntries = op; - syncResult.databaseError = true; - return; + Log.w(TAG, "contact write error", e); + syncResult.stats.numSkippedEntries += op; + /* + * We do not consider system contacts failure a fatal error. + * This is actually a workaround for systems with disabled permissions or + * exotic firmwares. It can also protect against security 3rd party apps or + * non-Android platforms, such as Jolla/Alien Dalvik. + */ } commit(usersProvider, syncResult); - - if (ownContactJid != null) - // we found our own contact, trust our own key now - UsersProvider.trustUserKey(mContext, ownContactJid); } // timeout or error @@ -602,41 +391,12 @@ private void commit(ContentProviderClient usersProvider, SyncResult syncResult) Log.d(TAG, "users database committed"); Contact.invalidate(); } - catch (RemoteException e) { + catch (Exception e) { Log.e(TAG, "error committing users database - aborting sync", e); syncResult.databaseError = true; } } - private void requestRosterMatch(String id, List list) { - Intent i = new Intent(mContext, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_ROSTER_MATCH); - i.putExtra(MessageCenterService.EXTRA_PACKET_ID, id); - i.putExtra(MessageCenterService.EXTRA_JIDLIST, list.toArray(new String[list.size()])); - mContext.startService(i); - } - - private void requestPresenceData(String id) { - Intent i = new Intent(mContext, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_PRESENCE); - i.putExtra(MessageCenterService.EXTRA_TYPE, Presence.Type.probe.toString()); - i.putExtra(MessageCenterService.EXTRA_PACKET_ID, id); - mContext.startService(i); - } - - private void requestPublicKeys() { - Intent i = new Intent(mContext, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_PUBLICKEY); - i.putExtra(MessageCenterService.EXTRA_PACKET_ID, IQ_PACKET_ID); - mContext.startService(i); - } - - private void requestBlocklist() { - Intent i = new Intent(mContext, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_BLOCKLIST); - mContext.startService(i); - } - private String getDisplayName(ContentProviderClient client, String lookupKey, String defaultValue) { String displayName = null; Cursor nameQuery = null; @@ -657,7 +417,8 @@ private String getDisplayName(ContentProviderClient client, String lookupKey, St try { nameQuery.close(); } - catch (Exception e) {} + catch (Exception ignored) { + } } return (displayName != null) ? displayName : defaultValue; @@ -672,6 +433,16 @@ private int deleteAll(Account account, ContentProviderClient provider) .build(), null, null); } + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + private int deleteProfile(Account account, ContentProviderClient provider) + throws RemoteException { + return provider.delete(ContactsContract.Profile.CONTENT_RAW_CONTACTS_URI.buildUpon() + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name) + .appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type) + .build(), null, null); + } + /* private int deleteContact(Account account, long rawContactId) { Uri uri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId) @@ -697,22 +468,46 @@ private int deleteContact(Account account, long rawContactId) { private void addContact(Account account, String username, String phone, String jid, List operations, int index) { - if (BuildConfig.DEBUG) { + if (Log.isDebug()) { Log.d(TAG, "adding contact \"" + username + "\" <" + phone + ">"); } - ContentProviderOperation.Builder builder; - final int opIndex = index * 3; + // create our RawContact + operations.add(insertRawContact(account, username, phone, jid, + RawContacts.CONTENT_URI).build()); + + // add contact data + addContactData(username, phone, operations, index); + } + + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + private void addProfile(Account account, String username, String phone, String jid, + List operations, int index) { + if (Log.isDebug()) { + Log.d(TAG, "adding profile \"" + username + "\" <" + phone + ">"); + } // create our RawContact - builder = ContentProviderOperation.newInsert(RawContacts.CONTENT_URI) + operations.add(insertRawContact(account, username, phone, jid, + ContactsContract.Profile.CONTENT_RAW_CONTACTS_URI).build()); + + // add contact data + addContactData(username, phone, operations, index); + } + + private ContentProviderOperation.Builder insertRawContact(Account account, String username, String phone, String jid, Uri uri) { + return ContentProviderOperation.newInsert(uri) .withValue(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_DEFAULT) .withValue(RawContacts.ACCOUNT_NAME, account.name) .withValue(RawContacts.ACCOUNT_TYPE, account.type) .withValue(RAW_COLUMN_DISPLAY_NAME, username) .withValue(RAW_COLUMN_PHONE, phone) .withValue(RAW_COLUMN_USERID, jid); - operations.add(builder.build()); + } + + private void addContactData(String username, String phone, List operations, int index) { + ContentProviderOperation.Builder builder; + final int opIndex = index * 3; // create a Data record of common type 'StructuredName' for our RawContact builder = ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) diff --git a/app/src/main/java/org/kontalk/ui/AboutActivity.java b/app/src/main/java/org/kontalk/ui/AboutActivity.java index d2e29615c..2e94329d1 100644 --- a/app/src/main/java/org/kontalk/ui/AboutActivity.java +++ b/app/src/main/java/org/kontalk/ui/AboutActivity.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,20 +18,15 @@ package org.kontalk.ui; -import org.kontalk.R; -import org.kontalk.billing.IBillingService; - import android.content.Intent; import android.os.Bundle; -import android.support.v4.app.Fragment; -import android.support.v4.app.FragmentManager; -import android.support.v4.app.FragmentPagerAdapter; -import android.support.v4.app.FragmentTransaction; -import android.support.v4.view.ViewPager; -import android.support.v7.app.ActionBar; -import android.support.v7.app.ActionBar.Tab; -import android.support.v7.app.ActionBarActivity; -import android.view.MenuItem; +import com.google.android.material.tabs.TabLayout; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import org.kontalk.R; /** @@ -42,7 +37,7 @@ * @author Daniele Ricci * @author Andrea Cappelli */ -public class AboutActivity extends ActionBarActivity { +public class AboutActivity extends ToolbarActivity { public static final String ACTION_DONATION = "org.kontalk.DONATION"; public static final String ACTION_CREDITS = "org.kontalk.CREDITS"; @@ -53,38 +48,20 @@ public class AboutActivity extends ActionBarActivity { private static final int NUM_ITEMS = 3; - private AboutPagerAdapter mAdapter; - private ViewPager mPager; - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.about_screen); - mAdapter = new AboutPagerAdapter(getSupportFragmentManager()); + setupToolbar(true, true); - mPager = (ViewPager) findViewById(R.id.pager); - mPager.setAdapter(mAdapter); - mPager.setOnPageChangeListener( - new ViewPager.OnPageChangeListener() { + AboutPagerAdapter adapter = new AboutPagerAdapter(getSupportFragmentManager()); - @Override - public void onPageSelected(int position) { - getSupportActionBar().setSelectedNavigationItem(position); - } + ViewPager pager = findViewById(R.id.pager); + pager.setAdapter(adapter); - @Override - public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { - } - - @Override - public void onPageScrollStateChanged(int position) { - } - - } - ); - - setupActivity(); + TabLayout tabs = findViewById(R.id.sliding_tabs); + tabs.setupWithViewPager(pager); Intent intent = getIntent(); if (intent != null) { @@ -92,74 +69,22 @@ public void onPageScrollStateChanged(int position) { if (action != null) { if (ACTION_DONATION.equals(action)) - mPager.setCurrentItem(ABOUT_DONATION, true); + pager.setCurrentItem(ABOUT_DONATION, true); else if (ACTION_CREDITS.equals(action)) - mPager.setCurrentItem(ABOUT_CREDITS, true); + pager.setCurrentItem(ABOUT_CREDITS, true); } } } - private void setupActivity() { - ActionBar bar = getSupportActionBar(); - bar.setDisplayHomeAsUpEnabled(true); - bar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS); - - ActionBar.TabListener listener = new ActionBar.TabListener() { - - @Override - public void onTabSelected(Tab tab, FragmentTransaction ft) { - mPager.setCurrentItem(tab.getPosition(), true); - } - - @Override - public void onTabUnselected(Tab tab, FragmentTransaction ft) { - } - - @Override - public void onTabReselected(Tab tab, FragmentTransaction ft) { - } - - }; - - bar.addTab(bar.newTab() - .setText(R.string.title_about) - .setTabListener(listener)); - bar.addTab(bar.newTab() - .setText(R.string.title_donation) - .setTabListener(listener)); - bar.addTab(bar.newTab() - .setText(R.string.title_credits) - .setTabListener(listener)); - } - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - return true; - } - - return super.onOptionsItemSelected(item); + protected boolean isNormalUpNavigation() { + return true; } - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - DonationFragment fragment = mAdapter.getDonationFragment(); - IBillingService service = fragment.getBillingService(); - - if (service == null || !service.handleActivityResult(requestCode, resultCode, data)) - super.onActivityResult(requestCode, resultCode, data); - } - - private static class AboutPagerAdapter extends FragmentPagerAdapter { - - // this is for IabHelper - private DonationFragment mDonationFragment; - - public AboutPagerAdapter(FragmentManager fm) { - super(fm); + private class AboutPagerAdapter extends FragmentPagerAdapter { + AboutPagerAdapter(FragmentManager fm) { + super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); } @Override @@ -174,8 +99,7 @@ public Fragment getItem(int position) { return new AboutFragment(); case ABOUT_DONATION: - mDonationFragment = new DonationFragment(); - return mDonationFragment; + return new DonationFragment(); case ABOUT_CREDITS: return new CreditsFragment(); @@ -185,8 +109,21 @@ public Fragment getItem(int position) { return new AboutFragment(); } - public DonationFragment getDonationFragment() { - return mDonationFragment; + @Override + public CharSequence getPageTitle(int position) { + switch (position) { + case ABOUT_ABOUT: + return getText(R.string.title_about); + + case ABOUT_DONATION: + return getText(R.string.title_donation); + + case ABOUT_CREDITS: + return getText(R.string.title_credits); + } + + // shouldn't happen, but just in case + return super.getPageTitle(position); } } diff --git a/app/src/main/java/org/kontalk/ui/AboutFragment.java b/app/src/main/java/org/kontalk/ui/AboutFragment.java index 65ac31d0a..bb9cd4d86 100644 --- a/app/src/main/java/org/kontalk/ui/AboutFragment.java +++ b/app/src/main/java/org/kontalk/ui/AboutFragment.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,12 +22,8 @@ import org.kontalk.util.SystemUtils; import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager.NameNotFoundException; -import android.net.Uri; import android.os.Bundle; -import android.support.v4.app.Fragment; +import androidx.fragment.app.Fragment; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; @@ -46,18 +42,10 @@ public class AboutFragment extends Fragment implements OnClickListener { public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.about_about, container, false); - try { - Context parent = getActivity(); - if (parent != null) { - PackageInfo pInfo = SystemUtils.getPackageInfo(parent); - - TextView txtVersion = (TextView) view.findViewById(R.id.version); - txtVersion.setText(getString(R.string.about_version, - pInfo.versionName, pInfo.versionCode)); - } - } - catch (NameNotFoundException e) { - // shouldn't happen + Context parent = getActivity(); + if (parent != null) { + TextView txtVersion = view.findViewById(R.id.version); + txtVersion.setText(SystemUtils.getVersionFullName(parent)); } view.findViewById(R.id.button_twitter).setOnClickListener(this); @@ -114,8 +102,7 @@ private void socialTwitter() { } private void startUrl(String url) { - Intent link = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - startActivity(link); + SystemUtils.openURL(getContext(), url); } } diff --git a/app/src/main/java/org/kontalk/ui/AbstractComposeFragment.java b/app/src/main/java/org/kontalk/ui/AbstractComposeFragment.java new file mode 100644 index 000000000..4a84af887 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/AbstractComposeFragment.java @@ -0,0 +1,2954 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import java.io.File; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.regex.Pattern; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.nispok.snackbar.Snackbar; +import com.nispok.snackbar.SnackbarManager; +import com.nispok.snackbar.enums.SnackbarType; +import com.nispok.snackbar.listeners.ActionClickListener; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.jivesoftware.smackx.chatstates.ChatState; + +import android.Manifest; +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.AsyncQueryHandler; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Configuration; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.MergeCursor; +import android.database.sqlite.SQLiteDiskIOException; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.provider.ContactsContract.Contacts; +import android.provider.MediaStore; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.ListFragment; +import androidx.core.content.ContextCompat; +import android.text.TextUtils; +import android.util.SparseBooleanArray; +import android.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import pub.devrel.easypermissions.AfterPermissionGranted; +import pub.devrel.easypermissions.EasyPermissions; + +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.authenticator.MyAccount; +import org.kontalk.crypto.Coder; +import org.kontalk.data.Contact; +import org.kontalk.data.Conversation; +import org.kontalk.message.AttachmentComponent; +import org.kontalk.message.AudioComponent; +import org.kontalk.message.CompositeMessage; +import org.kontalk.message.GroupCommandComponent; +import org.kontalk.message.ImageComponent; +import org.kontalk.message.LocationComponent; +import org.kontalk.message.MessageComponent; +import org.kontalk.message.TextComponent; +import org.kontalk.message.VCardComponent; +import org.kontalk.ui.position.model.Position; +import org.kontalk.position.PositionManager; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.provider.MyMessages.Threads; +import org.kontalk.provider.MyMessages.Threads.Conversations; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.service.DownloadService; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.service.msgcenter.event.ChatStateEvent; +import org.kontalk.service.msgcenter.event.ConnectedEvent; +import org.kontalk.service.msgcenter.event.DisconnectedEvent; +import org.kontalk.service.msgcenter.event.NoPresenceEvent; +import org.kontalk.service.msgcenter.event.RosterLoadedEvent; +import org.kontalk.service.msgcenter.event.UserOfflineEvent; +import org.kontalk.service.msgcenter.event.UserOnlineEvent; +import org.kontalk.ui.adapter.MessageListAdapter; +import org.kontalk.ui.view.AttachmentRevealFrameLayout; +import org.kontalk.ui.view.AudioContentView; +import org.kontalk.ui.view.AudioContentViewControl; +import org.kontalk.ui.view.AudioPlayerControl; +import org.kontalk.ui.view.ComposerBar; +import org.kontalk.ui.view.ComposerListener; +import org.kontalk.ui.view.MessageListItem; +import org.kontalk.ui.view.ReplyBar; +import org.kontalk.util.AudioRecording; +import org.kontalk.util.DataUtils; +import org.kontalk.util.MediaStorage; +import org.kontalk.util.MessageUtils; +import org.kontalk.util.Permissions; +import org.kontalk.util.Preferences; +import org.kontalk.util.Showcase; +import org.kontalk.util.SystemUtils; + +import static android.content.res.Configuration.KEYBOARDHIDDEN_NO; + + +/** + * Abstract message composing fragment. + * + * @author Daniele Ricci + * @author Andrea Cappelli + */ +public abstract class AbstractComposeFragment extends ListFragment implements + ComposerListener, View.OnLongClickListener, + // TODO these two interfaces should be handled by an inner class + AudioDialog.AudioDialogListener, AudioPlayerControl, + AbsListView.MultiChoiceModeListener { + static final String TAG = ComposeMessage.TAG; + + private static final int MESSAGE_LIST_QUERY_TOKEN = 8720; + private static final int CONVERSATION_QUERY_TOKEN = 8721; + private static final int MESSAGE_PAGE_QUERY_TOKEN = 8723; + + /** + * How many messages to load per page. + */ + private static final int MESSAGE_PAGE_SIZE = 1000; + + private static final int SELECT_ATTACHMENT_OPENABLE = 1; + private static final int SELECT_ATTACHMENT_CONTACT = 2; + private static final int SELECT_ATTACHMENT_PHOTO = 3; + private static final int SELECT_ATTACHMENT_LOCATION = 4; + private static final int REQUEST_INVITE_USERS = 5; + + // use this as base for request codes for child classes + protected static final int REQUEST_FIRST_CHILD = 100; + + protected enum WarningType { + SUCCESS(0), // not implemented + INFO(1), // not implemented + WARNING(2), + FATAL(3); + + private final int value; + + WarningType(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + } + + /* Attachment chooser stuff. */ + private AttachmentRevealFrameLayout mAttachmentContainer; + + protected ComposerBar mComposer; + protected ReplyBar mReplyBar; + + MessageListQueryHandler mQueryHandler; + MessageListAdapter mListAdapter; + /** + * Header view for the list view: "previous messages" button. + */ + private View mHeaderView; + private View mNextPageButton; + private TextView mStatusText; + private MenuItem mDeleteThreadMenu; + private MenuItem mToggleEncryptionMenu; + + private ImageView mBackground; + + /** + * The thread id. + */ + long threadId = -1; + protected Conversation mConversation; + protected String mUserName; + + protected boolean mConnected; + + /** + * Available resources. + */ + protected Set mAvailableResources = new HashSet<>(); + + /** + * Media player stuff. + */ + private int mMediaPlayerStatus = AudioContentView.STATUS_IDLE; + private Handler mHandler; + private Runnable mMediaPlayerUpdater; + private AudioContentViewControl mAudioControl; + private AudioFragment mAudioFragment; + + /** + * Audio recording dialog. + */ + private AudioDialog mAudioDialog; + + private PeerObserver mPeerObserver; + private File mCurrentPhoto; + + private boolean mOfflineModeWarned; + protected CharSequence mCurrentStatus; + + private int mCheckedItemCount; + + /** + * It will hold the ID of the latest manually requested media download. + * Used to restart the download automatically after the permission has been + * granted. + */ + private long mWaitingDownload; + + protected EventBus mServiceBus = MessageCenterService.bus(); + protected EventBus mDownloadBus = DownloadService.bus(); + + /** + * Returns a new fragment instance from a picked contact. + */ + public static AbstractComposeFragment fromUserId(Context context, String userId, boolean creatingGroup) { + AbstractComposeFragment f = new ComposeMessageFragment(); + Conversation conv = Conversation.loadFromUserId(context, userId); + // not found - create new + if (conv == null) { + Bundle args = new Bundle(); + args.putString("action", ComposeMessage.ACTION_VIEW_USERID); + args.putParcelable("data", Threads.getUri(userId)); + // non existing group threads can't exist, so no reason to use creatingGroup + f.setArguments(args); + return f; + } + + return fromConversation(context, conv, creatingGroup); + } + + /** + * Returns a new fragment instance from a {@link Conversation} instance. + */ + public static AbstractComposeFragment fromConversation(Context context, + Conversation conv, boolean creatingGroup) { + return fromConversation(context, conv.getThreadId(), conv.isGroupChat(), creatingGroup); + } + + /** + * Returns a new fragment instance from a thread ID. + */ + private static AbstractComposeFragment fromConversation(Context context, + long threadId, boolean group, boolean creatingGroup) { + AbstractComposeFragment f = group ? + new GroupMessageFragment() : new ComposeMessageFragment(); + Bundle args = new Bundle(); + args.putString("action", ComposeMessage.ACTION_VIEW_CONVERSATION); + args.putParcelable("data", + ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId)); + args.putBoolean(ComposeMessage.EXTRA_CREATING_GROUP, creatingGroup); + f.setArguments(args); + return f; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // setListAdapter() is post-poned + + ListView list = getListView(); + + list.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL); + list.setMultiChoiceModeListener(this); + + // add header view (this must be done before setting the adapter) + mHeaderView = LayoutInflater.from(getActivity()) + .inflate(R.layout.message_list_header, list, false); + mNextPageButton = mHeaderView.findViewById(R.id.load_next_page); + mNextPageButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // disable button in the meantime + enableHeaderView(false); + // start query for the next page + startMessagesQuery(mQueryHandler.getLastId()); + } + }); + list.addHeaderView(mHeaderView, null, false); + + // set custom background (if any) + mBackground = getView().findViewById(R.id.background); + Drawable bg = Preferences.getConversationBackground(getActivity()); + if (bg != null) { + mBackground.setScaleType(ImageView.ScaleType.CENTER_CROP); + mBackground.setImageDrawable(bg); + } + else { + mBackground.setScaleType(ImageView.ScaleType.FIT_XY); + mBackground.setImageResource(R.drawable.app_background_tile); + } + + processArguments(savedInstanceState); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + mComposer.onKeyboardStateChanged(newConfig.keyboardHidden == KEYBOARDHIDDEN_NO); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.compose_message, container, false); + + // footer (for tablet presence status) + mStatusText = view.findViewById(R.id.status_text); + + // reply bar + mReplyBar = view.findViewById(R.id.reply_bar); + mReplyBar.setOnCancelListener(new ReplyBar.OnCancelListener() { + @Override + public void onCancel(ReplyBar view) { + view.hide(); + } + }); + + mComposer = view.findViewById(R.id.composer_bar); + mComposer.setComposerListener(this); + mComposer.setRootView(container.getRootView().findViewById(R.id.root_view)); + + Configuration config = getResources().getConfiguration(); + mComposer.onKeyboardStateChanged(config.keyboardHidden == KEYBOARDHIDDEN_NO); + + initAttachmentView(view); + + return view; + } + + private final MessageListAdapter.OnContentChangedListener mContentChangedListener = new MessageListAdapter.OnContentChangedListener() { + public void onContentChanged(MessageListAdapter adapter) { + if (isVisible()) + startQuery(); + } + }; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setHasOptionsMenu(true); + mQueryHandler = new MessageListQueryHandler(this); + mHandler = new Handler(); + + // list adapter creation is post-poned + } + + public boolean isActionModeActive() { + return mCheckedItemCount > 0; + } + + @Override + public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) { + if (checked) + mCheckedItemCount++; + else + mCheckedItemCount--; + mode.setTitle(getResources() + .getQuantityString(R.plurals.context_selected, + mCheckedItemCount, mCheckedItemCount)); + + mode.invalidate(); + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = mode.getMenuInflater(); + inflater.inflate(R.menu.compose_message_ctx, menu); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + MenuItem deleteMenu = menu.findItem(R.id.menu_delete); + MenuItem replyMenu = menu.findItem(R.id.menu_reply); + MenuItem retryMenu = menu.findItem(R.id.menu_retry); + MenuItem shareMenu = menu.findItem(R.id.menu_share); + MenuItem copyTextMenu = menu.findItem(R.id.menu_copy_text); + MenuItem detailsMenu = menu.findItem(R.id.menu_details); + MenuItem openMenu = menu.findItem(R.id.menu_open); + MenuItem dlMenu = menu.findItem(R.id.menu_download); + MenuItem cancelDlMenu = menu.findItem(R.id.menu_cancel_download); + MenuItem saveAttachment = menu.findItem(R.id.menu_save_attachment); + + // initial status + deleteMenu.setVisible(true); + replyMenu.setVisible(false); + retryMenu.setVisible(false); + shareMenu.setVisible(false); + copyTextMenu.setVisible(true); + detailsMenu.setVisible(false); + openMenu.setVisible(false); + dlMenu.setVisible(false); + cancelDlMenu.setVisible(false); + saveAttachment.setVisible(false); + + boolean singleItem = (mCheckedItemCount == 1); + if (singleItem) { + CompositeMessage msg = getCheckedItem(); + + // group command can't be deleted or have details + if (msg.hasComponent(GroupCommandComponent.class)) { + deleteMenu.setVisible(false); + } + else { + // message waiting for user review or not delivered + if (msg.getStatus() == Messages.STATUS_PENDING || msg.getStatus() == Messages.STATUS_NOTDELIVERED) { + retryMenu.setVisible(true); + } + + // some commands can be used only on unencrypted messages + if (!msg.isEncrypted()) { + AttachmentComponent attachment = msg.getComponent(AttachmentComponent.class); + TextComponent text = msg.getComponent(TextComponent.class); + + // sharing media messages has no purpose if media file hasn't been + // retrieved yet; also exclude location messages for now because they're unsupported + if ((text != null || attachment == null || attachment.getLocalUri() != null) && !msg.hasComponent(LocationComponent.class)) + shareMenu.setVisible(true); + + // non-empty text: copy text to clipboard + if (text != null && !TextUtils.isEmpty(text.getContent())) + copyTextMenu.setVisible(true); + + // text message: enable reply + if (text != null) { + replyMenu.setVisible(true); + } + + if (attachment != null) { + + // message has a local uri - add open file entry + if (attachment.getLocalUri() != null) { + int resId; + if (attachment instanceof ImageComponent) + resId = R.string.view_image; + else if (attachment instanceof AudioComponent) + resId = R.string.open_audio; + else + resId = R.string.open_file; + + openMenu.setTitle(resId); + openMenu.setVisible(true); + saveAttachment.setVisible(true); + } + + // message has a fetch url - add download control entry + if (msg.getDirection() == Messages.DIRECTION_IN && attachment.getFetchUrl() != null) { + if (!DownloadService.isQueued(attachment.getFetchUrl())) { + int string; + // already fetched + if (attachment.getLocalUri() != null) + string = R.string.download_again; + else + string = R.string.download_file; + + dlMenu.setTitle(string); + dlMenu.setVisible(true); + } + else { + cancelDlMenu.setVisible(true); + } + } + } + } + + detailsMenu.setVisible(true); + } + } + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_delete: { + // using clone because listview returns its original copy + deleteSelectedMessages(DataUtils + .cloneSparseBooleanArray(getListView().getCheckedItemPositions())); + mode.finish(); + return true; + } + + case R.id.menu_reply: { + CompositeMessage msg = getCheckedItem(); + replyMessage(msg); + mode.finish(); + return true; + } + + case R.id.menu_retry: { + CompositeMessage msg = getCheckedItem(); + retryMessage(msg); + mode.finish(); + return true; + } + + case R.id.menu_share: { + CompositeMessage msg = getCheckedItem(); + shareMessage(msg); + mode.finish(); + return true; + } + + case R.id.menu_copy_text: { + if (mCheckedItemCount == 1) { + CompositeMessage msg = getCheckedItem(); + copyMessage(msg); + } + else { + copySelectedMessages(getListView().getCheckedItemPositions()); + } + mode.finish(); + return true; + } + + case R.id.menu_open: { + CompositeMessage msg = getCheckedItem(); + openFile(msg); + mode.finish(); + return true; + } + + case R.id.menu_download: { + CompositeMessage msg = getCheckedItem(); + mWaitingDownload = msg.getDatabaseId(); + startDownload(msg); + mode.finish(); + return true; + } + + case R.id.menu_cancel_download: { + CompositeMessage msg = getCheckedItem(); + stopDownload(msg); + mode.finish(); + return true; + } + + case R.id.menu_details: { + CompositeMessage msg = getCheckedItem(); + showMessageDetails(msg); + mode.finish(); + return true; + } + + case R.id.menu_save_attachment: { + CompositeMessage msg = getCheckedItem(); + saveAttachments(msg); + mode.finish(); + return true; + } + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + mCheckedItemCount = 0; + getListView().clearChoices(); + mListAdapter.notifyDataSetChanged(); + } + + private CompositeMessage getCheckedItem() { + if (mCheckedItemCount != 1) + throw new IllegalStateException("checked items count must be exactly 1"); + + Cursor cursor = (Cursor) getListView().getItemAtPosition(getCheckedItemPosition()); + return CompositeMessage.fromCursor(getActivity(), cursor); + } + + private int getCheckedItemPosition() { + SparseBooleanArray checked = getListView().getCheckedItemPositions(); + return checked.keyAt(checked.indexOfValue(true)); + } + + private void copyMessage(CompositeMessage msg) { + ClipboardManager cpm = (ClipboardManager) getActivity() + .getSystemService(Context.CLIPBOARD_SERVICE); + cpm.setPrimaryClip(ClipData.newPlainText(null, msg.toTextContent())); + + Toast.makeText(getContext(), R.string.message_text_copied, + Toast.LENGTH_SHORT).show(); + } + + private void copySelectedMessages(final SparseBooleanArray checked) { + String prevUserId = null; + String selfJid = Kontalk.get().getDefaultAccount().getSelfJID(); + StringBuilder massText = new StringBuilder(); + + for (int i = 0, c = getListView().getCount()+getListView().getHeaderViewsCount(); i < c; ++i) { + if (checked.get(i)) { + Cursor cursor = (Cursor) getListView().getItemAtPosition(i); + CompositeMessage msg = CompositeMessage.fromCursor(getContext(), cursor); + String userId = msg.getDirection() == Messages.DIRECTION_IN ? + msg.getSender() : Kontalk.get().getDefaultAccount().getSelfJID(); + + if (prevUserId == null || !prevUserId.equalsIgnoreCase(userId)) { + String displayName; + if (msg.getDirection() == Messages.DIRECTION_IN) { + Contact contact = Contact.findByUserId(getContext(), userId); + displayName = contact.getDisplayName(); + } + else { + displayName = Kontalk.get().getDefaultAccount().getDisplayName(); + } + + if (massText.length() > 0) + massText.append("\n"); + + massText.append(displayName) + .append(":\n"); + + prevUserId = userId; + } + + String text = msg.toTextContent(); + if (text != null) { + massText.append(text) + .append("\n"); + } + } + } + + ClipboardManager cpm = (ClipboardManager) getActivity() + .getSystemService(Context.CLIPBOARD_SERVICE); + cpm.setPrimaryClip(ClipData.newPlainText(null, massText.toString())); + + Toast.makeText(getContext(), R.string.message_text_copied, + Toast.LENGTH_SHORT).show(); + } + + private void deleteSelectedMessages(final SparseBooleanArray checked) { + final List list = new LinkedList<>(); + for (int i = 0, c = getListView().getCount()+getListView().getHeaderViewsCount(); i < c; ++i) { + if (checked.get(i)) { + Cursor cursor = (Cursor) getListView().getItemAtPosition(i); + // skip group command messages + if (!GroupCommandComponent.isCursor(cursor)) + list.add(new CompositeMessage.DeleteMessageHolder(cursor)); + } + } + + new MaterialDialog.Builder(getContext()) + .content(R.string.confirm_will_delete_messages) + .positiveText(android.R.string.ok) + .positiveColorRes(R.color.button_danger) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + Context ctx = dialog.getContext(); + for (CompositeMessage.DeleteMessageHolder item : list) { + CompositeMessage.deleteMessage(ctx, item); + } + mListAdapter.notifyDataSetChanged(); + } + }) + .negativeText(android.R.string.cancel) + .show(); + } + + private void initAttachmentView(View view) { + mAttachmentContainer = view.findViewById(R.id.attachment_container); + + View.OnClickListener hideAttachmentListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + hideAttachmentView(); + } + }; + view.findViewById(R.id.attachment_overlay).setOnClickListener(hideAttachmentListener); + view.findViewById(R.id.attach_hide).setOnClickListener(hideAttachmentListener); + + view.findViewById(R.id.attach_camera).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + selectPhotoAttachment(); + hideAttachmentView(); + } + }); + + view.findViewById(R.id.attach_gallery).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + selectGalleryAttachment(); + hideAttachmentView(); + } + }); + + view.findViewById(R.id.attach_video).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Toast.makeText(getContext(), R.string.msg_not_implemented, Toast.LENGTH_SHORT).show(); + } + }); + + view.findViewById(R.id.attach_audio).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + selectAudioAttachment(); + hideAttachmentView(); + } + }); + + view.findViewById(R.id.attach_file).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Toast.makeText(getContext(), R.string.msg_not_implemented, Toast.LENGTH_SHORT).show(); + } + }); + + view.findViewById(R.id.attach_vcard).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + selectContactAttachment(); + hideAttachmentView(); + } + }); + + view.findViewById(R.id.attach_location).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + selectPositionAttachment(); + hideAttachmentView(); + } + }); + } + + @Override + public void onAttachClick() { + toggleAttachmentView(); + } + + @Override + public void onTextEntryFocus() { + tryHideAttachmentView(true); + } + + @Override + public boolean canOpenEmoji() { + if (SystemUtils.supportsMultiWindow() && getActivity().isInMultiWindowMode()) { + Toast.makeText(getContext(), R.string.err_emoji_disabled_in_multiwindow, + Toast.LENGTH_LONG).show(); + return false; + } + return true; + } + + /** + * Sends out the text message in the composing entry. + */ + @Override + public void sendTextMessage(String message) { + if (!TextUtils.isEmpty(message)) { + offlineModeWarning(); + + // start thread + long inReplyTo = mReplyBar.getMessageId(); + new TextMessageThread(message, inReplyTo).start(); + if (inReplyTo > 0) + mReplyBar.hide(); + } + } + + /** + * Sends out a binary message. + */ + @Override + public void sendBinaryMessage(Uri uri, String mime, boolean media, Class> klass) { + Log.v(TAG, "sending binary content: " + uri); + offlineModeWarning(); + // start thread + new BinaryMessageThread(uri, mime, media, klass).start(); + } + + /** + * Sends out a location message. + */ + @Override + public void sendLocationMessage(String message, double lat, double lon, String geoText, String geoStreet) { + offlineModeWarning(); + // start thread + new LocationMessageThread(message, lat, lon, geoText, geoStreet).start(); + } + + private final class TextMessageThread extends Thread { + private final String mText; + private final long mInReplyTo; + + TextMessageThread(String text, long inReplyTo) { + mText = text; + mInReplyTo = inReplyTo; + } + + @Override + public void run() { + final Activity context = getActivity(); + if (context == null) + return; + + try { + final Conversation conv = mConversation; + Uri newMsg; + try { + newMsg = Kontalk.get().getMessagesController() + .sendTextMessage(conv, mText, mInReplyTo).get(); + } + catch (ExecutionException e) { + // unwrap exception + throw e.getCause(); + } + + // update thread id from the inserted message + if (threadId <= 0) { + threadId = MessagesProviderClient.getThreadByMessage(context, newMsg); + if (threadId > 0) { + startQuery(); + } + else { + Log.v(TAG, "no data - cannot start query for this composer"); + } + } + } + catch (SQLiteDiskIOException e) { + Log.d(TAG, "error storing message", e); + context.runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(getActivity(), R.string.error_store_outbox, + Toast.LENGTH_LONG).show(); + } + }); + } + catch (Throwable e) { + Log.d(TAG, "error storing message", e); + ReportingManager.logException(e); + context.runOnUiThread(new Runnable() { + public void run() { + Toast.makeText(getActivity(), + R.string.err_store_message_failed, + Toast.LENGTH_LONG).show(); + } + }); + } + } + } + + private final class BinaryMessageThread extends Thread { + private final Uri mUri; + private final String mMime; + private final boolean mMedia; + private final Class> mKlass; + + BinaryMessageThread(Uri uri, String mime, boolean media, + Class> klass) { + mUri = uri; + mMime = mime; + mMedia = media; + mKlass = klass; + } + + @Override + public void run() { + final Activity context = getActivity(); + if (context == null) + return; + + try { + final Conversation conv = mConversation; + Uri newMsg = Kontalk.get().getMessagesController() + .sendBinaryMessage(conv, mUri, mMime, mMedia, mKlass); + + // update thread id from the inserted message + if (threadId <= 0) { + threadId = MessagesProviderClient.getThreadByMessage(context, newMsg); + if (threadId > 0) { + // we can run it here because progress=false + startQuery(); + } + else { + Log.v(TAG, "no data - cannot start query for this composer"); + } + } + } + catch (SQLiteDiskIOException e) { + context.runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(getActivity(), R.string.error_store_outbox, + Toast.LENGTH_LONG).show(); + } + }); + } + catch (Exception e) { + ReportingManager.logException(e); + context.runOnUiThread(new Runnable() { + public void run() { + Toast.makeText(getActivity(), + R.string.err_store_message_failed, + Toast.LENGTH_LONG).show(); + } + }); + } + } + } + + private final class LocationMessageThread extends Thread { + private final String mText; + private final double mLatitude; + private final double mLongitude; + private final String mGeoText; + private final String mGeoStreet; + + LocationMessageThread(String text, double lat, double lon, String geoText, String geoStreet) { + mText = text; + mLatitude = lat; + mLongitude = lon; + mGeoText = geoText; + mGeoStreet = geoStreet; + } + + @Override + public void run() { + final Activity context = getActivity(); + if (context == null) + return; + + try { + final Conversation conv = mConversation; + Uri newMsg = Kontalk.get().getMessagesController() + .sendLocationMessage(conv, mText, mLatitude, mLongitude, mGeoText, mGeoStreet); + + // update thread id from the inserted message + if (threadId <= 0) { + threadId = MessagesProviderClient.getThreadByMessage(context, newMsg); + if (threadId > 0) { + startQuery(); + } + else { + Log.v(TAG, "no data - cannot start query for this composer"); + } + } + } + catch (SQLiteDiskIOException e) { + context.runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(getActivity(), R.string.error_store_outbox, + Toast.LENGTH_LONG).show(); + } + }); + } + catch (Exception e) { + ReportingManager.logException(e); + context.runOnUiThread(new Runnable() { + public void run() { + Toast.makeText(getActivity(), + R.string.err_store_message_failed, + Toast.LENGTH_LONG).show(); + } + }); + } + } + } + + /** + * Sends an inactive chat state message. + */ + public abstract boolean sendInactive(); + + protected abstract void onInflateOptionsMenu(Menu menu, MenuInflater inflater); + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + onInflateOptionsMenu(menu, inflater); + mDeleteThreadMenu = menu.findItem(R.id.delete_thread); + mToggleEncryptionMenu = menu.findItem(R.id.toggle_encryption); + updateUI(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + // action mode is active - no processing + if (isActionModeActive()) + return true; + + switch (item.getItemId()) { + case R.id.delete_thread: + if (threadId > 0) + deleteThread(); + return true; + + case R.id.invite_group: + addUsers(); + return true; + + case R.id.toggle_encryption: + toggleEncryption(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void toggleEncryption() { + if (mConversation.isEncryptionEnabled()) { + new MaterialDialog.Builder(getActivity()) + .title(R.string.title_disable_encryption) + .content(R.string.msg_disable_encryption) + .positiveText(R.string.menu_disable_encryption) + .positiveColorRes(R.color.button_danger) + .negativeText(android.R.string.cancel) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog materialDialog, @NonNull DialogAction dialogAction) { + setEncryption(false); + updateUI(); + } + }) + .show(); + } + else { + setEncryption(true); + updateUI(); + } + } + + void setEncryption(boolean encryption) { + if (mConversation != null) + mConversation.setEncryptionEnabled(encryption); + } + + @Override + public void onListItemClick(ListView listView, View view, int position, long id) { + MessageListItem item = (MessageListItem) view; + final CompositeMessage msg = item.getMessage(); + + AttachmentComponent attachment = msg.getComponent(AttachmentComponent.class); + + LocationComponent location = msg.getComponent(LocationComponent.class); + + if (attachment != null && (attachment.getFetchUrl() != null || attachment.getLocalUri() != null)) { + + // outgoing message or already fetched + if (attachment.getLocalUri() != null) { + // open file + openFile(msg); + } + else { + // info & download dialog + CharSequence message = MessageUtils + .getFileInfoMessage(getActivity(), msg, getDecodedPeer(msg)); + + MaterialDialog.Builder builder = new MaterialDialog.Builder(getActivity()) + .title(R.string.title_file_info) + .content(message) + .negativeText(android.R.string.cancel) + .cancelable(true); + + if (!DownloadService.isQueued(attachment.getFetchUrl())) { + MaterialDialog.SingleButtonCallback startDL = new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + // start file download + startDownload(msg); + } + }; + builder.positiveText(R.string.download) + .onPositive(startDL); + } + else { + MaterialDialog.SingleButtonCallback stopDL = new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + // cancel file download + stopDownload(msg); + } + }; + builder.positiveText(R.string.download_cancel) + .onPositive(stopDL); + } + + builder.show(); + } + } + + else if (location != null) { + String userId = item.getMessage().getSender(); + if (item.getMessage().getSender() == null) + userId = Kontalk.get().getDefaultAccount().getSelfJID(); + + Intent intent = new Intent(getActivity(), PositionActivity.class); + Position p = new Position(location.getLatitude(), location.getLongitude(), + "", ""); + intent.putExtra(PositionActivity.EXTRA_USERPOSITION, p); + intent.putExtra(PositionActivity.EXTRA_USERID, userId); + startActivity(intent); + } + + else { + item.onClick(); + } + } + + private void startDownload(CompositeMessage msg) { + AttachmentComponent attachment = msg + .getComponent(AttachmentComponent.class); + + if (attachment != null && attachment.getFetchUrl() != null) { + DownloadService.start(getContext(), msg.getDatabaseId(), + msg.getSender(), attachment.getMime(), msg.getTimestamp(), + attachment.getSecurityFlags() != Coder.SECURITY_CLEARTEXT, + attachment.getFetchUrl(), getUserId(), false); + } + else { + // corrupted message :( + Toast.makeText(getActivity(), R.string.err_attachment_corrupted, + Toast.LENGTH_LONG).show(); + } + } + + private void stopDownload(CompositeMessage msg) { + AttachmentComponent attachment = msg.getComponent(AttachmentComponent.class); + + if (attachment != null && attachment.getFetchUrl() != null) { + DownloadService.abort(getContext(), + Uri.parse(attachment.getFetchUrl())); + } + } + + private void saveAttachments(CompositeMessage msg) { + if (SystemUtils.supportsScopedStorage() || Permissions.canWriteExternalStorage(getContext())) { + doSaveAttachments(msg); + } + else { + Permissions.requestWriteExternalStorage(this, getString(R.string.err_storage_denied)); + Preferences.setPermissionAsked(Manifest.permission.WRITE_EXTERNAL_STORAGE); + Preferences.setPermissionAsked(Manifest.permission.READ_EXTERNAL_STORAGE); + } + } + + private void doSaveAttachments(CompositeMessage msg) { + int totalAttachments = 0; + int savedAttachments = 0; + for (MessageComponent component : msg.getComponents()) { + if (component instanceof AttachmentComponent) { + totalAttachments++; + + AttachmentComponent attachment = (AttachmentComponent) component; + Uri fileUri = attachment.getLocalUri(); + if (fileUri == null) { + Log.d(TAG, "attachment not downloaded, skipping"); + continue; + } + if (!"file".equals(fileUri.getScheme())) { + Log.w(TAG, "attachment comes from unknown source, skipping: " + fileUri); + continue; + } + + File file = new File(fileUri.getPath()); + + MediaStorage.MediaStoreType mediaType; + if (attachment instanceof ImageComponent) { + mediaType = MediaStorage.MediaStoreType.IMAGE; + } + else if (attachment instanceof AudioComponent) { + // FIXME audios are only recordings for now, but they won't be forever + mediaType = MediaStorage.MediaStoreType.RECORDING; + } + else if (attachment instanceof VCardComponent) { + mediaType = MediaStorage.MediaStoreType.OTHER; + } + else /*if (attachment instanceof DefaultAttachmentComponent)*/ { + mediaType = MediaStorage.MediaStoreType.OTHER; + } + try { + MediaStorage.publishMedia(getContext(), file, mediaType); + savedAttachments++; + } + catch (IOException e) { + Log.w(TAG, "unable to save attachment: " + file, e); + } + } + } + + String message; + if (savedAttachments == 0 && totalAttachments == 1) { + message = getString(R.string.err_storage_attachment_single); + } + else if (totalAttachments > 1 && savedAttachments < totalAttachments) { + message = getString(R.string.err_storage_attachment_some); + } + else { + message = getResources() + .getQuantityString(R.plurals.msg_storage_attachment_saved, + savedAttachments); + } + + Toast.makeText(getContext(), message, Toast.LENGTH_LONG).show(); + } + + private void openFile(CompositeMessage msg) { + AttachmentComponent attachment = msg.getComponent(AttachmentComponent.class); + + if (attachment != null) { + Intent i = new Intent(Intent.ACTION_VIEW); + Uri uri = MediaStorage.getWorldReadableUri(getContext(), + attachment.getLocalUri(), i); + i.setDataAndType(uri, attachment.getMime()); + try { + startActivity(i); + } + catch (ActivityNotFoundException e) { + Toast.makeText(getActivity(), R.string.chooser_error_no_app, + Toast.LENGTH_LONG).show(); + } + } + } + + private void chooseContact() { + // TODO one day it will be like this + // Intent i = new Intent(Intent.ACTION_PICK, Users.CONTENT_URI); + Intent i = new Intent(getContext(), ContactsListActivity.class); + i.putExtra(ContactsListActivity.MODE_MULTI_SELECT, true); + i.putExtra(ContactsListActivity.MODE_ADD_USERS, true); + startActivityForResult(i, REQUEST_INVITE_USERS); + } + + boolean tryHideAttachmentView() { + return tryHideAttachmentView(false); + } + + boolean tryHideAttachmentView(boolean instant) { + if (isAttachmentViewVisible()) { + mAttachmentContainer.hide(); + return true; + } + return false; + } + + private boolean isAttachmentViewVisible() { + return mAttachmentContainer.getVisibility() == View.VISIBLE; + } + + void hideAttachmentView() { + mAttachmentContainer.hide(); + } + + /** + * Show or hide the attachment selector. + */ + private void toggleAttachmentView() { + mAttachmentContainer.toggle(); + } + + @AfterPermissionGranted(Permissions.RC_CAMERA) + void startCameraAttachment() { + final Context context = getContext(); + if (context == null) + return; + + try { + mCurrentPhoto = MediaStorage.getOutgoingPhotoFile(context); + + final Intent intent = SystemUtils.externalIntent(MediaStore.ACTION_IMAGE_CAPTURE); + Uri uri = MediaStorage.getWorldWritableUri(getContext(), + Uri.fromFile(mCurrentPhoto), intent); + Log.d(TAG, "opening camera with uri: " + uri); + + intent.putExtra(MediaStore.EXTRA_OUTPUT, uri); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + intent.setClipData(ClipData.newUri(context.getContentResolver(), + "Picture path", uri)); + } + + startActivityForResult(intent, SELECT_ATTACHMENT_PHOTO); + } + catch (IOException e) { + Log.e(TAG, "error creating temp file", e); + Toast.makeText(getActivity(), R.string.chooser_error_no_camera, + Toast.LENGTH_LONG).show(); + } + + } + + private void requestCameraPermission() { + if (!Permissions.canUseCamera(getContext())) { + boolean requestStorage = !SystemUtils.supportsScopedStorage(); + int resId = requestStorage ? + R.string.err_camera_picture_storage_denied : + R.string.err_camera_picture_denied; + Permissions.requestCamera(this, getString(resId), requestStorage); + } + else { + startCameraAttachment(); + } + } + + /** + * Starts an activity for shooting a picture. + */ + void selectPhotoAttachment() { + try { + // check if camera is available + final PackageManager packageManager = getActivity().getPackageManager(); + final Intent intent = SystemUtils.externalIntent(MediaStore.ACTION_IMAGE_CAPTURE); + List list = + packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + if (list.size() <= 0) throw new UnsupportedOperationException(); + + requestCameraPermission(); + } + catch (UnsupportedOperationException ue) { + Toast.makeText(getActivity(), R.string.chooser_error_no_camera_app, + Toast.LENGTH_LONG).show(); + } + } + + /** + * Starts an activity for picture attachment selection. + */ + @TargetApi(Build.VERSION_CODES.KITKAT) + void selectGalleryAttachment() { + boolean useSAF = MediaStorage.isStorageAccessFrameworkAvailable(); + Intent pictureIntent = createGalleryIntent(useSAF); + + try { + startActivityForResult(pictureIntent, SELECT_ATTACHMENT_OPENABLE); + } + catch (ActivityNotFoundException e1) { + try { + if (useSAF) { + // try direct file system access + pictureIntent = createGalleryIntent(false); + startActivityForResult(pictureIntent, SELECT_ATTACHMENT_OPENABLE); + } + else { + // simulate error + throw new ActivityNotFoundException("gallery"); + } + } + catch (ActivityNotFoundException e2) { + Toast.makeText(getActivity(), R.string.chooser_error_no_gallery_app, + Toast.LENGTH_LONG).show(); + } + } + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + private Intent createGalleryIntent(boolean useSAF) { + Intent intent; + if (!useSAF) { + intent = SystemUtils.externalIntent(Intent.ACTION_GET_CONTENT) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + else { + intent = SystemUtils.externalIntent(Intent.ACTION_OPEN_DOCUMENT); + } + + return intent + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("image/*") + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } + + /** + * Starts activity for a vCard attachment from a contact. + */ + void selectContactAttachment() { + try { + Intent i = SystemUtils.externalIntent(Intent.ACTION_PICK, Contacts.CONTENT_URI); + startActivityForResult(i, SELECT_ATTACHMENT_CONTACT); + } + catch (ActivityNotFoundException e) { + // no contacts app found (crap device eh?) + Toast.makeText(getActivity(), + R.string.err_no_contacts_app, + Toast.LENGTH_LONG).show(); + } + } + + void selectAudioAttachment() { + Activity context = getActivity(); + if (context != null) { + // create audio fragment if needed + AudioFragment audio = getAudioFragment(); + + // stop everything + if (mAudioControl != null) { + resetAudio(mAudioControl); + } + else { + audio.resetPlayer(); + audio.setMessageId(-1); + } + + // show dialog + mAudioDialog = new AudioDialog(context, audio, this); + mAudioDialog.setOwnerActivity(context); + mAudioDialog.show(); + } + } + + void selectPositionAttachment() { + startActivityForResult(new Intent(getContext(), PositionActivity.class), SELECT_ATTACHMENT_LOCATION); + } + + private AudioFragment getAudioFragment() { + if (isAdded()) { + FragmentManager fm = getParentFragmentManager(); + AudioFragment found = (AudioFragment) fm.findFragmentByTag("audio"); + if (found != null) { + mAudioFragment = found; + } + else { + mAudioFragment = new AudioFragment(); + fm.beginTransaction() + .add(mAudioFragment, "audio") + .commit(); + } + } + + return mAudioFragment; + } + + protected abstract void deleteConversation(); + + private void deleteThread() { + new MaterialDialog.Builder(getActivity()) + .content(R.string.confirm_will_delete_thread) + .positiveText(android.R.string.ok) + .positiveColorRes(R.color.button_danger) + .negativeText(android.R.string.cancel) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + deleteConversation(); + } + }) + .show(); + } + + void addUsers() { + chooseContact(); + } + + protected abstract void addUsers(String[] members); + + private void replyMessage(CompositeMessage msg) { + TextComponent textComponent = msg.getComponent(TextComponent.class); + if (textComponent != null) { + String displayName; + String jid; + if (msg.getDirection() == Messages.DIRECTION_IN) { + String sender = msg.getSender(); + Contact contact = Contact.findByUserId(getContext(), sender); + displayName = contact.getDisplayName(); + jid = contact.getJID(); + } + else { + MyAccount myAccount = Kontalk.get().getDefaultAccount(); + displayName = myAccount.getDisplayName(); + jid = myAccount.getSelfJID(); + } + + mReplyBar.show(msg.getDatabaseId(), jid, displayName, textComponent.getContent()); + } + } + + private void retryMessage(CompositeMessage msg) { + Kontalk.get().getMessagesController() + .retryMessage(msg.getDatabaseId(), mConversation.isEncryptionEnabled()); + } + + void scrollToPosition(int position) { + getListView().setSelection(position); + } + + private boolean isSearching() { + Bundle args = getArguments(); + return args != null && args.getLong(ComposeMessage.EXTRA_MESSAGE, -1) >= 0; + } + + protected synchronized void startQuery() { + Conversation.startQuery(mQueryHandler, + CONVERSATION_QUERY_TOKEN, threadId); + // message list query will be started by query handler + } + + void startMessagesQuery() { + CompositeMessage.startQuery(mQueryHandler, MESSAGE_LIST_QUERY_TOKEN, + threadId, isSearching() ? 0 : MESSAGE_PAGE_SIZE, 0); + } + + void startMessagesQuery(long lastId) { + CompositeMessage.startQuery(mQueryHandler, MESSAGE_PAGE_QUERY_TOKEN, + threadId, isSearching() ? 0 : MESSAGE_PAGE_SIZE, lastId); + } + + private void stopQuery() { + hideHeaderView(); + if (mListAdapter != null) + mListAdapter.changeCursor(null); + + if (mQueryHandler != null) { + // be sure to cancel all queries + mQueryHandler.abort(); + } + } + + private void showMessageDetails(CompositeMessage msg) { + MessageUtils.showMessageDetails(getActivity(), msg, getDecodedPeer(msg), getDecodedName(msg)); + } + + /** + * Returns the phone number of the message sender, if available. + */ + protected abstract String getDecodedPeer(CompositeMessage msg); + + /** + * Returns the display name of the message sender, if available. + */ + protected abstract String getDecodedName(CompositeMessage msg); + + // TODO abstract those intent creators + private void shareMessage(CompositeMessage msg) { + Intent i = null; + AttachmentComponent attachment = msg.getComponent(AttachmentComponent.class); + + if (attachment != null) { + i = ComposeMessage.sendMediaMessage(getContext(), + attachment.getLocalUri(), attachment.getMime()); + } + else if (msg.getComponent(TextComponent.class) != null) { + TextComponent txt = msg.getComponent(TextComponent.class); + i = ComposeMessage.sendTextMessage(txt.getContent()); + } + else if (msg.getComponent(LocationComponent.class) != null) { + LocationComponent location = msg.getComponent(LocationComponent.class); + i = new Intent(android.content.Intent.ACTION_VIEW, + Uri.parse("geo:" + location.getLatitude() + "," + + location.getLongitude() + "?q=" + + location.getLatitude() + "," + location.getLongitude())); + } + + if (i != null) { + try { + startActivity(i); + } + catch (ActivityNotFoundException e) { + Toast.makeText(getContext(), R.string.chooser_error_no_share_app, + Toast.LENGTH_LONG).show(); + } + catch (SecurityException e) { + Toast.makeText(getContext(), R.string.chooser_error_share_denied, + Toast.LENGTH_LONG).show(); + } + } + else { + // TODO ehm... + Log.w(TAG, "unshareable message type"); + } + } + + protected void loadConversationMetadata(Uri uri) { + threadId = ContentUris.parseId(uri); + mConversation = Conversation.loadFromId(getActivity(), threadId); + if (mConversation == null) { + Log.w(TAG, "conversation for thread " + threadId + " not found!"); + startActivity(new Intent(getActivity(), ConversationsActivity.class)); + getActivity().finish(); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + // image from storage/picture from camera + // since there are like up to 3 different ways of doing this... + if (requestCode == SELECT_ATTACHMENT_OPENABLE || requestCode == SELECT_ATTACHMENT_PHOTO) { + if (resultCode == Activity.RESULT_OK) { + Uri[] uris = null; + String[] mimes = null; + + // returning from camera + if (requestCode == SELECT_ATTACHMENT_PHOTO) { + if (mCurrentPhoto != null) { + Uri uri = Uri.fromFile(mCurrentPhoto); + + if (SystemUtils.supportsScopedStorage() || Permissions.canWriteExternalStorage(getContext())) { + try { + MediaStorage.publishMedia(getContext(), mCurrentPhoto, MediaStorage.MediaStoreType.PHOTO); + } + catch (Exception e) { + Log.w(TAG, "unable to publish photo to media store", e); + Toast.makeText(getContext(), R.string.err_storage_photo_gallery, + Toast.LENGTH_LONG).show(); + } + } + + mCurrentPhoto = null; + + uris = new Uri[]{uri}; + } + } + else { + if (mCurrentPhoto != null) { + mCurrentPhoto.delete(); + mCurrentPhoto = null; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && data.getClipData() != null) { + ClipData cdata = data.getClipData(); + uris = new Uri[cdata.getItemCount()]; + + for (int i = 0; i < uris.length; i++) { + ClipData.Item item = cdata.getItemAt(i); + uris[i] = item.getUri(); + } + } + else { + uris = new Uri[]{data.getData()}; + mimes = new String[]{data.getType()}; + } + + // SAF available, request persistable permissions + if (MediaStorage.isStorageAccessFrameworkAvailable()) { + for (Uri uri : uris) { + if (uri != null && !"file".equals(uri.getScheme())) { + try { + MediaStorage.requestPersistablePermissions(getActivity(), uri); + } + catch (SecurityException e) { + // we'll try to access the file anyway later + Log.w(TAG, "unable to request persistable permissions - will try access anyway"); + } + } + } + } + } + + for (int i = 0; uris != null && i < uris.length; i++) { + Uri uri = uris[i]; + if (uri == null) + continue; + + String mime = (mimes != null && mimes.length >= uris.length) ? + mimes[i] : null; + + if (mime == null || mime.startsWith("*/") + || mime.endsWith("/*")) { + mime = MediaStorage.getType(getActivity(), uri); + Log.v(TAG, "using detected mime type " + mime); + } + + if (ImageComponent.supportsMimeType(mime)) + sendBinaryMessage(uri, mime, true, ImageComponent.class); + else if (VCardComponent.supportsMimeType(mime)) + sendBinaryMessage(uri, VCardComponent.MIME_TYPE, false, VCardComponent.class); + else + Toast.makeText(getActivity(), R.string.send_mime_not_supported, Toast.LENGTH_LONG) + .show(); + } + } + // operation aborted + else { + // delete photo :) + if (mCurrentPhoto != null) { + mCurrentPhoto.delete(); + mCurrentPhoto = null; + } + } + } + // contact card (vCard) + else if (requestCode == SELECT_ATTACHMENT_CONTACT) { + if (resultCode == Activity.RESULT_OK) { + Uri uri = data.getData(); + if (uri != null) { + Uri vcardUri = null; + + // get lookup key + final Cursor c = getContext().getContentResolver() + .query(uri, new String[]{Contacts.LOOKUP_KEY}, null, null, null); + if (c != null) { + try { + if (c.moveToFirst()) { + String lookupKey = c.getString(0); + vcardUri = Uri.withAppendedPath(Contacts.CONTENT_VCARD_URI, lookupKey); + } + } + catch (Exception e) { + Log.w(TAG, "unable to lookup selected contact. Did you grant me the permission?", e); + ReportingManager.logException(e); + } + finally { + c.close(); + } + } + + if (vcardUri != null) { + sendBinaryMessage(vcardUri, VCardComponent.MIME_TYPE, false, VCardComponent.class); + } + else { + Toast.makeText(getContext(), R.string.err_no_contact, + Toast.LENGTH_LONG).show(); + } + } + } + } + // user location + else if (requestCode == SELECT_ATTACHMENT_LOCATION) { + if (resultCode == Activity.RESULT_OK) { + Position position = (Position) data.getSerializableExtra("position"); + String mapsUrl = PositionManager.getMapsUrl(getContext(), position.getLatitude(), position.getLongitude()); + sendLocationMessage(mapsUrl, position.getLatitude(), position.getLongitude(), + position.getName(), position.getAddress()); + } + } + // invite user + else if (requestCode == REQUEST_INVITE_USERS) { + if (resultCode == Activity.RESULT_OK) { + + ArrayList uris; + Uri threadUri = data.getData(); + if (threadUri != null) { + String userId = threadUri.getLastPathSegment(); + addUsers(new String[]{userId}); + } + else if ((uris = data.getParcelableArrayListExtra("org.kontalk.contacts")) != null) { + String[] users = new String[uris.size()]; + for (int i = 0; i < users.length; i++) + users[i] = uris.get(i).getLastPathSegment(); + addUsers(users); + } + + } + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle out) { + super.onSaveInstanceState(out); + out.putParcelable(Uri.class.getName(), Threads.getUri(getUserId())); + // save composer status + if (mComposer != null) + mComposer.onSaveInstanceState(out); + // current photo being shot + if (mCurrentPhoto != null) { + out.putString("currentPhoto", mCurrentPhoto.toString()); + } + // audio dialog open + if (mAudioDialog != null) { + mAudioDialog.onSaveInstanceState(out); + } + // audio player stuff + out.putInt("mediaPlayerStatus", mMediaPlayerStatus); + } + + /** + * Handles ACTION_VIEW intents. + */ + protected abstract void handleActionView(Uri uri); + + /** + * Handles ACTION_VIEW_USERID intents: providing the user ID/JID. + */ + protected abstract boolean handleActionViewConversation(Uri uri, Bundle args); + + private void processArguments(Bundle savedInstanceState) { + Bundle args; + if (savedInstanceState != null) { + Uri uri = savedInstanceState.getParcelable(Uri.class.getName()); + // threadId = ContentUris.parseId(uri); + args = new Bundle(); + args.putString("action", ComposeMessage.ACTION_VIEW_USERID); + args.putParcelable("data", uri); + + String currentPhoto = savedInstanceState.getString("currentPhoto"); + if (currentPhoto != null) { + mCurrentPhoto = new File(currentPhoto); + } + + // audio playing + setAudioStatus(savedInstanceState.getInt("mediaPlayerStatus", AudioContentView.STATUS_IDLE)); + + // audio dialog stuff + mAudioDialog = AudioDialog.onRestoreInstanceState(getActivity(), + savedInstanceState, getAudioFragment(), this); + if (mAudioDialog != null) { + Log.d(TAG, "recreating audio dialog"); + mAudioDialog.show(); + } + } + else { + args = getArguments(); + } + + if (args != null && args.size() > 0) { + final String action = args.getString("action"); + + // view intent + if (Intent.ACTION_VIEW.equals(action)) { + Uri uri = args.getParcelable("data"); + handleActionView(uri); + } + + // view conversation - just threadId provided + else if (ComposeMessage.ACTION_VIEW_CONVERSATION.equals(action)) { + Uri uri = args.getParcelable("data"); + loadConversationMetadata(uri); + } + + // view conversation - just userId provided + else if (ComposeMessage.ACTION_VIEW_USERID.equals(action)) { + Uri uri = args.getParcelable("data"); + if (!handleActionViewConversation(uri, args)) { + getActivity().finish(); + return; + } + } + } + + // set title if we are autonomous + if (args != null) { + String title = mUserName; + //if (mUserPhone != null) title += " <" + mUserPhone + ">"; + setActivityTitle(title, ""); + } + + // update conversation stuff + if (mConversation != null) + onConversationCreated(); + + onArgumentsProcessed(); + } + + protected abstract void onArgumentsProcessed(); + + public void setActivityTitle(@Nullable CharSequence title, @Nullable CharSequence status) { + if (mStatusText != null) { + // tablet UI - ignore title + mStatusText.setText(status); + } + else { + ComposeMessageParent parent = (ComposeMessageParent) getActivity(); + parent.setTitle(title, status); + } + } + + public void setActivityStatusUpdating() { + if (mStatusText != null) { + CharSequence text = mStatusText.getText(); + if (text != null && text.length() > 0) { + mStatusText.setText(ComposeMessage.applyUpdatingStyle(text)); + } + } + else { + ComposeMessageParent parent = (ComposeMessageParent) getActivity(); + parent.setUpdatingSubtitle(); + } + } + + public ComposeMessage getParentActivity() { + Activity _activity = getActivity(); + return (_activity instanceof ComposeMessage) ? (ComposeMessage) _activity + : null; + } + + void processStart() { + ComposeMessage activity = getParentActivity(); + // opening for contact picker - do nothing + if (threadId < 0 && activity != null + && activity.getSendIntent() != null) + return; + + if (mListAdapter == null) { + Pattern highlight = null; + Bundle args = getArguments(); + if (args != null) { + String highlightString = args + .getString(ComposeMessage.EXTRA_HIGHLIGHT); + highlight = (highlightString == null) ? null : Pattern.compile( + "\\b" + Pattern.quote(highlightString), + Pattern.CASE_INSENSITIVE); + } + + mListAdapter = new MessageListAdapter(getActivity(), null, + highlight, getListView(), this); + mListAdapter.setOnContentChangedListener(mContentChangedListener); + setListAdapter(mListAdapter); + } + + if (threadId > 0) { + // always reload conversation + startQuery(); + } + else { + mConversation = Conversation.createNew(getActivity()); + mConversation.setRecipient(getUserId()); + onConversationCreated(); + } + } + + /** + * Called when the {@link Conversation} object has been created. + */ + protected void onConversationCreated() { + // restore any draft + mComposer.restoreText(mConversation.getDraft()); + + if (mConversation.getThreadId() > 0) { + if (mConversation.getUnreadCount() > 0) { + /* + * FIXME this has the usual issue about resuming while screen is + * still locked, having focus and so on... + * See issue #28. + */ + Log.v(TAG, "marking thread as read"); + mConversation.markAsRead(); + } + } + else { + // new conversation -- observe peer Uri + registerPeerObserver(); + } + + // register to events now + if (!mServiceBus.isRegistered(this)) + mServiceBus.register(this); + + updateUI(); + } + + protected void resetConnectionStatus() { + // reset compose sent flag + mComposer.resetCompose(); + // reset available resources list + mAvailableResources.clear(); + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onDownloadStarted(DownloadService.DownloadStarted event) { + // needed to avoid multiple download starting for the same media + if (event.id == mWaitingDownload) { + mWaitingDownload = 0; + } + } + + @AfterPermissionGranted(Permissions.RC_WRITE_EXT_STORAGE) + void onWriteStorageGranted() { + if (mWaitingDownload > 0) { + CompositeMessage msg = CompositeMessage.loadMessage(getContext(), mWaitingDownload); + if (msg != null) { + startDownload(msg); + } + } + } + + /** + * Called when a presence is received. + */ + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onUserOnline(UserOnlineEvent event) { + // check that origin matches the current chat + if (!isUserId(event.jid.toString())) { + // not for us + return; + } + + mAvailableResources.add(event.jid.toString()); + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onUserOffline(UserOfflineEvent event) { + // check that origin matches the current chat + if (!isUserId(event.jid.toString())) { + // not for us + return; + } + + mAvailableResources.remove(event.jid.toString()); + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public abstract void onNoUserPresence(NoPresenceEvent event); + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN_ORDERED) + public void onConnected(ConnectedEvent event) { + resetConnectionStatus(); + mConnected = true; + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onDisconnected(DisconnectedEvent event) { + resetConnectionStatus(); + mConnected = false; + } + + /** + * Called when the roster has been loaded (ACTION_ROSTER). + */ + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN_ORDERED) + public void onRosterLoaded(RosterLoadedEvent event) { + } + + /** + * Called when the contact starts typing. + */ + protected abstract void onStartTyping(String jid, @Nullable String groupJid); + + /** + * Called when the contact stops typing. + */ + protected abstract void onStopTyping(String jid, @Nullable String groupJid); + + /** + * Should return true if the contact is a user ID in the current context. + */ + protected abstract boolean isUserId(String jid); + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onChateState(ChatStateEvent event) { + // we are receiving a composing notification from our peer + String from = event.from.toString(); + if (isUserId(from)) { + String groupJid = event.group != null ? event.group.toString() : null; + + if (event.chatState == ChatState.composing) { + onStartTyping(event.from.toString(), groupJid); + } + else { + onStopTyping(from, groupJid); + } + } + } + + protected boolean isWarningVisible(WarningType type) { + Snackbar bar = SnackbarManager.getCurrentSnackbar(); + if (bar != null) { + WarningType oldType = (WarningType) bar.getTag(); + if (oldType != null && oldType == type) + return true; + } + return false; + } + + /** Hides the warning bar. */ + protected void hideWarning() { + SnackbarManager.dismiss(); + } + + /** Hides the warning bar only if type matches. */ + protected void hideWarning(WarningType type) { + Snackbar bar = SnackbarManager.getCurrentSnackbar(); + if (bar != null) { + WarningType oldType = (WarningType) bar.getTag(); + if (oldType == type) + bar.dismiss(); + } + } + + protected void showWarning(CharSequence text, final View.OnClickListener listener, WarningType type) { + View view = getView(); + Activity context = getActivity(); + if (view == null || context == null) + return; + + Snackbar bar = SnackbarManager.getCurrentSnackbar(); + if (bar != null) { + WarningType oldType = (WarningType) bar.getTag(); + if (oldType != null && oldType.getValue() > type.getValue()) + return; + + bar.dismiss(); + } + + bar = Snackbar.with(context) + .type(SnackbarType.MULTI_LINE) + .text(text) + .duration(Snackbar.SnackbarDuration.LENGTH_INDEFINITE) + .dismissOnActionClicked(false) + .allowMultipleActionClicks(true); + + if (listener != null) { + bar.swipeToDismiss(false) + .actionLabel(R.string.warning_button_details) + .actionListener(new ActionClickListener() { + @Override + public void onActionClicked(Snackbar snackbar) { + listener.onClick(null); + } + }); + } + else { + bar.swipeToDismiss(true) + .animation(false); + } + + int colorId = 0; + int textColorId = 0; + switch (type) { + case FATAL: + textColorId = R.color.warning_bar_text_fatal; + colorId = R.color.warning_bar_background_fatal; + break; + case WARNING: + textColorId = R.color.warning_bar_text_warning; + colorId = R.color.warning_bar_background_warning; + break; + } + + bar.setTag(type); + bar.color(ContextCompat.getColor(context, colorId)) + .textColor(ContextCompat.getColor(context, textColorId)); + + if (listener != null) { + SnackbarManager.show(bar); + } + else { + SnackbarManager.show(bar, view.findViewById(R.id.warning_bar)); + } + } + + protected void setStatusText(CharSequence text) { + ComposeMessageParent parent = (ComposeMessageParent) getActivity(); + if (parent instanceof ComposeMessage) + setActivityTitle(null, text); + else { + if (mStatusText != null) + mStatusText.setText(text); + } + } + + private synchronized void registerPeerObserver() { + if (mPeerObserver == null) { + Uri uri = Threads.getUri(mConversation.getRecipient()); + mPeerObserver = new PeerObserver(getActivity(), mQueryHandler); + getActivity().getContentResolver().registerContentObserver(uri, + false, mPeerObserver); + } + } + + synchronized void unregisterPeerObserver() { + if (mPeerObserver != null) { + Context context = mPeerObserver.mContext; + context.getContentResolver().unregisterContentObserver(mPeerObserver); + mPeerObserver = null; + } + } + + private final class PeerObserver extends ContentObserver { + final Context mContext; + + PeerObserver(Context context, Handler handler) { + super(handler); + mContext = context; + } + + @Override + public void onChange(boolean selfChange) { + Conversation conv = Conversation.loadFromUserId(mContext, getUserId()); + + if (conv != null) { + mConversation = conv; + threadId = mConversation.getThreadId(); + + // auto-unregister + unregisterPeerObserver(); + } + + // fire cursor update + Log.v(TAG, "peer observer active"); + processStart(); + } + + @Override + public boolean deliverSelfNotifications() { + return false; + } + } + + @Override + public void onResume() { + super.onResume(); + + if (Kontalk.get().getDefaultAccount() == null) { + NumberValidation.start(getActivity()); + getActivity().finish(); + return; + } + + // hold message center + MessageCenterService.hold(getActivity(), true); + + mDownloadBus.register(this); + + ComposeMessage activity = getParentActivity(); + if (activity == null || !activity.hasLostFocus() || activity.hasWindowFocus()) { + onFocus(); + } + + Showcase.showNextHint(this); + } + + public void onFocus() { + // resume content watcher + resumeContentListener(); + + // set notifications on pause + MessagingNotification.setPaused(getUserId()); + + // we are updating the status now + setActivityStatusUpdating(); + + // cursor was previously destroyed -- reload everything + processStart(); + } + + @Override + public void onPause() { + super.onPause(); + + // not interested in any more events + mServiceBus.unregister(this); + + // notify composer bar + mComposer.onPause(); + + // hide attachment view + tryHideAttachmentView(true); + + // hide emoji drawer + tryHideEmojiDrawer(); + + // pause content watcher + pauseContentListener(); + + // notify parent of pausing + ComposeMessage parent = getParentActivity(); + if (parent != null) + parent.fragmentLostFocus(); + + CharSequence text = mComposer.getText(); + int len = text.length(); + + // resume notifications + MessagingNotification.setPaused(null); + + // save last message as draft + if (threadId > 0) { + + // no draft and no messages - delete conversation + if (len == 0 && mConversation.getMessageCount() == 0 && + mConversation.getRequestStatus() != Threads.REQUEST_WAITING && + !mConversation.isGroupChat()) { + + mConversation.delete(false); + } + + // update draft + else { + try { + MessagesProviderClient.updateDraft(getContext(), threadId, text.toString()); + } + catch (SQLiteDiskIOException e) { + // TODO warn user + Log.w(TAG, "error saving draft", e); + len = 0; + } + } + } + + // new thread, create empty conversation + else { + if (len > 0) { + // save to local storage + try { + MessagesProviderClient.insertEmptyThread(getActivity(), getUserId(), text.toString()); + } + catch (SQLiteDiskIOException e) { + // TODO warn user + Log.w(TAG, "error saving draft", e); + len = 0; + } + } + } + + if (len > 0) { + Toast.makeText(getActivity(), R.string.msg_draft_saved, + Toast.LENGTH_LONG).show(); + } + + if (mComposer.isComposeSent()) { + // send inactive state notification + sendInactive(); + mComposer.resetCompose(); + } + + // release message center + MessageCenterService.release(getActivity()); + + mDownloadBus.unregister(this); + + // release audio player + AudioFragment audio = getAudioFragment(); + if (audio != null) { + stopMediaPlayerUpdater(); + + if (!getActivity().isChangingConfigurations()) { + audio.setMessageId(-1); + audio.finish(true); + } + } + } + + @Override + public void onStop() { + super.onStop(); + unregisterPeerObserver(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + stopQuery(); + if (mComposer != null) { + mComposer.onDestroy(); + } + if (mAudioDialog != null) { + mAudioDialog.dismiss(); + mAudioDialog = null; + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this); + } + + private void pauseContentListener() { + if (mListAdapter != null) + mListAdapter.setOnContentChangedListener(null); + } + + private void resumeContentListener() { + if (mListAdapter != null) + mListAdapter.setOnContentChangedListener(mContentChangedListener); + } + + public final boolean isFinishing() { + Activity activity = getActivity(); + return (activity == null || activity.isFinishing()) || isRemoving(); + } + + void showHeaderView() { + mHeaderView.setVisibility(View.VISIBLE); + } + + void hideHeaderView() { + mHeaderView.setVisibility(View.GONE); + } + + void enableHeaderView(boolean enabled) { + mNextPageButton.setEnabled(enabled); + } + + protected void updateUI() { + boolean threadEnabled = (threadId > 0); + + if (mDeleteThreadMenu != null) { + mDeleteThreadMenu.setEnabled(threadEnabled); + } + + Context context = getActivity(); + if (context != null) { + if (mConversation != null && Preferences.getEncryptionEnabled(context)) { + boolean encryption = mConversation.isEncryptionEnabled(); + if (mToggleEncryptionMenu != null) { + mToggleEncryptionMenu + .setVisible(true) + .setEnabled(true) + .setChecked(encryption); + } + if (mBackground != null) { + if (encryption) { + mBackground.clearColorFilter(); + } + else { + mBackground.setColorFilter(ContextCompat + .getColor(context, R.color.app_background_unsafe_filter)); + } + } + } + else { + if (mToggleEncryptionMenu != null) { + mToggleEncryptionMenu + .setVisible(false) + .setEnabled(false) + .setChecked(false); + } + if (mBackground != null) { + mBackground.setColorFilter(ContextCompat + .getColor(context, R.color.app_background_unsafe_filter)); + } + } + } + } + + boolean tryHideEmojiDrawer() { + if (mComposer.isEmojiVisible()) { + mComposer.hideEmojiDrawer(false); + return true; + } + return false; + } + + public Conversation getConversation() { + return mConversation; + } + + public Contact getContact() { + return (mConversation != null) ? mConversation.getContact() : null; + } + + public long getThreadId() { + return threadId; + } + + protected void setThreadId(long threadId) { + this.threadId = threadId; + } + + /** + * Returns the user id of this conversation. + */ + public abstract String getUserId(); + + public void setTextEntry(CharSequence text) { + mComposer.setText(text); + } + + @Override + public boolean onLongClick(View v) { + // this seems to be necessary... + return false; + } + + public void closeConversation() { + // main activity + if (getParentActivity() != null) { + getActivity().finish(); + } + // using fragments... + else { + ConversationsActivity activity = (ConversationsActivity) getActivity(); + activity.getListFragment().endConversation(this); + } + } + + private void offlineModeWarning() { + if (Preferences.getOfflineMode() && !mOfflineModeWarned) { + mOfflineModeWarned = true; + Toast.makeText(getActivity(), R.string.warning_offline_mode, + Toast.LENGTH_LONG).show(); + } + } + + @Override + public void textChanged(CharSequence text) { + Snackbar bar = SnackbarManager.getCurrentSnackbar(); + if (bar != null) { + WarningType type = (WarningType) bar.getTag(); + if (type != null && type.getValue() < WarningType.FATAL.getValue()) { + bar.dismiss(); + } + } + } + + @Override + public void onRecordingSuccessful(File file) { + if (file != null) + sendBinaryMessage(Uri.fromFile(file), AudioRecording.MIME_TYPE, false, AudioComponent.class); + } + + @Override + public void onRecordingCancel() { + mAudioDialog = null; + } + + @Override + public void buttonClick(File audioFile, AudioContentViewControl view, long messageId) { + AudioFragment audio = getAudioFragment(); + if (audio.getMessageId() == messageId) { + switch (mMediaPlayerStatus) { + case AudioContentView.STATUS_PLAYING: + pauseAudio(view); + break; + case AudioContentView.STATUS_PAUSED: + case AudioContentView.STATUS_ENDED: + playAudio(view, messageId); + break; + + } + } + else { + switch (mMediaPlayerStatus) { + case AudioContentView.STATUS_IDLE: + if (prepareAudio(audioFile, view, messageId)) + playAudio(view, messageId); + break; + case AudioContentView.STATUS_ENDED: + case AudioContentView.STATUS_PLAYING: + case AudioContentView.STATUS_PAUSED: + resetAudio(mAudioControl); + if (prepareAudio(audioFile, view, messageId)) + playAudio(view, messageId); + break; + } + } + } + + private boolean prepareAudio(File audioFile, final AudioContentViewControl view, final long messageId) { + stopMediaPlayerUpdater(); + try { + AudioFragment audio = getAudioFragment(); + audio.preparePlayer(audioFile); + + // prepare was successful + audio.setMessageId(messageId); + mAudioControl = view; + + view.prepare(audio.getPlayerDuration()); + audio.seekPlayerTo(view.getPosition()); + view.setProgressChangeListener(true); + audio.setListener(new AudioFragment.AudioFragmentListener() { + @Override + public void onCompletion(AudioFragment audio) { + stopMediaPlayerUpdater(); + view.end(); + // this is mainly to get the wake lock released + audio.pausePlaying(); + audio.seekPlayerTo(0); + setAudioStatus(AudioContentView.STATUS_ENDED); + } + + @Override + public void onAudioFocusLost(AudioFragment audio) { + stopAllSounds(); + } + + @Override + public void onPause(AudioFragment audio) { + view.pause(); + stopMediaPlayerUpdater(); + setAudioStatus(AudioContentView.STATUS_PAUSED); + } + + @Override + public void onError(AudioFragment audio) { + stopMediaPlayerUpdater(); + view.end(); + setAudioStatus(AudioContentView.STATUS_ENDED); + Toast.makeText(getContext(), R.string.err_playing_audio, Toast.LENGTH_LONG) + .show(); + } + }); + return true; + } + catch (IOException e) { + Toast.makeText(getActivity(), R.string.err_file_not_found, Toast.LENGTH_SHORT).show(); + return false; + } + } + + @Override + public void playAudio(AudioContentViewControl view, long messageId) { + if (getAudioFragment().startPlaying()) { + view.play(); + setAudioStatus(AudioContentView.STATUS_PLAYING); + startMediaPlayerUpdater(view); + } + } + + private void updatePosition(AudioContentViewControl view) { + // we don't use getElapsedTime() here because it might get moved by seeking + view.updatePosition(getAudioFragment().getPlayerPosition()); + } + + @Override + public void pauseAudio(AudioContentViewControl view) { + view.pause(); + getAudioFragment().pausePlaying(); + stopMediaPlayerUpdater(); + setAudioStatus(AudioContentView.STATUS_PAUSED); + } + + private void resetAudio(AudioContentViewControl view) { + if (view != null) { + stopMediaPlayerUpdater(); + view.end(); + } + AudioFragment audio = getAudioFragment(); + if (audio != null) { + audio.resetPlayer(); + audio.setMessageId(-1); + } + } + + private void setAudioStatus(int audioStatus) { + mMediaPlayerStatus = audioStatus; + } + + @Override + public void stopAllSounds() { + resetAudio(mAudioControl); + } + + @Override + public void onBind(long messageId, final AudioContentViewControl view) { + final AudioFragment audio = getAudioFragment(); + if (audio != null && audio.getMessageId() == messageId) { + mAudioControl = view; + audio.setListener(new AudioFragment.AudioFragmentListener() { + @Override + public void onCompletion(AudioFragment audio) { + stopMediaPlayerUpdater(); + view.end(); + audio.seekPlayerTo(0); + setAudioStatus(AudioContentView.STATUS_ENDED); + } + + @Override + public void onAudioFocusLost(AudioFragment audio) { + stopAllSounds(); + } + + @Override + public void onPause(AudioFragment audio) { + view.pause(); + stopMediaPlayerUpdater(); + setAudioStatus(AudioContentView.STATUS_PAUSED); + } + + @Override + public void onError(AudioFragment audio) { + stopMediaPlayerUpdater(); + view.end(); + setAudioStatus(AudioContentView.STATUS_ENDED); + Toast.makeText(getContext(), R.string.err_playing_audio, Toast.LENGTH_LONG) + .show(); + } + }); + + view.setProgressChangeListener(true); + view.prepare(audio.getPlayerDuration()); + if (audio.isPlaying()) { + startMediaPlayerUpdater(view); + view.play(); + } + else { + view.pause(); + } + } + } + + @Override + public void onUnbind(long messageId, AudioContentViewControl view) { + AudioFragment audio = getAudioFragment(); + if (audio != null && audio.getMessageId() == messageId) { + mAudioControl = null; + audio.setListener(new AudioFragment.AudioFragmentListener() { + @Override + public void onCompletion(AudioFragment audio) { + audio.seekPlayerTo(0); + setAudioStatus(AudioContentView.STATUS_ENDED); + } + + @Override + public void onAudioFocusLost(AudioFragment audio) { + stopAllSounds(); + } + + @Override + public void onPause(AudioFragment audio) { + setAudioStatus(AudioContentView.STATUS_PAUSED); + } + + @Override + public void onError(AudioFragment audio) { + setAudioStatus(AudioContentView.STATUS_ENDED); + } + }); + + view.setProgressChangeListener(false); + if (!MessagesProviderClient.exists(getActivity(), messageId)) { + resetAudio(view); + } + + else { + stopMediaPlayerUpdater(); + } + } + } + + @Override + public boolean isPlaying() { + AudioFragment audio = getAudioFragment(); + return audio != null && audio.isPlaying(); + } + + @Override + public void seekTo(int position) { + AudioFragment audio = getAudioFragment(); + if (audio != null) + audio.seekPlayerTo(position); + } + + private void startMediaPlayerUpdater(final AudioContentViewControl view) { + updatePosition(view); + mMediaPlayerUpdater = new Runnable() { + @Override + public void run() { + updatePosition(view); + mHandler.postDelayed(this, 100); + } + }; + mHandler.postDelayed(mMediaPlayerUpdater, 100); + } + + private void stopMediaPlayerUpdater() { + if (mMediaPlayerUpdater != null) { + mHandler.removeCallbacks(mMediaPlayerUpdater); + mMediaPlayerUpdater = null; + } + } + + /** + * The conversation list query handler. + */ + private static final class MessageListQueryHandler extends AsyncQueryHandler { + private WeakReference mParent; + private boolean mCancel; + private long mLastId; + + MessageListQueryHandler(AbstractComposeFragment parent) { + super(parent.getActivity().getApplicationContext().getContentResolver()); + mParent = new WeakReference<>(parent); + } + + @Override + public synchronized void startQuery(int token, Object cookie, Uri uri, String[] projection, String selection, String[] selectionArgs, String orderBy) { + mCancel = false; + super.startQuery(token, cookie, uri, projection, selection, selectionArgs, orderBy); + } + + @Override + protected synchronized void onQueryComplete(int token, Object cookie, Cursor cursor) { + final AbstractComposeFragment parent = mParent.get(); + if (parent == null || cursor == null || parent.isFinishing() || mCancel) { + // close cursor - if any + if (cursor != null) + cursor.close(); + + mCancel = false; + if (parent != null) { + parent.unregisterPeerObserver(); + parent.mListAdapter.changeCursor(null); + } + return; + } + + switch (token) { + case MESSAGE_LIST_QUERY_TOKEN: + + // no messages to show - exit + if (cursor.getCount() == 0 + && (parent.mConversation == null || + // no draft + (parent.mConversation.getDraft() == null && + // no subscription request + parent.mConversation.getRequestStatus() != Threads.REQUEST_WAITING && + // no text in compose entry + parent.mComposer.getText().length() == 0 && + // no group chat + !parent.mConversation.isGroupChat()))) { + + Log.i(TAG, "no data to view - exit"); + + // close conversation + parent.closeConversation(); + + } + else { + // first query - use last id of this new cursor + if (cursor.getCount() > 0) { + cursor.moveToFirst(); + mLastId = Conversation.getMessageId(cursor); + } + + // save reloading status for next time + Bundle args = parent.getArguments(); + + // see if we have to scroll to a specific message + int newSelectionPos = -1; + + if (args != null && !args.getBoolean(ComposeMessage.EXTRA_RELOADING)) { + long msgId = args.getLong(ComposeMessage.EXTRA_MESSAGE, -1); + if (msgId > 0) { + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + long curId = cursor.getLong(CompositeMessage.COLUMN_ID); + if (curId == msgId) { + newSelectionPos = cursor.getPosition(); + break; + } + } + } + + args.putBoolean(ComposeMessage.EXTRA_RELOADING, true); + } + + parent.mListAdapter.changeCursor(cursor); + if (newSelectionPos >= 0) { + // +1 is for the header view + final int pos = newSelectionPos + 1; + parent.getListView().post(new Runnable() { + @Override + public void run() { + parent.scrollToPosition(pos); + } + }); + } + + if (newSelectionPos < 0 && cursor.getCount() >= MESSAGE_PAGE_SIZE) + parent.showHeaderView(); + + parent.updateUI(); + } + + break; + + case MESSAGE_PAGE_QUERY_TOKEN: + if (cursor.getCount() > 0) { + int newSelectionPos = -1; + + // there is no more data after this page + if (cursor.getCount() < MESSAGE_PAGE_SIZE) + parent.hideHeaderView(); + + // save last id of this new cursor + cursor.moveToFirst(); + mLastId = Conversation.getMessageId(cursor); + + // join with the old cursor (if any) + Cursor oldCursor = parent.mListAdapter.getCursor(); + if (oldCursor != null) { + // the new selection will be the next item after this new cursor + newSelectionPos = cursor.getCount(); + cursor = new MergeCursor(new Cursor[]{cursor, oldCursor}); + } + + parent.mListAdapter.swapCursor(cursor); + if (newSelectionPos >= 0) + parent.getListView().setSelection(newSelectionPos); + + parent.updateUI(); + } + else { + // this happens when the first page is exactly PAGE_SIZE big + parent.hideHeaderView(); + } + + parent.enableHeaderView(true); + break; + + case CONVERSATION_QUERY_TOKEN: + if (cursor.moveToFirst()) { + parent.mConversation = Conversation.createFromCursor( + parent.getActivity(), cursor); + parent.onConversationCreated(); + } + + cursor.close(); + + parent.startMessagesQuery(); + break; + + default: + Log.e(TAG, "onQueryComplete called with unknown token " + token); + } + + } + + public synchronized void abort() { + mCancel = true; + mLastId = 0; + cancelOperation(MESSAGE_LIST_QUERY_TOKEN); + cancelOperation(CONVERSATION_QUERY_TOKEN); + cancelOperation(MESSAGE_PAGE_QUERY_TOKEN); + } + + public long getLastId() { + return mLastId; + } + + } + +} diff --git a/app/src/main/java/org/kontalk/ui/AbstractConversationsFragment.java b/app/src/main/java/org/kontalk/ui/AbstractConversationsFragment.java new file mode 100644 index 000000000..b6501f165 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/AbstractConversationsFragment.java @@ -0,0 +1,426 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import java.util.ArrayList; +import java.util.List; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; + +import android.app.Activity; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.paging.PagedList; +import android.os.Bundle; +import android.os.Handler; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.appcompat.view.ActionMode; +import androidx.recyclerview.selection.ItemKeyProvider; +import androidx.recyclerview.selection.Selection; +import androidx.recyclerview.selection.SelectionTracker; +import androidx.recyclerview.selection.StorageStrategy; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.TextView; + +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.data.Conversation; +import org.kontalk.provider.MyMessages; +import org.kontalk.ui.adapter.ConversationListAdapter; +import org.kontalk.ui.model.ConversationsViewModel; +import org.kontalk.ui.view.ConversationListItem; + + +public abstract class AbstractConversationsFragment extends Fragment + implements Contact.ContactChangeListener, ConversationListAdapter.OnItemClickListener { + static final String TAG = ConversationsActivity.TAG; + + private View mEmptyView; + private RecyclerView mListView; + private ConversationListAdapter mListAdapter; + private ConversationsViewModel mViewModel; + private RecyclerView.AdapterDataObserver mObserver; + + private SelectionTracker mSelectionTracker; + private ActionModeCallback mActionModeCallback; + private ActionMode mActionMode; + + private Handler mHandler; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mHandler = new Handler(); + mViewModel = new ViewModelProvider(this).get(ConversationsViewModel.class); + mObserver = new RecyclerView.AdapterDataObserver() { + @Override + public void onChanged() { + final ConversationsCallback parent = getParentCallback(); + if (parent != null) { + mHandler.post(new Runnable() { + @Override + public void run() { + parent.onDatabaseChanged(); + } + }); + } + } + + @Override + public void onItemRangeChanged(int positionStart, int itemCount) { + onChanged(); + } + + @Override + public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) { + onChanged(); + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + onChanged(); + } + + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + onChanged(); + } + + @Override + public void onItemRangeRemoved(int positionStart, int itemCount) { + onChanged(); + } + }; + } + + @Override + public abstract View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState); + + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mEmptyView = view.findViewById(android.R.id.empty); + + mListView = view.findViewById(android.R.id.list); + mListView.setLayoutManager(new LinearLayoutManager(view.getContext(), + LinearLayoutManager.VERTICAL, false)); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + mListAdapter = new ConversationListAdapter(getContext()); + mListAdapter.setItemListener(this); + onAdapterCreated(mListAdapter); + + mListView.setAdapter(mListAdapter); + mActionModeCallback = new ActionModeCallback(); + + final ItemKeyProvider keyProvider = new ItemKeyProvider(ItemKeyProvider.SCOPE_MAPPED) { + @Override + public Long getKey(int position) { + return mListAdapter.getItemId(position); + } + + @Override + public int getPosition(@NonNull Long key) { + RecyclerView.ViewHolder viewHolder = mListView.findViewHolderForItemId(key); + return viewHolder == null ? RecyclerView.NO_POSITION : viewHolder.getLayoutPosition(); + } + }; + + mSelectionTracker = new SelectionTracker.Builder<>( + "conversation-selector", + mListView, + keyProvider, + new ConversationListAdapter.ConversationItemDetailsLookup(mListView), + StorageStrategy.createLongStorage()) + .withSelectionPredicate(new SelectionTracker.SelectionPredicate() { + @Override + public boolean canSetStateForKey(@NonNull Long key, boolean nextState) { + return key > 0; + } + + @Override + public boolean canSetStateAtPosition(int position, boolean nextState) { + if (position >= 0) { + Long key = keyProvider.getKey(position); + return key != null && key > 0; + } + return false; + } + + @Override + public boolean canSelectMultiple() { + return true; + } + }) + .build(); + mSelectionTracker.addObserver(new SelectionTracker.SelectionObserver() { + @Override + public void onSelectionChanged() { + AbstractConversationsFragment.this.onSelectionChanged(); + } + }); + + mListAdapter.setSelectionTracker(mSelectionTracker); + + mViewModel.load(getContext(), isArchived()); + mViewModel.getData().observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(@Nullable PagedList conversations) { + mListAdapter.submitList(conversations); + mEmptyView.setVisibility(conversations != null && conversations.size() > 0 ? + View.GONE : View.VISIBLE); + } + }); + mListAdapter.registerAdapterDataObserver(mObserver); + + if (savedInstanceState != null) { + mSelectionTracker.onRestoreInstanceState(savedInstanceState); + } + + if (mSelectionTracker.hasSelection()){ + mActionMode = getParentCallback().startSupportActionMode(mActionModeCallback); + updateActionModeSelectionCount(mSelectionTracker.getSelection().size()); + } + } + + /** Whether to enable hybrid single and multiple selection. */ + protected abstract boolean isSingleSelection(); + + /** Child classes should use this to set item listeners. */ + protected abstract void onAdapterCreated(ConversationListAdapter adapter); + + /** Whether we should query for archived chats or not. */ + protected abstract boolean isArchived(); + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + mSelectionTracker.onSaveInstanceState(outState); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (mListAdapter != null) + mListAdapter.unregisterAdapterDataObserver(mObserver); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + return isActionModeActive() || super.onOptionsItemSelected(item); + } + + public boolean isActionModeActive() { + return mActionMode != null; + } + + protected ConversationsCallback getParentCallback() { + return (ConversationsCallback) getActivity(); + } + + public ConversationsViewModel getViewModel() { + return mViewModel; + } + + public ConversationListAdapter getListAdapter() { + return mListAdapter; + } + + public Selection getSelectedPositions() { + return mSelectionTracker.getSelection(); + } + + @Override + public void onStart() { + super.onStart(); + Contact.registerContactChangeListener(this); + } + + @Override + public void onStop() { + super.onStop(); + Contact.unregisterContactChangeListener(this); + } + + @Override + public void onItemClick(ConversationListItem item, int position) { + if (mActionMode != null) + return; + + Conversation conv = item.getConversation(); + + ConversationsCallback parent = getParentCallback(); + if (parent != null) { + //mMultiSelector.setSelectedPosition(position); + parent.openConversation(conv); + } + } + + public void onSelectionChanged() { + if (mSelectionTracker.hasSelection() && mActionMode == null) { + mActionMode = getParentCallback().startSupportActionMode(mActionModeCallback); + updateActionModeSelectionCount(mSelectionTracker.getSelection().size()); + } + else if (!mSelectionTracker.hasSelection() && mActionMode != null) { + mActionMode.finish(); + mActionMode = null; + } + else if (mActionMode != null) { + updateActionModeSelectionCount(mSelectionTracker.getSelection().size()); + } + } + + private void updateActionModeSelectionCount(int count) { + mActionMode.setTitle(getResources() + .getQuantityString(R.plurals.context_selected, + count, count)); + mActionMode.invalidate(); + } + + @Override + public void onContactInvalidated(String userId) { + Activity parent = getActivity(); + if (parent != null) { + parent.runOnUiThread(new Runnable() { + @Override + public void run() { + mListAdapter.notifyDataSetChanged(); + } + }); + } + } + + public boolean hasListItems() { + return mListAdapter != null && mListAdapter.getRealItemCount() > 0; + } + + /** Utility for child classes to delete selected threads. */ + protected void deleteSelectedThreads() { + boolean addGroupCheckbox = false; + int checkedCount = 0; + + final Selection selected = getSelectedPositions(); + final List list = new ArrayList<>(selected.size()); + for (long id: selected) { + Conversation conv = Conversation.loadFromId(getContext(), id); + if (!addGroupCheckbox && conv.isGroupChat() && + conv.getGroupMembership() == MyMessages.Groups.MEMBERSHIP_MEMBER) { + addGroupCheckbox = true; + } + list.add(conv); + } + + final boolean hasGroupCheckbox = addGroupCheckbox; + MaterialDialog.Builder builder = new MaterialDialog.Builder(getContext()) + .customView(R.layout.dialog_text2_check, false) + .positiveText(android.R.string.ok) + .positiveColorRes(R.color.button_danger) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + boolean promptCheckBoxChecked = false; + if (hasGroupCheckbox) { + CheckBox promptCheckbox = dialog + .getCustomView().findViewById(R.id.promptCheckbox); + promptCheckBoxChecked = promptCheckbox.isChecked(); + } + + for (Conversation conv : list) { + boolean hasLeftGroup = conv.isGroupChat() && + (conv.getGroupMembership() == MyMessages.Groups.MEMBERSHIP_PARTED || + conv.getGroupMembership() == MyMessages.Groups.MEMBERSHIP_KICKED); + conv.delete(hasGroupCheckbox ? promptCheckBoxChecked : hasLeftGroup); + } + getListAdapter().notifyDataSetChanged(); + } + }) + .negativeText(android.R.string.cancel); + + MaterialDialog dialog = builder.build(); + + ((TextView) dialog.getCustomView().findViewById(android.R.id.text1)) + .setText(getResources().getQuantityString(R.plurals.confirm_will_delete_threads, checkedCount)); + + if (addGroupCheckbox) { + TextView text2 = dialog.getCustomView().findViewById(android.R.id.text2); + text2.setText(R.string.delete_threads_groups_disclaimer); + text2.setVisibility(View.VISIBLE); + + CheckBox promptCheckbox = dialog.getCustomView().findViewById(R.id.promptCheckbox); + promptCheckbox.setText(getResources() + .getQuantityString(R.plurals.delete_threads_leave_groups, checkedCount)); + promptCheckbox.setVisibility(View.VISIBLE); + } + + dialog.show(); + } + + // action mode callback methods for child classes + + /** For use to child classes. */ + protected abstract boolean onCreateActionMode(ActionMode mode, Menu menu); + + /** For use to child classes. */ + protected abstract boolean onPrepareActionMode(ActionMode mode, Menu menu); + + /** For use to child classes. */ + protected abstract boolean onActionItemClicked(ActionMode mode, MenuItem item); + + private final class ActionModeCallback implements ActionMode.Callback { + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + return AbstractConversationsFragment.this + .onActionItemClicked(mode, item); + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + return AbstractConversationsFragment.this.onCreateActionMode(mode, menu); + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return AbstractConversationsFragment.this.onPrepareActionMode(mode, menu); + } + + @Override + public void onDestroyActionMode(ActionMode actionMode) { + mActionMode = null; + mSelectionTracker.clearSelection(); + mListAdapter.notifyDataSetChanged(); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/AccountAuthenticatorActionBarActivity.java b/app/src/main/java/org/kontalk/ui/AccountAuthenticatorActionBarActivity.java index d73e5c344..3a3dbdfd6 100644 --- a/app/src/main/java/org/kontalk/ui/AccountAuthenticatorActionBarActivity.java +++ b/app/src/main/java/org/kontalk/ui/AccountAuthenticatorActionBarActivity.java @@ -19,7 +19,6 @@ import android.accounts.AccountAuthenticatorResponse; import android.accounts.AccountManager; import android.os.Bundle; -import android.support.v7.app.ActionBarActivity; /** @@ -36,7 +35,7 @@ * is never set or if it is set to null then error {@link android.accounts.AccountManager#ERROR_CODE_CANCELED} * will be called on the response. */ -public class AccountAuthenticatorActionBarActivity extends ActionBarActivity { +public abstract class AccountAuthenticatorActionBarActivity extends ToolbarActivity { private AccountAuthenticatorResponse mAccountAuthenticatorResponse = null; private Bundle mResultBundle = null; diff --git a/app/src/main/java/org/kontalk/ui/ArchivedConversationsActivity.java b/app/src/main/java/org/kontalk/ui/ArchivedConversationsActivity.java new file mode 100644 index 000000000..ea7cf68ff --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ArchivedConversationsActivity.java @@ -0,0 +1,89 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import android.app.Activity; +import android.content.ContentUris; +import android.content.Intent; +import android.os.Bundle; + +import org.kontalk.R; +import org.kontalk.data.Conversation; +import org.kontalk.provider.MyMessages; +import org.kontalk.service.msgcenter.MessageCenterService; + + +/** + * The archived conversations list activity. + * This is a always dedicated activity (also on tablets) which will reply to the + * calling activity with the selected conversation. + * @author Daniele Ricci + */ +public class ArchivedConversationsActivity extends ToolbarActivity + implements ConversationsCallback { + public static final String TAG = ArchivedConversationsActivity.class.getSimpleName(); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.archived_conversations_screen); + + setupToolbar(true, true); + } + + @Override + protected boolean isNormalUpNavigation() { + return true; + } + + @Override + public void onResume() { + super.onResume(); + + // hold message center + MessageCenterService.hold(this, true); + } + + @Override + protected void onPause() { + super.onPause(); + // release message center + MessageCenterService.release(this); + } + + @Override + public void openConversation(Conversation conv) { + // return result to caller + setResult(RESULT_OK, new Intent(null, ContentUris + .withAppendedId(MyMessages.Threads.Conversations.CONTENT_URI, conv.getThreadId()))); + finish(); + } + + @Override + public void onDatabaseChanged() { + // nothing to do + } + + public static void start(Activity activity, int requestCode) { + activity.startActivityForResult(new Intent(activity, ArchivedConversationsActivity.class), + requestCode); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/ArchivedConversationsFragment.java b/app/src/main/java/org/kontalk/ui/ArchivedConversationsFragment.java new file mode 100644 index 000000000..8132c4764 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ArchivedConversationsFragment.java @@ -0,0 +1,95 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.appcompat.view.ActionMode; +import androidx.recyclerview.selection.Selection; + +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import org.kontalk.R; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.ui.adapter.ConversationListAdapter; + + +public class ArchivedConversationsFragment extends AbstractConversationsFragment { + static final String TAG = ConversationsActivity.TAG; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.archived_conversation_list, container, false); + } + + @Override + protected boolean isArchived() { + return true; + } + + @Override + protected boolean isSingleSelection() { + return false; + } + + @Override + protected void onAdapterCreated(ConversationListAdapter adapter) { + // nothing to do + } + + @Override + protected boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = mode.getMenuInflater(); + inflater.inflate(R.menu.archived_conversation_list_ctx, menu); + return true; + } + + @Override + protected boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + protected boolean onActionItemClicked(ActionMode mode, MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_unarchive: + unarchiveSelectedThreads(); + mode.finish(); + return true; + case R.id.menu_delete: + deleteSelectedThreads(); + mode.finish(); + return true; + } + return false; + } + + private void unarchiveSelectedThreads() { + Selection selected = getSelectedPositions(); + for (long id: selected) { + MessagesProviderClient.setArchived(getContext(), id, false); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/AudioDialog.java b/app/src/main/java/org/kontalk/ui/AudioDialog.java index 0589daa77..38da5c022 100644 --- a/app/src/main/java/org/kontalk/ui/AudioDialog.java +++ b/app/src/main/java/org/kontalk/ui/AudioDialog.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2011 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,23 +20,25 @@ import java.io.File; import java.io.IOException; +import java.util.concurrent.TimeUnit; -import org.kontalk.R; -import org.kontalk.ui.view.CircularSeekBar; -import org.kontalk.util.MediaStorage; +import com.afollestad.materialdialogs.MaterialDialog; +import android.animation.Animator; +import android.animation.Animator.AnimatorListener; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.animation.ValueAnimator.AnimatorUpdateListener; import android.annotation.SuppressLint; -import android.app.AlertDialog; +import android.app.Activity; import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; import android.content.pm.PackageManager; -import android.media.AudioManager; -import android.media.MediaPlayer; -import android.media.MediaRecorder; import android.os.Bundle; +import androidx.core.content.ContextCompat; +import androidx.appcompat.app.AlertDialog; import android.text.format.DateUtils; -import android.util.Log; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; @@ -46,11 +48,12 @@ import android.widget.TextView; import android.widget.Toast; -import com.nineoldandroids.animation.Animator; -import com.nineoldandroids.animation.Animator.AnimatorListener; -import com.nineoldandroids.animation.ObjectAnimator; -import com.nineoldandroids.animation.ValueAnimator; -import com.nineoldandroids.animation.ValueAnimator.AnimatorUpdateListener; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.ui.view.CircularSeekBar; +import org.kontalk.util.AudioRecording; +import org.kontalk.util.MediaStorage; +import org.kontalk.util.Permissions; /** @@ -59,12 +62,10 @@ * @author Daniele Ricci */ public class AudioDialog extends AlertDialog { - private static final String TAG = ComposeMessage.TAG; + static final String TAG = ComposeMessage.TAG; private static final String STATE_PREFIX = "AudioDialog_"; - public static final String DEFAULT_MIME = "audio/3gpp"; - private static final int STATUS_IDLE = 0; private static final int STATUS_RECORDING = 1; private static final int STATUS_STOPPED = 2; @@ -74,11 +75,16 @@ public class AudioDialog extends AlertDialog { private static final int STATUS_SEND = 6; /** Max duration of recorded audio in milliseconds. */ - private static final int MAX_AUDIO_DURATION = 120*1000; + private static final long MAX_AUDIO_DURATION = TimeUnit.MINUTES.toMillis(5); private static final int MAX_PROGRESS = 100; + static final String MAX_AUDIO_DURATION_TEXT = DateUtils + .formatElapsedTime(MAX_AUDIO_DURATION / 1000); + private CircularSeekBar mProgressBar; - private ObjectAnimator mProgressBarAnimator; + /** @deprecated Can't rely on animations for this. This must go away. */ + @Deprecated + ObjectAnimator mProgressBarAnimator; private ImageView mImageButton; private TextView mTimeTxt; private TextView mHintTxt; @@ -109,15 +115,23 @@ public AudioDialog(Context context, AudioFragment data, AudioDialogListener resu @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - mTimeTxt = (TextView) findViewById(R.id.time); + mTimeTxt = findViewById(R.id.time); mTimeTxt.setText(DateUtils.formatElapsedTime(0)); - mHintTxt = (TextView) findViewById(R.id.hint); - mImageButton = (ImageView) findViewById(R.id.image_audio); - mProgressBar = (CircularSeekBar) findViewById(R.id.circularSeekBar); - mProgressBar.getProgress(); + mHintTxt = findViewById(R.id.hint); + mImageButton = findViewById(R.id.image_audio); + mProgressBar = findViewById(R.id.circularSeekBar); mProgressBar.setMax(MAX_PROGRESS); mProgressBar.setVisibility(View.INVISIBLE); getButton(Dialog.BUTTON_POSITIVE).setVisibility(View.GONE); + + Activity context = getOwnerActivity(); + if (context == null) { + dismiss(); + } + else if (!Permissions.canRecordAudio(context)) { + Permissions.requestRecordAudio(context, context + .getString(R.string.err_audio_or_storage_denied)); + } } @Override @@ -160,14 +174,36 @@ public static AudioDialog onRestoreInstanceState(Context context, Bundle state, private void init() { LayoutInflater inflater = LayoutInflater.from(getContext()); + @SuppressLint("InflateParams") View v = inflater.inflate(R.layout.audio_dialog, null); setView(v); - mData.getPlayer().setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + mData.setListener(new AudioFragment.AudioFragmentListener() { + @Override + public void onCompletion(AudioFragment audio) { + mImageButton.setImageResource(R.drawable.play); + mProgressBarAnimator.end(); + mStatus = STATUS_ENDED; + } + + @Override + public void onAudioFocusLost(AudioFragment audio) { + pauseAudio(); + } + @Override - public void onCompletion(MediaPlayer mp) { + public void onPause(AudioFragment audio) { + mImageButton.setImageResource(R.drawable.play); + mProgressBarAnimator.end(); + mStatus = STATUS_PAUSED; + } + + @Override + public void onError(AudioFragment audio) { mImageButton.setImageResource(R.drawable.play); mProgressBarAnimator.end(); mStatus = STATUS_ENDED; + Toast.makeText(getContext(), R.string.err_playing_audio, Toast.LENGTH_LONG) + .show(); } }); @@ -201,7 +237,7 @@ else if (mStatus == STATUS_PAUSED || mStatus == STATUS_ENDED) { @Override public void onClick(DialogInterface dialog, int which) { if (mFile != null) { - mData.getPlayer().setOnCompletionListener(null); + mData.setListener(null); mListener.onRecordingSuccessful(mFile); mStatus = STATUS_SEND; } @@ -233,14 +269,14 @@ protected void onStart() { case STATUS_PAUSED: case STATUS_ENDED: // restart - animate(mProgressBar, null, 0, MAX_PROGRESS, mData.getPlayer().getDuration()); + animate(mProgressBar, null, 0, MAX_PROGRESS, mData.getPlayerDuration()); setupViewForPlaying(0, mStatus); - mData.getPlayer().seekTo(0); + mData.seekPlayerTo(0); break; case STATUS_PLAYING: // restore animator float progress = calculatePlayingProgress(); - animate(mProgressBar, null, progress, MAX_PROGRESS, mData.getPlayer().getDuration()); + animate(mProgressBar, null, progress, MAX_PROGRESS, mData.getPlayerDuration()); setupViewForPlaying(progress); setupForPlaying(); resumeAudio(); @@ -263,7 +299,7 @@ private void finish() { } else if (mStatus == STATUS_PLAYING || mStatus == STATUS_SEND) { pauseAudio(mStatus == STATUS_SEND); - mData.getPlayer().release(); + mData.finish(true); } if (mStatus != STATUS_SEND && mFile != null) { @@ -280,7 +316,7 @@ private float calculateRecordingProgress() { private float calculatePlayingProgress() { long time = mData.getElapsedTime(); - return (float) (time * 100) / mData.getPlayer().getDuration(); + return (float) (time * 100) / mData.getPlayerDuration(); } @SuppressLint("ResourceAsColor") @@ -317,7 +353,7 @@ private void setupViewForPlaying(float progress, int overrideStatus) { } private void setViewsColor(int resId) { - int color = getContext().getResources().getColor(resId); + int color = ContextCompat.getColor(getContext(), resId); mProgressBar.setCircleProgressColor(color); mProgressBar.setPointerColor(color); mProgressBar.setPointerBorderColor(color); @@ -328,42 +364,45 @@ private void setViewsColor(int resId) { * Begins recording audio. * @throws IOException if writing to storage failed */ - private void startRecord() throws IOException { - mFile = MediaStorage.getOutgoingAudioFile(); + void startRecord() throws IOException { + mFile = MediaStorage.getOutgoingAudioFile(getContext()); setupViewForRecording(0); - MediaRecorder recorder = mData.getRecorder(); - recorder.setAudioSource(MediaRecorder.AudioSource.MIC); - recorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP); - recorder.setOutputFile(mFile.getAbsolutePath()); - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB); try { - // Start recording + AudioRecording.setupMediaRecorder(mData.getRecorder(), mFile); + // start recording mData.startRecording(); mStatus = STATUS_RECORDING; } - catch (IllegalStateException e) { - Log.e(TAG, "error starting audio recording", e); - } catch (IOException e) { Log.e(TAG, "error writing on external storage", e); cancel(); - new Builder(getContext()) - .setMessage(R.string.err_audio_record_writing) - .setNegativeButton(android.R.string.ok, null) + + new MaterialDialog.Builder(getContext()) + .content(R.string.err_audio_record_writing) + .positiveText(android.R.string.ok) .show(); } catch (RuntimeException e) { Log.e(TAG, "error starting audio recording", e); cancel(); - new AlertDialog.Builder(getContext()) - .setMessage(R.string.err_audio_record) - .setNegativeButton(android.R.string.ok, null) + + int resId; + if (Permissions.canRecordAudio(getContext())) { + resId = R.string.err_audio_record; + } + else { + resId = R.string.err_audio_record_permission; + } + + new MaterialDialog.Builder(getContext()) + .content(resId) + .positiveText(android.R.string.ok) .show(); } } - private void stopRecord() { + void stopRecord() { try { // stop recorder mData.stopRecording(); @@ -371,9 +410,9 @@ private void stopRecord() { catch (RuntimeException e) { Log.e(TAG, "error recording audio", e); cancel(); - new AlertDialog.Builder(getContext()) - .setMessage(R.string.err_audio_record) - .setNegativeButton(android.R.string.ok, null) + new MaterialDialog.Builder(getContext()) + .content(R.string.err_audio_record) + .positiveText(android.R.string.ok) .show(); } setupViewForPlaying(0); @@ -381,61 +420,61 @@ private void stopRecord() { mStatus = STATUS_STOPPED; } - private void playAudio() { + void playAudio() { mProgressBar.setClickable(true); try { - MediaPlayer player = mData.getPlayer(); - player.setAudioStreamType(AudioManager.STREAM_MUSIC); - player.setDataSource(mFile.getAbsolutePath()); - player.prepare(); + mData.preparePlayer(mFile); } catch (IOException e) { Log.e (TAG, "error reading from external storage", e); - new AlertDialog.Builder(getContext()) - .setMessage(R.string.err_playing_sdcard) - .setNegativeButton(android.R.string.ok, null) + new MaterialDialog.Builder(getContext()) + .content(R.string.err_playing_sdcard) + .positiveText(android.R.string.ok) .show(); } catch (Exception e) { Log.e(TAG, "error playing audio", e); } setupForPlaying(); - animate(mProgressBar, null, 0, MAX_PROGRESS, mData.getPlayer().getDuration()); + animate(mProgressBar, null, 0, MAX_PROGRESS, mData.getPlayerDuration()); resumeAudio(); } private void setupForPlaying() { mHintTxt.setVisibility(View.GONE); mTimeTxt.setVisibility(View.VISIBLE); - mTimeTxt.setTextColor(getContext().getResources().getColor(R.color.audio_pbar_play)); - mTimeCircle = MAX_PROGRESS / (float) mData.getPlayer().getDuration(); + int color = ContextCompat.getColor(getContext(), R.color.audio_pbar_play); + mTimeTxt.setTextColor(color); + mTimeCircle = MAX_PROGRESS / (float) mData.getPlayerDuration(); } - private void pauseAudio() { + void pauseAudio() { pauseAudio(false); } private void pauseAudio(boolean sending) { mImageButton.setImageResource(R.drawable.play); mProgressBarAnimator.cancel(); - mData.getPlayer().pause(); + mData.pausePlaying(); if (!sending) mStatus = STATUS_PAUSED; } - private void resumeAudio() { + void resumeAudio() { mImageButton.setImageResource(R.drawable.pause); if (mStatus == STATUS_PAUSED || mStatus == STATUS_ENDED) mProgressBarAnimator.start(); // STATUS_PLAYING is used when restoring dialog if (mStatus == STATUS_PAUSED || mStatus == STATUS_PLAYING) - mProgressBarAnimator.setCurrentPlayTime(mData.getPlayer().getCurrentPosition()); - mData.startPlaying(); + mProgressBarAnimator.setCurrentPlayTime(mData.getPlayerPosition()); + mData.startPlaying(false); mStatus = STATUS_PLAYING; } + /** @deprecated Can't rely on animations for this. This must go away. */ + @Deprecated private void animate(final CircularSeekBar progressBar, final AnimatorListener listener, - final float progress, final float maxProgress, final int duration) { + final float progress, final float maxProgress, final long duration) { mProgressBarAnimator = ObjectAnimator.ofFloat(progressBar, "progress", maxProgress); mProgressBarAnimator.setInterpolator(new LinearInterpolator()); mProgressBarAnimator.setDuration(duration); @@ -473,13 +512,13 @@ public boolean onTouch(View v, MotionEvent event) { else if (event.getAction() == android.view.MotionEvent.ACTION_UP) { progressBar.setPointerAlpha(0); progressBar.setPointerAlphaOnTouch(0); - mData.getPlayer().seekTo(mPlayerSeekTo); + mData.seekPlayerTo(mPlayerSeekTo); if (mCheckSeek == STATUS_PLAYING) resumeAudio(); } else if (event.getAction() == android.view.MotionEvent.ACTION_MOVE && (mStatus == STATUS_PLAYING || mStatus == STATUS_PAUSED)) { mPlayerSeekTo = (int) (progressBar.getProgress() / mTimeCircle); - mTimeTxt.setText(DateUtils.formatElapsedTime(mPlayerSeekTo / 1000)); + setDurationText(mPlayerSeekTo); } return false; } @@ -493,7 +532,7 @@ else if (event.getAction() == android.view.MotionEvent.ACTION_MOVE && (mStatus = public void onAnimationUpdate(final ValueAnimator animation) { progressBar.setProgress((Float) animation.getAnimatedValue()); long time = animation.getCurrentPlayTime(); - mTimeTxt.setText(DateUtils.formatElapsedTime(time / 1000)); + setDurationText(time); } }); progressBar.setProgress(progress); @@ -503,6 +542,15 @@ public void onAnimationUpdate(final ValueAnimator animation) { } } + void setDurationText(long millis) { + long duration = (mStatus == STATUS_PLAYING) ? mData.getPlayerDuration() : -1; + mTimeTxt.setText(mTimeTxt.getContext().getString(R.string.audio_duration_max, + DateUtils.formatElapsedTime(millis / 1000), + // recording or playing? + duration < 0 ? MAX_AUDIO_DURATION_TEXT : + DateUtils.formatElapsedTime(duration / 1000))); + } + public static boolean isSupported(Context context) { return context.getPackageManager().hasSystemFeature( PackageManager.FEATURE_MICROPHONE); diff --git a/app/src/main/java/org/kontalk/ui/AudioFragment.java b/app/src/main/java/org/kontalk/ui/AudioFragment.java index 01e96ccc5..521f882a0 100644 --- a/app/src/main/java/org/kontalk/ui/AudioFragment.java +++ b/app/src/main/java/org/kontalk/ui/AudioFragment.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2011 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,27 +18,58 @@ package org.kontalk.ui; - +import java.io.File; import java.io.IOException; +import android.app.Activity; +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.media.AudioAttributes; +import android.media.AudioManager; import android.media.MediaPlayer; import android.media.MediaRecorder; +import android.os.Build; import android.os.Bundle; import android.os.SystemClock; -import android.support.v4.app.Fragment; +import androidx.fragment.app.Fragment; + +import org.kontalk.util.ProximityScreenLocker; +import org.kontalk.util.SystemUtils; + /** * A fragment that handles media recorder and player instances, independently * of its parent activity. * @author Daniele Ricci */ -public class AudioFragment extends Fragment { +public class AudioFragment extends Fragment implements MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener, + AudioManager.OnAudioFocusChangeListener, SensorEventListener { private MediaRecorder mRecorder; private MediaPlayer mPlayer; private long mStartTime; + /** Message id of the media currently being played. */ + private long mMessageId = -1; + + private AudioFragmentListener mListener; + private boolean mAudioFocus; + + /** Proximity wake lock. */ + private ProximityScreenLocker mProximityLock; + + private SensorManager mSensorManager; + private Sensor mProximitySensor; + /** True if we are covering the proximity sensor. */ + private boolean mProximityClosed; + + /** This is used when restarting playback. */ + private File mLastDataSource; + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -46,11 +77,14 @@ public void onCreate(Bundle savedInstanceState) { setRetainInstance(true); } - public MediaPlayer getPlayer() { - if (mPlayer == null) { - mPlayer = new MediaPlayer(); + @Override + public void onAttach(Context context) { + super.onAttach(context); + + if (mProximitySensor == null) { + mSensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); + mProximitySensor = mSensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY); } - return mPlayer; } public MediaRecorder getRecorder() { @@ -65,6 +99,7 @@ public void startRecording() throws IOException { MediaRecorder recorder = getRecorder(); recorder.prepare(); recorder.start(); + acquireLock(false); } public long getElapsedTime() { @@ -72,6 +107,8 @@ public long getElapsedTime() { } public void stopRecording() { + // release lock anyway + releaseLock(false); if (mRecorder != null) { mRecorder.stop(); mRecorder.reset(); @@ -81,17 +118,310 @@ public void stopRecording() { } } - public void startPlaying() { + public void preparePlayer(File audioFile) throws IOException { + mLastDataSource = audioFile; + preparePlayer(audioFile, false); + } + + private void preparePlayer(File audioFile, boolean frontSpeaker) throws IOException { + if (mPlayer == null) { + mPlayer = new MediaPlayer(); + mPlayer.setOnCompletionListener(this); + mPlayer.setOnErrorListener(this); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + mPlayer.setAudioAttributes(new AudioAttributes.Builder() + .setContentType(frontSpeaker ? AudioAttributes.CONTENT_TYPE_SPEECH : + AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(frontSpeaker ? AudioAttributes.USAGE_VOICE_COMMUNICATION : + AudioAttributes.USAGE_MEDIA) + .build()); + } + else { + mPlayer.setAudioStreamType(frontSpeaker ? AudioManager.STREAM_VOICE_CALL : + AudioManager.STREAM_MUSIC); + } + + mPlayer.setDataSource(audioFile.getAbsolutePath()); + mPlayer.prepare(); + } + + private void restartPlayback(boolean frontSpeaker, boolean start) throws IOException { + if (mPlayer != null && mLastDataSource != null) { + // pause the listener + mPlayer.setOnCompletionListener(null); + mPlayer.pause(); + // save current position and destroy everything + int position = mPlayer.getCurrentPosition(); + mPlayer.reset(); + mPlayer.release(); + mPlayer = null; + // restart playback from last position + preparePlayer(mLastDataSource, frontSpeaker); + mPlayer.seekTo(position); + if (start) + mPlayer.start(); + } + } + + public int getPlayerDuration() { + return mPlayer != null ? mPlayer.getDuration() : -1; + } + + public int getPlayerPosition() { + return mPlayer != null ? mPlayer.getCurrentPosition() : -1; + } + + public boolean startPlaying() { + return startPlaying(true); + } + + public boolean startPlaying(boolean proximity) { + if (mPlayer != null) { + if (acquireAudioFocus()) { + mStartTime = SystemClock.uptimeMillis(); + mPlayer.start(); + // started, acquire lock + acquireLock(proximity); + return true; + } + } + return false; + } + + public void pausePlaying() { + if (mPlayer != null && mPlayer.isPlaying()) { + mPlayer.pause(); + // paused, release lock + releaseLock(true); + releaseAudioFocus(); + } + } + + public void seekPlayerTo(int msec) { + if (mPlayer != null) + mPlayer.seekTo(msec); + } + + public void resetPlayer() { + if (mPlayer != null) + mPlayer.reset(); + releaseLock(true); + releaseAudioFocus(); + } + + public void setListener(AudioFragmentListener listener) + { + mListener = listener; + } + + interface AudioFragmentListener { + /** Called on play completion. */ + void onCompletion(AudioFragment audio); + + /** Called when the app loses audio focus. */ + void onAudioFocusLost(AudioFragment audio); + + /** Called when we pause without user intervention. */ + void onPause(AudioFragment audio); + + /** Called when an error occurs during playing. */ + void onError(AudioFragment audio); + } + + @Override + public void onCompletion(MediaPlayer mp) { + releaseLock(true); + // release any audio focus + releaseAudioFocus(); + + if (mListener != null) { + mListener.onCompletion(this); + } + } + + @Override + public boolean onError(MediaPlayer mp, int what, int extra) { + resetPlayer(); if (mPlayer != null) { - mStartTime = SystemClock.uptimeMillis(); - mPlayer.start(); + mPlayer.release(); + mPlayer = null; } + + if (mListener != null) + mListener.onError(this); + + // returning true will avoid onCompletion from being called + return true; + } + + public boolean isPlaying() { + return mPlayer != null && mPlayer.isPlaying(); + } + + public void setMessageId(long mMessageId) { + this.mMessageId = mMessageId; + } + + public long getMessageId() { + return mMessageId; + } + + public void finish(boolean release) { + if (mPlayer != null && release) + mPlayer.release(); + finish(); } public void finish() { + mLastDataSource = null; mPlayer = null; mRecorder = null; mStartTime = 0; + mListener = null; + releaseLock(true); + releaseAudioFocus(); + } + + private boolean isNearToSensor(float value) { + return value < 5.0f && value != mProximitySensor.getMaximumRange(); + } + + @Override + public void onSensorChanged(SensorEvent event) { + if (event.sensor == mProximitySensor) { + if (mProximityLock instanceof SensorEventListener) { + ((SensorEventListener) mProximityLock).onSensorChanged(event); + } + + if (isNearToSensor(event.values[0])) { + proximityNear(); + } + else { + proximityFar(); + } + } + } + + private void proximityNear() { + if (!mProximityClosed) { + mProximityClosed = true; + try { + // restart playback from phone speaker + restartPlayback(true, true); + } + catch (IOException e) { + onCompletion(mPlayer); + } + } + } + + private void proximityFar() { + if (mProximityClosed) { + mProximityClosed = false; + try { + // restart playback from speaker + restartPlayback(false, false); + // paused, release lock + releaseLock(true); + releaseAudioFocus(); + + if (mListener != null) + mListener.onPause(this); + } + catch (IOException e) { + onCompletion(mPlayer); + } + } + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + if (mProximityLock instanceof SensorEventListener) { + ((SensorEventListener) mProximityLock).onAccuracyChanged(sensor, accuracy); + } + } + + private void acquireLock(boolean playing) { + Activity a = getActivity(); + if (a != null) { + SystemUtils.acquireScreenOn(a); + + if (playing) { + if (mProximityLock == null) { + mProximityLock = SystemUtils.createProximityScreenLocker(a); + mProximityLock.acquire(); + + if (mProximitySensor != null) { + mSensorManager.registerListener(this, mProximitySensor, + SensorManager.SENSOR_DELAY_NORMAL); + } + } + } + } + } + + private void releaseLock(boolean playing) { + Activity a = getActivity(); + if (a != null) { + SystemUtils.releaseScreenOn(a); + + if (playing && mProximityLock != null) { + mProximityLock.release(false); + mProximityLock = null; + + if (mProximitySensor != null) { + mSensorManager.unregisterListener(this, mProximitySensor); + // listener will be unregistered so we must do some manual steps + proximityFar(); + } + } + } + } + + private boolean acquireAudioFocus() { + if (!mAudioFocus) { + final Context ctx = getContext(); + if (ctx != null) { + // using the application context so the audio focus is global + AudioManager audio = (AudioManager) ctx.getApplicationContext() + .getSystemService(Context.AUDIO_SERVICE); + if (audio.requestAudioFocus(this, AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + mAudioFocus = true; + return true; + } + } + return false; + } + return true; + } + + private void releaseAudioFocus() { + if (mAudioFocus) { + final Context ctx = getContext(); + if (ctx != null) { + // using the application context so the audio focus is global + AudioManager audio = (AudioManager) ctx.getApplicationContext() + .getSystemService(Context.AUDIO_SERVICE); + audio.abandonAudioFocus(this); + mAudioFocus = false; + } + } + } + + @Override + public void onAudioFocusChange(int focusChange) { + if (focusChange == AudioManager.AUDIOFOCUS_LOSS || + focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || + focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { + mAudioFocus = false; + if (mListener != null) { + // notify listener that we should stop playing now + mListener.onAudioFocusLost(AudioFragment.this); + } + } } } diff --git a/app/src/main/java/org/kontalk/ui/BootstrapPreferences.java b/app/src/main/java/org/kontalk/ui/BootstrapPreferences.java deleted file mode 100644 index 411f7c6c8..000000000 --- a/app/src/main/java/org/kontalk/ui/BootstrapPreferences.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.ui; - -import org.kontalk.R; -import org.kontalk.service.msgcenter.PushServiceManager; - -import android.annotation.TargetApi; -import android.app.ActionBar; -import android.os.Build; -import android.os.Bundle; -import android.preference.Preference; -import android.preference.PreferenceActivity; -import android.view.MenuItem; - - -/** - * Preference activity for some bootstrap preferences. - * @author Daniele Ricci - * @version 1.0 - */ -public class BootstrapPreferences extends PreferenceActivity { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - addPreferencesFromResource(R.xml.bootstrap_preferences); - - // disable push notifications if GCM is not available on the device - if (!PushServiceManager.getInstance(this).isServiceAvailable()) { - final Preference push = findPreference("pref_push_notifications"); - push.setEnabled(false); - } - - setupActivity(); - PreferencesActivity.setupPreferences(this); - } - - @TargetApi(Build.VERSION_CODES.HONEYCOMB) - private void setupActivity() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { - ActionBar bar = getActionBar(); - bar.setDisplayShowHomeEnabled(true); - bar.setDisplayHomeAsUpEnabled(true); - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - return true; - } - - return super.onOptionsItemSelected(item); - } - -} diff --git a/app/src/main/java/org/kontalk/ui/CodeValidation.java b/app/src/main/java/org/kontalk/ui/CodeValidation.java index f2dcc1943..3a6ce418c 100644 --- a/app/src/main/java/org/kontalk/ui/CodeValidation.java +++ b/app/src/main/java/org/kontalk/ui/CodeValidation.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,80 +18,119 @@ package org.kontalk.ui; -import android.app.AlertDialog; +import java.net.SocketException; +import java.util.HashMap; +import java.util.Map; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.Target; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; + +import android.content.ComponentName; +import android.content.Context; import android.content.Intent; +import android.content.ServiceConnection; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; import android.os.Bundle; -import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import android.os.IBinder; import android.view.MenuItem; import android.view.View; -import android.view.Window; import android.view.WindowManager; import android.widget.Button; import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; +import org.kontalk.BuildConfig; +import org.kontalk.Log; import org.kontalk.R; -import org.kontalk.client.EndpointServer; -import org.kontalk.client.NumberValidator; -import org.kontalk.client.NumberValidator.NumberValidatorListener; -import org.kontalk.crypto.PersonalKey; -import org.kontalk.service.KeyPairGeneratorService; -import org.kontalk.util.Preferences; - -import java.net.SocketException; +import org.kontalk.provider.UsersProvider; +import org.kontalk.service.registration.RegistrationService; +import org.kontalk.service.registration.event.AbortRequest; +import org.kontalk.service.registration.event.AccountCreatedEvent; +import org.kontalk.service.registration.event.ChallengeError; +import org.kontalk.service.registration.event.ChallengeRequest; +import org.kontalk.util.InternalTrustStore; +import org.kontalk.util.SystemUtils; /** Manual validation code input. */ -public class CodeValidation extends AccountAuthenticatorActionBarActivity - implements NumberValidatorListener { +public class CodeValidation extends AccountAuthenticatorActionBarActivity { private static final String TAG = NumberValidation.TAG; private EditText mCode; private Button mButton; - private NumberValidator mValidator; - private PersonalKey mKey; - private String mName; - private String mPhone; - private String mPassphrase; - private EndpointServer.EndpointServerProvider mServerProvider; + private Button mFallbackButton; + private Button mCallButton; + private ProgressBar mProgress; - private byte[] mImportedPrivateKey; - private byte[] mImportedPublicKey; + Map mTrustedKeys; + + private EventBus mServiceBus = RegistrationService.bus(); + /** Just a dummy service connection to keep the service alive. */ + private ServiceConnection mServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + } + + @Override + public void onServiceDisconnected(ComponentName name) { + } + }; private static final class RetainData { - NumberValidator validator; + Map trustedKeys; + + RetainData() { + } } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - supportRequestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); setContentView(R.layout.code_validation_screen); + setupToolbar(true, false); - //setSupportProgressBarIndeterminate(true); - // HACK this is for crappy honeycomb :) - setSupportProgressBarIndeterminateVisibility(false); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - - mCode = (EditText) findViewById(R.id.validation_code); - mButton = (Button) findViewById(R.id.send_button); + mCode = findViewById(R.id.validation_code); + mButton = findViewById(R.id.send_button); + mFallbackButton = findViewById(R.id.fallback_button); + mCallButton = findViewById(R.id.code_validation_call); + mProgress = findViewById(R.id.progressbar); // configuration change?? RetainData data = (RetainData) getLastCustomNonConfigurationInstance(); if (data != null) { - mValidator = data.validator; - if (mValidator != null) { - startProgress(); - mValidator.setListener(this); - } + // TODO when to startProgress(); ? + mTrustedKeys = data.trustedKeys; } + Intent intent = getIntent(); + RegistrationService.CurrentState cstate = RegistrationService.currentState(); + + String phone = cstate.phoneNumber; + int requestCode = getIntent().getIntExtra("requestCode", -1); if (requestCode == NumberValidation.REQUEST_VALIDATION_CODE || getIntent().getStringExtra("sender") == null) { - findViewById(R.id.code_validation_sender) + findViewById(R.id.code_validation_phone) .setVisibility(View.GONE); findViewById(R.id.code_validation_intro2) .setVisibility(View.GONE); @@ -99,29 +138,144 @@ protected void onCreate(Bundle savedInstanceState) { .setText(R.string.code_validation_intro_manual); } else { - String sender = getIntent().getStringExtra("sender"); - ((TextView) findViewById(R.id.code_validation_sender)) - .setText(sender); + String challenge = cstate.challenge; + String sender = intent.getStringExtra("sender"); + boolean canFallback = intent.getBooleanExtra("canFallback", false); + + final TextView phoneText = findViewById(R.id.code_validation_phone); + String formattedPhone; + try { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + Phonenumber.PhoneNumber phoneNumber = util.parse(phone, null); + formattedPhone = util.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL); + } + catch (NumberParseException e) { + formattedPhone = phone; + } + + CharSequence textId1, textId2; + if (RegistrationService.isMissedCall(sender) || RegistrationService.CHALLENGE_MISSED_CALL.equals(challenge)) { + // reverse missed call + textId1 = getText(R.string.code_validation_intro_missed_call); + textId2 = getString(R.string.code_validation_intro2_missed_call, + RegistrationService.getChallengeLength(sender)); + if (canFallback) { + mFallbackButton.setText(R.string.button_validation_fallback); + mFallbackButton.setVisibility(View.VISIBLE); + } + // show sender label and hide call button + phoneText.setText(formattedPhone); + phoneText.setVisibility(View.VISIBLE); + mCallButton.setVisibility(View.GONE); + mCode.setVisibility(View.VISIBLE); + } + else if (RegistrationService.CHALLENGE_CALLER_ID.equals(challenge)) { + // user-initiated missed call + textId1 = getText(R.string.code_validation_intro_callerid); + textId2 = getText(R.string.code_validation_intro2_callerid); + if (canFallback) { + mFallbackButton.setText(R.string.button_validation_fallback_callerid); + mFallbackButton.setVisibility(View.VISIBLE); + } + // show call button and hide sender label + mCallButton.setText(sender); + mCallButton.setVisibility(View.VISIBLE); + phoneText.setVisibility(View.GONE); + mCode.setVisibility(View.GONE); + // the incoming foreign number notice doesn't apply in this case + findViewById(R.id.code_validation_intro3).setVisibility(View.GONE); + } + else { + // PIN code + textId1 = getText(R.string.code_validation_intro); + textId2 = getText(R.string.code_validation_intro2); + if (canFallback) { + mFallbackButton.setText(R.string.button_validation_fallback); + mFallbackButton.setVisibility(View.VISIBLE); + } + // show sender label and hide call button + phoneText.setText(formattedPhone); + phoneText.setVisibility(View.VISIBLE); + mCallButton.setVisibility(View.GONE); + mCode.setVisibility(View.VISIBLE); + } + + if (mCallButton.getVisibility() == View.VISIBLE) { + mCallButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + SystemUtils.dial(CodeValidation.this, mCallButton.getText()); + } + }); + } + + ((TextView) findViewById(R.id.code_validation_intro)).setText(textId1); + ((TextView) findViewById(R.id.code_validation_intro2)).setText(textId2); } - Intent i = getIntent(); - mKey = i.getParcelableExtra(KeyPairGeneratorService.EXTRA_KEY); - mName = i.getStringExtra("name"); - mPhone = i.getStringExtra("phone"); - mPassphrase = i.getStringExtra("passphrase"); - mImportedPrivateKey = i.getByteArrayExtra("importedPrivateKey"); - mImportedPublicKey = i.getByteArrayExtra("importedPublicKey"); - - String server = i.getStringExtra("server"); - if (server != null) - mServerProvider = new EndpointServer.SingleServerProvider(server); - else - /* - * FIXME HUGE problem here. If we already have a verification code, - * how are we supposed to know from what server it came from?? - * https://github.com/kontalk/androidclient/issues/118 - */ - mServerProvider = Preferences.getEndpointServerProvider(this); + mTrustedKeys = (HashMap) intent.getSerializableExtra("trustedKeys"); + + // brand information + final String brandImage = intent.getStringExtra("brandImage"); + final String brandLink = intent.getStringExtra("brandLink"); + if (brandImage != null) { + findViewById(R.id.brand_poweredby).setVisibility(View.VISIBLE); + + // we should use our builtin keystore + try { + InternalTrustStore.initUrlConnections(this); + } + catch (Exception e) { + Log.w(TAG, "unable to initialize internal trust store", e); + } + + final View brandParent = findViewById(R.id.brand_parent); + brandParent.setVisibility(View.VISIBLE); + final ProgressBar brandProgress = findViewById(R.id.brand_loading); + brandProgress.setVisibility(View.VISIBLE); + final ImageView brandView = findViewById(R.id.brand); + brandView.setVisibility(View.VISIBLE); + + Glide.with(this) + .load(brandImage) + .listener(new RequestListener() { + @Override + public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + brandParent.setVisibility(View.GONE); + brandView.setVisibility(View.GONE); + brandProgress.setVisibility(View.GONE); + if (brandLink != null) { + TextView brandTextView = findViewById(R.id.brand_text); + brandTextView.setText(brandLink); + brandTextView.setVisibility(View.VISIBLE); + } + return true; + } + + @Override + public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { + brandProgress.setVisibility(View.GONE); + return false; + } + }) + .into(brandView); + + if (brandLink != null) { + brandView.setContentDescription(brandLink); + brandView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + SystemUtils.openURL(CodeValidation.this, brandLink); + } + }); + } + } + } + + /** Not used. */ + @Override + protected boolean isNormalUpNavigation() { + return false; } @Override @@ -138,9 +292,22 @@ public boolean onOptionsItemSelected(MenuItem item) { @Override public void onBackPressed() { - // we are going back voluntarily - Preferences.clearRegistrationProgress(this); - super.onBackPressed(); + new MaterialDialog.Builder(this) + .title(R.string.title_confirm_cancel_registration) + .content(R.string.confirm_cancel_registration) + .positiveText(android.R.string.ok) + .positiveColorRes(R.color.button_danger) + .negativeText(android.R.string.cancel) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog materialDialog, @NonNull DialogAction dialogAction) { + // we are going back voluntarily + mServiceBus.post(new AbortRequest()); + RegistrationService.clearSavedState(); + CodeValidation.super.onBackPressed(); + } + }) + .show(); } /** No search here. */ @@ -153,148 +320,156 @@ public boolean onSearchRequested() { @Override public Object onRetainCustomNonConfigurationInstance() { RetainData data = new RetainData(); - data.validator = mValidator; + data.trustedKeys = mTrustedKeys; return data; } + @Override + protected void onStart() { + super.onStart(); + // start a users resync in the meantime + RegistrationService.CurrentState cstate = RegistrationService.currentState(); + new UsersResyncTask(cstate.server.getNetwork()).execute(getApplicationContext()); + // be sure to bind to the registration service so it's not killed by the OS + bindService(new Intent(this, RegistrationService.class), mServiceConnection, BIND_IMPORTANT); + } + @Override protected void onStop() { super.onStop(); if (isFinishing()) abort(true); + + try { + unbindService(mServiceConnection); + } + catch (IllegalArgumentException ignored) { + } } - private void keepScreenOn(boolean active) { + void keepScreenOn(boolean active) { if (active) getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); else getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); } - private void error(int title, int message) { - new AlertDialog.Builder(this) - .setTitle(title) - .setMessage(message) - .setNeutralButton(android.R.string.ok, null) + private void error(int message) { + new MaterialDialog.Builder(this) + .content(message) + .positiveText(android.R.string.ok) + .show(); + } + + public void doFallback(View view) { + new MaterialDialog.Builder(this) + .title(R.string.title_fallback) + .content(R.string.msg_fallback) + .positiveText(android.R.string.ok) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + setResult(NumberValidation.RESULT_FALLBACK); + finish(); + } + }) + .negativeText(android.R.string.cancel) .show(); } public void validateCode(View view) { - String code = mCode.getText().toString().trim(); - if (code.length() == 0) { - error(R.string.title_invalid_code, R.string.msg_invalid_code); - return; + String code = null; + String challenge = getIntent().getStringExtra("challenge"); + if (!RegistrationService.CHALLENGE_CALLER_ID.equals(challenge)) { + code = mCode.getText().toString().trim(); + if (code.length() == 0) { + error(R.string.msg_invalid_code); + return; + } } startProgress(); - // send the code - boolean imported = (mImportedPrivateKey != null && mImportedPublicKey != null); - mServerProvider.reset(); - mValidator = new NumberValidator(this, mServerProvider, mName, mPhone, - imported ? null : mKey, mPassphrase); - mValidator.setListener(this); - if (imported) - mValidator.importKey(mImportedPrivateKey, mImportedPublicKey); - - mValidator.manualInput(code); - mValidator.start(); + mServiceBus.register(this); + mServiceBus.post(new ChallengeRequest(code)); } private void enableControls(boolean enabled) { mButton.setEnabled(enabled); + mFallbackButton.setEnabled(enabled); + mCallButton.setEnabled(enabled); mCode.setEnabled(enabled); } private void startProgress() { - setSupportProgressBarIndeterminateVisibility(true); + if (!BuildConfig.TESTING.get()) { + mProgress.setVisibility(View.VISIBLE); + } enableControls(false); keepScreenOn(true); } - private void abort(boolean ending) { + void abort(boolean ending) { if (!ending) { - setSupportProgressBarIndeterminateVisibility(false); + mProgress.setVisibility(View.INVISIBLE); enableControls(true); } else { // ending - clear registration progress - Preferences.clearRegistrationProgress(this); + RegistrationService.clearSavedState(); } keepScreenOn(false); - if (mValidator != null) { - mValidator.shutdown(); - mValidator = null; - } + mServiceBus.unregister(this); } - @Override - public void onError(NumberValidator v, final Throwable e) { - Log.e(TAG, "validation error.", e); - runOnUiThread(new Runnable() { - public void run() { - int msgId; - if (e instanceof SocketException) - msgId = R.string.err_validation_network_error; - else - msgId = R.string.err_validation_error; - Toast.makeText(CodeValidation.this, msgId, Toast.LENGTH_LONG).show(); - abort(false); + @Subscribe(threadMode = ThreadMode.MAIN) + public void onChallengeError(ChallengeError error) { + Log.e(TAG, "validation error.", error.exception); + keepScreenOn(false); + int msgId; + if (error.exception instanceof SocketException) { + msgId = R.string.err_validation_network_error; + } + else { + String challenge = RegistrationService.currentState().challenge; + if (RegistrationService.CHALLENGE_CALLER_ID.equals(challenge)) { + // we are verifying through user-initiated missed call + // notify the user that the verification didn't succeed + msgId = R.string.err_authentication_failed_callerid; } - }); - } - - @Override - public void onServerCheckFailed(NumberValidator v) { - runOnUiThread(new Runnable() { - public void run() { - Toast.makeText(CodeValidation.this, R.string.err_validation_server_not_supported, Toast.LENGTH_LONG).show(); - abort(false); + else { + // we are verifying through PIN-based challenge + // notify the user that the challenge code wasn't accepted + msgId = R.string.err_authentication_failed; } - }); + } + Toast.makeText(this, msgId, Toast.LENGTH_LONG).show(); + abort(false); } - @Override - public void onValidationRequested(NumberValidator v, String sender) { - // not used. - } + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAccountCreated(AccountCreatedEvent event) { + RegistrationService.stop(this); - @Override - public void onValidationFailed(NumberValidator v, int reason) { - // not used. + abort(true); + Intent i = new Intent(); + i.putExtra(NumberValidation.PARAM_ACCOUNT_NAME, event.account.getName()); + setResult(RESULT_OK, i); + finish(); } - @Override - public void onAuthTokenReceived(final NumberValidator v, final byte[] privateKeyData, final byte[] publicKeyData) { - Log.d(TAG, "got authentication token!"); - - runOnUiThread(new Runnable() { - @Override - public void run() { - abort(true); - Intent i = new Intent(); - i.putExtra(NumberValidation.PARAM_SERVER_URI, v.getServer().toString()); - i.putExtra(NumberValidation.PARAM_PUBLICKEY, publicKeyData); - i.putExtra(NumberValidation.PARAM_PRIVATEKEY, privateKeyData); - setResult(RESULT_OK, i); - finish(); - } - }); - } + private static final class UsersResyncTask extends AsyncTask { - @Override - public void onAuthTokenFailed(NumberValidator v, int reason) { - Log.e(TAG, "authentication token request failed (" + reason + ")"); - runOnUiThread(new Runnable() { - @Override - public void run() { - keepScreenOn(false); - Toast.makeText(CodeValidation.this, - R.string.err_authentication_failed, - Toast.LENGTH_LONG).show(); - abort(false); - } - }); - } + private final String xmppDomain; + UsersResyncTask(String xmppDomain) { + this.xmppDomain = xmppDomain; + } + + @Override + protected Void doInBackground(Context... contexts) { + UsersProvider.resync(contexts[0], xmppDomain); + return null; + } + } } diff --git a/app/src/main/java/org/kontalk/ui/ComposeMessage.java b/app/src/main/java/org/kontalk/ui/ComposeMessage.java index a18f9a542..cda1e11d7 100644 --- a/app/src/main/java/org/kontalk/ui/ComposeMessage.java +++ b/app/src/main/java/org/kontalk/ui/ComposeMessage.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,53 +18,45 @@ package org.kontalk.ui; -import java.util.ArrayList; import java.util.regex.Pattern; -import org.kontalk.Kontalk; -import org.kontalk.R; -import org.kontalk.authenticator.Authenticator; -import org.kontalk.client.NumberValidator; -import org.kontalk.data.Contact; -import org.kontalk.data.Conversation; -import org.kontalk.message.ImageComponent; -import org.kontalk.message.TextComponent; -import org.kontalk.message.VCardComponent; -import org.kontalk.provider.MyMessages.Threads; -import org.kontalk.provider.MyMessages.Threads.Conversations; -import org.kontalk.util.MediaStorage; -import org.kontalk.util.MessageUtils; -import org.kontalk.util.XMPPUtils; - -import android.annotation.TargetApi; import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.graphics.Typeface; -import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; -import android.support.v7.app.ActionBar; -import android.support.v7.app.ActionBarActivity; +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentTransaction; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.Toolbar; import android.text.Spannable; import android.text.SpannableString; import android.text.style.StyleSpan; -import android.util.Log; -import android.view.MenuItem; import android.view.View; -import android.view.Window; -import android.widget.FrameLayout; -import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.authenticator.MyAccount; +import org.kontalk.data.Conversation; +import org.kontalk.message.TextComponent; +import org.kontalk.provider.MyMessages.Threads; +import org.kontalk.provider.MyMessages.Threads.Conversations; +import org.kontalk.service.registration.RegistrationService; +import org.kontalk.util.MediaStorage; +import org.kontalk.util.SystemUtils; +import org.kontalk.util.ViewUtils; +import org.kontalk.util.XMPPUtils; + /** * Conversation writing activity. * @author Daniele Ricci - * @version 1.0 */ -public class ComposeMessage extends ActionBarActivity { +public class ComposeMessage extends ToolbarActivity implements ComposeMessageParent { public static final String TAG = ComposeMessage.class.getSimpleName(); private static final int REQUEST_CONTACT_PICKER = 9721; @@ -80,132 +72,209 @@ public class ComposeMessage extends ActionBarActivity { public static final String EXTRA_MESSAGE = "org.kontalk.conversation.MESSAGE"; /** Used with VIEW actions, highlight a {@link Pattern} in messages. */ public static final String EXTRA_HIGHLIGHT = "org.kontalk.conversation.HIGHLIGHT"; + /** Used with SEND actions sent via direct share. */ + public static final String EXTRA_USERID = "org.kontalk.conversation.USERID"; + /** Used internally when reloading: does not trigger scroll-to-match. */ + static final String EXTRA_RELOADING = "org.kontalk.conversation.RELOADING"; + /** Set to true for showing the group chat on creation disclaimer. */ + static final String EXTRA_CREATING_GROUP = "org.kontalk.CREATING_GROUP"; /** The SEND intent. */ - private Intent sendIntent; + private Intent mSendIntent; + /** The SEND intent from direct share. */ + private Intent mDirectSendIntent; - private ComposeMessageFragment mFragment; - private TextView mTitleView; - private TextView mSubtitleView; + AbstractComposeFragment mFragment; /** * True if the window has lost focus the last time * {@link #onWindowFocusChanged} was called. */ private boolean mLostFocus; + /** + * This is set to true in {@link #onResume} and to false in {@link #onPause}. + * It is checked in {@link #onWindowFocusChanged} to ensure that the activity is indeed + * visible before granting focus capabilities. + */ + private boolean mResumed; + + /** Toolbar title TextView. Used for applying our emojis. */ + private TextView mTitleView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - supportRequestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); - setContentView(R.layout.compose_message_screen); setupActionBar(); - // load the fragment - mFragment = (ComposeMessageFragment) getSupportFragmentManager() - .findFragmentById(R.id.fragment_compose_message); - - View customView = getSupportActionBar().getCustomView(); - mTitleView = (TextView) customView.findViewById(R.id.title); - mSubtitleView = (TextView) customView.findViewById(R.id.summary); + if (savedInstanceState != null) { + mFragment = (AbstractComposeFragment) getSupportFragmentManager() + .findFragmentById(R.id.fragment_compose_message); + } - processIntent(savedInstanceState); + if (mFragment == null) { + // build chat fragment + AbstractComposeFragment f = getComposeFragment(savedInstanceState); + if (f != null) { + // insert it into the activity + setComposeFragment(f); + } + } } - @TargetApi(android.os.Build.VERSION_CODES.HONEYCOMB) private void setupActionBar() { - ActionBar bar = getSupportActionBar(); - bar.setDisplayShowHomeEnabled(true); - bar.setCustomView(R.layout.compose_message_action_view); - bar.setDisplayShowCustomEnabled(true); - bar.setDisplayHomeAsUpEnabled(true); - bar.setHomeButtonEnabled(true); - - // hack to remove padding from activity icon - - int homeId; - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB) - homeId = android.R.id.home; - else - homeId = android.support.v7.appcompat.R.id.home; - - ImageView icon = (ImageView) findViewById(homeId); - if (icon != null) { - FrameLayout.LayoutParams iconLp = (FrameLayout.LayoutParams) icon.getLayoutParams(); - iconLp.topMargin = iconLp.bottomMargin = 0; - icon.setLayoutParams(iconLp); + Toolbar toolbar = super.setupToolbar(true, true); + // TODO find a way to use a colored selector + toolbar.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mFragment == null || !mFragment.isActionModeActive()) + onTitleClick(); + } + }); + + try { + // hack to extract the title text view + mTitleView = (TextView) toolbar.getChildAt(0); + } + catch (Exception e) { + Log.v(TAG, "unable to retrieve toolbar view. Custom emojis will not be applied to activity title."); } } @Override - public boolean onOptionsItemSelected(MenuItem item) { - int itemId = item.getItemId(); + protected boolean isNormalUpNavigation() { + return false; + } - switch (itemId) { - case android.R.id.home: - onAvatarClick(); - return true; - } + private void setComposeFragment(@NonNull AbstractComposeFragment f) { + mFragment = f; + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_compose_message, f) + .setTransition(FragmentTransaction.TRANSIT_NONE) + .commitNowAllowingStateLoss(); + } - return super.onOptionsItemSelected(item); + @Override + public void loadConversation(long threadId, boolean creatingGroup) { + // create bootstrap intent + setIntent(ComposeMessage.fromConversation(this, threadId, creatingGroup)); + loadConversation(); } - /** Sets custom title. Pass null to any of the arguments to skip setting it. */ - public void setTitle(CharSequence title, CharSequence subtitle, Contact contact) { - if (title != null) - mTitleView.setText(title); - if (subtitle != null) - mSubtitleView.setText(subtitle); - if (contact != null) { - Drawable avatar = contact.getAvatar(this, null); - if (avatar == null) - avatar = getResources().getDrawable(R.drawable.ic_contact_picture); + @Override + public void loadConversation(Uri threadUri) { + onNewIntent(fromThreadUri(this, threadUri)); + } - getSupportActionBar().setIcon(avatar); + public void loadConversation() { + // build chat fragment + AbstractComposeFragment f = getComposeFragment(null); + if (f != null) { + // insert it into the activity + setComposeFragment(f); + } + else { + // conversation disappeared + finish(); } } - public void setUpdatingSubtitle() { - setUpdatingSubtitle(mSubtitleView); + @Override + public void setTitle(CharSequence title, CharSequence subtitle) { + ActionBar bar = getSupportActionBar(); + if (title != null) { + final CharSequence titleStr; + if (mTitleView != null) { + titleStr = ViewUtils.injectEmojis(mTitleView, title); + } + else { + titleStr = title; + } + bar.setTitle(titleStr); + } + if (subtitle != null) + bar.setSubtitle(subtitle); } - static void setUpdatingSubtitle(TextView view) { - CharSequence current = view.getText(); + @Override + public void setUpdatingSubtitle() { + ActionBar bar = getSupportActionBar(); + CharSequence current = bar.getSubtitle(); // no need to set updating status if no text is displayed - if (current.length() > 0) { - // we call toString() to strip any existing span - SpannableString status = new SpannableString(current.toString()); - status.setSpan(sUpdatingTextSpan, - 0, status.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - - view.setText(status); + if (current != null && current.length() > 0) { + bar.setSubtitle(applyUpdatingStyle(current)); } } + static CharSequence applyUpdatingStyle(CharSequence text) { + // we call toString() to strip any existing span + SpannableString status = new SpannableString(text.toString()); + status.setSpan(sUpdatingTextSpan, + 0, status.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return status; + } + @Override public void onBackPressed() { - if (mFragment == null || !mFragment.tryHideEmojiDrawer()) + if (mFragment == null || (!mFragment.tryHideAttachmentView() && !mFragment.tryHideEmojiDrawer())) super.onBackPressed(); } - private void onAvatarClick() { - finish(); - startActivity(new Intent(this, ConversationList.class)); + public void onTitleClick() { + if (mFragment instanceof ComposeMessageFragment) + ((ComposeMessageFragment) mFragment).viewContactInfo(); + else if (mFragment instanceof GroupMessageFragment) + ((GroupMessageFragment) mFragment).viewGroupInfo(); } - public void onTitleboxClick(View view) { - if (mFragment != null) - mFragment.viewContact(); + private AbstractComposeFragment getComposeFragment(Bundle savedInstanceState) { + Bundle args = processIntent(savedInstanceState); + if (args != null) { + AbstractComposeFragment f = null; + Uri threadUri = args.getParcelable("data"); + String action = args.getString("action"); + if (ACTION_VIEW_CONVERSATION.equals(action)) { + long threadId = ContentUris.parseId(threadUri); + Conversation conv = Conversation.loadFromId(this, threadId); + if (conv != null) { + f = conv.isGroupChat() ? + new GroupMessageFragment() : + new ComposeMessageFragment(); + } + } + else if (ACTION_VIEW_USERID.equals(action)) { + String userId = threadUri.getLastPathSegment(); + Conversation conv = Conversation.loadFromUserId(this, userId); + f = conv != null && conv.isGroupChat() ? + new GroupMessageFragment() : + new ComposeMessageFragment(); + } + else { + // default to a single user chat + f = new ComposeMessageFragment(); + } + + if (f != null) + f.setArguments(args); + return f; + } + + return null; } - private void processIntent(Bundle savedInstanceState) { + private Bundle processIntent(Bundle savedInstanceState) { Intent intent; if (savedInstanceState != null) { mLostFocus = savedInstanceState.getBoolean("lostFocus"); Uri uri = savedInstanceState.getParcelable(Uri.class.getName()); + if (uri == null) { + Log.d(TAG, "restoring non-loaded conversation, aborting"); + finish(); + return null; + } intent = new Intent(ACTION_VIEW_USERID, uri); } else { @@ -225,14 +294,13 @@ private void processIntent(Bundle savedInstanceState) { Uri uri = intent.getData(); // two-panes UI: start conversation list - if (Kontalk.hasTwoPanesUI(this) && Intent.ACTION_VIEW.equals(action)) { - Intent startIntent = new Intent(getApplicationContext(), ConversationList.class); - startIntent.setAction(Intent.ACTION_VIEW); - startIntent.setData(uri); + if (Kontalk.hasTwoPanesUI(this)) { + Intent startIntent = new Intent(action, uri, + getApplicationContext(), ConversationsActivity.class); startActivity(startIntent); // no need to go further finish(); - return; + return null; } // single-pane UI: start normally else { @@ -241,41 +309,50 @@ private void processIntent(Bundle savedInstanceState) { args.putParcelable("data", uri); args.putLong(EXTRA_MESSAGE, intent.getLongExtra(EXTRA_MESSAGE, -1)); args.putString(EXTRA_HIGHLIGHT, intent.getStringExtra(EXTRA_HIGHLIGHT)); + args.putBoolean(EXTRA_CREATING_GROUP, intent.getBooleanExtra(EXTRA_CREATING_GROUP, false)); } } // send external content else if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) { - sendIntent = intent; String mime = intent.getType(); Log.i(TAG, "sending data to someone: " + mime); - chooseContact(); - // onActivityResult will handle the rest - return; + String userId = intent.getStringExtra(EXTRA_USERID); + if (userId != null) { + handleSendResult(userId, intent); + // we'll handle directSendIntent later + return null; + } + else { + mSendIntent = intent; + chooseContact(); + // onActivityResult will handle the rest + return null; + } } // send to someone else if (Intent.ACTION_SENDTO.equals(action)) { try { + MyAccount account = Kontalk.get().getDefaultAccount(); Uri uri = intent.getData(); // a phone number should come here... - String number = NumberValidator.fixNumber(this, - uri.getSchemeSpecificPart(), - Authenticator.getDefaultAccountName(this), 0); + String number = RegistrationService.fixNumber(this, + uri.getSchemeSpecificPart(), account.getPhoneNumber(), 0); // compute hash and open conversation - String jid = XMPPUtils.createLocalJID(this, MessageUtils.sha1(number)); + String jid = account.createLocalJID(XMPPUtils.createLocalpart(number)); // two-panes UI: start conversation list if (Kontalk.hasTwoPanesUI(this)) { - Intent startIntent = new Intent(getApplicationContext(), ConversationList.class); + Intent startIntent = new Intent(getApplicationContext(), ConversationsActivity.class); startIntent.setAction(ACTION_VIEW_USERID); startIntent.setData(Threads.getUri(jid)); startActivity(startIntent); // no need to go further finish(); - return; + return null; } // single-pane UI: start normally else { @@ -291,10 +368,10 @@ else if (Intent.ACTION_SENDTO.equals(action)) { } } - if (args != null) { - mFragment.setMyArguments(args); - } + return args; } + + return null; } @Override @@ -305,19 +382,7 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { if (threadUri != null) { Log.i(TAG, "composing message for conversation: " + threadUri); String userId = threadUri.getLastPathSegment(); - Intent i = fromUserId(this, userId); - if (i != null) { - onNewIntent(i); - - // process SEND intent if necessary - if (sendIntent != null) - processSendIntent(); - } - else { - Toast.makeText(this, R.string.contact_not_registered, Toast.LENGTH_LONG) - .show(); - finish(); - } + handleSendResult(userId, null); } } else { @@ -330,7 +395,42 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { } } + private void handleSendResult(String userId, Intent directShareIntent) { + Intent i = fromUserId(this, userId); + if (i != null) { + if (Kontalk.hasTwoPanesUI(this)) { + // we need to go back to the main activity + Intent startIntent = new Intent(getApplicationContext(), ConversationsActivity.class); + startIntent.setAction(ACTION_VIEW_USERID); + startIntent.setData(Threads.getUri(userId)); + startIntent.putExtra(ConversationsActivity.EXTRA_SEND_INTENT, + directShareIntent != null ? directShareIntent : mSendIntent); + startActivity(startIntent); + finish(); + } + else { + onNewIntent(i); + + // process SEND intent if necessary + if (mSendIntent != null) + processSendIntent(); + // otherwise save direct share intent for later + else if (directShareIntent != null) + mDirectSendIntent = directShareIntent; + } + } + else { + Toast.makeText(this, R.string.contact_not_registered, Toast.LENGTH_LONG) + .show(); + finish(); + } + } + public static Intent fromUserId(Context context, String userId) { + return fromUserId(context, userId, false); + } + + public static Intent fromUserId(Context context, String userId, boolean creatingGroup) { Conversation conv = Conversation.loadFromUserId(context, userId); // not found - create new if (conv == null) { @@ -340,34 +440,60 @@ public static Intent fromUserId(Context context, String userId) { return ni; } + return fromConversation(context, conv, creatingGroup); + } + + public static Intent fromThreadUri(Context context, Uri threadUri) { + String userId = threadUri.getLastPathSegment(); + Conversation conv = Conversation.loadFromUserId(context, userId); + // not found - create new + if (conv == null) { + Intent ni = new Intent(context, ComposeMessage.class); + ni.setAction(ComposeMessage.ACTION_VIEW_USERID); + ni.setData(threadUri); + return ni; + } + return fromConversation(context, conv); } /** Creates an {@link Intent} for launching the composer for a given {@link Conversation}. */ public static Intent fromConversation(Context context, Conversation conv) { - return fromConversation(context, conv.getThreadId()); + return fromConversation(context, conv, false); + } + + /** Creates an {@link Intent} for launching the composer for a given {@link Conversation}. */ + public static Intent fromConversation(Context context, Conversation conv, boolean creatingGroup) { + return fromConversation(context, conv.getThreadId(), creatingGroup); } /** Creates an {@link Intent} for launching the composer for a given thread Id. */ public static Intent fromConversation(Context context, long threadId) { - return new Intent(ComposeMessage.ACTION_VIEW_CONVERSATION, - ContentUris.withAppendedId(Conversations.CONTENT_URI, - threadId), + return fromConversation(context, threadId, false); + } + + /** Creates an {@link Intent} for launching the composer for a given thread Id. */ + public static Intent fromConversation(Context context, long threadId, boolean creatingGroup) { + Intent i = new Intent(ComposeMessage.ACTION_VIEW_CONVERSATION, + ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId), context, ComposeMessage.class); + i.putExtra(EXTRA_CREATING_GROUP, creatingGroup); + return i; } /** Creates an {@link Intent} for sending a text message. */ public static Intent sendTextMessage(String text) { - Intent i = new Intent(Intent.ACTION_SEND); + Intent i = SystemUtils.externalIntent(Intent.ACTION_SEND); i.setType(TextComponent.MIME_TYPE); i.putExtra(Intent.EXTRA_TEXT, text); return i; } - public static Intent sendMediaMessage(Uri uri, String mime) { - Intent i = new Intent(Intent.ACTION_SEND); + public static Intent sendMediaMessage(Context context, Uri uri, String mime) { + Intent i = SystemUtils.externalIntent(Intent.ACTION_SEND); i.setType(mime); - i.putExtra(Intent.EXTRA_STREAM, uri); + i.putExtra(Intent.EXTRA_STREAM, MediaStorage + .getWorldReadableUri(context, uri, i)); return i; } @@ -375,95 +501,56 @@ private void chooseContact() { // TODO one day it will be like this // Intent i = new Intent(Intent.ACTION_PICK, Users.CONTENT_URI); Intent i = new Intent(this, ContactsListActivity.class); - i.putExtra("picker", true); + i.putExtra(ContactsListActivity.MODE_RECENTS, true); startActivityForResult(i, REQUEST_CONTACT_PICKER); } - private void sendMedia(Uri uri) { - Log.d(TAG, "looking up mime type for uri " + uri); - String mime = MediaStorage.getType(this, uri); - Log.d(TAG, "using detected mime type " + mime); - - if (ImageComponent.supportsMimeType(mime)) { - // send image immediately - mFragment.sendBinaryMessage(uri, mime, true, ImageComponent.class); - } - - else if (VCardComponent.supportsMimeType(mime)) { - mFragment.sendBinaryMessage(uri, mime, true, VCardComponent.class); - } - - else { - // notify to user - Log.w(TAG, "mime " + mime + " not supported"); - Toast.makeText(this, R.string.send_mime_not_supported, Toast.LENGTH_LONG) - .show(); - } - } - private void processSendIntent() { - String mime = sendIntent.getType(); - boolean multi = Intent.ACTION_SEND_MULTIPLE.equals(sendIntent.getAction()); - - if (multi) { - // multiple texts: take only the first one - // FIXME this will not allow text file attachments - if (TextComponent.supportsMimeType(mime)) { - ArrayList texts = sendIntent.getCharSequenceArrayListExtra(Intent.EXTRA_TEXT); - if (texts != null && texts.size() > 0) - mFragment.setTextEntry(texts.get(0)); - } - - else { - ArrayList uris = sendIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (uris != null) { - for (Uri uri : uris) { - sendMedia(uri); - } - } - } - } - - else { - // FIXME this will not allow text file attachments - if (TextComponent.supportsMimeType(mime)) { - CharSequence text = sendIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - mFragment.setTextEntry(text); - } - - else { - sendMedia((Uri) sendIntent.getParcelableExtra(Intent.EXTRA_STREAM)); - } - } - - sendIntent = null; + SendIntentReceiver.processSendIntent(this, mSendIntent, mFragment); + mSendIntent = null; } @Override protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); setIntent(intent); - processIntent(null); - mFragment.reload(); - } - - @Override - protected void onRestoreInstanceState(Bundle state) { - super.onRestoreInstanceState(state); - processIntent(state); + loadConversation(); } @Override protected void onSaveInstanceState(Bundle out) { super.onSaveInstanceState(out); - out.putParcelable(Uri.class.getName(), Threads.getUri(mFragment.getUserId())); + if (mFragment != null) + out.putParcelable(Uri.class.getName(), Threads.getUri(mFragment.getUserId())); out.putBoolean("lostFocus", mLostFocus); } + @Override + protected void onResumeFragments() { + super.onResumeFragments(); + if (mDirectSendIntent != null) { + SendIntentReceiver.processSendIntent(this, mDirectSendIntent, mFragment); + mDirectSendIntent = null; + } + } + + @Override + protected void onResume() { + super.onResume(); + mResumed = true; + } + + @Override + protected void onPause() { + super.onPause(); + mResumed = false; + } + @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); - if (hasFocus) { + if (hasFocus && mResumed) { if (mLostFocus) { mFragment.onFocus(); mLostFocus = false; @@ -480,6 +567,6 @@ public boolean hasLostFocus() { } public Intent getSendIntent() { - return sendIntent; + return mSendIntent; } } diff --git a/app/src/main/java/org/kontalk/ui/ComposeMessageFragment.java b/app/src/main/java/org/kontalk/ui/ComposeMessageFragment.java index 7b9fd496c..78f685555 100644 --- a/app/src/main/java/org/kontalk/ui/ComposeMessageFragment.java +++ b/app/src/main/java/org/kontalk/ui/ComposeMessageFragment.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,1445 +18,696 @@ package org.kontalk.ui; -import java.io.File; -import java.io.IOException; import java.util.HashSet; import java.util.List; -import java.util.Random; import java.util.Set; -import java.util.regex.Pattern; -import org.jivesoftware.smack.packet.IQ; +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smack.util.StringUtils; import org.jivesoftware.smackx.chatstates.ChatState; +import org.jxmpp.jid.BareJid; +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; import org.jxmpp.util.XmppStringUtils; -import org.spongycastle.openpgp.PGPPublicKey; -import org.spongycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; -import android.annotation.TargetApi; +import android.Manifest; import android.app.Activity; -import android.app.AlertDialog; -import android.content.AsyncQueryHandler; -import android.content.BroadcastReceiver; import android.content.ContentResolver; import android.content.ContentUris; -import android.content.ContentValues; import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; -import android.content.IntentFilter; import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.content.res.Configuration; -import android.database.ContentObserver; import android.database.Cursor; -import android.database.sqlite.SQLiteException; -import android.graphics.Color; -import android.graphics.drawable.Drawable; -import android.media.AudioManager; -import android.media.MediaPlayer; +import android.database.sqlite.SQLiteDiskIOException; import android.net.Uri; -import android.os.Build; import android.os.Bundle; -import android.os.Handler; -import android.provider.ContactsContract.Contacts; -import android.provider.MediaStore; -import android.support.v4.app.FragmentManager; -import android.support.v4.app.ListFragment; -import android.support.v4.content.LocalBroadcastManager; -import android.text.ClipboardManager; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; -import android.util.Log; -import android.util.TypedValue; -import android.view.ContextMenu; -import android.view.ContextMenu.ContextMenuInfo; -import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.widget.AdapterView.AdapterContextMenuInfo; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.ListView; -import android.widget.TextView; import android.widget.Toast; +import pub.devrel.easypermissions.EasyPermissions; + +import org.kontalk.Kontalk; +import org.kontalk.Log; import org.kontalk.R; -import org.kontalk.authenticator.Authenticator; -import org.kontalk.crypto.Coder; +import org.kontalk.authenticator.MyAccount; +import org.kontalk.client.EndpointServer; import org.kontalk.crypto.PGP; import org.kontalk.data.Contact; import org.kontalk.data.Conversation; -import org.kontalk.message.AttachmentComponent; -import org.kontalk.message.AudioComponent; import org.kontalk.message.CompositeMessage; -import org.kontalk.message.ImageComponent; -import org.kontalk.message.MessageComponent; -import org.kontalk.message.TextComponent; -import org.kontalk.message.VCardComponent; -import org.kontalk.provider.MessagesProvider; -import org.kontalk.provider.MyMessages.CommonColumns; -import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.KontalkGroupCommands; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.provider.MyMessages; import org.kontalk.provider.MyMessages.Threads; -import org.kontalk.provider.MyMessages.Threads.Conversations; -import org.kontalk.provider.MyMessages.Threads.Requests; +import org.kontalk.provider.MyUsers; import org.kontalk.provider.UsersProvider; -import org.kontalk.service.DownloadService; import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.service.msgcenter.PrivacyCommand; +import org.kontalk.service.msgcenter.event.ConnectedEvent; +import org.kontalk.service.msgcenter.event.LastActivityEvent; +import org.kontalk.service.msgcenter.event.LastActivityRequest; +import org.kontalk.service.msgcenter.event.NoPresenceEvent; +import org.kontalk.service.msgcenter.event.PreapproveSubscriptionRequest; +import org.kontalk.service.msgcenter.event.PresenceEvent; +import org.kontalk.service.msgcenter.event.PresenceRequest; +import org.kontalk.service.msgcenter.event.PublicKeyEvent; +import org.kontalk.service.msgcenter.event.PublicKeyRequest; +import org.kontalk.service.msgcenter.event.RosterLoadedEvent; +import org.kontalk.service.msgcenter.event.SendChatStateRequest; +import org.kontalk.service.msgcenter.event.SendMessageRequest; +import org.kontalk.service.msgcenter.event.SetUserPrivacyRequest; +import org.kontalk.service.msgcenter.event.SubscribeRequest; +import org.kontalk.service.msgcenter.event.UserBlockedEvent; +import org.kontalk.service.msgcenter.event.UserOfflineEvent; +import org.kontalk.service.msgcenter.event.UserOnlineEvent; +import org.kontalk.service.msgcenter.event.UserSubscribedEvent; +import org.kontalk.service.msgcenter.event.UserUnblockedEvent; +import org.kontalk.service.msgcenter.event.VersionEvent; +import org.kontalk.service.msgcenter.event.VersionRequest; import org.kontalk.sync.Syncer; -import org.kontalk.ui.adapter.MessageListAdapter; -import org.kontalk.ui.view.AudioContentView; -import org.kontalk.ui.view.AudioContentViewControl; -import org.kontalk.ui.view.AudioPlayerControl; -import org.kontalk.ui.view.ComposerBar; -import org.kontalk.ui.view.ComposerListener; -import org.kontalk.ui.view.IconContextMenu; -import org.kontalk.ui.view.IconContextMenu.IconContextMenuOnClickListener; -import org.kontalk.ui.view.MessageListItem; -import org.kontalk.util.MediaStorage; +import org.kontalk.util.DataUtils; import org.kontalk.util.MessageUtils; +import org.kontalk.util.Permissions; import org.kontalk.util.Preferences; +import org.kontalk.util.SystemUtils; import org.kontalk.util.XMPPUtils; -import static android.content.res.Configuration.KEYBOARDHIDDEN_NO; -import static org.kontalk.service.msgcenter.MessageCenterService.PRIVACY_ACCEPT; -import static org.kontalk.service.msgcenter.MessageCenterService.PRIVACY_BLOCK; -import static org.kontalk.service.msgcenter.MessageCenterService.PRIVACY_REJECT; -import static org.kontalk.service.msgcenter.MessageCenterService.PRIVACY_UNBLOCK; - /** * The composer fragment. * @author Daniele Ricci * @author Andrea Cappelli */ -public class ComposeMessageFragment extends ListFragment implements - ComposerListener, View.OnLongClickListener, IconContextMenuOnClickListener, - // TODO these two interfaces should be handled by an inner class - AudioDialog.AudioDialogListener, AudioPlayerControl { +public class ComposeMessageFragment extends AbstractComposeFragment + implements EasyPermissions.PermissionCallbacks { private static final String TAG = ComposeMessage.TAG; - private static final int MESSAGE_LIST_QUERY_TOKEN = 8720; - private static final int CONVERSATION_QUERY_TOKEN = 8721; - - private static final int SELECT_ATTACHMENT_OPENABLE = Activity.RESULT_FIRST_USER + 1; - private static final int SELECT_ATTACHMENT_CONTACT = Activity.RESULT_FIRST_USER + 2; - - private static final int PRESENCE_DELAY_THRESHOLD = 5000; - - private enum WarningType { - FATAL, - WARNING, - INFO, // not implemented - SUCCESS, // not implemented - } - - /** Context menu group ID for this fragment. */ - private static final int CONTEXT_MENU_GROUP_ID = 2; - - /* Attachment chooser stuff. */ - private static final int CONTEXT_MENU_ATTACHMENT = 1; - private static final int ATTACHMENT_ACTION_PICTURE = 1; - private static final int ATTACHMENT_ACTION_CONTACT = 2; - private static final int ATTACHMENT_ACTION_AUDIO = 3; - private IconContextMenu attachmentMenu; - - private ComposerBar mComposer; - - private MessageListQueryHandler mQueryHandler; - private MessageListAdapter mListAdapter; - private TextView mStatusText; - private ViewGroup mInvitationBar; - private MenuItem mDeleteThreadMenu; + ViewGroup mInvitationBar; private MenuItem mViewContactMenu; private MenuItem mCallMenu; private MenuItem mBlockMenu; private MenuItem mUnblockMenu; - /** The thread id. */ - private long threadId = -1; - private Conversation mConversation; - private Bundle mArguments; - /** The user we are talking to. */ - private String mUserJID; - private String mUserName; + String mUserJID; private String mUserPhone; - /** Available resources. */ - private Set mAvailableResources = new HashSet(); - private String mLastActivityId; + String mLastActivityRequestId; + String mVersionRequestId; + String mKeyRequestId; - /** MediaPlayer */ - private MediaPlayer mPlayer; - private int mStatus = AudioContentView.STATUS_IDLE; - private long mMediaPlayerMessageId; - private Handler mHandler; - private Runnable mMediaPlayerUpdater; - private AudioContentViewControl mAudioControl; - private AudioDialog mAudioDialog; - - private PeerObserver mPeerObserver; - private File mCurrentPhoto; - - private LocalBroadcastManager mLocalBroadcastManager; - private BroadcastReceiver mPresenceReceiver; - private BroadcastReceiver mPrivacyListener; - - private boolean mOfflineModeWarned; - private CharSequence mCurrentStatus; private boolean mIsTyping; - /** Returns a new fragment instance from a picked contact. */ - public static ComposeMessageFragment fromUserId(Context context, String userId) { - ComposeMessageFragment f = new ComposeMessageFragment(); - Conversation conv = Conversation.loadFromUserId(context, userId); - // not found - create new - if (conv == null) { - Bundle args = new Bundle(); - args.putString("action", ComposeMessage.ACTION_VIEW_USERID); - args.putParcelable("data", Threads.getUri(userId)); - f.setArguments(args); - return f; - } - - return fromConversation(context, conv); - } - - /** Returns a new fragment instance from a {@link Conversation} instance. */ - public static ComposeMessageFragment fromConversation(Context context, - Conversation conv) { - return fromConversation(context, conv.getThreadId()); - } - - /** Returns a new fragment instance from a thread ID. */ - public static ComposeMessageFragment fromConversation(Context context, - long threadId) { - ComposeMessageFragment f = new ComposeMessageFragment(); - Bundle args = new Bundle(); - args.putString("action", ComposeMessage.ACTION_VIEW_CONVERSATION); - args.putParcelable("data", - ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId)); - f.setArguments(args); - return f; + @Override + protected void onInflateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.compose_message_menu, menu); + mViewContactMenu = menu.findItem(R.id.view_contact); + mCallMenu = menu.findItem(R.id.call_contact); + mBlockMenu = menu.findItem(R.id.block_user); + mUnblockMenu = menu.findItem(R.id.unblock_user); } @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - // setListAdapter() is post-poned + public boolean onOptionsItemSelected(MenuItem item) { + if (super.onOptionsItemSelected(item)) + return true; - ListView list = getListView(); - list.setFastScrollEnabled(true); - registerForContextMenu(list); + switch (item.getItemId()) { + case R.id.call_contact: + callContact(); + return true; - mComposer = (ComposerBar) getView().findViewById(R.id.composer_bar); - mComposer.setComposerListener(this); + case R.id.view_contact: + viewContact(); + return true; - // footer (for tablet presence status) - mStatusText = (TextView) getView().findViewById(R.id.status_text); + case R.id.block_user: + blockUser(); + return true; - // set custom background (if any) - Drawable bg = Preferences.getConversationBackground(getActivity()); - if (bg != null) { - ImageView background = (ImageView) getView().findViewById(R.id.background); - list.setCacheColorHint(Color.TRANSPARENT); - list.setBackgroundColor(Color.TRANSPARENT); - background.setImageDrawable(bg); + case R.id.unblock_user: + unblockUser(); + return true; } - mComposer.setRootView(getView().findViewById(R.id.root_view)); + return false; + } - Configuration config = getResources().getConfiguration(); - mComposer.onKeyboardStateChanged(config.keyboardHidden == KEYBOARDHIDDEN_NO); + private void callContact() { + final Context context = getContext(); - processArguments(savedInstanceState); + if (Permissions.canCallPhone(context)) { + doCallContact(); + } + else if (EasyPermissions.permissionPermanentlyDenied(this, Manifest.permission.CALL_PHONE)) { + doDialContact(); + } + else { + Permissions.requestCallPhone(this); + } } - @Override - public void onAttach(Activity activity) { - super.onAttach(activity); - mLocalBroadcastManager = LocalBroadcastManager.getInstance(activity); + private void doCallContact() { + SystemUtils.call(getContext(), mUserPhone); } - @Override - public void onDetach() { - super.onDetach(); - mLocalBroadcastManager = null; + private void doDialContact() { + SystemUtils.dial(getContext(), mUserPhone); } @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - - mComposer.onKeyboardStateChanged(newConfig.keyboardHidden == KEYBOARDHIDDEN_NO); + public void onPermissionsGranted(int requestCode, @NonNull List perms) { + if (perms.contains(Manifest.permission.CALL_PHONE)) { + doCallContact(); + } } - public void reload() { - processArguments(null); + @Override + public void onPermissionsDenied(int requestCode, @NonNull List perms) { + if (perms.contains(Manifest.permission.CALL_PHONE)) { + doDialContact(); + } } - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - return inflater.inflate(R.layout.compose_message, container, false); + public void viewContactInfo() { + final Activity ctx = getActivity(); + if (ctx == null) + return; + + if (mConversation != null) { + Contact contact = mConversation.getContact(); + if (contact != null) { + if (Kontalk.hasTwoPanesUI(ctx)) { + ContactInfoDialog.start(ctx, this, contact.getJID(), 0); + } + else { + ContactInfoActivity.start(ctx, this, contact.getJID(), 0); + } + } + } } - private final MessageListAdapter.OnContentChangedListener mContentChangedListener = new MessageListAdapter.OnContentChangedListener() { - public void onContentChanged(MessageListAdapter adapter) { - if (isVisible()) - startQuery(true, false); + public void viewContact() { + if (mConversation != null) { + Contact contact = mConversation.getContact(); + if (contact != null) { + Uri uri = contact.getUri(); + if (uri != null) { + Intent i = SystemUtils.externalIntent(Intent.ACTION_VIEW, uri); + if (i.resolveActivity(getActivity().getPackageManager()) != null) { + startActivity(i); + } + else { + // no contacts app found (crap device eh?) + Toast.makeText(getActivity(), + R.string.err_no_contacts_app, + Toast.LENGTH_LONG).show(); + } + } + else { + // no contact found + Toast.makeText(getActivity(), + R.string.err_no_contact, + Toast.LENGTH_SHORT).show(); + } + } } - }; + } - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + private void blockUser() { + new MaterialDialog.Builder(getActivity()) + .title(R.string.title_block_user_warning) + .content(R.string.msg_block_user_warning) + .positiveText(R.string.menu_block_user) + .positiveColorRes(R.color.button_danger) + .negativeText(android.R.string.cancel) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + setPrivacy(dialog.getContext(), PrivacyCommand.BLOCK); + } + }) + .show(); + } + + private void unblockUser() { + new MaterialDialog.Builder(getActivity()) + .title(R.string.title_unblock_user_warning) + .content(R.string.msg_unblock_user_warning) + .positiveText(R.string.menu_unblock_user) + .positiveColorRes(R.color.button_danger) + .negativeText(android.R.string.cancel) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + setPrivacy(dialog.getContext(), PrivacyCommand.UNBLOCK); + } + }) + .show(); + } - setHasOptionsMenu(true); - mQueryHandler = new MessageListQueryHandler(); - mHandler = new Handler(); + /** Translates special contacts into special strings. */ + private String getDisplayName(@NonNull Contact contact) { + Context context = getContext(); + if (context == null) + return contact.getDisplayName(); - // list adapter creation is post-poned + EndpointServer server = Kontalk.get().getEndpointServer(); + if (contact.getJID().equalsIgnoreCase(server.getNetwork())) { + return context.getString(R.string.contact_name_server); + } + else { + return contact.getDisplayName(); + } } - /** Sends out a binary message. */ @Override - public void sendBinaryMessage(Uri uri, String mime, boolean media, - Class> klass) { - Log.v(TAG, "sending binary content: " + uri); - Uri newMsg = null; - String msgId = null; - File previewFile = null; - long length = -1; - - boolean encrypted = Preferences.getEncryptionEnabled(getActivity()); - int compress = 0; - if (klass == ImageComponent.class) { - compress = Preferences.getImageCompression(getActivity()); + protected void loadConversationMetadata(Uri uri) { + super.loadConversationMetadata(uri); + if (mConversation != null) { + mUserJID = mConversation.getRecipient(); + Contact contact = mConversation.getContact(); + if (contact != null) { + mUserName = getDisplayName(contact); + mUserPhone = contact.getNumber(); + } + else { + mUserName = mUserJID; + } } + } + @Override + protected void handleActionView(Uri uri) { + long threadId = 0; + ContentResolver cres = getContext().getContentResolver(); + + /* + * FIXME this will retrieve name directly from contacts, + * resulting in a possible discrepancy with users database + */ + Cursor c = null; try { - // TODO convert to thread (?) - - offlineModeWarning(); - - msgId = MessageCenterService.messageId(); - - // generate thumbnail - // FIXME this is blocking!!!! - if (media && klass == ImageComponent.class) { - // FIXME hard-coded to ImageComponent - String filename = ImageComponent.buildMediaFilename(msgId, MediaStorage.THUMBNAIL_MIME_NETWORK); - previewFile = MediaStorage.cacheThumbnail(getActivity(), uri, - filename, true); + c = cres.query(uri, new String[]{ + Syncer.DATA_COLUMN_DISPLAY_NAME, + Syncer.DATA_COLUMN_PHONE}, null, null, null); + if (c != null && c.moveToFirst()) { + mUserName = c.getString(0); + mUserPhone = c.getString(1); + + // FIXME should it be retrieved from RawContacts.SYNC3 ?? + MyAccount account = Kontalk.get().getDefaultAccount(); + mUserJID = account.createLocalJID(XMPPUtils + .createLocalpart(mUserPhone)); + + threadId = MessagesProviderClient.findThread(getContext(), mUserJID); } + } + catch (SecurityException e) { + // user denied access to contacts. Sorry! + Toast.makeText(getContext(), R.string.warn_external_contacts_denied, + Toast.LENGTH_LONG).show(); + closeConversation(); + return; + } + finally { + DataUtils.close(c); + } - length = MediaStorage.getLength(getActivity(), uri); - - // save to database - ContentValues values = new ContentValues(); - values.put(Messages.MESSAGE_ID, msgId); - values.put(Messages.PEER, mUserJID); - - /* TODO ask for a text to send with the image - values.put(Messages.BODY_MIME, TextComponent.MIME_TYPE); - values.put(Messages.BODY_CONTENT, content.getBytes()); - values.put(Messages.BODY_LENGTH, content.length()); - */ - - values.put(Messages.UNREAD, false); - // of course outgoing messages are not encrypted in database - values.put(Messages.ENCRYPTED, false); - values.put(Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); - values.put(Messages.DIRECTION, Messages.DIRECTION_OUT); - values.put(Messages.TIMESTAMP, System.currentTimeMillis()); - values.put(Messages.STATUS, Messages.STATUS_SENDING); - - if (previewFile != null) - values.put(Messages.ATTACHMENT_PREVIEW_PATH, previewFile.getAbsolutePath()); - - values.put(Messages.ATTACHMENT_MIME, mime); - values.put(Messages.ATTACHMENT_LOCAL_URI, uri.toString()); - values.put(Messages.ATTACHMENT_LENGTH, length); - values.put(Messages.ATTACHMENT_COMPRESS, compress); - - newMsg = getActivity().getContentResolver().insert( - Messages.CONTENT_URI, values); + if (threadId > 0) { + mConversation = Conversation.loadFromId(getContext(), + threadId); + setThreadId(threadId); + } + else if (mUserJID == null) { + Toast.makeText(getContext(), R.string.err_no_contact, + Toast.LENGTH_LONG).show(); + closeConversation(); } - catch (Exception e) { - Log.e(TAG, "unable to store media", e); + else { + mConversation = Conversation.createNew(getContext()); + mConversation.setRecipient(mUserJID); } + } - if (newMsg != null) { + @Override + protected boolean handleActionViewConversation(Uri uri, Bundle args) { + mUserJID = uri.getPathSegments().get(1); + mConversation = Conversation.loadFromUserId(getActivity(), + mUserJID); - // update thread id from the inserted message - if (threadId <= 0) { - Cursor c = getActivity().getContentResolver().query(newMsg, - new String[] { Messages.THREAD_ID }, null, null, null); - if (c.moveToFirst()) { - threadId = c.getLong(0); - mConversation = null; - startQuery(true, false); - } - else { - Log.v(TAG, "no data - cannot start query for this composer"); - } - c.close(); + if (mConversation == null) { + mConversation = Conversation.createNew(getActivity()); + mConversation.setNumberHint(args.getString("number")); + mConversation.setRecipient(mUserJID); + } + // this way avoid doing the users database query twice + else { + if (mConversation.getContact() == null) { + mConversation.setNumberHint(args.getString("number")); + mConversation.setRecipient(mUserJID); } + } - // send message! - String previewPath = (previewFile != null) ? previewFile.getAbsolutePath() : null; - MessageCenterService.sendBinaryMessage(getActivity(), - mUserJID, mime, uri, length, previewPath, encrypted, compress, - ContentUris.parseId(newMsg), msgId); + setThreadId(mConversation.getThreadId()); + Contact contact = mConversation.getContact(); + if (contact != null) { + mUserName = getDisplayName(contact); + mUserPhone = contact.getNumber(); } else { - getActivity().runOnUiThread(new Runnable() { - public void run() { - Toast.makeText(getActivity(), - R.string.err_store_message_failed, - Toast.LENGTH_LONG).show(); - } - }); + mUserName = mUserJID; + mUserPhone = null; } - } - private final class TextMessageThread extends Thread { - private final String mText; - - TextMessageThread(String text) { - mText = text; - } - - @Override - public void run() { - try { - boolean encrypted = Preferences.getEncryptionEnabled(getActivity()); - - /* TODO maybe this hack could work...? - MessageListItem v = (MessageListItem) LayoutInflater.from(getActivity()) - .inflate(R.layout.message_list_item, getListView(), false); - v.bind(getActivity(), msg, contact, null); - getListView().addFooterView(v); - */ - byte[] bytes = mText.getBytes(); - - String msgId = MessageUtils.messageId(); - - // save to local storage - ContentValues values = new ContentValues(); - // must supply a message ID... - values.put(Messages.MESSAGE_ID, msgId); - values.put(Messages.PEER, mUserJID); - values.put(Messages.BODY_MIME, TextComponent.MIME_TYPE); - values.put(Messages.BODY_CONTENT, bytes); - values.put(Messages.BODY_LENGTH, bytes.length); - values.put(Messages.UNREAD, false); - values.put(Messages.DIRECTION, Messages.DIRECTION_OUT); - values.put(Messages.TIMESTAMP, System.currentTimeMillis()); - values.put(Messages.STATUS, Messages.STATUS_SENDING); - // of course outgoing messages are not encrypted in database - values.put(Messages.ENCRYPTED, false); - values.put(Messages.SECURITY_FLAGS, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); - Uri newMsg = getActivity().getContentResolver().insert( - Messages.CONTENT_URI, values); - if (newMsg != null) { - // update thread id from the inserted message - if (threadId <= 0) { - Cursor c = getActivity().getContentResolver().query(newMsg, - new String[] { Messages.THREAD_ID }, null, null, - null); - if (c.moveToFirst()) { - threadId = c.getLong(0); - mConversation = null; - // we can run it here because progress=false - startQuery(true, false); - } - else { - Log.v(TAG, "no data - cannot start query for this composer"); - } - c.close(); - } + return true; + } - // send message! - MessageCenterService.sendTextMessage(getActivity(), - mUserJID, mText, encrypted, - ContentUris.parseId(newMsg), msgId); - } - else { - getActivity().runOnUiThread(new Runnable() { + @Override + protected void onArgumentsProcessed() { + // non existant thread - check for not synced contact + if (getThreadId() <= 0 && mConversation != null && mUserJID != null) { + Contact contact = mConversation.getContact(); + if ((contact == null || !contact.isRegistered()) && mUserPhone != null) { + // ask user to send invitation + new MaterialDialog.Builder(getActivity()) + .title(R.string.title_user_not_found) + .content(R.string.message_user_not_found) + // nothing happens if user chooses to contact the user anyway + .positiveText(R.string.yes_user_not_found) + .negativeText(R.string.no_user_not_found) + .onNegative(new MaterialDialog.SingleButtonCallback() { @Override - public void run() { - Toast.makeText(getActivity(), R.string.error_store_outbox, - Toast.LENGTH_LONG).show(); + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + sendInvitation(); } - }); - } - } - catch (Exception e) { - // whatever - Log.d(TAG, "broken message thread", e); + }) + .show(); } } } - /** Sends out the text message in the composing entry. */ @Override - public void sendTextMessage(String message) { - if (!TextUtils.isEmpty(message)) { - /* - * TODO show an animation to warn the user that the message - * is being sent (actually stored). - */ - - offlineModeWarning(); - - // start thread - new TextMessageThread(message).start(); + public boolean sendTyping() { + if (mAvailableResources.size() > 0) { + mServiceBus.post(new SendChatStateRequest.Builder(null) + .setChatState(ChatState.composing) + .setTo(JidCreate.fromOrThrowUnchecked(mUserJID)) + .build()); + return true; } + return false; } @Override - public boolean sendTyping() { + public boolean sendInactive() { if (mAvailableResources.size() > 0) { - MessageCenterService.sendChatState(getActivity(), mUserJID, ChatState.composing); + mServiceBus.post(new SendChatStateRequest.Builder(null) + .setChatState(ChatState.inactive) + .setTo(JidCreate.fromOrThrowUnchecked(mUserJID)) + .build()); return true; } return false; } @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.compose_message_menu, menu); - - mDeleteThreadMenu = menu.findItem(R.id.delete_thread); - mViewContactMenu = menu.findItem(R.id.view_contact); - mCallMenu = menu.findItem(R.id.call_contact); - mBlockMenu = menu.findItem(R.id.block_user); - mUnblockMenu = menu.findItem(R.id.unblock_user); - updateUI(); + protected void deleteConversation() { + try { + // delete all + mConversation.delete(true); + } + catch (SQLiteDiskIOException e) { + Log.w(TAG, "error deleting thread"); + Toast.makeText(getActivity(), R.string.error_delete_thread, + Toast.LENGTH_LONG).show(); + } } @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.call_contact: - startActivity(new Intent(Intent.ACTION_CALL, Uri.parse("tel:" - + mUserPhone))); - return true; - - case R.id.view_contact: - viewContact(); - return true; - - case R.id.menu_attachment: - selectAttachment(); - return true; - - case R.id.delete_thread: - if (threadId > 0) - deleteThread(); - - return true; - - case R.id.block_user: - blockUser(); - return true; + protected boolean isUserId(String jid) { + return XMPPUtils.equalsBareJID(jid, mUserJID); + } - case R.id.unblock_user: - unblockUser(); - return true; + @Override + protected void resetConnectionStatus() { + super.resetConnectionStatus(); + // reset any pending request + mLastActivityRequestId = null; + mVersionRequestId = null; + mKeyRequestId = null; + } - } + @Override + public void onRosterLoaded(RosterLoadedEvent event) { + super.onRosterLoaded(event); + // probe presence + requestPresence(); + } - return super.onOptionsItemSelected(item); + @Override + protected void onStartTyping(String jid, @Nullable String groupJid) { + mIsTyping = true; + setStatusText(getString(R.string.seen_typing_label)); } @Override - public void onListItemClick(ListView listView, View view, int position, long id) { - MessageListItem item = (MessageListItem) view; - final CompositeMessage msg = item.getMessage(); + protected void onStopTyping(String jid, @Nullable String groupJid) { + mIsTyping = false; + setStatusText(mCurrentStatus != null ? mCurrentStatus : ""); + } - AttachmentComponent attachment = (AttachmentComponent) msg - .getComponent(AttachmentComponent.class); + private void onUserStatusChanged(PresenceEvent event) { + final Context context = getContext(); + if (context == null) + return; - if (attachment != null && (attachment.getFetchUrl() != null || attachment.getLocalUri() != null)) { + // hide any present warning + hideWarning(WarningType.FATAL); - // outgoing message or already fetched - if (attachment.getLocalUri() != null) { - // open file - openFile(msg); + Contact contact = getContact(); + if (contact != null) { + // if this is null, we are accepting the key for the first time + PGPPublicKeyRing trustedPublicKey = contact.getTrustedPublicKeyRing(); + + // request the key if we don't have a trusted one and of course if the user has a key + boolean unknownKey = (trustedPublicKey == null && contact.getFingerprint() != null); + boolean changedKey = false; + // check if fingerprint changed (only if we have roster presence) + if (event.type != null && trustedPublicKey != null && event.fingerprint != null) { + String oldFingerprint = PGP.getFingerprint(PGP.getMasterKey(trustedPublicKey)); + if (!event.fingerprint.equalsIgnoreCase(oldFingerprint)) { + // fingerprint has changed since last time + changedKey = true; + } } - else { - // info & download dialog - CharSequence message = MessageUtils - .getFileInfoMessage(getActivity(), msg, - mUserPhone != null ? mUserPhone : mUserJID); - - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) - .setTitle(R.string.title_file_info) - .setMessage(message) - .setNegativeButton(android.R.string.cancel, null) - .setCancelable(true); - - if (!DownloadService.isQueued(attachment.getFetchUrl())) { - DialogInterface.OnClickListener startDL = new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - // start file download - startDownload(msg); - } - }; - builder.setPositiveButton(R.string.download, startDL); + // user has no key (or we have no roster presence) or it couldn't be found: request it + else if ((trustedPublicKey == null && event.fingerprint == null) || event.type == null) { + if (mKeyRequestId != null) { + // avoid request loop + mKeyRequestId = null; } else { - DialogInterface.OnClickListener stopDL = new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - // cancel file download - stopDownload(msg); - } - }; - builder.setPositiveButton(R.string.download_cancel, stopDL); + // autotrust the key we are about to request + // but set the trust level to ignored because we didn't really verify it + Keyring.setAutoTrustLevel(context, event.jid.toString(), MyUsers.Keys.TRUST_IGNORED); + requestPublicKey(event.jid); } - - builder.show(); } - } - else { - item.onClick(); + // key checks are to be done in advanced mode only + if (Keyring.isAdvancedMode(context, event.jid.toString())) { + if (changedKey) { + // warn user that public key is changed + showKeyChangedWarning(event.fingerprint); + } + else if (unknownKey) { + // warn user that public key is unknown + showKeyUnknownWarning(event.fingerprint); + } + } } } - private void startDownload(CompositeMessage msg) { - AttachmentComponent attachment = (AttachmentComponent) msg - .getComponent(AttachmentComponent.class); + @Override + public void onUserOnline(UserOnlineEvent event) { + final Context context = getContext(); + if (context == null) + return; - if (attachment != null && attachment.getFetchUrl() != null) { - Intent i = new Intent(getActivity(), DownloadService.class); - i.setAction(DownloadService.ACTION_DOWNLOAD_URL); - i.putExtra(CompositeMessage.MSG_ID, msg.getDatabaseId()); - i.putExtra(CompositeMessage.MSG_SENDER, msg.getSender()); - i.putExtra(CompositeMessage.MSG_TIMESTAMP, msg.getTimestamp()); - i.putExtra(CompositeMessage.MSG_ENCRYPTED, attachment.getSecurityFlags() != Coder.SECURITY_CLEARTEXT); - i.setData(Uri.parse(attachment.getFetchUrl())); - getActivity().startService(i); - } - else { - // corrupted message :( - Toast.makeText(getActivity(), R.string.err_attachment_corrupted, - Toast.LENGTH_LONG).show(); + // check that origin matches the current chat + if (!isUserId(event.jid.toString())) { + // not for us + return; } - } - private void stopDownload(CompositeMessage msg) { - AttachmentComponent attachment = (AttachmentComponent) msg - .getComponent(AttachmentComponent.class); + super.onUserOnline(event); - if (attachment != null && attachment.getFetchUrl() != null) { - Intent i = new Intent(getActivity(), DownloadService.class); - i.setAction(DownloadService.ACTION_DOWNLOAD_ABORT); - i.setData(Uri.parse(attachment.getFetchUrl())); - getActivity().startService(i); - } - } + onUserStatusChanged(event); - private void openFile(CompositeMessage msg) { - AttachmentComponent attachment = (AttachmentComponent) msg - .getComponent(AttachmentComponent.class); + CharSequence statusText; - if (attachment != null) { - Intent i = new Intent(Intent.ACTION_VIEW); - i.setDataAndType(attachment.getLocalUri(), attachment.getMime()); - startActivity(i); + mIsTyping = mIsTyping || Contact.isTyping(event.jid.toString()); + if (mIsTyping) { + setStatusText(context.getString(R.string.seen_typing_label)); } - } - /** Listener for attachment type chooser. */ - @Override - public void onClick(int id) { - switch (id) { - case ATTACHMENT_ACTION_PICTURE: - selectImageAttachment(); - break; - case ATTACHMENT_ACTION_CONTACT: - selectContactAttachment(); - break; - case ATTACHMENT_ACTION_AUDIO: - selectAudioAttachment(); - break; + /* + * FIXME using mode this way has several flaws. + * 1. it doesn't take multiple resources into account + * 2. it doesn't account for away status duration (we don't have this information at all) + */ + boolean isAway = (event.mode == Presence.Mode.away); + if (isAway) { + statusText = context.getString(R.string.seen_away_label); + } + else { + statusText = context.getString(R.string.seen_online_label); } - } - public void viewContact() { - if (mConversation != null) { - Contact contact = mConversation.getContact(); - if (contact != null) { - Intent i = new Intent(Intent.ACTION_VIEW, contact.getUri()); - if (i.resolveActivity(getActivity().getPackageManager()) != null) { - startActivity(i); - } - else { - // no contacts app found (crap device eh?) - Toast.makeText(getActivity(), - R.string.err_no_contacts_app, - Toast.LENGTH_LONG).show(); - } - } + String version = Contact.getVersion(event.jid.toString()); + // do not request version info if already requested before + if (!isAway && version == null && mVersionRequestId == null) { + requestVersion(event.jid); + } + + // a new resource just connected, send typing information again + // (only if we already sent it in this session) + // FIXME this will always broadcast the message to all resources + if (mComposer.isComposeSent() && mComposer.isSendEnabled() && + Preferences.getSendTyping(context)) { + sendTyping(); } - } - /** Starts dialog for attachment selection. */ - public void selectAttachment() { - if (attachmentMenu == null) { - attachmentMenu = new IconContextMenu(getActivity(), CONTEXT_MENU_ATTACHMENT); - attachmentMenu.addItem(getResources(), R.string.attachment_picture, R.drawable.ic_launcher_gallery, ATTACHMENT_ACTION_PICTURE); - attachmentMenu.addItem(getResources(), R.string.attachment_contact, R.drawable.ic_launcher_contacts, ATTACHMENT_ACTION_CONTACT); - if (AudioDialog.isSupported(getActivity())) - attachmentMenu.addItem(getResources(), R.string.attachment_audio, R.drawable.ic_launcher_audio, ATTACHMENT_ACTION_AUDIO); - attachmentMenu.setOnClickListener(this); - } - attachmentMenu.createMenu(getString(R.string.menu_attachment)).show(); + setCurrentStatusText(statusText); } - /** Starts activity for an image attachment. */ - @TargetApi(Build.VERSION_CODES.KITKAT) - private void selectImageAttachment() { - Intent pictureIntent; + @Override + public void onUserOffline(UserOfflineEvent event) { + final Context context = getContext(); + if (context == null) + return; - if (!MediaStorage.isStorageAccessFrameworkAvailable()) { - pictureIntent = new Intent(Intent.ACTION_GET_CONTENT); - } - else { - pictureIntent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + if (!isUserId(event.jid.toString())) { + // not for us + return; } - pictureIntent - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("image/*"); + int resourceCount = mAvailableResources.size(); - Intent chooser = null; - try { - // check if camera is available - final PackageManager packageManager = getActivity().getPackageManager(); - final Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - List list = - packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - if (list.size() <= 0) throw new UnsupportedOperationException(); - - mCurrentPhoto = MediaStorage.getOutgoingImageFile(); - Intent take = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - take.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(mCurrentPhoto)); - chooser = Intent.createChooser(pictureIntent, getString(R.string.chooser_send_picture)); - chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{take}); - } - catch (UnsupportedOperationException ue) { - Log.d(TAG, "no camera app or no camera present", ue); - } - catch (IOException e) { - Log.e(TAG, "error creating temp file", e); - Toast.makeText(getActivity(), R.string.chooser_error_no_camera, - Toast.LENGTH_LONG).show(); - } + super.onUserOffline(event); - if (chooser == null) chooser = pictureIntent; - startActivityForResult(chooser, SELECT_ATTACHMENT_OPENABLE); - } + boolean removed = resourceCount != mAvailableResources.size(); - /** Starts activity for a vCard attachment from a contact. */ - private void selectContactAttachment() { - Intent i = new Intent(Intent.ACTION_PICK, Contacts.CONTENT_URI); - startActivityForResult(i, SELECT_ATTACHMENT_CONTACT); - } + onUserStatusChanged(event); - private void selectAudioAttachment() { - // create audio fragment if needed - AudioFragment audio = getAudioFragment(); - mAudioDialog = new AudioDialog(getActivity(), audio, this); - mAudioDialog.show(); - } + CharSequence statusText = null; - private AudioFragment getAudioFragment() { - FragmentManager fm = getFragmentManager(); - AudioFragment fragment = (AudioFragment) fm - .findFragmentByTag("audio"); - if (fragment == null) { - fragment = new AudioFragment(); - fm.beginTransaction() - .add(fragment, "audio") - .commit(); - } - - return fragment; - } - - private void deleteThread() { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setTitle(R.string.confirm_delete_thread); - builder.setIcon(android.R.drawable.ic_dialog_alert); - builder.setMessage(R.string.confirm_will_delete_thread); - builder.setPositiveButton(android.R.string.ok, - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - mComposer.setText(""); - MessagesProvider.deleteThread(getActivity(), threadId); - } - }); - builder.setNegativeButton(android.R.string.cancel, null); - builder.create().show(); - } - - private void deleteMessage(final long id) { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setTitle(R.string.confirm_delete_message); - builder.setIcon(android.R.drawable.ic_dialog_alert); - builder.setMessage(R.string.confirm_will_delete_message); - builder.setPositiveButton(android.R.string.ok, - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - getActivity().getContentResolver().delete( - ContentUris.withAppendedId( - Messages.CONTENT_URI, id), null, null); - } - }); - builder.setNegativeButton(android.R.string.cancel, null); - builder.create().show(); - } - - private void blockUser() { - new AlertDialog.Builder(getActivity()) - .setTitle(R.string.menu_block_user) - .setMessage(R.string.msg_block_user_warning) - .setPositiveButton(R.string.menu_block_user, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - setPrivacy(PRIVACY_BLOCK); - } - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - private void unblockUser() { - new AlertDialog.Builder(getActivity()) - .setTitle(R.string.menu_unblock_user) - .setMessage(R.string.msg_unblock_user_warning) - .setPositiveButton(R.string.menu_unblock_user, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - setPrivacy(PRIVACY_UNBLOCK); - } - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - private void decryptMessage(CompositeMessage msg) { - try { - Context ctx = getActivity(); - - MessageUtils.decryptMessage(ctx, null, msg); - - // write updated data to the database - ContentValues values = new ContentValues(); - MessageUtils.fillContentValues(values, msg); - - ctx.getContentResolver().update(Messages.getUri(msg.getId()), - values, null, null); - } - catch (Exception e) { - Log.e(TAG, "decryption failed", e); - - // TODO i18n - Toast.makeText(getActivity(), "Decryption failed!", - Toast.LENGTH_LONG).show(); - } - } - - private void retryMessage(CompositeMessage msg) { - Intent i = new Intent(getActivity(), MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_RETRY); - i.putExtra(MessageCenterService.EXTRA_MESSAGE, ContentUris.withAppendedId - (Messages.CONTENT_URI, msg.getDatabaseId())); - getActivity().startService(i); - } - - private static final int MENU_RETRY = 1; - private static final int MENU_SHARE = 2; - private static final int MENU_COPY_TEXT = 3; - private static final int MENU_DECRYPT = 4; - private static final int MENU_OPEN = 5; - private static final int MENU_DOWNLOAD = 6; - private static final int MENU_CANCEL_DOWNLOAD = 7; - private static final int MENU_DETAILS = 8; - private static final int MENU_DELETE = 9; - - @Override - public void onCreateContextMenu(ContextMenu menu, View v, - ContextMenuInfo menuInfo) { - AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; - MessageListItem vitem = (MessageListItem) info.targetView; - CompositeMessage msg = vitem.getMessage(); - - menu.setHeaderTitle(R.string.title_message_options); - - // message waiting for user review - if (msg.getStatus() == Messages.STATUS_PENDING) { - menu.add(CONTEXT_MENU_GROUP_ID, MENU_RETRY, MENU_RETRY, R.string.resend); - } - - // some commands can be used only on unencrypted messages - if (!msg.isEncrypted()) { - - AttachmentComponent attachment = (AttachmentComponent) msg - .getComponent(AttachmentComponent.class); - TextComponent text = (TextComponent) msg - .getComponent(TextComponent.class); - - // sharing media messages has no purpose if media file hasn't been - // retrieved yet - if (text != null || (attachment != null ? attachment.getLocalUri() != null : true)) { - menu.add(CONTEXT_MENU_GROUP_ID, MENU_SHARE, MENU_SHARE, R.string.share); - } - - // non-empty text: copy text to clipboard - if (text != null && !TextUtils.isEmpty(text.getContent())) { - menu.add(CONTEXT_MENU_GROUP_ID, MENU_COPY_TEXT, MENU_COPY_TEXT, - R.string.copy_message_text); + /* + * All available resources have gone. Mark + * the user as offline immediately and use the + * timestamp provided with the stanza (if any). + */ + if (mAvailableResources.size() == 0) { + // an offline user can't be typing + mIsTyping = false; + + if (removed) { + // resource was removed now, mark as just offline + statusText = context.getText(R.string.seen_moment_ago_label); } - - if (attachment != null) { - - // message has a local uri - add open file entry - if (attachment.getLocalUri() != null) { - int resId; - if (attachment instanceof ImageComponent) - resId = R.string.view_image; - else if (attachment instanceof AudioComponent) - resId = R.string.open_audio; - else - resId = R.string.open_file; - - menu.add(CONTEXT_MENU_GROUP_ID, MENU_OPEN, MENU_OPEN, resId); + else { + // resource is offline, request last activity + Contact contact = getContact(); + if (contact != null && contact.getLastSeen() > 0) { + setLastSeenTimestamp(context, contact.getLastSeen()); } - - // message has a fetch url - add download control entry - if (msg.getDirection() == Messages.DIRECTION_IN && attachment.getFetchUrl() != null) { - int id, string; - if (!DownloadService.isQueued(attachment.getFetchUrl())) { - // already fetched - if (attachment.getLocalUri() != null) - string = R.string.download_again; - else - string = R.string.download_file; - id = MENU_DOWNLOAD; - } - else { - string = R.string.download_cancel; - id = MENU_CANCEL_DOWNLOAD; - } - menu.add(CONTEXT_MENU_GROUP_ID, id, id, string); + else if (mLastActivityRequestId == null) { + mLastActivityRequestId = StringUtils.randomString(6); + mServiceBus.post(new LastActivityRequest(mLastActivityRequestId, event.jid.asBareJid())); } - - } - } - else { - - menu.add(CONTEXT_MENU_GROUP_ID, MENU_DECRYPT, MENU_DECRYPT, - R.string.decrypt_message); - + if (statusText != null) { + setCurrentStatusText(statusText); } - - menu.add(CONTEXT_MENU_GROUP_ID, MENU_DETAILS, MENU_DETAILS, R.string.menu_message_details); - menu.add(CONTEXT_MENU_GROUP_ID, MENU_DELETE, MENU_DELETE, R.string.delete_message); } @Override - public boolean onContextItemSelected(android.view.MenuItem item) { - // not our context - if (item.getGroupId() != CONTEXT_MENU_GROUP_ID) - return false; - - AdapterContextMenuInfo info = (AdapterContextMenuInfo) item - .getMenuInfo(); - MessageListItem v = (MessageListItem) info.targetView; - CompositeMessage msg = v.getMessage(); - - switch (item.getItemId()) { - case MENU_SHARE: { - Intent i = null; - AttachmentComponent attachment = (AttachmentComponent) msg - .getComponent(AttachmentComponent.class); - - if (attachment != null) { - i = ComposeMessage.sendMediaMessage(attachment.getLocalUri(), - attachment.getMime()); - } - - else { - TextComponent txt = (TextComponent) msg - .getComponent(TextComponent.class); - - if (txt != null) - i = ComposeMessage.sendTextMessage(txt.getContent()); - } - - if (i != null) - startActivity(i); - else - // TODO ehm... - Log.w(TAG, "error sharing message"); - - return true; - } - - case MENU_COPY_TEXT: { - TextComponent txt = (TextComponent) msg - .getComponent(TextComponent.class); - - String text = (txt != null) ? txt.getContent() : ""; - - ClipboardManager cpm = (ClipboardManager) getActivity() - .getSystemService(Context.CLIPBOARD_SERVICE); - cpm.setText(text); - - Toast.makeText(getActivity(), R.string.message_text_copied, - Toast.LENGTH_SHORT).show(); - return true; - } - - case MENU_DECRYPT: { - decryptMessage(msg); - return true; - } - - case MENU_RETRY: { - retryMessage(msg); - return true; - } - - case MENU_DOWNLOAD: { - startDownload(msg); - return true; - } - - case MENU_CANCEL_DOWNLOAD: { - stopDownload(msg); - return true; - } - - case MENU_DETAILS: { - CharSequence messageDetails = MessageUtils.getMessageDetails( - getActivity(), msg, mUserPhone != null ? mUserPhone : mUserJID); - new AlertDialog.Builder(getActivity()) - .setTitle(R.string.title_message_details) - .setMessage(messageDetails) - .setPositiveButton(android.R.string.ok, null) - .setCancelable(true).show(); - return true; - } - - case MENU_DELETE: { - deleteMessage(msg.getDatabaseId()); - return true; - } - - case MENU_OPEN: { - openFile(msg); - return true; - } - } - - return super.onContextItemSelected(item); - } - - private void startQuery(boolean reloadConversation, boolean progress) { - try { - if (progress) - getActivity().setProgressBarIndeterminateVisibility(true); - - CompositeMessage.startQuery(mQueryHandler, MESSAGE_LIST_QUERY_TOKEN, - threadId); - - if (reloadConversation) - Conversation.startQuery(mQueryHandler, - CONVERSATION_QUERY_TOKEN, threadId); - - } - catch (SQLiteException e) { - Log.e(TAG, "query error", e); - } - } - - private void loadConversationMetadata(Uri uri) { - threadId = ContentUris.parseId(uri); - mConversation = Conversation.loadFromId(getActivity(), threadId); - if (mConversation == null) { - Log.w(TAG, "conversation for thread " + threadId + " not found!"); - startActivity(new Intent(getActivity(), ConversationList.class)); - getActivity().finish(); + public void onNoUserPresence(NoPresenceEvent event) { + final Context context = getContext(); + if (context == null) return; - } - mUserJID = mConversation.getRecipient(); - Contact contact = mConversation.getContact(); - if (contact != null) { - mUserName = contact.getName(); - mUserPhone = contact.getNumber(); - } - else { - mUserName = mUserJID; + // check that origin matches the current chat + if (!isUserId(event.jid.toString())) { + // not for us + return; } - } - - private Bundle myArguments() { - return (mArguments != null) ? mArguments : getArguments(); - } - - public void setMyArguments(Bundle args) { - mArguments = args; - } - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == SELECT_ATTACHMENT_OPENABLE) { - if (resultCode == Activity.RESULT_OK) { - Uri uri = null; - String mime = null; - - // returning from camera - if (data == null) { - /* - * FIXME picture taking should be done differently. - * Use a MediaStore-based uri and use a requestCode just - * for taking pictures. - */ - if (mCurrentPhoto != null) { - uri = Uri.fromFile(mCurrentPhoto); - // notify media scanner - Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); - mediaScanIntent.setData(uri); - getActivity().sendBroadcast(mediaScanIntent); - mCurrentPhoto = null; - } - } - else { - if (mCurrentPhoto != null) { - mCurrentPhoto.delete(); - mCurrentPhoto = null; - } - uri = data.getData(); - mime = data.getType(); - - // SAF available, request persistable permissions - if (MediaStorage.isStorageAccessFrameworkAvailable()) { - MediaStorage.requestPersistablePermissions(getActivity(), data); - } - } - - if (uri != null) { - if (mime == null || mime.startsWith("*/") - || mime.endsWith("/*")) { - mime = MediaStorage.getType(getActivity(), uri); - Log.v(TAG, "using detected mime type " + mime); - } - - if (ImageComponent.supportsMimeType(mime)) - sendBinaryMessage(uri, mime, true, ImageComponent.class); - else if (VCardComponent.supportsMimeType(mime)) - sendBinaryMessage(uri, VCardComponent.MIME_TYPE, false, VCardComponent.class); - else - Toast.makeText(getActivity(), R.string.send_mime_not_supported, Toast.LENGTH_LONG) - .show(); - } - } - // operation aborted - else { - // delete photo :) - if (mCurrentPhoto != null) { - mCurrentPhoto.delete(); - mCurrentPhoto = null; - } - } - } - else if (requestCode == SELECT_ATTACHMENT_CONTACT) { - if (resultCode == Activity.RESULT_OK) { - Uri uri = data.getData(); - if (uri != null) { - // get lookup key - final Cursor c = getActivity().getContentResolver() - .query(uri, new String[] { Contacts.LOOKUP_KEY }, null, null, null); - if (c != null) { - try { - c.moveToFirst(); - String lookupKey = c.getString(0); - Uri vcardUri = Uri.withAppendedPath(Contacts.CONTENT_VCARD_URI, lookupKey); - sendBinaryMessage(vcardUri, VCardComponent.MIME_TYPE, false, VCardComponent.class); - } - finally { - c.close(); - } - } - } - } - } - } + // no roster entry found, request subscription (only for local networks) + if (Kontalk.get().getDefaultAccount().isNetworkJID(event.jid.asBareJid())) { + // pre-approve our presence and request subscription + mServiceBus.post(new PreapproveSubscriptionRequest(event.jid)); + mServiceBus.post(new SubscribeRequest(event.jid)); - @Override - public void onSaveInstanceState(Bundle out) { - super.onSaveInstanceState(out); - out.putParcelable(Uri.class.getName(), Threads.getUri(mUserJID)); - // save composer status - if (mComposer != null) - mComposer.onSaveInstanceState(out); - // current photo being shot - if (mCurrentPhoto != null) { - out.putString("currentPhoto", mCurrentPhoto.toString()); - } - // audio dialog open - if (mAudioDialog != null) { - mAudioDialog.onSaveInstanceState(out); + setStatusText(context.getString(R.string.invitation_sent_label)); } } - private void processArguments(Bundle savedInstanceState) { - Bundle args; - if (savedInstanceState != null) { - Uri uri = savedInstanceState.getParcelable(Uri.class.getName()); - // threadId = ContentUris.parseId(uri); - args = new Bundle(); - args.putString("action", ComposeMessage.ACTION_VIEW_USERID); - args.putParcelable("data", uri); - - String currentPhoto = savedInstanceState.getString("currentPhoto"); - if (currentPhoto != null) { - mCurrentPhoto = new File(currentPhoto); - } - - mAudioDialog = AudioDialog.onRestoreInstanceState(getActivity(), - savedInstanceState, getAudioFragment(), this); - if (mAudioDialog != null) { - Log.d(TAG, "recreating audio dialog"); - mAudioDialog.show(); - } - } - else { - args = myArguments(); - } - - if (args != null && args.size() > 0) { - final String action = args.getString("action"); - - // view intent - if (Intent.ACTION_VIEW.equals(action)) { - Uri uri = args.getParcelable("data"); - ContentResolver cres = getActivity().getContentResolver(); - - /* - * FIXME this will retrieve name directly from contacts, - * resulting in a possible discrepancy with users database - */ - Cursor c = cres.query(uri, new String[] { - Syncer.DATA_COLUMN_DISPLAY_NAME, - Syncer.DATA_COLUMN_PHONE }, null, null, null); - if (c.moveToFirst()) { - mUserName = c.getString(0); - mUserPhone = c.getString(1); - - // FIXME should it be retrieved from RawContacts.SYNC3 ?? - mUserJID = XMPPUtils.createLocalJID(getActivity(), MessageUtils.sha1(mUserPhone)); - - Cursor cp = cres.query(Messages.CONTENT_URI, - new String[] { Messages.THREAD_ID }, Messages.PEER - + " = ?", new String[] { mUserJID }, null); - if (cp.moveToFirst()) - threadId = cp.getLong(0); - cp.close(); - } - c.close(); - - if (threadId > 0) { - mConversation = Conversation.loadFromId(getActivity(), - threadId); - } - else { - mConversation = Conversation.createNew(getActivity()); - mConversation.setRecipient(mUserJID); - } - } - - // view conversation - just threadId provided - else if (ComposeMessage.ACTION_VIEW_CONVERSATION.equals(action)) { - Uri uri = args.getParcelable("data"); - loadConversationMetadata(uri); - } - - // view conversation - just userId provided - else if (ComposeMessage.ACTION_VIEW_USERID.equals(action)) { - Uri uri = args.getParcelable("data"); - mUserJID = uri.getPathSegments().get(1); - mConversation = Conversation.loadFromUserId(getActivity(), - mUserJID); - - if (mConversation == null) { - mConversation = Conversation.createNew(getActivity()); - mConversation.setNumberHint(args.getString("number")); - mConversation.setRecipient(mUserJID); - } - // this way avoid doing the users database query twice - else { - if (mConversation.getContact() == null) { - mConversation.setNumberHint(args.getString("number")); - mConversation.setRecipient(mUserJID); - } - } - - threadId = mConversation.getThreadId(); - Contact contact = mConversation.getContact(); - if (contact != null) { - mUserName = contact.getName(); - mUserPhone = contact.getNumber(); - } - else { - mUserName = mUserJID; + /** Sends a subscription request for the current peer. */ + void requestPresence() { + // do not request presence for domain JIDs + if (!XMPPUtils.isDomainJID(mUserJID)) { + Context context = getContext(); + if (context != null) { + // all of this shall be done only if there isn't a request from the other contact + // FIXME when accepting an invitation, the if below could be + // false because of delay or no reloading of mConversation at all + // thus skipping the presence request. + // An automatic solution could be to emit a ROSTER_LOADED whenever the roster changes + // still this _if_ should go away in favour of the message center checking for subscription status + // and the UI reacting to a pending request status (i.e. "pending_in") + if (mConversation.getRequestStatus() != Threads.REQUEST_WAITING) { + // request last presence + mServiceBus.post(new PresenceRequest(JidCreate.bareFromOrThrowUnchecked(mUserJID))); } } } - - // set title if we are autonomous - if (mArguments != null) { - String title = mUserName; - //if (mUserPhone != null) title += " <" + mUserPhone + ">"; - setActivityTitle(title, "", null); - } - - // update conversation stuff - if (mConversation != null) - onConversationCreated(); - - // non existant thread - check for not synced contact - if (threadId <= 0 && mConversation != null) { - Contact contact = mConversation.getContact(); - if (mUserPhone != null && contact != null ? !contact.isRegistered() : true) { - // ask user to send invitation - DialogInterface.OnClickListener noListener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - // FIXME is this specific to sms app? - Intent i = new Intent(Intent.ACTION_SENDTO, - Uri.parse("smsto:" + mUserPhone)); - i.putExtra("sms_body", - getString(R.string.text_invite_message)); - startActivity(i); - getActivity().finish(); - } - }; - - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder. - setTitle(R.string.title_user_not_found) - .setMessage(R.string.message_user_not_found) - // nothing happens if user chooses to contact the user anyway - .setPositiveButton(R.string.yes_user_not_found, null) - .setNegativeButton(R.string.no_user_not_found, noListener) - .show(); - - } - } - } - - public void setActivityTitle(CharSequence title, CharSequence status, Contact contact) { - Activity parent = getActivity(); - if (parent instanceof ComposeMessage) - ((ComposeMessage) parent).setTitle(title, status, contact); - else if (title != null) - parent.setTitle(title); } - public void setActivityStatusUpdating() { - Activity parent = getActivity(); - if (parent instanceof ComposeMessage) - ((ComposeMessage) parent).setUpdatingSubtitle(); - else if (mStatusText != null) { - ComposeMessage.setUpdatingSubtitle(mStatusText); - } - } - - public ComposeMessage getParentActivity() { - Activity _activity = getActivity(); - return (_activity instanceof ComposeMessage) ? (ComposeMessage) _activity - : null; - } - - private void processStart(boolean resuming) { - ComposeMessage activity = getParentActivity(); - // opening for contact picker - do nothing - if (threadId < 0 && activity != null - && activity.getSendIntent() != null) - return; - - if (mListAdapter == null) { - Pattern highlight = null; - Bundle args = myArguments(); - if (args != null) { - String highlightString = args - .getString(ComposeMessage.EXTRA_HIGHLIGHT); - highlight = (highlightString == null) ? null : Pattern.compile( - "\\b" + Pattern.quote(highlightString), - Pattern.CASE_INSENSITIVE); - } - - mListAdapter = new MessageListAdapter(getActivity(), null, - highlight, getListView(), this); - mListAdapter.setOnContentChangedListener(mContentChangedListener); - setListAdapter(mListAdapter); - } - - if (threadId > 0) { - // always reload conversation - startQuery(true, resuming); - } - else { - // HACK this is for crappy honeycomb :) - getActivity().setProgressBarIndeterminateVisibility(false); - - mConversation = Conversation.createNew(getActivity()); - mConversation.setRecipient(mUserJID); - onConversationCreated(); - } + void sendInvitation() { + // FIXME is this specific to sms app? + Intent i = SystemUtils.externalIntent(Intent.ACTION_SENDTO, + Uri.parse("smsto:" + mUserPhone)); + i.putExtra("sms_body", + getString(R.string.text_invite_message)); + startActivity(i); + getActivity().finish(); } /** Called when the {@link Conversation} object has been created. */ - private void onConversationCreated() { - // subscribe to presence notifications - subscribePresence(); - - // restore any draft - mComposer.restoreText(mConversation.getDraft()); - - if (mConversation.getThreadId() > 0 && mConversation.getUnreadCount() > 0) { - /* - * FIXME this has the usual issue about resuming while screen is - * still locked, having focus and so on... - * See issue #28. - */ - mConversation.markAsRead(); - } - else { - // new conversation -- observe peer Uri - registerPeerObserver(); - } - - // update contact icon - setActivityTitle(null, null, mConversation.getContact()); + @Override + protected void onConversationCreated() { + super.onConversationCreated(); // setup invitation bar boolean visible = (mConversation.getRequestStatus() == Threads.REQUEST_WAITING); @@ -1464,20 +715,20 @@ private void onConversationCreated() { if (visible) { if (mInvitationBar == null) { - mInvitationBar = (ViewGroup) getView().findViewById(R.id.invitation_bar); + mInvitationBar = getView().findViewById(R.id.invitation_bar); // setup listeners and show button bar View.OnClickListener listener = new View.OnClickListener() { public void onClick(View v) { mInvitationBar.setVisibility(View.GONE); - int action; + PrivacyCommand action; if (v.getId() == R.id.button_accept) - action = PRIVACY_ACCEPT; + action = PrivacyCommand.ACCEPT; else - action = PRIVACY_REJECT; + action = PrivacyCommand.REJECT; - setPrivacy(action); + setPrivacy(v.getContext(), action); } }; @@ -1490,1130 +741,510 @@ public void onClick(View v) { mInvitationBar.findViewById(R.id.button_identity) .setOnClickListener(new View.OnClickListener() { public void onClick(View v) { - showIdentityDialog(true); + showIdentityDialog(true, R.string.title_invitation); } } ); - } - } - - if (mInvitationBar != null) - mInvitationBar.setVisibility(visible ? View.VISIBLE : View.GONE); - - updateUI(); - } - - private void setPrivacy(int action) { - int status; - - switch (action) { - case PRIVACY_ACCEPT: - status = Threads.REQUEST_REPLY_PENDING_ACCEPT; - break; - - case PRIVACY_BLOCK: - case PRIVACY_REJECT: - status = Threads.REQUEST_REPLY_PENDING_BLOCK; - break; - - case PRIVACY_UNBLOCK: - status = Threads.REQUEST_REPLY_PENDING_UNBLOCK; - break; - - default: - return; - } - - Context ctx = getActivity(); - - // mark request as pending accepted - ContentValues values = new ContentValues(1); - values.put(Threads.REQUEST_STATUS, status); - - // FIXME this won't work on new threads - - ctx.getContentResolver().update(Requests.CONTENT_URI, - values, CommonColumns.PEER + "=?", - new String[] { mUserJID }); - - // accept invitation - if (action == PRIVACY_ACCEPT) { - // trust the key - UsersProvider.trustUserKey(ctx, mUserJID); - // reload contact - invalidateContact(); - } - // setup broadcast receiver for block/unblock reply - else if (action == PRIVACY_REJECT || action == PRIVACY_BLOCK || action == PRIVACY_UNBLOCK) { - if (mPrivacyListener == null) { - mPrivacyListener = new BroadcastReceiver() { - public void onReceive(Context context, Intent intent) { - String from = XmppStringUtils.parseBareJid(intent - .getStringExtra(MessageCenterService.EXTRA_FROM)); - - if (mUserJID.equals(from)) { - // this will trigger a Contact reload - mConversation.setRecipient(mUserJID); - // this will update block/unblock menu items - updateUI(); - // request presence subscription if unblocking - if (MessageCenterService.ACTION_UNBLOCKED.equals(intent.getAction())) { - Toast.makeText(getActivity(), - R.string.msg_user_unblocked, - Toast.LENGTH_LONG).show(); - - // hide any block warning - // a new warning will be issued for the key if needed - hideWarning(); - presenceSubscribe(); - } - else { - Toast.makeText(getActivity(), - R.string.msg_user_blocked, - Toast.LENGTH_LONG).show(); - } - - // we don't need this receiver anymore - mLocalBroadcastManager.unregisterReceiver(this); - } - } - }; - } - - IntentFilter filter = new IntentFilter(MessageCenterService.ACTION_BLOCKED); - filter.addAction(MessageCenterService.ACTION_UNBLOCKED); - mLocalBroadcastManager.registerReceiver(mPrivacyListener, filter); - } - - // send command to message center - MessageCenterService.replySubscription(ctx, mUserJID, action); - } - - private void invalidateContact() { - Contact.invalidate(mUserJID); - // this will trigger contact reload - mConversation.setRecipient(mUserJID); - } - - private void showIdentityDialog(boolean informationOnly) { - String fingerprint; - String uid; - - PGPPublicKeyRing publicKey = UsersProvider.getPublicKey(getActivity(), mUserJID, false); - if (publicKey != null) { - PGPPublicKey pk = PGP.getMasterKey(publicKey); - fingerprint = PGP.formatFingerprint(PGP.getFingerprint(pk)); - uid = PGP.getUserId(pk, null); // TODO server!!! - } - else { - // FIXME using another string - fingerprint = uid = getString(R.string.peer_unknown); - } - - SpannableStringBuilder text = new SpannableStringBuilder(); - text.append(getString(R.string.text_invitation1)) - .append('\n'); - - Contact c = mConversation.getContact(); - if (c != null) { - text.append(c.getName()) - .append(" <") - .append(c.getNumber()) - .append('>'); - } - else { - int start = text.length() - 1; - text.append(uid); - text.setSpan(MessageUtils.STYLE_BOLD, start, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - - text.append('\n') - .append(getString(R.string.text_invitation2)) - .append('\n'); - - int start = text.length() - 1; - text.append(fingerprint); - text.setSpan(MessageUtils.STYLE_BOLD, start, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - AlertDialog.Builder builder = new AlertDialog - .Builder(getActivity()) - .setIcon(android.R.drawable.ic_dialog_info) - .setMessage(text); - - if (informationOnly) { - builder.setPositiveButton(android.R.string.ok, null) - .setTitle(R.string.title_invitation); - } - else { - DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - // hide warning bar - hideWarning(); - - switch (which) { - case DialogInterface.BUTTON_POSITIVE: - // trust new key - trustKeyChange(); - break; - case DialogInterface.BUTTON_NEGATIVE: - // block user immediately - setPrivacy(PRIVACY_BLOCK); - break; - } - } - }; - builder.setTitle(R.string.title_public_key_warning) - .setPositiveButton(R.string.button_accept, listener) - .setNegativeButton(R.string.button_block, listener); - } - - builder.show(); - } - - private void hideWarning() { - LinearLayout root = (LinearLayout) getView().findViewById(R.id.container); - TextView warning = (TextView) root.findViewById(R.id.warning_bar); - if (warning != null) { - root.removeView(warning); - } - } - - private void trustKeyChange() { - // mark current key as trusted - UsersProvider.trustUserKey(getActivity(), mUserJID); - // reload contact - invalidateContact(); - } - - private void showKeyChangedWarning() { - showWarning(getText(R.string.warning_public_key), new View.OnClickListener() { - @Override - public void onClick(View v) { - DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - switch (which) { - case DialogInterface.BUTTON_POSITIVE: - // hide warning bar - hideWarning(); - // trust new key - trustKeyChange(); - break; - case DialogInterface.BUTTON_NEUTRAL: - showIdentityDialog(false); - break; - case DialogInterface.BUTTON_NEGATIVE: - // hide warning bar - hideWarning(); - // block user immediately - setPrivacy(PRIVACY_BLOCK); - break; - } - } - }; - new AlertDialog.Builder(getActivity()) - .setIcon(android.R.drawable.ic_dialog_alert) - .setTitle(R.string.title_public_key_warning) - .setMessage(R.string.msg_public_key_warning) - .setPositiveButton(R.string.button_accept, listener) - .setNeutralButton(R.string.button_identity, listener) - .setNegativeButton(R.string.button_block, listener) - .show(); - } - }, WarningType.FATAL); - } - - private void showWarning(CharSequence text, View.OnClickListener listener, WarningType type) { - LinearLayout root = (LinearLayout) getView().findViewById(R.id.container); - TextView warning = (TextView) root.findViewById(R.id.warning_bar); - if (warning == null) { - warning = (TextView) LayoutInflater.from(getActivity()) - .inflate(R.layout.warning_bar, root, false); - root.addView(warning, 0); - } - int textId = 0; - int colorId = 0; - switch (type) { - case FATAL: - textId = android.R.attr.textAppearanceSmallInverse; - colorId = R.color.warning_bar_background_fatal; - break; - case WARNING: - textId = android.R.attr.textAppearanceSmall; - colorId = R.color.warning_bar_background_warning; - break; - } - final TypedValue typedValue = new TypedValue(); - getActivity().getTheme().resolveAttribute(textId, typedValue, true); - warning.setTextAppearance(getActivity(), typedValue.resourceId); - warning.setBackgroundColor(getResources().getColor(colorId)); - warning.setOnClickListener(listener); - warning.setText(text); - } - - private void subscribePresence() { - // TODO this needs serious refactoring - if (mPresenceReceiver == null) { - mPresenceReceiver = new BroadcastReceiver() { - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - - if (MessageCenterService.ACTION_PRESENCE.equals(action)) { - String from = intent.getStringExtra(MessageCenterService.EXTRA_FROM); - String bareFrom = from != null ? XmppStringUtils.parseBareJid(from) : null; - - // we are receiving a presence from our peer - if (from != null && bareFrom.equalsIgnoreCase(mUserJID)) { - - // we handle only (un)available presence stanzas - String type = intent.getStringExtra(MessageCenterService.EXTRA_TYPE); - - if (type == null) { - // no roster entry found, request subscription - - // pre-approve our presence if we don't have contact's key - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_PRESENCE); - i.putExtra(MessageCenterService.EXTRA_TO, mUserJID); - i.putExtra(MessageCenterService.EXTRA_TYPE, Presence.Type.subscribed.name()); - context.startService(i); - - // request subscription - i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_PRESENCE); - i.putExtra(MessageCenterService.EXTRA_TO, mUserJID); - i.putExtra(MessageCenterService.EXTRA_TYPE, Presence.Type.subscribe.name()); - context.startService(i); - - setStatusText(context.getString(R.string.invitation_sent_label)); - } - - // (un)available presence - else if (Presence.Type.available.name().equals(type) || Presence.Type.unavailable.name().equals(type)) { - - CharSequence statusText = null; - - // really not much sense in requesting the key for a non-existing contact - Contact contact = getContact(); - if (contact != null) { - boolean subscribedFrom = intent.getBooleanExtra(MessageCenterService.EXTRA_SUBSCRIBED_FROM, false); - String newFingerprint = intent.getStringExtra(MessageCenterService.EXTRA_FINGERPRINT); - // if this is null, we are accepting the key for the first time - PGPPublicKeyRing trustedPublicKey = contact.getTrustedPublicKeyRing(); - - // request the key if we don't have a trusted one or we are subscribed from the contact - boolean requestKey = (trustedPublicKey == null || subscribedFrom); - // check if fingerprint changed - if (trustedPublicKey != null) { - String oldFingerprint = PGP.getFingerprint(PGP.getMasterKey(trustedPublicKey)); - if (newFingerprint == null || newFingerprint.equalsIgnoreCase(oldFingerprint)) { - // no fingerprint available or fingerprint has not changed since last time - requestKey = false; - } - } - else { - // request key if we got one in the first place - requestKey = (contact.getFingerprint() != null); - } - - if (requestKey) { - // warn user that public key is changed - showKeyChangedWarning(); - } - } - - if (Presence.Type.available.toString().equals(type)) { - mAvailableResources.add(from); - statusText = context.getString(R.string.seen_online_label); - } - else if (Presence.Type.unavailable.toString().equals(type)) { - boolean removed = mAvailableResources.remove(from); - /* - * All available resources have gone. Mark - * the user as offline immediately and use the - * timestamp provided with the stanza (if any). - */ - if (mAvailableResources.size() == 0) { - // an offline user can't be typing - mIsTyping = false; - - if (removed) { - // resource was removed now, mark as just offline - statusText = context.getText(R.string.seen_moment_ago_label); - } - else { - // resource is offline, request last activity - if (contact != null && contact.getLastSeen() > 0) { - setLastSeenTimestamp(context, contact.getLastSeen()); - } - else if (mLastActivityId == null) { - mLastActivityId = StringUtils.randomString(6); - MessageCenterService.requestLastActivity(context, bareFrom, mLastActivityId); - } - } - - /* user offline - long stamp = intent.getLongExtra(MessageCenterService.EXTRA_STAMP, -1); - if (stamp >= 0 && ((System.currentTimeMillis() - stamp) > PRESENCE_DELAY_THRESHOLD)) { - statusText = MessageUtils.formatRelativeTimeSpan(context, stamp); - } - else { - statusText = context.getString(R.string.seen_moment_ago_label); - } - */ - } - } - - if (statusText != null) { - mCurrentStatus = statusText; - if (!mIsTyping) - setStatusText(statusText); - } - } - - // subscription accepted, probe presence - else if (Presence.Type.subscribed.name().equals(type)) { - presenceSubscribe(); - } - } - } - - else if (MessageCenterService.ACTION_LAST_ACTIVITY.equals(action)) { - // ignore last activity if we had an available presence in the meantime - String id = intent.getStringExtra(MessageCenterService.EXTRA_PACKET_ID); - if (id != null && id.equals(mLastActivityId)) { - mLastActivityId = null; - if (mAvailableResources.size() == 0) { - String type = intent.getStringExtra(MessageCenterService.EXTRA_TYPE); - if (type == null || !type.equalsIgnoreCase(IQ.Type.error.toString())) { - long seconds = intent.getLongExtra(MessageCenterService.EXTRA_SECONDS, -1); - setLastSeenSeconds(context, seconds); - } - } - } - } - - else if (MessageCenterService.ACTION_CONNECTED.equals(action)) { - // reset compose sent flag - mComposer.resetCompose(); - // reset available resources list - mAvailableResources.clear(); - // reset any pending last activity request - mLastActivityId = null; - } - - else if (MessageCenterService.ACTION_ROSTER_LOADED.equals(action)) { - // probe presence - presenceSubscribe(); - } - - else if (MessageCenterService.ACTION_MESSAGE.equals(action)) { - String from = intent.getStringExtra(MessageCenterService.EXTRA_FROM); - String chatState = intent.getStringExtra("org.kontalk.message.chatState"); - - // we are receiving a composing notification from our peer - if (from != null && XMPPUtils.equalsBareJID(from, mUserJID)) { - if (chatState != null && ChatState.composing.toString().equals(chatState)) { - mIsTyping = true; - setStatusText(context.getString(R.string.seen_typing_label)); - } - else { - mIsTyping = false; - setStatusText(mCurrentStatus != null ? mCurrentStatus : ""); - } - } - } - - } - }; - - // listen for user presence, connection and incoming messages - IntentFilter filter = new IntentFilter(); - filter.addAction(MessageCenterService.ACTION_PRESENCE); - filter.addAction(MessageCenterService.ACTION_CONNECTED); - filter.addAction(MessageCenterService.ACTION_ROSTER_LOADED); - filter.addAction(MessageCenterService.ACTION_LAST_ACTIVITY); - filter.addAction(MessageCenterService.ACTION_MESSAGE); - - mLocalBroadcastManager.registerReceiver(mPresenceReceiver, filter); - - // request connection and roster load status - Context ctx = getActivity(); - if (ctx != null) { - MessageCenterService.requestConnectionStatus(ctx); - MessageCenterService.requestRosterStatus(ctx); - } - } - } - - private void setLastSeenTimestamp(Context context, long stamp) { - setCurrentStatusText(MessageUtils.formatRelativeTimeSpan(context, stamp)); - } - - private void setLastSeenSeconds(Context context, long seconds) { - CharSequence statusText = null; - if (seconds == 0) { - // it's improbable, but whatever... - statusText = context.getText(R.string.seen_moment_ago_label); - } - else if (seconds > 0) { - long stamp = System.currentTimeMillis() - (seconds * 1000); - - Contact contact = getContact(); - if (contact != null) { - contact.setLastSeen(stamp); - } - - // seconds ago relative to our time - statusText = MessageUtils.formatRelativeTimeSpan(context, stamp); - } - - if (statusText != null) { - setCurrentStatusText(statusText); - } - } - - private void setCurrentStatusText(CharSequence statusText) { - mCurrentStatus = statusText; - if (!mIsTyping) - setStatusText(statusText); - } - - /** Sends a subscription request for the current peer. */ - private void presenceSubscribe() { - Context context = getActivity(); - if (context != null) { - // all of this shall be done only if there isn't a request from the other contact - if (mConversation.getRequestStatus() != Threads.REQUEST_WAITING) { - // request last presence - Intent i = new Intent(context, MessageCenterService.class); - i.setAction(MessageCenterService.ACTION_PRESENCE); - i.putExtra(MessageCenterService.EXTRA_TO, mUserJID); - i.putExtra(MessageCenterService.EXTRA_TYPE, Presence.Type.probe.name()); - context.startService(i); - } - } - } - - private void unsubcribePresence() { - if (mPresenceReceiver != null) { - mLocalBroadcastManager.unregisterReceiver(mPresenceReceiver); - mPresenceReceiver = null; - } - } - - private void setStatusText(CharSequence text) { - Activity parent = getActivity(); - if (parent instanceof ComposeMessage) - setActivityTitle(null, text, null); - else { - if (mStatusText != null) - mStatusText.setText(text); - } - } - - private synchronized void registerPeerObserver() { - if (mPeerObserver == null) { - Uri uri = Threads.getUri(mConversation.getRecipient()); - mPeerObserver = new PeerObserver(getActivity(), mQueryHandler); - getActivity().getContentResolver().registerContentObserver(uri, - false, mPeerObserver); - } - } - - private synchronized void unregisterPeerObserver() { - if (mPeerObserver != null) { - getActivity().getContentResolver().unregisterContentObserver( - mPeerObserver); - mPeerObserver = null; - } - } - - private final class PeerObserver extends ContentObserver { - private final Context mContext; - - public PeerObserver(Context context, Handler handler) { - super(handler); - mContext = context; - } - - @Override - public void onChange(boolean selfChange) { - Conversation conv = Conversation.loadFromUserId(mContext, mUserJID); - - if (conv != null) { - mConversation = conv; - threadId = mConversation.getThreadId(); - - // auto-unregister - unregisterPeerObserver(); - } - - // fire cursor update - processStart(false); - } - - @Override - public boolean deliverSelfNotifications() { - return false; - } - } - - @Override - public void onResume() { - super.onResume(); - - if (Authenticator.getDefaultAccount(getActivity()) == null) { - NumberValidation.start(getActivity()); - getActivity().finish(); - return; - } - - // hold message center - MessageCenterService.hold(getActivity()); - - ComposeMessage activity = getParentActivity(); - if (activity == null || !activity.hasLostFocus() || activity.hasWindowFocus()) { - onFocus(); - } - } - - public void onFocus() { - // resume content watcher - resumeContentListener(); - - // we are updating the status now - setActivityStatusUpdating(); - - // cursor was previously destroyed -- reload everything - // mConversation = null; - processStart(true); - if (mUserJID != null) { - // set notifications on pause - MessagingNotification.setPaused(mUserJID); - - // clear chat invitation (if any) - // TODO use jid here - MessagingNotification.clearChatInvitation(getActivity(), mUserJID); - } - } - - @Override - public void onPause() { - super.onPause(); - - // notify composer bar - mComposer.onPause(); + } + } - // hide emoji drawer - tryHideEmojiDrawer(); + if (mInvitationBar != null) + mInvitationBar.setVisibility(visible ? View.VISIBLE : View.GONE); + } - // pause content watcher - pauseContentListener(); + void setPrivacy(@NonNull Context ctx, PrivacyCommand action) { + int status; - // notify parent of pausing - ComposeMessage parent = getParentActivity(); - if (parent != null) - parent.fragmentLostFocus(); + switch (action) { + case ACCEPT: + status = Threads.REQUEST_REPLY_PENDING_ACCEPT; + break; - CharSequence text = mComposer.getText(); - int len = text.length(); + case BLOCK: + case REJECT: + status = Threads.REQUEST_REPLY_PENDING_BLOCK; + break; - // resume notifications - MessagingNotification.setPaused(null); + case UNBLOCK: + status = Threads.REQUEST_REPLY_PENDING_UNBLOCK; + break; - // save last message as draft - if (threadId > 0) { + default: + return; + } - // no draft and no messages - delete conversation - if (len == 0 && mConversation.getMessageCount() == 0 && - mConversation.getRequestStatus() != Threads.REQUEST_WAITING) { + // temporarly disable peer observer because the next call will write to the threads table + unregisterPeerObserver(); - // FIXME shouldn't be faster to just delete the thread? - MessagesProvider.deleteThread(getActivity(), threadId); - } + // mark request as pending accepted + UsersProvider.setRequestStatus(ctx, mUserJID, status); - // update draft - else { - ContentValues values = new ContentValues(1); - values.put(Threads.DRAFT, (len > 0) ? text.toString() : null); - getActivity().getContentResolver().update( - ContentUris.withAppendedId(Threads.CONTENT_URI, threadId), - values, null, null); + // accept invitation + if (action == PrivacyCommand.ACCEPT) { + // trust the key + String fingerprint = getContact().getFingerprint(); + // we might not have a fingerprint if it's an XMPP user + if (fingerprint != null) { + Kontalk.get().getMessagesController() + .setTrustLevelAndRetryMessages(mUserJID, + fingerprint, MyUsers.Keys.TRUST_VERIFIED); } } - // new thread, create empty conversation - else { - if (len > 0) { - // save to local storage - ContentValues values = new ContentValues(); - // must supply a message ID... - values.put(Messages.MESSAGE_ID, - "draft" + (new Random().nextInt())); - values.put(Messages.PEER, mUserJID); - values.put(Messages.BODY_CONTENT, new byte[0]); - values.put(Messages.BODY_LENGTH, 0); - values.put(Messages.BODY_MIME, TextComponent.MIME_TYPE); - values.put(Messages.DIRECTION, Messages.DIRECTION_OUT); - values.put(Messages.TIMESTAMP, System.currentTimeMillis()); - values.put(Messages.ENCRYPTED, false); - values.put(Threads.DRAFT, text.toString()); - getActivity().getContentResolver().insert(Messages.CONTENT_URI, - values); - } - } + // reload contact + invalidateContact(); - if (len > 0) { - Toast.makeText(getActivity(), R.string.msg_draft_saved, - Toast.LENGTH_LONG).show(); - } + // send command to message center + BareJid jid = JidCreate.bareFromOrThrowUnchecked(mUserJID); + MessageCenterService.bus() + .post(new SetUserPrivacyRequest(jid, action)); - if (Preferences.getSendTyping(getActivity())) { - // send inactive state notification - if (mAvailableResources.size() > 0) - MessageCenterService.sendChatState(getActivity(), mUserJID, ChatState.inactive); - mComposer.resetCompose(); + // reload manually + mConversation = Conversation.loadFromUserId(ctx, mUserJID); + if (mConversation == null) { + // threads was deleted (it was a request thread) + threadId = 0; + } + processStart(); + if (threadId == 0) { + // no thread means no peer observer will be invoked + // we need to manually trigger this + ConnectedEvent connectedEvent = mServiceBus.getStickyEvent(ConnectedEvent.class); + if (connectedEvent != null) { + onConnected(connectedEvent); + } + RosterLoadedEvent rosterLoadedEvent = mServiceBus.getStickyEvent(RosterLoadedEvent.class); + if (rosterLoadedEvent != null) { + onRosterLoaded(rosterLoadedEvent); + } } + } - // unsubcribe presence notifications - unsubcribePresence(); - - // release message center - MessageCenterService.release(getActivity()); + void invalidateContact() { + Contact.invalidate(mUserJID); + reloadContact(); + } - // release audio player - if (mPlayer != null) { - stopMediaPlayerUpdater(); - mMediaPlayerMessageId = -1; - mPlayer.release(); - mPlayer = null; + void reloadContact() { + if (mConversation != null) { + // this will trigger contact reload + mConversation.setRecipient(mUserJID); } } @Override - public void onStop() { - super.onStop(); - unregisterPeerObserver(); - if (mListAdapter != null) - mListAdapter.changeCursor(null); + protected void addUsers(String[] members) { + String selfJid = Kontalk.get().getDefaultAccount().getSelfJID(); + String groupId = StringUtils.randomString(20); + String groupJid = KontalkGroupCommands.createGroupJid(groupId, selfJid); - // be sure to cancel all queries - mQueryHandler.cancelOperation(MESSAGE_LIST_QUERY_TOKEN); - mQueryHandler.cancelOperation(CONVERSATION_QUERY_TOKEN); - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (mComposer != null) { - mComposer.onDestroy(); - } - if (mAudioDialog != null) { - mAudioDialog.dismiss(); - mAudioDialog = null; + // ensure no duplicates + Set usersList = new HashSet<>(); + String userId = getUserId(); + if (!userId.equalsIgnoreCase(selfJid)) + usersList.add(userId); + for (String member : members) { + // exclude ourselves + if (!member.equalsIgnoreCase(selfJid)) + usersList.add(member); } - } - - private void pauseContentListener() { - if (mListAdapter != null) - mListAdapter.setOnContentChangedListener(null); - } - private void resumeContentListener() { - if (mListAdapter != null) - mListAdapter.setOnContentChangedListener(mContentChangedListener); + if (usersList.size() > 0) { + askGroupSubject(usersList, groupJid); + } } - public final boolean isFinishing() { - Activity activity = getActivity(); - return (activity == null || activity.isFinishing()) || isRemoving(); + private void askGroupSubject(final Set usersList, final String groupJid) { + new MaterialDialog.Builder(getContext()) + .title(R.string.title_group_subject) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .input(null, null, true, new MaterialDialog.InputCallback() { + @Override + public void onInput(@NonNull MaterialDialog dialog, CharSequence input) { + String title = !TextUtils.isEmpty(input) ? input.toString() : null; + + String[] users = usersList.toArray(new String[0]); + long groupThreadId = Conversation.initGroupChat(getContext(), + groupJid, title, users, + mComposer.getText().toString()); + + // store create group command to outbox + // NOTE: group chats can currently only be created with chat encryption enabled + boolean encrypted = Preferences.getEncryptionEnabled(getContext()); + String msgId = MessageCenterService.messageId(); + Uri cmdMsg = KontalkGroupCommands.createGroup(getContext(), + groupThreadId, groupJid, users, msgId, encrypted); + // TODO check for null + + // send create group command now + MessageCenterService.bus() + .post(new SendMessageRequest(ContentUris.parseId(cmdMsg))); + + // open the new conversation + ((ComposeMessageParent) getActivity()).loadConversation(groupThreadId, true); + } + }) + .inputRange(0, MyMessages.Groups.GROUP_SUBJECT_MAX_LENGTH) + .show(); } - private void updateUI() { - Contact contact = (mConversation != null) ? mConversation - .getContact() : null; - - boolean contactEnabled = contact != null && contact.getId() > 0; - boolean threadEnabled = (threadId > 0); + void showIdentityDialog(boolean informationOnly, int titleId) { + String fingerprint; + String uid; - if (mCallMenu != null) { - Context context = getActivity(); - // FIXME what about VoIP? - if (context != null && !context.getPackageManager().hasSystemFeature( - PackageManager.FEATURE_TELEPHONY)) { - mCallMenu.setVisible(false).setEnabled(false); - } - else { - mCallMenu.setVisible(true).setEnabled(true); - mCallMenu.setEnabled(contactEnabled); - } - mViewContactMenu.setEnabled(contactEnabled); - mDeleteThreadMenu.setEnabled(threadEnabled); + PGPPublicKeyRing publicKey = Keyring.getPublicKey(getActivity(), mUserJID, MyUsers.Keys.TRUST_UNKNOWN); + if (publicKey != null) { + PGPPublicKey pk = PGP.getMasterKey(publicKey); + fingerprint = PGP.formatFingerprint(PGP.getFingerprint(pk)); + uid = PGP.getUserId(pk, XmppStringUtils.parseDomain(mUserJID)); } - - if (mBlockMenu != null) { - Context context = getActivity(); - if (context != null && Authenticator.isSelfJID(context, mUserJID)) { - mBlockMenu.setVisible(false).setEnabled(false); - mUnblockMenu.setVisible(false).setEnabled(false); - } - else if (contact != null) { - // block/unblock - boolean blocked = contact.isBlocked(); - if (blocked) - // show warning if blocked - showWarning(getText(R.string.warning_user_blocked), null, WarningType.WARNING); - - mBlockMenu.setVisible(!blocked).setEnabled(!blocked); - mUnblockMenu.setVisible(blocked).setEnabled(blocked); - } - else { - mBlockMenu.setVisible(true).setEnabled(true); - mUnblockMenu.setVisible(true).setEnabled(true); - } + else { + // FIXME using another string + fingerprint = uid = getString(R.string.peer_unknown); } - } - boolean tryHideEmojiDrawer() { - if (mComposer.isEmojiVisible()) { - mComposer.hideEmojiDrawer(false); - return true; - } - return false; - } + SpannableStringBuilder text = new SpannableStringBuilder(); + text.append(getString(R.string.text_invitation1)) + .append('\n'); - /** The conversation list query handler. */ - // TODO convert to static class and use a weak reference to the context - private final class MessageListQueryHandler extends AsyncQueryHandler { - public MessageListQueryHandler() { - super(getActivity().getApplicationContext().getContentResolver()); + Contact c = mConversation.getContact(); + if (c != null && c.getName() != null && c.getNumber() != null) { + text.append(c.getName()) + .append(" <") + .append(c.getNumber()) + .append('>'); + } + else { + int start = text.length() - 1; + text.append(uid); + text.setSpan(MessageUtils.STYLE_BOLD, start, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } - @Override - protected synchronized void onQueryComplete(int token, Object cookie, - Cursor cursor) { - if (cursor == null || isFinishing()) { - // close cursor - if any - if (cursor != null) - cursor.close(); - - Log.e(TAG, "query aborted or error!"); - unregisterPeerObserver(); - mListAdapter.changeCursor(null); - return; - } - - switch (token) { - case MESSAGE_LIST_QUERY_TOKEN: + text.append('\n') + .append(getString(R.string.text_invitation2)) + .append('\n'); - // no messages to show - exit - if (cursor.getCount() == 0 - && (mConversation == null || - // no draft - (mConversation.getDraft() == null && - // no subscription request - mConversation.getRequestStatus() != Threads.REQUEST_WAITING && - // no text in compose entry - mComposer.getText().length() == 0))) { + int start = text.length() - 1; + text.append(fingerprint); + text.setSpan(MessageUtils.STYLE_BOLD, start, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - Log.i(TAG, "no data to view - exit"); + MaterialDialog.Builder builder = new MaterialDialog + .Builder(getActivity()) + .content(text); - // close conversation - closeConversation(); + if (informationOnly) { + builder.title(titleId); + } + else { + builder.title(titleId) + .positiveText(R.string.button_accept) + .positiveColorRes(R.color.button_success) + .negativeText(R.string.button_block) + .negativeColorRes(R.color.button_danger) + .onAny(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + // hide warning bar + hideWarning(); - } - else { - // see if we have to scroll to a specific message - int newSelectionPos = -1; - - Bundle args = myArguments(); - if (args != null) { - long msgId = args.getLong(ComposeMessage.EXTRA_MESSAGE, - -1); - if (msgId > 0) { - - cursor.moveToPosition(-1); - while (cursor.moveToNext()) { - long curId = cursor.getLong(CompositeMessage.COLUMN_ID); - if (curId == msgId) { - newSelectionPos = cursor.getPosition(); - break; - } - } - } + switch (which) { + case POSITIVE: + // trust new key + trustKeyChange(dialog.getContext(), null); + break; + case NEGATIVE: + // block user immediately + setPrivacy(dialog.getContext(), PrivacyCommand.BLOCK); + break; } - - mListAdapter.changeCursor(cursor); - if (newSelectionPos > 0) - getListView().setSelection(newSelectionPos); - - getActivity().setProgressBarIndeterminateVisibility(false); - updateUI(); - } - - break; - - case CONVERSATION_QUERY_TOKEN: - if (cursor.moveToFirst()) { - mConversation = Conversation.createFromCursor( - getActivity(), cursor); - onConversationCreated(); } - - cursor.close(); - - break; - - default: - Log.e(TAG, "onQueryComplete called with unknown token " + token); - } + }); } - } - public Conversation getConversation() { - return mConversation; + builder.show(); } - public Contact getContact() { - return (mConversation != null) ? mConversation.getContact() : null; + void trustKeyChange(@NonNull Context context, String fingerprint) { + // mark current key as trusted + if (fingerprint == null) + fingerprint = getContact().getFingerprint(); + Kontalk.get().getMessagesController() + .setTrustLevelAndRetryMessages(mUserJID, fingerprint, MyUsers.Keys.TRUST_VERIFIED); + // reload contact + invalidateContact(); } - public long getThreadId() { - return threadId; + private void showKeyWarning(int textId, final int dialogTitleId, final int dialogMessageId, final Object... data) { + final Activity context = getActivity(); + if (context != null) { + showWarning(context.getText(textId), new View.OnClickListener() { + @Override + public void onClick(View v) { + new MaterialDialog.Builder(context) + .title(dialogTitleId) + .content(dialogMessageId) + .positiveText(R.string.button_accept) + .positiveColorRes(R.color.button_success) + .neutralText(R.string.button_identity) + .negativeText(R.string.button_block) + .negativeColorRes(R.color.button_danger) + .onAny(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + switch (which) { + case POSITIVE: + // hide warning bar + hideWarning(); + // trust new key + trustKeyChange(dialog.getContext(), (String) data[0]); + break; + case NEUTRAL: + showIdentityDialog(false, dialogTitleId); + break; + case NEGATIVE: + // hide warning bar + hideWarning(); + // block user immediately + setPrivacy(dialog.getContext(), PrivacyCommand.BLOCK); + break; + } + } + }) + .show(); + } + }, WarningType.FATAL); + } } - public String getUserId() { - return mUserJID; + private void showKeyUnknownWarning(String fingerprint) { + showKeyWarning(R.string.warning_public_key_unknown, + R.string.title_public_key_unknown_warning, R.string.msg_public_key_unknown_warning, + fingerprint); } - public void setTextEntry(CharSequence text) { - mComposer.setText(text); + private void showKeyChangedWarning(String newFingerprint) { + showKeyWarning(R.string.warning_public_key_changed, + R.string.title_public_key_changed_warning, R.string.msg_public_key_changed_warning, + newFingerprint); } - @Override - public boolean onLongClick(View v) { - // this seems to be necessary... - return false; + void setVersionInfo(Context context, String version) { + if (SystemUtils.isOlderVersion(context, version)) { + showWarning(context.getText(R.string.warning_older_version), null, WarningType.WARNING); + } } - public void closeConversation() { - // main activity - if (getParentActivity() != null) { - getActivity().finish(); - } - // using fragments... - else { - ConversationList activity = (ConversationList) getActivity(); - activity.getListFragment().endConversation(this); - } + private void setLastSeenTimestamp(Context context, long stamp) { + setCurrentStatusText(MessageUtils.formatRelativeTimeSpan(context, stamp)); } - private void offlineModeWarning() { - if (Preferences.getOfflineMode(getActivity()) && !mOfflineModeWarned) { - mOfflineModeWarned = true; - Toast.makeText(getActivity(), R.string.warning_offline_mode, - Toast.LENGTH_LONG).show(); - } + void setLastSeenSeconds(Context context, long seconds) { + setCurrentStatusText(MessageUtils + .formatLastSeen(context, getContact(), seconds)); } - @Override - public void onRecordingSuccessful(File file) { - if (file != null) - sendBinaryMessage(Uri.fromFile(file), AudioDialog.DEFAULT_MIME, true, AudioComponent.class); + private void setCurrentStatusText(CharSequence statusText) { + mCurrentStatus = statusText; + if (!mIsTyping) + setStatusText(statusText); } - @Override - public void onRecordingCancel() { - mAudioDialog = null; + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onPublicKey(PublicKeyEvent event) { + if (event.id != null && event.id.equals(mKeyRequestId)) { + // reload contact + invalidateContact(); + // request presence again + requestPresence(); + } } - @Override - public void buttonClick(File audioFile, AudioContentViewControl view, long messageId) { - if (mMediaPlayerMessageId == messageId) { - switch (mStatus) { - case AudioContentView.STATUS_PLAYING: - pauseAudio(view); - break; - case AudioContentView.STATUS_PAUSED: - case AudioContentView.STATUS_ENDED: - playAudio(view, messageId); - break; + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onLastActivity(LastActivityEvent event) { + final Context context = getContext(); + if (context == null) + return; - } - } - else { - switch (mStatus) { - case AudioContentView.STATUS_IDLE: - if (prepareAudio(audioFile, view, messageId)) - playAudio(view, messageId); - break; - case AudioContentView.STATUS_ENDED: - case AudioContentView.STATUS_PLAYING: - case AudioContentView.STATUS_PAUSED: - resetAudio(mAudioControl); - if (prepareAudio(audioFile, view, messageId)) - playAudio(view, messageId); - break; + if (event.id != null && event.id.equals(mLastActivityRequestId)) { + mLastActivityRequestId = null; + // ignore last activity if we had an available presence in the meantime + if (mAvailableResources.size() == 0) { + if (event.error == null && event.idleTime >= 0) { + setLastSeenSeconds(context, event.idleTime); + } + else { + setCurrentStatusText(context.getString(R.string.seen_offline_label)); + } } } } - private boolean prepareAudio(File audioFile, final AudioContentViewControl view, final long messageId) { - if (mPlayer == null) - mPlayer = new MediaPlayer(); + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onVersion(VersionEvent event) { + Context context = getContext(); + if (context == null) + return; - stopMediaPlayerUpdater(); - try { - mPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); - mPlayer.setDataSource(audioFile.getAbsolutePath()); - mPlayer.prepare(); - - // prepare was successful - mMediaPlayerMessageId = messageId; - mAudioControl = view; - - view.prepare(mPlayer.getDuration()); - mPlayer.seekTo(view.getPosition()); - view.setProgressChangeListener(true); - mPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { - @Override - public void onCompletion(MediaPlayer mp) { - stopMediaPlayerUpdater(); - view.end(); - mPlayer.seekTo(0); - setAudioStatus(AudioContentView.STATUS_ENDED); + // compare version and show warning if needed + if (event.id != null && event.id.equals(mVersionRequestId)) { + mVersionRequestId = null; + if (event.name != null && event.name.equalsIgnoreCase(context.getString(R.string.app_name))) { + if (event.version != null) { + // cache the version + Contact.setVersion(event.jid.toString(), event.version); + setVersionInfo(context, event.version); } - }); - return true; - } - catch (IOException e) { - Toast.makeText(getActivity(), R.string.err_file_not_found, Toast.LENGTH_SHORT).show(); - return false; + } } } - @Override - public void playAudio(AudioContentViewControl view, long messageId) { - view.play(); - mPlayer.start(); - setAudioStatus(AudioContentView.STATUS_PLAYING); - startMediaPlayerUpdater(view); - } + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onUserSubscribed(UserSubscribedEvent event) { + if (!isUserId(event.jid.toString())) { + // not for us + return; + } - private void updatePosition(AudioContentViewControl view) { - view.updatePosition(mPlayer.getCurrentPosition()); + // reload contact + invalidateContact(); + // request presence + requestPresence(); } - @Override - public void pauseAudio(AudioContentViewControl view) { - view.pause(); - mPlayer.pause(); - stopMediaPlayerUpdater(); - setAudioStatus(AudioContentView.STATUS_PAUSED); + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onUserBlocked(UserBlockedEvent event) { + final Context context = getContext(); + if (context == null) + return; + + if (!isUserId(event.jid.toString())) { + // not for us + return; + } + + // reload contact + reloadContact(); + // this will update block/unblock menu items + updateUI(); + Toast.makeText(context, + R.string.msg_user_blocked, + Toast.LENGTH_LONG).show(); } - private void resetAudio(AudioContentViewControl view) { - if (view != null){ - stopMediaPlayerUpdater(); - view.end(); + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onUserUnblocked(UserUnblockedEvent event) { + final Context context = getContext(); + if (context == null) + return; + + if (!isUserId(event.jid.toString())) { + // not for us + return; } - if (mPlayer != null) - mPlayer.reset(); - mMediaPlayerMessageId = -1; + + // reload contact + reloadContact(); + // this will update block/unblock menu items + updateUI(); + // hide any block warning + // a new warning will be issued for the key if needed + hideWarning(); + // request presence subscription when unblocking + requestPresence(); + Toast.makeText(context, + R.string.msg_user_unblocked, + Toast.LENGTH_LONG).show(); } - private void setAudioStatus(int audioStatus) { - mStatus = audioStatus; + private void requestVersion(Jid jid) { + Context context = getActivity(); + if (context != null) { + mVersionRequestId = StringUtils.randomString(6); + mServiceBus.post(new VersionRequest(mVersionRequestId, jid)); + } } - @Override - public void stopAllSounds() { - resetAudio(mAudioControl); + private void requestPublicKey(Jid jid) { + Context context = getActivity(); + if (context != null) { + mKeyRequestId = StringUtils.randomString(6); + mServiceBus.post(new PublicKeyRequest(mKeyRequestId, jid)); + } } - @Override - public void onBind(long messageId, final AudioContentViewControl view) { - if (mMediaPlayerMessageId == messageId) { - mAudioControl = view; - if (mPlayer != null) { - mPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { - @Override - public void onCompletion(MediaPlayer mp) { - stopMediaPlayerUpdater(); - view.end(); - mPlayer.seekTo(0); - setAudioStatus(AudioContentView.STATUS_ENDED); - } - }); + public void onFocus() { + super.onFocus(); - view.setProgressChangeListener(true); - view.prepare(mPlayer.getDuration()); - if (mPlayer.isPlaying()) { - startMediaPlayerUpdater(view); - view.play(); - } - else { - view.pause(); - } - } + if (mUserJID != null) { + // clear chat invitation (if any) + MessagingNotification.clearChatInvitation(getActivity(), mUserJID); } } - @Override - public void onUnbind(long messageId, AudioContentViewControl view) { - if (mMediaPlayerMessageId == messageId) { - mAudioControl = null; - if (mPlayer != null) { - mPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { - @Override - public void onCompletion(MediaPlayer mp) { - mPlayer.seekTo(0); - setAudioStatus(AudioContentView.STATUS_ENDED); - } - }); - } + protected void updateUI() { + super.updateUI(); - view.setProgressChangeListener(false); - if (!MessagesProvider.exists(getActivity(), messageId)) { - resetAudio(view); - } + Contact contact = (mConversation != null) ? mConversation + .getContact() : null; + + boolean contactEnabled = contact != null && contact.getId() > 0; + if (mCallMenu != null) { + Context context = getContext(); + // FIXME what about VoIP? + if (context != null && !context.getPackageManager().hasSystemFeature( + PackageManager.FEATURE_TELEPHONY)) { + mCallMenu.setVisible(false).setEnabled(false); + } else { - stopMediaPlayerUpdater(); + mCallMenu.setVisible(true).setEnabled(true); + mCallMenu.setEnabled(contactEnabled); } + mViewContactMenu.setEnabled(contactEnabled); } - } - @Override - public boolean isPlaying() { - return mPlayer.isPlaying(); + if (mBlockMenu != null) { + Context context = getContext(); + if (context != null) { + if (Kontalk.get().getDefaultAccount().isSelfJID(mUserJID)) { + mBlockMenu.setVisible(false).setEnabled(false); + mUnblockMenu.setVisible(false).setEnabled(false); + } + else if (contact != null) { + // block/unblock + boolean blocked = contact.isBlocked(); + if (blocked) + // show warning if blocked + showWarning(context.getText(R.string.warning_user_blocked), + null, WarningType.WARNING); + + mBlockMenu.setVisible(!blocked).setEnabled(!blocked); + mUnblockMenu.setVisible(blocked).setEnabled(blocked); + } + else { + mBlockMenu.setVisible(true).setEnabled(true); + mUnblockMenu.setVisible(true).setEnabled(true); + } + } + } } @Override - public void seekTo(int position) { - mPlayer.seekTo(position); + public String getUserId() { + return mUserJID; } - private void startMediaPlayerUpdater(final AudioContentViewControl view) { - updatePosition(view); - mMediaPlayerUpdater = new Runnable() { - @Override - public void run() { - updatePosition(view); - mHandler.postDelayed(this, 100); - } - }; - mHandler.postDelayed(mMediaPlayerUpdater, 100); + @Override + protected String getDecodedPeer(CompositeMessage msg) { + return mUserPhone != null ? mUserPhone : mUserJID; } - private void stopMediaPlayerUpdater() { - if (mMediaPlayerUpdater != null) { - mHandler.removeCallbacks(mMediaPlayerUpdater); - mMediaPlayerUpdater = null; - } + @Override + protected String getDecodedName(CompositeMessage msg) { + Contact c = getContact(); + return (c != null) ? c.getName() : null; } } diff --git a/app/src/main/java/org/kontalk/ui/ComposeMessageParent.java b/app/src/main/java/org/kontalk/ui/ComposeMessageParent.java new file mode 100644 index 000000000..a9a87e8d0 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ComposeMessageParent.java @@ -0,0 +1,43 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + + +import android.net.Uri; +import androidx.annotation.Nullable; + +/** + * Interface implemented by activities that can be parent of + * {@link ComposeMessageFragment}. + * @author Daniele Ricci + */ +interface ComposeMessageParent { + + /** Sets title and subtitle. Pass null to any of them to skip. */ + void setTitle(@Nullable CharSequence title, @Nullable CharSequence subtitle); + + /** Sets the subtitle in an updating status. */ + void setUpdatingSubtitle(); + + /** Loads the given conversation, replacing the fragment as needed. */ + void loadConversation(long threadId, boolean creatingGroup); + + /** Loads the given conversation, replacing the fragment as needed. */ + void loadConversation(Uri threadUri); +} diff --git a/app/src/main/java/org/kontalk/ui/ContactInfoActivity.java b/app/src/main/java/org/kontalk/ui/ContactInfoActivity.java new file mode 100644 index 000000000..51b665a83 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ContactInfoActivity.java @@ -0,0 +1,104 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import androidx.fragment.app.Fragment; +import android.view.MenuItem; + +import org.kontalk.R; +import org.kontalk.service.msgcenter.MessageCenterService; + + +/** + * Contact information activity. + * @author Daniele Ricci + */ +public class ContactInfoActivity extends ToolbarActivity implements ContactInfoFragment.ContactInfoParent { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.contact_info_screen); + + setupToolbar(true, false); + + if (savedInstanceState == null) { + Intent i = getIntent(); + String userId = i.getStringExtra("user"); + Fragment f = ContactInfoFragment.newInstance(userId); + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment, f) + .commit(); + } + } + + @Override + protected void onPause() { + super.onPause(); + // release message center + MessageCenterService.release(this); + } + + @Override + protected void onResume() { + super.onResume(); + // hold message center + MessageCenterService.hold(this, true); + } + + /** Not used. */ + @Override + protected boolean isNormalUpNavigation() { + return false; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + public void finish() { + super.finish(); + if (!(this instanceof ContactInfoDialog)) + overridePendingTransition(R.anim.stay, R.anim.slide_down); + } + + public static void start(Activity context, Fragment parent, String userId, int requestCode) { + Intent intent = new Intent(context, ContactInfoActivity.class); + intent.putExtra("user", userId); + parent.startActivityForResult(intent, requestCode); + context.overridePendingTransition(R.anim.slide_up, R.anim.stay); + } + + @Override + public void dismiss() { + finish(); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/ContactInfoDialog.java b/app/src/main/java/org/kontalk/ui/ContactInfoDialog.java new file mode 100644 index 000000000..8000dd76b --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ContactInfoDialog.java @@ -0,0 +1,38 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import android.content.Context; +import android.content.Intent; +import androidx.fragment.app.Fragment; + + +/** + * Contact information dialog. + * @author Daniele Ricci + */ +public class ContactInfoDialog extends ContactInfoActivity { + + public static void start(Context context, Fragment parent, String userId, int requestCode) { + Intent intent = new Intent(context, ContactInfoDialog.class); + intent.putExtra("user", userId); + parent.startActivityForResult(intent, requestCode); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/ContactInfoFragment.java b/app/src/main/java/org/kontalk/ui/ContactInfoFragment.java new file mode 100644 index 000000000..73ed76735 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ContactInfoFragment.java @@ -0,0 +1,509 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.jivesoftware.smack.packet.Presence; +import org.jivesoftware.smack.util.StringUtils; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.graphics.Typeface; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.TextView; + +import pub.devrel.easypermissions.EasyPermissions; + +import org.kontalk.Kontalk; +import org.kontalk.R; +import org.kontalk.crypto.PGP; +import org.kontalk.data.Contact; +import org.kontalk.provider.MyUsers; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.service.msgcenter.event.ConnectedEvent; +import org.kontalk.service.msgcenter.event.LastActivityEvent; +import org.kontalk.service.msgcenter.event.LastActivityRequest; +import org.kontalk.service.msgcenter.event.NoPresenceEvent; +import org.kontalk.service.msgcenter.event.PresenceRequest; +import org.kontalk.service.msgcenter.event.RosterLoadedEvent; +import org.kontalk.service.msgcenter.event.UserOfflineEvent; +import org.kontalk.service.msgcenter.event.UserOnlineEvent; +import org.kontalk.ui.view.ContactInfoBanner; +import org.kontalk.util.MessageUtils; +import org.kontalk.util.Permissions; +import org.kontalk.util.SystemUtils; +import org.kontalk.util.XMPPUtils; + + +/** + * Contact information fragment + * @author Daniele Ricci + */ +public class ContactInfoFragment extends Fragment + implements Contact.ContactChangeListener, EasyPermissions.PermissionCallbacks { + + Contact mContact; + + private ContactInfoBanner mInfoBanner; + private TextView mPhoneNumber; + private ImageButton mCallButton; + private TextView mFingerprint; + private ImageButton mTrustStatus; + View mTrustButtons; + private TextView mUserId; + + /** + * Available resources. + */ + Set mAvailableResources = new HashSet<>(); + + String mLastActivityRequestId; + + private EventBus mServiceBus = MessageCenterService.bus(); + + public static ContactInfoFragment newInstance(String userId) { + ContactInfoFragment f = new ContactInfoFragment(); + Bundle data = new Bundle(); + data.putString("user", userId); + f.setArguments(data); + return f; + } + + private void loadContact(String userId) { + Context context = getContext(); + if (context == null) + return; + + mContact = Contact.findByUserId(context, userId); + + mInfoBanner.bind(context, mContact); + + String number = mContact.getNumber(); + mPhoneNumber.setText(number != null ? + MessageUtils.reformatPhoneNumber(number) : context.getString(R.string.peer_unknown)); + mCallButton.setVisibility(number != null ? View.VISIBLE : View.GONE); + + mUserId.setText(mContact.getJID()); + + String fingerprint = mContact.getFingerprint(); + if (fingerprint != null) { + mFingerprint.setText(PGP.formatFingerprint(fingerprint) + .replaceFirst(" {2}", "\n")); + mFingerprint.setTypeface(Typeface.MONOSPACE); + + int resId, textId, trustButtonsVisibility; + + if (mContact.isSelf()) { + resId = R.drawable.ic_trust_verified; + textId = R.string.trust_verified; + trustButtonsVisibility = View.GONE; + mTrustStatus.setEnabled(false); + } + else if (mContact.isKeyChanged()) { + // the key has changed and was not trusted yet + resId = R.drawable.ic_trust_unknown; + textId = R.string.trust_unknown; + trustButtonsVisibility = View.VISIBLE; + mTrustStatus.setEnabled(true); + } + else { + mTrustStatus.setEnabled(true); + + switch (mContact.getTrustedLevel()) { + case MyUsers.Keys.TRUST_UNKNOWN: + resId = R.drawable.ic_trust_unknown; + textId = R.string.trust_unknown; + trustButtonsVisibility = View.VISIBLE; + break; + case MyUsers.Keys.TRUST_IGNORED: + resId = R.drawable.ic_trust_ignored; + textId = R.string.trust_ignored; + trustButtonsVisibility = View.VISIBLE; + break; + case MyUsers.Keys.TRUST_VERIFIED: + resId = R.drawable.ic_trust_verified; + textId = R.string.trust_verified; + trustButtonsVisibility = View.GONE; + break; + default: + resId = -1; + textId = -1; + trustButtonsVisibility = View.GONE; + } + } + + mTrustButtons.setVisibility(trustButtonsVisibility); + + if (resId > 0) { + mTrustStatus.setImageResource(resId); + mTrustStatus.setVisibility(View.VISIBLE); + mTrustStatus.setContentDescription(getString(textId)); + } + else { + mTrustStatus.setImageDrawable(null); + mTrustStatus.setVisibility(View.GONE); + } + } + else { + mFingerprint.setText(context.getString(R.string.peer_unknown)); + mFingerprint.setTypeface(Typeface.DEFAULT); + mTrustStatus.setImageDrawable(null); + mTrustStatus.setVisibility(View.GONE); + } + + if (!mServiceBus.isRegistered(this)) { + mServiceBus.register(this); + } + /* + if (mReceiver == null) { + // listen to roster entry status requests + mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String jid = intent.getStringExtra(MessageCenterService.EXTRA_FROM); + boolean isSubscribed = intent + .getBooleanExtra(MessageCenterService.EXTRA_SUBSCRIBED_FROM, false) && + intent.getBooleanExtra(MessageCenterService.EXTRA_SUBSCRIBED_TO, false); + // TODO update something + // TODO mInfoBanner.bind(context, mContact); + } + }; + + IntentFilter filter = new IntentFilter(MessageCenterService.ACTION_ROSTER_STATUS); + mLocalBroadcastManager.registerReceiver(mReceiver, filter); + } + */ + } + + // TODO version event + // TODO blocked event + // TODO unblocked event + // TODO subscribed event + // TODO roster status event + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN_ORDERED) + public void onConnected(ConnectedEvent event) { + // reset available resources list + mAvailableResources.clear(); + // reset any pending request + mLastActivityRequestId = null; + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN_ORDERED) + public void onRosterLoaded(RosterLoadedEvent event) { + requestPresence(); + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onUserOnline(UserOnlineEvent event) { + final Context context = getContext(); + if (context == null) + return; + + if (!mContact.getJID().equals(event.jid.asBareJid().toString())) { + // not for us + return; + } + + mAvailableResources.add(event.jid.toString()); + + CharSequence statusText; + + boolean isAway = (event.mode == Presence.Mode.away); + if (isAway) { + statusText = context.getString(R.string.seen_away_label); + } + else { + statusText = context.getString(R.string.seen_online_label); + } + + mInfoBanner.setSummary(statusText); + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onUserOffline(UserOfflineEvent event) { + final Context context = getContext(); + if (context == null) + return; + + if (!mContact.getJID().equals(event.jid.asBareJid().toString())) { + // not for us + return; + } + + CharSequence statusText = null; + + boolean removed = mAvailableResources.remove(event.jid.toString()); + + /* + * All available resources have gone. Mark + * the user as offline immediately and use the + * timestamp provided with the stanza (if any). + */ + if (mAvailableResources.size() == 0) { + if (removed) { + // resource was removed now, mark as just offline + statusText = formatLastSeenText(context, + context.getText(R.string.seen_moment_ago_label)); + } + else { + // resource is offline, request last activity + if (mContact.getLastSeen() > 0) { + setLastSeenTimestamp(context, mContact.getLastSeen()); + } + else if (mLastActivityRequestId == null) { + mLastActivityRequestId = StringUtils.randomString(6); + mServiceBus.post(new LastActivityRequest(mLastActivityRequestId, event.jid.asBareJid())); + } + } + } + + if (statusText != null) { + mInfoBanner.setSummary(statusText); + } + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onNoUserPresence(NoPresenceEvent event) { + final Context context = getContext(); + if (context == null) + return; + + if (!mContact.getJID().equals(event.jid.asBareJid().toString())) { + // not for us + return; + } + + // no roster entry found, awaiting subscription or not subscribed + mInfoBanner.setSummary(context.getString(R.string.invitation_sent_label)); + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onLastActivity(LastActivityEvent event) { + final Context context = getContext(); + if (context == null) + return; + + if (event.id != null && event.id.equals(mLastActivityRequestId)) { + mLastActivityRequestId = null; + // ignore last activity if we had an available presence in the meantime + if (mAvailableResources.size() == 0) { + if (event.error == null && event.idleTime >= 0) { + setLastSeenSeconds(context, event.idleTime); + } + else { + mInfoBanner.setSummary(context.getString(R.string.seen_offline_label)); + } + } + } + } + + private void setLastSeenTimestamp(Context context, long stamp) { + mInfoBanner.setSummary(formatLastSeenText(context, + MessageUtils.formatRelativeTimeSpan(context, stamp))); + } + + void setLastSeenSeconds(Context context, long seconds) { + mInfoBanner.setSummary(formatLastSeenText(context, + MessageUtils.formatLastSeen(context, mContact, seconds))); + } + + private CharSequence formatLastSeenText(Context context, CharSequence text) { + return context.getString(R.string.contactinfo_last_seen, text); + } + + private void requestPresence() { + final Context context = getContext(); + if (context == null) + return; + + // do not request presence for domain JIDs + if (!XMPPUtils.isDomainJID(mContact.getJID())) { + mServiceBus.post(new PresenceRequest(mContact.getJID())); + } + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.contact_info, container, false); + + mInfoBanner = view.findViewById(R.id.contact_info); + mPhoneNumber = view.findViewById(R.id.contact_phone); + mCallButton = view.findViewById(R.id.btn_call); + mCallButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + callContact(); + } + }); + + mFingerprint = view.findViewById(R.id.fingerprint); + mTrustStatus = view.findViewById(R.id.btn_trust_status); + mUserId = view.findViewById(R.id.userid); + + mTrustButtons = view.findViewById(R.id.trust_button_bar); + mTrustStatus.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (!mContact.isSelf()) { + // TODO animate this + mTrustButtons.setVisibility(mTrustButtons.getVisibility() == View.VISIBLE ? + View.GONE : View.VISIBLE); + } + } + }); + + view.findViewById(R.id.btn_ignore).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + trustKey(mContact.getFingerprint(), MyUsers.Keys.TRUST_IGNORED); + } + }); + view.findViewById(R.id.btn_refuse).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + trustKey(mContact.getFingerprint(), MyUsers.Keys.TRUST_UNKNOWN); + } + }); + view.findViewById(R.id.btn_accept).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + trustKey(mContact.getFingerprint(), MyUsers.Keys.TRUST_VERIFIED); + } + }); + + return view; + } + + void callContact() { + final Context context = getContext(); + + if (Permissions.canCallPhone(context)) { + doCallContact(); + } + else if (EasyPermissions.permissionPermanentlyDenied(this, Manifest.permission.CALL_PHONE)) { + doDialContact(); + } + else { + Permissions.requestCallPhone(this); + } + } + + private void doCallContact() { + SystemUtils.call(getContext(), mContact.getNumber()); + } + + private void doDialContact() { + SystemUtils.dial(getContext(), mContact.getNumber()); + } + + @Override + public void onPermissionsGranted(int requestCode, @NonNull List perms) { + if (perms.contains(Manifest.permission.CALL_PHONE)) { + doCallContact(); + } + } + + @Override + public void onPermissionsDenied(int requestCode, @NonNull List perms) { + if (perms.contains(Manifest.permission.CALL_PHONE)) { + doDialContact(); + } + } + + void trustKey(String fingerprint, int trustLevel) { + String jid = mContact.getJID(); + Kontalk.get().getMessagesController() + .setTrustLevelAndRetryMessages(jid, fingerprint, trustLevel); + Contact.invalidate(jid); + reload(); + } + + @Override + public void onContactInvalidated(String userId) { + Activity context = getActivity(); + if (context != null) { + context.runOnUiThread(new Runnable() { + @Override + public void run() { + // just reload + reload(); + } + }); + } + } + + void reload() { + // reload conversation data + Bundle data = getArguments(); + String userId = data.getString("user"); + loadContact(userId); + } + + @Override + public void onResume() { + super.onResume(); + reload(); + } + + @Override + public void onPause() { + super.onPause(); + mServiceBus.unregister(this); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (!(context instanceof ContactInfoParent)) + throw new IllegalArgumentException("parent activity must implement " + + ContactInfoParent.class.getSimpleName()); + } + + @Override + public void onDetach() { + super.onDetach(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this); + } + + public interface ContactInfoParent { + + void dismiss(); + + } + +} diff --git a/app/src/main/java/org/kontalk/ui/ContactsListActivity.java b/app/src/main/java/org/kontalk/ui/ContactsListActivity.java index 608674f2b..79d32ad6b 100644 --- a/app/src/main/java/org/kontalk/ui/ContactsListActivity.java +++ b/app/src/main/java/org/kontalk/ui/ContactsListActivity.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,65 +18,147 @@ package org.kontalk.ui; -import java.text.Collator; import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; import java.util.List; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.Toast; + +import org.kontalk.Kontalk; import org.kontalk.R; -import org.kontalk.authenticator.Authenticator; import org.kontalk.data.Contact; -import org.kontalk.message.TextComponent; import org.kontalk.provider.MyMessages.Threads; import org.kontalk.service.msgcenter.MessageCenterService; -import org.kontalk.sync.SyncAdapter; import org.kontalk.ui.view.ContactPickerListener; +import org.kontalk.util.Permissions; import org.kontalk.util.Preferences; -import android.content.ComponentName; -import android.content.Intent; -import android.content.pm.ResolveInfo; -import android.os.Bundle; -import android.os.Parcelable; -import android.support.v7.app.ActionBarActivity; -import android.view.Menu; -import android.view.MenuItem; -import android.view.Window; -import android.widget.Toast; - -public class ContactsListActivity extends ActionBarActivity - implements ContactsSyncActivity, ContactPickerListener { +public class ContactsListActivity extends ToolbarActivity + implements ContactPickerListener { public static final String TAG = ContactsListActivity.class.getSimpleName(); - private MenuItem mSyncButton; + public static final String MODE_MULTI_SELECT = "org.kontalk.contacts.MULTI_SELECT"; + public static final String MODE_ADD_USERS = "org.kontalk.contacts.ADD_USERS"; + public static final String MODE_RECENTS = "org.kontalk.contacts.RECENTS"; + + private static final int VIEW_MODE_CONTACTS = 0; + private static final int VIEW_MODE_RECENTS = 1; private ContactsListFragment mFragment; + private MenuItem mSwitchContacts; + private MenuItem mSwitchRecents; + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - supportRequestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); setContentView(R.layout.contacts_list_screen); - //setSupportProgressBarIndeterminate(true); - // HACK this is for crappy honeycomb :) - setSupportProgressBarIndeterminateVisibility(false); + setupToolbar(true, true); - mFragment = (ContactsListFragment) getSupportFragmentManager() + boolean multiselect = getIntent().getBooleanExtra(MODE_MULTI_SELECT, false); + if (multiselect) { + boolean addUsers = getIntent().getBooleanExtra(MODE_ADD_USERS, false); + // FIXME using another string + setTitle(addUsers ? R.string.menu_invite_group : R.string.action_compose_group); + } + + boolean recents = getIntent().getBooleanExtra(MODE_RECENTS, false); + if (recents) { + setTitle(R.string.contacts_list_recents_title); + } + else { + setTitle(R.string.contacts_list_title); + } + + if (savedInstanceState == null) { + mFragment = ContactsListFragment.newInstance(multiselect, recents); + getSupportFragmentManager().beginTransaction() + .add(R.id.fragment_contacts_list, mFragment) + .commitAllowingStateLoss(); + } + else { + mFragment = (ContactsListFragment) getSupportFragmentManager() .findFragmentById(R.id.fragment_contacts_list); + } if (!getIntent().getBooleanExtra("picker", false)) getSupportActionBar().setDisplayHomeAsUpEnabled(true); - if (!Preferences.getContactsListVisited(this)) + if (!Preferences.getContactsListVisited()) Toast.makeText(this, R.string.msg_do_refresh, Toast.LENGTH_LONG).show(); } + @Override + public boolean onCreateOptionsMenu(Menu menu) { + if (isRecentsMode()) { + getMenuInflater().inflate(R.menu.contacts_list_parent_menu, menu); + mSwitchContacts = menu.findItem(R.id.menu_switch_contacts); + mSwitchRecents = menu.findItem(R.id.menu_switch_recents); + } + + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + if (isRecentsMode()) { + int viewMode = getCurrentViewMode(); + mSwitchContacts.setVisible(viewMode == VIEW_MODE_RECENTS); + mSwitchRecents.setVisible(viewMode == VIEW_MODE_CONTACTS); + } + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_switch_contacts: + switchToContacts(); + return true; + + case R.id.menu_switch_recents: + switchToRecents(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void switchToContacts() { + setTitle(R.string.contacts_list_title); + replaceView(false); + } + + private void switchToRecents() { + setTitle(R.string.contacts_list_recents_title); + replaceView(true); + } + + private void replaceView(boolean recents) { + boolean multiselect = getIntent().getBooleanExtra(MODE_MULTI_SELECT, false); + mFragment = ContactsListFragment.newInstance(multiselect, recents); + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_contacts_list, mFragment) + .commitAllowingStateLoss(); + } + + @Override + protected void onStart() { + super.onStart(); + if (!Permissions.canReadContacts(this)) { + Toast.makeText(this, R.string.warn_contacts_denied, Toast.LENGTH_LONG).show(); + } + } + @Override protected void onPause() { super.onPause(); @@ -88,44 +170,29 @@ protected void onPause() { protected void onResume() { super.onResume(); - if (Authenticator.getDefaultAccount(this) == null) { + if (Kontalk.get().getDefaultAccount() == null) { NumberValidation.start(this); finish(); return; } // hold message center - MessageCenterService.hold(this); + MessageCenterService.hold(this, true); mFragment.startQuery(); } - @Override - public synchronized boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.contacts_list_menu, menu); - mSyncButton = menu.findItem(R.id.menu_refresh); - mSyncButton.setVisible(!SyncAdapter.isActive(this)); - return true; + private boolean isRecentsMode() { + return getIntent().getBooleanExtra(MODE_RECENTS, false); } - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - startActivity(new Intent(this, ConversationList.class)); - return true; - - case R.id.menu_refresh: - startSync(true); - return true; - - case R.id.menu_invite: - startInvite(); - return true; - } + private int getCurrentViewMode() { + return mFragment.isRecentsMode() ? VIEW_MODE_RECENTS : VIEW_MODE_CONTACTS; + } - return super.onOptionsItemSelected(item); + @Override + protected boolean isNormalUpNavigation() { + return false; } /** Called when a contact has been selected from a {@link ContactsListFragment}. */ @@ -136,88 +203,15 @@ public void onContactSelected(ContactsListFragment fragment, Contact contact) { finish(); } - private void startSync(boolean errorWarning) { - if (MessageCenterService.isNetworkConnectionAvailable(this)) { - if (SyncAdapter.requestSync(this, true)) - setSyncing(true); - } - else if (errorWarning) { - Toast.makeText(this, R.string.err_sync_nonetwork, Toast.LENGTH_LONG).show(); - } - } - @Override - public void setSyncing(boolean syncing) { - if (mSyncButton != null) - mSyncButton.setVisible(!syncing); - setSupportProgressBarIndeterminateVisibility(syncing); - } - - private void startInvite() { - Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType(TextComponent.MIME_TYPE); - shareIntent.putExtra(Intent.EXTRA_TEXT, getString(R.string.text_invite_message)); - - List resInfo = getPackageManager().queryIntentActivities(shareIntent, 0); - // having size=1 means that we are the only handlers - if (resInfo != null && resInfo.size() > 1) { - List targets = new ArrayList(); - - for (ResolveInfo resolveInfo : resInfo) { - String packageName = resolveInfo.activityInfo.packageName; - - if (!getPackageName().equals(packageName)) { - // copy intent and add resolved info - Intent targetShareIntent = new Intent(shareIntent); - targetShareIntent - .setPackage(packageName) - .setComponent(new ComponentName( - packageName, resolveInfo.activityInfo.name)) - .putExtra("org.kontalk.invite.label", resolveInfo.loadLabel(getPackageManager())); - - targets.add(targetShareIntent); - } - } - - // initial intents are added before of the main intent, so we remove the last one here - Intent chooser = Intent.createChooser(targets.remove(targets.size() - 1), getString(R.string.menu_invite)); - Collections.sort(targets, new DisplayNameComparator()); - // remove custom extras - for (Intent intent : targets) - intent.removeExtra("org.kontalk.invite.label"); - - Parcelable[] extraIntents = new Parcelable[targets.size()]; - targets.toArray(extraIntents); - chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, extraIntents); - - startActivity(chooser); - } - - else { - // no activity to handle invitation - Toast.makeText(this, R.string.warn_invite_no_app, - Toast.LENGTH_SHORT).show(); - } - } - - public static class DisplayNameComparator implements - Comparator { - public DisplayNameComparator() { - mCollator.setStrength(Collator.PRIMARY); - } - - public final int compare(Intent a, Intent b) { - CharSequence sa = a.getCharSequenceExtra("org.kontalk.invite.label"); - if (sa == null) - sa = a.getComponent().getClassName(); - CharSequence sb = b.getCharSequenceExtra("org.kontalk.invite.label"); - if (sb == null) - sb = b.getComponent().getClassName(); - - return mCollator.compare(sa.toString(), sb.toString()); - } - - private final Collator mCollator = Collator.getInstance(); + public void onContactsSelected(ContactsListFragment fragment, List contacts) { + Intent i = new Intent(Intent.ACTION_PICK); + ArrayList uris = new ArrayList<>(contacts.size()); + for (Contact contact : contacts) + uris.add(Threads.getUri(contact.getJID())); + i.putParcelableArrayListExtra("org.kontalk.contacts", uris); + setResult(RESULT_OK, i); + finish(); } } diff --git a/app/src/main/java/org/kontalk/ui/ContactsListFragment.java b/app/src/main/java/org/kontalk/ui/ContactsListFragment.java index bb9f669bc..e3b5eab5a 100644 --- a/app/src/main/java/org/kontalk/ui/ContactsListFragment.java +++ b/app/src/main/java/org/kontalk/ui/ContactsListFragment.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,57 +18,149 @@ package org.kontalk.ui; -import org.kontalk.R; -import org.kontalk.data.Contact; -import org.kontalk.sync.SyncAdapter; -import org.kontalk.ui.adapter.ContactsListAdapter; -import org.kontalk.ui.view.ContactPickerListener; -import org.kontalk.ui.view.ContactsListItem; -import org.kontalk.util.RunnableBroadcastReceiver; +import java.text.Collator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import android.annotation.SuppressLint; import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; import android.content.IntentFilter; +import android.content.pm.ResolveInfo; import android.database.Cursor; import android.os.Bundle; import android.os.Handler; -import android.support.v4.app.ListFragment; -import android.support.v4.content.LocalBroadcastManager; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.fragment.app.ListFragment; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import android.util.SparseBooleanArray; import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import lb.library.PinnedHeaderListView; + +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.message.TextComponent; +import org.kontalk.sync.SyncAdapter; +import org.kontalk.ui.adapter.ContactsListAdapter; +import org.kontalk.ui.view.ContactPickerListener; +import org.kontalk.ui.view.ContactsListItem; +import org.kontalk.util.DataUtils; +import org.kontalk.util.RunnableBroadcastReceiver; +import org.kontalk.util.SystemUtils; /** Contacts list selection fragment. */ -public class ContactsListFragment extends ListFragment - implements ContactsListAdapter.OnContentChangedListener { +public class ContactsListFragment extends ListFragment implements + ContactsListAdapter.OnContentChangedListener, + ContactsSyncer { + + private static final int MAX_RECENT_CONVERSATIONS = 25; private Cursor mCursor; private ContactsListAdapter mListAdapter; + private SwipeRefreshLayout mRefresher; private LocalBroadcastManager mBroadcastManager; private RunnableBroadcastReceiver mSyncMonitor; private Handler mHandler; + private MenuItem mSyncButton; + + private boolean mMultiselect; + private boolean mRecents; + private final RunnableBroadcastReceiver.ActionRunnable mPostSyncAction = new RunnableBroadcastReceiver.ActionRunnable() { public void run(String action) { if (SyncAdapter.ACTION_SYNC_START.equals(action)) { - ((ContactsSyncActivity) getActivity()).setSyncing(true); + setSyncing(true); } else if (SyncAdapter.ACTION_SYNC_FINISH.equals(action)) { startQuery(); - ((ContactsSyncActivity) getActivity()).setSyncing(false); + setSyncing(false); } } }; + public static ContactsListFragment newInstance(boolean multiselect, boolean recents) { + ContactsListFragment f = new ContactsListFragment(); + Bundle args = new Bundle(); + args.putBoolean("multiselect", multiselect); + args.putBoolean("recents", recents); + f.setArguments(args); + return f; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + if (savedInstanceState == null) { + mMultiselect = getArguments().getBoolean("multiselect", false); + mRecents = getArguments().getBoolean("recents", false); + } + else { + mMultiselect = savedInstanceState.getBoolean("multiselect", false); + mRecents = savedInstanceState.getBoolean("recents", false); + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean("multiselect", mMultiselect); + outState.putBoolean("recents", mRecents); + } + @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return inflater.inflate(R.layout.contacts_list, container, false); } + // lint bug: for setChoiceMode which is actually available in API level 9 + @SuppressLint("NewApi") + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + PinnedHeaderListView list = (PinnedHeaderListView) getListView(); + + View pinnedHeaderView = LayoutInflater.from(view.getContext()) + .inflate(R.layout.pinned_header_listview_side_header, list, false); + list.setPinnedHeaderView(pinnedHeaderView); + list.setEmptyView(view.findViewById(android.R.id.empty)); + list.setChoiceMode(mMultiselect ? ListView.CHOICE_MODE_MULTIPLE : ListView.CHOICE_MODE_NONE); + + mRefresher = view.findViewById(R.id.refresher); + mRefresher.setEnabled(false); + + TextView emptyText = view.findViewById(android.R.id.empty); + if (isRecentsMode()) { + emptyText.setText(R.string.text_contacts_recents_empty); + } + else { + emptyText.setText(R.string.text_contacts_empty); + } + } + @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); @@ -76,6 +168,11 @@ public void onActivityCreated(Bundle savedInstanceState) { Activity parent = getActivity(); mListAdapter = new ContactsListAdapter(parent, getListView()); + mListAdapter.setPinnedHeader(parent); + PinnedHeaderListView list = (PinnedHeaderListView) getListView(); + list.setEnableHeaderTransparencyChanges(false); + list.setOnScrollListener(mListAdapter); + mListAdapter.setOnContentChangedListener(this); setListAdapter(mListAdapter); @@ -84,8 +181,9 @@ public void onActivityCreated(Bundle savedInstanceState) { // retain current sync state to hide the refresh button and start indeterminate progress registerSyncReceiver(); - if (SyncAdapter.isActive(parent)) - ((ContactsSyncActivity) parent).setSyncing(true); + if (SyncAdapter.isActive(parent)) { + setSyncing(true); + } } @Override @@ -116,11 +214,95 @@ public void onResume() { } @Override - public void onListItemClick(ListView l, View v, int position, long id) { + public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.contacts_list_menu, menu); + mSyncButton = menu.findItem(R.id.menu_refresh); + + // multiselect mode needs the confirm action + // we can do this here because it's not dynamic + menu.findItem(R.id.menu_compose).setVisible(mMultiselect); + menu.findItem(R.id.menu_invite).setVisible(!mMultiselect); + + super.onCreateOptionsMenu(menu, inflater); + } + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + Context ctx = getContext(); + if (ctx != null) { + mSyncButton.setVisible(!SyncAdapter + .isActive(ctx) && !mMultiselect && !mRecents); + } + super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_compose: + // using clone because listview returns its original copy + openSelectedContacts(DataUtils + .cloneSparseBooleanArray(getListView().getCheckedItemPositions())); + return true; + + case R.id.menu_refresh: + startSync(true); + return true; + + case R.id.menu_invite: + startInvite(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + public boolean isRecentsMode() { + return mRecents; + } + + private void openSelectedContacts(SparseBooleanArray checked) { + List selected = new ArrayList<>(checked.size()); + for (int i = 0, c = mListAdapter.getCount(); i < c; ++i) { + if (checked.get(i)) { + Cursor cursor = (Cursor) mListAdapter.getItem(i); + Contact contact = Contact.fromUsersCursor(getContext(), cursor); + selected.add(contact); + } + } ContactPickerListener parent = (ContactPickerListener) getActivity(); + if (parent != null) { + parent.onContactsSelected(this, selected); + } + } + + @Override + public void onListItemClick(ListView l, View v, int position, long id) { + if (!mMultiselect) { + ContactPickerListener parent = (ContactPickerListener) getActivity(); + if (parent != null) { + parent.onContactSelected(this, ((ContactsListItem) v).getContact()); + } + } + } + + @Override + public void startSync(boolean errorWarning) { + Activity activity = getActivity(); + if (SystemUtils.isNetworkConnectionAvailable(activity)) { + if (SyncAdapter.requestSync(activity, true)) + setSyncing(true); + } + else if (errorWarning) { + Toast.makeText(activity, R.string.err_sync_nonetwork, Toast.LENGTH_LONG).show(); + } + } - if (parent != null) - parent.onContactSelected(this, ((ContactsListItem) v).getContact()); + @Override + public void setSyncing(boolean syncing) { + mRefresher.setRefreshing(syncing); + if (mSyncButton != null) + mSyncButton.setVisible(!syncing && !mMultiselect); } private void registerSyncReceiver() { @@ -135,8 +317,17 @@ private void registerSyncReceiver() { } public void startQuery() { - mCursor = Contact.queryContacts(getActivity()); - mListAdapter.changeCursor(mCursor); + final Context context = getContext(); + if (context != null) { + if (mRecents) { + mCursor = Contact.queryRecentThreads(context, MAX_RECENT_CONVERSATIONS); + } + else { + mCursor = Contact.queryContacts(context); + } + + mListAdapter.changeCursor(mCursor); + } } @Override @@ -144,4 +335,74 @@ public void onContentChanged(ContactsListAdapter adapter) { startQuery(); } + private void startInvite() { + Context ctx = getActivity(); + Intent shareIntent = SystemUtils.externalIntent(Intent.ACTION_SEND); + shareIntent.setType(TextComponent.MIME_TYPE); + shareIntent.putExtra(Intent.EXTRA_TEXT, getString(R.string.text_invite_message)); + + List resInfo = ctx.getPackageManager().queryIntentActivities(shareIntent, 0); + // having size=1 means that we are the only handlers + if (resInfo != null && resInfo.size() > 1) { + List targets = new ArrayList<>(); + + for (ResolveInfo resolveInfo : resInfo) { + String packageName = resolveInfo.activityInfo.packageName; + + if (!ctx.getPackageName().equals(packageName)) { + // copy intent and add resolved info + Intent targetShareIntent = new Intent(shareIntent); + targetShareIntent + .setPackage(packageName) + .setComponent(new ComponentName( + packageName, resolveInfo.activityInfo.name)) + .putExtra("org.kontalk.invite.label", resolveInfo.activityInfo.loadLabel(ctx.getPackageManager())); + + targets.add(targetShareIntent); + } + } + + if (targets.size() > 0) { + // initial intents are added before the main intent, so we remove the last one here + Intent chooser = Intent.createChooser(targets.remove(targets.size() - 1), getString(R.string.menu_invite)); + if (targets.size() > 0) { + Collections.sort(targets, new DisplayNameComparator()); + // remove custom extras + for (Intent intent : targets) + intent.removeExtra("org.kontalk.invite.label"); + + Parcelable[] extraIntents = targets.toArray(new Parcelable[0]); + chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, extraIntents); + } + + startActivity(chooser); + return; + } + } + + // no activity to handle invitation + Toast.makeText(ctx, R.string.warn_invite_no_app, + Toast.LENGTH_SHORT).show(); + } + + static class DisplayNameComparator implements + Comparator { + DisplayNameComparator() { + mCollator.setStrength(Collator.PRIMARY); + } + + public final int compare(Intent a, Intent b) { + CharSequence sa = a.getCharSequenceExtra("org.kontalk.invite.label"); + if (sa == null) + sa = a.getComponent().getClassName(); + CharSequence sb = b.getCharSequenceExtra("org.kontalk.invite.label"); + if (sb == null) + sb = b.getComponent().getClassName(); + + return mCollator.compare(sa.toString(), sb.toString()); + } + + private final Collator mCollator = Collator.getInstance(); + } + } diff --git a/app/src/main/java/org/kontalk/ui/ContactsSyncActivity.java b/app/src/main/java/org/kontalk/ui/ContactsSyncActivity.java deleted file mode 100644 index 1bdc12110..000000000 --- a/app/src/main/java/org/kontalk/ui/ContactsSyncActivity.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.ui; - - -/** Interface for activities embedding a {@link ContactsListFragment}. */ -public interface ContactsSyncActivity { - - /** Alerts the user in some way that a contacts sync is under way. */ - public void setSyncing(boolean syncing); -} diff --git a/app/src/main/java/org/kontalk/ui/ContactsSyncer.java b/app/src/main/java/org/kontalk/ui/ContactsSyncer.java new file mode 100644 index 000000000..72a74ed10 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ContactsSyncer.java @@ -0,0 +1,30 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + + +/** Interface for activities embedding a {@link ContactsListFragment}. */ +public interface ContactsSyncer { + + /** Alerts the user in some way that a contacts sync is under way. */ + void setSyncing(boolean syncing); + + /** Starts a sync. */ + void startSync(boolean errorWarning); +} diff --git a/app/src/main/java/org/kontalk/ui/ConversationList.java b/app/src/main/java/org/kontalk/ui/ConversationList.java deleted file mode 100644 index a41d9e34b..000000000 --- a/app/src/main/java/org/kontalk/ui/ConversationList.java +++ /dev/null @@ -1,413 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.ui; - -import org.kontalk.BuildConfig; -import org.kontalk.R; -import org.kontalk.authenticator.Authenticator; -import org.kontalk.authenticator.LegacyAuthentication; -import org.kontalk.data.Contact; -import org.kontalk.data.Conversation; -import org.kontalk.provider.MessagesProvider; -import org.kontalk.provider.MyMessages.Threads; -import org.kontalk.service.msgcenter.MessageCenterService; -import org.kontalk.sync.SyncAdapter; -import org.kontalk.sync.Syncer; -import org.kontalk.ui.view.ContactPickerListener; -import org.kontalk.util.MessageUtils; -import org.kontalk.util.Preferences; -import org.kontalk.util.XMPPUtils; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.app.Activity; -import android.app.AlertDialog; -import android.app.Dialog; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.IntentFilter; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.support.v4.app.FragmentTransaction; -import android.support.v4.content.LocalBroadcastManager; -import android.support.v7.app.ActionBarActivity; -import android.text.InputType; -import android.widget.ListAdapter; -import android.widget.Toast; - - -/** - * The conversations list activity. - * @author Daniele Ricci - * @version 1.0 - */ -public class ConversationList extends ActionBarActivity - implements ContactsSyncActivity, ContactPickerListener { - public static final String TAG = ConversationList.class.getSimpleName(); - - private ConversationListFragment mFragment; - - private LockedProgressDialog mUpgradeProgress; - private BroadcastReceiver mUpgradeReceiver; - - private static final int REQUEST_CONTACT_PICKER = 7720; - - private static final int DIALOG_AUTH_ERROR_WARNING = 1; - - private static final String ACTION_AUTH_ERROR_WARNING = "org.kontalk.AUTH_ERROR_WARN"; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.conversation_list_screen); - - mFragment = (ConversationListFragment) getSupportFragmentManager() - .findFragmentById(R.id.fragment_conversation_list); - - if (!xmppUpgrade()) - handleIntent(getIntent()); - } - - /** Big upgrade: asymmetric key encryption (for XMPP). */ - private boolean xmppUpgrade() { - AccountManager am = (AccountManager) getSystemService(Context.ACCOUNT_SERVICE); - Account account = Authenticator.getDefaultAccount(am); - if (account != null) { - if (!Authenticator.hasPersonalKey(am, account)) { - // first of all, disable offline mode - Preferences.setOfflineMode(this, false); - - String name = Authenticator.getDefaultDisplayName(this); - if (name == null || name.length() == 0) { - // ask for user name - askForPersonalName(); - } - else { - // proceed to upgrade immediately - proceedXmppUpgrade(name); - } - - return true; - } - } - - return false; - } - - private void askForPersonalName() { - DialogInterface.OnClickListener okListener = new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - // no key pair found, generate a new one - if (BuildConfig.DEBUG) { - Toast.makeText(ConversationList.this, - R.string.msg_generating_keypair, Toast.LENGTH_LONG).show(); - } - - String name = InputDialog - .getInputText((Dialog) dialog) - .toString(); - - // upgrade account - proceedXmppUpgrade(name); - } - }; - - DialogInterface.OnCancelListener cancelListener = new DialogInterface.OnCancelListener() { - public void onCancel(DialogInterface dialog) { - new AlertDialog.Builder(ConversationList.this) - .setTitle(R.string.title_no_personal_key) - .setMessage(R.string.msg_no_personal_key) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - }; - - new InputDialog.Builder(this, - InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PERSON_NAME) - .setTitle(R.string.title_no_name) - .setMessage(R.string.msg_no_name) - .setPositiveButton(android.R.string.ok, okListener) - .setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - dialog.cancel(); - } - }) - .setOnCancelListener(cancelListener) - .show(); - } - - private void proceedXmppUpgrade(String name) { - // start progress dialog - mUpgradeProgress = new LockedProgressDialog(this); - mUpgradeProgress.setIndeterminate(true); - mUpgradeProgress.setMessage(getString(R.string.msg_xmpp_upgrading)); - mUpgradeProgress.show(); - - // setup operation completed received - mUpgradeReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - LocalBroadcastManager lbm = LocalBroadcastManager - .getInstance(getApplicationContext()); - lbm.unregisterReceiver(mUpgradeReceiver); - mUpgradeReceiver = null; - - // force contact list update - SyncAdapter.requestSync(ConversationList.this, true); - - if (mUpgradeProgress != null) { - mUpgradeProgress.dismiss(); - mUpgradeProgress = null; - } - } - }; - IntentFilter filter = new IntentFilter(MessageCenterService.ACTION_REGENERATE_KEYPAIR); - LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(this); - lbm.registerReceiver(mUpgradeReceiver, filter); - - LegacyAuthentication.doUpgrade(getApplicationContext(), name); - } - - /** Called when a new intent is sent to the activity (if already started). */ - @Override - protected void onNewIntent(Intent intent) { - handleIntent(intent); - - ConversationListFragment fragment = getListFragment(); - fragment.startQuery(); - } - - @Override - protected Dialog onCreateDialog(int id, Bundle args) { - if (id == DIALOG_AUTH_ERROR_WARNING) { - - return new AlertDialog.Builder(this) - .setTitle(R.string.title_auth_error) - .setMessage(R.string.msg_auth_error) - .setPositiveButton(android.R.string.ok, null) - .create(); - - } - - return super.onCreateDialog(id, args); - } - - private void handleIntent(Intent intent) { - if (intent != null) { - String action = intent.getAction(); - - if (ACTION_AUTH_ERROR_WARNING.equals(action)) { - showDialog(DIALOG_AUTH_ERROR_WARNING); - } - - // this is for intents coming from the world, forwarded by ComposeMessage - else { - boolean actionView = Intent.ACTION_VIEW.equals(action); - if (actionView || ComposeMessage.ACTION_VIEW_USERID.equals(action)) { - Uri uri = null; - - if (actionView) { - Cursor c = getContentResolver().query(intent.getData(), - new String[]{Syncer.DATA_COLUMN_PHONE}, - null, null, null); - if (c.moveToFirst()) { - String phone = c.getString(0); - String userJID = XMPPUtils.createLocalJID(this, - MessageUtils.sha1(phone)); - uri = Threads.getUri(userJID); - } - c.close(); - } else { - uri = intent.getData(); - } - - if (uri != null) - openConversation(uri); - } - } - } - } - - @Override - public boolean onSearchRequested() { - ConversationListFragment fragment = getListFragment(); - - ListAdapter list = fragment.getListAdapter(); - // no data found - if (list == null || list.getCount() == 0) - return false; - - startSearch(null, false, null, false); - return true; - } - - @Override - public void onBackPressed() { - ComposeMessageFragment f = (ComposeMessageFragment) getSupportFragmentManager() - .findFragmentById(R.id.fragment_compose_message); - if (f == null || !f.tryHideEmojiDrawer()) - super.onBackPressed(); - } - - @Override - public void onResume() { - super.onResume(); - - final Context context = getApplicationContext(); - new Thread(new Runnable() { - @Override - public void run() { - // mark all messages as old - MessagesProvider.markAllThreadsAsOld(context); - // update notification - MessagingNotification.updateMessagesNotification(context, false); - } - }).start(); - - if (Authenticator.getDefaultAccount(this) == null) { - NumberValidation.start(this); - finish(); - } - else { - // hold message center - MessageCenterService.hold(this); - } - } - - @Override - protected void onPause() { - super.onPause(); - // release message center - MessageCenterService.release(this); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - // contact chooser - if (requestCode == REQUEST_CONTACT_PICKER) { - if (resultCode == Activity.RESULT_OK) { - Uri uri = data.getData(); - if (uri != null) - openConversation(uri); - } - } - } - - public ConversationListFragment getListFragment() { - return mFragment; - } - - public boolean isDualPane() { - return findViewById(R.id.fragment_compose_message) != null; - } - - /** Called when a contact has been selected from a {@link ContactsListFragment}. */ - @Override - public void onContactSelected(ContactsListFragment fragment, Contact contact) { - // open by user hash - openConversation(Threads.getUri(contact.getJID())); - } - - public void showContactPicker() { - // TODO one day it will be like this - // Intent i = new Intent(Intent.ACTION_PICK, Users.CONTENT_URI); - Intent i = new Intent(this, ContactsListActivity.class); - startActivityForResult(i, REQUEST_CONTACT_PICKER); - } - - @Override - public void setSyncing(boolean syncing) { - // TODO - } - - public void openConversation(Conversation conv, int position) { - if (isDualPane()) { - mFragment.getListView().setItemChecked(position, true); - - // get the old fragment - ComposeMessageFragment f = (ComposeMessageFragment) getSupportFragmentManager() - .findFragmentById(R.id.fragment_compose_message); - - // check if we are replacing the same fragment - if (f == null || !f.getConversation().getRecipient().equals(conv.getRecipient())) { - f = ComposeMessageFragment.fromConversation(this, conv); - // Execute a transaction, replacing any existing fragment - // with this one inside the frame. - FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); - ft.replace(R.id.fragment_compose_message, f); - ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN); - ft.addToBackStack(null); - ft.commit(); - } - } - else { - Intent i = ComposeMessage.fromConversation(this, conv); - startActivity(i); - } - } - - private void openConversation(Uri threadUri) { - if (isDualPane()) { - // TODO position - //mFragment.getListView().setItemChecked(position, true); - - // load conversation - String userId = threadUri.getLastPathSegment(); - Conversation conv = Conversation.loadFromUserId(this, userId); - - // get the old fragment - ComposeMessageFragment f = (ComposeMessageFragment) getSupportFragmentManager() - .findFragmentById(R.id.fragment_compose_message); - - // check if we are replacing the same fragment - if (f == null || conv == null || !f.getConversation().getRecipient().equals(conv.getRecipient())) { - if (conv == null) - f = ComposeMessageFragment.fromUserId(this, userId); - else - f = ComposeMessageFragment.fromConversation(this, conv); - - // Execute a transaction, replacing any existing fragment - // with this one inside the frame. - FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); - ft.replace(R.id.fragment_compose_message, f); - ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); - ft.addToBackStack(null); - ft.commitAllowingStateLoss(); - } - } - else { - Intent i = ComposeMessage.fromUserId(this, threadUri.getLastPathSegment()); - if (i != null) - startActivity(i); - else - Toast.makeText(this, R.string.contact_not_registered, Toast.LENGTH_LONG) - .show(); - } - } - - public static Intent authenticationErrorWarning(Context context) { - Intent i = new Intent(context.getApplicationContext(), ConversationList.class); - i.setAction(ACTION_AUTH_ERROR_WARNING); - return i; - } - -} diff --git a/app/src/main/java/org/kontalk/ui/ConversationListFragment.java b/app/src/main/java/org/kontalk/ui/ConversationListFragment.java deleted file mode 100644 index e830c508a..000000000 --- a/app/src/main/java/org/kontalk/ui/ConversationListFragment.java +++ /dev/null @@ -1,435 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.ui; - -import org.kontalk.R; -import org.kontalk.data.Contact; -import org.kontalk.data.Conversation; -import org.kontalk.provider.MessagesProvider; -import org.kontalk.ui.adapter.ConversationListAdapter; -import org.kontalk.ui.view.ConversationListItem; -import org.kontalk.util.Preferences; - -import android.app.AlertDialog; -import android.content.AsyncQueryHandler; -import android.content.ContentResolver; -import android.content.Context; -import android.content.DialogInterface; -import android.content.DialogInterface.OnClickListener; -import android.content.Intent; -import android.database.Cursor; -import android.database.sqlite.SQLiteException; -import android.os.Bundle; -import android.support.v4.app.ListFragment; -import android.util.Log; -import android.view.ContextMenu; -import android.view.ContextMenu.ContextMenuInfo; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AdapterView.AdapterContextMenuInfo; -import android.widget.ListView; -import android.widget.Toast; - - -public class ConversationListFragment extends ListFragment { - private static final String TAG = ConversationList.TAG; - - private static final int THREAD_LIST_QUERY_TOKEN = 8720; - - /** Context menu group ID for this fragment. */ - private static final int CONTEXT_MENU_GROUP_ID = 1; - - private ThreadListQueryHandler mQueryHandler; - private ConversationListAdapter mListAdapter; - private boolean mDualPane; - - /** Search menu item. */ - private MenuItem mSearchMenu; - private MenuItem mDeleteAllMenu; - /** Offline mode menu item. */ - private MenuItem mOfflineMenu; - - private final ConversationListAdapter.OnContentChangedListener mContentChangedListener = - new ConversationListAdapter.OnContentChangedListener() { - public void onContentChanged(ConversationListAdapter adapter) { - startQuery(); - } - }; - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - return inflater.inflate(R.layout.conversation_list, container, false); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - mQueryHandler = new ThreadListQueryHandler(getActivity().getContentResolver()); - mListAdapter = new ConversationListAdapter(getActivity(), null, getListView()); - mListAdapter.setOnContentChangedListener(mContentChangedListener); - - ListView list = getListView(); - - // Check to see if we have a frame in which to embed the details - // fragment directly in the containing UI. - View detailsFrame = getActivity().findViewById(R.id.fragment_compose_message); - mDualPane = detailsFrame != null - && detailsFrame.getVisibility() == View.VISIBLE; - - if (mDualPane) { - // TODO restore state - list.setChoiceMode(ListView.CHOICE_MODE_SINGLE); - list.setItemsCanFocus(true); - } - - setListAdapter(mListAdapter); - registerForContextMenu(list); - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - // TODO save state - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.conversation_list_menu, menu); - - // compose message - /* - MenuItem item = menu.findItem(R.id.menu_compose); - MenuItemCompat.setShowAsAction(item, MenuItemCompat.SHOW_AS_ACTION_ALWAYS); - */ - - // search - mSearchMenu = menu.findItem(R.id.menu_search); - //MenuItemCompat.setShowAsAction(mSearchMenu, MenuItemCompat.SHOW_AS_ACTION_ALWAYS); - - mDeleteAllMenu = menu.findItem(R.id.menu_delete_all); - - // offline mode - mOfflineMenu = menu.findItem(R.id.menu_offline); - - // trigger manually - onDatabaseChanged(); - updateOffline(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch(item.getItemId()) { - case R.id.menu_compose: - chooseContact(); - return true; - - case R.id.menu_status: - StatusActivity.start(getActivity()); - return true; - - case R.id.menu_offline: - final Context ctx = getActivity(); - final boolean currentMode = Preferences.getOfflineMode(ctx); - if (!currentMode && !Preferences.getOfflineModeUsed(ctx)) { - // show offline mode warning - new AlertDialog.Builder(ctx) - .setTitle(R.string.title_offline_mode_warning) - .setMessage(R.string.message_offline_mode_warning) - .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - Preferences.setOfflineModeUsed(ctx); - switchOfflineMode(); - } - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - else { - switchOfflineMode(); - } - return true; - - case R.id.menu_search: - getActivity().onSearchRequested(); - return true; - - case R.id.menu_delete_all: - deleteAll(); - return true; - - case R.id.menu_mykey: - launchMyKey(); - return true; - - case R.id.menu_donate: - launchDonate(); - return true; - - case R.id.menu_settings: { - PreferencesActivity.start(getActivity()); - return true; - } - } - - return super.onOptionsItemSelected(item); - } - - private static final int MENU_OPEN_THREAD = 1; - private static final int MENU_VIEW_CONTACT = 2; - private static final int MENU_DELETE_THREAD = 3; - - @Override - public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { - AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; - ConversationListItem vitem = (ConversationListItem) info.targetView; - Conversation conv = vitem.getConversation(); - if (conv != null) { - Contact contact = conv.getContact(); - String title; - if (contact != null) - title = contact.getName() != null ? contact.getName() : contact.getNumber(); - else - title = conv.getRecipient(); - - menu.setHeaderTitle(title); - menu.add(CONTEXT_MENU_GROUP_ID, MENU_OPEN_THREAD, MENU_OPEN_THREAD, R.string.view_conversation); - if (contact != null && contact.getId() > 0) - menu.add(CONTEXT_MENU_GROUP_ID, MENU_VIEW_CONTACT, MENU_VIEW_CONTACT, R.string.view_contact); - menu.add(CONTEXT_MENU_GROUP_ID, MENU_DELETE_THREAD, MENU_DELETE_THREAD, R.string.delete_thread); - } - } - - @Override - public boolean onContextItemSelected(android.view.MenuItem item) { - // not our context - if (item.getGroupId() != CONTEXT_MENU_GROUP_ID) - return false; - - AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); - ConversationListItem vitem = (ConversationListItem) info.targetView; - Conversation conv = vitem.getConversation(); - - switch (item.getItemId()) { - case MENU_OPEN_THREAD: - ConversationList parent = getParentActivity(); - if (parent != null) - parent.openConversation(conv, info.position); - return true; - - case MENU_VIEW_CONTACT: - Contact contact = conv.getContact(); - if (contact != null) - startActivity(new Intent(Intent.ACTION_VIEW, contact.getUri())); - return true; - - case MENU_DELETE_THREAD: - deleteThread(conv.getThreadId()); - return true; - } - - return super.onContextItemSelected(item); - } - - private void launchDonate() { - Intent i = new Intent(getActivity(), AboutActivity.class); - i.setAction(AboutActivity.ACTION_DONATION); - startActivity(i); - } - - private void launchMyKey() { - Intent i = new Intent(getActivity(), MyKeyActivity.class); - startActivity(i); - } - - private void deleteThread(final long threadId) { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setTitle(R.string.confirm_delete_thread); - builder.setIcon(android.R.drawable.ic_dialog_alert); - builder.setMessage(R.string.confirm_will_delete_thread); - builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - MessagesProvider.deleteThread(getActivity(), threadId); - MessagingNotification.updateMessagesNotification(getActivity().getApplicationContext(), false); - } - }); - builder.setNegativeButton(android.R.string.cancel, null); - builder.create().show(); - } - - public void chooseContact() { - ConversationList parent = getParentActivity(); - if (parent != null) - parent.showContactPicker(); - } - - private void deleteAll() { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setTitle(R.string.confirm_delete_all); - builder.setIcon(android.R.drawable.ic_dialog_alert); - builder.setMessage(R.string.confirm_will_delete_all); - builder.setPositiveButton(android.R.string.ok, new OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - MessagesProvider.deleteDatabase(getActivity()); - MessagingNotification.updateMessagesNotification(getActivity().getApplicationContext(), false); - } - }); - builder.setNegativeButton(android.R.string.cancel, null); - builder.create().show(); - } - - public ConversationList getParentActivity() { - return (ConversationList) getActivity(); - } - - public void startQuery() { - Cursor c = null; - try { - c = Conversation.startQuery(getActivity()); - } - catch (SQLiteException e) { - Log.e(TAG, "query error", e); - } - mQueryHandler.onQueryComplete(THREAD_LIST_QUERY_TOKEN, null, c); - } - - @Override - public void onStart() { - super.onStart(); - startQuery(); - } - - @Override - public void onResume() { - super.onResume(); - - // update offline mode - updateOffline(); - } - - @Override - public void onStop() { - super.onStop(); - mListAdapter.changeCursor(null); - } - - @Override - public void onListItemClick(ListView l, View v, int position, long id) { - ConversationListItem cv = (ConversationListItem) v; - Conversation conv = cv.getConversation(); - - ConversationList parent = getParentActivity(); - if (parent != null) - parent.openConversation(conv, position); - } - - /** Used only in fragment contexts. */ - public void endConversation(ComposeMessageFragment composer) { - getFragmentManager().beginTransaction().remove(composer).commit(); - } - - public final boolean isFinishing() { - return (getActivity() == null || - (getActivity() != null && getActivity().isFinishing())) || - isRemoving(); - } - - /* Updates various UI elements after a database change. */ - private void onDatabaseChanged() { - boolean visible = (mListAdapter != null && !mListAdapter.isEmpty()); - if (mSearchMenu != null) { - mSearchMenu.setEnabled(visible).setVisible(visible); - } - // if it's null it hasn't gone through onCreateOptionsMenu() yet - if (mSearchMenu != null) { - mSearchMenu.setEnabled(visible).setVisible(visible); - mDeleteAllMenu.setEnabled(visible).setVisible(visible); - } - } - - /** Updates offline mode menu. */ - private void updateOffline() { - Context context = getActivity(); - if (mOfflineMenu != null && context != null) { - boolean offlineMode = Preferences.getOfflineMode(context); - int icon = (offlineMode) ? R.drawable.ic_menu_start_conversation : - android.R.drawable.ic_menu_close_clear_cancel; - int title = (offlineMode) ? R.string.menu_online : R.string.menu_offline; - mOfflineMenu.setIcon(icon); - mOfflineMenu.setTitle(title); - } - } - - private void switchOfflineMode() { - Context ctx = getActivity(); - boolean currentMode = Preferences.getOfflineMode(ctx); - Preferences.switchOfflineMode(ctx); - updateOffline(); - // notify the user about the change - int text = (currentMode) ? R.string.going_online : R.string.going_offline; - Toast.makeText(ctx, text, Toast.LENGTH_SHORT).show(); - } - - /** - * The conversation list query handler. - */ - private final class ThreadListQueryHandler extends AsyncQueryHandler { - public ThreadListQueryHandler(ContentResolver contentResolver) { - super(contentResolver); - } - - @Override - protected void onQueryComplete(int token, Object cookie, Cursor cursor) { - if (cursor == null || isFinishing()) { - // close cursor - if any - if (cursor != null) cursor.close(); - - Log.w(TAG, "query aborted or error!"); - mListAdapter.changeCursor(null); - return; - } - - switch (token) { - case THREAD_LIST_QUERY_TOKEN: - mListAdapter.changeCursor(cursor); - onDatabaseChanged(); - break; - - default: - Log.e(TAG, "onQueryComplete called with unknown token " + token); - } - } - } - - public boolean isDualPane() { - return mDualPane; - } - -} diff --git a/app/src/main/java/org/kontalk/ui/ConversationsActivity.java b/app/src/main/java/org/kontalk/ui/ConversationsActivity.java new file mode 100644 index 000000000..2ed999ed3 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ConversationsActivity.java @@ -0,0 +1,681 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; + +import org.jivesoftware.smack.util.StringUtils; + +import android.app.Activity; +import android.app.SearchManager; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.database.sqlite.SQLiteDiskIOException; +import android.net.Uri; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.fragment.app.FragmentTransaction; +import androidx.appcompat.widget.SearchView; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.Toast; + +import org.kontalk.Kontalk; +import org.kontalk.R; +import org.kontalk.authenticator.MyAccount; +import org.kontalk.data.Conversation; +import org.kontalk.provider.KontalkGroupCommands; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.provider.MyMessages; +import org.kontalk.provider.MyMessages.Threads; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.sync.Syncer; +import org.kontalk.ui.prefs.HelpPreference; +import org.kontalk.ui.prefs.PreferencesActivity; +import org.kontalk.util.DataUtils; +import org.kontalk.util.Preferences; +import org.kontalk.util.XMPPUtils; + + +/** + * The conversations list activity. + * + * Layout is a sliding pane holding the conversation list as primary view and the contact list as + * browser side view. + * + * @author Daniele Ricci + */ +public class ConversationsActivity extends MainActivity + implements ComposeMessageParent, ConversationsCallback { + public static final String TAG = ConversationsActivity.class.getSimpleName(); + + /** An intent extra for storing an ACTION_SEND intent from {@link ComposeMessage}. */ + public static final String EXTRA_SEND_INTENT = "org.kontalk.SEND_INTENT"; + + private ConversationsFragment mFragment; + + /** Search menu item. */ + private MenuItem mSearchMenu; + private MenuItem mDeleteAllMenu; + /** Offline mode menu item. */ + private MenuItem mOfflineMenu; + + private static final int REQUEST_CONTACT_PICKER = 7720; + private static final int REQUEST_ARCHIVED_THREAD = 7721; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.conversations_screen); + + setupToolbar(false, false); + + mFragment = (ConversationsFragment) getSupportFragmentManager() + .findFragmentById(R.id.fragment_conversation_list); + + if (Kontalk.get().getDefaultAccount() != null && !afterOnCreate()) + handleIntent(getIntent()); + } + + /** Called when a new intent is sent to the activity (if already started). */ + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + handleIntent(intent); + } + + protected boolean handleIntent(Intent intent) { + if (super.handleIntent(intent)) + return true; + + if (intent != null) { + String action = intent.getAction(); + + // this is for intents coming from the world, forwarded by ComposeMessage + boolean actionView = Intent.ACTION_VIEW.equals(action); + if (actionView || ComposeMessage.ACTION_VIEW_USERID.equals(action)) { + Uri uri = null; + + if (actionView) { + Cursor c = null; + try { + c = getContentResolver().query(intent.getData(), + new String[]{Syncer.DATA_COLUMN_PHONE}, + null, null, null); + if (c != null && c.moveToFirst()) { + String phone = c.getString(0); + MyAccount account = Kontalk.get().getDefaultAccount(); + String userJID = account.createLocalJID(XMPPUtils + .createLocalpart(phone)); + uri = Threads.getUri(userJID); + } + } + catch (SecurityException e) { + // user denied access to contacts. Sorry! + Toast.makeText(this, R.string.warn_external_contacts_denied, + Toast.LENGTH_LONG).show(); + return true; + } + finally { + DataUtils.close(c); + } + } + else { + uri = intent.getData(); + } + + if (uri != null) + openConversation(uri); + } + else if (ComposeMessage.ACTION_VIEW_CONVERSATION.equals(action)) { + Uri uri = intent.getData(); + if (uri != null) { + long threadId = ContentUris.parseId(uri); + if (threadId >= 0) + openConversation(threadId); + } + } + } + + return true; + } + + private void processSendIntent(Intent sendIntent) { + AbstractComposeFragment f = getCurrentConversation(); + SendIntentReceiver.processSendIntent(this, sendIntent, f); + } + + @Override + public boolean onSearchRequested() { + ConversationsFragment fragment = getListFragment(); + + // no data found + if (!fragment.hasListItems()) + return false; + + toggleSearch(); + return false; + } + + private void toggleSearch() { + if (mSearchMenu != null) { + if (mSearchMenu.isActionViewExpanded()) + mSearchMenu.collapseActionView(); + else + mSearchMenu.expandActionView(); + } + } + + @Override + public void onBackPressed() { + if (mFragment != null && mFragment.isActionMenuOpen()) { + mFragment.closeActionMenu(); + return; + } + AbstractComposeFragment f = getCurrentConversation(); + if (f == null || !f.tryHideEmojiDrawer()) { + super.onBackPressed(); + } + } + + @Override + public void onResume() { + super.onResume(); + + // set title for offline mode + setOfflineModeTitle(); + + final Context context = getApplicationContext(); + new Thread(new Runnable() { + @Override + public void run() { + if (Kontalk.get().getDefaultAccount() != null) { + // mark all messages as old + MessagesProviderClient.markAllThreadsAsOld(context); + // update notification + MessagingNotification.updateMessagesNotification(context, false); + } + } + }).start(); + + if (Kontalk.get().getDefaultAccount() == null) { + NumberValidation.start(this); + finish(); + } + else { + // hold message center + MessageCenterService.hold(this, true); + // since we have the conversation list open, we're going to disable notifications + // no need to notify the user twice + MessagingNotification.disable(); + } + + updateOffline(); + } + + @Override + protected void onResumeFragments() { + super.onResumeFragments(); + + Intent intent = getIntent(); + if (intent != null) { + Intent sendIntent = getIntent().getParcelableExtra(EXTRA_SEND_INTENT); + if (sendIntent != null) { + // handle the share intent sent from ComposeMessage + processSendIntent(sendIntent); + // clear the intent + intent.removeExtra(EXTRA_SEND_INTENT); + } + } + } + + @Override + protected void onPause() { + super.onPause(); + // release message center + MessageCenterService.release(this); + // enable notifications again + MessagingNotification.enable(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + // contact chooser + if (requestCode == REQUEST_CONTACT_PICKER) { + if (resultCode == Activity.RESULT_OK) { + ArrayList uris; + Uri uri = data.getData(); + if (uri != null) { + openConversation(uri); + } + else if ((uris = data.getParcelableArrayListExtra("org.kontalk.contacts")) != null) { + startGroupChat(uris); + } + } + } + else if (requestCode == REQUEST_ARCHIVED_THREAD) { + if (resultCode == Activity.RESULT_OK) { + Uri uri = data.getData(); + if (uri != null) { + // we'll have the thread id in the uri + long threadId = ContentUris.parseId(uri); + if (threadId >= 0) + openConversation(threadId); + } + } + } + else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + private AbstractComposeFragment getCurrentConversation() { + return (AbstractComposeFragment) getSupportFragmentManager() + .findFragmentById(R.id.fragment_compose_message); + } + + private void startGroupChat(List users) { + String selfJid = Kontalk.get().getDefaultAccount().getSelfJID(); + String groupId = StringUtils.randomString(20); + String groupJid = KontalkGroupCommands.createGroupJid(groupId, selfJid); + + // ensure no duplicates + Set usersList = new HashSet<>(); + for (Uri uri : users) { + String member = uri.getLastPathSegment(); + // exclude ourselves + if (!member.equalsIgnoreCase(selfJid)) + usersList.add(member); + } + + if (usersList.size() > 0) { + askGroupSubject(usersList, groupJid); + } + } + + private void askGroupSubject(final Set usersList, final String groupJid) { + new MaterialDialog.Builder(this) + .title(R.string.title_group_subject) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .input(null, null, true, new MaterialDialog.InputCallback() { + @Override + public void onInput(@NonNull MaterialDialog dialog, CharSequence input) { + String title = !TextUtils.isEmpty(input) ? input.toString() : null; + String[] users = usersList.toArray(new String[0]); + + try { + Uri cmdMsg; + try { + // WARNING blocking call + // TODO a status dialog should probably be used here + cmdMsg = Kontalk.get().getMessagesController() + .createGroup(users, groupJid, title).get(); + } + catch (ExecutionException e) { + // unwrap exception + throw e.getCause(); + } + + if (cmdMsg != null) { + // load the new conversation + openConversation(Threads.getUri(groupJid), true); + } + } + catch (SQLiteDiskIOException e) { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(ConversationsActivity.this, R.string.error_store_outbox, + Toast.LENGTH_LONG).show(); + } + }); + } + catch (Throwable e) { + ReportingManager.logException(e); + runOnUiThread(new Runnable() { + public void run() { + Toast.makeText(ConversationsActivity.this, + R.string.err_store_message_failed, + Toast.LENGTH_LONG).show(); + } + }); + } + } + }) + .inputRange(0, MyMessages.Groups.GROUP_SUBJECT_MAX_LENGTH) + .show(); + } + + public void setOfflineModeTitle() { + setTitle(MessageCenterService.isOfflineMode(this)); + } + + public void setTitle(boolean offline) { + setTitle(offline ? R.string.app_name_offline : R.string.app_name); + } + + public ConversationsFragment getListFragment() { + return mFragment; + } + + public boolean isDualPane() { + return findViewById(R.id.fragment_compose_message) != null; + } + + public void showContactPicker(boolean multiselect) { + // TODO one day it will be like this + // Intent i = new Intent(Intent.ACTION_PICK, Users.CONTENT_URI); + Intent i = new Intent(this, ContactsListActivity.class); + i.putExtra(ContactsListActivity.MODE_MULTI_SELECT, multiselect); + // FIXME when in single pane mode, onActivityResult will cause us to requery for nothing!! + // maybe we should let handle the start of ComposeMessage to the contacts list activity + // use two subclass activities: one for returning a result, one for launching compose message + startActivityForResult(i, REQUEST_CONTACT_PICKER); + } + + @Override + public void setTitle(@Nullable CharSequence title, @Nullable CharSequence subtitle) { + // nothing + } + + @Override + public void setUpdatingSubtitle() { + // nothing + } + + /** For tablets. */ + @Override + public void loadConversation(long threadId, boolean creatingGroup) { + openConversation(threadId, creatingGroup); + } + + /** For tablets. */ + @Override + public void loadConversation(Uri threadUri) { + openConversation(threadUri, false); + } + + @Override + public void openConversation(Conversation conv) { + if (isDualPane()) { + // get the old fragment + AbstractComposeFragment f = getCurrentConversation(); + + // check if we are replacing the same fragment + Conversation oldConv = (f != null ? f.getConversation() : null); + if (oldConv == null || !oldConv.getRecipient().equals(conv.getRecipient())) { + f = AbstractComposeFragment.fromConversation(this, conv, false); + // Execute a transaction, replacing any existing fragment + // with this one inside the frame. + FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); + ft.replace(R.id.fragment_compose_message, f); + ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN); + ft.commit(); + } + } + else { + Intent i = ComposeMessage.fromConversation(this, conv); + startActivity(i); + } + } + + void openConversation(Uri threadUri) { + openConversation(threadUri, false); + } + + void openConversation(Uri threadUri, boolean creatingGroup) { + if (isDualPane()) { + // load conversation + String userId = threadUri.getLastPathSegment(); + Conversation conv = Conversation.loadFromUserId(this, userId); + + // get the old fragment + AbstractComposeFragment f = getCurrentConversation(); + + // check if we are replacing the same fragment + Conversation oldConv = (f != null ? f.getConversation() : null); + if (oldConv == null || conv == null || !oldConv.getRecipient().equals(conv.getRecipient())) { + if (conv == null) + f = AbstractComposeFragment.fromUserId(this, userId, creatingGroup); + else + f = AbstractComposeFragment.fromConversation(this, conv, creatingGroup); + + // Execute a transaction, replacing any existing fragment + // with this one inside the frame. + FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); + ft.replace(R.id.fragment_compose_message, f); + ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN); + ft.commitAllowingStateLoss(); + } + } + else { + Intent i = ComposeMessage.fromUserId(this, threadUri.getLastPathSegment(), creatingGroup); + if (i != null) + startActivity(i); + else + Toast.makeText(this, R.string.contact_not_registered, Toast.LENGTH_LONG) + .show(); + } + } + + private void openConversation(long threadId) { + openConversation(threadId, false); + } + + private void openConversation(long threadId, boolean creatingGroup) { + if (isDualPane()) { + // load conversation + Conversation conv = Conversation.loadFromId(this, threadId); + if (conv == null) + return; + + // get the old fragment + AbstractComposeFragment f = getCurrentConversation(); + + // check if we are replacing the same fragment + Conversation oldConv = (f != null ? f.getConversation() : null); + if (oldConv == null || !oldConv.getRecipient().equals(conv.getRecipient())) { + f = AbstractComposeFragment.fromConversation(this, conv, creatingGroup); + + // Execute a transaction, replacing any existing fragment + // with this one inside the frame. + FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); + ft.replace(R.id.fragment_compose_message, f); + ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN); + ft.commitAllowingStateLoss(); + } + } + else { + startActivity(ComposeMessage.fromConversation(this, threadId, creatingGroup)); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.conversation_list_menu, menu); + + // search + mSearchMenu = menu.findItem(R.id.menu_search); + SearchView searchView = (SearchView) mSearchMenu.getActionView(); + SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE); + searchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName())); + // LayoutParams.MATCH_PARENT does not work, use a big value instead + searchView.setMaxWidth(1000000); + + mDeleteAllMenu = menu.findItem(R.id.menu_delete_all); + + // offline mode + mOfflineMenu = menu.findItem(R.id.menu_offline); + + // trigger manually + onDatabaseChanged(); + updateOffline(); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch(item.getItemId()) { + case android.R.id.home: + // TODO @deprecated + onBackPressed(); + return true; + + case R.id.menu_status: + StatusActivity.start(this); + return true; + + case R.id.menu_offline: + final Context ctx = this; + final boolean currentMode = Preferences.getOfflineMode(); + if (!currentMode && !Preferences.getOfflineModeUsed()) { + // show offline mode warning + new MaterialDialog.Builder(ctx) + .content(R.string.message_offline_mode_warning) + .positiveText(android.R.string.ok) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + Preferences.setOfflineModeUsed(); + switchOfflineMode(); + } + }) + .negativeText(android.R.string.cancel) + .show(); + } + else { + switchOfflineMode(); + } + return true; + + case R.id.menu_delete_all: + deleteAll(); + return true; + + case R.id.menu_mykey: + launchMyKey(); + return true; + + case R.id.menu_donate: + launchDonate(); + return true; + + case R.id.menu_help: + launchHelp(); + return true; + + case R.id.menu_settings: { + PreferencesActivity.start(this); + return true; + } + } + + return super.onOptionsItemSelected(item); + } + + /** Updates various UI elements after a database change. */ + @UiThread + @Override + public void onDatabaseChanged() { + boolean visible = mFragment.hasListItems(); + if (mSearchMenu != null) { + mSearchMenu.setEnabled(visible).setVisible(visible); + } + // if it's null it hasn't gone through onCreateOptionsMenu() yet + if (mSearchMenu != null) { + mSearchMenu.setEnabled(visible).setVisible(visible); + mDeleteAllMenu.setEnabled(visible).setVisible(visible); + } + } + + /** Updates offline mode menu. */ + private void updateOffline() { + if (mOfflineMenu != null) { + boolean offlineMode = Preferences.getOfflineMode(); + // set menu + int icon = (offlineMode) ? R.drawable.ic_menu_online : + R.drawable.ic_menu_offline; + int title = (offlineMode) ? R.string.menu_online : R.string.menu_offline; + mOfflineMenu.setIcon(icon); + mOfflineMenu.setTitle(title); + // set window title + setTitle(offlineMode); + } + } + + void switchOfflineMode() { + boolean currentMode = Preferences.getOfflineMode(); + Preferences.switchOfflineMode(this); + updateOffline(); + // notify the user about the change + int text = (currentMode) ? R.string.going_online : R.string.going_offline; + Toast.makeText(this, text, Toast.LENGTH_SHORT).show(); + } + + private void deleteAll() { + new MaterialDialog.Builder(this) + .content(R.string.confirm_will_delete_all) + .positiveText(android.R.string.ok) + .positiveColorRes(R.color.button_danger) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + Conversation.deleteAll(ConversationsActivity.this, dialog.isPromptCheckBoxChecked(), false); + MessagingNotification.updateMessagesNotification(getApplicationContext(), false); + } + }) + .checkBoxPromptRes(R.string.delete_threads_leave_any_groups, false, null) + .negativeText(android.R.string.cancel) + .show(); + } + + private void launchDonate() { + Intent i = new Intent(this, AboutActivity.class); + i.setAction(AboutActivity.ACTION_DONATION); + startActivity(i); + } + + private void launchMyKey() { + Intent i = new Intent(this, MyKeyActivity.class); + startActivity(i); + } + + private void launchHelp() { + HelpPreference.openHelp(this); + } + + public void startArchivedConversations() { + ArchivedConversationsActivity.start(this, REQUEST_ARCHIVED_THREAD); + } +} diff --git a/app/src/main/java/org/kontalk/ui/ConversationsCallback.java b/app/src/main/java/org/kontalk/ui/ConversationsCallback.java new file mode 100644 index 000000000..c1c0a0044 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ConversationsCallback.java @@ -0,0 +1,40 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import androidx.annotation.NonNull; +import androidx.appcompat.view.ActionMode; + +import org.kontalk.data.Conversation; + + +/** + * Callback interface for conversations list fragments. + * Mainly used to provide a common parent interface to {@link ConversationsFragment}. + * @author Daniele Ricci + */ +public interface ConversationsCallback { + + void openConversation(Conversation conv); + + ActionMode startSupportActionMode(@NonNull ActionMode.Callback callback); + + void onDatabaseChanged(); + +} diff --git a/app/src/main/java/org/kontalk/ui/ConversationsFragment.java b/app/src/main/java/org/kontalk/ui/ConversationsFragment.java new file mode 100644 index 000000000..e874727a7 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ConversationsFragment.java @@ -0,0 +1,212 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import com.github.clans.fab.FloatingActionMenu; + +import android.content.Context; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.appcompat.view.ActionMode; +import androidx.recyclerview.selection.Selection; + +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.data.Conversation; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.ui.adapter.ConversationListAdapter; +import org.kontalk.util.Showcase; + + +public class ConversationsFragment extends AbstractConversationsFragment + implements ConversationListAdapter.OnFooterClickListener { + static final String TAG = ConversationsActivity.TAG; + + FloatingActionMenu mAction; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.conversation_list, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mAction = view.findViewById(R.id.action); + mAction.setClosedOnTouchOutside(true); + + view.findViewById(R.id.action_compose).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + chooseContact(false); + } + }); + view.findViewById(R.id.action_compose_group).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + chooseContact(true); + } + }); + } + + @Override + protected boolean isArchived() { + return false; + } + + @Override + protected boolean isSingleSelection() { + return isDualPane(); + } + + private boolean isDualPane() { + View detailsFrame = getActivity().findViewById(R.id.fragment_compose_message); + return detailsFrame != null && detailsFrame.getVisibility() == View.VISIBLE; + } + + @Override + protected void onAdapterCreated(ConversationListAdapter adapter) { + adapter.setFooterListener(this); + } + + @Override + protected boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = mode.getMenuInflater(); + inflater.inflate(R.menu.conversation_list_ctx, menu); + return true; + } + + @Override + protected boolean onPrepareActionMode(ActionMode mode, Menu menu) { + boolean singleItem = (getSelectedPositions().size() == 1); + menu.findItem(R.id.menu_sticky).setVisible(singleItem); + return true; + } + + @Override + protected boolean onActionItemClicked(ActionMode mode, MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_archive: + archiveSelectedThreads(); + mode.finish(); + return true; + case R.id.menu_delete: + deleteSelectedThreads(); + mode.finish(); + return true; + case R.id.menu_sticky: + stickSelectedThread(); + mode.finish(); + return true; + } + return false; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + return isActionModeActive() || super.onOptionsItemSelected(item); + } + + public boolean isActionMenuOpen() { + return mAction != null && mAction.isOpened(); + } + + public void closeActionMenu() { + if (isActionMenuOpen()) + mAction.close(true); + } + + private void archiveSelectedThreads() { + final Context context = getContext(); + if (context == null) + return; + + Selection selected = getSelectedPositions(); + for (long id: selected) { + MessagesProviderClient.setArchived(context, id, true); + } + } + + private Conversation getCheckedItem() { + Selection selected = getSelectedPositions(); + if (selected.size() != 1) + throw new IllegalStateException("checked items count must be exactly 1"); + + return Conversation.loadFromId(getContext(), selected.iterator().next()); + } + + private void stickSelectedThread() { + Conversation conv = getCheckedItem(); + if (conv != null) { + conv.setSticky(!conv.isSticky()); + } + getListAdapter().notifyDataSetChanged(); + } + + public void chooseContact(boolean multiselect) { + // we can cast to the activity because it can only happen there + ConversationsActivity parent = getParentActivity(); + if (parent != null) + parent.showContactPicker(multiselect); + } + + private ConversationsActivity getParentActivity() { + return (ConversationsActivity) getActivity(); + } + + @Override + public void onStart() { + super.onStart(); + Contact.registerContactChangeListener(this); + Showcase.showNextHint(this); + } + + @Override + public void onStop() { + super.onStop(); + Contact.unregisterContactChangeListener(this); + if (isActionMenuOpen()) + mAction.close(false); + } + + @Override + public void onFooterClick() { + // we can cast to the activity because it can only happen there + ConversationsActivity parent = getParentActivity(); + if (parent != null) + parent.startArchivedConversations(); + } + + /** Used only in fragment contexts. */ + public void endConversation(AbstractComposeFragment composer) { + getParentFragmentManager() + .beginTransaction() + .remove(composer) + .commit(); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/CreditsFragment.java b/app/src/main/java/org/kontalk/ui/CreditsFragment.java index 20f7b1013..56158e512 100644 --- a/app/src/main/java/org/kontalk/ui/CreditsFragment.java +++ b/app/src/main/java/org/kontalk/ui/CreditsFragment.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,7 +28,7 @@ import org.json.JSONTokener; import android.os.Bundle; -import android.support.v4.app.Fragment; +import androidx.fragment.app.Fragment; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -48,9 +48,10 @@ public class CreditsFragment extends Fragment { public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.about_credits, container, false); - TextView coredevs = (TextView) view.findViewById(R.id.coredevs); - TextView gfx = (TextView) view.findViewById(R.id.gfx); - TextView translators = (TextView) view.findViewById(R.id.translators); + TextView coredevs = view.findViewById(R.id.coredevs); + TextView contrib = view.findViewById(R.id.contrib); + TextView gfx = view.findViewById(R.id.gfx); + TextView translators = view.findViewById(R.id.translators); InputStream in = null; try { @@ -63,6 +64,9 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa JSONArray devteam = root.getJSONArray("devteam"); addCredits(coredevs, devteam); + JSONArray contributors = root.getJSONArray("contributors"); + addCredits(contrib, contributors); + JSONArray gfxteam = root.getJSONArray("gfx"); addCredits(gfx, gfxteam); diff --git a/app/src/main/java/org/kontalk/ui/DonationFragment.java b/app/src/main/java/org/kontalk/ui/DonationFragment.java index 09f859121..3d5522907 100644 --- a/app/src/main/java/org/kontalk/ui/DonationFragment.java +++ b/app/src/main/java/org/kontalk/ui/DonationFragment.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,38 +18,28 @@ package org.kontalk.ui; -import java.util.Arrays; +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; -import org.kontalk.BuildConfig; -import org.kontalk.R; -import org.kontalk.billing.BillingResult; -import org.kontalk.billing.BillingServiceManager; -import org.kontalk.billing.IBillingService; -import org.kontalk.billing.IInventory; -import org.kontalk.billing.IProductDetails; -import org.kontalk.billing.IPurchase; -import org.kontalk.billing.OnBillingSetupFinishedListener; -import org.kontalk.billing.OnConsumeFinishedListener; -import org.kontalk.billing.OnPurchaseFinishedListener; -import org.kontalk.billing.QueryInventoryFinishedListener; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.ProgressDialog; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; -import android.support.v4.app.Fragment; -import android.text.ClipboardManager; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import org.kontalk.R; +import org.kontalk.util.SystemUtils; + /** * Donation fragment. @@ -58,67 +48,10 @@ */ public class DonationFragment extends Fragment implements OnClickListener { - // for Google Play - private IBillingService mBillingService; - private static final int IAB_REQUEST_CODE = 10001; - - OnPurchaseFinishedListener mPurchaseFinishedListener = new OnPurchaseFinishedListener() { - - public void onPurchaseFinished(BillingResult result, IPurchase purchase) { - if (mBillingService == null) return; - - if (result.isSuccess()) { - // consume purchase in the background - mBillingService.consumeAsync(purchase, mConsumeFinishedListener); - } - } - }; - - OnConsumeFinishedListener mConsumeFinishedListener = new OnConsumeFinishedListener() { - public void onConsumeFinished(IPurchase purchase, BillingResult result) { - int msg; - if (result.isSuccess()) - msg = R.string.msg_iab_thankyou; - else - msg = R.string.msg_iab_thankyou_warning; - Toast.makeText(getActivity(), msg, Toast.LENGTH_LONG).show(); - } - }; - - private final class OnPreConsumeFinishedListener implements OnConsumeFinishedListener { - private int mConsumedItems; - private String[] mItems; - - public void onConsumeFinished(IPurchase purchase, BillingResult result) { - mConsumedItems++; - if (mConsumedItems >= mItems.length) { - mConsumedItems = 0; - showDonationSelector(mItems); - } - } - - public void setDonationItems(String[] items) { - mItems = items; - mConsumedItems = 0; - } - } - - OnPreConsumeFinishedListener mPreConsumeFinishedListener = new OnPreConsumeFinishedListener(); - - public IBillingService getBillingService() { - return mBillingService; - } - @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.about_donation, container, false); - View button = view.findViewById(R.id.donate_google); - if (BillingServiceManager.isEnabled()) - button.setOnClickListener(this); - else - button.setVisibility(View.GONE); - view.findViewById(R.id.donate_paypal).setOnClickListener(this); view.findViewById(R.id.donate_bitcoin).setOnClickListener(this); view.findViewById(R.id.donate_flattr).setOnClickListener(this); @@ -128,10 +61,6 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa public void onClick(View v) { switch (v.getId()) { - case R.id.donate_google: - donateGoogle(); - break; - case R.id.donate_paypal: donatePaypal(); break; @@ -147,31 +76,33 @@ public void onClick(View v) { } private void donateFlattr() { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.flattr_url)))); + SystemUtils.openURL(getContext(), getString(R.string.flattr_url)); } private void donateBitcoin() { final String address = getString(R.string.bitcoin_address); Uri uri = Uri.parse("bitcoin:" + address); - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri.toString())); + Intent intent = SystemUtils.externalIntent(Intent.ACTION_VIEW, Uri.parse(uri.toString())); - Activity ctx = getActivity(); + final Context ctx = getContext(); final PackageManager pm = ctx.getPackageManager(); if (pm.resolveActivity(intent, 0) != null) startActivity(intent); else - new AlertDialog - .Builder(getActivity()) - .setTitle(R.string.title_bitcoin_dialog) - .setMessage(getString(R.string.text_bitcoin_dialog, address)) - .setPositiveButton(android.R.string.ok, null) - .setNeutralButton(R.string.copy_clipboard, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - ClipboardManager cpm = (ClipboardManager) getActivity() - .getSystemService(Context.CLIPBOARD_SERVICE); - cpm.setText(address); - - Toast.makeText(getActivity(), R.string.bitcoin_clipboard_copied, + new MaterialDialog.Builder(ctx) + .title(R.string.title_bitcoin_dialog) + .content(getString(R.string.text_bitcoin_dialog, address)) + .positiveText(android.R.string.ok) + .neutralText(R.string.copy_clipboard) + .onNeutral(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + Context ctx = dialog.getContext(); + ClipboardManager cpm = (ClipboardManager) ctx + .getSystemService(Context.CLIPBOARD_SERVICE); + cpm.setPrimaryClip(ClipData.newPlainText(null, address)); + + Toast.makeText(ctx, R.string.bitcoin_clipboard_copied, Toast.LENGTH_LONG).show(); } }) @@ -180,138 +111,13 @@ public void onClick(DialogInterface dialog, int which) { private void donatePaypal() { // just start Paypal donate button URL - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.paypal_url)))); - } - - private void setupGoogle(final ProgressDialog progress) { - if (mBillingService == null) { - mBillingService = BillingServiceManager.getInstance(getActivity()); - mBillingService.enableDebugLogging(BuildConfig.DEBUG); - - mBillingService.startSetup(new OnBillingSetupFinishedListener() { - public void onSetupFinished(BillingResult result) { - if (!result.isSuccess()) { - alert(R.string.title_error, getString(R.string.iab_error_setup, result.getResponse())); - mBillingService = null; - progress.dismiss(); - return; - } - - queryInventory(progress); - } - }); - } - - else { - queryInventory(progress); - } - } - - private void queryInventory(final ProgressDialog progress) { - final String[] iabItems = getResources().getStringArray(R.array.iab_items); - - QueryInventoryFinishedListener gotInventoryListener = new QueryInventoryFinishedListener() { - public void onQueryInventoryFinished(BillingResult result, IInventory inventory) { - if (mBillingService == null) return; - - // dismiss progress - progress.dismiss(); - - if (result.isFailure()) { - alert(R.string.title_error, getString(R.string.iab_error_query, result.getResponse())); - } - - else { - // prepare items for the dialog - String[] dialogItems = new String[iabItems.length]; - - for (int i = 0; i < iabItems.length; i++) { - IProductDetails sku = inventory.getSkuDetails(iabItems[i]); - if (sku != null) { - dialogItems[i] = sku.getDescription(); - } - else { - dialogItems[i] = iabItems[i]; - } - } - - // setup pre-consume listener - mPreConsumeFinishedListener.setDonationItems(dialogItems); - int purchases = 0; - // consume purchases just to be sure - for (String item : iabItems) { - IPurchase purchase = inventory.getPurchase(item); - if (purchase != null) { - purchases++; - mBillingService.consumeAsync(purchase, - mPreConsumeFinishedListener); - } - } - - // no purchases to be consumed, show donation now - if (purchases == 0) - showDonationSelector(dialogItems); - } - } - }; - - - if (mBillingService != null) - mBillingService.queryInventoryAsync(true, Arrays - .asList(iabItems), gotInventoryListener); - } - - private void showDonationSelector(CharSequence[] dialogItems) { - final String[] iabItems = getResources().getStringArray(R.array.iab_items); - - // show dialog with choices - new AlertDialog.Builder(getActivity()) - .setTitle(R.string.title_donation) - .setItems(dialogItems, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - // start the purchase - String itemId = iabItems[which]; - mBillingService.launchPurchaseFlow(getActivity(), itemId, - IAB_REQUEST_CODE, mPurchaseFinishedListener); - } - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - private void donateGoogle() { - // progress dialog - ProgressDialog dialog = ProgressDialog.show(getActivity(), getString(R.string.title_donation), - getString(R.string.msg_connecting_iab), true, true, - new DialogInterface.OnCancelListener() { - public void onCancel(DialogInterface dialog) { - // FIXME this doesn't seem to work in some cases - if (mBillingService != null) { - mBillingService.dispose(); - mBillingService = null; - } - } - }); - - setupGoogle(dialog); - } - - @Override - public void onDestroy() { - super.onDestroy(); - - if (mBillingService != null) { - mBillingService.dispose(); - mBillingService = null; - } + SystemUtils.openURL(getContext(), getString(R.string.paypal_url)); } - private void alert(int title, String message) { - new AlertDialog - .Builder(getActivity()) - .setTitle(title) - .setMessage(message) - .setNeutralButton(android.R.string.ok, null) + private void alert(String message) { + new MaterialDialog.Builder(getContext()) + .content(message) + .positiveText(android.R.string.ok) .show(); } } diff --git a/app/src/main/java/org/kontalk/ui/FrameLayoutFixed.java b/app/src/main/java/org/kontalk/ui/FrameLayoutFixed.java index bc3b7bc40..66da7acba 100644 --- a/app/src/main/java/org/kontalk/ui/FrameLayoutFixed.java +++ b/app/src/main/java/org/kontalk/ui/FrameLayoutFixed.java @@ -8,17 +8,18 @@ package org.kontalk.ui; +import java.util.ArrayList; + import android.content.Context; import android.graphics.drawable.Drawable; import android.util.AttributeSet; -import android.util.Log; import android.view.View; import android.widget.FrameLayout; -import java.util.ArrayList; +import org.kontalk.Log; public class FrameLayoutFixed extends FrameLayout { - private final ArrayList mMatchParentChildren = new ArrayList(1); + private final ArrayList mMatchParentChildren = new ArrayList<>(1); public FrameLayoutFixed(Context context) { super(context); diff --git a/app/src/main/java/org/kontalk/ui/GroupInfoActivity.java b/app/src/main/java/org/kontalk/ui/GroupInfoActivity.java new file mode 100644 index 000000000..f3c9f33ec --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/GroupInfoActivity.java @@ -0,0 +1,108 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import androidx.fragment.app.Fragment; +import android.view.MenuItem; + +import org.kontalk.R; +import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.service.msgcenter.MessageCenterService; + + +/** + * Group information activity. + * @author Daniele Ricci + */ +public class GroupInfoActivity extends ToolbarActivity implements GroupInfoFragment.GroupInfoParent { + + static final int RESULT_PRIVATE_CHAT = RESULT_FIRST_USER; + static final int RESULT_ADD_USERS = RESULT_FIRST_USER + 1; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.group_info_screen); + + setupToolbar(true, false); + + if (savedInstanceState == null) { + Intent i = getIntent(); + long threadId = i.getLongExtra("conversation", Messages.NO_THREAD); + Fragment f = GroupInfoFragment.newInstance(threadId); + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment, f) + .commit(); + } + } + + @Override + protected void onPause() { + super.onPause(); + // release message center + MessageCenterService.release(this); + } + + @Override + protected void onResume() { + super.onResume(); + // hold message center + MessageCenterService.hold(this, true); + } + + /** Not used. */ + @Override + protected boolean isNormalUpNavigation() { + return false; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + public void finish() { + super.finish(); + if (!(this instanceof GroupInfoDialog)) + overridePendingTransition(R.anim.stay, R.anim.slide_down); + } + + public static void start(Activity context, Fragment parent, long threadId, int requestCode) { + Intent intent = new Intent(context, GroupInfoActivity.class); + intent.putExtra("conversation", threadId); + parent.startActivityForResult(intent, requestCode); + context.overridePendingTransition(R.anim.slide_up, R.anim.stay); + } + + @Override + public void dismiss() { + finish(); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/GroupInfoDialog.java b/app/src/main/java/org/kontalk/ui/GroupInfoDialog.java new file mode 100644 index 000000000..a570411f2 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/GroupInfoDialog.java @@ -0,0 +1,38 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import android.content.Context; +import android.content.Intent; +import androidx.fragment.app.Fragment; + + +/** + * Group information dialog. + * @author Daniele Ricci + */ +public class GroupInfoDialog extends GroupInfoActivity { + + public static void start(Context context, Fragment parent, long threadId, int requestCode) { + Intent intent = new Intent(context, GroupInfoDialog.class); + intent.putExtra("conversation", threadId); + parent.startActivityForResult(intent, requestCode); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/GroupInfoFragment.java b/app/src/main/java/org/kontalk/ui/GroupInfoFragment.java new file mode 100644 index 000000000..2c5f0e925 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/GroupInfoFragment.java @@ -0,0 +1,793 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import java.text.Collator; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.jxmpp.util.XmppStringUtils; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.graphics.Typeface; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.ListFragment; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.CharacterStyle; +import android.text.style.ForegroundColorSpan; +import android.util.SparseBooleanArray; +import android.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.BaseAdapter; +import android.widget.Button; +import android.widget.ListView; +import android.widget.TextView; + +import org.kontalk.Kontalk; +import org.kontalk.R; +import org.kontalk.client.KontalkGroupManager.KontalkGroup; +import org.kontalk.crypto.PGP; +import org.kontalk.data.Contact; +import org.kontalk.data.Conversation; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.provider.MyMessages; +import org.kontalk.provider.MyMessages.Groups; +import org.kontalk.provider.MyUsers; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.service.msgcenter.event.RosterStatusEvent; +import org.kontalk.service.msgcenter.event.RosterStatusRequest; +import org.kontalk.ui.view.ContactsListItem; +import org.kontalk.util.DataUtils; +import org.kontalk.util.SystemUtils; + + +/** + * Group information fragment + * FIXME this class is too tied to the concept of "Kontalk group" + * @author Daniele Ricci + */ +public class GroupInfoFragment extends ListFragment + implements Contact.ContactChangeListener, AbsListView.MultiChoiceModeListener { + + private TextView mTitle; + private Button mSetSubject; + private Button mLeave; + private Button mIgnoreAll; + private MenuItem mAddMenu; + private MenuItem mRemoveMenu; + private MenuItem mChatMenu; + private MenuItem mReaddMenu; + + GroupMembersAdapter mMembersAdapter; + + Conversation mConversation; + + private int mCheckedItemCount; + + private EventBus mServiceBus = MessageCenterService.bus(); + + public static GroupInfoFragment newInstance(long threadId) { + GroupInfoFragment f = new GroupInfoFragment(); + Bundle data = new Bundle(); + data.putLong("conversation", threadId); + f.setArguments(data); + return f; + } + + private void loadConversation(long threadId) { + mConversation = Conversation.loadFromId(getContext(), threadId); + if (mConversation == null) { + // conversation diseappared?? End now. + Activity parent = getActivity(); + if (parent != null) + parent.finish(); + return; + } + + mMembersAdapter.setGroupJid(mConversation.getGroupJid()); + String subject = mConversation.getGroupSubject(); + mTitle.setText(TextUtils.isEmpty(subject) ? + getString(R.string.group_untitled) : subject); + + String selfJid = Kontalk.get().getDefaultAccount().getSelfJID(); + boolean isOwner = KontalkGroup.checkOwnership(mConversation.getGroupJid(), selfJid); + boolean isMember = mConversation.getGroupMembership() == Groups.MEMBERSHIP_MEMBER; + mSetSubject.setEnabled(isOwner && isMember); + mLeave.setEnabled(isMember); + + if (!mServiceBus.isRegistered(this)) { + mServiceBus.register(this); + } + + // load members + boolean showIgnoreAll = false; + String[] members = getGroupMembers(); + mMembersAdapter.clear(); + for (String jid : members) { + Contact c = Contact.findByUserId(getContext(), jid); + if (c.isKeyChanged() || c.getTrustedLevel() == MyUsers.Keys.TRUST_UNKNOWN) + showIgnoreAll = true; + boolean owner = KontalkGroup.checkOwnership(mConversation.getGroupJid(), jid); + boolean isSelfJid = jid.equalsIgnoreCase(selfJid); + mMembersAdapter.add(c, owner, isSelfJid ? true : null); + if (!isSelfJid) { + // request roster entry status + // FIXME if the message center is down this won't work (e.g. offline mode) + mServiceBus.post(new RosterStatusRequest(jid)); + } + } + + mIgnoreAll.setVisibility(showIgnoreAll ? View.VISIBLE : View.GONE); + + // notifyDataSetChanged() will be called after receiving roster status. + // It will prevent the blocked/unsubscribed icon flickering. + // Roster status is just a query to the roster database so it will work + // also when disconnected. + // FIXME roster status is not available while offline + + updateUI(); + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onRosterStatus(RosterStatusEvent event) { + boolean isSubscribed = event.subscribedFrom && event.subscribedTo; + mMembersAdapter.setSubscribed(event.jid.toString(), isSubscribed); + } + + private void updateUI() { + String selfJid = Kontalk.get().getDefaultAccount().getSelfJID(); + boolean isOwner = KontalkGroup.checkOwnership(mConversation.getGroupJid(), selfJid); + if (mRemoveMenu != null) { + mRemoveMenu.setVisible(isOwner); + } + if (mAddMenu != null) { + mAddMenu.setVisible(isOwner); + } + if (mReaddMenu != null) { + mReaddMenu.setVisible(isOwner); + } + } + + private String[] getGroupMembers() { + String[] members = mConversation.getGroupPeers(); + String[] added = MessagesProviderClient.getGroupMembers(getContext(), + mConversation.getGroupJid(), Groups.MEMBER_PENDING_ADDED); + if (added.length > 0) + members = DataUtils.concatenate(members, added); + // if we are in the group, add ourself to the list + if (mConversation.getGroupMembership() == Groups.MEMBERSHIP_MEMBER) { + String selfJid = Kontalk.get().getDefaultAccount().getSelfJID(); + members = DataUtils.concatenate(members, selfJid); + } + return members; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + mMembersAdapter = new GroupMembersAdapter(getContext(), null); + setListAdapter(mMembersAdapter); + + ListView list = getListView(); + list.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL); + list.setMultiChoiceModeListener(this); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.group_info, container, false); + + mTitle = view.findViewById(R.id.title); + + mSetSubject = view.findViewById(R.id.btn_change_title); + mSetSubject.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + new MaterialDialog.Builder(getContext()) + .title(R.string.title_group_subject) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .input(null, mConversation.getGroupSubject(), true, new MaterialDialog.InputCallback() { + @Override + public void onInput(@NonNull MaterialDialog dialog, CharSequence input) { + setGroupSubject(!TextUtils.isEmpty(input) ? input.toString() : null); + } + }) + .inputRange(0, Groups.GROUP_SUBJECT_MAX_LENGTH) + .show(); + } + }); + mLeave = view.findViewById(R.id.btn_leave); + mLeave.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + confirmLeave(); + } + }); + mIgnoreAll = view.findViewById(R.id.btn_ignore_all); + mIgnoreAll.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + new MaterialDialog.Builder(getContext()) + .title(R.string.title_ignore_all_identities) + .content(R.string.msg_ignore_all_identities) + .positiveText(android.R.string.ok) + .positiveColorRes(R.color.button_danger) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + mMembersAdapter.ignoreAll(); + reload(); + } + }) + .negativeText(android.R.string.cancel) + .show(); + } + }); + + return view; + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + updateUI(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.group_info_menu, menu); + mAddMenu = menu.findItem(R.id.menu_invite); + } + + void setGroupSubject(String subject) { + mConversation.setGroupSubject(subject); + reload(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + // action mode is active - no processing + if (isActionModeActive()) + return true; + + switch (item.getItemId()) { + case R.id.menu_invite: + Activity parent = getActivity(); + if (parent != null) { + parent.setResult(GroupInfoActivity.RESULT_ADD_USERS, null); + parent.finish(); + } + return true; + } + + return super.onOptionsItemSelected(item); + } + + public boolean isActionModeActive() { + return mCheckedItemCount > 0; + } + + @Override + public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) { + if (checked) + mCheckedItemCount++; + else + mCheckedItemCount--; + mode.setTitle(getResources() + .getQuantityString(R.plurals.context_selected, + mCheckedItemCount, mCheckedItemCount)); + mode.invalidate(); + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_remove: + // using clone because listview returns its original copy + removeSelectedUsers(DataUtils + .cloneSparseBooleanArray(getListView().getCheckedItemPositions())); + mode.finish(); + return true; + case R.id.menu_add_again: + // using clone because listview returns its original copy + readdUser(DataUtils + .cloneSparseBooleanArray(getListView().getCheckedItemPositions())); + return true; + case R.id.menu_chat: + openChat(getCheckedItem().contact.getJID()); + return true; + } + return false; + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = mode.getMenuInflater(); + inflater.inflate(R.menu.group_info_ctx, menu); + mRemoveMenu = menu.findItem(R.id.menu_remove); + mChatMenu = menu.findItem(R.id.menu_chat); + mReaddMenu = menu.findItem(R.id.menu_add_again); + updateUI(); + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + mCheckedItemCount = 0; + getListView().clearChoices(); + mMembersAdapter.notifyDataSetChanged(); + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + mChatMenu.setVisible(mCheckedItemCount == 1); + return true; + } + + private GroupMembersAdapter.GroupMember getCheckedItem() { + if (mCheckedItemCount != 1) + throw new IllegalStateException("checked items count must be exactly 1"); + + return (GroupMembersAdapter.GroupMember) getListView() + .getItemAtPosition(getCheckedItemPosition()); + } + + private int getCheckedItemPosition() { + SparseBooleanArray checked = getListView().getCheckedItemPositions(); + return checked.keyAt(checked.indexOfValue(true)); + } + + private void removeSelectedUsers(final SparseBooleanArray checked) { + boolean removingSelf = false; + List users = new LinkedList<>(); + for (int i = 0, c = mMembersAdapter.getCount(); i < c; ++i) { + if (checked.get(i)) { + GroupMembersAdapter.GroupMember member = + (GroupMembersAdapter.GroupMember) mMembersAdapter.getItem(i); + if (Kontalk.get().getDefaultAccount().isSelfJID(member.contact.getJID())) { + removingSelf = true; + } + else { + users.add(member.contact.getJID()); + } + } + } + + if (users.size() > 0) { + mConversation.removeUsers(users.toArray(new String[0])); + reload(); + } + + if (removingSelf) + confirmLeave(); + } + + @Override + public void onContactInvalidated(String userId) { + Activity context = getActivity(); + if (context != null) { + context.runOnUiThread(new Runnable() { + @Override + public void run() { + // just reload + reload(); + } + }); + } + } + + @Override + public void onListItemClick(ListView l, View v, int position, long id) { + // open identity dialog + // TODO one day this will be the contact info activity + GroupMembersAdapter.GroupMember member = + (GroupMembersAdapter.GroupMember) mMembersAdapter.getItem(position); + showIdentityDialog(member.contact, member.subscribed); + } + + private void showIdentityDialog(Contact c, Boolean subscribed) { + final String jid = c.getJID(); + final String dialogFingerprint; + final String fingerprint; + final boolean selfJid = Kontalk.get().getDefaultAccount().isSelfJID(jid); + int titleResId = R.string.title_identity; + String uid; + + PGPPublicKeyRing publicKey = Keyring.getPublicKey(getContext(), jid, MyUsers.Keys.TRUST_UNKNOWN); + if (publicKey != null) { + PGPPublicKey pk = PGP.getMasterKey(publicKey); + String rawFingerprint = PGP.getFingerprint(pk); + fingerprint = PGP.formatFingerprint(rawFingerprint); + + uid = PGP.getUserId(pk, XmppStringUtils.parseDomain(jid)); + dialogFingerprint = selfJid ? null : rawFingerprint; + } + else { + // FIXME using another string + fingerprint = getString(R.string.peer_unknown); + uid = null; + dialogFingerprint = null; + } + + if (Kontalk.get().getDefaultAccount().isSelfJID(jid)) { + titleResId = R.string.title_identity_self; + } + + SpannableStringBuilder text = new SpannableStringBuilder(); + + if (c.getName() != null && c.getNumber() != null) { + text.append(c.getName()) + .append('\n') + .append(c.getNumber()); + } + else { + int start = text.length(); + text.append(uid != null ? uid : c.getJID()); + text.setSpan(SystemUtils.getTypefaceSpan(Typeface.BOLD), start, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + text.append('\n') + .append(getString(R.string.text_invitation2)) + .append('\n'); + + int start = text.length(); + text.append(fingerprint); + text.setSpan(SystemUtils.getTypefaceSpan(Typeface.BOLD), start, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + int trustStringId; + CharacterStyle[] trustSpans = null; + + if (subscribed == null) { + trustStringId = 0; + } + else if (subscribed) { + int trustedLevel; + if (c.isKeyChanged()) { + // the key has changed and was not trusted yet + trustedLevel = MyUsers.Keys.TRUST_UNKNOWN; + } + else { + trustedLevel = c.getTrustedLevel(); + } + + switch (trustedLevel) { + case MyUsers.Keys.TRUST_IGNORED: + trustStringId = R.string.trust_ignored; + trustSpans = new CharacterStyle[] { + SystemUtils.getTypefaceSpan(Typeface.BOLD), + SystemUtils.getColoredSpan(getContext(), R.color.button_danger) + }; + break; + + case MyUsers.Keys.TRUST_VERIFIED: + trustStringId = R.string.trust_verified; + trustSpans = new CharacterStyle[] { + SystemUtils.getTypefaceSpan(Typeface.BOLD), + SystemUtils.getColoredSpan(getContext(), R.color.button_success) + }; + break; + + case MyUsers.Keys.TRUST_UNKNOWN: + default: + trustStringId = R.string.trust_unknown; + trustSpans = new CharacterStyle[] { + SystemUtils.getTypefaceSpan(Typeface.BOLD), + SystemUtils.getColoredSpan(getContext(), R.color.button_danger) + }; + break; + } + } + else { + trustStringId = R.string.status_notsubscribed; + trustSpans = new CharacterStyle[] { + SystemUtils.getTypefaceSpan(Typeface.BOLD), + }; + } + + if (trustStringId > 0) { + text.append('\n').append(getString(R.string.status_label)); + start = text.length(); + text.append(getString(trustStringId)); + for (CharacterStyle span : trustSpans) + text.setSpan(span, start, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + MaterialDialog.Builder builder = new MaterialDialog.Builder(getContext()) + .content(text) + .title(titleResId); + + if (dialogFingerprint != null && subscribed == Boolean.TRUE) { + builder.onAny(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + switch (which) { + case POSITIVE: + // trust the key + trustKey(jid, dialogFingerprint, MyUsers.Keys.TRUST_VERIFIED); + break; + case NEUTRAL: + // ignore the key + trustKey(jid, dialogFingerprint, MyUsers.Keys.TRUST_IGNORED); + break; + case NEGATIVE: + // untrust the key + trustKey(jid, dialogFingerprint, MyUsers.Keys.TRUST_UNKNOWN); + break; + } + } + }) + .positiveText(R.string.button_accept) + .positiveColorRes(R.color.button_success) + .neutralText(R.string.button_ignore) + .negativeText(R.string.button_refuse) + .negativeColorRes(R.color.button_danger); + } + else if (!selfJid) { + builder.onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + openChat(jid); + } + }) + .positiveText(R.string.button_private_chat); + } + + builder.show(); + } + + private void readdUser(final SparseBooleanArray checked) { + List users = new LinkedList<>(); + for (int i = 0, c = mMembersAdapter.getCount(); i < c; ++i) { + if (checked.get(i)) { + GroupMembersAdapter.GroupMember member = + (GroupMembersAdapter.GroupMember) mMembersAdapter.getItem(i); + if (!Kontalk.get().getDefaultAccount().isSelfJID(member.contact.getJID())) { + users.add(member.contact.getJID()); + } + } + } + + if (users.size() > 0) { + mConversation.addUsers(users.toArray(new String[0])); + } + + getActivity().finish(); + } + + void openChat(String jid) { + Intent i = new Intent(); + i.setData(MyMessages.Threads.getUri(jid)); + Activity parent = getActivity(); + parent.setResult(GroupInfoActivity.RESULT_PRIVATE_CHAT, i); + parent.finish(); + } + + void trustKey(String jid, String fingerprint, int trustLevel) { + Kontalk.get().getMessagesController() + .setTrustLevelAndRetryMessages(jid, fingerprint, trustLevel); + Contact.invalidate(jid); + reload(); + } + + void confirmLeave() { + new MaterialDialog.Builder(getContext()) + .content(R.string.confirm_will_leave_group) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + // leave group + mConversation.leaveGroup(); + getActivity().finish(); + } + }) + .show(); + } + + void reload() { + // reload conversation data + Bundle data = getArguments(); + long threadId = data.getLong("conversation"); + loadConversation(threadId); + } + + @Override + public void onResume() { + super.onResume(); + reload(); + } + + @Override + public void onStop() { + super.onStop(); + mServiceBus.unregister(this); + } + + private static final class GroupMembersAdapter extends BaseAdapter { + private static final class GroupMember { + final Contact contact; + Boolean subscribed; + + GroupMember(Contact contact, Boolean subscribed) { + this.contact = contact; + this.subscribed = subscribed; + } + } + + private final Context mContext; + private final List mMembers; + private String mOwner; + private String mGroupJid; + + GroupMembersAdapter(Context context, String groupJid) { + mContext = context; + mMembers = new LinkedList<>(); + mGroupJid = groupJid; + } + + public void setGroupJid(String groupJid) { + mGroupJid = groupJid; + } + + public void clear() { + mMembers.clear(); + } + + @Override + public void notifyDataSetChanged() { + Collections.sort(mMembers, new DisplayNameComparator()); + super.notifyDataSetChanged(); + } + + public void add(Contact contact, boolean isOwner, Boolean subscribed) { + mMembers.add(new GroupMember(contact, subscribed)); + if (isOwner) + mOwner = contact.getJID(); + } + + @Override + public int getCount() { + return mMembers.size(); + } + + @Override + public Object getItem(int position) { + return mMembers.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View v; + if (convertView == null) { + v = newView(parent); + } else { + v = convertView; + } + bindView(v, position); + return v; + } + + private View newView(ViewGroup parent) { + return LayoutInflater.from(mContext) + .inflate(R.layout.contact_item, parent, false); + } + + private void bindView(View v, int position) { + ContactsListItem view = (ContactsListItem) v; + GroupMember member = (GroupMember) getItem(position); + Contact contact = member.contact; + String prependStatus = null; + CharacterStyle prependStyle = null; + if (contact.getJID().equalsIgnoreCase(mOwner)) { + prependStatus = mContext.getString(R.string.group_info_owner_member); + prependStyle = new ForegroundColorSpan(Color.RED); + } + view.bind(mContext, contact, prependStatus, prependStyle, + member.subscribed); + } + + public void ignoreAll() { + synchronized (mMembers) { + for (GroupMember m : mMembers) { + Contact c = m.contact; + String fingerprint = c.getFingerprint(); + if (fingerprint != null && (c.isKeyChanged() || c.getTrustedLevel() == MyUsers.Keys.TRUST_UNKNOWN)) { + Keyring.setTrustLevel(mContext, c.getJID(), fingerprint, MyUsers.Keys.TRUST_IGNORED); + Contact.invalidate(c.getJID()); + } + } + Kontalk.get().getMessagesController() + .retryMessagesTo(mGroupJid); + } + } + + public void setSubscribed(String jid, boolean isSubscribed) { + synchronized (mMembers) { + for (GroupMember m : mMembers) { + Contact c = m.contact; + if (c.getJID().equalsIgnoreCase(jid)) { + m.subscribed = isSubscribed; + break; + } + } + } + // we don't need to sort, so call super directly + super.notifyDataSetChanged(); + } + + static class DisplayNameComparator implements + Comparator { + DisplayNameComparator() { + mCollator.setStrength(Collator.PRIMARY); + } + + public final int compare(GroupMember a, GroupMember b) { + return mCollator.compare(a.contact.getDisplayName(), + b.contact.getDisplayName()); + } + + private final Collator mCollator = Collator.getInstance(); + } + + } + + public interface GroupInfoParent { + + void dismiss(); + + } + +} diff --git a/app/src/main/java/org/kontalk/ui/GroupMessageFragment.java b/app/src/main/java/org/kontalk/ui/GroupMessageFragment.java new file mode 100644 index 000000000..b6d13cb72 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/GroupMessageFragment.java @@ -0,0 +1,661 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.jivesoftware.smack.util.StringUtils; +import org.jivesoftware.smackx.chatstates.ChatState; +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; +import org.bouncycastle.openpgp.PGPPublicKeyRing; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.database.sqlite.SQLiteDiskIOException; +import android.net.Uri; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.client.KontalkGroupManager; +import org.kontalk.crypto.PGP; +import org.kontalk.data.Contact; +import org.kontalk.data.Conversation; +import org.kontalk.message.CompositeMessage; +import org.kontalk.provider.Keyring; +import org.kontalk.provider.MyMessages; +import org.kontalk.provider.MyMessages.Groups; +import org.kontalk.provider.MyUsers; +import org.kontalk.service.msgcenter.event.NoPresenceEvent; +import org.kontalk.service.msgcenter.event.PresenceEvent; +import org.kontalk.service.msgcenter.event.PresenceRequest; +import org.kontalk.service.msgcenter.event.PublicKeyEvent; +import org.kontalk.service.msgcenter.event.PublicKeyRequest; +import org.kontalk.service.msgcenter.event.RosterLoadedEvent; +import org.kontalk.service.msgcenter.event.SendChatStateRequest; +import org.kontalk.service.msgcenter.event.UserOfflineEvent; +import org.kontalk.service.msgcenter.event.UserOnlineEvent; +import org.kontalk.util.Preferences; +import org.kontalk.util.XMPPUtils; + + +/** + * Composer fragment for group chats. + * @author Daniele Ricci + */ +public class GroupMessageFragment extends AbstractComposeFragment { + private static final String TAG = ComposeMessage.TAG; + + private static final int REQUEST_GROUP_INFO = REQUEST_FIRST_CHILD + 1; + + /** The virtual or real group JID. */ + private String mGroupJID; + + /** List of typing users. */ + private Set mTypingUsers = new HashSet<>(); + + /** Map of request IDs for public key requests. */ + private Map mKeyRequestIds = new HashMap<>(); + + private MenuItem mInviteGroupMenu; + private MenuItem mSetGroupSubjectMenu; + private MenuItem mGroupInfoMenu; + private MenuItem mLeaveGroupMenu; + + @Override + protected void updateUI() { + super.updateUI(); + Context context; + if (mInviteGroupMenu != null && (context = getContext()) != null) { + boolean visible; + String myUser = Kontalk.get().getDefaultAccount().getSelfJID(); + + // menu items requiring ownership and membership + visible = KontalkGroupManager.KontalkGroup + .checkOwnership(mConversation.getGroupJid(), myUser) && + mConversation.getGroupMembership() == Groups.MEMBERSHIP_MEMBER; + mInviteGroupMenu.setVisible(visible); + mInviteGroupMenu.setEnabled(visible); + mSetGroupSubjectMenu.setVisible(visible); + mSetGroupSubjectMenu.setEnabled(visible); + + // menu items requiring membership + visible = mConversation.getGroupMembership() == Groups.MEMBERSHIP_MEMBER; + mGroupInfoMenu.setVisible(visible); + mGroupInfoMenu.setEnabled(visible); + mLeaveGroupMenu.setVisible(visible); + mLeaveGroupMenu.setEnabled(visible); + + if (visible) { + if (mConversation != null) { + int count = mConversation.getGroupPeers().length + 1; + visible = count > 1; + } + else { + visible = false; + } + } + + if (!visible) + tryHideAttachmentView(); + } + } + + @Override + protected void onInflateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.group_message_menu, menu); + mInviteGroupMenu = menu.findItem(R.id.invite_group); + mSetGroupSubjectMenu = menu.findItem(R.id.group_subject); + mGroupInfoMenu = menu.findItem(R.id.group_info); + mLeaveGroupMenu = menu.findItem(R.id.leave_group); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (super.onOptionsItemSelected(item)) + return true; + + switch (item.getItemId()) { + case R.id.group_info: + viewGroupInfo(); + return true; + case R.id.group_subject: + changeGroupSubject(); + return true; + case R.id.leave_group: + leaveGroup(); + return true; + } + + return false; + } + + @Override + protected void addUsers(String[] members) { + Set existingMembers = new HashSet<>(); + Collections.addAll(existingMembers, mConversation.getGroupPeers()); + + // ensure no duplicates + String selfJid = Kontalk.get().getDefaultAccount().getSelfJID(); + Set usersList = new HashSet<>(); + for (String member : members) { + // exclude ourselves and do not add if already an existing member + if (!member.equalsIgnoreCase(selfJid) && !existingMembers.contains(member)) + usersList.add(member); + } + + if (usersList.size() > 0) { + String[] users = usersList.toArray(new String[0]); + mConversation.addUsers(users); + // reload conversation + ((ComposeMessageParent) getActivity()).loadConversation(getThreadId(), false); + } + } + + @Override + protected void deleteConversation() { + try { + // this will also leave the group if true is passed + mConversation.delete(false); + } + catch (SQLiteDiskIOException e) { + Log.w(TAG, "error deleting thread"); + Toast.makeText(getActivity(), R.string.error_delete_thread, + Toast.LENGTH_LONG).show(); + } + } + + private void leaveGroup() { + new MaterialDialog.Builder(getActivity()) + .content(R.string.confirm_will_leave_group) + .positiveText(android.R.string.ok) + .positiveColorRes(R.color.button_danger) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + // leave group + if (dialog.isPromptCheckBoxChecked()) { + mConversation.delete(true); + // manually close the conversation + closeConversation(); + } + else { + mConversation.leaveGroup(); + // reload conversation + if (isVisible()) + startQuery(); + } + } + }) + .checkBoxPromptRes(R.string.leave_group_delete_messages, false, null) + .negativeText(android.R.string.cancel) + .show(); + } + + private void changeGroupSubject() { + new MaterialDialog.Builder(getContext()) + .title(R.string.title_group_subject) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .input(null, mConversation.getGroupSubject(), true, new MaterialDialog.InputCallback() { + @Override + public void onInput(@NonNull MaterialDialog dialog, CharSequence input) { + setGroupSubject(!TextUtils.isEmpty(input) ? input.toString() : null); + } + }) + .inputRange(0, Groups.GROUP_SUBJECT_MAX_LENGTH) + .show(); + } + + void setGroupSubject(String subject) { + mConversation.setGroupSubject(subject); + // reload conversation + ((ComposeMessageParent) getActivity()).loadConversation(getThreadId(), false); + } + + private void requestPresence() { + if (mConversation != null) { + String[] users = mConversation.getGroupPeers(); + if (users != null) { + for (String user : users) { + mServiceBus.post(new PresenceRequest(JidCreate.bareFromOrThrowUnchecked(user))); + } + } + } + } + + @Override + protected String getDecodedPeer(CompositeMessage msg) { + if (msg.getDirection() == MyMessages.Messages.DIRECTION_IN) { + String userId = msg.getSender(); + Contact c = Contact.findByUserId(getContext(), userId); + return c != null ? c.getNumber() : userId; + } + return null; + } + + @Override + protected String getDecodedName(CompositeMessage msg) { + if (msg.getDirection() == MyMessages.Messages.DIRECTION_IN) { + String userId = msg.getSender(); + Contact c = Contact.findByUserId(getContext(), userId); + return c.getName(); + } + return null; + } + + @Override + protected void loadConversationMetadata(Uri uri) { + super.loadConversationMetadata(uri); + if (mConversation != null) { + mGroupJID = mConversation.getRecipient(); + mUserName = mGroupJID; + } + } + + /** + * Since this is a group chat, it can be only opened from within the app. + * No ACTION_VIEW intent ever will be delivered for this. + */ + @Override + protected void handleActionView(Uri uri) { + throw new AssertionError("This shouldn't be called ever!"); + } + + /** + * Used only during activity restore. + */ + @Override + protected boolean handleActionViewConversation(Uri uri, Bundle args) { + mGroupJID = uri.getPathSegments().get(1); + mConversation = Conversation.loadFromUserId(getActivity(), mGroupJID); + // unlikely, but better safe than sorry + if (mConversation == null) { + Log.i(TAG, "conversation for " + mGroupJID + " not found - exiting"); + return false; + } + + setThreadId(mConversation.getThreadId()); + mUserName = mGroupJID; + return true; + } + + @Override + protected void onArgumentsProcessed() { + } + + @Override + protected void onConversationCreated() { + // warning will be reloaded if necessary + hideWarning(); + + super.onConversationCreated(); + + boolean sendEnabled; + int membership = mConversation.getGroupMembership(); + switch (membership) { + case Groups.MEMBERSHIP_PARTED: + sendEnabled = false; + break; + case Groups.MEMBERSHIP_KICKED: + sendEnabled = false; + break; + case Groups.MEMBERSHIP_OBSERVER: { + int count = mConversation.getGroupPeers().length; + sendEnabled = count > 1; + break; + } + case Groups.MEMBERSHIP_MEMBER: { + // +1 because we are not included in the members list + int count = mConversation.getGroupPeers().length + 1; + sendEnabled = count > 1; + break; + } + default: + // shouldn't happen + throw new RuntimeException("Unknown membership status: " + membership); + } + + // disable sending if necessary + mComposer.setSendEnabled(sendEnabled); + + updateStatusText(); + } + + private void showKeyWarning() { + Activity context = getActivity(); + if (context != null) { + showWarning(context.getText(R.string.warning_public_key_group_unverified), new View.OnClickListener() { + @Override + public void onClick(View v) { + viewGroupInfo(); + } + }, WarningType.FATAL); + } + } + + private void requestPublicKey(Jid jid) { + Context context = getActivity(); + if (context != null) { + String requestId = StringUtils.randomString(6); + mKeyRequestIds.put(jid.asBareJid().toString(), requestId); + mServiceBus.post(new PublicKeyRequest(requestId, jid)); + } + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onPublicKey(PublicKeyEvent event) { + if (event.id != null && event.id.equals(mKeyRequestIds.get(event.jid.asBareJid().toString()))) { + // invalidate contact + Contact.invalidate(event.jid.asBareJid().toString()); + // request presence again + requestPresence(); + } + } + + private void onUserStatusChanged(PresenceEvent event) { + Context context = getContext(); + if (context == null) + return; + + if (Log.isDebug()) { + Log.d(TAG, "group member presence from " + event.jid + " (type=" + event.type + ", fingerprint=" + event.fingerprint + ")"); + } + + updateStatusText(); + + // no encryption - pointless to verify keys + if (!Preferences.getEncryptionEnabled(context)) + return; + + String bareJid = event.jid.asBareJid().toString(); + + Contact contact = Contact.findByUserId(context, bareJid); + // if this is null, we are accepting the key for the first time + PGPPublicKeyRing trustedPublicKey = contact.getTrustedPublicKeyRing(); + + // request the key if we don't have a trusted one and of course if the user has a key + boolean unknownKey = (trustedPublicKey == null && contact.getFingerprint() != null); + boolean changedKey = false; + // check if fingerprint changed (only if we have roster presence) + if (event.type != null && trustedPublicKey != null && event.fingerprint != null) { + String oldFingerprint = PGP.getFingerprint(PGP.getMasterKey(trustedPublicKey)); + if (!event.fingerprint.equalsIgnoreCase(oldFingerprint)) { + // fingerprint has changed since last time + changedKey = true; + } + } + // user has no key (or we have no roster presence) or it couldn't be found: request it + else if ((trustedPublicKey == null && event.fingerprint == null) || event.type == null) { + if (mKeyRequestIds.containsKey(event.jid.toString())) { + // avoid request loop + mKeyRequestIds.remove(event.jid.toString()); + } + else { + // autotrust the key we are about to request + // but set the trust level to ignored because we didn't really verify it + Keyring.setAutoTrustLevel(context, event.jid.toString(), MyUsers.Keys.TRUST_IGNORED); + requestPublicKey(event.jid); + } + } + + if (Keyring.isAdvancedMode(context, event.jid.toString()) && (changedKey || unknownKey)) { + showKeyWarning(); + } + } + + @Override + public void onUserOnline(UserOnlineEvent event) { + // check that origin matches the current chat + if (!isUserId(event.jid.toString())) { + // not for us + return; + } + + super.onUserOnline(event); + onUserStatusChanged(event); + } + + @Override + public void onUserOffline(UserOfflineEvent event) { + // check that origin matches the current chat + if (!isUserId(event.jid.toString())) { + // not for us + return; + } + + super.onUserOffline(event); + onUserStatusChanged(event); + } + + @Override + public void onNoUserPresence(NoPresenceEvent event) { + // nothing to do + } + + @Override + protected void resetConnectionStatus() { + super.resetConnectionStatus(); + mTypingUsers.clear(); + updateStatusText(); + mKeyRequestIds.clear(); + } + + @Override + public void onRosterLoaded(RosterLoadedEvent event) { + requestPresence(); + } + + @Override + protected void onStartTyping(String jid, @Nullable String groupJid) { + if (mGroupJID.equals(groupJid)) { + mTypingUsers.add(jid); + updateStatusText(); + } + } + + @Override + protected void onStopTyping(String jid, @Nullable String groupJid) { + if (mGroupJID.equals(groupJid)) { + mTypingUsers.remove(jid); + updateStatusText(); + } + } + + @Override + protected boolean isUserId(String jid) { + if (mConversation != null) { + String[] users = mConversation.getGroupPeers(); + if (users != null) { + for (String user : users) { + if (XMPPUtils.equalsBareJID(jid, user)) + return true; + } + } + } + return false; + } + + @Override + public String getUserId() { + return mGroupJID; + } + + @Override + public boolean sendTyping() { + if (mAvailableResources.size() > 0) { + mServiceBus.post(new SendChatStateRequest.Builder(null) + .setChatState(ChatState.composing) + .setTo(JidCreate.fromOrThrowUnchecked(mGroupJID)) + .setGroup(true) + .build()); + return true; + } + return false; + } + + @Override + public boolean sendInactive() { + if (mAvailableResources.size() > 0) { + mServiceBus.post(new SendChatStateRequest.Builder(null) + .setChatState(ChatState.inactive) + .setTo(JidCreate.fromOrThrowUnchecked(mGroupJID)) + .setGroup(true) + .build()); + return true; + } + return false; + } + + /** Updates the status text in the toolbar. */ + private void updateStatusText() { + Context context = getContext(); + if (context == null) + return; + + int typingPeople = mTypingUsers.size(); + if (typingPeople > 0) { + int msgId; + Object[] args; + if (typingPeople == 1) { + Contact c = Contact.findByUserId(context, mTypingUsers.iterator().next()); + msgId = R.string.seen_group_typing_label_one; + args = new Object[] { c.getShortDisplayName() }; + } + else if (typingPeople == 2) { + Iterator users = mTypingUsers.iterator(); + Contact c1 = Contact.findByUserId(context, users.next()); + Contact c2 = Contact.findByUserId(context, users.next()); + msgId = R.string.seen_group_typing_label_two; + args = new Object[] { c1.getShortDisplayName(), c2.getShortDisplayName() }; + } + else { + msgId = R.string.seen_group_typing_label_more; + args = new Object[] { typingPeople }; + } + setActivityTitle(null, context.getResources().getString(msgId, args)); + } + else { + final Conversation conv = mConversation; + if (conv != null) { + // set group title + String subject = conv.getGroupSubject(); + if (TextUtils.isEmpty(subject)) + subject = context.getString(R.string.group_untitled); + + String status; + int membership = conv.getGroupMembership(); + switch (membership) { + case Groups.MEMBERSHIP_PARTED: + status = context.getString(R.string.group_command_text_part_self); + break; + case Groups.MEMBERSHIP_KICKED: + status = context.getString(R.string.group_command_text_part_kicked); + break; + case Groups.MEMBERSHIP_OBSERVER: { + int count = conv.getGroupPeers().length; + status = getMemberCountQuantityString(count, mAvailableResources.size()); + break; + } + case Groups.MEMBERSHIP_MEMBER: { + // +1 because we are not included in the members list + int count = conv.getGroupPeers().length + 1; + // the "connected" here is used to show ourselves as online + status = getMemberCountQuantityString(count, mConnected ? mAvailableResources.size() + 1 : 0); + break; + } + default: + // shouldn't happen + throw new RuntimeException("Unknown membership status: " + membership); + } + + setActivityTitle(subject, status); + } + } + } + + // FIXME not i18n-friendly (especially for RTL) + private String getMemberCountQuantityString(int count, int online) { + StringBuilder msg = new StringBuilder(); + msg.append(getResources().getQuantityString(R.plurals.group_people, count, count)); + + if (online > 0) { + msg.append(", ") + .append(getResources().getQuantityString(R.plurals.group_people_online, online, online)); + } + + return msg.toString(); + } + + public void viewGroupInfo() { + final Activity ctx = getActivity(); + if (ctx == null) + return; + + int membership = mConversation != null ? mConversation.getGroupMembership() : Groups.MEMBERSHIP_PARTED; + if (membership == Groups.MEMBERSHIP_MEMBER || membership == Groups.MEMBERSHIP_OBSERVER) { + if (Kontalk.hasTwoPanesUI(ctx)) { + GroupInfoDialog.start(ctx, this, getThreadId(), REQUEST_GROUP_INFO); + } + else { + GroupInfoActivity.start(ctx, this, getThreadId(), REQUEST_GROUP_INFO); + } + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_GROUP_INFO) { + switch (resultCode) { + case GroupInfoActivity.RESULT_PRIVATE_CHAT: + ((ComposeMessageParent) getActivity()) + .loadConversation(data.getData()); + break; + + case GroupInfoActivity.RESULT_ADD_USERS: + addUsers(); + break; + } + } + else { + super.onActivityResult(requestCode, resultCode, data); + } + } +} diff --git a/app/src/main/java/org/kontalk/ui/ImportDeviceActivity.java b/app/src/main/java/org/kontalk/ui/ImportDeviceActivity.java new file mode 100644 index 000000000..53bdd8593 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ImportDeviceActivity.java @@ -0,0 +1,133 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import android.text.TextUtils; +import android.view.View; +import android.widget.EditText; + +import org.kontalk.BuildConfig; +import org.kontalk.R; +import org.kontalk.service.registration.RegistrationService; + + +/** + * Allows the user to input the server name and the secret code for importing + * the personal key from another device. + * @author Daniele Ricci + */ +public class ImportDeviceActivity extends ToolbarActivity implements View.OnClickListener { + + public static final String EXTRA_ACCOUNT = "org.kontalk.import_device.account"; + public static final String EXTRA_SERVER = "org.kontalk.import_device.server"; + public static final String EXTRA_TOKEN = "org.kontalk.import_device.token"; + + private EditText mTextAccount; + private EditText mTextServer; + private EditText mTextToken; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.import_device_screen); + + mTextAccount = findViewById(R.id.account); + mTextServer = findViewById(R.id.servername); + mTextToken = findViewById(R.id.token); + + findViewById(R.id.button_import).setOnClickListener(this); + + setupToolbar(true, true); + } + + /** Used only for the main button. */ + @Override + public void onClick(View view) { + String account = mTextAccount.getText().toString().trim(); + String server = mTextServer.getText().toString().trim(); + String token = mTextToken.getText().toString().trim(); + if (TextUtils.isEmpty(account) || TextUtils.isEmpty(server) || TextUtils.isEmpty(token)) { + error(R.string.err_import_device_fill_all); + return; + } + + if (!BuildConfig.DEBUG) { + // verify the phone number just in case + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + Phonenumber.PhoneNumber phone; + try { + phone = util.parse(account, null); + // handle special cases + RegistrationService.handleSpecialCases(phone); + + String regionCode = util.getRegionCodeForCountryCode(phone.getCountryCode()); + if (!util.isValidNumberForRegion(phone, regionCode) && !RegistrationService.isSpecialNumber(phone)) + throw new NumberParseException(NumberParseException.ErrorType.INVALID_COUNTRY_CODE, "invalid number for region " + regionCode); + } + catch (Exception e) { + error(R.string.msg_invalid_number); + return; + } + + account = util.format(phone, PhoneNumberUtil.PhoneNumberFormat.E164); + } + + token = preprocessToken(token); + + Intent data = new Intent(); + data.putExtra(EXTRA_ACCOUNT, account); + data.putExtra(EXTRA_SERVER, server); + data.putExtra(EXTRA_TOKEN, token); + setResult(RESULT_OK, data); + finish(); + } + + /** Preprocess a token typed in by the user (e.g. remove spaces). */ + private String preprocessToken(CharSequence rawToken) { + return rawToken.toString().replace(" ", ""); + } + + private void error(@StringRes int text) { + new MaterialDialog.Builder(this) + .content(text) + .positiveText(android.R.string.ok) + .show(); + } + + @Override + protected boolean isNormalUpNavigation() { + return true; + } + + public static void start(@NonNull Activity activity, int requestCode) { + activity.startActivityForResult(new Intent(activity, + ImportDeviceActivity.class), requestCode); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/InputDialog.java b/app/src/main/java/org/kontalk/ui/InputDialog.java deleted file mode 100644 index 054b3b41f..000000000 --- a/app/src/main/java/org/kontalk/ui/InputDialog.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.ui; - -import org.kontalk.R; - -import android.app.AlertDialog; -import android.app.Dialog; -import android.content.Context; -import android.support.annotation.NonNull; -import android.text.InputType; -import android.text.method.PasswordTransformationMethod; -import android.view.LayoutInflater; -import android.view.View; -import android.view.Window; -import android.view.WindowManager; -import android.widget.EditText; - - -/** - * A custom {@link AlertDialog} with an {@link EditText} inside. - * This isn't really a dialog class, it is rather a container for a custom - * dialog builder which adds some features over the default one. - * @author Daniele Ricci - */ -public class InputDialog { - - private static final int TEXT_VIEW_ID = R.id.textinput; - - private InputDialog() { - } - - public static CharSequence getInputText(Dialog dialog) { - return ((EditText) dialog.findViewById(TEXT_VIEW_ID)).getText(); - } - - public static class Builder extends AlertDialog.Builder { - - public Builder(Context context, int inputType) { - super(context); - - setCustomView(context, inputType); - } - - private void setCustomView(Context context, int inputType) { - View view = LayoutInflater.from(context) - .inflate(R.layout.edittext_dialog, null, false); - EditText text = (EditText) view.findViewById(TEXT_VIEW_ID); - - text.setInputType(inputType); - if ((inputType & InputType.TYPE_TEXT_VARIATION_PASSWORD) != 0) { - text.setTransformationMethod(PasswordTransformationMethod.getInstance()); - } - - setView(view); - } - - @NonNull - @Override - public AlertDialog create() { - AlertDialog dialog = super.create(); - requestInputMethod(dialog); - return dialog; - } - } - - static void requestInputMethod(Dialog dialog) { - Window window = dialog.getWindow(); - window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); - } - -} diff --git a/app/src/main/java/org/kontalk/ui/LockedDialog.java b/app/src/main/java/org/kontalk/ui/LockedDialog.java new file mode 100644 index 000000000..eba331ff7 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/LockedDialog.java @@ -0,0 +1,51 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import com.afollestad.materialdialogs.MaterialDialog; + +import android.content.Context; + + +/** + * A dialog that can't be dismissed. + * @author Daniele Ricci + * @version 1.0 + */ +public class LockedDialog { + + private LockedDialog() { + } + + public static class Builder extends NonSearchableDialog.Builder { + + public Builder(Context context) { + super(context); + cancelable(false); + } + + @Override + public MaterialDialog build() { + MaterialDialog dialog = super.build(); + dialog.setCanceledOnTouchOutside(false); + return dialog; + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/LockedProgressDialog.java b/app/src/main/java/org/kontalk/ui/LockedProgressDialog.java deleted file mode 100644 index e2c1d1b4a..000000000 --- a/app/src/main/java/org/kontalk/ui/LockedProgressDialog.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.ui; - -import android.content.Context; - - -/** - * A progress dialog that can't be dismissed. - * @author Daniele Ricci - * @version 1.0 - */ -public class LockedProgressDialog extends NonSearchableProgressDialog { - - public LockedProgressDialog(Context ctx) { - super(ctx); - setCancelable(false); - setCanceledOnTouchOutside(false); - } - -} diff --git a/app/src/main/java/org/kontalk/ui/MainActivity.java b/app/src/main/java/org/kontalk/ui/MainActivity.java new file mode 100644 index 000000000..1190762c0 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/MainActivity.java @@ -0,0 +1,350 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import java.io.IOException; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.provider.Settings; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import android.text.InputType; +import android.widget.CompoundButton; +import android.widget.Toast; + +import pub.devrel.easypermissions.AfterPermissionGranted; +import pub.devrel.easypermissions.EasyPermissions; + +import org.jivesoftware.smack.util.SHA1; +import org.kontalk.Kontalk; +import org.kontalk.R; +import org.kontalk.authenticator.Authenticator; +import org.kontalk.data.Contact; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.sync.SyncAdapter; +import org.kontalk.util.Permissions; +import org.kontalk.util.Preferences; +import org.kontalk.util.SystemUtils; + + +/** + * Abstract main activity. + * @author Daniele Ricci + * @version 1.0 + */ +public abstract class MainActivity extends ToolbarActivity { + + private static final int DIALOG_AUTH_ERROR_WARNING = 1; + private static final int DIALOG_AUTH_REQUEST_PASSWORD = 2; + + private static final String ACTION_AUTH_ERROR_WARNING = "org.kontalk.AUTH_ERROR_WARN"; + private static final String ACTION_AUTH_REQUEST_PASSWORD = "org.kontalk.AUTH_REQUEST_PASSWORD"; + + /** + * Doesn't really matter because subclasses should not use use setDisplayHomeAsUpEnabled. + */ + @Override + protected boolean isNormalUpNavigation() { + return true; + } + + /** + * To be called at the end of subclasses' {@link #onCreate}. + * @return true if the method handled the situation, false otherwise + */ + protected boolean afterOnCreate() { + askPermissions(); + + return !checkPassword() && (ifHuaweiAlert() || ifDozeAlert()); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this); + } + + @SuppressLint("InlinedApi") + private void askPermissions() { + if (!Preferences.isPermissionAsked(Manifest.permission.READ_CONTACTS) || + !Preferences.isPermissionAsked(Manifest.permission.WRITE_CONTACTS)) { + + if (!Permissions.canWriteContacts(this)) { + Permissions.requestContacts(this, getString(R.string.err_contacts_denied)); + + Preferences.setPermissionAsked(Manifest.permission.READ_CONTACTS); + Preferences.setPermissionAsked(Manifest.permission.WRITE_CONTACTS); + } + } + + if (!SystemUtils.supportsScopedStorage()) { + if (!Preferences.isPermissionAsked(Manifest.permission.READ_EXTERNAL_STORAGE) || + !Preferences.isPermissionAsked(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + + if (!Permissions.canWriteExternalStorage(this)) { + Permissions.requestWriteExternalStorage(this, getString(R.string.err_storage_denied)); + + Preferences.setPermissionAsked(Manifest.permission.READ_EXTERNAL_STORAGE); + Preferences.setPermissionAsked(Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + } + } + } + + @AfterPermissionGranted(Permissions.RC_CONTACTS) + void contactsGranted() { + // let us register the content observer for contacts if necessary + Contact.init(this); + // we finally have contacts, trigger a sync + SyncAdapter.requestSync(MainActivity.this, true); + } + + private boolean ifDozeAlert() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) + return false; + + boolean skipMessage = Preferences.isSkipDozeMode(); + if (!skipMessage) { + if (!SystemUtils.isIgnoringBatteryOptimizations(this)) { + new MaterialDialog.Builder(this) + .title(R.string.title_doze_mode) + .content(R.string.msg_doze_mode) + .positiveText(R.string.btn_request_doze_mode_whitelist) + .neutralText(R.string.learn_more) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @RequiresApi(Build.VERSION_CODES.M) + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + requestDozeModeWhitelist(); + } + }) + .onNeutral(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + SystemUtils.openURL(dialog.getContext(), getString(R.string.help_doze_whitelist)); + } + }) + .checkBoxPromptRes(R.string.check_dont_show_again, false, new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + Preferences.setSkipDozeMode(isChecked); + } + }) + .show(); + + return true; + } + else { + Preferences.setSkipDozeMode(true); + } + } + return false; + } + + @SuppressLint("BatteryLife") + @RequiresApi(Build.VERSION_CODES.M) + void requestDozeModeWhitelist() { + try { + startActivity(new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:" + getPackageName()))); + } + catch (ActivityNotFoundException e) { + try { + startActivity(new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)); + Toast.makeText(this, R.string.msg_request_doze_failed_manual, Toast.LENGTH_LONG).show(); + } + catch (ActivityNotFoundException e2) { + // you may as well throw your device out of the window + startActivity(new Intent(Settings.ACTION_SETTINGS)); + Toast.makeText(this, R.string.msg_request_doze_failed_all, Toast.LENGTH_LONG).show(); + } + } + } + + // http://stackoverflow.com/a/35220476/1045199 + private boolean ifHuaweiAlert() { + boolean skipMessage = Preferences.isSkipHuaweiProtectedApps(); + if (!skipMessage) { + Intent intent = new Intent(); + intent.setClassName("com.huawei.systemmanager", "com.huawei.systemmanager.optimize.process.ProtectActivity"); + if (SystemUtils.isCallable(this, intent)) { + new MaterialDialog.Builder(this) + .title(R.string.title_huawei_protected_apps) + .content(R.string.msg_huawei_protected_apps) + .positiveText(R.string.btn_huawei_protected_apps) + .neutralText(R.string.learn_more) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + startHuaweiProtectedApps(); + } + }) + .onNeutral(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + SystemUtils.openURL(dialog.getContext(), getString(R.string.help_huawei_protected_apps)); + } + }) + .checkBoxPromptRes(R.string.check_dont_show_again, false, new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + Preferences.setSkipHuaweiProtectedApps(isChecked); + } + }) + .show(); + + return true; + } + else { + Preferences.setSkipHuaweiProtectedApps(true); + } + } + return false; + } + + void startHuaweiProtectedApps() { + try { + String cmd = "am start -n com.huawei.systemmanager/.optimize.process.ProtectActivity"; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + cmd += " --user " + SystemUtils.getUserSerial(this); + } + Runtime.getRuntime().exec(cmd); + } + catch (IOException ignored) { + } + } + + boolean isPasswordValid() { + try { + Kontalk.get().getPersonalKey(); + return true; + } + catch (Exception e) { + return false; + } + } + + private boolean checkPassword() { + if (Kontalk.get().getDefaultAccount() == null) + return false; + + if (Kontalk.get().getDefaultAccount().getPassphrase() == null || !isPasswordValid()) { + askForPassword(); + return true; + } + + return false; + } + + private void askForPassword() { + showDialog(DIALOG_AUTH_REQUEST_PASSWORD); + } + + @Override + protected Dialog onCreateDialog(int id, Bundle args) { + switch (id) { + case DIALOG_AUTH_ERROR_WARNING: + return new MaterialDialog.Builder(this) + .title(R.string.title_auth_error) + .content(R.string.msg_auth_error) + .positiveText(android.R.string.ok) + .build(); + + case DIALOG_AUTH_REQUEST_PASSWORD: + return new MaterialDialog.Builder(this) + .title(R.string.title_passphrase_request) + .inputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) + .input(0, 0, true, new MaterialDialog.InputCallback() { + @Override + public void onInput(@NonNull MaterialDialog dialog, CharSequence input) { + String passphrase = input.toString(); + // user-entered passphrase is hashed + String hashed = SHA1.hex(passphrase); + Authenticator.setPassphrase(MainActivity.this, hashed, true); + Kontalk.get().invalidatePersonalKey(); + if (isPasswordValid()) { + MessageCenterService.start(MainActivity.this); + } + else { + Toast.makeText(MainActivity.this, R.string.err_invalid_passphrase, + Toast.LENGTH_LONG).show(); + } + } + }) + .onNegative(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + finish(); + } + }) + .negativeText(android.R.string.cancel) + .positiveText(android.R.string.ok) + .build(); + + } + + return super.onCreateDialog(id, args); + } + + /** + * Something to handle for the given intent. + * @param intent + * @return true if the intent has been handled. + */ + protected boolean handleIntent(Intent intent) { + if (intent != null) { + String action = intent.getAction(); + + if (ACTION_AUTH_ERROR_WARNING.equals(action)) { + showDialog(DIALOG_AUTH_ERROR_WARNING); + return true; + } + else if (ACTION_AUTH_REQUEST_PASSWORD.equals(action)) { + showDialog(DIALOG_AUTH_REQUEST_PASSWORD); + return true; + } + } + + return false; + } + + public static Intent authenticationErrorWarning(Context context) { + Intent i = new Intent(context.getApplicationContext(), ConversationsActivity.class); + i.setAction(ACTION_AUTH_ERROR_WARNING); + return i; + } + + public static Intent passwordRequest(Context context) { + Intent i = new Intent(context.getApplicationContext(), ConversationsActivity.class); + i.setAction(ACTION_AUTH_REQUEST_PASSWORD); + return i; + } + +} diff --git a/app/src/main/java/org/kontalk/ui/MessagingNotification.java b/app/src/main/java/org/kontalk/ui/MessagingNotification.java index f497e87ca..6876f468b 100644 --- a/app/src/main/java/org/kontalk/ui/MessagingNotification.java +++ b/app/src/main/java/org/kontalk/ui/MessagingNotification.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,87 +18,192 @@ package org.kontalk.ui; -import java.util.HashMap; +import java.io.File; +import java.io.InputStream; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; import java.util.Map; +import java.util.Set; -import org.kontalk.R; -import org.kontalk.authenticator.Authenticator; -import org.kontalk.data.Contact; -import org.kontalk.message.CompositeMessage; -import org.kontalk.provider.MessagesProvider; -import org.kontalk.provider.MyMessages.CommonColumns; -import org.kontalk.provider.MyMessages.Messages; -import org.kontalk.provider.MyMessages.Threads; -import org.kontalk.util.Preferences; +import org.jxmpp.jid.Jid; +import org.jxmpp.util.XmppStringUtils; -import android.accounts.Account; import android.app.Notification; +import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; -import android.content.BroadcastReceiver; import android.content.ContentResolver; +import android.content.ContentUris; import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; +import android.content.res.Resources; import android.database.Cursor; +import android.graphics.Bitmap; import android.graphics.Typeface; -import android.graphics.drawable.BitmapDrawable; import android.media.AudioManager; import android.net.Uri; -import android.support.v4.app.NotificationCompat; -import android.support.v4.app.NotificationCompat.BigTextStyle; -import android.support.v4.app.NotificationCompat.InboxStyle; -import android.support.v4.app.NotificationCompat.Style; +import android.os.Build; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationCompat.BigPictureStyle; +import androidx.core.app.NotificationCompat.InboxStyle; +import androidx.core.app.NotificationCompat.MessagingStyle; +import androidx.core.app.NotificationCompat.Style; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.app.Person; +import androidx.core.app.TaskStackBuilder; +import androidx.core.content.ContextCompat; import android.text.Spannable; import android.text.SpannableStringBuilder; +import android.text.TextUtils; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; +import androidx.core.graphics.drawable.IconCompat; +import me.leolin.shortcutbadger.ShortcutBadger; + +import org.kontalk.Kontalk; +import org.kontalk.R; +import org.kontalk.authenticator.MyAccount; +import org.kontalk.data.Contact; +import org.kontalk.message.CompositeMessage; +import org.kontalk.message.GroupCommandComponent; +import org.kontalk.message.ImageComponent; +import org.kontalk.message.LocationComponent; +import org.kontalk.provider.MessagesProviderClient.GroupThreadContent; +import org.kontalk.provider.MyMessages.Groups; +import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.provider.MyMessages.Threads; +import org.kontalk.service.NotificationActionReceiver; +import org.kontalk.util.DataUtils; +import org.kontalk.util.MediaStorage; +import org.kontalk.util.MessageUtils; +import org.kontalk.util.Preferences; + /** * Various utility methods for managing system notifications. * @author Daniele Ricci */ public class MessagingNotification { + + /** + * A simple documentation annotation to link a notification ID constant + * with a channel ID. Be sure to respect that. + */ + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.SOURCE) + private @interface NotificationChannelId { + String value(); + } + + public static final String CHANNEL_INCOMING_MESSAGE = "incoming_message"; + public static final String CHANNEL_MEDIA_DOWNLOAD = "media_download"; + public static final String CHANNEL_MEDIA_UPLOAD = "media_upload"; + public static final String CHANNEL_MESSAGE_CENTER = "message_center"; + public static final String CHANNEL_OTHER = "other"; + + @NotificationChannelId(CHANNEL_INCOMING_MESSAGE) public static final int NOTIFICATION_ID_MESSAGES = 101; + + @NotificationChannelId(CHANNEL_MEDIA_UPLOAD) public static final int NOTIFICATION_ID_UPLOADING = 102; + + @NotificationChannelId(CHANNEL_MEDIA_UPLOAD) public static final int NOTIFICATION_ID_UPLOAD_ERROR = 103; + + @NotificationChannelId(CHANNEL_MEDIA_DOWNLOAD) public static final int NOTIFICATION_ID_DOWNLOADING = 104; + + @NotificationChannelId(CHANNEL_MEDIA_DOWNLOAD) public static final int NOTIFICATION_ID_DOWNLOAD_OK = 105; + + @NotificationChannelId(CHANNEL_MEDIA_DOWNLOAD) public static final int NOTIFICATION_ID_DOWNLOAD_ERROR = 106; - public static final int NOTIFICATION_ID_QUICK_REPLY = 107; - public static final int NOTIFICATION_ID_KEYPAIR_GEN = 108; - public static final int NOTIFICATION_ID_INVITATION = 109; - public static final int NOTIFICATION_ID_AUTH_ERROR = 110; - /** Minimum delay for progress notification updates. */ - public static final int NOTIFICATION_UPDATE_DELAY = 500; + @NotificationChannelId(CHANNEL_OTHER) + public static final int NOTIFICATION_ID_KEYPAIR_GEN = 107; + + @NotificationChannelId(CHANNEL_INCOMING_MESSAGE) + public static final int NOTIFICATION_ID_INVITATION = 108; + + @NotificationChannelId(CHANNEL_OTHER) + public static final int NOTIFICATION_ID_AUTH_ERROR = 109; + + @NotificationChannelId(CHANNEL_MESSAGE_CENTER) + public static final int NOTIFICATION_ID_FOREGROUND = 110; + + @NotificationChannelId(CHANNEL_OTHER) + public static final int NOTIFICATION_ID_REGISTRATION = 111; private static final String[] MESSAGES_UNREAD_PROJECTION = { Messages.THREAD_ID, - CommonColumns.PEER, + Messages.PEER, + Messages.BODY_MIME, Messages.BODY_CONTENT, Messages.ATTACHMENT_MIME, - CommonColumns.ENCRYPTED, + Messages.ATTACHMENT_PREVIEW_PATH, + Messages.GEO_LATITUDE, + Messages.ENCRYPTED, + Groups.GROUP_JID, + Groups.SUBJECT, + Messages.TIMESTAMP, }; + // mapped to MESSAGES_UNREAD_PROJECTION + private static final int COLUMN_MESSAGES_THREAD_ID = 0; + private static final int COLUMN_MESSAGES_PEER = 1; + private static final int COLUMN_MESSAGES_BODY_MIME = 2; + private static final int COLUMN_MESSAGES_BODY_CONTENT = 3; + private static final int COLUMN_MESSAGES_ATTACHMENT_MIME = 4; + private static final int COLUMN_MESSAGES_ATTACHMENT_PREVIEW_PATH = 5; + private static final int COLUMN_MESSAGES_GEO_LATITUDE = 6; + private static final int COLUMN_MESSAGES_ENCRYPTED = 7; + private static final int COLUMN_MESSAGES_GROUP_JID = 8; + private static final int COLUMN_MESSAGES_GROUP_SUBJECT = 9; + private static final int COLUMN_MESSAGES_TIMESTAMP = 10; + private static final String[] THREADS_UNREAD_PROJECTION = { - CommonColumns._ID, - CommonColumns.PEER, + Threads._ID, + Threads.PEER, Threads.MIME, Threads.CONTENT, - CommonColumns.ENCRYPTED, - CommonColumns.UNREAD, + Threads.ENCRYPTED, + Threads.UNREAD, + Groups.GROUP_JID, + Groups.SUBJECT, + Threads.TIMESTAMP, }; + // mapped to THREADS_UNREAD_PROJECTION + private static final int COLUMN_THREADS_ID = 0; + private static final int COLUMN_THREADS_PEER = 1; + private static final int COLUMN_THREADS_MIME = 2; + private static final int COLUMN_THREADS_CONTENT = 3; + private static final int COLUMN_THREADS_ENCRYPTED = 4; + private static final int COLUMN_THREADS_UNREAD = 5; + private static final int COLUMN_THREADS_GROUP_JID = 6; + private static final int COLUMN_THREADS_GROUP_SUBJECT = 7; + private static final int COLUMN_THREADS_TIMESTAMP = 8; + private static final String MESSAGES_UNREAD_SELECTION = - CommonColumns.NEW + " <> 0 AND " + - CommonColumns.DIRECTION + " = " + Messages.DIRECTION_IN; + Messages.NEW + " <> 0 AND " + + Messages.DIRECTION + " = " + Messages.DIRECTION_IN; /** Pending delayed notification update flag. */ - private static volatile boolean sPending; + @SuppressWarnings("WeakerAccess") + static volatile boolean sPending; + + /** Temporary disable all notifications flag */ + private static volatile boolean sDisabled; /** Peer to NOT be notified for new messages. */ private static volatile String sPaused; @@ -106,49 +211,133 @@ public class MessagingNotification { /** Peer of last notified chat invitation. */ private static volatile String sLastInvitation; - /** On delete intent stuff. */ - private static final String ACTION_NOTIFICATION_DELETED = "org.kontalk.ACTION_NOTIFICATION_DELETED"; - private static final OnDeletedReceiver sNotificationDeletedReceiver = new OnDeletedReceiver(); - private static Intent sNotificationOnDeleteIntent; - - private static class OnDeletedReceiver extends BroadcastReceiver { - public void onReceive(Context context, Intent intent) { - // mark all messages as old - MessagesProvider.markAllThreadsAsOld(context); - } - } + /** Notification action intents stuff. */ + public static final String ACTION_NOTIFICATION_DELETED = "org.kontalk.ACTION_NOTIFICATION_DELETED"; + public static final String ACTION_NOTIFICATION_REPLY = "org.kontalk.ACTION_NOTIFICATION_REPLY"; + public static final String ACTION_NOTIFICATION_MARK_READ = "org.kontalk.ACTION_NOTIFICATION_MARK_READ"; /** This class is not instanciable. */ private MessagingNotification() {} public static void init(Context context) { - // set up the intent filter for notification deleted action - IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(ACTION_NOTIFICATION_DELETED); - context.registerReceiver(sNotificationDeletedReceiver, intentFilter); + createChannels(context); + } + + private static void createChannels(Context context) { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Resources resources = context.getResources(); + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + createMessageChannel(resources, notificationManager); + createDownloadChannel(resources, notificationManager); + createUploadChannel(resources, notificationManager); + createMessageCenterChannel(resources, notificationManager); + createOtherChannel(resources, notificationManager); + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private static void createMessageChannel(Resources resources, NotificationManager notificationManager) { + CharSequence name = resources.getString(R.string.notify_incoming_message_channel_name); + String description = resources.getString(R.string.notify_incoming_message_channel_description); + NotificationChannel channel = new NotificationChannel(CHANNEL_INCOMING_MESSAGE, name, + NotificationManager.IMPORTANCE_HIGH); + channel.setDescription(description); + channel.enableVibration(true); + channel.enableLights(true); + channel.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); + notificationManager.createNotificationChannel(channel); + } + + @RequiresApi(Build.VERSION_CODES.O) + private static void createDownloadChannel(Resources resources, NotificationManager notificationManager) { + CharSequence name = resources.getString(R.string.notify_media_download_channel_name); + String description = resources.getString(R.string.notify_media_download_channel_description); + NotificationChannel channel = new NotificationChannel(CHANNEL_MEDIA_DOWNLOAD, name, + NotificationManager.IMPORTANCE_MIN); + channel.setDescription(description); + channel.enableLights(false); + channel.enableVibration(false); + channel.setShowBadge(false); + channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + notificationManager.createNotificationChannel(channel); + } - // initialize the notification deleted action - sNotificationOnDeleteIntent = new Intent(ACTION_NOTIFICATION_DELETED); + @RequiresApi(Build.VERSION_CODES.O) + private static void createUploadChannel(Resources resources, NotificationManager notificationManager) { + CharSequence name = resources.getString(R.string.notify_media_upload_channel_name); + String description = resources.getString(R.string.notify_media_upload_channel_description); + NotificationChannel channel = new NotificationChannel(CHANNEL_MEDIA_UPLOAD, name, + NotificationManager.IMPORTANCE_MIN); + channel.setDescription(description); + channel.enableLights(false); + channel.enableVibration(false); + channel.setShowBadge(false); + channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + notificationManager.createNotificationChannel(channel); + } + + @RequiresApi(Build.VERSION_CODES.O) + private static void createMessageCenterChannel(Resources resources, NotificationManager notificationManager) { + CharSequence name = resources.getString(R.string.notify_message_center_channel_name); + String description = resources.getString(R.string.notify_message_center_channel_description); + NotificationChannel channel = new NotificationChannel(CHANNEL_MESSAGE_CENTER, name, + NotificationManager.IMPORTANCE_MIN); + channel.setDescription(description); + channel.enableLights(false); + channel.enableVibration(false); + channel.setShowBadge(false); + channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + notificationManager.createNotificationChannel(channel); + } + + @RequiresApi(Build.VERSION_CODES.O) + private static void createOtherChannel(Resources resources, NotificationManager notificationManager) { + CharSequence name = resources.getString(R.string.notify_other_channel_name); + String description = resources.getString(R.string.notify_other_channel_description); + NotificationChannel channel = new NotificationChannel(CHANNEL_OTHER, name, + NotificationManager.IMPORTANCE_DEFAULT); + channel.setDescription(description); + channel.enableLights(true); + channel.enableVibration(true); + channel.setShowBadge(true); + channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + notificationManager.createNotificationChannel(channel); } public static void setPaused(String jid) { sPaused = jid; } - public static String getPaused() { - return sPaused; + public static boolean isPaused(Jid jid) { + return sPaused != null && sPaused.equalsIgnoreCase(jid.asBareJid().toString()); + } + + public static boolean isPaused(String jid) { + return sPaused != null && sPaused.equalsIgnoreCase(XmppStringUtils.parseBareJid(jid)); + } + + /** Enables all notifications. */ + public static void enable() { + sDisabled = false; + } + + /** Temporarly disable all notifications. */ + public static void disable() { + sDisabled = true; } private static boolean supportsBigNotifications() { return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN; } + static boolean supportsDirectReply() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N; + } + /** Starts messages notification updates in another thread. */ public static void delayedUpdateMessagesNotification(final Context context, final boolean isNew) { - // notifications are disabled - if (!Preferences.getNotificationsEnabled(context)) - return; - if (!sPending) { sPending = true; new Thread(new Runnable() { @@ -167,13 +356,11 @@ public void run() { * @param isNew if true a new message has come (starts notification alerts) */ public static void updateMessagesNotification(Context context, boolean isNew) { - // notifications are disabled - if (!Preferences.getNotificationsEnabled(context)) - return; // no default account. WTF?!? - Account account = Authenticator.getDefaultAccount(context); - if (account == null) + MyAccount account = Kontalk.get().getDefaultAccount(); + if (account == null) { return; + } // if notifying new messages, wait a little bit // to let all incoming messages come through @@ -191,8 +378,7 @@ public static void updateMessagesNotification(Context context, boolean isNew) { */ ContentResolver res = context.getContentResolver(); - NotificationManager nm = (NotificationManager) context - .getSystemService(Context.NOTIFICATION_SERVICE); + NotificationManagerCompat nm = NotificationManagerCompat.from(context); String query = MESSAGES_UNREAD_SELECTION; String[] args = null; @@ -211,16 +397,18 @@ public static void updateMessagesNotification(Context context, boolean isNew) { } // is there a peer to not notify for? - if (sPaused != null) { - query += " AND " + CommonColumns.PEER + " <> ?"; - args = new String[] { sPaused }; + final String paused = sPaused; + if (paused != null) { + query += " AND " + Messages.PEER + " <> ? AND " + + "(" + Groups.GROUP_JID + " IS NULL OR " + Groups.GROUP_JID + " <> ?)"; + args = new String[] { paused, paused }; } Cursor c = res.query(uri, proj, query, args, order); // this shouldn't happen, but who knows... if (c == null) { - nm.cancel(NOTIFICATION_ID_MESSAGES); + clearMessageNotification(context); return; } @@ -228,228 +416,167 @@ public static void updateMessagesNotification(Context context, boolean isNew) { int unread = c.getCount(); if (unread == 0) { c.close(); - nm.cancel(NOTIFICATION_ID_MESSAGES); + clearMessageNotification(context); + return; + } + + // notifications are disabled + if (!Preferences.getNotificationsEnabled(context) || sDisabled) { + c.close(); return; } - NotificationCompat.Builder builder = new NotificationCompat.Builder(context.getApplicationContext()); + NotificationCompat.Builder builder = new NotificationCompat + .Builder(context.getApplicationContext(), CHANNEL_INCOMING_MESSAGE); + Set conversationIds = new HashSet<>(unread); + long latestTimestamp = 0; if (supportsBigNotifications()) { - Map convs = new HashMap(); + NotificationGenerator ngen = new NotificationGenerator(context, builder); - String peer = null; long id = 0; while (c.moveToNext()) { // thread_id for PendingIntent - id = c.getLong(0); - peer = c.getString(1); - byte[] content = c.getBlob(2); - String attMime = c.getString(3); - - CharSequence[] b = convs.get(peer); - if (b == null) { - b = new CharSequence[2]; - b[0] = new StringBuilder(); - b[1] = null; - convs.put(peer, b); - } - else { - ((StringBuilder) b[0]).append('\n'); + id = c.getLong(COLUMN_MESSAGES_THREAD_ID); + String peer = c.getString(COLUMN_MESSAGES_PEER); + String mime = c.getString(COLUMN_MESSAGES_BODY_MIME); + byte[] content = c.getBlob(COLUMN_MESSAGES_BODY_CONTENT); + String attMime = c.getString(COLUMN_MESSAGES_ATTACHMENT_MIME); + String attPreviewPath = c.getString(COLUMN_MESSAGES_ATTACHMENT_PREVIEW_PATH); + boolean encrypted = c.getInt(COLUMN_MESSAGES_ENCRYPTED) != 0; + String groupJid = c.getString(COLUMN_MESSAGES_GROUP_JID); + String groupSubject = c.getString(COLUMN_MESSAGES_GROUP_SUBJECT); + long timestamp = c.getLong(COLUMN_MESSAGES_TIMESTAMP); + + if (!c.isNull(COLUMN_MESSAGES_GEO_LATITUDE)) { + content = context.getString(R.string.notification_location).getBytes(); } - String textContent; + // store conversation id for intents + conversationIds.add(ContentUris.withAppendedId(Threads.CONTENT_URI, id)); - boolean encrypted = c.getInt(4) != 0; - if (encrypted) { - textContent = context.getString(R.string.text_encrypted); - } - else if (content == null && attMime != null) { - textContent = CompositeMessage.getSampleTextContent(attMime); - } - else { - textContent = content != null ? new String(content) : ""; - } - - ((StringBuilder) b[0]).append(textContent); - b[1] = textContent; + Uri attPreview = attPreviewPath != null ? Uri.fromFile(new File(attPreviewPath)) : null; + ngen.addMessage(peer, mime, content, attMime, attPreview, encrypted, timestamp, groupJid, groupSubject); + latestTimestamp = Math.max(latestTimestamp, timestamp); } c.close(); - // TODO we are not ready for this -- builder.addAction(android.R.drawable.ic_menu_revert, "Reply", accumulator.getPendingIntent()); - - /* -- FIXME FIXME VERY UGLY CODE FIXME FIXME -- */ + int convCount = ngen.build(account, unread, conversationIds.iterator().next()); - Style style; - CharSequence title, text, ticker; - - // more than one conversation - use InboxStyle - if (convs.size() > 1) { - style = new InboxStyle(); - - // ticker: "X unread messages" - ticker = context.getString(R.string.unread_messages, unread); - - // title - title = ticker; - - // text: comma separated names (TODO RTL?) - StringBuilder btext = new StringBuilder(); - int count = 0; - for (String user : convs.keySet()) { - count++; - - Contact contact = Contact.findByUserId(context, user); - String name = (contact != null) ? contact.getName() : - context.getString(R.string.peer_unknown); - - if (contact != null) { - if (btext.length() > 0) - btext.append(", "); - btext.append(name); - } - - // inbox line - if (count < 5) { - SpannableStringBuilder buf = new SpannableStringBuilder(); - buf.append(name).append(' '); - buf.setSpan(new ForegroundColorSpan(context.getResources() - .getColor(R.color.notification_name_color)), - 0, buf.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - // take just the last message - buf.append(convs.get(user)[1]); - - ((InboxStyle) style).addLine(buf); - } - } - - if (btext.length() > 0) - text = btext.toString(); - else - // TODO i18n - text = "(unknown users)"; - - String summary; - if (count > 5) - // TODO i18n - summary = "+" + (convs.size() - count) + " more"; - else - summary = account.name; - - ((InboxStyle) style).setSummaryText(summary); - } - // one conversation, use BigTextStyle - else { - String content = convs.get(peer)[0].toString(); - CharSequence last = convs.get(peer)[1]; - - // big text content - style = new BigTextStyle(); - ((BigTextStyle) style).bigText(content); - ((BigTextStyle) style).setSummaryText(account.name); - - // ticker - Contact contact = Contact.findByUserId(context, peer); - String name = (contact != null) ? contact.getName() : - context.getString(R.string.peer_unknown); - // debug mode -- conversation.peer; - - SpannableStringBuilder buf = new SpannableStringBuilder(); - buf.append(name).append(':').append(' '); - buf.setSpan(new StyleSpan(Typeface.BOLD), 0, buf.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - buf.append(last); - - ticker = buf; - - // title - title = name; - - // text - text = (unread > 1) ? - context.getString(R.string.unread_messages, unread) - : content; - - // avatar - if (contact != null) { - BitmapDrawable avatar = (BitmapDrawable) contact.getAvatar(context, null); - if (avatar != null) - builder.setLargeIcon(avatar.getBitmap()); - } - } - - builder.setNumber(unread); builder.setSmallIcon(R.drawable.ic_stat_notify); - - builder.setTicker(ticker); - builder.setContentTitle(title); - builder.setContentText(text); - builder.setStyle(style); - builder.setDeleteIntent(PendingIntent.getBroadcast(context, 0, - sNotificationOnDeleteIntent, 0)); + builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE); Intent ni; - // more than one unread conversation - open ConversationList - if (convs.size() > 1) { - ni = new Intent(context, ConversationList.class); + // more than one unread conversation - open conversations list + if (convCount > 1) { + ni = new Intent(context, ConversationsActivity.class); ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); } - // one unread conversation - open ComposeMessage on that peer + // one unread conversation - open compose message on that thread else { ni = ComposeMessage.fromConversation(context, id); } - PendingIntent pi = PendingIntent.getActivity(context, NOTIFICATION_ID_MESSAGES, - ni, 0); + PendingIntent pi = createPendingIntent(context, ni); builder.setContentIntent(pi); } else { - // loop all threads and accumulate them MessageAccumulator accumulator = new MessageAccumulator(context); while (c.moveToNext()) { - String content = c.getString(3); - boolean encrypted = c.getInt(4) != 0; + long threadId = c.getLong(COLUMN_THREADS_ID); + String peer = c.getString(COLUMN_THREADS_PEER); + String mime = c.getString(COLUMN_THREADS_MIME); + String content = c.getString(COLUMN_THREADS_CONTENT); + boolean encrypted = c.getInt(COLUMN_THREADS_ENCRYPTED) != 0; + int unreadCount = c.getInt(COLUMN_THREADS_UNREAD); + String groupJid = c.getString(COLUMN_THREADS_GROUP_JID); + String groupSubject = c.getString(COLUMN_THREADS_GROUP_SUBJECT); + long timestamp = c.getLong(COLUMN_THREADS_TIMESTAMP); - if (encrypted) + if (encrypted) { content = context.getString(R.string.text_encrypted); - else if (content == null) - content = CompositeMessage.getSampleTextContent(c.getString(2)); + } + else if (GroupCommandComponent.supportsMimeType(mime)) { + // content is in a special format + GroupThreadContent parsed = GroupThreadContent.parseIncoming(content); + try { + peer = parsed.sender; + content = GroupCommandComponent.getTextContent(context, parsed.command, true); + } + catch (UnsupportedOperationException e) { + // TODO using another string + content = context.getString(R.string.peer_unknown); + } + } + else if (LocationComponent.supportsMimeType(mime)) { + content = context.getString(R.string.notification_location); + } + else if (content == null) { + content = CompositeMessage.getSampleTextContent(mime); + } accumulator.accumulate( - c.getLong(0), - c.getString(1), + threadId, + peer, content, - c.getInt(5) + unreadCount, + // group data + groupJid, + groupSubject ); + // actually we don't need to check for max since conversations were selected + // in timestamp order, but whatever... + latestTimestamp = Math.max(latestTimestamp, timestamp); + conversationIds.add(ContentUris.withAppendedId(Threads.CONTENT_URI, threadId)); } c.close(); builder.setTicker(accumulator.getTicker()); Contact contact = accumulator.getContact(); if (contact != null) { - BitmapDrawable avatar = (BitmapDrawable) contact.getAvatar(context, null); - if (avatar != null) - builder.setLargeIcon(avatar.getBitmap()); + // setLargeIcon will resize the icon + Bitmap avatar = contact.getAvatarBitmap(context, false); + builder.setLargeIcon(avatar); } builder.setNumber(accumulator.unreadCount); builder.setSmallIcon(R.drawable.ic_stat_notify); builder.setContentTitle(accumulator.getTitle()); builder.setContentText(accumulator.getText()); builder.setContentIntent(accumulator.getPendingIntent()); - builder.setDeleteIntent(PendingIntent.getBroadcast(context, 0, - sNotificationOnDeleteIntent, 0)); + + // overwrite thread count with message count (used for badges later) + unread = accumulator.unreadCount; } + // shouldn't happen, but let's check it anyway + if (latestTimestamp > 0) + builder.setWhen(latestTimestamp); + + // build on delete intent for conversations + Intent notificationDeleteIntent = new Intent(context, NotificationActionReceiver.class); + notificationDeleteIntent.setAction(ACTION_NOTIFICATION_DELETED); + notificationDeleteIntent.putExtra("org.kontalk.datalist", conversationIds.toArray(new Uri[0])); + builder.setDeleteIntent(PendingIntent.getBroadcast(context, 0, + notificationDeleteIntent, PendingIntent.FLAG_UPDATE_CURRENT)); + if (isNew) { - setDefaults(context, builder); + setAlerts(context, builder); } // features (priority, category) - setFeatures(builder); + setFeatures(context, builder); - nm.notify(NOTIFICATION_ID_MESSAGES, builder.build()); + Notification notification = builder.build(); + + // set badge counter + ShortcutBadger.applyNotification(context, notification, unread); + ShortcutBadger.applyCount(context, unread); + + nm.notify(NOTIFICATION_ID_MESSAGES, notification); /* TODO take this from configuration boolean quickReply = false; @@ -464,27 +591,88 @@ else if (content == null) */ } - private static void setDefaults(Context context, NotificationCompat.Builder builder) { - int defaults = Notification.DEFAULT_LIGHTS; + /** Adds proper alerts to the notification. */ + private static void setAlerts(Context context, NotificationCompat.Builder builder) { + // apparently we can't set defaults for vibrate if we want to set LED color + builder.setDefaults(0); + + if (Preferences.getNotificationLED(context)) { + int ledColor = Preferences.getNotificationLEDColor(context); + builder.setLights(ledColor, 1000, 1000); + } + else { + // this will disable the LED completely + builder.setLights(0, 0, 0); + } String ringtone = Preferences.getNotificationRingtone(context); - if (ringtone != null && ringtone.length() > 0) + if (ringtone != null && ringtone.length() > 0) { builder.setSound(Uri.parse(ringtone)); + } String vibrate = Preferences.getNotificationVibrate(context); if ("always".equals(vibrate) || ("silent_only".equals(vibrate) && ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE)) - .getRingerMode() != AudioManager.RINGER_MODE_NORMAL)) - defaults |= Notification.DEFAULT_VIBRATE; + .getRingerMode() != AudioManager.RINGER_MODE_NORMAL)) { + // pattern from Android's NotificationManagerService + builder.setVibrate(new long[] {0, 250, 250, 250}); + } + } + /** High priority message with Kontalk colors :-) */ + private static void setFeatures(Context context, NotificationCompat.Builder builder) { + builder + .setPriority(NotificationCompat.PRIORITY_MAX) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setColor(ContextCompat.getColor(context, R.color.app_accent)); + } - builder.setDefaults(defaults); + private static void clearMessageNotification(Context context) { + NotificationManagerCompat nm = NotificationManagerCompat.from(context); + nm.cancel(NOTIFICATION_ID_MESSAGES); + ShortcutBadger.removeCount(context); } - private static void setFeatures(NotificationCompat.Builder builder) { - builder.setPriority(NotificationCompat.PRIORITY_HIGH); - // FIXME this can't be used with appcompat v21 - //builder.setCategory(NotificationCompat.CATEGORY_MESSAGE); + private static final class NotificationConversation { + static final class ConversationMessage { + final String peer; + final CharSequence content; + final long timestamp; + final String mime; + final Uri media; + + ConversationMessage(String peer, CharSequence content, long timestamp, String mime, Uri media) { + this.peer = peer; + this.content = content; + this.timestamp = timestamp; + this.mime = mime; + this.media = media; + } + + @Override + public String toString() { + return content.toString(); + } + } + + final List content; + final String groupJid; + final String groupSubject; + + final Set allPeers = new HashSet<>(); + CharSequence lastContent; + + NotificationConversation(List content, CharSequence lastContent, String groupJid, String groupSubject) { + this.content = content; + this.lastContent = lastContent; + this.groupJid = groupJid; + this.groupSubject = groupSubject; + } + + public void addContent(ConversationMessage message) { + this.content.add(message); + this.allPeers.add(message.peer); + } } /** Triggers a notification for a chat invitation. */ @@ -496,8 +684,7 @@ public static void chatInvitation(Context context, String jid) { // find the contact for the userId Contact contact = Contact.findByUserId(context, jid); - String title = (contact != null) ? contact.getName() : - context.getString(R.string.peer_unknown); + String title = contact.getDisplayName(); // notification will open the conversation Intent ni = ComposeMessage.fromUserId(context, jid); @@ -509,30 +696,27 @@ public static void chatInvitation(Context context, String jid) { // build the notification NotificationCompat.Builder builder = new NotificationCompat - .Builder(context.getApplicationContext()) + .Builder(context.getApplicationContext(), CHANNEL_INCOMING_MESSAGE) .setAutoCancel(true) .setSmallIcon(R.drawable.ic_stat_notify) .setTicker(context.getString(R.string.title_invitation)) + .setPriority(NotificationCompat.PRIORITY_HIGH) .setContentTitle(title) .setContentText(context.getString(R.string.invite_notification)) .setContentIntent(pi); // include an avatar if any - if (contact != null) { - BitmapDrawable avatar = (BitmapDrawable) contact.getAvatar(context, null); - if (avatar != null) - builder.setLargeIcon(avatar.getBitmap()); - } + // setLargeIcon will resize the icon + Bitmap avatar = contact.getAvatarBitmap(context, false); + builder.setLargeIcon(avatar); - // defaults (sound, vibration, lights) - setDefaults(context, builder); + // alerts (sound, vibration, lights) + setAlerts(context, builder); // features (priority, category) - setFeatures(builder); + setFeatures(context, builder); // fire it up! - NotificationManager nm = (NotificationManager) context - .getSystemService(Context.NOTIFICATION_SERVICE); - + NotificationManagerCompat nm = NotificationManagerCompat.from(context); nm.notify(NOTIFICATION_ID_INVITATION, builder.build()); // this is for clearChatInvitation() @@ -542,9 +726,7 @@ public static void chatInvitation(Context context, String jid) { /** Cancel a chat invitation notification. */ public static void clearChatInvitation(Context context, String userId) { if (userId.equalsIgnoreCase(sLastInvitation)) { - NotificationManager nm = (NotificationManager) context - .getSystemService(Context.NOTIFICATION_SERVICE); - + NotificationManagerCompat nm = NotificationManagerCompat.from(context); nm.cancel(NOTIFICATION_ID_INVITATION); } } @@ -552,13 +734,13 @@ public static void clearChatInvitation(Context context, String userId) { /** Fires an authentication error notification. */ public static void authenticationError(Context context) { // notification will open the conversation - Intent ni = ConversationList.authenticationErrorWarning(context); + Intent ni = ConversationsActivity.authenticationErrorWarning(context); PendingIntent pi = PendingIntent.getActivity(context, NOTIFICATION_ID_AUTH_ERROR, ni, PendingIntent.FLAG_UPDATE_CURRENT); // build the notification NotificationCompat.Builder builder = new NotificationCompat - .Builder(context.getApplicationContext()) + .Builder(context.getApplicationContext(), CHANNEL_OTHER) .setAutoCancel(true) .setSmallIcon(R.drawable.ic_stat_notify) .setTicker(context.getString(R.string.title_auth_error)) @@ -567,37 +749,405 @@ public static void authenticationError(Context context) { .setContentIntent(pi); // defaults (sound, vibration, lights) - setDefaults(context, builder); + setAlerts(context, builder); // fire it up! - NotificationManager nm = (NotificationManager) context - .getSystemService(Context.NOTIFICATION_SERVICE); - + NotificationManagerCompat nm = NotificationManagerCompat.from(context); nm.notify(NOTIFICATION_ID_AUTH_ERROR, builder.build()); } public static void clearAuthenticationError(Context context) { - NotificationManager nm = (NotificationManager) context - .getSystemService(Context.NOTIFICATION_SERVICE); - + NotificationManagerCompat nm = NotificationManagerCompat.from(context); nm.cancel(NOTIFICATION_ID_AUTH_ERROR); } + public static Notification buildForegroundNotification(Context context) { + Intent ni = new Intent(context.getApplicationContext(), ConversationsActivity.class); + PendingIntent pi = PendingIntent.getActivity(context, + NOTIFICATION_ID_FOREGROUND, ni, PendingIntent.FLAG_UPDATE_CURRENT); + + // build the notification + return new NotificationCompat + .Builder(context.getApplicationContext(), CHANNEL_MESSAGE_CENTER) + .setOngoing(true) + .setSmallIcon(R.drawable.ic_stat_notify) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setTicker(context.getString(R.string.notification_ticker_service_running)) + .setContentTitle(context.getString(R.string.app_name)) + .setContentText(context.getString(R.string.notification_text_service_running)) + .setContentIntent(pi) + .build(); + } + + /** + * Takes messages to be notified and fills a notification builder. + * Used only for big notifications (JB+). + */ + private static final class NotificationGenerator { + private final Context mContext; + private final NotificationCompat.Builder mBuilder; + private final Map mConversations; + + NotificationGenerator(Context context, NotificationCompat.Builder builder) { + mContext = context; + mBuilder = builder; + mConversations = new LinkedHashMap<>(); + } + + void addMessage(String peer, String mime, byte[] content, String attMime, Uri attPreview, boolean encrypted, long timestamp, + String groupJid, String groupSubject) { + String key = conversationKey(peer, groupJid); + NotificationConversation conv = mConversations.get(key); + if (conv == null) { + conv = new NotificationConversation(new LinkedList<>(), + null, groupJid, groupSubject); + mConversations.put(key, conv); + } + + String textContent; + + if (encrypted) { + textContent = mContext.getString(R.string.text_encrypted); + } + else if (content == null && attMime != null) { + textContent = CompositeMessage.getSampleTextContent(attMime); + } + else { + textContent = content != null ? new String(content) : ""; + if (GroupCommandComponent.supportsMimeType(mime)) { + try { + textContent = GroupCommandComponent.getTextContent(mContext, textContent, true); + } + catch (UnsupportedOperationException e) { + // TODO using another string + textContent = mContext.getString(R.string.peer_unknown); + } + } + } + + conv.addContent(new NotificationConversation.ConversationMessage(peer, textContent, timestamp, attMime, attPreview)); + conv.lastContent = textContent; + } + + private String conversationKey(String peer, String groupJid) { + return groupJid != null ? groupJid : peer; + } + + /** + * Call this when all messages are added. We will fill the builder with + * relevant information. + * @param account account information + * @param unread total number of unread messages + * @param firstThreadUri Uri of first conversation, used for reply intent + * @return the number of conversations (i.e. threads) involved + */ + int build(MyAccount account, int unread, Uri firstThreadUri) { + int convCount = mConversations.size(); + Style style = null; + CharSequence title, text, ticker; + + // more than one conversation - use InboxStyle + if (convCount > 1) { + InboxStyle inboxStyle = new InboxStyle(); + + // ticker: "X unread messages" + ticker = mContext.getResources().getQuantityString(R.plurals.unread_messages, unread, unread); + + // title + title = ticker; + + // text: comma separated names (TODO RTL?) + StringBuilder btext = new StringBuilder(); + int count = 0; + for (String convId : mConversations.keySet()) { + NotificationConversation conv = mConversations.get(convId); + count++; + + // we'll take the last message and user who sent it + String lastPeer = ((LinkedList) conv.content) + .getLast().peer; + Contact contact = Contact.findByUserId(mContext, lastPeer); + String name = contact.getDisplayName(); + + if (conv.groupJid != null) { + name = mContext.getResources().getString(R.string.notification_group_title, + name, (TextUtils.isEmpty(conv.groupSubject) ? + mContext.getString(R.string.group_untitled) : conv.groupSubject)); + } + + // add persons to notification + for (String peer : conv.allPeers) { + Contact lContact = Contact.findByUserId(mContext, peer); + Uri personUri = lContact.getUri(); + if (personUri == null && lContact.getNumber() != null) { + // no contact uri available, try phone number lookup + try { + personUri = Uri.parse("tel:" + lContact.getNumber()); + } + catch (Exception ignored) { + } + } + if (personUri != null) + mBuilder.addPerson(personUri.toString()); + } + + if (btext.length() > 0) + btext.append(", "); + btext.append(name); + + // inbox line + if (count < 5) { + SpannableStringBuilder buf = new SpannableStringBuilder(); + buf.append(name).append(' '); + buf.setSpan(new ForegroundColorSpan(ContextCompat + .getColor(mContext, R.color.notification_name_color)), + 0, buf.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + // take just the last message + buf.append(conv.lastContent); + + inboxStyle.addLine(buf); + } + } + + if (btext.length() > 0) + text = btext.toString(); + else + // TODO i18n + text = "(unknown users)"; + + String summary; + int moreCount = mConversations.size() - count; + if (moreCount > 0) { + summary = mContext.getResources() + .getQuantityString(R.plurals.notification_more, moreCount, moreCount); + } + else { + summary = account.getName(); + } + + inboxStyle.setSummaryText(summary); + + style = inboxStyle; + } + // one conversation/one group, use MessagingStyle + else { + NotificationConversation conv = mConversations.values().iterator().next(); + List content = conv.content; + CharSequence last = conv.lastContent; + + // needed only for custom direct reply + SpannableStringBuilder allContent = null; + if (!supportsDirectReply()) + allContent = new SpannableStringBuilder(); + + String name = null; + + // single image media, use big picture style + NotificationConversation.ConversationMessage soloMessage = content.get(0); + if (unread == 1 && soloMessage.media != null && ImageComponent.supportsMimeType(soloMessage.mime)) { + InputStream in = null; + try { + in = mContext.getContentResolver().openInputStream(soloMessage.media); + Bitmap bitmap = MediaStorage.loadBitmapSimple(in); + + style = new BigPictureStyle() + .bigPicture(bitmap); + + // show the group name next to the sender in this case + if (conv.groupJid != null) { + Contact contact = Contact.findByUserId(mContext, soloMessage.peer); + name = mContext.getResources().getString(R.string.notification_group_title, + contact.getDisplayName(), (TextUtils.isEmpty(conv.groupSubject) ? + mContext.getString(R.string.group_untitled) : conv.groupSubject)); + } + } + catch (Exception e) { + // unable to load big picture + // will go on with normal notification + } + finally { + DataUtils.close(in); + } + } + + if (style == null) { + MessagingStyle msgStyle = new MessagingStyle(new Person + .Builder() + .setName(mContext.getString(R.string.person_me)) + //.setIcon(...no icon for now) + .build() + ); + int start; + for (NotificationConversation.ConversationMessage message : content) { + Contact contact = Contact.findByUserId(mContext, message.peer); + name = contact.getDisplayName(); + String uri = contact.getUri() != null ? contact.getUri().toString() : null; + + Person person = new Person.Builder() + .setName(name) + .setUri(uri) + .setKey(contact.getJID()) + .setIcon(IconCompat.createWithBitmap(contact.getAvatarBitmap(mContext, true))) + .build(); + + msgStyle.addMessage(message.content, message.timestamp, person); + + if (allContent != null) { + if (allContent.length() > 0) + allContent.append("\n"); + + start = allContent.length(); + allContent.append(name).append(':').append(' '); + allContent.setSpan(new StyleSpan(Typeface.BOLD), start, start + name.length() + 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + allContent.append(message.toString()); + } + } + + // group ticker + if (conv.groupJid != null) { + name = mContext.getResources().getString(R.string.notification_group_title, + name, (TextUtils.isEmpty(conv.groupSubject) ? + mContext.getString(R.string.group_untitled) : conv.groupSubject)); + msgStyle.setConversationTitle((TextUtils.isEmpty(conv.groupSubject) ? + mContext.getString(R.string.group_untitled) : conv.groupSubject)); + } + + style = msgStyle; + } + else if (name == null) { + NotificationConversation.ConversationMessage message = content.get(content.size()-1); + Contact contact = Contact.findByUserId(mContext, message.peer); + name = contact.getDisplayName(); + } + + // name now contains data from the latest message + + // add people data + for (String peer : conv.allPeers) { + Contact contact = Contact.findByUserId(mContext, peer); + Uri personUri = contact.getUri(); + if (personUri == null && contact.getNumber() != null) { + // no contact uri available, try phone number lookup + try { + personUri = Uri.parse("tel:" + contact.getNumber()); + } + catch (Exception ignored) { + } + } + if (personUri != null) + mBuilder.addPerson(personUri.toString()); + + // avatar (non-group) + if (conv.groupJid == null) { + // setLargeIcon will resize the icon + Bitmap avatar = contact.getAvatarBitmap(mContext, false); + mBuilder.setLargeIcon(avatar); + } + } + + // group avatar and ticker + if (conv.groupJid != null) { + // default group avatar + Bitmap defaultGroupAvatar = MessageUtils.drawableToBitmap(ContextCompat + .getDrawable(mContext, R.drawable.ic_default_group)); + + if (Contact.isRoundedAvatars()) { + Bitmap roundedAvatar = MediaStorage.createRoundBitmap(defaultGroupAvatar); + if (roundedAvatar != defaultGroupAvatar) + defaultGroupAvatar.recycle(); + defaultGroupAvatar = roundedAvatar; + } + + mBuilder.setLargeIcon(defaultGroupAvatar); + } + + SpannableStringBuilder buf = new SpannableStringBuilder(); + buf.append(name).append(':').append(' '); + buf.setSpan(new StyleSpan(Typeface.BOLD), 0, buf.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + buf.append(last); + + ticker = buf; + + // title + title = name; + + // text + text = (unread > 1) ? + mContext.getResources().getQuantityString(R.plurals.unread_messages, unread, unread) + : last; + + // mark as read pending intent + // TODO this should also be used for messages from a single group + Intent markReadIntent = new Intent(ACTION_NOTIFICATION_MARK_READ, + firstThreadUri, mContext, NotificationActionReceiver.class); + PendingIntent readPendingIntent = PendingIntent.getBroadcast(mContext, 0, + markReadIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + if (supportsDirectReply()) { + // pending intent will start a broadcast receiver + Intent replyIntent = new Intent(ACTION_NOTIFICATION_REPLY, firstThreadUri, + mContext, NotificationActionReceiver.class); + PendingIntent replyPendingIntent = PendingIntent + .getBroadcast(mContext, 0, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + // reply action + mBuilder.addAction(new androidx.core.app.NotificationCompat + .Action.Builder(R.drawable.ic_menu_reply, mContext.getString(R.string.reply), replyPendingIntent) + .addRemoteInput(NotificationActionReceiver.buildReplyInput(mContext)) + .build() + ); + } + else { + // pending intent will start a the quick reply dialog + long firstThreadId = ContentUris.parseId(firstThreadUri); + Intent replyIntent = QuickReplyActivity.getStartIntent(mContext, firstThreadId, allContent); + PendingIntent replyPendingIntent = PendingIntent + .getActivity(mContext, 0, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + // reply action + mBuilder.addAction(R.drawable.ic_menu_reply, mContext.getString(R.string.reply), replyPendingIntent); + } + + mBuilder.addAction(R.drawable.ic_menu_check, mContext.getString(R.string.mark_read), readPendingIntent); + } + + mBuilder.setTicker(ticker); + mBuilder.setContentTitle(title); + mBuilder.setContentText(text); + mBuilder.setStyle(style); + mBuilder.setNumber(unread); + + return convCount; + } + } + + static PendingIntent createPendingIntent(Context context, Intent intent) { + return TaskStackBuilder.create(context) + // add all of DetailsActivity's parents to the stack, + // followed by DetailsActivity itself + .addNextIntentWithParentStack(intent) + .getPendingIntent(NOTIFICATION_ID_MESSAGES, + PendingIntent.FLAG_UPDATE_CURRENT); + } + /** * This class accumulates all incoming unread threads and returns * well-formed data to be used in a {@link Notification}. - * @author Daniele Ricci + * Used only for legacy notifications (pre-JB). */ private static final class MessageAccumulator { - private final class ConversationStub { + static final class ConversationStub { public long id; public String peer; public String content; + public String groupJid; + public String groupSubject; } private ConversationStub conversation; private int convCount; - private int unreadCount; + int unreadCount; private Context mContext; private Contact mContact; @@ -606,7 +1156,7 @@ public MessageAccumulator(Context context) { } /** Adds a conversation thread to the accumulator. */ - public void accumulate(long id, String peer, String content, int unread) { + public void accumulate(long id, String peer, String content, int unread, String groupJid, String groupSubject) { // check old accumulated conversation if (conversation != null) { if (!conversation.peer.equalsIgnoreCase(peer)) @@ -621,6 +1171,8 @@ public void accumulate(long id, String peer, String content, int unread) { conversation.id = id; conversation.peer = peer; conversation.content = content; + conversation.groupJid = groupJid; + conversation.groupSubject = groupSubject; unreadCount += unread; } @@ -636,10 +1188,17 @@ public Contact getContact() { /** Returns the text that should be used as a ticker in the notification. */ public CharSequence getTicker() { cacheContact(); - String peer = (mContact != null) ? mContact.getName() : + String peer = (mContact != null) ? mContact.getDisplayName() : mContext.getString(R.string.peer_unknown); // debug mode -- conversation.peer; + // append group subject to contact name if any + if (conversation.groupJid != null) { + peer = mContext.getResources().getString(R.string.notification_group_title, + peer, (TextUtils.isEmpty(conversation.groupSubject) ? + mContext.getString(R.string.group_untitled) : conversation.groupSubject)); + } + SpannableStringBuilder buf = new SpannableStringBuilder(); buf.append(peer).append(':').append(' '); buf.setSpan(new StyleSpan(Typeface.BOLD), 0, buf.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -655,16 +1214,25 @@ public String getTitle() { } else { cacheContact(); - return (mContact != null) ? mContact.getName() : + String peer = (mContact != null) ? mContact.getDisplayName() : mContext.getString(R.string.peer_unknown); // debug mode -- conversation.peer; + + // append group subject to contact name if any + if (conversation.groupJid != null) { + peer = mContext.getResources().getString(R.string.notification_group_title, + peer, (TextUtils.isEmpty(conversation.groupSubject) ? + mContext.getString(R.string.group_untitled) : conversation.groupSubject)); + } + + return peer; } } /** Returns the text that should be used as the notification text. */ public String getText() { return (unreadCount > 1) ? - mContext.getString(R.string.unread_messages, unreadCount) + mContext.getResources().getQuantityString(R.plurals.unread_messages, unreadCount, unreadCount) : conversation.content; } @@ -673,7 +1241,7 @@ public PendingIntent getPendingIntent() { Intent ni; // more than one unread conversation - open ConversationList if (convCount > 1) { - ni = new Intent(mContext, ConversationList.class); + ni = new Intent(mContext, ConversationsActivity.class); ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); @@ -682,23 +1250,7 @@ public PendingIntent getPendingIntent() { else { ni = ComposeMessage.fromConversation(mContext, conversation.id); } - return PendingIntent.getActivity(mContext, NOTIFICATION_ID_MESSAGES, - ni, 0); - } - - public String getLastMessageText() { - return conversation.content; - } - - public String getLastMessagePeer() { - return conversation.peer; - } - - public PendingIntent getLastMessagePendingIntent() { - // one unread conversation - open ComposeMessage on that peer - Intent ni = ComposeMessage.fromConversation(mContext, conversation.id); - return PendingIntent.getActivity(mContext, NOTIFICATION_ID_QUICK_REPLY, - ni, 0); + return createPendingIntent(mContext, ni); } } } diff --git a/app/src/main/java/org/kontalk/ui/MyKeyActivity.java b/app/src/main/java/org/kontalk/ui/MyKeyActivity.java index f4543452e..1b2ace7af 100644 --- a/app/src/main/java/org/kontalk/ui/MyKeyActivity.java +++ b/app/src/main/java/org/kontalk/ui/MyKeyActivity.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,63 +18,59 @@ package org.kontalk.ui; -import java.util.HashMap; -import java.util.Map; - -import com.google.zxing.BarcodeFormat; -import com.google.zxing.EncodeHintType; -import com.google.zxing.WriterException; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; - import android.graphics.Bitmap; -import android.graphics.Color; -import android.graphics.Point; import android.os.Bundle; -import android.support.v7.app.ActionBar; -import android.support.v7.app.ActionBarActivity; -import android.util.Log; -import android.view.MenuItem; +import android.view.View; import android.widget.ImageView; import android.widget.TextView; import org.kontalk.Kontalk; +import org.kontalk.Log; import org.kontalk.R; import org.kontalk.crypto.PGP; import org.kontalk.crypto.PersonalKey; -import org.kontalk.util.SystemUtils; +import org.kontalk.util.ViewUtils; /** * My key activity. * @author Daniele Ricci */ -public class MyKeyActivity extends ActionBarActivity { +public class MyKeyActivity extends ToolbarActivity implements ViewUtils.OnQRCodeGeneratedListener { private static final String TAG = Kontalk.TAG; + private View mViewport; + private TextView mAccountName; private TextView mTextName; private TextView mTextFingerprint; private ImageView mQRCode; + private View mLoading; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.mykey_screen); - setupActivity(); + setupToolbar(true, true); - mTextName = (TextView) findViewById(R.id.name); - mTextFingerprint = (TextView) findViewById(R.id.fingerprint); - mQRCode = (ImageView) findViewById(R.id.qrcode); + mViewport = findViewById(R.id.scroller); + mAccountName = findViewById(R.id.account); + mTextName = findViewById(R.id.name); + mTextFingerprint = findViewById(R.id.fingerprint); + mQRCode = findViewById(R.id.qrcode); + mLoading = findViewById(R.id.loading); } @Override protected void onStart() { super.onStart(); + + mAccountName.setText(Kontalk.get().getDefaultAccount().getName()); + // load personal key PersonalKey key; try { - key = Kontalk.get(this).getPersonalKey(); + key = Kontalk.get().getPersonalKey(); } catch (Exception e) { // TODO handle errors @@ -87,59 +83,26 @@ protected void onStart() { String fingerprint = key.getFingerprint(); mTextFingerprint.setText(PGP.formatFingerprint(fingerprint) - .replaceFirst(" ", "\n")); - - try { - Bitmap qrCode = getQRCodeBitmap(PGP.createFingerprintURI(fingerprint)); - mQRCode.setImageBitmap(qrCode); - } - catch (WriterException e) { - // TODO handle errors - Log.w(TAG, "unable to generate fingerprint QR code"); - } - } + .replaceFirst(" {2}", "\n")); - private void setupActivity() { - ActionBar bar = getSupportActionBar(); - bar.setDisplayHomeAsUpEnabled(true); + ViewUtils.getQRCodeBitmapAsync(this, mViewport, + PGP.createFingerprintURI(fingerprint), this); } @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - return true; - } - - return super.onOptionsItemSelected(item); + protected boolean isNormalUpNavigation() { + return true; } - private Bitmap getQRCodeBitmap(String text) throws WriterException { - QRCodeWriter writer = new QRCodeWriter(); - Map hints = new HashMap<>(); - hints.put(EncodeHintType.MARGIN, 2); - Point size = SystemUtils.getDisplaySize(this); - BitMatrix matrix = writer.encode(text, BarcodeFormat.QR_CODE, size.x, size.x, hints); - return toBitmap(matrix); + @Override + public void onQRCodeGenerated(Bitmap qrCode) { + mLoading.setVisibility(View.GONE); + mQRCode.setImageBitmap(qrCode); } - /** - * Writes the given Matrix on a new Bitmap object. - * http://codeisland.org/2013/generating-qr-codes-with-zxing/ - * @param matrix the matrix to write. - * @return the new {@link Bitmap}-object. - */ - public static Bitmap toBitmap(BitMatrix matrix){ - int height = matrix.getHeight(); - int width = matrix.getWidth(); - Bitmap bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); - for (int x = 0; x < width; x++){ - for (int y = 0; y < height; y++){ - bmp.setPixel(x, y, matrix.get(x, y) ? Color.BLACK : Color.WHITE); - } - } - return bmp; + @Override + public void onQRCodeError(Exception e) { + mLoading.setVisibility(View.GONE); + // TODO error } - } diff --git a/app/src/main/java/org/kontalk/ui/NonSearchableDialog.java b/app/src/main/java/org/kontalk/ui/NonSearchableDialog.java new file mode 100644 index 000000000..8d642ae2d --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/NonSearchableDialog.java @@ -0,0 +1,49 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import com.afollestad.materialdialogs.MaterialDialog; + +import android.content.Context; + + +public class NonSearchableDialog extends MaterialDialog { + + private NonSearchableDialog(Builder builder) { + super(builder); + } + + @Override + public boolean onSearchRequested() { + return false; + } + + public static class Builder extends MaterialDialog.Builder { + + public Builder(Context context) { + super(context); + } + + @Override + public MaterialDialog build() { + return new NonSearchableDialog(this); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/NonSearchableProgressDialog.java b/app/src/main/java/org/kontalk/ui/NonSearchableProgressDialog.java deleted file mode 100644 index 457fcd0a0..000000000 --- a/app/src/main/java/org/kontalk/ui/NonSearchableProgressDialog.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.ui; - -import android.app.ProgressDialog; -import android.content.Context; - - -public class NonSearchableProgressDialog extends ProgressDialog { - - public NonSearchableProgressDialog(Context context) { - super(context); - } - - @Override - public boolean onSearchRequested() { - return false; - } - -} diff --git a/app/src/main/java/org/kontalk/ui/NumberValidation.java b/app/src/main/java/org/kontalk/ui/NumberValidation.java index b57a0e041..631dba2a4 100644 --- a/app/src/main/java/org/kontalk/ui/NumberValidation.java +++ b/app/src/main/java/org/kontalk/ui/NumberValidation.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,32 +18,63 @@ package org.kontalk.ui; -import android.accounts.Account; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.afollestad.materialdialogs.folderselector.FileChooserDialog; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.NumberParseException.ErrorType; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat; +import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; + import android.accounts.AccountManager; -import android.accounts.AccountManagerCallback; -import android.accounts.AccountManagerFuture; -import android.app.AlertDialog; +import android.annotation.SuppressLint; import android.app.Dialog; -import android.app.ProgressDialog; -import android.content.ContentResolver; +import android.content.BroadcastReceiver; +import android.content.ComponentName; import android.content.Context; import android.content.DialogInterface; import android.content.DialogInterface.OnCancelListener; import android.content.DialogInterface.OnDismissListener; import android.content.Intent; import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.net.Uri; import android.os.Bundle; import android.os.Handler; -import android.provider.ContactsContract; -import android.support.v4.content.LocalBroadcastManager; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.core.text.HtmlCompat; + +import android.os.IBinder; import android.telephony.PhoneNumberUtils; import android.telephony.TelephonyManager; import android.text.Editable; import android.text.InputType; +import android.text.Spanned; import android.text.TextUtils; import android.text.TextWatcher; -import android.util.Base64; -import android.util.Log; +import android.util.DisplayMetrics; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; @@ -54,99 +85,146 @@ import android.widget.Button; import android.widget.EditText; import android.widget.Spinner; +import android.widget.TextView; import android.widget.Toast; -import com.google.i18n.phonenumbers.NumberParseException; -import com.google.i18n.phonenumbers.NumberParseException.ErrorType; -import com.google.i18n.phonenumbers.PhoneNumberUtil; -import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat; -import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; - -import org.jivesoftware.smack.util.StringUtils; -import org.jxmpp.util.XmppStringUtils; -import org.spongycastle.openpgp.PGPException; +import butterknife.BindView; +import butterknife.ButterKnife; +import pub.devrel.easypermissions.AfterPermissionGranted; +import pub.devrel.easypermissions.EasyPermissions; import org.kontalk.BuildConfig; import org.kontalk.Kontalk; +import org.kontalk.Log; import org.kontalk.R; import org.kontalk.authenticator.Authenticator; import org.kontalk.client.EndpointServer; -import org.kontalk.client.NumberValidator; -import org.kontalk.client.NumberValidator.NumberValidatorListener; -import org.kontalk.crypto.PGPUserID; -import org.kontalk.crypto.PersonalKey; -import org.kontalk.crypto.PersonalKeyImporter; -import org.kontalk.crypto.X509Bridge; -import org.kontalk.service.KeyPairGeneratorService; -import org.kontalk.service.KeyPairGeneratorService.KeyGeneratorReceiver; -import org.kontalk.service.KeyPairGeneratorService.PersonalKeyRunnable; +import org.kontalk.crypto.PersonalKeyPack; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.service.DatabaseImporterService; +import org.kontalk.service.MessagesImporterService; +import org.kontalk.service.registration.RegistrationService; +import org.kontalk.service.registration.event.AbortRequest; +import org.kontalk.service.registration.event.AcceptTermsRequest; +import org.kontalk.service.registration.event.AccountCreatedEvent; +import org.kontalk.service.registration.event.FallbackVerificationRequest; +import org.kontalk.service.registration.event.ImportKeyError; +import org.kontalk.service.registration.event.ImportKeyRequest; +import org.kontalk.service.registration.event.KeyReceivedEvent; +import org.kontalk.service.registration.event.LoginTestEvent; +import org.kontalk.service.registration.event.PassphraseInputEvent; +import org.kontalk.service.registration.event.RetrieveKeyError; +import org.kontalk.service.registration.event.RetrieveKeyRequest; +import org.kontalk.service.registration.event.ServerCheckError; +import org.kontalk.service.registration.event.TermsAcceptedEvent; +import org.kontalk.service.registration.event.ThrottlingError; +import org.kontalk.service.registration.event.UserConflictError; +import org.kontalk.service.registration.event.VerificationError; +import org.kontalk.service.registration.event.VerificationRequest; +import org.kontalk.service.registration.event.VerificationRequestedEvent; import org.kontalk.sync.SyncAdapter; import org.kontalk.ui.adapter.CountryCodesAdapter; import org.kontalk.ui.adapter.CountryCodesAdapter.CountryCode; -import org.kontalk.util.MessageUtils; +import org.kontalk.ui.prefs.PreferencesActivity; +import org.kontalk.util.ParameterRunnable; +import org.kontalk.util.Permissions; import org.kontalk.util.Preferences; - -import java.io.FileInputStream; -import java.io.IOException; -import java.lang.ref.WeakReference; -import java.net.SocketException; -import java.util.Comparator; -import java.util.HashSet; -import java.util.Locale; -import java.util.Set; -import java.util.zip.ZipInputStream; +import org.kontalk.util.SystemUtils; /** Number validation activity. */ public class NumberValidation extends AccountAuthenticatorActionBarActivity - implements NumberValidatorListener { + implements FileChooserDialog.FileCallback { static final String TAG = NumberValidation.class.getSimpleName(); - public static final int REQUEST_MANUAL_VALIDATION = 771; - public static final int REQUEST_VALIDATION_CODE = 772; + static final int REQUEST_MANUAL_VALIDATION = 771; + static final int REQUEST_VALIDATION_CODE = 772; + static final int REQUEST_SCAN_TOKEN = 773; + static final int REQUEST_ASK_TOKEN = 774; + + public static final int RESULT_FALLBACK = RESULT_FIRST_USER + 1; public static final String PARAM_FROM_INTERNAL = "org.kontalk.internal"; + public static final String PARAM_ACCOUNT_NAME = "org.kontalk.accountName"; public static final String PARAM_PUBLICKEY = "org.kontalk.publickey"; public static final String PARAM_PRIVATEKEY = "org.kontalk.privatekey"; public static final String PARAM_SERVER_URI = "org.kontalk.server"; - - private AccountManager mAccountManager; - private EditText mNameText; - private Spinner mCountryCode; - private EditText mPhone; - private Button mValidateButton; - private Button mInsertCode; - private ProgressDialog mProgress; + public static final String PARAM_TERMS_URL = "org.kontalk.termsURL"; + public static final String PARAM_CHALLENGE = "org.kontalk.challenge"; + public static final String PARAM_TRUSTED_KEYS = "org.kontalk.trustedkeys"; + + private static final String CHOOSER_TAG_MESSAGES_DB = "messages.db"; + + @BindView(R.id.name) + EditText mNameText; + @BindView(R.id.phone_cc) + Spinner mCountryCode; + @BindView(R.id.phone_number) + EditText mPhone; + @BindView(R.id.button_validate) + Button mValidateButton; + + private MaterialDialog mProgress; private CharSequence mProgressMessage; - private NumberValidator mValidator; - private Handler mHandler; - private String mPhoneNumber; - private String mName; + Handler mHandler; - private PersonalKey mKey; - private String mPassphrase; - private byte[] mImportedPublicKey; - private byte[] mImportedPrivateKey; + private boolean mForce; private LocalBroadcastManager lbm; - private boolean mFromInternal; + /** Will be true when resuming for a fallback registration. */ + private boolean mClearState; + boolean mFromInternal; /** Runnable for delaying initial manual sync starter. */ - private Runnable mSyncStart; + Runnable mSyncStart; private boolean mSyncing; - private KeyGeneratorReceiver mKeyReceiver; + private boolean mWaitingAcceptTerms; + private boolean mPermissionsAsked; + + /** + * A list of servers for which the user accepted the service terms. + * We need to keep a record so we don't ask twice for the same server + * (that's because two attempts might get two different random servers). + */ + private Set mAcceptedTermsServers = new HashSet<>(); + + private BroadcastReceiver mMessagesImporterReceiver; + + private EventBus mServiceBus = RegistrationService.bus(); + /** Just a dummy service connection to keep the service alive. */ + private ServiceConnection mServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + } + + @Override + public void onServiceDisconnected(ComponentName name) { + } + }; private static final class RetainData { - NumberValidator validator; /** @deprecated Use saved instance state. */ @Deprecated CharSequence progressMessage; /** @deprecated Use saved instance state. */ @Deprecated boolean syncing; + + RetainData() { + } + } + + private static final class RegistrationInput { + final String displayName; + final String phoneNumber; + + RegistrationInput(String displayName, String phoneNumber) { + this.displayName = displayName; + this.phoneNumber = phoneNumber; + } } /** @@ -177,15 +255,16 @@ private Set getSupportedRegions(PhoneNumberUtil util) { // ignored } - return new HashSet(); + return new HashSet<>(); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.number_validation); + ButterKnife.bind(this); + setupToolbar(false, false); - mAccountManager = AccountManager.get(this); mHandler = new Handler(); lbm = LocalBroadcastManager.getInstance(getApplicationContext()); @@ -193,14 +272,10 @@ public void onCreate(Bundle savedInstanceState) { final Intent intent = getIntent(); mFromInternal = intent.getBooleanExtra(PARAM_FROM_INTERNAL, false); - mNameText = (EditText) findViewById(R.id.name); - mCountryCode = (Spinner) findViewById(R.id.phone_cc); - mPhone = (EditText) findViewById(R.id.phone_number); - mValidateButton = (Button) findViewById(R.id.button_validate); - mInsertCode = (Button) findViewById(R.id.button_validation_code); - // populate country codes - final CountryCodesAdapter ccList = new CountryCodesAdapter(this, R.layout.country_item, R.layout.country_dropdown_item); + final CountryCodesAdapter ccList = new CountryCodesAdapter(this, + R.layout.countrycode_spinner_item, + android.R.layout.simple_spinner_dropdown_item); PhoneNumberUtil util = PhoneNumberUtil.getInstance(); Set ccSet = getSupportedRegions(util); for (String cc : ccSet) @@ -218,29 +293,10 @@ public void onItemSelected(AdapterView parent, View view, int position, long } public void onNothingSelected(AdapterView parent) { - // TODO Auto-generated method stub + // unused } }); - // FIXME this doesn't consider creation because of configuration change - PhoneNumber myNum = NumberValidator.getMyNumber(this); - if (myNum != null) { - CountryCode cc = new CountryCode(); - cc.regionCode = util.getRegionCodeForNumber(myNum); - if (cc.regionCode == null) - cc.regionCode = util.getRegionCodeForCountryCode(myNum.getCountryCode()); - mCountryCode.setSelection(ccList.getPositionForId(cc)); - mPhone.setText(String.valueOf(myNum.getNationalNumber())); - } - else { - final TelephonyManager tm = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE); - final String regionCode = tm.getSimCountryIso().toUpperCase(Locale.US); - CountryCode cc = new CountryCode(); - cc.regionCode = regionCode; - cc.countryCode = util.getCountryCodeForRegion(regionCode); - mCountryCode.setSelection(ccList.getPositionForId(cc)); - } - // listener for autoselecting country code from typed phone number mPhone.addTextChangedListener(new TextWatcher() { @Override @@ -267,10 +323,6 @@ public void afterTextChanged(Editable s) { if (data.syncing) { delayedSync(); } - - mValidator = data.validator; - if (mValidator != null) - mValidator.setListener(this); } if (data.progressMessage != null) { setProgressMessage(data.progressMessage, true); @@ -278,34 +330,35 @@ public void afterTextChanged(Editable s) { } } + /** Not used. */ + @Override + protected boolean isNormalUpNavigation() { + return false; + } + @Override protected void onSaveInstanceState(Bundle state) { super.onSaveInstanceState(state); - state.putString("name", mName); - state.putString("phoneNumber", mPhoneNumber); - state.putParcelable("key", mKey); - state.putString("passphrase", mPassphrase); - state.putByteArray("importedPrivateKey", mImportedPrivateKey); - state.putByteArray("importedPublicKey", mImportedPublicKey); + state.putBoolean("waitingAcceptTerms", mWaitingAcceptTerms); + state.putBoolean("permissionsAsked", mPermissionsAsked); + state.putStringArrayList("acceptedTermsServers", new ArrayList<>(mAcceptedTermsServers)); } @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); - - mName = savedInstanceState.getString("name"); - mPhoneNumber = savedInstanceState.getString("phoneNumber"); - mKey = savedInstanceState.getParcelable("key"); - mPassphrase = savedInstanceState.getString("passphrase"); - mImportedPublicKey = savedInstanceState.getByteArray("importedPublicKey"); - mImportedPrivateKey = savedInstanceState.getByteArray("importedPrivateKey"); + mWaitingAcceptTerms = savedInstanceState.getBoolean("waitingAcceptTerms"); + mPermissionsAsked = savedInstanceState.getBoolean("permissionsAsked"); + List acceptedTermsServers = savedInstanceState.getStringArrayList("acceptedTermsServers"); + if (acceptedTermsServers != null) { + mAcceptedTermsServers = new HashSet<>(acceptedTermsServers); + } } /** Returning the validator thread. */ @Override public Object onRetainCustomNonConfigurationInstance() { RetainData data = new RetainData(); - data.validator = mValidator; if (mProgress != null) data.progressMessage = mProgressMessage; data.syncing = mSyncing; return data; @@ -315,6 +368,7 @@ public Object onRetainCustomNonConfigurationInstance() { public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.number_validation_menu, menu); + menu.findItem(R.id.menu_import_messages_database).setVisible(BuildConfig.DEBUG); return true; } @@ -322,14 +376,21 @@ public boolean onCreateOptionsMenu(Menu menu) { public boolean onOptionsItemSelected(MenuItem item) { switch(item.getItemId()) { case R.id.menu_settings: { - Intent intent = new Intent(this, BootstrapPreferences.class); - startActivityIfNeeded(intent, -1); + PreferencesActivity.start(this); break; } case R.id.menu_import_key: { importKey(); break; } + case R.id.menu_import_device: { + importDevice(); + break; + } + case R.id.menu_import_messages_database: { + importMessagesDatabase(); + break; + } default: return true; } @@ -340,103 +401,189 @@ public boolean onOptionsItemSelected(MenuItem item) { protected void onStart() { super.onStart(); - Preferences.RegistrationProgress saved; - try { - saved = Preferences.getRegistrationProgress(this); - } - catch (Exception e) { - Log.w(TAG, "unable to restore registration progress"); - Preferences.clearRegistrationProgress(this); - saved = null; - } - if (saved != null) { - mName = saved.name; - mPhoneNumber = saved.phone; - mKey = saved.key; - mPassphrase = saved.passphrase; - mImportedPublicKey = saved.importedPublicKey; - mImportedPrivateKey = saved.importedPrivateKey; - - // update UI - mNameText.setText(mName); - mPhone.setText(mPhoneNumber); - syncCountryCodeSelector(); - - startValidationCode(REQUEST_MANUAL_VALIDATION, saved.sender, saved.server, false); + if (RegistrationService.hasSavedState() || getLastCustomNonConfigurationInstance() != null) { + if (mClearState) { + RegistrationService.clearSavedState(); + mClearState = false; + } + else { + // register to the bus immediately if we have saved state + register(); + } } - if (mKey == null) { - PersonalKeyRunnable action = new PersonalKeyRunnable() { - public void run(PersonalKey key) { - if (key != null) { - mKey = key; - if (mValidator != null) - // this will release the waiting lock - mValidator.setKey(mKey); - } - - // no key, key pair generation started - else if (BuildConfig.DEBUG) { - Toast.makeText(NumberValidation.this, - R.string.msg_generating_keypair, - Toast.LENGTH_LONG).show(); - } - } - }; - - // random passphrase (40 characters!!!!) - mPassphrase = StringUtils.randomString(40); - - mKeyReceiver = new KeyGeneratorReceiver(mHandler, action); - - IntentFilter filter = new IntentFilter(KeyPairGeneratorService.ACTION_GENERATE); - filter.addAction(KeyPairGeneratorService.ACTION_STARTED); - lbm.registerReceiver(mKeyReceiver, filter); - - Intent i = new Intent(this, KeyPairGeneratorService.class); - i.setAction(KeyPairGeneratorService.ACTION_GENERATE); - startService(i); - } + // workaround for https://issuetracker.google.com/issues/110237673 + // I know I should start the service in onResume, but I like to cheat + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + // start registration service immediately + RegistrationService.start(NumberValidation.this); + // be sure to bind to the registration service so it's not killed by the OS + bindService(new Intent(NumberValidation.this, RegistrationService.class), mServiceConnection, BIND_IMPORTANT); + } + }, 200); } @Override protected void onStop() { super.onStop(); keepScreenOn(false); + mServiceBus.unregister(this); - stopKeyReceiver(); + try { + unbindService(mServiceConnection); + } + catch (IllegalArgumentException ignored) { + } + + stopMessagesImporterReceiver(); if (mProgress != null) { - if (isFinishing()) + if (!isWaitingAcceptTerms() && isFinishing()) mProgress.cancel(); else mProgress.dismiss(); } } - private void stopKeyReceiver() { - if (mKeyReceiver != null) - lbm.unregisterReceiver(mKeyReceiver); + @Override + protected void onResume() { + super.onResume(); + // ask for access to contacts + askPermissions(); } @Override - protected void onUserLeaveHint() { - keepScreenOn(false); - if (mProgress != null) - mProgress.cancel(); + public void onBackPressed() { + // willingly exiting the registration process + RegistrationService.stop(this); + super.onBackPressed(); + } + + private void register() { + if (!mServiceBus.isRegistered(this)) { + mServiceBus.register(this); + } + } + + private void register(Object postEvent) { + register(); + mServiceBus.post(postEvent); + } + + private void unregister() { + if (mServiceBus.isRegistered(this)) { + mServiceBus.unregister(this); + } + } + + private boolean isWaitingAcceptTerms() { + return mWaitingAcceptTerms; + } + + void stopMessagesImporterReceiver() { + if (mMessagesImporterReceiver != null) { + lbm.unregisterReceiver(mMessagesImporterReceiver); + mMessagesImporterReceiver = null; + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == REQUEST_MANUAL_VALIDATION && resultCode == RESULT_OK) { - finishLogin(data.getStringExtra(PARAM_SERVER_URI), - data.getByteArrayExtra(PARAM_PRIVATEKEY), - data.getByteArrayExtra(PARAM_PUBLICKEY), - true); + if (requestCode == REQUEST_MANUAL_VALIDATION) { + if (resultCode == RESULT_OK) { + finishLogin(data.getStringExtra(PARAM_ACCOUNT_NAME)); + } + else if (resultCode == RESULT_FALLBACK) { + mClearState = true; + startValidation(RegistrationService.currentState().force, true); + } + } + else if (requestCode == REQUEST_SCAN_TOKEN) { + if (resultCode == RESULT_OK && data != null) { + String token = data.getStringExtra("text"); + if (!TextUtils.isEmpty(token)) { + RegisterDeviceActivity.PrivateKeyToken privateKeyToken = + RegisterDeviceActivity.parseTokenText(token); + + if (privateKeyToken != null) { + requestPrivateKey(privateKeyToken.account, privateKeyToken.server, privateKeyToken.token); + return; + } + } + + new MaterialDialog.Builder(this) + .content(R.string.import_device_invalid_barcode) + .positiveText(android.R.string.ok) + .show(); + } + } + else if (requestCode == REQUEST_ASK_TOKEN) { + if (resultCode == RESULT_OK && data != null) { + String account = data.getStringExtra(ImportDeviceActivity.EXTRA_ACCOUNT); + String server = data.getStringExtra(ImportDeviceActivity.EXTRA_SERVER); + String token = data.getStringExtra(ImportDeviceActivity.EXTRA_TOKEN); + // no need to verify the data, import device activity already checked + + requestPrivateKey(account, server, token); + } + } + else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + @AfterPermissionGranted(Permissions.RC_PHONE_STATE) + @SuppressLint("MissingPermission") + void detectMyNumber() { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + + // FIXME this doesn't consider creation because of configuration change + CountryCodesAdapter ccList = (CountryCodesAdapter) mCountryCode.getAdapter(); + PhoneNumber myNum = RegistrationService.getMyNumber(this); + if (myNum != null) { + CountryCode cc = new CountryCode(); + cc.regionCode = util.getRegionCodeForNumber(myNum); + if (cc.regionCode == null) + cc.regionCode = util.getRegionCodeForCountryCode(myNum.getCountryCode()); + mCountryCode.setSelection(ccList.getPositionForId(cc)); + mPhone.setText(String.valueOf(myNum.getNationalNumber())); + } + else { + final TelephonyManager tm = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE); + String country = tm.getSimCountryIso(); + if (country != null) { + final String regionCode = country.toUpperCase(Locale.US); + CountryCode cc = new CountryCode(); + cc.regionCode = regionCode; + cc.countryCode = util.getCountryCodeForRegion(regionCode); + mCountryCode.setSelection(ccList.getPositionForId(cc)); + } } } + private void askPermissions() { + if (mPermissionsAsked) + return; + + Permissions.requestPhoneState(this, getString(R.string.err_validation_phone_state_denied)); + + if (!Permissions.canWriteContacts(this)) { + Permissions.requestContacts(this, getString(R.string.err_validation_contacts_denied)); + } + + mPermissionsAsked = true; + } + private void keepScreenOn(boolean active) { + Log.i(TAG, "keeping screen " + (active ? "ON" : "OFF")); if (active) getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); else @@ -451,7 +598,7 @@ public static void start(Context context) { } /** Sync country code with text entered by the user, if possible. */ - private void syncCountryCodeSelector() { + void syncCountryCodeSelector() { try { PhoneNumberUtil util = PhoneNumberUtil.getInstance(); CountryCode cc = (CountryCode) mCountryCode.getSelectedItem(); @@ -473,99 +620,200 @@ private void syncCountryCodeSelector() { } private void enableControls(boolean enabled) { + Log.i(TAG, "setting controls " + (enabled ? "ENABLED" : "DISABLED")); + mNameText.setEnabled(enabled); mValidateButton.setEnabled(enabled); - mInsertCode.setEnabled(enabled); mCountryCode.setEnabled(enabled); mPhone.setEnabled(enabled); } - private void error(int title, int message) { - new AlertDialog.Builder(this) - .setTitle(title) - .setMessage(message) - .setNeutralButton(android.R.string.ok, null) + void error(int message) { + new MaterialDialog.Builder(this) + .content(message) + .positiveText(android.R.string.ok) .show(); } - private boolean checkInput(boolean importing) { - mPhoneNumber = null; - String phoneStr = null; + void onGenericError(Throwable e) { + keepScreenOn(false); + int msgId; + if (e instanceof SocketException) { + msgId = R.string.err_validation_network_error; + } + else { + msgId = R.string.err_validation_error; + } + Toast.makeText(NumberValidation.this, msgId, Toast.LENGTH_LONG).show(); + abort(); + } + + private void checkInput(boolean importing, final ParameterRunnable callback) { + final String phoneStr; + final String displayName; // check name first if (!importing) { - mName = mNameText.getText().toString().trim(); - if (mName.length() == 0) { - error(R.string.title_no_name, R.string.msg_no_name); - return false; + displayName = mNameText.getText().toString().trim(); + if (displayName.length() == 0) { + error(R.string.msg_no_name); + callback.run(null); + return; } } else { - // we will use the one in the imported key - mName = null; + // retrieve from imported key later + displayName = null; } - PhoneNumberUtil util = PhoneNumberUtil.getInstance(); - CountryCode cc = (CountryCode) mCountryCode.getSelectedItem(); - if (!BuildConfig.DEBUG) { - PhoneNumber phone; - try { - phone = util.parse(mPhone.getText().toString(), cc.regionCode); - // autoselect correct country if user entered country code too - if (phone.hasCountryCode()) { - CountryCode ccLookup = new CountryCode(); - ccLookup.regionCode = util.getRegionCodeForNumber(phone); - ccLookup.countryCode = phone.getCountryCode(); - int position = ((CountryCodesAdapter) mCountryCode.getAdapter()).getPositionForId(ccLookup); - if (position >= 0) { - mCountryCode.setSelection(position); - cc = (CountryCode) mCountryCode.getItemAtPosition(position); + String phoneInput = mPhone.getText().toString(); + String phoneConfirm; + // if the user entered a phone number use it even when importing for backward compatibility + if (!importing || !phoneInput.isEmpty()){ + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + CountryCode cc = (CountryCode) mCountryCode.getSelectedItem(); + if (cc == null) { + error(R.string.msg_invalid_cc); + callback.run(null); + return; + } + + if (!BuildConfig.DEBUG) { + PhoneNumber phone; + try { + phone = util.parse(phoneInput, cc.regionCode); + // autoselect correct country if user entered country code too + if (phone.hasCountryCode()) { + CountryCode ccLookup = new CountryCode(); + ccLookup.regionCode = util.getRegionCodeForNumber(phone); + ccLookup.countryCode = phone.getCountryCode(); + int position = ((CountryCodesAdapter) mCountryCode.getAdapter()).getPositionForId(ccLookup); + if (position >= 0) { + mCountryCode.setSelection(position); + cc = (CountryCode) mCountryCode.getItemAtPosition(position); + } } + // handle special cases + RegistrationService.handleSpecialCases(phone); + if (!util.isValidNumberForRegion(phone, cc.regionCode) && !RegistrationService.isSpecialNumber(phone)) + throw new NumberParseException(ErrorType.INVALID_COUNTRY_CODE, "invalid number for region " + cc.regionCode); } - if (!util.isValidNumberForRegion(phone, cc.regionCode)) { - throw new NumberParseException(ErrorType.INVALID_COUNTRY_CODE, "invalid number for region " + cc.regionCode); + catch (NumberParseException e1) { + error(R.string.msg_invalid_number); + callback.run(null); + return; } - } - catch (NumberParseException e1) { - error(R.string.title_invalid_number, R.string.msg_invalid_number); - return false; - } - // check phone number format - if (phone != null) { + // check phone number format phoneStr = util.format(phone, PhoneNumberFormat.E164); if (!PhoneNumberUtils.isWellFormedSmsAddress(phoneStr)) { Log.i(TAG, "not a well formed SMS address"); } + + // for the confirmation dialog + phoneConfirm = util.format(phone, PhoneNumberFormat.INTERNATIONAL); + } + else { + phoneStr = phoneConfirm = String.format(Locale.US, "+%d%s", cc.countryCode, mPhone.getText().toString()); + } + + // phone is null - invalid number + if (phoneStr == null) { + Toast.makeText(this, R.string.warn_invalid_number, Toast.LENGTH_SHORT) + .show(); + callback.run(null); + return; + } + + Log.v(TAG, "Using phone number to register: " + phoneStr); + + // confirmation dialog only if not importing + if (!importing && !mForce) { + MaterialDialog dialog = new MaterialDialog.Builder(this) + .customView(R.layout.dialog_register_confirm, false) + .positiveText(android.R.string.ok) + .positiveColorRes(R.color.button_success) + .negativeText(android.R.string.cancel) + .onAny(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + switch (which) { + case POSITIVE: + callback.run(new RegistrationInput(displayName, phoneStr)); + break; + case NEGATIVE: + callback.run(null); + break; + } + } + }) + .cancelListener(new OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + callback.run(null); + } + }) + .build(); + + TextView phoneTextView = (TextView) dialog.findViewById(R.id.bigtext); + phoneTextView.setText(phoneConfirm); + + dialog.show(); + } + else { + callback.run(new RegistrationInput(displayName, phoneStr)); } } else { - phoneStr = String.format(Locale.US, "+%d%s", cc.countryCode, mPhone.getText().toString()); + // we will use the data from the imported key + callback.run(new RegistrationInput(null, null)); } - - // phone is null - invalid number - if (phoneStr == null) { - Toast.makeText(this, R.string.warn_invalid_number, Toast.LENGTH_SHORT) - .show(); - return false; - } - - Log.v(TAG, "Using phone number to register: " + phoneStr); - mPhoneNumber = phoneStr; - return true; } - private void startValidation(boolean force) { + void startValidation(final boolean force, final boolean fallback) { + mForce = force; enableControls(false); - if (!checkInput(false)) { - enableControls(true); + checkInput(false, new ParameterRunnable() { + @Override + public void run(RegistrationInput result) { + if (result != null) { + if (fallback) { + startFallbackValidation(); + } + else { + startValidationNormal(null, result.displayName, result.phoneNumber, force); + } + } + else { + enableControls(true); + } + } + }); + } + + @RegistrationService.BrandImageSize + private int getBrandImageSize() { + int density = getResources().getDisplayMetrics().densityDpi; + if (density <= DisplayMetrics.DENSITY_MEDIUM) { + return RegistrationService.BRAND_IMAGE_SMALL; + } + else if (density <= DisplayMetrics.DENSITY_HIGH) { + return RegistrationService.BRAND_IMAGE_MEDIUM; + } + else if (density <= DisplayMetrics.DENSITY_XXHIGH) { + return RegistrationService.BRAND_IMAGE_LARGE; } else { - startValidationNormal(null, force, false); + return RegistrationService.BRAND_IMAGE_HD; } } - private void startValidationNormal(String manualServer, boolean force, boolean testImport) { + private boolean startValidationNormal(String manualServer, String displayName, String phoneNumber, boolean force) { + if (!SystemUtils.isNetworkConnectionAvailable(this)) { + error(R.string.err_validation_nonetwork); + return false; + } + // start async request Log.d(TAG, "phone number checked, sending validation request"); startProgress(); @@ -578,19 +826,23 @@ private void startValidationNormal(String manualServer, boolean force, boolean t provider = Preferences.getEndpointServerProvider(this); } - boolean imported = (mImportedPrivateKey != null && mImportedPublicKey != null); + register(new VerificationRequest(phoneNumber, displayName, + provider, force, getBrandImageSize())); + return true; + } - mValidator = new NumberValidator(this, provider, mName, mPhoneNumber, - imported ? null : mKey, mPassphrase); - mValidator.setListener(this); - mValidator.setForce(force); - if (imported) - mValidator.importKey(mImportedPrivateKey, mImportedPublicKey); + private boolean startFallbackValidation() { + if (!SystemUtils.isNetworkConnectionAvailable(this)) { + error(R.string.err_validation_nonetwork); + return false; + } - if (testImport) - mValidator.testImport(); + // start async request + Log.d(TAG, "sending fallback validation request"); + startProgress(); - mValidator.start(); + register(new FallbackVerificationRequest()); + return true; } /** @@ -599,153 +851,258 @@ private void startValidationNormal(String manualServer, boolean force, boolean t * @param v not used */ public void validatePhone(View v) { - keepScreenOn(true); - startValidation(false); + startValidation(false, false); } - /** - * Opens manual validation window immediately. - * Also used by the view definition as the {@link OnClickListener}. - * @param v not used - */ - public void validateCode(View v) { - if (checkInput(false)) - startValidationCode(REQUEST_VALIDATION_CODE, null); + private void importMessagesDatabase() { + if (!Permissions.canReadExternalStorage(NumberValidation.this)) { + // TODO rationale + Permissions.requestReadExternalStorage(NumberValidation.this, null, 1); + } + else { + browseImportMessagesDatabase(); + } + } + + // Permissions added 1*100 because of index 1 provided in the call + @AfterPermissionGranted(Permissions.RC_READ_EXT_STORAGE + 100) + void browseImportMessagesDatabase() { + new FileChooserDialog.Builder(NumberValidation.this) + .mimeType("*/*") + .tag(CHOOSER_TAG_MESSAGES_DB) + .show(getSupportFragmentManager()); } /** Opens import keys from another device wizard. */ private void importKey() { - if (checkInput(true)) { - // import keys -- number verification with server is still needed - // though because of key rollback protection - // TODO allow for manual validation too - // TODO we should verify the number against the user ID - - new AlertDialog.Builder(this) - .setTitle(R.string.pref_import_keypair) - .setMessage(getString(R.string.msg_import_keypair, PersonalKeyImporter.KEYPACK_FILENAME)) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - // do not wait for the generated key - stopKeyReceiver(); - - ZipInputStream zip = null; - try { - zip = new ZipInputStream(new FileInputStream - (PersonalKeyImporter.DEFAULT_KEYPACK)); - - // ask passphrase to user and assign to mPassphrase - importAskPassphrase(zip); - } - catch (Exception e) { - Log.e(TAG, "error importing keys", e); - mImportedPublicKey = mImportedPrivateKey = null; + checkInput(true, new ParameterRunnable() { + @Override + public void run(RegistrationInput result) { + if (result != null) { + // import keys -- number verification with server is still needed + // though because of key rollback protection + // TODO allow for manual validation too + + if (!Permissions.canReadExternalStorage(NumberValidation.this)) { + // TODO rationale + Permissions.requestReadExternalStorage(NumberValidation.this, null); + } + else { + browseImportKey(); + } + } + } + }); + } - try { - if (zip != null) - zip.close(); - } - catch (IOException ignored) { - // ignored. - } + @AfterPermissionGranted(Permissions.RC_READ_EXT_STORAGE) + void browseImportKey() { + new FileChooserDialog.Builder(NumberValidation.this) + .initialPath(PersonalKeyPack.DEFAULT_KEYPACK.getParent()) + .mimeType(PersonalKeyPack.KEYPACK_MIME) + .show(getSupportFragmentManager()); + } - Toast.makeText(NumberValidation.this, - R.string.err_import_keypair_failed, - Toast.LENGTH_LONG).show(); - } - } - }) - .show(); + /** + * Opens a screen for shooting a QR code or typing in a secure token from + * another device. + */ + @SuppressLint("UnsupportedChromeOsCameraSystemFeature") + void importDevice() { + PackageManager pm = getPackageManager(); + boolean hasCamera; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) { + hasCamera = pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY); + } + else { + hasCamera = pm.hasSystemFeature(PackageManager.FEATURE_CAMERA); } - } - private void importAskPassphrase(final ZipInputStream zip) { - new InputDialog.Builder(this, - InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) - .setTitle(R.string.title_passphrase) - .setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - try { - zip.close(); - } - catch (IOException e) { - // ignored + MaterialDialog.Builder builder = new MaterialDialog.Builder(this) + .title(R.string.menu_import_device) + .content(R.string.import_device_message) + .neutralText(R.string.import_device_btn_input) + .onAny(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + switch (which) { + case POSITIVE: + scanToken(); + break; + case NEUTRAL: + askToken(); + break; } } - }) - .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - startImport(zip, InputDialog.getInputText - ((Dialog) dialog).toString()); + }); + + if (hasCamera) { + builder.positiveText(R.string.import_device_btn_scan); + } + + builder.show(); + } + + void scanToken() { + ScanTextActivity.start(this, getString(R.string.import_device_scan_screen_title), + REQUEST_SCAN_TOKEN); + } + + void askToken() { + ImportDeviceActivity.start(this, REQUEST_ASK_TOKEN); + } + + void requestPrivateKey(String account, String server, String token) { + if (!SystemUtils.isNetworkConnectionAvailable(this)) { + error(R.string.err_validation_nonetwork); + return; + } + + startProgress(getString(R.string.import_device_requesting)); + + register(new RetrieveKeyRequest(new EndpointServer(server), account, token)); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onKeyReceived(final KeyReceivedEvent event) { + new MaterialDialog.Builder(NumberValidation.this) + .title(R.string.title_passphrase) + .inputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) + .input(null, null, new MaterialDialog.InputCallback() { + @Override + public void onInput(@NonNull MaterialDialog dialog, CharSequence input) { + register(new PassphraseInputEvent(input.toString())); } }) + .negativeText(android.R.string.cancel) + .positiveText(android.R.string.ok) .show(); } - private void startImport(ZipInputStream zip, String passphrase) { - PersonalKeyImporter importer = null; - String manualServer = Preferences.getServerURI(this); + @Subscribe(threadMode = ThreadMode.MAIN) + public void onRetrieveKeyError(RetrieveKeyError event) { + // FIXME maybe a somewhat more detailed explaination + onGenericError(event.exception); + } - try { - importer = new PersonalKeyImporter(zip, passphrase); - importer.load(); + @Subscribe(threadMode = ThreadMode.MAIN) + public void onImportKeyError(ImportKeyError event) { + if (event.exception instanceof RegistrationService.NoPhoneNumberFoundException) { + Toast.makeText(this, R.string.warn_invalid_number, Toast.LENGTH_SHORT).show(); + } + else { + onGenericError(event.exception); + } + } - // we do not save this test key into the mKey field - // we need it to be clear so the validator will use the imported data - // createPersonalKey is called only to make sure data is valid - PersonalKey key = importer.createPersonalKey(); - if (key == null) - throw new PGPException("unable to load imported personal key."); + @Subscribe(threadMode = ThreadMode.MAIN) + public void onLoginTest(LoginTestEvent event) { + if (event.exception != null) { + if (RegistrationService.currentState().workflow == RegistrationService.Workflow.IMPORT_KEY) { + // We are importing a key but the server refused it. + // Proceed with normal verification but using the imported key + // which will be signed by the server again + // TODO proceed with normal verification flow + } + else { + onGenericError(event.exception); + } + } + } - String uidStr = key.getUserId(null); - PGPUserID uid = PGPUserID.parse(uidStr); - if (uid == null) - throw new PGPException("malformed user ID: " + uidStr); + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAccountCreated(AccountCreatedEvent event) { + RegistrationService.stop(this); + String challenge = RegistrationService.currentState().challenge; - // check that uid matches phone number - String email = uid.getEmail(); - String numberHash = MessageUtils.sha1(mPhoneNumber); - String localpart = XmppStringUtils.parseLocalpart(email); - if (!numberHash.equalsIgnoreCase(localpart)) - throw new PGPException("email does not match phone number: " + email); + // send back result + final Intent intent = new Intent(); + intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, event.account.getName()); + intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, Authenticator.ACCOUNT_TYPE); - // use server from the key only if we didn't set our own - if (TextUtils.isEmpty(manualServer)) - manualServer = XmppStringUtils.parseDomain(email); + setAccountAuthenticatorResult(intent.getExtras()); + setResult(RESULT_OK, intent); - mName = uid.getName(); - mImportedPublicKey = importer.getPublicKeyData(); - mImportedPrivateKey = importer.getPrivateKeyData(); - } + ReportingManager.logSignUp(challenge); - catch (Exception e) { - Log.e(TAG, "error importing keys", e); - mImportedPublicKey = mImportedPrivateKey = null; - mName = null; + // manual sync starter + delayedSync(); + } - Toast.makeText(this, - R.string.err_import_keypair_failed, - Toast.LENGTH_LONG).show(); - } + private void importAskPassphrase(final InputStream zip) { + new MaterialDialog.Builder(this) + .title(R.string.title_passphrase) + .inputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) + .input(null, null, new MaterialDialog.InputCallback() { + @Override + public void onInput(@NonNull MaterialDialog dialog, CharSequence input) { + startProgress(getString(R.string.msg_importing_key)); - finally { + register(new ImportKeyRequest(Kontalk.get().getEndpointServer(), + zip, input.toString(), true, getBrandImageSize())); + } + }) + .onNegative(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog materialDialog, @NonNull DialogAction dialogAction) { + try { + zip.close(); + } + catch (IOException e) { + // ignored + } + } + }) + .negativeText(android.R.string.cancel) + .positiveText(android.R.string.ok) + .show(); + } + + @Override + public void onFileSelection(@NonNull FileChooserDialog fileChooserDialog, @NonNull File file) { + if (CHOOSER_TAG_MESSAGES_DB.equals(fileChooserDialog.getTag())) { + // FIXME this whole progress and report system is for debug purposes only + if (mMessagesImporterReceiver == null) { + mMessagesImporterReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + stopMessagesImporterReceiver(); + + String error = intent.getStringExtra(DatabaseImporterService.EXTRA_ERROR); + if (error == null) + // TODO i18n + error = "Messages imported successfully."; + + new MaterialDialog.Builder(NumberValidation.this) + // TODO i18n + .title("Import messages") + .content(error) + .positiveText(android.R.string.ok) + .show(); + } + }; + lbm.registerReceiver(mMessagesImporterReceiver, + new IntentFilter(DatabaseImporterService.ACTION_FINISH)); + } + + // TODO setup progress dialog + MessagesImporterService.startImport(this, Uri.fromFile(file)); + } + else { try { - if (importer != null) - importer.close(); + importAskPassphrase(new FileInputStream(file)); } - catch (Exception e) { - // ignored + catch (FileNotFoundException e) { + Log.e(TAG, "error importing keys", e); + Toast.makeText(this, + R.string.err_import_keypair_read, + Toast.LENGTH_LONG).show(); } } + } - if (mImportedPublicKey != null && mImportedPrivateKey != null) { - // we can now store the passphrase - mPassphrase = passphrase; - - // begin usual validation - startValidationNormal(manualServer, true, true); - } + @Override + public void onFileChooserDismissed(@NonNull FileChooserDialog dialog) { } /** No search here. */ @@ -760,41 +1117,45 @@ public void startProgress() { private void startProgress(CharSequence message) { if (mProgress == null) { - mProgress = new NonSearchableProgressDialog(this); - mProgress.setIndeterminate(true); + MaterialDialog.Builder builder = new NonSearchableDialog + .Builder(this) + .cancelListener(new OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + Toast.makeText(NumberValidation.this, R.string.msg_validation_canceled, Toast.LENGTH_LONG).show(); + abort(); + } + }) + .dismissListener(new OnDismissListener() { + public void onDismiss(DialogInterface dialog) { + // remove sync starter + if (mSyncStart != null) { + mHandler.removeCallbacks(mSyncStart); + mSyncStart = null; + } + } + }); + + if (!BuildConfig.TESTING.get()) { + builder.progress(true, 0); + } + + mProgress = builder.build(); mProgress.setCanceledOnTouchOutside(false); setProgressMessage(message != null ? message : getText(R.string.msg_validating_phone)); - mProgress.setOnCancelListener(new OnCancelListener() { - @Override - public void onCancel(DialogInterface dialog) { - keepScreenOn(false); - Toast.makeText(NumberValidation.this, R.string.msg_validation_canceled, Toast.LENGTH_LONG).show(); - abort(); - } - }); - mProgress.setOnDismissListener(new OnDismissListener() { - public void onDismiss(DialogInterface dialog) { - // remove sync starter - if (mSyncStart != null) { - mHandler.removeCallbacks(mSyncStart); - mSyncStart = null; - } - } - }); } + enableControls(false); + keepScreenOn(true); mProgress.show(); } - public void abortProgress() { + private void abortProgress() { if (mProgress != null) { mProgress.dismiss(); mProgress = null; } - } - - public void abortProgress(boolean enableControls) { - abortProgress(); - enableControls(enableControls); + keepScreenOn(false); + enableControls(true); } public void abort() { @@ -803,13 +1164,15 @@ public void abort() { public void abort(boolean ending) { if (!ending) { - abortProgress(true); + abortProgress(); } - if (mValidator != null) { - mValidator.shutdown(); - mValidator = null; - } + unregister(); + mServiceBus.post(new AbortRequest()); + + mForce = false; + keepScreenOn(false); + enableControls(true); } private void setProgressMessage(CharSequence message) { @@ -823,11 +1186,20 @@ private void setProgressMessage(CharSequence message, boolean create) { if (mProgress != null) { mProgressMessage = message; - mProgress.setMessage(message); + mProgress.setContent(message); } } - private void delayedSync() { + /** Mainly for dismissing the cancel listener for the progress dialog. */ + @Override + public void finish() { + if (mProgress != null) { + mProgress.setOnCancelListener(null); + } + super.finish(); + } + + void delayedSync() { mSyncing = true; mSyncStart = new Runnable() { public void run() { @@ -842,12 +1214,11 @@ public void run() { // if we have been called internally, start ConversationList if (mFromInternal) - startActivity(new Intent(getApplicationContext(), ConversationList.class)); + startActivity(new Intent(getApplicationContext(), ConversationsActivity.class)); Toast.makeText(getApplicationContext(), R.string.msg_authenticated, Toast.LENGTH_LONG).show(); // end this - abortProgress(); finish(); } }; @@ -860,33 +1231,6 @@ public void run() { mHandler.postDelayed(mSyncStart, 2000); } - /** Used only if imported key was tested successfully. */ - @Override - public void onAuthTokenReceived(final NumberValidator v, final byte[] privateKey, final byte[] publicKey) { - runOnUiThread(new Runnable() { - @Override - public void run() { - abort(true); - finishLogin(v.getServer().toString(), privateKey, publicKey, false); - } - }); - } - - @Override - public void onAuthTokenFailed(NumberValidator v, int reason) { - Log.e(TAG, "authentication token request failed (" + reason + ")"); - runOnUiThread(new Runnable() { - @Override - public void run() { - keepScreenOn(false); - Toast.makeText(NumberValidation.this, - R.string.err_authentication_failed, - Toast.LENGTH_LONG).show(); - abort(); - } - }); - } - private void statusInitializing() { if (mProgress == null) startProgress(); @@ -894,228 +1238,190 @@ private void statusInitializing() { setProgressMessage(getString(R.string.msg_initializing)); } - protected void finishLogin(final String serverUri, final byte[] privateKeyData, final byte[] publicKeyData, boolean updateKey) { + protected void finishLogin(final String accountName) { Log.v(TAG, "finishing login"); statusInitializing(); - if (updateKey) { - // update public key - try { - mKey.update(publicKeyData); - } - catch (IOException e) { - // abort - throw new RuntimeException("error decoding public key", e); - } - } - - completeLogin(serverUri, privateKeyData, publicKeyData); + onAccountCreated(new AccountCreatedEvent(Kontalk.get().getDefaultAccount())); } - private void completeLogin(String serverUri, byte[] privateKeyData, byte[] publicKeyData) { - // generate the bridge certificate - byte[] bridgeCertData; - try { - // TODO subjectAltName? - bridgeCertData = X509Bridge.createCertificate(privateKeyData, publicKeyData, mPassphrase, null).getEncoded(); - } - catch (Exception e) { - // abort - throw new RuntimeException("unable to build X.509 bridge certificate", e); + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAcceptTermsRequested(AcceptTermsRequest request) { + RegistrationService.CurrentState cstate = RegistrationService.currentState(); + if (mAcceptedTermsServers.contains(cstate.server.getNetwork())) { + // terms already accepted for this server + register(new TermsAcceptedEvent()); + return; } - final Account account = new Account(mPhoneNumber, Authenticator.ACCOUNT_TYPE); + if (!mWaitingAcceptTerms) { + mWaitingAcceptTerms = true; - // workaround for bug in AccountManager (http://stackoverflow.com/a/11698139/1045199) - // procedure will continue in removeAccount callback - mAccountManager.removeAccount(account, - new AccountRemovalCallback(this, account, mPassphrase, - privateKeyData, publicKeyData, bridgeCertData, mName, serverUri), - mHandler); + DialogFragment dialog = AcceptTermsDialogFragment.newInstance(cstate.server.getNetwork(), request.termsUrl); + dialog.show(getSupportFragmentManager(), "accept_terms"); + } } - @Override - public void onError(NumberValidator v, final Throwable e) { - Log.e(TAG, "validation error.", e); - runOnUiThread(new Runnable() { - @Override - public void run() { - keepScreenOn(false); - int msgId; - if (e instanceof SocketException) - msgId = R.string.err_validation_network_error; - else - msgId = R.string.err_validation_error; - Toast.makeText(NumberValidation.this, msgId, Toast.LENGTH_LONG).show(); - abort(); - } - }); + public void onAcceptTermsConfirmed(String network) { + mAcceptedTermsServers.add(network); + startProgress(); + register(new TermsAcceptedEvent()); } - @Override - public void onServerCheckFailed(NumberValidator v) { - runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(NumberValidation.this, R.string.err_validation_server_not_supported, Toast.LENGTH_LONG).show(); - abort(); - } - }); + public void onAcceptTermsCancel() { + mWaitingAcceptTerms = false; + abort(); } - @Override - public void onValidationFailed(NumberValidator v, final int reason) { - Log.e(TAG, "phone number validation failed (" + reason + ")"); - runOnUiThread(new Runnable() { - @Override - public void run() { - if (reason == NumberValidator.ERROR_USER_EXISTS) { - userExistsWarning(); - } - else { - int msg; - if (reason == NumberValidator.ERROR_THROTTLING) - msg = R.string.err_validation_retry_later; - else - msg = R.string.err_validation_failed; + @Subscribe(threadMode = ThreadMode.MAIN) + public void onVerificationRequested(VerificationRequestedEvent event) { + RegistrationService.CurrentState state = RegistrationService.currentState(); + if (state.restored) { + mForce = state.force; - Toast.makeText(NumberValidation.this, msg, Toast.LENGTH_LONG).show(); - } - abort(); - } - }); - } + // update UI + mNameText.setText(state.displayName); + mPhone.setText(state.phoneNumber); + syncCountryCodeSelector(); + } - @Override - public void onValidationRequested(NumberValidator v, String sender) { Log.d(TAG, "validation has been requested, requesting validation code to user"); - proceedManual(sender); + proceedManual(event.sender, event.brandImageUrl, event.brandLink, event.canFallback); } - private void userExistsWarning() { - new AlertDialog.Builder(this) - .setTitle(R.string.title_user_exists) - .setIcon(android.R.drawable.ic_dialog_alert) - .setMessage(R.string.err_validation_user_exists) - .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Subscribe(threadMode = ThreadMode.MAIN) + public void onVerificationError(VerificationError error) { + if (error instanceof ServerCheckError) { + Toast.makeText(this, R.string.err_validation_server_not_supported, + Toast.LENGTH_LONG).show(); + } + else if (error instanceof UserConflictError) { + userExistsWarning(); + } + else if (error instanceof ThrottlingError) { + Toast.makeText(this, R.string.err_validation_retry_later, + Toast.LENGTH_LONG).show(); + } + else { + Log.e(TAG, "validation error.", error.exception); + keepScreenOn(false); + int msgId; + if (error.exception instanceof SocketException) { + msgId = R.string.err_validation_network_error; + } + else { + msgId = R.string.err_validation_error; + } + Toast.makeText(this, msgId, Toast.LENGTH_LONG).show(); + } + abort(); + } + + void userExistsWarning() { + MaterialDialog.Builder builder = new MaterialDialog.Builder(this) + .content(R.string.err_validation_user_exists) + .positiveText(R.string.btn_device_import) + .positiveColorRes(R.color.button_success) + .negativeText(android.R.string.cancel) + .neutralText(R.string.btn_device_overwrite) + .neutralColorRes(R.color.button_danger) + .onAny(new MaterialDialog.SingleButtonCallback() { @Override - public void onClick(DialogInterface dialog, int which) { - startValidation(true); + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + switch (which) { + case POSITIVE: + importDevice(); + break; + case NEUTRAL: + startValidation(true, false); + break; + } } - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); + }); + try { + builder.show(); + } + catch (Exception ignored) { + } } /** Proceeds to the next step in manual validation. */ - private void proceedManual(final String sender) { + private void proceedManual(final String sender, final String brandImage, final String brandLink, final boolean canFallback) { runOnUiThread(new Runnable() { @Override public void run() { - abortProgress(true); - startValidationCode(REQUEST_MANUAL_VALIDATION, sender); + abortProgress(); + startValidationCode(REQUEST_MANUAL_VALIDATION, sender, brandImage, brandLink, canFallback); } }); } - private void startValidationCode(int requestCode, String sender) { - startValidationCode(requestCode, sender, null, true); - } - - private void startValidationCode(int requestCode, String sender, EndpointServer server, boolean saveProgress) { - // validator might be null if we are skipping verification code request - String serverUri = null; - if (server != null) - serverUri = server.toString(); - else if (mValidator != null) - serverUri = mValidator.getServer().toString(); - - // save state to preferences - if (saveProgress) { - Preferences.saveRegistrationProgress(this, - mName, mPhoneNumber, mKey, mPassphrase, - mImportedPublicKey, mImportedPrivateKey, - serverUri, sender); - } + void startValidationCode(int requestCode, String sender, + String brandImage, String brandLink, boolean canFallback) { Intent i = new Intent(NumberValidation.this, CodeValidation.class); i.putExtra("requestCode", requestCode); - i.putExtra("name", mName); - i.putExtra("phone", mPhoneNumber); - i.putExtra("passphrase", mPassphrase); - i.putExtra("importedPublicKey", mImportedPublicKey); - i.putExtra("importedPrivateKey", mImportedPrivateKey); - i.putExtra("server", serverUri); i.putExtra("sender", sender); - i.putExtra(KeyPairGeneratorService.EXTRA_KEY, mKey); + i.putExtra("canFallback", canFallback); + i.putExtra("brandImage", brandImage); + i.putExtra("brandLink", brandLink); startActivityForResult(i, REQUEST_MANUAL_VALIDATION); } - private static class AccountRemovalCallback implements AccountManagerCallback { - private WeakReference a; - private final Account account; - private final String passphrase; - private final byte[] privateKeyData; - private final byte[] publicKeyData; - private final byte[] bridgeCertData; - private final String name; - private final String serverUri; - - public AccountRemovalCallback(NumberValidation activity, Account account, - String passphrase, byte[] privateKeyData, byte[] publicKeyData, - byte[] bridgeCertData, String name, String serverUri) { - this.a = new WeakReference(activity); - this.account = account; - this.passphrase = passphrase; - this.privateKeyData = privateKeyData; - this.publicKeyData = publicKeyData; - this.bridgeCertData = bridgeCertData; - this.name = name; - this.serverUri = serverUri; + public static final class AcceptTermsDialogFragment extends DialogFragment { + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + // build dialog text + String network = getArguments().getString("network"); + String termsUrl = getArguments().getString("termsURL"); + String baseText = getString(R.string.registration_accept_terms_text, + network, termsUrl); + + Spanned text = HtmlCompat.fromHtml(baseText, 0); + + return new MaterialDialog.Builder(getActivity()) + .title(R.string.registration_accept_terms_title) + .content(text) + .positiveText(R.string.yes) + .positiveColorRes(R.color.button_success) + .negativeText(R.string.no) + .negativeColorRes(R.color.button_danger) + .onAny(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + switch (which) { + case POSITIVE: + ((NumberValidation) getActivity()) + .onAcceptTermsConfirmed(getArguments().getString("network")); + break; + case NEGATIVE: + ((NumberValidation) getActivity()) + .onAcceptTermsCancel(); + break; + } + } + }) + .build(); } @Override - public void run(AccountManagerFuture result) { - NumberValidation ctx = a.get(); - if (ctx != null) { - AccountManager am = (AccountManager) ctx - .getSystemService(Context.ACCOUNT_SERVICE); - - // account userdata - Bundle data = new Bundle(); - data.putString(Authenticator.DATA_PRIVATEKEY, Base64.encodeToString(privateKeyData, Base64.NO_WRAP)); - data.putString(Authenticator.DATA_PUBLICKEY, Base64.encodeToString(publicKeyData, Base64.NO_WRAP)); - data.putString(Authenticator.DATA_BRIDGECERT, Base64.encodeToString(bridgeCertData, Base64.NO_WRAP)); - data.putString(Authenticator.DATA_NAME, name); - data.putString(Authenticator.DATA_SERVER_URI, serverUri); - - // this is the password to the private key - am.addAccountExplicitly(account, passphrase, data); - - // put data once more (workaround for Android bug http://stackoverflow.com/a/11698139/1045199) - am.setUserData(account, Authenticator.DATA_PRIVATEKEY, data.getString(Authenticator.DATA_PRIVATEKEY)); - am.setUserData(account, Authenticator.DATA_PUBLICKEY, data.getString(Authenticator.DATA_PUBLICKEY)); - am.setUserData(account, Authenticator.DATA_BRIDGECERT, data.getString(Authenticator.DATA_BRIDGECERT)); - am.setUserData(account, Authenticator.DATA_NAME, data.getString(Authenticator.DATA_NAME)); - am.setUserData(account, Authenticator.DATA_SERVER_URI, serverUri); - - // Set contacts sync for this account. - ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true); - ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1); - - // send back result - final Intent intent = new Intent(); - intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, account.name); - intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, Authenticator.ACCOUNT_TYPE); - - ctx.setAccountAuthenticatorResult(intent.getExtras()); - ctx.setResult(RESULT_OK, intent); - - // manual sync starter - ctx.delayedSync(); - } + public void onCancel(@NonNull DialogInterface dialog) { + super.onCancel(dialog); + ((NumberValidation) getActivity()) + .onAcceptTermsCancel(); } + public static AcceptTermsDialogFragment newInstance(String network, String termsUrl) { + AcceptTermsDialogFragment f = new AcceptTermsDialogFragment(); + Bundle args = new Bundle(); + args.putString("network", network); + args.putString("termsURL", termsUrl); + f.setArguments(args); + return f; + } } + } diff --git a/app/src/main/java/org/kontalk/ui/PasswordInputDialog.java b/app/src/main/java/org/kontalk/ui/PasswordInputDialog.java index 6f3c2a5c1..8371372e0 100644 --- a/app/src/main/java/org/kontalk/ui/PasswordInputDialog.java +++ b/app/src/main/java/org/kontalk/ui/PasswordInputDialog.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,13 +18,17 @@ package org.kontalk.ui; -import android.app.AlertDialog; +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; + +import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; -import android.support.annotation.NonNull; +import androidx.annotation.NonNull; import android.view.LayoutInflater; import android.view.View; -import android.widget.Button; +import android.view.Window; +import android.view.WindowManager; import android.widget.EditText; import android.widget.TextView; import android.widget.Toast; @@ -36,16 +40,17 @@ * A password input dialog supporting double input with verification. * @author Daniele Ricci */ +// TODO convert to material dialog public class PasswordInputDialog { private PasswordInputDialog() { } public interface OnPasswordInputListener { - public void onClick(DialogInterface dialog, int which, String password); + void onClick(DialogInterface dialog, int which, String password); } - public static class Builder extends AlertDialog.Builder { + public static class Builder extends MaterialDialog.Builder { private Context mContext; private EditText mPassword1; @@ -63,41 +68,46 @@ public Builder(Context context, int prompt1, int prompt2) { mContext = context; setCustomView(prompt1, prompt2); } - + public Builder setMinLength(int length) { mMinLength = length; return this; } - @NonNull - public Builder setTitle(CharSequence title) { - return (Builder) super.setTitle(title); + @Override + public Builder title(@NonNull CharSequence title) { + return (Builder) super.title(title); } - @NonNull - public Builder setTitle(int titleId) { - return (Builder) super.setTitle(titleId); + @Override + public Builder title(int titleRes) { + return (Builder) super.title(titleRes); } - public Builder setPositiveButton(int textId, OnPasswordInputListener listener) { - return setPositiveButton(mContext.getText(textId), listener); + @Override + public Builder content(int contentRes) { + return (Builder) super.content(contentRes); + } + + public Builder positiveText(int positiveRes, OnPasswordInputListener listener) { + return positiveText(mContext.getText(positiveRes), listener); } - public Builder setPositiveButton(CharSequence text, final OnPasswordInputListener listener) { + public Builder positiveText(CharSequence message, OnPasswordInputListener listener) { mListener = listener; - return (Builder) super.setPositiveButton(text, null); + return (Builder) super.positiveText(message); } @NonNull @Override - public AlertDialog create() { - final AlertDialog dialog = super.create(); - InputDialog.requestInputMethod(dialog); + public MaterialDialog build() { + final MaterialDialog dialog = super.build(); + requestInputMethod(dialog); if (mListener != null) { dialog.setOnShowListener(new DialogInterface.OnShowListener() { public void onShow(DialogInterface iDialog) { - Button button = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + View button = dialog.getActionButton(DialogAction.POSITIVE); button.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { String password1 = mPassword1.getText().toString(); @@ -137,15 +147,20 @@ private void setCustomView(int prompt1, int prompt2) { View view = LayoutInflater.from(mContext) .inflate(R.layout.password_dialog, null, false); - mPassword1 = (EditText) view.findViewById(R.id.password1); - mPassword2 = (EditText) view.findViewById(R.id.password2); + mPassword1 = view.findViewById(R.id.password1); + mPassword2 = view.findViewById(R.id.password2); if (prompt1 > 0) ((TextView) view.findViewById(R.id.prompt1)).setText(prompt1); if (prompt2 > 0) ((TextView) view.findViewById(R.id.prompt2)).setText(prompt2); - setView(view); + customView(view, false); + } + + static void requestInputMethod(Dialog dialog) { + Window window = dialog.getWindow(); + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); } } diff --git a/app/src/main/java/org/kontalk/ui/PositionActivity.java b/app/src/main/java/org/kontalk/ui/PositionActivity.java new file mode 100644 index 000000000..3bc6ae9ae --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/PositionActivity.java @@ -0,0 +1,69 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import android.os.Bundle; +import androidx.fragment.app.Fragment; + +import org.kontalk.R; +import org.kontalk.position.PositionManager; + +/** + * Location Activity + * + * @author andreacappelli + */ + +public class PositionActivity extends ToolbarActivity { + + public static final String EXTRA_USERID = "org.kontalk.location.USERID"; + public static final String EXTRA_USERPOSITION = "org.kontalk.location.USERPOSITION"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_location); + + setupToolbar(true, true); + + if (savedInstanceState == null) { + Bundle bundle = new Bundle(); + bundle.putString(EXTRA_USERID, getIntent().getStringExtra(EXTRA_USERID)); + bundle.putSerializable(EXTRA_USERPOSITION, getIntent().getSerializableExtra(EXTRA_USERPOSITION)); + + Fragment fragment = getIntent().getSerializableExtra(EXTRA_USERPOSITION) == null ? + PositionManager.getSendPositionFragment(this) : + PositionManager.getPositionFragment(this); + + if (fragment == null) + finish(); + + fragment.setArguments(bundle); + + getSupportFragmentManager() + .beginTransaction() + .add(R.id.container, fragment, fragment.getClass().getName()).commit(); + } + } + + @Override + protected boolean isNormalUpNavigation() { + return true; + } +} diff --git a/app/src/main/java/org/kontalk/ui/PreferencesActivity.java b/app/src/main/java/org/kontalk/ui/PreferencesActivity.java deleted file mode 100644 index 3c9241509..000000000 --- a/app/src/main/java/org/kontalk/ui/PreferencesActivity.java +++ /dev/null @@ -1,560 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.ui; - -import java.io.File; - -import android.accounts.AccountManagerCallback; -import android.accounts.AccountManagerFuture; -import android.annotation.TargetApi; -import android.app.ActionBar; -import android.app.Activity; -import android.app.AlertDialog; -import android.app.Dialog; -import android.app.ProgressDialog; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Environment; -import android.preference.CheckBoxPreference; -import android.preference.Preference; -import android.preference.Preference.OnPreferenceChangeListener; -import android.preference.Preference.OnPreferenceClickListener; -import android.preference.PreferenceActivity; -import android.preference.PreferenceManager; -import android.text.InputType; -import android.util.Log; -import android.view.MenuItem; -import android.widget.Toast; - -import org.kontalk.Kontalk; -import org.kontalk.R; -import org.kontalk.authenticator.Authenticator; -import org.kontalk.client.EndpointServer; -import org.kontalk.client.ServerList; -import org.kontalk.crypto.PersonalKey; -import org.kontalk.service.ServerListUpdater; -import org.kontalk.service.msgcenter.MessageCenterService; -import org.kontalk.service.msgcenter.PushServiceManager; -import org.kontalk.util.MessageUtils; -import org.kontalk.util.Preferences; - -import static org.kontalk.crypto.PersonalKeyImporter.KEYPACK_FILENAME; - - -/** - * Preferences activity. - * TODO convert to fragments layout - * @author Daniele Ricci - */ -public final class PreferencesActivity extends PreferenceActivity { - private static final String TAG = Kontalk.TAG; - - private static final int REQUEST_PICK_BACKGROUND = Activity.RESULT_FIRST_USER + 1; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - // no account - redirect to bootstrap preferences - if (Authenticator.getDefaultAccount(this) == null) { - startActivity(new Intent(this, BootstrapPreferences.class)); - finish(); - return; - } - - // upgrade from old version: pref_text_enter becomes string - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - try { - prefs.getString("pref_text_enter", null); - } - catch (ClassCastException e) { - // legacy mode - prefs.edit() - .putString("pref_text_enter", - prefs.getBoolean("pref_text_enter", false) ? - "newline" : "default") - .commit(); - } - - addPreferencesFromResource(R.xml.preferences); - - setupActivity(); - - // push notifications checkbox - final Preference pushNotifications = findPreference("pref_push_notifications"); - pushNotifications.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - CheckBoxPreference pref = (CheckBoxPreference) preference; - if (pref.isChecked()) - MessageCenterService.enablePushNotifications(getApplicationContext()); - else - MessageCenterService.disablePushNotifications(getApplicationContext()); - - return true; - } - }); - - // message center restart - final Preference restartMsgCenter = findPreference("pref_restart_msgcenter"); - restartMsgCenter.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - Log.w(TAG, "manual message center restart requested"); - MessageCenterService.restart(getApplicationContext()); - Toast.makeText(PreferencesActivity.this, R.string.msg_msgcenter_restarted, Toast.LENGTH_SHORT).show(); - return true; - } - }); - - // change passphrase - final Preference changePassphrase = findPreference("pref_change_passphrase"); - changePassphrase.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - - if (Authenticator.isUserPassphrase(PreferencesActivity.this)) { - - OnPassphraseRequestListener action = new OnPassphraseRequestListener() { - public void onValidPassphrase(String passphrase) { - askNewPassphrase(); - } - - public void onInvalidPassphrase() { - new AlertDialog.Builder(PreferencesActivity.this) - .setTitle(R.string.title_passphrase) - .setMessage(R.string.err_password_invalid) - .show(); - } - }; - askCurrentPassphrase(action); - } - - else { - askNewPassphrase(); - } - - - return true; - } - }); - - // regenerate key pair - final Preference regenKeyPair = findPreference("pref_regenerate_keypair"); - regenKeyPair.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - new AlertDialog.Builder(PreferencesActivity.this) - .setTitle(R.string.pref_regenerate_keypair) - .setMessage(R.string.pref_regenerate_keypair_confirm) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - Toast.makeText(PreferencesActivity.this, R.string.msg_generating_keypair, - Toast.LENGTH_LONG).show(); - - MessageCenterService.regenerateKeyPair(getApplicationContext()); - } - }) - .show(); - - return true; - } - }); - - // export key pair - final Preference exportKeyPair = findPreference("pref_export_keypair"); - exportKeyPair.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - - // TODO check for external storage presence - - final OnPassphraseChangedListener action = new OnPassphraseChangedListener() { - public void onPassphraseChanged(String passphrase) { - try { - Kontalk.get(PreferencesActivity.this).exportPersonalKey(passphrase); - - Toast.makeText(PreferencesActivity.this, - R.string.msg_keypair_exported, - Toast.LENGTH_LONG).show(); - - } - catch (Exception e) { - - Log.e(TAG, "error exporting keys", e); - Toast.makeText(PreferencesActivity.this, - // TODO i18n - "Unable to export personal key.", - Toast.LENGTH_LONG).show(); - - } - } - }; - - // passphrase was never set by the user - // encrypt it with a user-defined passphrase first - if (!Authenticator.isUserPassphrase(PreferencesActivity.this)) { - askNewPassphrase(action); - } - - else { - OnPassphraseRequestListener action2 = new OnPassphraseRequestListener() { - public void onValidPassphrase(String passphrase) { - action.onPassphraseChanged(passphrase); - } - - public void onInvalidPassphrase() { - new AlertDialog.Builder(PreferencesActivity.this) - .setTitle(R.string.pref_export_keypair) - .setMessage(R.string.err_password_invalid) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - }; - - askCurrentPassphrase(action2); - } - - return true; - } - }); - - // import key pair - final Preference importKeyPair = findPreference("pref_import_keypair"); - importKeyPair.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - new AlertDialog.Builder(PreferencesActivity.this) - .setTitle(R.string.pref_import_keypair) - .setMessage(getString(R.string.msg_import_keypair, KEYPACK_FILENAME)) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - Uri keypack = Uri.fromFile(new File(Environment - .getExternalStorageDirectory(), KEYPACK_FILENAME)); - MessageCenterService.importKeyPair(getApplicationContext(), - keypack, ((Kontalk) getApplication()).getCachedPassphrase()); - } - }) - .show(); - - return true; - } - }); - - // delete account - final Preference deleteAccount = findPreference("pref_delete_account"); - deleteAccount.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - new AlertDialog.Builder(PreferencesActivity.this) - .setTitle(R.string.pref_delete_account) - .setMessage(R.string.msg_delete_account) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - // progress dialog - final LockedProgressDialog progress = new LockedProgressDialog(PreferencesActivity.this); - progress.setMessage(getString(R.string.msg_delete_account_progress)); - progress.setIndeterminate(true); - progress.show(); - - // stop the message center first - MessageCenterService.stop(getApplicationContext()); - - AccountManagerCallback callback = new AccountManagerCallback() { - public void run(AccountManagerFuture future) { - // dismiss progress - progress.dismiss(); - // exit now - finish(); - } - }; - Authenticator.removeDefaultAccount(PreferencesActivity.this, callback); - } - }) - .show(); - - return true; - } - }); - - // use custom background - final Preference customBg = findPreference("pref_custom_background"); - customBg.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - // discard reference to custom background drawable - Preferences.setCachedCustomBackground(null); - return false; - } - }); - - // set background - final Preference setBackground = findPreference("pref_background_uri"); - setBackground.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - final Intent i = new Intent(Intent.ACTION_GET_CONTENT); - i.addCategory(Intent.CATEGORY_OPENABLE); - i.setType("image/*"); - startActivityForResult(i, REQUEST_PICK_BACKGROUND); - return true; - } - }); - - // set balloon theme - final Preference balloons = findPreference("pref_balloons"); - balloons.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - Preferences.setCachedBalloonTheme((String) newValue); - return true; - } - }); - - // manual server address is handled in Application context - // we just handle validation here - setupPreferences(this); - - // server list last update timestamp - final Preference updateServerList = findPreference("pref_update_server_list"); - updateServerList.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - final ServerListUpdater updater = new ServerListUpdater(PreferencesActivity.this); - - final ProgressDialog diag = new ProgressDialog(PreferencesActivity.this); - diag.setCancelable(true); - diag.setMessage(getString(R.string.serverlist_updating)); - diag.setIndeterminate(true); - diag.setOnCancelListener(new DialogInterface.OnCancelListener() { - @Override - public void onCancel(DialogInterface dialog) { - updater.cancel(); - } - }); - - updater.setListener(new ServerListUpdater.UpdaterListener() { - @Override - public void error(Throwable e) { - diag.cancel(); - message(R.string.serverlist_update_error); - } - - @Override - public void networkNotAvailable() { - diag.cancel(); - message(R.string.serverlist_update_nonetwork); - } - - @Override - public void offlineModeEnabled() { - diag.cancel(); - message(R.string.serverlist_update_offline); - } - - @Override - public void noData() { - diag.cancel(); - message(R.string.serverlist_update_nodata); - } - - @Override - public void updated(final ServerList list) { - diag.dismiss(); - runOnUiThread(new Runnable() { - @Override - public void run() { - Preferences.updateServerListLastUpdate(updateServerList, list); - // restart message center - MessageCenterService.restart(getApplicationContext()); - } - }); - } - - private void message(final int textId) { - runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(PreferencesActivity.this, textId, - Toast.LENGTH_LONG).show(); - } - }); - } - }); - - diag.show(); - updater.start(); - return true; - } - }); - - // update 'last update' string - ServerList list = ServerListUpdater.getCurrentList(this); - if (list != null) - Preferences.updateServerListLastUpdate(updateServerList, list); - } - - private interface OnPassphraseChangedListener { - void onPassphraseChanged(String passphrase); - } - - private interface OnPassphraseRequestListener { - void onValidPassphrase(String passphrase); - - void onInvalidPassphrase(); - } - - private void askCurrentPassphrase(final OnPassphraseRequestListener action) { - new InputDialog.Builder(PreferencesActivity.this, - InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) - .setTitle(R.string.title_passphrase) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - - String passphrase = InputDialog.getInputText((Dialog) dialog).toString(); - // user-entered passphrase is hashed, so compare with SHA-1 version - String hashed = MessageUtils.sha1(passphrase); - if (hashed.equals(((Kontalk) getApplicationContext()) - .getCachedPassphrase())) { - action.onValidPassphrase(passphrase); - } - else { - action.onInvalidPassphrase(); - } - - } - }) - .show(); - } - - private void askNewPassphrase() { - askNewPassphrase(null); - } - - private void askNewPassphrase(final OnPassphraseChangedListener action) { - new PasswordInputDialog.Builder(this) - .setMinLength(PersonalKey.MIN_PASSPHRASE_LENGTH) - .setTitle(R.string.pref_change_passphrase) - .setPositiveButton(android.R.string.ok, new PasswordInputDialog.OnPasswordInputListener() { - public void onClick(DialogInterface dialog, int which, String password) { - String oldPassword = ((Kontalk) getApplicationContext()).getCachedPassphrase(); - try { - // user-entered passphrase must be hashed - String hashed = MessageUtils.sha1(password); - Authenticator.changePassphrase(PreferencesActivity.this, oldPassword, hashed, true); - ((Kontalk) getApplicationContext()).invalidatePersonalKey(); - - if (action != null) - action.onPassphraseChanged(password); - } - catch (Exception e) { - Toast.makeText(PreferencesActivity.this, - R.string.err_change_passphrase, Toast.LENGTH_LONG) - .show(); - } - } - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - @TargetApi(Build.VERSION_CODES.HONEYCOMB) - private void setupActivity() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { - ActionBar bar = getActionBar(); - bar.setDisplayShowHomeEnabled(true); - bar.setDisplayHomeAsUpEnabled(true); - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - startActivity(new Intent(this, ConversationList.class)); - return true; - } - - return super.onOptionsItemSelected(item); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == REQUEST_PICK_BACKGROUND) { - if (resultCode == RESULT_OK) { - // invalidate any previous reference - Preferences.setCachedCustomBackground(null); - // resize and cache image - // TODO do this in background (might take some time) - File image = Preferences.cacheConversationBackground(this, data.getData()); - // save to preferences - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - prefs.edit() - .putString("pref_background_uri", Uri.fromFile(image).toString()) - .commit(); - } - } - else - super.onActivityResult(requestCode, resultCode, data); - } - - public static void start(Activity context) { - Intent intent = new Intent(context, PreferencesActivity.class); - context.startActivityIfNeeded(intent, -1); - } - - static void setupPreferences(final PreferenceActivity activity) { - // disable push notifications if GCM is not available on the device - if (!PushServiceManager.getInstance(activity).isServiceAvailable()) { - final CheckBoxPreference push = (CheckBoxPreference) activity - .findPreference("pref_push_notifications"); - push.setEnabled(false); - push.setChecked(false); - push.setSummary(R.string.pref_title_disabled_push_notifications); - } - - final Preference manualServer = activity.findPreference("pref_network_uri"); - manualServer.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { - public boolean onPreferenceChange(Preference preference, Object newValue) { - String value = newValue.toString().trim(); - if (value.length() > 0 && !EndpointServer.validate(value)) { - new AlertDialog.Builder(activity) - .setIcon(android.R.drawable.ic_dialog_alert) - .setTitle(R.string.pref_network_uri) - .setMessage(R.string.err_server_invalid_format) - .setPositiveButton(android.R.string.ok, null) - .show(); - return false; - } - return true; - } - }); - - } - -} diff --git a/app/src/main/java/org/kontalk/ui/ProgressNotificationBuilder.java b/app/src/main/java/org/kontalk/ui/ProgressNotificationBuilder.java index 5af462ed2..ff13a8c1f 100644 --- a/app/src/main/java/org/kontalk/ui/ProgressNotificationBuilder.java +++ b/app/src/main/java/org/kontalk/ui/ProgressNotificationBuilder.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,7 +23,7 @@ import android.app.Notification; import android.app.PendingIntent; import android.content.Context; -import android.support.v4.app.NotificationCompat; +import androidx.core.app.NotificationCompat; import android.widget.RemoteViews; @@ -38,11 +38,12 @@ public class ProgressNotificationBuilder extends NotificationCompat.Builder { private int mLayout; public ProgressNotificationBuilder(Context context, + String channelId, int layout, CharSequence tickerText, int smallIcon, PendingIntent intent) { - super(context); + super(context, channelId); mContext = context; mLayout = layout; @@ -52,6 +53,10 @@ public ProgressNotificationBuilder(Context context, setSmallIcon(smallIcon); setContentIntent(intent); setOngoing(true); + setCategory(NotificationCompat.CATEGORY_PROGRESS); + // apparently PRIORITY_MIN/IMPORTANCE_MIN is not compatible with setOnGoing + // system will raise this to PRIORITY_LOW/IMPORTANCE_MIN unless changed by user + setPriority(NotificationCompat.PRIORITY_MIN); } /** @@ -59,33 +64,17 @@ public ProgressNotificationBuilder(Context context, * @param progress if less than 0, progress bar will be indeterminate */ public ProgressNotificationBuilder progress(int progress, int contentTitle, int contentText) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH) { - this.setSmallIcon(R.drawable.ic_stat_notify) - .setProgress(100, progress, false) - .setContentTitle(mContext.getString(contentTitle)) - .setContentText(mContext.getString(contentText)); - - if (progress < 0) - setProgress(0, 0, true); - else - setProgress(100, progress, false); - - // not using custom content view - mContentView = null; - } - else { - mContentView = new RemoteViews(mContext.getPackageName(), mLayout); - // this should not be needed -- contentView.setOnClickPendingIntent(R.id.progress_notification, null); - mContentView.setTextViewText(R.id.title, mContext.getString(contentText)); - mContentView.setTextViewText(R.id.progress_text, (progress < 0) ? "" : String.format("%d%%", progress)); - - if (progress < 0) - mContentView.setProgressBar(R.id.progress_bar, 0, 0, true); - else - mContentView.setProgressBar(R.id.progress_bar, 100, progress, false); - - setContent(mContentView); - } + this.setSmallIcon(R.drawable.ic_stat_notify) + .setContentTitle(mContext.getString(contentTitle)) + .setContentText(mContext.getString(contentText)); + + if (progress < 0) + setProgress(0, 0, true); + else + setProgress(100, progress, false); + + // not using custom content view + mContentView = null; return this; } @@ -95,7 +84,7 @@ public Notification build() { Notification no = super.build(); if (mContentView != null) { - /** + /* * HACK working around bug #30495 * @see https://code.google.com/p/android/issues/detail?id=30495 */ diff --git a/app/src/main/java/org/kontalk/ui/QuickReplyActivity.java b/app/src/main/java/org/kontalk/ui/QuickReplyActivity.java index 66c129161..c3c6a73ac 100644 --- a/app/src/main/java/org/kontalk/ui/QuickReplyActivity.java +++ b/app/src/main/java/org/kontalk/ui/QuickReplyActivity.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,78 +18,83 @@ package org.kontalk.ui; -import java.io.IOException; -import java.util.Random; +import java.util.concurrent.ExecutionException; -import org.kontalk.R; -import org.kontalk.data.Contact; -import org.kontalk.provider.MyMessages.Messages; -import org.kontalk.util.Preferences; - -import android.app.Activity; -import android.app.PendingIntent; -import android.app.PendingIntent.CanceledException; -import android.content.ContentValues; import android.content.Context; import android.content.Intent; +import android.database.sqlite.SQLiteDiskIOException; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; -import android.util.Log; import android.view.View; import android.view.inputmethod.InputMethodManager; -import android.widget.Button; import android.widget.EditText; +import android.widget.ImageButton; import android.widget.TextView; import android.widget.Toast; +import org.kontalk.Kontalk; +import org.kontalk.R; +import org.kontalk.data.Conversation; +import org.kontalk.provider.MessagesProviderClient; +import org.kontalk.reporting.ReportingManager; + + +public class QuickReplyActivity extends ToolbarActivity { -public class QuickReplyActivity extends Activity { - private static final String TAG = QuickReplyActivity.class.getSimpleName(); + private static final String EXTRA_THREAD_ID = "org.kontalk.quickreply.THREAD_ID"; + private static final String EXTRA_MESSAGE = "org.kontalk.quickreply.MESSAGE"; - private TextView mFrom; private TextView mContent; private EditText mContentEdit; - private Button mReply; - private PendingIntent mOpenConv; + private ImageButton mReply; - private String userId; - private String userString; - private Contact mContact; + Conversation mConversation; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.quick_reply); - mFrom = (TextView) findViewById(R.id.from); - mContent = (TextView) findViewById(R.id.content); - mContentEdit = (EditText) findViewById(R.id.content_editor); - mReply = (Button) findViewById(R.id.reply); + setupToolbar(false, false); + + mContent = findViewById(R.id.content); + mContentEdit = findViewById(R.id.content_editor); + mReply = findViewById(R.id.reply); processIntent(); } + @Override + protected boolean isNormalUpNavigation() { + return false; + } + @Override protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); setIntent(intent); processIntent(); } private void processIntent() { Intent intent = getIntent(); - Log.v(TAG, "processing intent: " + intent); - userId = intent.getStringExtra("org.kontalk.quickreply.FROM"); - mContact = Contact.findByUserId(this, userId); + long threadId = intent.getLongExtra(EXTRA_THREAD_ID, 0); + mConversation = Conversation.loadFromId(this, threadId); + if (mConversation == null) { + // deleted or else + finish(); + } + else { + CharSequence content = intent.getCharSequenceExtra(EXTRA_MESSAGE); - mOpenConv = intent.getParcelableExtra("org.kontalk.quickreply.OPEN_INTENT"); - userString = (mContact != null) ? mContact.getName() + "<" + mContact.getNumber() + ">" : getString(R.string.peer_unknown); - // TODO i18n - mFrom.setText("From: " + userString); + String title = mConversation.isGroupChat() ? + mConversation.getGroupSubject() : mConversation.getContact().getDisplayName(); + setTitle(title); - String content = intent.getStringExtra("org.kontalk.quickreply.MESSAGE"); - mContent.setText(content); + mContent.setText(content); + } } public void reply(View view) { @@ -98,38 +103,33 @@ public void reply(View view) { sendTextMessage(mContentEdit.getText().toString()); } else { - // TODO i18n - mFrom.setText("To: " + userString); + mReply.setImageResource(R.drawable.ic_menu_send); mContent.setVisibility(View.GONE); mContentEdit.setVisibility(View.VISIBLE); mContentEdit.requestFocus(); } } - public void close(View view) { + public void markRead(View view) { + MessagesProviderClient.markThreadAsRead(this, mConversation.getRecipient()); + MessagingNotification.delayedUpdateMessagesNotification(this, false); finish(); } - public void delete(View view) { - // TODO - } - public void openConversation(View view) { - try { - mOpenConv.send(); - } - catch (CanceledException e) { - Log.e(TAG, "intent canceled!", e); - } + // update draft + MessagesProviderClient.updateDraft(this, mConversation.getThreadId(), + mContentEdit.getText().toString()); + // open conversation (draft will appear) + startActivity(ComposeMessage.fromConversation(this, mConversation.getThreadId())); finish(); } - private void enableEditing(boolean enabled) { - mContentEdit.setEnabled(enabled); - mReply.setEnabled(enabled); + private void disableEditing() { + mContentEdit.setEnabled(false); + mReply.setEnabled(false); } - private final class TextMessageThread extends Thread { private final String mText; @@ -139,48 +139,39 @@ private final class TextMessageThread extends Thread { @Override public void run() { - final Context ctx = QuickReplyActivity.this; + final Context context = QuickReplyActivity.this; try { - // get encryption key if needed - String key = null; - if (Preferences.getEncryptionEnabled(ctx)) { - // use recipient phone number - key = Contact.numberByUserId(ctx, userId); + Uri newMsg; + try { + newMsg = Kontalk.get().getMessagesController() + .sendTextMessage(mConversation, mText, 0).get(); + } + catch (ExecutionException e) { + // unwrap exception + throw e.getCause(); } - byte[] bytes = mText.getBytes(); - - // save to local storage - ContentValues values = new ContentValues(); - // must supply a message ID... - values.put(Messages.MESSAGE_ID, "draft" + (new Random().nextInt())); - values.put(Messages.PEER, userId); - //values.put(Messages.MIME, PlainTextMessage.MIME_TYPE); - //values.put(Messages.CONTENT, bytes); - values.put(Messages.UNREAD, false); - values.put(Messages.DIRECTION, Messages.DIRECTION_OUT); - values.put(Messages.TIMESTAMP, System.currentTimeMillis()); - values.put(Messages.STATUS, Messages.STATUS_SENDING); - //values.put(Messages.ENCRYPT_KEY, key); - //values.put(Messages.LENGTH, bytes.length); - Uri newMsg = ctx.getContentResolver().insert( - Messages.CONTENT_URI, values); if (newMsg != null) { - // TODO send the message! - } - else { - throw new IOException(); + // mark as read and finish + markRead(null); } } - catch (Exception e) { - // whatever - Log.d(TAG, "broken message thread", e); + catch (SQLiteDiskIOException e) { runOnUiThread(new Runnable() { @Override public void run() { - Toast.makeText(ctx, - R.string.error_store_outbox, Toast.LENGTH_LONG).show(); - enableEditing(true); + Toast.makeText(context, R.string.error_store_outbox, + Toast.LENGTH_LONG).show(); + } + }); + } + catch (Throwable e) { + ReportingManager.logException(e); + runOnUiThread(new Runnable() { + public void run() { + Toast.makeText(context, + R.string.err_store_message_failed, + Toast.LENGTH_LONG).show(); } }); } @@ -190,7 +181,7 @@ public void run() { /** Sends out the text message in the composing entry. */ public void sendTextMessage(String text) { if (!TextUtils.isEmpty(text)) { - enableEditing(false); + disableEditing(); // start thread new TextMessageThread(text).start(); @@ -203,4 +194,12 @@ public void sendTextMessage(String text) { } } + public static Intent getStartIntent(Context context, long threadId, CharSequence content) { + Intent i = new Intent(context, QuickReplyActivity.class); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + i.putExtra(EXTRA_THREAD_ID, threadId); + i.putExtra(EXTRA_MESSAGE, content); + return i; + } + } diff --git a/app/src/main/java/org/kontalk/ui/RegisterDeviceActivity.java b/app/src/main/java/org/kontalk/ui/RegisterDeviceActivity.java new file mode 100644 index 000000000..c32c892fd --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/RegisterDeviceActivity.java @@ -0,0 +1,139 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.Bundle; +import androidx.annotation.NonNull; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import org.kontalk.Kontalk; +import org.kontalk.R; +import org.kontalk.util.ViewUtils; + + +/** + * Shows the secure token for registering another device with the same key. + * @author Daniele Ricci + */ +public class RegisterDeviceActivity extends ToolbarActivity implements ViewUtils.OnQRCodeGeneratedListener { + private static final String TAG = Kontalk.TAG; + + private View mViewport; + private TextView mAccountName; + private TextView mTextServer; + private TextView mTextToken; + private ImageView mQRCode; + private View mLoading; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.register_device_screen); + + setupToolbar(true, true); + + mViewport = findViewById(R.id.scroller); + mAccountName = findViewById(R.id.account); + mTextServer = findViewById(R.id.servername); + mTextToken = findViewById(R.id.token); + mQRCode = findViewById(R.id.qrcode); + mLoading = findViewById(R.id.loading); + } + + @SuppressLint("SetTextI18n") + @Override + protected void onStart() { + super.onStart(); + + String account = Kontalk.get().getDefaultAccount().getName(); + mAccountName.setText(account); + + String from = getIntent().getStringExtra("from"); + mTextServer.setText(from); + + String token = getIntent().getStringExtra("token"); + + if ((token.length() % 2) == 0) { + mTextToken.setText(token.substring(0, token.length() / 2) + " " + + token.substring(token.length() / 2)); + } + else { + mTextToken.setText(token); + } + + ViewUtils.getQRCodeBitmapAsync(this, mViewport, + generateTokenText(account, token, from), this); + } + + @Override + protected boolean isNormalUpNavigation() { + return true; + } + + @Override + public void onQRCodeGenerated(Bitmap qrCode) { + mLoading.setVisibility(View.GONE); + mQRCode.setImageBitmap(qrCode); + } + + @Override + public void onQRCodeError(Exception e) { + mLoading.setVisibility(View.GONE); + // TODO error + } + + /** This must match the parsing done in {@link #parseTokenText}. */ + private static String generateTokenText(String account, String token, String from) { + return account + ";" + from + ";" + token; + } + + /** This must match the generator in {@link #generateTokenText}. */ + public static PrivateKeyToken parseTokenText(String tokenText) { + String[] parsed = tokenText.split(";", 3); + return (parsed.length == 3) ? + new PrivateKeyToken(parsed[0], parsed[1], parsed[2]) : null; + } + + /** A token parsed by {@link #parseTokenText}. */ + public static final class PrivateKeyToken { + public final String account; + public final String server; + public final String token; + + PrivateKeyToken(String account, String server, String token) { + this.account = account; + this.server = server; + this.token = token; + } + } + + public static void start(@NonNull Context context, @NonNull String token, @NonNull String server) { + Intent i = new Intent(context, RegisterDeviceActivity.class); + i.putExtra("token", token); + i.putExtra("from", server); + context.startActivity(i); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/ScanTextActivity.java b/app/src/main/java/org/kontalk/ui/ScanTextActivity.java new file mode 100644 index 000000000..4695a0562 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ScanTextActivity.java @@ -0,0 +1,139 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import java.util.Collections; +import java.util.List; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.Result; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.NonNull; +import android.view.MenuItem; +import android.view.ViewGroup; + +import me.dm7.barcodescanner.zxing.ZXingScannerView; +import pub.devrel.easypermissions.EasyPermissions; + +import org.kontalk.R; +import org.kontalk.util.Permissions; + + +/** + * Simple activity to scan a barcode and return the result to the caller. + * @author Daniele Ricci + */ +public class ScanTextActivity extends ToolbarActivity + implements ZXingScannerView.ResultHandler, EasyPermissions.PermissionCallbacks { + + private ZXingScannerView mScannerView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.scan_text_screen); + + mScannerView = new ZXingScannerView(this); + List formats = Collections.singletonList(BarcodeFormat.QR_CODE); + mScannerView.setFormats(formats); + mScannerView.setAspectTolerance(0.5f); + + ViewGroup contentFrame = findViewById(R.id.content); + contentFrame.addView(mScannerView); + + setupToolbar(true, false); + setTitle(getIntent().getStringExtra("title")); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + protected void onResume() { + super.onResume(); + mScannerView.setResultHandler(this); + + if (Permissions.canUseCamera(this)) { + startCamera(); + } + else { + Permissions.requestCamera(this, getString(R.string.err_camera_scanner_denied)); + } + } + + @Override + protected void onPause() { + super.onPause(); + stopCamera(); + } + + @Override + protected boolean isNormalUpNavigation() { + return true; + } + + private void startCamera() { + mScannerView.startCamera(); + } + + private void stopCamera() { + mScannerView.stopCamera(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this); + } + + @Override + public void handleResult(Result result) { + Intent data = new Intent(); + data.putExtra("text", result.getText()); + setResult(RESULT_OK, data); + finish(); + } + + public static void start(Activity activity, String title, int requestCode) { + Intent i = new Intent(activity, ScanTextActivity.class); + i.putExtra("title", title); + activity.startActivityForResult(i, requestCode); + } + + @Override + public void onPermissionsGranted(int requestCode, @NonNull List perms) { + startCamera(); + } + + @Override + public void onPermissionsDenied(int requestCode, @NonNull List perms) { + finish(); + } +} diff --git a/app/src/main/java/org/kontalk/ui/SearchActivity.java b/app/src/main/java/org/kontalk/ui/SearchActivity.java index 53ec6415e..031a33d33 100644 --- a/app/src/main/java/org/kontalk/ui/SearchActivity.java +++ b/app/src/main/java/org/kontalk/ui/SearchActivity.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,20 +18,18 @@ package org.kontalk.ui; -import org.kontalk.R; - import android.app.SearchManager; import android.content.Intent; import android.os.Bundle; -import android.support.v7.app.ActionBarActivity; -import android.view.MenuItem; + +import org.kontalk.R; /** * Activity wrapper for {@link SearchFragment}. * @author Daniele Ricci */ -public class SearchActivity extends ActionBarActivity { +public class SearchActivity extends ToolbarActivity { public static final String TAG = SearchActivity.class.getSimpleName(); private SearchFragment mFragment; @@ -41,7 +39,7 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.search_list_screen); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); + setupToolbar(true, true); mFragment = (SearchFragment) getSupportFragmentManager() .findFragmentById(R.id.fragment_search_list); @@ -56,16 +54,8 @@ protected void onCreate(Bundle savedInstanceState) { } @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - startActivity(new Intent(this, ConversationList.class)); - return true; - - default: - return super.onOptionsItemSelected(item); - } + protected boolean isNormalUpNavigation() { + return true; } } diff --git a/app/src/main/java/org/kontalk/ui/SearchFragment.java b/app/src/main/java/org/kontalk/ui/SearchFragment.java index f3ef6765e..a7b608001 100644 --- a/app/src/main/java/org/kontalk/ui/SearchFragment.java +++ b/app/src/main/java/org/kontalk/ui/SearchFragment.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -27,7 +27,7 @@ import android.content.Intent; import android.database.Cursor; import android.os.Bundle; -import android.support.v4.app.ListFragment; +import androidx.fragment.app.ListFragment; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -71,7 +71,8 @@ public void setQuery(String query) { Activity parent = getActivity(); if (parent != null) { mCursor = SearchItem.query(parent, mQuery); - getActivity().startManagingCursor(mCursor); + if (mCursor != null) + getActivity().startManagingCursor(mCursor); mListAdapter = new SearchListAdapter(parent, mCursor); // TODO mListAdapter.setOnContentChangedListener(mContentChangedListener); diff --git a/app/src/main/java/org/kontalk/ui/SendIntentReceiver.java b/app/src/main/java/org/kontalk/ui/SendIntentReceiver.java new file mode 100644 index 000000000..97209d114 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/SendIntentReceiver.java @@ -0,0 +1,107 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import java.util.ArrayList; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.widget.Toast; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.message.ImageComponent; +import org.kontalk.message.TextComponent; +import org.kontalk.message.VCardComponent; +import org.kontalk.util.MediaStorage; + + +/** + * Static utilities used by activites that can receive ACTION_SEND intents, + * namely {@link ConversationsActivity} (indirectly) and {@link ComposeMessage}. + * Not the best solution, but it was too complicated to be used as parent class. + */ +class SendIntentReceiver { + private static final String TAG = ComposeMessage.class.getSimpleName(); + + private SendIntentReceiver() { + } + + static void processSendIntent(Context context, Intent sendIntent, AbstractComposeFragment fragment) { + String mime = sendIntent.getType(); + boolean multi = Intent.ACTION_SEND_MULTIPLE.equals(sendIntent.getAction()); + + if (multi) { + // multiple texts: take only the first one + // FIXME this will not allow text file attachments + if (TextComponent.supportsMimeType(mime)) { + ArrayList texts = sendIntent.getCharSequenceArrayListExtra(Intent.EXTRA_TEXT); + if (texts != null && texts.size() > 0) + fragment.setTextEntry(texts.get(0)); + } + + else { + ArrayList uris = sendIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (uris != null) { + for (Uri uri : uris) { + sendMedia(context, uri, fragment); + } + } + } + } + + else { + // FIXME this will not allow text file attachments + CharSequence text = sendIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); + if (text != null || TextComponent.supportsMimeType(mime)) { + fragment.setTextEntry(text); + } + + else { + Uri uri = sendIntent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null) + sendMedia(context, uri, fragment); + } + } + } + + private static void sendMedia(Context context, Uri uri, AbstractComposeFragment fragment) { + Log.d(TAG, "looking up mime type for uri " + uri); + String mime = MediaStorage.getType(context, uri); + Log.d(TAG, "using detected mime type " + mime); + + if (ImageComponent.supportsMimeType(mime)) { + // send image immediately + fragment.sendBinaryMessage(uri, mime, true, ImageComponent.class); + } + + else if (VCardComponent.supportsMimeType(mime)) { + fragment.sendBinaryMessage(uri, mime, true, VCardComponent.class); + } + + else { + // notify to user + Log.w(TAG, "mime " + mime + " not supported"); + Toast.makeText(context, R.string.send_mime_not_supported, Toast.LENGTH_LONG) + .show(); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/StatusActivity.java b/app/src/main/java/org/kontalk/ui/StatusActivity.java index c1950e622..8d306425b 100644 --- a/app/src/main/java/org/kontalk/ui/StatusActivity.java +++ b/app/src/main/java/org/kontalk/ui/StatusActivity.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,13 +18,12 @@ package org.kontalk.ui; -import org.kontalk.R; - import android.app.Activity; import android.content.Intent; import android.os.Bundle; -import android.support.v7.app.ActionBarActivity; -import android.view.MenuItem; + +import org.kontalk.R; +import org.kontalk.service.msgcenter.MessageCenterService; /** @@ -32,14 +31,14 @@ * TODO use popup activity on tablet * @author Daniele Ricci */ -public class StatusActivity extends ActionBarActivity { +public class StatusActivity extends ToolbarActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.status_screen); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); + setupToolbar(true, true); } public static void start(Activity context) { @@ -48,16 +47,22 @@ public static void start(Activity context) { } @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - startActivity(new Intent(this, ConversationList.class)); - return true; - - default: - return super.onOptionsItemSelected(item); - } + protected void onResume() { + super.onResume(); + // hold message center + MessageCenterService.hold(this, true); + } + + @Override + protected void onPause() { + super.onPause(); + // release message center + MessageCenterService.release(this); + } + + @Override + protected boolean isNormalUpNavigation() { + return true; } } diff --git a/app/src/main/java/org/kontalk/ui/StatusFragment.java b/app/src/main/java/org/kontalk/ui/StatusFragment.java index 59ad3d87a..770bb6dd3 100644 --- a/app/src/main/java/org/kontalk/ui/StatusFragment.java +++ b/app/src/main/java/org/kontalk/ui/StatusFragment.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,7 +25,7 @@ import android.app.Activity; import android.database.Cursor; import android.os.Bundle; -import android.support.v4.app.ListFragment; +import androidx.fragment.app.ListFragment; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -58,9 +58,9 @@ public void onCreate(Bundle savedInstanceState) { public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.status_fragment, container, false); - mStatus = (EditText) view.findViewById(android.R.id.input); + mStatus = view.findViewById(android.R.id.input); // TODO retrieve current status from server? - mStatus.setText(Preferences.getStatusMessage(getActivity())); + mStatus.setText(Preferences.getStatusMessage()); view.findViewById(R.id.button_ok).setOnClickListener(this); view.findViewById(R.id.button_cancel).setOnClickListener(this); @@ -98,11 +98,11 @@ private void finish(String text) { if (parent != null) { if (text.trim().length() <= 0) text = text.trim(); - Preferences.setStatusMessage(parent, text); + Preferences.setStatusMessage(text); Preferences.addRecentStatusMessage(parent, text); // start the message center to push the status message - MessageCenterService.updateStatus(parent); + MessageCenterService.updateStatus(text); parent.finish(); } diff --git a/app/src/main/java/org/kontalk/ui/ToolbarActivity.java b/app/src/main/java/org/kontalk/ui/ToolbarActivity.java new file mode 100644 index 000000000..79f94a8e3 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/ToolbarActivity.java @@ -0,0 +1,126 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import android.content.Intent; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NavUtils; +import androidx.core.app.TaskStackBuilder; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import android.view.ActionMode; +import android.view.MenuItem; + +import org.kontalk.R; + + +/** + * A base class for toolbar-based activities. + * @author Daniele Ricci + */ +public abstract class ToolbarActivity extends AppCompatActivity { + + private boolean mUseUpNavigation; + + /** + * Setup the toolbar. + * @param home whether to enable {@link androidx.appcompat.app.ActionBar#setDisplayHomeAsUpEnabled}. + * @param useUpNavigation whether to enable up navigation behavior. + * @return the toolbar + */ + protected Toolbar setupToolbar(boolean home, boolean useUpNavigation) { + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + mUseUpNavigation = useUpNavigation; + if (home) + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + return toolbar; + } + + /** + * @see Providing Up Navigation + */ + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (mUseUpNavigation) { + switch (item.getItemId()) { + case android.R.id.home: + navigateUp(); + return true; + } + } + + return super.onOptionsItemSelected(item); + } + + public void navigateUp() { + if (isNormalUpNavigation()) { + NavUtils.navigateUpFromSameTask(this); + } + else { + Intent upIntent = NavUtils.getParentActivityIntent(this); + if (NavUtils.shouldUpRecreateTask(this, upIntent)) { + // This activity is NOT part of this app's task, so create a new task + // when navigating up, with a synthesized back stack. + TaskStackBuilder.create(this) + // Add all of this activity's parents to the back stack + .addNextIntentWithParentStack(upIntent) + // Navigate up to the closest parent + .startActivities(); + finish(); + } + else { + // This activity is part of this app's task, so simply + // navigate up to the logical parent activity. + NavUtils.navigateUpTo(this, upIntent); + } + } + } + + /** + * Subclasses should return true if we should use + * {@link androidx.core.app.NavUtils#navigateUpFromSameTask(android.app.Activity)} for + * navigating up or false if we should create a new task. + */ + protected abstract boolean isNormalUpNavigation(); + + /** Workarounding Android bug https://issuetracker.google.com/issues/37032031 */ + @Override + public ActionMode startActionMode(final ActionMode.Callback callback) { + final ActionMode mode = super.startActionMode(callback); + if (mode != null) { + mode.invalidate(); + } + return mode; + } + + /** Workarounding Android bug https://issuetracker.google.com/issues/37032031 */ + @Nullable + @Override + public androidx.appcompat.view.ActionMode startSupportActionMode(@NonNull androidx.appcompat.view.ActionMode.Callback callback) { + final androidx.appcompat.view.ActionMode mode = super.startSupportActionMode(callback); + if (mode != null) { + mode.invalidate(); + } + return mode; + } +} diff --git a/app/src/main/java/org/kontalk/ui/adapter/ContactsListAdapter.java b/app/src/main/java/org/kontalk/ui/adapter/ContactsListAdapter.java index 4f29e4b08..050a153f7 100644 --- a/app/src/main/java/org/kontalk/ui/adapter/ContactsListAdapter.java +++ b/app/src/main/java/org/kontalk/ui/adapter/ContactsListAdapter.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,24 +18,31 @@ package org.kontalk.ui.adapter; -import org.kontalk.R; -import org.kontalk.data.Contact; -import org.kontalk.ui.ContactsListActivity; -import org.kontalk.ui.view.ContactsListItem; -import org.kontalk.ui.view.MessageListItem; +import com.android.contacts.common.list.ContactsSectionIndexer; import android.content.Context; import android.database.Cursor; -import android.util.Log; +import android.os.Bundle; +import androidx.core.content.ContextCompat; +import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.CursorAdapter; -import android.widget.ListView; import android.widget.AbsListView.RecyclerListener; +import android.widget.ListView; +import android.widget.TextView; + +import lb.library.cursor.SearchablePinnedHeaderCursorListViewAdapter; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.provider.MyUsers; +import org.kontalk.ui.ContactsListActivity; +import org.kontalk.ui.view.ContactsListItem; -public class ContactsListAdapter extends CursorAdapter { +public class ContactsListAdapter extends SearchablePinnedHeaderCursorListViewAdapter { private static final String TAG = ContactsListActivity.TAG; private final LayoutInflater mFactory; @@ -47,15 +54,43 @@ public ContactsListAdapter(Context context, ListView list) { list.setRecyclerListener(new RecyclerListener() { public void onMovedToScrapHeap(View view) { - if (view instanceof MessageListItem) { + if (view instanceof ContactsListItem) { ((ContactsListItem) view).unbind(); } } }); } + public interface OnContentChangedListener { + void onContentChanged(ContactsListAdapter adapter); + } + + public void setPinnedHeader(Context context) { + final TypedValue typedValue = new TypedValue(); + + context.getTheme().resolveAttribute(android.R.attr.colorBackground, typedValue, true); + int pinnedHeaderBackgroundColor = ContextCompat.getColor(context, typedValue.resourceId); + setPinnedHeaderBackgroundColor(pinnedHeaderBackgroundColor); + + int textColor = ContextCompat.getColor(context, R.color.pinned_header_text); + setPinnedHeaderTextColor(textColor); + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + final View inflated = mFactory.inflate(R.layout.contacts_list_item, parent, false); + final ViewHolder holder = new ViewHolder(); + holder.headerView = inflated.findViewById(R.id.header_text); + holder.text1 = inflated.findViewById(android.R.id.text1); + holder.text2 = inflated.findViewById(android.R.id.text2); + inflated.setTag(holder); + return inflated; + } + @Override public void bindView(View view, Context context, Cursor cursor) { + super.bindView(view, context, cursor); + if (!(view instanceof ContactsListItem)) { Log.e(TAG, "Unexpected bound view: " + view); return; @@ -67,12 +102,39 @@ public void bindView(View view, Context context, Cursor cursor) { } @Override - public View newView(Context context, Cursor cursor, ViewGroup parent) { - return mFactory.inflate(R.layout.contacts_list_item, parent, false); + protected TextView findHeaderView(View itemView) { + return ((ViewHolder) itemView.getTag()).headerView; } - public interface OnContentChangedListener { - void onContentChanged(ContactsListAdapter adapter); + @Override + protected Cursor getFilterCursor(CharSequence charSequence) { + return null; + } + + @Override + public void changeCursor(Cursor cursor) { + super.changeCursor(cursor); + // create indexer + updateIndexer(cursor); + } + + private void updateIndexer(Cursor cursor) { + if (cursor == null) { + setSectionIndexer(null); + return; + } + + Bundle bundle = cursor.getExtras(); + if (bundle.containsKey(MyUsers.Users.EXTRA_INDEX_TITLES) && + bundle.containsKey(MyUsers.Users.EXTRA_INDEX_COUNTS)) { + String[] sections = bundle.getStringArray(MyUsers.Users.EXTRA_INDEX_TITLES); + int[] counts = bundle.getIntArray(MyUsers.Users.EXTRA_INDEX_COUNTS); + + setSectionIndexer(new ContactsSectionIndexer(sections, counts)); + } + else { + setSectionIndexer(null); + } } public void setOnContentChangedListener(OnContentChangedListener l) { @@ -86,4 +148,13 @@ protected void onContentChanged() { mOnContentChangedListener.onContentChanged(this); } } + + private static class ViewHolder { + TextView headerView; + public TextView text1; + public TextView text2; + + ViewHolder() { + } + } } diff --git a/app/src/main/java/org/kontalk/ui/adapter/ConversationFooterViewHolder.java b/app/src/main/java/org/kontalk/ui/adapter/ConversationFooterViewHolder.java new file mode 100644 index 000000000..99d3102e0 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/adapter/ConversationFooterViewHolder.java @@ -0,0 +1,57 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.adapter; + +import android.content.Context; +import androidx.recyclerview.widget.RecyclerView; +import android.view.View; +import android.widget.TextView; + +import org.kontalk.R; + + +class ConversationFooterViewHolder extends RecyclerView.ViewHolder implements + View.OnClickListener { + + private final ConversationListAdapter.OnFooterClickListener mListener; + + ConversationFooterViewHolder(View itemView, ConversationListAdapter.OnFooterClickListener listener) { + super(itemView); + mListener = listener; + if (listener != null) { + itemView.setOnClickListener(this); + } + } + + void bindView(Context context, Integer archivedCount) { + if (archivedCount != null && archivedCount > 0) { + ((TextView) itemView).setText(context + .getString(R.string.footer_archived_threads, archivedCount)); + } + else { + itemView.setVisibility(View.INVISIBLE); + } + } + + @Override + public void onClick(View v) { + mListener.onFooterClick(); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/adapter/ConversationListAdapter.java b/app/src/main/java/org/kontalk/ui/adapter/ConversationListAdapter.java index dfee4a8f5..b08c1d23e 100644 --- a/app/src/main/java/org/kontalk/ui/adapter/ConversationListAdapter.java +++ b/app/src/main/java/org/kontalk/ui/adapter/ConversationListAdapter.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,73 +18,163 @@ package org.kontalk.ui.adapter; -import org.kontalk.R; -import org.kontalk.data.Conversation; -import org.kontalk.ui.ConversationList; -import org.kontalk.ui.view.ConversationListItem; -import org.kontalk.ui.view.MessageListItem; - +import androidx.annotation.Nullable; +import androidx.paging.PagedListAdapter; import android.content.Context; -import android.database.Cursor; -import android.util.Log; +import androidx.annotation.NonNull; +import androidx.recyclerview.selection.ItemDetailsLookup; +import androidx.recyclerview.selection.SelectionTracker; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; import android.view.LayoutInflater; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; -import android.widget.AbsListView.RecyclerListener; -import android.widget.CursorAdapter; -import android.widget.ListView; +import org.jivesoftware.smack.util.StringUtils; +import org.kontalk.R; +import org.kontalk.data.Conversation; +import org.kontalk.ui.view.ConversationListItem; + + +public class ConversationListAdapter extends PagedListAdapter { -public class ConversationListAdapter extends CursorAdapter { - private static final String TAG = ConversationList.TAG; + private static final int ITEM_TYPE_ITEM = 0; + private static final int ITEM_TYPE_FOOTER = 1; + + private static final DiffUtil.ItemCallback sDiffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(Conversation oldItem, Conversation newItem) { + return oldItem.getThreadId() == newItem.getThreadId(); + } + + @Override + public boolean areContentsTheSame(Conversation oldItem, Conversation newItem) { + // include any attribute that might change the state of the UI + return oldItem.getThreadId() == newItem.getThreadId() && + oldItem.getStatus() == newItem.getStatus() && + oldItem.getDate() == newItem.getDate() && + oldItem.isSticky() == newItem.isSticky() && + // this is for the count-only item + oldItem.getMessageCount() == newItem.getMessageCount() && + oldItem.getUnreadCount() == newItem.getUnreadCount() && + oldItem.getRequestStatus() == newItem.getRequestStatus() && + StringUtils.nullSafeCharSequenceEquals(oldItem.getSubject(), newItem.getSubject()) && + StringUtils.nullSafeCharSequenceEquals(oldItem.getDraft(), newItem.getDraft()); + } + }; private final LayoutInflater mFactory; - private OnContentChangedListener mOnContentChangedListener; + private SelectionTracker mSelectionTracker; + private OnItemClickListener mItemListener; + private OnFooterClickListener mFooterListener; - public ConversationListAdapter(Context context, Cursor cursor, ListView list) { - super(context, cursor, false); + public ConversationListAdapter(Context context) { + super(sDiffCallback); mFactory = LayoutInflater.from(context); + setHasStableIds(true); + } - list.setRecyclerListener(new RecyclerListener() { - public void onMovedToScrapHeap(View view) { - if (view instanceof MessageListItem) { - ((ConversationListItem) view).unbind(); - } - } - }); + public void setSelectionTracker(SelectionTracker selectionTracker) { + mSelectionTracker = selectionTracker; + } + + public void setItemListener(OnItemClickListener itemListener) { + mItemListener = itemListener; + } + + public void setFooterListener(OnFooterClickListener footerListener) { + mFooterListener = footerListener; + } + + public int getRealItemCount() { + if (getItemCount() > 0) { + int count = getItemCount(); + Conversation conv = getItem(count - 1); + return conv != null && conv.isCountOnly() ? + count - 1 : count; + } + return 0; + } + + @Override + public long getItemId(int position) { + return getItem(position).getThreadId(); } @Override - public void bindView(View view, Context context, Cursor cursor) { - if (!(view instanceof ConversationListItem)) { - Log.e(TAG, "Unexpected bound view: " + view); - return; + public int getItemViewType(int position) { + Conversation conv = getItem(position); + if (conv != null && conv.isCountOnly()) { + return ITEM_TYPE_FOOTER; + } + else { + return ITEM_TYPE_ITEM; } + } - ConversationListItem headerView = (ConversationListItem) view; - Conversation conv = Conversation.createFromCursor(context, cursor); + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case ITEM_TYPE_FOOTER: + return new ConversationFooterViewHolder(mFactory + .inflate(R.layout.conversation_list_footer, parent, false), mFooterListener); + case ITEM_TYPE_ITEM: + default: + return new ConversationViewHolder((ConversationListItem) mFactory + .inflate(R.layout.conversation_list_item, parent, false), mItemListener); + } + } - headerView.bind(context, conv); + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + if (holder instanceof ConversationViewHolder) { + boolean selected = mSelectionTracker.isSelected(getItemId(position)); + ((ConversationViewHolder) holder).bindView(mFactory.getContext(), getItem(position), selected); + } + else if (holder instanceof ConversationFooterViewHolder) { + Conversation archivedCount = getItem(position); + ((ConversationFooterViewHolder) holder).bindView(mFactory.getContext(), + archivedCount != null && archivedCount.getMessageCount() > 0 ? + archivedCount.getMessageCount() : null); + } } @Override - public View newView(Context context, Cursor cursor, ViewGroup parent) { - return mFactory.inflate(R.layout.conversation_list_item, parent, false); + public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) { + if (holder instanceof ConversationViewHolder) { + ((ConversationViewHolder) holder).unbindView(); + } } - public interface OnContentChangedListener { - void onContentChanged(ConversationListAdapter adapter); + public interface OnItemClickListener { + void onItemClick(ConversationListItem item, int position); } - public void setOnContentChangedListener(OnContentChangedListener l) { - mOnContentChangedListener = l; + public interface OnFooterClickListener { + void onFooterClick(); } - @Override - protected void onContentChanged() { - Cursor c = getCursor(); - if (c != null && !c.isClosed() && mOnContentChangedListener != null) { - mOnContentChangedListener.onContentChanged(this); + public static final class ConversationItemDetailsLookup extends ItemDetailsLookup { + private final RecyclerView mListView; + + public ConversationItemDetailsLookup(RecyclerView listView) { + mListView = listView; + } + + @Nullable + @Override + public ItemDetails getItemDetails(@NonNull MotionEvent event) { + View view = mListView.findChildViewUnder(event.getX(), event.getY()); + if (view != null) { + RecyclerView.ViewHolder holder = mListView.getChildViewHolder(view); + if (holder instanceof ConversationViewHolder) { + return ((ConversationViewHolder) holder).getItemDetails(); + } + } + return null; } } + } diff --git a/app/src/main/java/org/kontalk/ui/adapter/ConversationViewHolder.java b/app/src/main/java/org/kontalk/ui/adapter/ConversationViewHolder.java new file mode 100644 index 000000000..30985331e --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/adapter/ConversationViewHolder.java @@ -0,0 +1,80 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.adapter; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.recyclerview.selection.ItemDetailsLookup; +import androidx.recyclerview.widget.RecyclerView; + +import org.kontalk.data.Conversation; +import org.kontalk.ui.view.ConversationListItem; + + +class ConversationViewHolder extends RecyclerView.ViewHolder implements + View.OnClickListener { + + private final ConversationListAdapter.OnItemClickListener mListener; + + ConversationViewHolder(ConversationListItem itemView, + ConversationListAdapter.OnItemClickListener listener) { + super(itemView); + mListener = listener; + itemView.setOnClickListener(this); + } + + void bindView(Context context, Conversation conversation, boolean selected) { + if (conversation != null) { + ((ConversationListItem) itemView).bind(context, conversation, selected); + } + } + + void unbindView() { + ((ConversationListItem) itemView).unbind(); + } + + @Override + public void onClick(View v) { + if (mListener != null) { + mListener.onItemClick((ConversationListItem) itemView, + getAdapterPosition()); + } + } + + public ItemDetailsLookup.ItemDetails getItemDetails() { + return new ItemDetailsLookup.ItemDetails() { + @Override + public int getPosition() { + return getAdapterPosition(); + } + + @Nullable + @Override + public Long getSelectionKey() { + Conversation conv = ((ConversationListItem) itemView).getConversation(); + return conv != null ? conv.getThreadId() : null; + } + }; + } + + +} + diff --git a/app/src/main/java/org/kontalk/ui/adapter/CountryCodesAdapter.java b/app/src/main/java/org/kontalk/ui/adapter/CountryCodesAdapter.java index c03099c67..a15802c31 100644 --- a/app/src/main/java/org/kontalk/ui/adapter/CountryCodesAdapter.java +++ b/app/src/main/java/org/kontalk/ui/adapter/CountryCodesAdapter.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,17 +24,14 @@ import java.util.List; import java.util.Locale; -import org.kontalk.R; -import org.kontalk.client.NumberValidator; +import org.kontalk.service.registration.RegistrationService; import android.content.Context; -import android.util.SparseIntArray; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.CheckedTextView; -import android.widget.ImageView; import android.widget.TextView; import com.google.i18n.phonenumbers.PhoneNumberUtil; @@ -42,248 +39,6 @@ public class CountryCodesAdapter extends BaseAdapter { - /** - * Static mappings of region codes to flags drawable. - * Don't you really think this was written manually? NAAAAHH!!! :P - */ - private static final SparseIntArray sFlags = new SparseIntArray(233); - - static { - sFlags.put(0x4146 /* AF */, R.drawable.flag_af); - sFlags.put(0x414c /* AL */, R.drawable.flag_al); - sFlags.put(0x445a /* DZ */, R.drawable.flag_dz); - sFlags.put(0x4144 /* AD */, R.drawable.flag_ad); - sFlags.put(0x414f /* AO */, R.drawable.flag_ao); - sFlags.put(0x4149 /* AI */, R.drawable.flag_ai); - sFlags.put(0x4147 /* AG */, R.drawable.flag_ag); - sFlags.put(0x5341 /* SA */, R.drawable.flag_sa); - sFlags.put(0x4152 /* AR */, R.drawable.flag_ar); - sFlags.put(0x414d /* AM */, R.drawable.flag_am); - sFlags.put(0x4157 /* AW */, R.drawable.flag_aw); - sFlags.put(0x4155 /* AU */, R.drawable.flag_au); - sFlags.put(0x4154 /* AT */, R.drawable.flag_at); - sFlags.put(0x415a /* AZ */, R.drawable.flag_az); - sFlags.put(0x4253 /* BS */, R.drawable.flag_bs); - sFlags.put(0x4248 /* BH */, R.drawable.flag_bh); - sFlags.put(0x4244 /* BD */, R.drawable.flag_bd); - sFlags.put(0x4242 /* BB */, R.drawable.flag_bb); - sFlags.put(0x4245 /* BE */, R.drawable.flag_be); - sFlags.put(0x425a /* BZ */, R.drawable.flag_bz); - sFlags.put(0x424a /* BJ */, R.drawable.flag_bj); - sFlags.put(0x424d /* BM */, R.drawable.flag_bm); - sFlags.put(0x4254 /* BT */, R.drawable.flag_bt); - sFlags.put(0x4259 /* BY */, R.drawable.flag_by); - sFlags.put(0x424f /* BO */, R.drawable.flag_bo); - sFlags.put(0x4241 /* BA */, R.drawable.flag_ba); - sFlags.put(0x4257 /* BW */, R.drawable.flag_bw); - sFlags.put(0x4252 /* BR */, R.drawable.flag_br); - sFlags.put(0x424e /* BN */, R.drawable.flag_bn); - sFlags.put(0x4247 /* BG */, R.drawable.flag_bg); - sFlags.put(0x4246 /* BF */, R.drawable.flag_bf); - sFlags.put(0x4249 /* BI */, R.drawable.flag_bi); - sFlags.put(0x4b48 /* KH */, R.drawable.flag_kh); - sFlags.put(0x434d /* CM */, R.drawable.flag_cm); - sFlags.put(0x4341 /* CA */, R.drawable.flag_ca); - sFlags.put(0x4356 /* CV */, R.drawable.flag_cv); - sFlags.put(0x5444 /* TD */, R.drawable.flag_td); - sFlags.put(0x434c /* CL */, R.drawable.flag_cl); - sFlags.put(0x434e /* CN */, R.drawable.flag_cn); - sFlags.put(0x4359 /* CY */, R.drawable.flag_cy); - sFlags.put(0x5641 /* VA */, R.drawable.flag_va); - sFlags.put(0x434f /* CO */, R.drawable.flag_co); - sFlags.put(0x4b4d /* KM */, R.drawable.flag_km); - sFlags.put(0x4347 /* CG */, R.drawable.flag_cg); - sFlags.put(0x4b50 /* KP */, R.drawable.flag_kp); - sFlags.put(0x4b52 /* KR */, R.drawable.flag_kr); - sFlags.put(0x4352 /* CR */, R.drawable.flag_cr); - sFlags.put(0x4349 /* CI */, R.drawable.flag_ci); - sFlags.put(0x4852 /* HR */, R.drawable.flag_hr); - sFlags.put(0x4355 /* CU */, R.drawable.flag_cu); - sFlags.put(0x444b /* DK */, R.drawable.flag_dk); - sFlags.put(0x444d /* DM */, R.drawable.flag_dm); - sFlags.put(0x4543 /* EC */, R.drawable.flag_ec); - sFlags.put(0x4547 /* EG */, R.drawable.flag_eg); - sFlags.put(0x5356 /* SV */, R.drawable.flag_sv); - sFlags.put(0x4145 /* AE */, R.drawable.flag_ae); - sFlags.put(0x4552 /* ER */, R.drawable.flag_er); - sFlags.put(0x4545 /* EE */, R.drawable.flag_ee); - sFlags.put(0x4554 /* ET */, R.drawable.flag_et); - sFlags.put(0x464a /* FJ */, R.drawable.flag_fj); - sFlags.put(0x5048 /* PH */, R.drawable.flag_ph); - sFlags.put(0x4649 /* FI */, R.drawable.flag_fi); - sFlags.put(0x4652 /* FR */, R.drawable.flag_fr); - sFlags.put(0x4741 /* GA */, R.drawable.flag_ga); - sFlags.put(0x474d /* GM */, R.drawable.flag_gm); - sFlags.put(0x4745 /* GE */, R.drawable.flag_ge); - sFlags.put(0x4445 /* DE */, R.drawable.flag_de); - sFlags.put(0x4748 /* GH */, R.drawable.flag_gh); - sFlags.put(0x4a4d /* JM */, R.drawable.flag_jm); - sFlags.put(0x4a50 /* JP */, R.drawable.flag_jp); - sFlags.put(0x4749 /* GI */, R.drawable.flag_gi); - sFlags.put(0x444a /* DJ */, R.drawable.flag_dj); - sFlags.put(0x4a4f /* JO */, R.drawable.flag_jo); - sFlags.put(0x4752 /* GR */, R.drawable.flag_gr); - sFlags.put(0x4744 /* GD */, R.drawable.flag_gd); - sFlags.put(0x474c /* GL */, R.drawable.flag_gl); - sFlags.put(0x4750 /* GP */, R.drawable.flag_gp); - sFlags.put(0x4755 /* GU */, R.drawable.flag_gu); - sFlags.put(0x4754 /* GT */, R.drawable.flag_gt); - sFlags.put(0x4746 /* GF */, R.drawable.flag_gf); - sFlags.put(0x474e /* GN */, R.drawable.flag_gn); - sFlags.put(0x4757 /* GW */, R.drawable.flag_gw); - sFlags.put(0x4751 /* GQ */, R.drawable.flag_gq); - sFlags.put(0x4759 /* GY */, R.drawable.flag_gy); - sFlags.put(0x4854 /* HT */, R.drawable.flag_ht); - sFlags.put(0x484e /* HN */, R.drawable.flag_hn); - sFlags.put(0x484b /* HK */, R.drawable.flag_hk); - sFlags.put(0x494e /* IN */, R.drawable.flag_in); - sFlags.put(0x4944 /* ID */, R.drawable.flag_id); - sFlags.put(0x4952 /* IR */, R.drawable.flag_ir); - sFlags.put(0x4951 /* IQ */, R.drawable.flag_iq); - sFlags.put(0x4945 /* IE */, R.drawable.flag_ie); - sFlags.put(0x4953 /* IS */, R.drawable.flag_is); - sFlags.put(0x4e46 /* NF */, R.drawable.flag_nf); - sFlags.put(0x4358 /* CX */, R.drawable.flag_cx); - sFlags.put(0x4158 /* AX */, R.drawable.flag_ax); - sFlags.put(0x4b59 /* KY */, R.drawable.flag_ky); - sFlags.put(0x4343 /* CC */, R.drawable.flag_cc); - sFlags.put(0x434b /* CK */, R.drawable.flag_ck); - sFlags.put(0x464b /* FK */, R.drawable.flag_fk); - sFlags.put(0x464f /* FO */, R.drawable.flag_fo); - sFlags.put(0x4d50 /* MP */, R.drawable.flag_mp); - sFlags.put(0x4d48 /* MH */, R.drawable.flag_mh); - sFlags.put(0x5342 /* SB */, R.drawable.flag_sb); - sFlags.put(0x5443 /* TC */, R.drawable.flag_tc); - sFlags.put(0x5647 /* VG */, R.drawable.flag_vg); - sFlags.put(0x5649 /* VI */, R.drawable.flag_vi); - sFlags.put(0x494c /* IL */, R.drawable.flag_il); - sFlags.put(0x4954 /* IT */, R.drawable.flag_it); - sFlags.put(0x4b5a /* KZ */, R.drawable.flag_kz); - sFlags.put(0x4b45 /* KE */, R.drawable.flag_ke); - sFlags.put(0x4b47 /* KG */, R.drawable.flag_kg); - sFlags.put(0x4b49 /* KI */, R.drawable.flag_ki); - sFlags.put(0x4b57 /* KW */, R.drawable.flag_kw); - sFlags.put(0x4c41 /* LA */, R.drawable.flag_la); - sFlags.put(0x4c53 /* LS */, R.drawable.flag_ls); - sFlags.put(0x4c56 /* LV */, R.drawable.flag_lv); - sFlags.put(0x4c42 /* LB */, R.drawable.flag_lb); - sFlags.put(0x4c52 /* LR */, R.drawable.flag_lr); - sFlags.put(0x4c59 /* LY */, R.drawable.flag_ly); - sFlags.put(0x4c49 /* LI */, R.drawable.flag_li); - sFlags.put(0x4c54 /* LT */, R.drawable.flag_lt); - sFlags.put(0x4c55 /* LU */, R.drawable.flag_lu); - sFlags.put(0x4d4f /* MO */, R.drawable.flag_mo); - sFlags.put(0x4d4b /* MK */, R.drawable.flag_mk); - sFlags.put(0x4d47 /* MG */, R.drawable.flag_mg); - sFlags.put(0x4d57 /* MW */, R.drawable.flag_mw); - sFlags.put(0x4d59 /* MY */, R.drawable.flag_my); - sFlags.put(0x4d56 /* MV */, R.drawable.flag_mv); - sFlags.put(0x4d4c /* ML */, R.drawable.flag_ml); - sFlags.put(0x4d54 /* MT */, R.drawable.flag_mt); - sFlags.put(0x4d41 /* MA */, R.drawable.flag_ma); - sFlags.put(0x4d51 /* MQ */, R.drawable.flag_mq); - sFlags.put(0x4d52 /* MR */, R.drawable.flag_mr); - sFlags.put(0x4d55 /* MU */, R.drawable.flag_mu); - sFlags.put(0x5954 /* YT */, R.drawable.flag_yt); - sFlags.put(0x4d58 /* MX */, R.drawable.flag_mx); - sFlags.put(0x464d /* FM */, R.drawable.flag_fm); - sFlags.put(0x4d44 /* MD */, R.drawable.flag_md); - sFlags.put(0x4d43 /* MC */, R.drawable.flag_mc); - sFlags.put(0x4d4e /* MN */, R.drawable.flag_mn); - sFlags.put(0x4d45 /* ME */, R.drawable.flag_me); - sFlags.put(0x4d53 /* MS */, R.drawable.flag_ms); - sFlags.put(0x4d5a /* MZ */, R.drawable.flag_mz); - sFlags.put(0x4d4d /* MM */, R.drawable.flag_mm); - sFlags.put(0x4e41 /* NA */, R.drawable.flag_na); - sFlags.put(0x4e52 /* NR */, R.drawable.flag_nr); - sFlags.put(0x4e50 /* NP */, R.drawable.flag_np); - sFlags.put(0x4e49 /* NI */, R.drawable.flag_ni); - sFlags.put(0x4e45 /* NE */, R.drawable.flag_ne); - sFlags.put(0x4e47 /* NG */, R.drawable.flag_ng); - sFlags.put(0x4e55 /* NU */, R.drawable.flag_nu); - sFlags.put(0x4e4f /* NO */, R.drawable.flag_no); - sFlags.put(0x4e43 /* NC */, R.drawable.flag_nc); - sFlags.put(0x4e5a /* NZ */, R.drawable.flag_nz); - sFlags.put(0x4f4d /* OM */, R.drawable.flag_om); - sFlags.put(0x4e4c /* NL */, R.drawable.flag_nl); - sFlags.put(0x504b /* PK */, R.drawable.flag_pk); - sFlags.put(0x5057 /* PW */, R.drawable.flag_pw); - sFlags.put(0x5053 /* PS */, R.drawable.flag_ps); - sFlags.put(0x5041 /* PA */, R.drawable.flag_pa); - sFlags.put(0x5047 /* PG */, R.drawable.flag_pg); - sFlags.put(0x5059 /* PY */, R.drawable.flag_py); - sFlags.put(0x5045 /* PE */, R.drawable.flag_pe); - sFlags.put(0x5046 /* PF */, R.drawable.flag_pf); - sFlags.put(0x504c /* PL */, R.drawable.flag_pl); - sFlags.put(0x5054 /* PT */, R.drawable.flag_pt); - sFlags.put(0x5052 /* PR */, R.drawable.flag_pr); - sFlags.put(0x5141 /* QA */, R.drawable.flag_qa); - sFlags.put(0x4742 /* GB */, R.drawable.flag_gb); - sFlags.put(0x435a /* CZ */, R.drawable.flag_cz); - sFlags.put(0x4346 /* CF */, R.drawable.flag_cf); - sFlags.put(0x444f /* DO */, R.drawable.flag_do); - sFlags.put(0x4344 /* CD */, R.drawable.flag_cd); - sFlags.put(0x5245 /* RE */, R.drawable.flag_re); - sFlags.put(0x524f /* RO */, R.drawable.flag_ro); - sFlags.put(0x5257 /* RW */, R.drawable.flag_rw); - sFlags.put(0x5255 /* RU */, R.drawable.flag_ru); - sFlags.put(0x4548 /* EH */, R.drawable.flag_eh); - sFlags.put(0x4b4e /* KN */, R.drawable.flag_kn); - sFlags.put(0x504d /* PM */, R.drawable.flag_pm); - sFlags.put(0x5643 /* VC */, R.drawable.flag_vc); - sFlags.put(0x4c43 /* LC */, R.drawable.flag_lc); - sFlags.put(0x5753 /* WS */, R.drawable.flag_ws); - sFlags.put(0x4153 /* AS */, R.drawable.flag_as); - sFlags.put(0x534d /* SM */, R.drawable.flag_sm); - sFlags.put(0x5348 /* SH */, R.drawable.flag_sh); - sFlags.put(0x534e /* SN */, R.drawable.flag_sn); - sFlags.put(0x5253 /* RS */, R.drawable.flag_rs); - sFlags.put(0x5343 /* SC */, R.drawable.flag_sc); - sFlags.put(0x534c /* SL */, R.drawable.flag_sl); - sFlags.put(0x5347 /* SG */, R.drawable.flag_sg); - sFlags.put(0x5359 /* SY */, R.drawable.flag_sy); - sFlags.put(0x534b /* SK */, R.drawable.flag_sk); - sFlags.put(0x5349 /* SI */, R.drawable.flag_si); - sFlags.put(0x534f /* SO */, R.drawable.flag_so); - sFlags.put(0x4553 /* ES */, R.drawable.flag_es); - sFlags.put(0x4c4b /* LK */, R.drawable.flag_lk); - sFlags.put(0x5553 /* US */, R.drawable.flag_us); - sFlags.put(0x5a41 /* ZA */, R.drawable.flag_za); - sFlags.put(0x5344 /* SD */, R.drawable.flag_sd); - sFlags.put(0x5352 /* SR */, R.drawable.flag_sr); - sFlags.put(0x534a /* SJ */, R.drawable.flag_sj); - sFlags.put(0x5345 /* SE */, R.drawable.flag_se); - sFlags.put(0x4348 /* CH */, R.drawable.flag_ch); - sFlags.put(0x535a /* SZ */, R.drawable.flag_sz); - sFlags.put(0x5354 /* ST */, R.drawable.flag_st); - sFlags.put(0x544a /* TJ */, R.drawable.flag_tj); - sFlags.put(0x5457 /* TW */, R.drawable.flag_tw); - sFlags.put(0x545a /* TZ */, R.drawable.flag_tz); - sFlags.put(0x494f /* IO */, R.drawable.flag_io); - sFlags.put(0x5448 /* TH */, R.drawable.flag_th); - sFlags.put(0x544c /* TL */, R.drawable.flag_tl); - sFlags.put(0x5447 /* TG */, R.drawable.flag_tg); - sFlags.put(0x544b /* TK */, R.drawable.flag_tk); - sFlags.put(0x544f /* TO */, R.drawable.flag_to); - sFlags.put(0x5454 /* TT */, R.drawable.flag_tt); - sFlags.put(0x544e /* TN */, R.drawable.flag_tn); - sFlags.put(0x5452 /* TR */, R.drawable.flag_tr); - sFlags.put(0x544d /* TM */, R.drawable.flag_tm); - sFlags.put(0x5456 /* TV */, R.drawable.flag_tv); - sFlags.put(0x5541 /* UA */, R.drawable.flag_ua); - sFlags.put(0x5547 /* UG */, R.drawable.flag_ug); - sFlags.put(0x4855 /* HU */, R.drawable.flag_hu); - sFlags.put(0x5559 /* UY */, R.drawable.flag_uy); - sFlags.put(0x555a /* UZ */, R.drawable.flag_uz); - sFlags.put(0x5655 /* VU */, R.drawable.flag_vu); - sFlags.put(0x5645 /* VE */, R.drawable.flag_ve); - sFlags.put(0x564e /* VN */, R.drawable.flag_vn); - sFlags.put(0x5746 /* WF */, R.drawable.flag_wf); - sFlags.put(0x5945 /* YE */, R.drawable.flag_ye); - sFlags.put(0x5a4d /* ZM */, R.drawable.flag_zm); - sFlags.put(0x5a57 /* ZW */, R.drawable.flag_zw); - } - private final LayoutInflater mInflater; private final List mData; private final int mViewId; @@ -305,7 +60,7 @@ public boolean equals(Object o) { if (this == o) return true; - if (o != null && o instanceof CountryCode) { + if (o instanceof CountryCode) { CountryCode other = (CountryCode) o; return regionCode != null && @@ -322,7 +77,7 @@ public String toString() { } public CountryCodesAdapter(Context context, int viewId, int dropdownViewId) { - this(context, new ArrayList(), viewId, dropdownViewId); + this(context, new ArrayList<>(), viewId, dropdownViewId); } public CountryCodesAdapter(Context context, List data, int viewId, int dropdownViewId) { @@ -340,7 +95,7 @@ public void add(String regionCode) { CountryCode cc = new CountryCode(); cc.regionCode = regionCode; cc.countryCode = PhoneNumberUtil.getInstance().getCountryCodeForRegion(regionCode); - cc.regionName = NumberValidator.getRegionDisplayName(regionCode, Locale.getDefault()); + cc.regionName = RegistrationService.getRegionDisplayName(regionCode, Locale.getDefault()); mData.add(cc); } @@ -378,18 +133,16 @@ public void setSelected(int position) { @Override public View getDropDownView(int position, View convertView, ViewGroup parent) { - DropDownViewHolder holder; + CheckedTextView textView; View view; if (convertView == null) { - view = mInflater.inflate(mDropdownViewId, null, false); - holder = new DropDownViewHolder(); - holder.icon = (ImageView) view.findViewById(android.R.id.icon); - holder.description = (CheckedTextView) view.findViewById(android.R.id.text1); - view.setTag(holder); + view = mInflater.inflate(mDropdownViewId, parent, false); + textView = view.findViewById(android.R.id.text1); + view.setTag(textView); } else { view = convertView; - holder = (DropDownViewHolder) view.getTag(); + textView = (CheckedTextView) view.getTag(); } CountryCode e = mData.get(position); @@ -400,67 +153,34 @@ public View getDropDownView(int position, View convertView, ViewGroup parent) { .append(e.countryCode) .append(')'); - holder.description.setText(text); - holder.description.setChecked((mSelected == position)); - - int flagId = getFlag(e.regionCode); - if (flagId > 0) - holder.icon.setImageResource(flagId); - else - holder.icon.setImageDrawable(null); + textView.setText(text); + textView.setChecked((mSelected == position)); return view; } @Override public View getView(int position, View convertView, ViewGroup parent) { - ViewHolder holder; + TextView textView; View view; if (convertView == null) { - view = mInflater.inflate(mViewId, null, false); - holder = new ViewHolder(); - holder.icon = (ImageView) view.findViewById(android.R.id.icon); - holder.description = (TextView) view.findViewById(android.R.id.text1); - view.setTag(holder); + view = mInflater.inflate(mViewId, parent, false); + textView = view.findViewById(android.R.id.text1); + view.setTag(textView); } else { view = convertView; - holder = (ViewHolder) view.getTag(); + textView = (TextView) view.getTag(); } CountryCode e = mData.get(position); StringBuilder text = new StringBuilder(3) .append('+') - .append(e.countryCode) - .append(" (") - .append(e.regionName) - .append(')'); - - holder.description.setText(text); + .append(e.countryCode); - int flagId = getFlag(e.regionCode); - if (flagId > 0) - holder.icon.setImageResource(flagId); - else - holder.icon.setImageDrawable(null); + textView.setText(text); return view; } - - private int getFlag(String regionCode) { - char[] b = regionCode.toCharArray(); - short key = (short) ((short) (b[0] << 8) + (short) b[1]); - return sFlags.get(key); - } - - private final static class ViewHolder { - TextView description; - ImageView icon; - } - - private final static class DropDownViewHolder { - CheckedTextView description; - ImageView icon; - } } diff --git a/app/src/main/java/org/kontalk/ui/adapter/MessageListAdapter.java b/app/src/main/java/org/kontalk/ui/adapter/MessageListAdapter.java index a4dad9441..d8414c6a7 100644 --- a/app/src/main/java/org/kontalk/ui/adapter/MessageListAdapter.java +++ b/app/src/main/java/org/kontalk/ui/adapter/MessageListAdapter.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,24 +20,25 @@ import java.util.regex.Pattern; -import org.kontalk.R; -import org.kontalk.data.Contact; -import org.kontalk.message.CompositeMessage; -import org.kontalk.provider.MyMessages.Messages; -import org.kontalk.ui.view.AudioPlayerControl; -import org.kontalk.ui.ComposeMessage; -import org.kontalk.ui.view.MessageListItem; - import android.content.Context; import android.database.Cursor; -import android.util.Log; +import androidx.cursoradapter.widget.CursorAdapter; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.AbsListView.RecyclerListener; -import android.widget.CursorAdapter; import android.widget.ListView; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.message.CompositeMessage; +import org.kontalk.message.GroupCommandComponent; +import org.kontalk.ui.ComposeMessage; +import org.kontalk.ui.view.AudioPlayerControl; +import org.kontalk.ui.view.MessageListItem; +import org.kontalk.util.MessageUtils; + + public class MessageListAdapter extends CursorAdapter { private static final String TAG = ComposeMessage.TAG; @@ -46,7 +47,6 @@ public class MessageListAdapter extends CursorAdapter { private final Pattern mHighlight; private OnContentChangedListener mOnContentChangedListener; - private Contact mContact; private AudioPlayerControl mAudioPlayerControl; public MessageListAdapter(Context context, Cursor cursor, Pattern highlight, ListView list, AudioPlayerControl audioPlayerControl) { @@ -73,21 +73,65 @@ public void bindView(View view, Context context, Cursor cursor) { MessageListItem headerView = (MessageListItem) view; CompositeMessage msg = CompositeMessage.fromCursor(context, cursor); - if (msg.getDirection() == Messages.DIRECTION_IN && mContact == null) - mContact = Contact.findByUserId(context, msg.getSender()); - long previous = -1; + long previousTimestamp = -1; + int previousItemType = -1; + String previousPeer = null; if (cursor.moveToPrevious()) { - previous = cursor.getLong(CompositeMessage.COLUMN_TIMESTAMP); - cursor.moveToNext(); + previousTimestamp = MessageUtils.getMessageTimestamp(cursor); + previousPeer = MessageUtils.getMessagePeer(cursor); + previousItemType = getItemViewType(cursor); } + cursor.moveToNext(); + + headerView.bind(context, msg, mHighlight, getItemViewType(cursor), + previousItemType, previousTimestamp, previousPeer, mAudioPlayerControl); + } + + @Override + public boolean isEnabled(int position) { + return !isEvent((Cursor) getItem(position)); + } + + private boolean isEvent(Cursor cursor) { + String mime = cursor.getString(CompositeMessage.COLUMN_BODY_MIME); + return (GroupCommandComponent.supportsMimeType(mime)); + } + + private boolean isGroupChat(Cursor cursor) { + return cursor.getString(CompositeMessage.COLUMN_GROUP_JID) != null; + } + + private int getItemViewType(Cursor cursor) { + int type = cursor.getInt(CompositeMessage.COLUMN_DIRECTION); + // MyMessages.DIRECTION_* OR-ed with 2 for group events + if (isEvent(cursor)) + type |= 2; + return type; + } - headerView.bind(context, msg, mContact, mHighlight, previous, mAudioPlayerControl); + @Override + public int getItemViewType(int position) { + Cursor c = (Cursor) getItem(position); + return getItemViewType(c); + } + + @Override + public int getViewTypeCount() { + // incoming (0), incoming event (2), outgoing (1), outgoing event (3) + // MyMessages.DIRECTION_* OR-ed with 2 + return 4; } @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { - return mFactory.inflate(R.layout.message_list_item, parent, false); + int type = cursor.getInt(CompositeMessage.COLUMN_DIRECTION); + boolean event = isEvent(cursor); + boolean groupChat = isGroupChat(cursor); + MessageListItem view = (MessageListItem) mFactory + .inflate(R.layout.message_list_item, parent, false); + view.afterInflate(type, event, groupChat); + return view; } public interface OnContentChangedListener { diff --git a/app/src/main/java/org/kontalk/ui/adapter/SearchListAdapter.java b/app/src/main/java/org/kontalk/ui/adapter/SearchListAdapter.java index 89de064b4..2964e2e3d 100644 --- a/app/src/main/java/org/kontalk/ui/adapter/SearchListAdapter.java +++ b/app/src/main/java/org/kontalk/ui/adapter/SearchListAdapter.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,19 +18,19 @@ package org.kontalk.ui.adapter; -import org.kontalk.R; -import org.kontalk.data.SearchItem; -import org.kontalk.ui.SearchActivity; -import org.kontalk.ui.view.SearchListItem; - import android.content.Context; import android.database.Cursor; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.CursorAdapter; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.data.SearchItem; +import org.kontalk.ui.SearchActivity; +import org.kontalk.ui.view.SearchListItem; + public class SearchListAdapter extends CursorAdapter { diff --git a/app/src/main/java/org/kontalk/ui/model/ConversationsViewModel.java b/app/src/main/java/org/kontalk/ui/model/ConversationsViewModel.java new file mode 100644 index 000000000..7b38802f9 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/model/ConversationsViewModel.java @@ -0,0 +1,52 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.model; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; +import androidx.paging.LivePagedListBuilder; +import androidx.paging.PagedList; +import android.content.Context; +import androidx.annotation.UiThread; + +import org.kontalk.data.Conversation; +import org.kontalk.data.ConversationsDataSourceFactory; + + +public class ConversationsViewModel extends ViewModel { + + private LiveData> mData; + + @UiThread + public void load(Context context, boolean archived) { + PagedList.Config config = new PagedList.Config.Builder() + .setPageSize(100) + .setEnablePlaceholders(false) + .build(); + mData = new LivePagedListBuilder<>( + new ConversationsDataSourceFactory(context.getApplicationContext(), archived), + config) + .build(); + } + + public LiveData> getData() { + return mData; + } + +} diff --git a/app/src/main/java/org/kontalk/ui/model/CountQueryLiveData.java b/app/src/main/java/org/kontalk/ui/model/CountQueryLiveData.java new file mode 100644 index 000000000..ac73ab78d --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/model/CountQueryLiveData.java @@ -0,0 +1,149 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.model; + +import android.annotation.SuppressLint; +import androidx.lifecycle.LiveData; +import android.database.ContentObserver; +import android.database.Cursor; +import android.os.AsyncTask; +import android.os.Handler; +import androidx.core.os.CancellationSignal; +import androidx.core.os.OperationCanceledException; + + +/** + * A LiveData that models a simple counting query to an integer. + * Subclasses should return a cursor through {@link #doQuery(CancellationSignal)}. + * The actual count will be read from the first column of the first row. + */ +public abstract class CountQueryLiveData extends LiveData { + + private final ForceLoadContentObserver mObserver; + private CancellationSignal mCancellationSignal; + private Cursor mCursor; + + protected CountQueryLiveData() { + mObserver = new ForceLoadContentObserver(); + } + + private void loadData() { + loadData(false); + } + + @SuppressLint("StaticFieldLeak") + private void loadData(boolean forceQuery) { + if (!forceQuery && getValue() != null) { + return; + } + + new AsyncTask() { + + @Override + protected Integer doInBackground(Void... params) { + try { + synchronized (CountQueryLiveData.this) { + mCancellationSignal = new CancellationSignal(); + } + Cursor cursor = null; + try { + cursor = doQuery(mCancellationSignal); + try { + if (!cursor.moveToNext()) + throw new RuntimeException("No data found."); + cursor.registerContentObserver(mObserver); + return cursor.getInt(0); + } + catch (Exception e) { + if (cursor != null) { + cursor.close(); + cursor = null; + } + throw e; + } + finally { + mCursor = cursor; + } + } + finally { + synchronized (CountQueryLiveData.this) { + mCancellationSignal = null; + } + mCursor = cursor; + } + } + catch (OperationCanceledException e) { + if (hasActiveObservers()) { + throw e; + } + return null; + } + } + + @Override + protected void onPostExecute(Integer count) { + setValue(count); + } + + }.execute(); + } + + protected abstract Cursor doQuery(CancellationSignal cancellationSignal); + + @Override + protected void onActive() { + loadData(); + } + + @Override + protected void onInactive() { + synchronized (this) { + if (mCancellationSignal != null) { + mCancellationSignal.cancel(); + } + } + } + + public void close() { + if (mCursor != null) { + mCursor.close(); + mCursor = null; + } + } + + final class ForceLoadContentObserver extends ContentObserver { + + ForceLoadContentObserver() { + super(new Handler()); + } + + @Override + public boolean deliverSelfNotifications() { + return true; + } + + @Override + public void onChange(boolean selfChange) { + loadData(true); + } + + } + + +} diff --git a/app/src/main/java/org/kontalk/ui/position/EmptyRow.java b/app/src/main/java/org/kontalk/ui/position/EmptyRow.java new file mode 100644 index 000000000..a80e45c20 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/EmptyRow.java @@ -0,0 +1,55 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import android.content.Context; +import android.widget.FrameLayout; + +/** + * Empty row + * + * @author andreacappelli + */ + +public class EmptyRow extends FrameLayout { + + int cellHeight; + + public EmptyRow(Context context) { + this(context, 8); + } + + public EmptyRow(Context context, int height) { + super(context); + cellHeight = height; + } + + public void setHeight(int height) { + cellHeight = height; + + requestLayout(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(cellHeight, MeasureSpec.EXACTLY)); + } + +} + diff --git a/app/src/main/java/org/kontalk/ui/position/GrayDividerRow.java b/app/src/main/java/org/kontalk/ui/position/GrayDividerRow.java new file mode 100644 index 000000000..6f5769d24 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/GrayDividerRow.java @@ -0,0 +1,80 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import org.kontalk.R; +import org.kontalk.util.ViewUtils; + + +/** + * Gray divider row + * @author Andrea Cappelli + */ +public class GrayDividerRow extends FrameLayout { + + private TextView mTextView; + + public GrayDividerRow(Context context) { + this(context, null); + } + + public GrayDividerRow(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public GrayDividerRow(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public GrayDividerRow(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init() { + inflate(getContext(), R.layout.gray_divider_row, this); + + setBackgroundColor(ContextCompat.getColor(getContext(), R.color.location_item_header_background)); + + mTextView = findViewById(R.id.text); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(ViewUtils.dp(getContext(), 32), MeasureSpec.EXACTLY)); + } + + public void setText(String text) { + mTextView.setText(text); + } + +} + diff --git a/app/src/main/java/org/kontalk/ui/position/LoadingRow.java b/app/src/main/java/org/kontalk/ui/position/LoadingRow.java new file mode 100644 index 000000000..7522dd2e4 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/LoadingRow.java @@ -0,0 +1,74 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.widget.FrameLayout; +import android.widget.TextView; + +import me.zhanghai.android.materialprogressbar.MaterialProgressBar; + +import org.kontalk.R; + + +/** + * Location loading row + * + * @author Andrea Cappelli + */ +public class LoadingRow extends FrameLayout { + + private MaterialProgressBar mProgressBar; + private TextView mText; + + public LoadingRow(Context context) { + this(context, null); + } + + public LoadingRow(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public LoadingRow(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public LoadingRow(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init() { + inflate(getContext(), R.layout.location_loading_row, this); + + mProgressBar = findViewById(R.id.progress_bar); + mText = findViewById(R.id.text); + } + + public void setLoading(boolean value) { + mProgressBar.setVisibility(value ? VISIBLE : INVISIBLE); + mText.setVisibility(value ? INVISIBLE : VISIBLE); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/position/LocationLoadingRow.java b/app/src/main/java/org/kontalk/ui/position/LocationLoadingRow.java new file mode 100644 index 000000000..92fd1dd69 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/LocationLoadingRow.java @@ -0,0 +1,80 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.widget.FrameLayout; +import android.widget.TextView; + +import me.zhanghai.android.materialprogressbar.MaterialProgressBar; + +import org.kontalk.R; +import org.kontalk.util.ViewUtils; + + +/** + * Location loading row + * + * @author Andrea Cappelli + */ +public class LocationLoadingRow extends FrameLayout { + + private MaterialProgressBar mProgressBar; + private TextView mText; + + public LocationLoadingRow(Context context) { + this(context, null); + } + + public LocationLoadingRow(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public LocationLoadingRow(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public LocationLoadingRow(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init() { + inflate(getContext(), R.layout.location_loading_row, this); + + mProgressBar = findViewById(R.id.progress_bar); + mText = findViewById(R.id.text); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec((int) (ViewUtils.dp(getContext(), 56) * 2.5f), MeasureSpec.EXACTLY)); + } + + public void setLoading(boolean value) { + mProgressBar.setVisibility(value ? VISIBLE : INVISIBLE); + mText.setVisibility(value ? INVISIBLE : VISIBLE); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/position/LocationRow.java b/app/src/main/java/org/kontalk/ui/position/LocationRow.java new file mode 100644 index 000000000..aa38d93c5 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/LocationRow.java @@ -0,0 +1,104 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import com.bumptech.glide.Glide; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.os.Build; +import androidx.core.content.ContextCompat; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.kontalk.R; +import org.kontalk.util.ViewUtils; + + +/** + * Location row + * @author Andrea Cappelli + */ +public class LocationRow extends RelativeLayout { + + private ImageView mImageView; + private TextView mNameTextView; + private TextView mAddressTextView; + private boolean mNeedDivider; + + private Paint mLinePaint; + + public LocationRow(Context context) { + this(context, null); + } + + public LocationRow(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public LocationRow(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public LocationRow(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init() { + inflate(getContext(), R.layout.location_row, this); + + mImageView = findViewById(R.id.image); + mNameTextView = findViewById(R.id.name_text); + mAddressTextView = findViewById(R.id.address_text); + + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(ViewUtils.dp(getContext(), 56) + (mNeedDivider ? 1 : 0), MeasureSpec.EXACTLY)); + } + + public void setLocation(String iconUrl, String nameText, String addressText, boolean needDivider) { + mNeedDivider = needDivider; + Glide.with(getContext()).load(iconUrl).into(mImageView); + mNameTextView.setText(nameText); + mAddressTextView.setText(addressText); + } + + @Override + protected void onDraw(Canvas canvas) { + if (mNeedDivider) { + if (mLinePaint == null) { + mLinePaint = new Paint(); + mLinePaint.setStrokeWidth(1); + mLinePaint.setColor(ContextCompat.getColor(getContext(), R.color.location_item_divider)); + } + canvas.drawLine(ViewUtils.dp(getContext(), 72), getHeight() - 1, getWidth(), getHeight() - 1, mLinePaint); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/position/PositionAbstractFragment.java b/app/src/main/java/org/kontalk/ui/position/PositionAbstractFragment.java new file mode 100644 index 000000000..074887c0b --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/PositionAbstractFragment.java @@ -0,0 +1,350 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import com.amulyakhare.textdrawable.TextDrawable; +import com.car2go.maps.AnyMap; +import com.car2go.maps.CameraUpdateFactory; +import com.car2go.maps.MapContainerView; +import com.car2go.maps.OnMapReadyCallback; +import com.car2go.maps.model.BitmapDescriptor; +import com.car2go.maps.model.LatLng; +import com.car2go.maps.model.MarkerOptions; + +import android.content.Context; +import android.content.Intent; +import android.location.Location; +import android.net.Uri; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import androidx.fragment.app.Fragment; +import androidx.core.content.ContextCompat; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import de.hdodenhof.circleimageview.CircleImageView; +import pub.devrel.easypermissions.AfterPermissionGranted; +import pub.devrel.easypermissions.EasyPermissions; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.ui.PositionActivity; +import org.kontalk.ui.position.model.Position; +import org.kontalk.util.Permissions; + + +/** + * Base position fragment + * + * @author Andrea Cappelli + * @author Daniele Ricci + */ +public abstract class PositionAbstractFragment extends Fragment + implements OnMapReadyCallback { + final static String TAG = PositionAbstractFragment.class.getSimpleName(); + + final static int DEFAULT_ZOOM = 12; + + private MapContainerView mMapView; + protected AnyMap mMap; + + protected Position mPosition; + protected Location mUserLocation; + protected Location mMyLocation; + + private FloatingActionButton mFabMyLocation; + private FloatingActionButton mFabRoute; + + private View mBottomView; + private CircleImageView mUserAvatar; + private TextView mUserName; + private TextView mDistance; + + private boolean mPermissionAsked; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + protected abstract View onInflateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState); + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = onInflateView(inflater, container, savedInstanceState); + + mMapView = view.findViewById(R.id.mapView); + + mBottomView = view.findViewById(R.id.bottom_view); + mUserAvatar = view.findViewById(R.id.avatar); + mUserName = view.findViewById(R.id.user_text_name); + mDistance = view.findViewById(R.id.user_text_position); + + mFabRoute = view.findViewById(R.id.fab_route); + + mFabMyLocation = view.findViewById(R.id.fab_my_position); + + mMapView.onCreate(savedInstanceState); + + return view; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mMyLocation = new Location("network"); + + mMapView.getMapAsync(this); + + String userId = getArguments().getString(PositionActivity.EXTRA_USERID); + + if (userId != null) { + Contact contact = Contact.findByUserId(getContext(), userId); + mUserAvatar.setImageDrawable(contact.getAvatar(getContext())); + mUserName.setText(contact.getDisplayName()); + } + else { + mUserName.setText(R.string.your_position); + TextDrawable drawable = TextDrawable.builder() + .buildRound("Y", ContextCompat.getColor(getContext(), R.color.app_primary)); + mUserAvatar.setImageDrawable(drawable); + } + + mPosition = (Position) getArguments().getSerializable(PositionActivity.EXTRA_USERPOSITION); + + mUserLocation = new Location("network"); + mUserLocation.setLatitude(mPosition.getLatitude()); + mUserLocation.setLongitude(mPosition.getLongitude()); + + mBottomView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (mUserLocation != null) { + LatLng latLng = new LatLng(mUserLocation.getLatitude(), mUserLocation.getLongitude()); + if (mMap != null) { + mMap.animateCamera(getCameraUpdateFactory().newLatLngZoom(latLng, DEFAULT_ZOOM)); + } + } + } + }); + + mFabMyLocation.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (mMyLocation != null && mMap != null && isLocationEnabled()) { + zoomToMyLocation(); + } + } + }); + + mFabRoute.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (mMyLocation != null) { + try { + double lat = mPosition.getLatitude(); + double lon = mPosition.getLongitude(); + startActivity(new Intent(Intent.ACTION_VIEW, + Uri.parse("geo:" + lat + "," + lon + "?q=" + lat + "," + lon))); + } + catch (Exception e) { + Log.e(TAG, e.getLocalizedMessage()); + } + } + } + }); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this); + } + + @AfterPermissionGranted(Permissions.RC_LOCATION) + void onLocationAccess() { + if (mMap != null) + onMapReady(mMap); + requestLocation(); + } + + /** Child classes should override this to begin location requests. */ + protected abstract void requestLocation(); + + protected abstract boolean isLocationEnabled(); + + protected void zoomToMyLocation() { + mMap.animateCamera(getCameraUpdateFactory() + .newLatLngZoom(new LatLng(mMyLocation.getLatitude(), mMyLocation.getLongitude()), DEFAULT_ZOOM)); + } + + @Override + public void onPause() { + super.onPause(); + mMapView.onPause(); + } + + @Override + public void onResume() { + super.onResume(); + mMapView.onResume(); + askPermissions(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mMapView.onDestroy(); + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + mMapView.onLowMemory(); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + mMapView.onSaveInstanceState(outState); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.position_menu, menu); + + super.onCreateOptionsMenu(menu, inflater); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (super.onOptionsItemSelected(item)) + return true; + + switch (item.getItemId()) { + case R.id.menu_share: { + try { + double lat = mPosition.getLatitude(); + double lon = mPosition.getLongitude(); + startActivity(new Intent(Intent.ACTION_VIEW, + Uri.parse("geo:" + lat + "," + lon + "?q=" + lat + "," + lon))); + } + catch (Exception e) { + Log.e(TAG, e.getLocalizedMessage()); + } + break; + } + case R.id.map: + if (!item.isChecked()) { + item.setChecked(true); + mMap.setMapType(AnyMap.Type.NORMAL); + } + return true; + + case R.id.satellite: + if (!item.isChecked()) { + item.setChecked(true); + mMap.setMapType(AnyMap.Type.SATELLITE); + } + return true; + } + + return false; + } + + //@Override + public void onLocationChanged(Location location) { + if (mMap != null) { + positionMarker(location); + } + } + + protected abstract CameraUpdateFactory getCameraUpdateFactory(); + + protected abstract BitmapDescriptor createMarkerBitmap(); + + @Override + public void onMapReady(final AnyMap anyMap) { + mMap = anyMap; + anyMap.setMyLocationEnabled(hasMyLocationPermission()); + anyMap.getUiSettings().setMyLocationButtonEnabled(false); + anyMap.getUiSettings().setMapToolbarEnabled(false); + anyMap.getUiSettings().setCompassEnabled(false); + + if (mPosition != null) { + LatLng latLng = new LatLng(mUserLocation.getLatitude(), mUserLocation.getLongitude()); + try { + mMap.addMarker(new MarkerOptions().position(latLng).anchor(0.5f, 1).icon(createMarkerBitmap())); + } + catch (Exception e) { + Log.e(TAG, "error adding position marker", e); + } + mMap.moveCamera(getCameraUpdateFactory().newLatLngZoom(latLng, DEFAULT_ZOOM)); + } + } + + private void positionMarker(Location location) { + if (location == null) + return; + + Context context = getContext(); + if (context == null) + return; + + mMyLocation = new Location(location); + + if (mUserLocation != null && mDistance != null) { + float distance = location.distanceTo(mUserLocation); + if (distance < 1000) { + mDistance.setText(context.getString(R.string.meters_away, (int) (distance))); + } + else { + mDistance.setText(context.getString(R.string.kilometers_away, distance / 1000.0f)); + } + } + } + + private boolean hasMyLocationPermission() { + return Permissions.canAccessSomeLocation(getContext()); + } + + private void askPermissions() { + if (!Permissions.canAccessLocation(getContext())) { + if (!mPermissionAsked) { + Permissions.requestLocation(this, getString(R.string.err_location_denied)); + mPermissionAsked = true; + } + } + else { + requestLocation(); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/position/PositionOsmFragment.java b/app/src/main/java/org/kontalk/ui/position/PositionOsmFragment.java new file mode 100644 index 000000000..30adf61a7 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/PositionOsmFragment.java @@ -0,0 +1,171 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.car2go.maps.CameraUpdateFactory; +import com.car2go.maps.model.BitmapDescriptor; +import com.car2go.maps.osm.BitmapDescriptorFactory; +import com.car2go.maps.osm.MapsConfiguration; + +import android.content.Context; +import android.content.Intent; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; +import android.provider.Settings; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import org.kontalk.R; + + +/** + * Position Open Street Maps fragment + * + * @author Andrea Cappelli + */ +public class PositionOsmFragment extends PositionAbstractFragment implements LocationListener { + + private LocationManager mLocationManager; + + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mLocationManager = (LocationManager) getActivity().getSystemService(Context.LOCATION_SERVICE); + } + + @Override + protected View onInflateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_position_osm, container, false); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + + MapsConfiguration.getInstance().initialize(getContext()); + + return view; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + // OSM doesn't have satellite + menu.removeItem(R.id.satellite); + } + + @Override + protected CameraUpdateFactory getCameraUpdateFactory() { + return com.car2go.maps.osm.CameraUpdateFactory.getInstance(); + } + + @Override + protected BitmapDescriptor createMarkerBitmap() { + // TODO OSM should have its own pin icon + return BitmapDescriptorFactory.getInstance().fromResource(R.drawable.ic_map_pin_google); + } + + @Override + protected boolean isLocationEnabled() { + if (!mLocationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) && + !mLocationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + needLocation(); + return false; + } + return true; + } + + private void needLocation() { + new MaterialDialog.Builder(getContext()) + .content(R.string.msg_location_disabled) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + Intent locationSettings = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS); + startActivity(locationSettings); + } + }) + .show(); + } + + @Override + public void onPause() { + super.onPause(); + mLocationManager.removeUpdates(this); + } + + @Override + public void requestLocation() { + boolean hasProvider = false, permissionDenied = false; + try { + mLocationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, this); + hasProvider = true; + } + catch (IllegalArgumentException e) { + // no gps available + } + catch (SecurityException e) { + permissionDenied = true; + } + try { + mLocationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, this); + hasProvider = true; + } + catch (IllegalArgumentException e) { + // no network location available + } + catch (SecurityException e) { + permissionDenied = true; + } + + if (permissionDenied) { + Toast.makeText(getContext(), R.string.err_location_permission, + Toast.LENGTH_LONG).show(); + } + else if (!hasProvider) { + Toast.makeText(getContext(), R.string.err_location_no_providers, + Toast.LENGTH_LONG).show(); + } + } + + @Override + public void onStatusChanged(String s, int i, Bundle bundle) { + } + + @Override + public void onProviderEnabled(String s) { + } + + @Override + public void onProviderDisabled(String s) { + } + +} + diff --git a/app/src/main/java/org/kontalk/ui/position/SendLocationRow.java b/app/src/main/java/org/kontalk/ui/position/SendLocationRow.java new file mode 100644 index 000000000..b784a4e77 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/SendLocationRow.java @@ -0,0 +1,82 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.kontalk.R; +import org.kontalk.util.ViewUtils; + + +/** + * Send location row + * @author Andrea Cappelli + */ +public class SendLocationRow extends RelativeLayout { + + private TextView mAccurateTextView; + private TextView mTitleTextView; + private ImageView mImageView; + + public SendLocationRow(Context context) { + this(context, null); + } + + public SendLocationRow(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public SendLocationRow(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + init(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public SendLocationRow(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + init(); + } + + private void init() { + inflate(getContext(), R.layout.send_location_row, this); + + mImageView = findViewById(R.id.image); + mTitleTextView = findViewById(R.id.title); + mAccurateTextView = findViewById(R.id.accurate); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(ViewUtils.dp(getContext(), 66), MeasureSpec.EXACTLY)); + } + + public void setText(String title, String accurateText) { + mTitleTextView.setText(title); + mAccurateTextView.setText(accurateText); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/position/SendPositionAbstractFragment.java b/app/src/main/java/org/kontalk/ui/position/SendPositionAbstractFragment.java new file mode 100644 index 000000000..3b8a1dfb1 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/SendPositionAbstractFragment.java @@ -0,0 +1,561 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.location.Location; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.appcompat.widget.SearchView; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import com.car2go.maps.AnyMap; +import com.car2go.maps.CameraUpdateFactory; +import com.car2go.maps.MapContainerView; +import com.car2go.maps.OnInterceptTouchEvent; +import com.car2go.maps.OnMapReadyCallback; +import com.car2go.maps.model.LatLng; + +import org.kontalk.R; +import org.kontalk.position.PositionManager; +import org.kontalk.ui.position.adapter.IPlacesAdapter; +import org.kontalk.ui.position.adapter.ISearchPlacesAdapter; +import org.kontalk.ui.position.model.Position; +import org.kontalk.util.Permissions; +import org.kontalk.util.RecyclerItemClickListener; +import org.kontalk.util.ViewUtils; + +import java.util.Locale; + +import pub.devrel.easypermissions.AfterPermissionGranted; +import pub.devrel.easypermissions.EasyPermissions; + + +/** + * Base send position fragment + * + * @author Andrea Cappelli + * @author Daniele Ricci + */ +public abstract class SendPositionAbstractFragment extends Fragment + implements OnMapReadyCallback { + + private static final String STATE_MYLOCATION = SendPositionAbstractFragment.class + .getSimpleName() + "myLocation"; + + FrameLayout mMapViewClip; + private MapContainerView mMapView; + AnyMap mMap; + + Location mUserLocation; + Location mMyLocation; + + ImageView mMapPin; + ImageView mPinX; + FloatingActionButton mFabMyLocation; + + RecyclerView mRecyclerView; + RecyclerView mSearchRecyclerView; + LinearLayoutManager mRecyclerViewLayoutManager; + RecyclerView.Adapter mSearchAdapter; + RecyclerView.Adapter mAdapter; + + private SendLocationRow mSendLocationRow; + + boolean mUserLocationMoved; + + private int mOverScrollHeight; + int mMarkerTop; + + private boolean mPermissionAsked; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + protected abstract View onInflateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState); + + @SuppressLint("WrongViewCast") + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = onInflateView(inflater, container, savedInstanceState); + + if (isPlacesEnabled()) { + mMapViewClip = view.findViewById(R.id.mapview_clip); + mRecyclerView = view.findViewById(R.id.recyclerView); + mSearchRecyclerView = view.findViewById(R.id.search_recyclerView); + } + else { + mSendLocationRow = view.findViewById(R.id.send_location); + } + + mMapView = view.findViewById(R.id.mapView); + + mMapPin = view.findViewById(R.id.map_pin); + mPinX = view.findViewById(R.id.pin_x); + mPinX.setAlpha(0.0f); + + mFabMyLocation = view.findViewById(R.id.fab_my_position); + + mMapView.onCreate(savedInstanceState); + + return view; + } + + protected abstract boolean isPlacesEnabled(); + + protected abstract void onMapTouchEvent(MotionEvent ev); + + protected abstract void onFabClicked(Location location); + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mUserLocation = new Location("network"); + + if (savedInstanceState != null) + mMyLocation = savedInstanceState.getParcelable(STATE_MYLOCATION); + else + mMyLocation = new Location("network"); + + mMapView.getMapAsync(this); + + mMapView.setOnInterceptTouchEventListener(new OnInterceptTouchEvent() { + @Override + public void onInterceptTouchEvent(MotionEvent ev) { + onMapTouchEvent(ev); + } + }); + + mFabMyLocation.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (mMyLocation != null && mMap != null && isLocationEnabled()) { + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.setDuration(200); + animatorSet.play(ObjectAnimator.ofFloat(mFabMyLocation, "alpha", 0.0f)); + animatorSet.start(); + onFabClicked(mMyLocation); + if (mAdapter != null) { + ((IPlacesAdapter) mAdapter).setCustomLocation(null); + } + mUserLocationMoved = false; + mMap.animateCamera(getCameraUpdateFactory().newLatLngZoom(new LatLng(mMyLocation.getLatitude(), mMyLocation.getLongitude()), PositionAbstractFragment.DEFAULT_ZOOM)); + } + } + }); + + if (isPlacesEnabled()) { + mAdapter = PositionManager.createPlacesAdapter(getContext()); + mSearchAdapter = PositionManager.createSearchPlacesAdapter(getContext()); + + mRecyclerViewLayoutManager = new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false); + LinearLayoutManager searchRecyclerViewLayoutManager = new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false); + + mRecyclerView.setLayoutManager(mRecyclerViewLayoutManager); + mRecyclerView.setAdapter(mAdapter); + + mSearchRecyclerView.setLayoutManager(searchRecyclerViewLayoutManager); + mSearchRecyclerView.setAdapter(mSearchAdapter); + + mRecyclerView.addOnItemTouchListener(new RecyclerItemClickListener(getContext(), new RecyclerItemClickListener.OnItemClickListener() { + @Override + public void onItemClick(View view, int position) { + if (position == 1) { + Position p = new Position(mUserLocation.getLatitude(), mUserLocation.getLongitude()); + Intent intent = new Intent(); + intent.putExtra("position", p); + getActivity().setResult(Activity.RESULT_OK, intent); + getActivity().finish(); + } + else { + Position item = ((IPlacesAdapter) mAdapter).getVenuesItem(position); + if (item != null) { + Intent intent = new Intent(); + intent.putExtra("position", item); + getActivity().setResult(Activity.RESULT_OK, intent); + getActivity().finish(); + } + } + } + })); + + mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + if (mAdapter.getItemCount() == 0) { + return; + } + int position = mRecyclerViewLayoutManager.findFirstVisibleItemPosition(); + if (position == RecyclerView.NO_POSITION) { + return; + } + updateClipView(position); + } + }); + + mSearchRecyclerView.addOnItemTouchListener(new RecyclerItemClickListener(getContext(), new RecyclerItemClickListener.OnItemClickListener() { + @Override + public void onItemClick(View view, int position) { + Position item = ((ISearchPlacesAdapter) mSearchAdapter).getVenuesItem(position); + if (item != null) { + Intent intent = new Intent(); + intent.putExtra("position", item); + getActivity().setResult(Activity.RESULT_OK, intent); + getActivity().finish(); + } + } + })); + + mFabMyLocation.setAlpha(0.0f); + } + else { + mSendLocationRow.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + Position position = new Position(mUserLocation.getLatitude(), mUserLocation.getLongitude()); + Intent intent = new Intent(); + intent.putExtra("position", position); + getActivity().setResult(Activity.RESULT_OK, intent); + getActivity().finish(); + } + }); + } + } + + protected abstract boolean isLocationEnabled(); + + @Override + public void onPause() { + super.onPause(); + mMapView.onPause(); + } + + @Override + public void onResume() { + super.onResume(); + mMapView.onResume(); + + if (isPlacesEnabled()) { + if (getView().getMeasuredHeight() == 0) + getView().post(new Runnable() { + @Override + public void run() { + prepareLayout(true); + } + }); + else + prepareLayout(true); + } + + // my location will be saved in state, so gps fix time will be zero + if (mMyLocation.getTime() <= 0) { + askPermissions(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + mMapView.onDestroy(); + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + mMapView.onLowMemory(); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + mMapView.onSaveInstanceState(outState); + outState.putParcelable(STATE_MYLOCATION, mMyLocation); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.send_position_menu, menu); + + if (isPlacesEnabled()) { + MenuItem searchItem = menu.findItem(R.id.menu_search); + searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { + @Override + public boolean onMenuItemActionExpand(MenuItem item) { + mRecyclerView.setVisibility(View.GONE); + mMapViewClip.setVisibility(View.GONE); + mSearchRecyclerView.setVisibility(View.VISIBLE); + return true; + } + + @Override + public boolean onMenuItemActionCollapse(MenuItem item) { + mRecyclerView.setVisibility(View.VISIBLE); + mMapViewClip.setVisibility(View.VISIBLE); + ((ISearchPlacesAdapter) mSearchAdapter).searchPlacesWithQuery(null, null); + mSearchRecyclerView.setVisibility(View.GONE); + return true; + } + }); + SearchView searchView = (SearchView) searchItem.getActionView(); + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + ((ISearchPlacesAdapter) mSearchAdapter).searchPlacesWithQuery(query, mUserLocation); + return false; + } + + @Override + public boolean onQueryTextChange(String newText) { + return false; + } + }); + } + else { + menu.removeItem(R.id.menu_search); + } + + super.onCreateOptionsMenu(menu, inflater); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (super.onOptionsItemSelected(item)) + return true; + + switch (item.getItemId()) { + case R.id.map: + if (!item.isChecked()) { + item.setChecked(true); + mMap.setMapType(AnyMap.Type.NORMAL); + } + return true; + + case R.id.satellite: + if (!item.isChecked()) { + item.setChecked(true); + mMap.setMapType(AnyMap.Type.SATELLITE); + } + return true; + } + + return false; + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this); + } + + @AfterPermissionGranted(Permissions.RC_LOCATION) + void onLocationAccess() { + if (mMap != null) + onMapReady(mMap); + requestLocation(); + } + + /** Child classes should override this to begin location requests. */ + protected abstract void requestLocation(); + + @Override + public void onMapReady(final AnyMap anyMap) { + mMap = anyMap; + try { + anyMap.setMyLocationEnabled(true); + } + catch (SecurityException e) { + // will enable my location later + } + anyMap.getUiSettings().setMyLocationButtonEnabled(false); + anyMap.getUiSettings().setMapToolbarEnabled(false); + anyMap.getUiSettings().setCompassEnabled(false); + } + + protected void setGpsPosition(Location location) { + if (isPlacesEnabled()) { + ((IPlacesAdapter) mAdapter).searchPlaces(mMyLocation); + ((IPlacesAdapter) mAdapter).setGpsPosition(mMyLocation); + } + else if (!mUserLocationMoved) { + mSendLocationRow.setText(getString(R.string.send_location), + getString(R.string.accurate_to, String.valueOf((int) location.getAccuracy()))); + } + } + + protected void setCustomLocation(Location location) { + if (isPlacesEnabled()) { + ((IPlacesAdapter) mAdapter).setCustomLocation(location); + } + else { + mSendLocationRow.setText(getString(R.string.send_selected_location), + String.format(Locale.US, "(%f, %f)", location.getLatitude(), location.getLongitude())); + } + } + + protected abstract CameraUpdateFactory getCameraUpdateFactory(); + + protected void positionMarker(Location location) { + if (location == null) { + return; + } + mMyLocation = new Location(location); + + setGpsPosition(mMyLocation); + + if (!mUserLocationMoved) { + mUserLocation = new Location(location); + + LatLng latLng = new LatLng(location.getLatitude(), location.getLongitude()); + + mMap.moveCamera(getCameraUpdateFactory().newLatLngZoom(latLng, PositionAbstractFragment.DEFAULT_ZOOM)); + } + } + + //@Override + public void onLocationChanged(Location location) { + if (mMap != null) { + positionMarker(location); + } + } + + // used only for places + void prepareLayout(final boolean resume) { + if (mRecyclerView != null) { + int viewHeight = getView().getMeasuredHeight(); + if (viewHeight == 0) { + return; + } + mOverScrollHeight = viewHeight - ViewUtils.dp(getContext(), 66); + + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) mMapViewClip.getLayoutParams(); + layoutParams.height = mOverScrollHeight; + mMapViewClip.setLayoutParams(layoutParams); + + ((IPlacesAdapter) mAdapter).setOverScrollHeight(mOverScrollHeight); + layoutParams = (FrameLayout.LayoutParams) mMapView.getLayoutParams(); + if (layoutParams != null) { + layoutParams.height = mOverScrollHeight + ViewUtils.dp(getContext(), 10); + if (mMap != null) { + mMap.setPadding(0, 0, 0, ViewUtils.dp(getContext(), 10)); + } + mMapView.setLayoutParams(layoutParams); + } + mAdapter.notifyDataSetChanged(); + + if (resume) { + mRecyclerViewLayoutManager.scrollToPositionWithOffset(0, -(int) (ViewUtils.dp(getContext(), 56) * 2.5f + ViewUtils.dp(getContext(), 36 + 66))); + updateClipView(mRecyclerViewLayoutManager.findFirstVisibleItemPosition()); + mRecyclerView.post(new Runnable() { + @Override + public void run() { + mRecyclerViewLayoutManager.scrollToPositionWithOffset(0, -(int) (ViewUtils.dp(getContext(), 56) * 2.5f + ViewUtils.dp(getContext(), 36 + 66))); + updateClipView(mRecyclerViewLayoutManager.findFirstVisibleItemPosition()); + } + }); + } + else { + updateClipView(mRecyclerViewLayoutManager.findFirstVisibleItemPosition()); + } + + } + } + + // used only for places + void updateClipView(int firstVisibleItem) { + if (firstVisibleItem == RecyclerView.NO_POSITION) { + return; + } + int height = 0; + int top = 0; + View child = mRecyclerView.getChildAt(0); + if (child != null) { + if (firstVisibleItem == 0) { + top = child.getTop(); + height = mOverScrollHeight + (top < 0 ? top : 0); + } + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) mMapViewClip.getLayoutParams(); + if (layoutParams != null) { + if (height <= 0) { + if (mMapView.getVisibility() == View.VISIBLE) { + mMapView.setVisibility(View.INVISIBLE); + mMapViewClip.setVisibility(View.INVISIBLE); + } + } + else { + if (mMapView.getVisibility() == View.INVISIBLE) { + mMapView.setVisibility(View.VISIBLE); + mMapViewClip.setVisibility(View.VISIBLE); + } + } + + mMapViewClip.setTranslationY(Math.min(0, top)); + mMapView.setTranslationY(Math.max(0, -top / 2)); + mMapPin.setTranslationY(mMarkerTop = -top - ViewUtils.dp(getContext(), 42) + height / 2); + mPinX.setTranslationY(-top - ViewUtils.dp(getContext(), 7) + height / 2); + + layoutParams = (FrameLayout.LayoutParams) mMapView.getLayoutParams(); + if (layoutParams != null && layoutParams.height != mOverScrollHeight + ViewUtils.dp(getContext(), 10)) { + layoutParams.height = mOverScrollHeight + ViewUtils.dp(getContext(), 10); + if (mMap != null) { + mMap.setPadding(0, 0, 0, ViewUtils.dp(getContext(), 10)); + } + mMapView.setLayoutParams(layoutParams); + } + } + } + } + + private void askPermissions() { + if (!Permissions.canAccessLocation(getContext())) { + if (!mPermissionAsked) { + Permissions.requestLocation(this, getString(R.string.err_location_denied)); + mPermissionAsked = true; + } + } + else { + if (mMap != null) + mMap.setMyLocationEnabled(true); + requestLocation(); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/position/SendPositionOsmFragment.java b/app/src/main/java/org/kontalk/ui/position/SendPositionOsmFragment.java new file mode 100644 index 000000000..00a37deba --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/SendPositionOsmFragment.java @@ -0,0 +1,258 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.car2go.maps.AnyMap; +import com.car2go.maps.model.LatLng; +import com.car2go.maps.osm.CameraUpdateFactory; +import com.car2go.maps.osm.MapsConfiguration; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; +import android.provider.Settings; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.util.Permissions; +import org.kontalk.util.ViewUtils; + + +/** + * Send position OpenStreetMaps Fragment + * + * @author Andrea Cappelli + */ +public class SendPositionOsmFragment extends SendPositionAbstractFragment implements LocationListener { + + private final static String TAG = SendPositionOsmFragment.class.getSimpleName(); + + private LocationManager mLocationManager; + + private AnimatorSet mAnimatorSet; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mLocationManager = (LocationManager) getActivity().getSystemService(Context.LOCATION_SERVICE); + } + + @Override + protected View onInflateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_send_position_osm, container, false); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + + MapsConfiguration.getInstance().initialize(getContext()); + + return view; + } + + @Override + protected boolean isPlacesEnabled() { + return false; + } + + @Override + protected void onFabClicked(Location location) { + setGpsPosition(location); + } + + @Override + protected CameraUpdateFactory getCameraUpdateFactory() { + return com.car2go.maps.osm.CameraUpdateFactory.getInstance(); + } + + @Override + protected void onMapTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + if (mAnimatorSet != null) { + mAnimatorSet.cancel(); + } + mAnimatorSet = new AnimatorSet(); + mAnimatorSet.setDuration(200); + mAnimatorSet.playTogether( + ObjectAnimator.ofFloat(mMapPin, "translationY", -ViewUtils.dp(getContext(), 10)), + ObjectAnimator.ofFloat(mPinX, "alpha", 1.0f)); + mAnimatorSet.start(); + } + else if (ev.getAction() == MotionEvent.ACTION_UP) { + if (mAnimatorSet != null) { + mAnimatorSet.cancel(); + } + mAnimatorSet = new AnimatorSet(); + mAnimatorSet.setDuration(200); + mAnimatorSet.playTogether( + ObjectAnimator.ofFloat(mMapPin, "translationY", 0), + ObjectAnimator.ofFloat(mPinX, "alpha", 0.0f)); + mAnimatorSet.start(); + } + if (ev.getAction() == MotionEvent.ACTION_MOVE) { + if (!mUserLocationMoved) { + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.setDuration(200); + animatorSet.play(ObjectAnimator.ofFloat(mFabMyLocation, "alpha", 1.0f)); + animatorSet.start(); + mUserLocationMoved = true; + } + + if (mMap != null && mMyLocation != null) { + mUserLocation.setLatitude(mMap.getCameraPosition().target.latitude); + mUserLocation.setLongitude(mMap.getCameraPosition().target.longitude); + } + + setCustomLocation(mUserLocation); + } + } + + protected boolean isLocationEnabled() { + if (!Permissions.canAccessLocation(getContext())) + return false; + + if (!mLocationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) && + !mLocationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + needLocation(); + return false; + } + return true; + } + + private void needLocation() { + new MaterialDialog.Builder(getContext()) + .content(R.string.msg_location_disabled) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + Intent locationSettings = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS); + startActivity(locationSettings); + } + }) + .show(); + } + + @Override + public void onPause() { + super.onPause(); + mLocationManager.removeUpdates(this); + } + + @Override + protected void requestLocation() { + // this will trigger a dialog to ask for location + isLocationEnabled(); + + boolean hasProvider = false, permissionDenied = false; + try { + mLocationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, this); + hasProvider = true; + } + catch (IllegalArgumentException e) { + // no gps available + } + catch (SecurityException e) { + permissionDenied = true; + } + try { + mLocationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, this); + hasProvider = true; + } + catch (IllegalArgumentException e) { + // no network location available + } + catch (SecurityException e) { + permissionDenied = true; + } + + if (permissionDenied) { + Toast.makeText(getContext(), R.string.err_location_permission, + Toast.LENGTH_LONG).show(); + } + else if (!hasProvider) { + Toast.makeText(getContext(), R.string.err_location_no_providers, + Toast.LENGTH_LONG).show(); + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + // OSM doesn't have satellite + menu.removeItem(R.id.satellite); + } + + @Override + public void onStatusChanged(String s, int i, Bundle bundle) { + + } + + @Override + public void onProviderEnabled(String s) { + + } + + @Override + public void onProviderDisabled(String s) { + + } + + @Override + public void onMapReady(final AnyMap anyMap) { + super.onMapReady(anyMap); + + if (Permissions.canAccessFineLocation(getContext())) { + @SuppressLint("MissingPermission") + Location lastLocation = mLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); + // Note that this can be NULL if last location isn't already known. + if (lastLocation != null) { + // Print current location if not null + Log.d(TAG, "last location: " + lastLocation.toString()); + LatLng latLng = new LatLng(lastLocation.getLatitude(), lastLocation.getLongitude()); + mMyLocation.setLatitude(lastLocation.getLatitude()); + mMyLocation.setLongitude(lastLocation.getLongitude()); + mMyLocation.setTime(lastLocation.getTime()); + if (mMap != null) + mMap.animateCamera(CameraUpdateFactory.getInstance().newLatLngZoom(latLng, 16)); + } + } + } +} diff --git a/app/src/main/java/org/kontalk/ui/position/adapter/IPlacesAdapter.java b/app/src/main/java/org/kontalk/ui/position/adapter/IPlacesAdapter.java new file mode 100644 index 000000000..be815cc58 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/adapter/IPlacesAdapter.java @@ -0,0 +1,42 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.adapter; + +import android.location.Location; + +import org.kontalk.ui.position.model.Position; + + +/** + * Very dummy interface extracted from PlacesAdapter. + * @author Daniele Ricci + */ +public interface IPlacesAdapter { + + Position getVenuesItem(int i); + + void setOverScrollHeight(int value); + + void setGpsPosition(Location location); + + void setCustomLocation(Location location); + + void searchPlaces(Location location); + +} diff --git a/app/src/main/java/org/kontalk/ui/position/adapter/ISearchPlacesAdapter.java b/app/src/main/java/org/kontalk/ui/position/adapter/ISearchPlacesAdapter.java new file mode 100644 index 000000000..3fbfcd63c --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/adapter/ISearchPlacesAdapter.java @@ -0,0 +1,36 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.adapter; + +import android.location.Location; + +import org.kontalk.ui.position.model.Position; + + +/** + * Very dummy interface extracted from SearchPlacesAdapter. + * @author Daniele Ricci + */ +public interface ISearchPlacesAdapter { + + Position getVenuesItem(int i); + + void searchPlacesWithQuery(String query, Location location); + +} diff --git a/app/src/main/java/org/kontalk/ui/position/model/Position.java b/app/src/main/java/org/kontalk/ui/position/model/Position.java new file mode 100644 index 000000000..2ba900f12 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/position/model/Position.java @@ -0,0 +1,78 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.position.model; + +import java.io.Serializable; + + +/** + * @author Andrea Cappelli + */ +public class Position implements Serializable { + + private double mLatitude; + private double mLongitude; + private String mName; + private String mAddress; + + public Position(double latitude, double longitude) { + mLatitude = latitude; + mLongitude = longitude; + } + + public Position(double latitude, double longitude, String name, String address) { + mLatitude = latitude; + mLongitude = longitude; + mName = name; + mAddress = address; + } + + public double getLatitude() { + return mLatitude; + } + + public void setLatitude(double latitude) { + mLatitude = latitude; + } + + public double getLongitude() { + return mLongitude; + } + + public void setLongitude(double longitude) { + mLongitude = longitude; + } + + public String getName() { + return mName; + } + + public void setName(String name) { + mName = name; + } + + public String getAddress() { + return mAddress; + } + + public void setAddress(String address) { + mAddress = address; + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/AccountFragment.java b/app/src/main/java/org/kontalk/ui/prefs/AccountFragment.java new file mode 100644 index 000000000..72a75ac1a --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/AccountFragment.java @@ -0,0 +1,666 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import java.io.FileNotFoundException; +import java.io.OutputStream; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.afollestad.materialdialogs.folderselector.FolderChooserDialog; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.jivesoftware.smack.util.SHA1; + +import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; +import android.app.Activity; +import android.app.Dialog; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.preference.Preference; + +import android.text.InputType; +import android.widget.Toast; + +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.authenticator.Authenticator; +import org.kontalk.authenticator.MyAccount; +import org.kontalk.client.ServerList; +import org.kontalk.crypto.PersonalKey; +import org.kontalk.crypto.PersonalKeyPack; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.service.ServerListUpdater; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.service.msgcenter.event.ConnectedEvent; +import org.kontalk.service.msgcenter.event.PrivateKeyUploadedEvent; +import org.kontalk.service.msgcenter.event.UploadPrivateKeyRequest; +import org.kontalk.service.registration.RegistrationService; +import org.kontalk.ui.LockedDialog; +import org.kontalk.ui.PasswordInputDialog; +import org.kontalk.ui.RegisterDeviceActivity; +import org.kontalk.util.MediaStorage; +import org.kontalk.util.Preferences; +import org.kontalk.util.SystemUtils; + + +/** + * Account settings fragment. + */ +public class AccountFragment extends RootPreferenceFragment { + static final String TAG = Kontalk.TAG; + + private static final int REQUEST_CREATE_KEYPACK = Activity.RESULT_FIRST_USER + 3; + + // this is used after when exiting to SAF for exporting + private String mPassphrase; + + // created on demand + private MaterialDialog mUploadPrivateKeyProgress; + + private ServerListUpdater mServerlistUpdater; + + private EventBus mServiceBus = MessageCenterService.bus(); + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + if (savedInstanceState != null) { + mPassphrase = savedInstanceState.getString("passphrase"); + } + + // Load the preferences from an XML resource + addPreferencesFromResource(R.xml.preferences_account); + + // load account information + MyAccount account = Kontalk.get().getDefaultAccount(); + String displayName = account.getDisplayName(); + String phoneNumber = account.getPhoneNumber(); + final Preference accountInfo = findPreference("pref_account_info"); + accountInfo.setTitle(displayName); + accountInfo.setSummary(RegistrationService.formatForDisplay(phoneNumber)); + + // change passphrase + final Preference changePassphrase = findPreference("pref_change_passphrase"); + changePassphrase.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + + if (Authenticator.isUserPassphrase(getActivity())) { + + OnPassphraseRequestListener action = new OnPassphraseRequestListener() { + public void onValidPassphrase(String passphrase) { + askNewPassphrase(); + } + + public void onInvalidPassphrase() { + new MaterialDialog.Builder(getActivity()) + .content(R.string.err_password_invalid) + .positiveText(android.R.string.ok) + .show(); + } + }; + askCurrentPassphrase(action); + } + + else { + askNewPassphrase(); + } + + + return true; + } + }); + + // regenerate key pair + final Preference regenKeyPair = findPreference("pref_regenerate_keypair"); + regenKeyPair.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + new MaterialDialog.Builder(getActivity()) + .title(R.string.pref_regenerate_keypair) + .content(R.string.pref_regenerate_keypair_confirm) + .negativeText(android.R.string.cancel) + .positiveText(android.R.string.ok) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + OnPassphraseChangedListener action = new OnPassphraseChangedListener() { + @Override + public void onPassphraseChanged(String passphrase) { + Context ctx = getContext(); + Toast.makeText(ctx, R.string.msg_generating_keypair, + Toast.LENGTH_LONG).show(); + + // FIXME should wait for ConnectedEvent + MessageCenterService.regenerateKeyPair(ctx.getApplicationContext(), passphrase); + } + }; + + if (Authenticator.isUserPassphrase(getActivity())) { + // passphrase was set by the user before + // ask for a new one + askNewPassphrase(action); + } + else { + action.onPassphraseChanged(null); + } + } + }) + .show(); + + return true; + } + }); + + // export key pair + final Preference exportKeyPair = findPreference("pref_export_keypair"); + exportKeyPair.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + + // TODO check for external storage presence + + final OnPassphraseChangedListener action = new OnPassphraseChangedListener() { + public void onPassphraseChanged(String passphrase) { + mPassphrase = passphrase; + try { + if (MediaStorage.isStorageAccessFrameworkAvailable()) { + MediaStorage.createFile(AccountFragment.this, + PersonalKeyPack.KEYPACK_MIME, + PersonalKeyPack.KEYPACK_FILENAME, + REQUEST_CREATE_KEYPACK); + return; + } + } + catch (ActivityNotFoundException e) { + Log.w(TAG, "Storage Access Framework not working properly"); + ReportingManager.logException(e); + } + + // also used as a fallback if SAF is not working properly + Context ctx = getContext(); + if (ctx != null) { + new FolderChooserDialog.Builder(ctx) + .tag(AccountFragment.this.getClass().getName()) + .initialPath(PersonalKeyPack.DEFAULT_KEYPACK.getParent()) + .show(getParentFragmentManager()); + } + } + }; + + askOrSetPassphrase(action); + + return true; + } + }); + + // register device + final Preference registerDevice = findPreference("pref_register_device"); + registerDevice.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + final OnPassphraseChangedListener action = new OnPassphraseChangedListener() { + public void onPassphraseChanged(String passphrase) { + uploadPrivateKey(passphrase); + } + }; + + askOrSetPassphrase(action); + + return true; + } + }); + + // server list last update timestamp + final Preference updateServerList = findPreference("pref_update_server_list"); + updateServerList.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + Context ctx = getActivity(); + mServerlistUpdater = new ServerListUpdater(ctx); + + final DialogHelperFragment diag = DialogHelperFragment + .newInstance(DialogHelperFragment.DIALOG_SERVERLIST_UPDATER); + final Context appCtx = getContext().getApplicationContext(); + + mServerlistUpdater.setListener(new ServerListUpdater.UpdaterListener() { + @Override + public void error(Throwable t) { + try { + ReportingManager.logException(t); + message(R.string.serverlist_update_error); + diag.dismiss(); + } + catch (Exception e) { + // did our best + } + } + + @Override + public void networkNotAvailable() { + try { + message(R.string.serverlist_update_nonetwork); + diag.dismiss(); + } + catch (Exception e) { + // did our best + } + } + + @Override + public void offlineModeEnabled() { + try { + message(R.string.serverlist_update_offline); + diag.dismiss(); + } + catch (Exception e) { + // did our best + } + } + + @Override + public void noData() { + try { + message(R.string.serverlist_update_nodata); + diag.dismiss(); + } + catch (Exception e) { + // did our best + } + } + + @Override + public void updated(final ServerList list) { + Preferences.updateServerListLastUpdate(updateServerList, list); + // restart message center + MessageCenterService.restart(appCtx); + try { + diag.dismiss(); + } + catch (Exception e) { + // did our best + } + } + + private void message(final int textId) { + try { + diag.getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(getActivity(), textId, + Toast.LENGTH_LONG).show(); + } + }); + } + catch (Exception e) { + // did our best + } + } + }); + + diag.show(getParentFragmentManager(), DialogHelperFragment.class.getSimpleName()); + mServerlistUpdater.start(); + return true; + } + }); + + // update 'last update' string + ServerList list = ServerListUpdater.getCurrentList(getActivity()); + if (list != null) + Preferences.updateServerListLastUpdate(updateServerList, list); + + // delete account + // TODO should be a custom class and a big red glowing text :) + final Preference deleteAccount = findPreference("pref_delete_account"); + deleteAccount.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + new MaterialDialog.Builder(getActivity()) + .title(R.string.pref_delete_account) + .content(R.string.msg_delete_account) + .negativeText(android.R.string.cancel) + .positiveText(android.R.string.ok) + .positiveColorRes(R.color.button_danger) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + // progress dialog + final Dialog progress = new LockedDialog + .Builder(getActivity()) + .content(R.string.msg_delete_account_progress) + .progress(true, 0) + .show(); + + // stop the message center first + Context ctx = getActivity(); + MessageCenterService.stop(ctx.getApplicationContext()); + + AccountManagerCallback callback = new AccountManagerCallback() { + public void run(AccountManagerFuture future) { + // invalidate cache + Kontalk.get().invalidatePersonalKey(); + // dismiss progress + progress.dismiss(); + // exit now + getActivity().finish(); + } + }; + Authenticator.removeDefaultAccount(ctx, callback); + } + }) + .show(); + + return true; + } + }); + } + + @Override + public void onResume() { + super.onResume(); + + ((BasePreferencesActivity) getActivity()).getSupportActionBar() + .setTitle(R.string.pref_account_settings); + } + + @Override + public void onStop() { + super.onStop(); + mServiceBus.unregister(this); + } + + interface OnPassphraseChangedListener { + void onPassphraseChanged(String passphrase); + } + + interface OnPassphraseRequestListener { + void onValidPassphrase(String passphrase); + + void onInvalidPassphrase(); + } + + void askCurrentPassphrase(final OnPassphraseRequestListener action) { + new MaterialDialog.Builder(getActivity()) + .title(R.string.title_passphrase) + .inputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) + .input(0, 0, true, new MaterialDialog.InputCallback() { + @Override + public void onInput(@NonNull MaterialDialog dialog, CharSequence input) { + String passphrase = input.toString(); + // user-entered passphrase is hashed, so compare with SHA-1 version + String hashed = SHA1.hex(passphrase); + if (hashed.equals(Kontalk.get() + .getDefaultAccount().getPassphrase())) { + action.onValidPassphrase(passphrase); + } + else { + action.onInvalidPassphrase(); + } + } + }) + .negativeText(android.R.string.cancel) + .positiveText(android.R.string.ok) + .show(); + } + + void askNewPassphrase() { + askNewPassphrase(null); + } + + void askNewPassphrase(final OnPassphraseChangedListener action) { + new PasswordInputDialog.Builder(getActivity()) + .setMinLength(PersonalKey.MIN_PASSPHRASE_LENGTH) + .title(R.string.pref_change_passphrase) + .positiveText(android.R.string.ok, new PasswordInputDialog.OnPasswordInputListener() { + public void onClick(DialogInterface dialog, int which, String password) { + Context ctx = getActivity(); + String oldPassword = Kontalk.get().getDefaultAccount().getPassphrase(); + try { + + // user-entered passphrase must be hashed + String hashed = SHA1.hex(password); + Authenticator.changePassphrase(ctx, oldPassword, hashed, true); + Kontalk.get().invalidatePersonalKey(); + + if (action != null) + action.onPassphraseChanged(password); + } + catch (Exception e) { + Toast.makeText(ctx, + R.string.err_change_passphrase, Toast.LENGTH_LONG) + .show(); + } + } + }) + .negativeText(android.R.string.cancel) + .show(); + } + + void askOrSetPassphrase(final OnPassphraseChangedListener action) { + final Context context = getContext(); + + // passphrase was never set by the user + // encrypt it with a user-defined passphrase first + if (!Authenticator.isUserPassphrase(context)) { + askNewPassphrase(action); + } + + else { + OnPassphraseRequestListener action2 = new OnPassphraseRequestListener() { + public void onValidPassphrase(String passphrase) { + action.onPassphraseChanged(passphrase); + } + + public void onInvalidPassphrase() { + new MaterialDialog.Builder(context) + .content(R.string.err_password_invalid) + .positiveText(android.R.string.ok) + .show(); + } + }; + + askCurrentPassphrase(action2); + } + } + + void uploadPrivateKey(final String passphrase) { + final Context context = getContext(); + if (context == null) + return; + + // check for network + if (!SystemUtils.isNetworkConnectionAvailable(context)) { + Toast.makeText(getActivity(), R.string.register_device_nonetwork, + Toast.LENGTH_LONG).show(); + return; + } + + // check for offline mode + if (Preferences.getOfflineMode()) { + Toast.makeText(getActivity(), R.string.register_device_offline, + Toast.LENGTH_LONG).show(); + return; + } + + mPassphrase = passphrase; + + // listen for events to receive the token to display to the user + mServiceBus.register(this); + + mUploadPrivateKeyProgress = new MaterialDialog.Builder(context) + .progress(true, 0) + .content(R.string.register_device_requesting) + .cancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialogInterface) { + mServiceBus.unregister(AccountFragment.this); + mUploadPrivateKeyProgress = null; + } + }) + .show(); + + MessageCenterService.start(context.getApplicationContext()); + } + + // FIXME ConnectedEvent will also be used by key pair regeneration + @Subscribe(sticky = true, threadMode = ThreadMode.BACKGROUND) + public void onConnected(ConnectedEvent event) { + Context context = getContext(); + if (context != null) { + mServiceBus.post(new UploadPrivateKeyRequest(mPassphrase)); + } + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + public void onPrivateKeyUploaded(PrivateKeyUploadedEvent event) { + mServiceBus.unregister(this); + + final Context context = getContext(); + if (context == null) + return; + + if (mUploadPrivateKeyProgress != null) { + mUploadPrivateKeyProgress.dismiss(); + mUploadPrivateKeyProgress = null; + } + + if (event.token == null) { + Toast.makeText(context, R.string.register_device_request_error, Toast.LENGTH_LONG).show(); + } + else { + RegisterDeviceActivity.start(context, event.token, event.server); + } + } + + public void exportPersonalKey(Context ctx, OutputStream out) { + try { + Kontalk.get().exportPersonalKey(out, mPassphrase); + mPassphrase = null; + + Toast.makeText(ctx, + R.string.msg_keypair_exported, + Toast.LENGTH_LONG).show(); + } + catch (Exception e) { + Log.e(TAG, "error exporting keys", e); + Toast.makeText(ctx, R.string.err_keypair_export_other, + Toast.LENGTH_LONG).show(); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putString("passphrase", mPassphrase); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_CREATE_KEYPACK) { + if (resultCode == Activity.RESULT_OK) { + Context ctx = getActivity(); + if (ctx != null && data != null && data.getData() != null) { + try { + OutputStream out = ctx.getContentResolver().openOutputStream(data.getData()); + exportPersonalKey(ctx, out); + } + catch (FileNotFoundException e) { + Log.e(TAG, "error exporting keys", e); + Toast.makeText(ctx, R.string.err_keypair_export_write, + Toast.LENGTH_LONG).show(); + } + } + } + } + else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + void cancelServerlistUpdater() { + if (mServerlistUpdater != null) { + mServerlistUpdater.cancel(); + mServerlistUpdater = null; + } + } + + public static final class DialogHelperFragment extends DialogFragment { + public static final int DIALOG_SERVERLIST_UPDATER = 1; + + public static DialogHelperFragment newInstance(int dialogId) { + DialogHelperFragment f = new DialogHelperFragment(); + Bundle args = new Bundle(); + args.putInt("id", dialogId); + f.setArguments(args); + return f; + } + + public DialogHelperFragment() { + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + } + + @Override + public void onDestroyView() { + if (getDialog() != null && getRetainInstance()) + getDialog().setOnDismissListener(null); + super.onDestroyView(); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Bundle args = getArguments(); + int id = args.getInt("id"); + + switch (id) { + case DIALOG_SERVERLIST_UPDATER: + return new MaterialDialog.Builder(getContext()) + .cancelable(true) + .content(R.string.serverlist_updating) + .progress(true, 0) + .cancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + ((AccountFragment) getTargetFragment()) + .cancelServerlistUpdater(); + } + }) + .build(); + } + + return super.onCreateDialog(savedInstanceState); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/AccountPreferencesActivity.kt b/app/src/main/java/org/kontalk/ui/prefs/AccountPreferencesActivity.kt new file mode 100644 index 000000000..5db232f42 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/AccountPreferencesActivity.kt @@ -0,0 +1,51 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs + +import android.os.Bundle +import androidx.fragment.app.Fragment + +import org.kontalk.R + + +/** + * Special preferences activity invoked from an account screen in system settings. + * @author Daniele Ricci + */ +class AccountPreferencesActivity : BasePreferencesActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.preferences_screen) + setupToolbar(true, true) + + if (savedInstanceState == null) { + val fragment: Fragment = AccountFragment() + supportFragmentManager.beginTransaction() + .replace(R.id.container, fragment) + .commit() + } + } + + override fun isNormalUpNavigation(): Boolean { + return true + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/AppearanceFragment.java b/app/src/main/java/org/kontalk/ui/prefs/AppearanceFragment.java new file mode 100644 index 000000000..846eebcef --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/AppearanceFragment.java @@ -0,0 +1,156 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.widget.Toast; + +import androidx.preference.Preference; +import androidx.preference.PreferenceManager; + +import org.kontalk.R; +import org.kontalk.ui.view.MessageListItemThemeFactory; +import org.kontalk.util.Preferences; + +import java.io.File; + +import com.afollestad.materialdialogs.MaterialDialog; + + +/** + * Appearance settings fragment. + */ +public class AppearanceFragment extends RootPreferenceFragment { + + private static final int REQUEST_PICK_BACKGROUND = Activity.RESULT_FIRST_USER + 1; + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_appearance); + + // use custom background + final Preference customBg = findPreference("pref_custom_background"); + customBg.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + // discard reference to custom background drawable + Preferences.setCachedCustomBackground(null); + return false; + } + }); + + // set background + final Preference setBackground = findPreference("pref_background_uri"); + setBackground.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + try { + final Intent i = new Intent(Intent.ACTION_GET_CONTENT); + i.addCategory(Intent.CATEGORY_OPENABLE); + i.setType("image/*"); + startActivityForResult(i, REQUEST_PICK_BACKGROUND); + } + catch (ActivityNotFoundException e) { + Toast.makeText(getActivity(), R.string.chooser_error_no_gallery_app, + Toast.LENGTH_LONG).show(); + } + return true; + } + }); + + // set balloon theme + final Preference balloons = findPreference("pref_balloons"); + balloons.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + String theme = (String) newValue; + Preferences.setCachedBalloonTheme(theme); + unsupportedDayNightThemeSelectedWarning(theme); + return true; + } + }); + + // set balloon theme for groups + final Preference balloonsGroups = findPreference("pref_balloons_groups"); + balloonsGroups.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + String theme = (String) newValue; + Preferences.setCachedBalloonGroupsTheme(theme); + unsupportedDayNightThemeSelectedWarning(theme); + return true; + } + }); + } + + @Override + public void onResume() { + super.onResume(); + + ((PreferencesActivity) getActivity()).getSupportActionBar() + .setTitle(R.string.pref_appearance_settings); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_PICK_BACKGROUND) { + if (resultCode == Activity.RESULT_OK) { + Context ctx = getActivity(); + if (ctx != null) { + // invalidate any previous reference + Preferences.setCachedCustomBackground(null); + // resize and cache image + // TODO do this in background (might take some time) + try { + File image = Preferences.cacheConversationBackground(ctx, data.getData()); + // save to preferences + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ctx); + prefs.edit() + .putString("pref_background_uri", Uri.fromFile(image).toString()) + .apply(); + } + catch (Exception e) { + Toast.makeText(ctx, R.string.err_custom_background, + Toast.LENGTH_LONG).show(); + } + } + } + } + else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + private void unsupportedDayNightThemeSelectedWarning(String theme) { + Context context = getContext(); + if (!MessageListItemThemeFactory.isDayNightSupported(context, theme)) { + new MaterialDialog.Builder(context) + // TODO i18n + .content("The selected theme may not fully support light and dark themes. You may see some weird stuff.") + .positiveText(android.R.string.ok) + .show(); + } + } +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/BasePreferencesActivity.java b/app/src/main/java/org/kontalk/ui/prefs/BasePreferencesActivity.java new file mode 100644 index 000000000..d38c38ac5 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/BasePreferencesActivity.java @@ -0,0 +1,46 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import com.afollestad.materialdialogs.color.ColorChooserDialog; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +import org.kontalk.ui.ToolbarActivity; +import org.kontalk.util.Preferences; + + +/** + * Special preferences activity invoked from system notifications settings. + * @author Daniele Ricci + */ +public abstract class BasePreferencesActivity extends ToolbarActivity + implements ColorChooserDialog.ColorCallback { + + // used only for notification LED color for now + @Override + public void onColorSelection(@NonNull ColorChooserDialog dialog, @ColorInt int selectedColor) { + Preferences.setNotificationLEDColor(selectedColor); + } + + @Override + public void onColorChooserDismissed(@NonNull ColorChooserDialog dialog) { + } +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/CopyDatabasePreference.java b/app/src/main/java/org/kontalk/ui/prefs/CopyDatabasePreference.java new file mode 100644 index 000000000..c420bd3e5 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/CopyDatabasePreference.java @@ -0,0 +1,180 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; + +import androidx.fragment.app.Fragment; +import androidx.preference.Preference; +import android.util.AttributeSet; +import android.widget.Toast; + +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.provider.MessagesProvider; +import org.kontalk.reporting.ReportingManager; +import org.kontalk.util.DataUtils; +import org.kontalk.util.MediaStorage; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.afollestad.materialdialogs.folderselector.FolderChooserDialog; + + +/** + * Preference for copying the messages database to the external storage. + * @author Daniele Ricci + */ +public class CopyDatabasePreference extends Preference { + private static final String TAG = Kontalk.TAG; + + public static final int REQUEST_COPY_DATABASE = Activity.RESULT_FIRST_USER + 4; + + private static final String DBFILE_MIME = "application/x-sqlite3"; + public static final String DBFILE_NAME = "kontalk-messages.db"; + + private Fragment mFragment; + + public CopyDatabasePreference(Context context) { + super(context); + init(); + } + + public CopyDatabasePreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public CopyDatabasePreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public CopyDatabasePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init() { + setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + requestFile(getContext()); + return true; + } + }); + } + + public void setParentFragment(Fragment fragment) { + mFragment = fragment; + } + + void requestFile(Context context) { + try { + if (MediaStorage.isStorageAccessFrameworkAvailable()) { + MediaStorage.createFile(mFragment, DBFILE_MIME, DBFILE_NAME, + REQUEST_COPY_DATABASE); + return; + } + } + catch (ActivityNotFoundException e) { + Log.w(TAG, "Storage Access Framework not working properly"); + ReportingManager.logException(e); + } + + // also used as a fallback if SAF is not working properly + Context ctx = getContext(); + if (ctx != null) { + new FolderChooserDialog.Builder(ctx) + .tag(getClass().getName()) + .initialPath(Environment.getExternalStorageDirectory().toString()) + .show(mFragment.getParentFragmentManager()); + } + } + + public static void copyDatabase(Context context, File dbOutFile) { + OutputStream dbOut = null; + try { + dbOut = new FileOutputStream(dbOutFile); + copyDatabase(context, dbOut, dbOut.toString()); + } + catch (IOException e) { + Toast.makeText(context, context + .getString(R.string.msg_copy_database_failed, e.toString()), Toast.LENGTH_LONG) + .show(); + } + finally { + DataUtils.close(dbOut); + } + } + + public static void copyDatabase(Context context, Uri dbOutFile) { + OutputStream dbOut = null; + try { + dbOut = context.getContentResolver().openOutputStream(dbOutFile); + copyDatabase(context, dbOut, dbOutFile.toString()); + } + catch (IOException e) { + Toast.makeText(context, context + .getString(R.string.msg_copy_database_failed, e.toString()), Toast.LENGTH_LONG) + .show(); + } + finally { + DataUtils.close(dbOut); + } + } + + private static void copyDatabase(Context context, OutputStream dbOut, String filename) { + MessagesProvider.lockForImport(context); + + InputStream dbIn = null; + try { + dbIn = new FileInputStream(MessagesProvider.getDatabaseUri(context)); + DataUtils.copy(dbIn, dbOut); + Toast.makeText(context, context + .getString(R.string.msg_copy_database_success, filename), Toast.LENGTH_LONG) + .show(); + } + catch (IOException e) { + Toast.makeText(context, context + .getString(R.string.msg_copy_database_failed, e.toString()), Toast.LENGTH_LONG) + .show(); + } + finally { + DataUtils.close(dbIn); + DataUtils.close(dbOut); + } + + MessagesProvider.unlockForImport(context); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/HelpPreference.java b/app/src/main/java/org/kontalk/ui/prefs/HelpPreference.java new file mode 100644 index 000000000..12f44d01a --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/HelpPreference.java @@ -0,0 +1,72 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import androidx.preference.Preference; +import android.util.AttributeSet; + +import org.kontalk.R; +import org.kontalk.util.SystemUtils; + + +/** + * Preference for the help link. + * @author Daniele Ricci + */ +public class HelpPreference extends Preference { + + public HelpPreference(Context context) { + super(context); + init(); + } + + public HelpPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public HelpPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public HelpPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init() { + setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + openHelp(getContext()); + return true; + } + }); + } + + public static void openHelp(Context context) { + SystemUtils.openURL(context, context.getString(R.string.help_url)); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/LocationFragment.java b/app/src/main/java/org/kontalk/ui/prefs/LocationFragment.java new file mode 100644 index 000000000..d7f3e39c9 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/LocationFragment.java @@ -0,0 +1,44 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.os.Bundle; + +import org.kontalk.R; + + +/** + * @author Andrea Cappelli + */ +public class LocationFragment extends RootPreferenceFragment { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + // Load the preferences from an XML resource + addPreferencesFromResource(R.xml.preferences_location); + } + + @Override + public void onResume() { + super.onResume(); + + ((PreferencesActivity) getActivity()).getSupportActionBar() + .setTitle(R.string.pref_location_settings); + } +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/MaintenanceFragment.java b/app/src/main/java/org/kontalk/ui/prefs/MaintenanceFragment.java new file mode 100644 index 000000000..9a7925c90 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/MaintenanceFragment.java @@ -0,0 +1,99 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.preference.CheckBoxPreference; +import androidx.preference.Preference; + +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.service.msgcenter.MessageCenterService; + + +/** + * Maintenance settings fragment. + */ +public class MaintenanceFragment extends RootPreferenceFragment { + static final String TAG = Kontalk.TAG; + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + // Load the preferences from an XML resource + addPreferencesFromResource(R.xml.preferences_maintenance); + + // message center restart + final Preference restartMsgCenter = findPreference("pref_restart_msgcenter"); + restartMsgCenter.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + Log.w(TAG, "manual message center restart requested"); + MessageCenterService.restart(preference.getContext().getApplicationContext()); + Toast.makeText(preference.getContext(), R.string.msg_msgcenter_restarted, Toast.LENGTH_SHORT).show(); + return true; + } + }); + + // send our stuff to the copy database preference + final CopyDatabasePreference copyDatabase = findPreference("pref_copy_database"); + copyDatabase.setParentFragment(this); + + if (Kontalk.get().getDefaultAccount() == null) { + // no account, hide/disable some stuff + restartMsgCenter.setEnabled(false); + } + + // explain the user that the foreground service is mandatory + if (MessageCenterService.mustSetForeground(getContext())) { + final CheckBoxPreference foregroundService = findPreference("pref_foreground_service"); + foregroundService.setEnabled(false); + foregroundService.setSummary(R.string.pref_title_foreground_service_mandatory); + } + } + + @Override + public void onResume() { + super.onResume(); + + ((PreferencesActivity) getActivity()).getSupportActionBar() + .setTitle(R.string.pref_maintenance); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == CopyDatabasePreference.REQUEST_COPY_DATABASE) { + if (resultCode == Activity.RESULT_OK) { + Context ctx = getActivity(); + if (ctx != null && data != null && data.getData() != null) { + CopyDatabasePreference.copyDatabase(ctx, data.getData()); + } + } + } + else { + super.onActivityResult(requestCode, resultCode, data); + } + } +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/MediaFragment.java b/app/src/main/java/org/kontalk/ui/prefs/MediaFragment.java new file mode 100644 index 000000000..f19132739 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/MediaFragment.java @@ -0,0 +1,44 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.os.Bundle; + +import org.kontalk.R; + +/** + * Messaging settings fragment. + */ +public class MediaFragment extends RootPreferenceFragment { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + // Load the preferences from an XML resource + addPreferencesFromResource(R.xml.preferences_media); + } + + @Override + public void onResume() { + super.onResume(); + + ((PreferencesActivity) getActivity()).getSupportActionBar() + .setTitle(R.string.pref_media_settings); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/MessagingFragment.java b/app/src/main/java/org/kontalk/ui/prefs/MessagingFragment.java new file mode 100644 index 000000000..c1f7559e8 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/MessagingFragment.java @@ -0,0 +1,57 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.os.Bundle; +import androidx.preference.Preference; + +import org.kontalk.R; + +/** + * Messaging settings fragment. + */ +public class MessagingFragment extends RootPreferenceFragment { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + // Load the preferences from an XML resource + addPreferencesFromResource(R.xml.preferences_messaging); + } + + @Override + protected void setupPreferences() { + // privacy section + final Preference privacy = findPreference("pref_privacy_settings"); + privacy.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + invokeCallback(R.xml.privacy_preferences); + return true; + } + }); + } + + @Override + public void onResume() { + super.onResume(); + + ((PreferencesActivity) getActivity()).getSupportActionBar() + .setTitle(R.string.pref_messaging_settings); + } +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/NetworkFragment.java b/app/src/main/java/org/kontalk/ui/prefs/NetworkFragment.java new file mode 100644 index 000000000..f55c7240b --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/NetworkFragment.java @@ -0,0 +1,86 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import com.afollestad.materialdialogs.MaterialDialog; + +import android.os.Bundle; + +import androidx.preference.Preference; + +import org.kontalk.R; +import org.kontalk.client.EndpointServer; + + +/** + * Network settings fragment. + */ +public class NetworkFragment extends RootPreferenceFragment { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + // Load the preferences from an XML resource + addPreferencesFromResource(R.xml.preferences_network); + + // manual server address is handled in Application context + // we just handle validation here + final Preference manualServer = findPreference("pref_network_uri"); + manualServer.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + public boolean onPreferenceChange(Preference preference, Object newValue) { + String value = newValue.toString().trim(); + if (value.length() > 0 && !EndpointServer.validate(value)) { + new MaterialDialog.Builder(getActivity()) + .title(R.string.pref_network_uri) + .content(R.string.err_server_invalid_format) + .positiveText(android.R.string.ok) + .show(); + return false; + } + return true; + } + }); + } + + @Override + public void onResume() { + super.onResume(); + ((PreferencesActivity) getActivity()).getSupportActionBar() + .setTitle(R.string.pref_network_settings); + + final Preference pushNotifications = findPreference("pref_push_notifications_parent"); + PushNotificationsPreference.setState(pushNotifications); + } + + @Override + protected void setupPreferences() { + setupPreferences("pref_push_notifications_parent", R.xml.preferences_network_push); + } + + private void setupPreferences(String pref, final int xml) { + final Preference preference = findPreference(pref); + preference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + invokeCallback(xml); + return true; + } + }); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/NetworkPushFragment.java b/app/src/main/java/org/kontalk/ui/prefs/NetworkPushFragment.java new file mode 100644 index 000000000..7892c0cbb --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/NetworkPushFragment.java @@ -0,0 +1,79 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.content.Context; +import android.os.Bundle; + +import androidx.preference.CheckBoxPreference; +import androidx.preference.Preference; + +import org.kontalk.R; +import org.kontalk.service.msgcenter.IPushService; +import org.kontalk.service.msgcenter.MessageCenterService; +import org.kontalk.service.msgcenter.PushServiceManager; + + +/** + * Push notifications settings fragment. + */ +public class NetworkPushFragment extends RootPreferenceFragment { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_network_push); + + // push notifications checkbox + final Preference pushNotifications = findPreference("pref_push_notifications"); + pushNotifications.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + Context ctx = getActivity(); + CheckBoxPreference pref = (CheckBoxPreference) preference; + if (pref.isChecked()) + MessageCenterService.enablePushNotifications(ctx.getApplicationContext()); + else + MessageCenterService.disablePushNotifications(ctx.getApplicationContext()); + + return true; + } + }); + } + + @Override + public void onResume() { + super.onResume(); + ((PreferencesActivity) getActivity()).getSupportActionBar() + .setTitle(R.string.pref_push_notifications); + } + + @Override + protected void setupPreferences() { + final Preference description = findPreference("pref_push_notifications_title"); + + IPushService service = PushServiceManager.getInstance(getContext()); + if (service == null) { + description.setTitle(R.string.pref_push_notifications_title_disabled); + } + else if (!service.isServiceAvailable()) { + description.setTitle(R.string.pref_push_notifications_title_unavailable); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/NotificationFragment.java b/app/src/main/java/org/kontalk/ui/prefs/NotificationFragment.java new file mode 100644 index 000000000..351bb2d07 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/NotificationFragment.java @@ -0,0 +1,136 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; + +import androidx.preference.Preference; +import androidx.preference.PreferenceManager; +import androidx.core.content.ContextCompat; + +import com.afollestad.materialdialogs.color.ColorChooserDialog; + +import org.kontalk.R; +import org.kontalk.ui.ToolbarActivity; +import org.kontalk.util.Preferences; + + +/** + * Notification settings fragment + */ +public class NotificationFragment extends RootPreferenceFragment { + + private static final int REQUEST_PICK_RINGTONE = Activity.RESULT_FIRST_USER + 2; + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + // Load the preferences from an XML resource + addPreferencesFromResource(R.xml.preferences_notification); + + // set ringtone + final Preference setRingtone = findPreference("pref_ringtone"); + setRingtone.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(preference.getContext()); + + String _currentRingtone = prefs.getString(preference.getKey(), + getString(R.string.pref_default_ringtone)); + Uri currentRingtone = !TextUtils.isEmpty(_currentRingtone) ? Uri.parse(_currentRingtone) : null; + + final Intent i = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER); + i.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, currentRingtone); + i.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true); + i.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)); + + i.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true); + i.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION); + i.putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, preference.getTitle()); + + startActivityForResult(i, REQUEST_PICK_RINGTONE); + return true; + } + }); + + // notification LED color + final Preference notificationLed = findPreference("pref_notification_led_color"); + notificationLed.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + Context context = getContext(); + int[] ledColors = new int[]{ + ContextCompat.getColor(context, android.R.color.white), + ContextCompat.getColor(context, R.color.blue_light), + ContextCompat.getColor(context, R.color.purple_light), + ContextCompat.getColor(context, R.color.green_light), + ContextCompat.getColor(context, R.color.yellow_light), + ContextCompat.getColor(context, R.color.red_light), + }; + + try { + new ColorChooserDialog.Builder(getActivity(), + R.string.pref_notification_led_color) + .customColors(ledColors, null) + .preselect(Preferences.getNotificationLEDColor(getContext())) + .allowUserColorInput(false) + .dynamicButtonColor(false) + .show(getParentFragmentManager()); + } + catch (IllegalStateException e) { + // fragment is being destroyed - ignore + } + return true; + } + }); + } + + @Override + public void onResume() { + super.onResume(); + ((ToolbarActivity) getActivity()) + .getSupportActionBar() + .setTitle(R.string.pref_notification_settings); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_PICK_RINGTONE) { + if (resultCode == Activity.RESULT_OK) { + Context ctx = getActivity(); + if (ctx != null) { + Uri uri = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI); + Preferences.setRingtone(uri != null ? uri.toString() : ""); + } + } + } + else { + super.onActivityResult(requestCode, resultCode, data); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/NotificationPreferencesActivity.java b/app/src/main/java/org/kontalk/ui/prefs/NotificationPreferencesActivity.java new file mode 100644 index 000000000..a85c19de7 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/NotificationPreferencesActivity.java @@ -0,0 +1,53 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.os.Bundle; +import androidx.fragment.app.Fragment; + +import org.kontalk.R; + + +/** + * Special preferences activity invoked from system notifications settings. + * @author Daniele Ricci + */ +public class NotificationPreferencesActivity extends BasePreferencesActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.preferences_screen); + setupToolbar(true, true); + + if (savedInstanceState == null) { + Fragment fragment = new NotificationFragment(); + getSupportFragmentManager().beginTransaction() + .replace(R.id.container, fragment) + .commit(); + } + } + + @Override + protected boolean isNormalUpNavigation() { + return true; + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/PreferencesActivity.java b/app/src/main/java/org/kontalk/ui/prefs/PreferencesActivity.java new file mode 100644 index 000000000..4cbc9c8e2 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/PreferencesActivity.java @@ -0,0 +1,167 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; + +import com.afollestad.materialdialogs.folderselector.FolderChooserDialog; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.NonNull; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.fragment.app.Fragment; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.crypto.PersonalKeyPack; + + +/** + * Preferences activity. + * TODO convert to fragments layout + * @author Daniele Ricci + */ +public final class PreferencesActivity extends BasePreferencesActivity + implements RootPreferenceFragment.Callback, FolderChooserDialog.FolderCallback { + private static final String TAG_NESTED = "nested"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.preferences_screen); + setupToolbar(true, false); + + if (savedInstanceState == null) { + Fragment fragment = new PreferencesFragment(); + getSupportFragmentManager().beginTransaction() + .replace(R.id.container, fragment) + .commit(); + } + } + + /** Not used (manual implementation in fragments). */ + @Override + protected boolean isNormalUpNavigation() { + return false; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + return getSupportFragmentManager().findFragmentById(R.id.container) + .onOptionsItemSelected(item); + } + + return super.onOptionsItemSelected(item); + } + + @Override + public void onBackPressed() { + // this if statement is necessary to navigate through nested and main fragments + if (getSupportFragmentManager().getBackStackEntryCount() == 0) { + super.onBackPressed(); + } + else { + getSupportFragmentManager().popBackStack(); + } + } + + @Override + public void onNestedPreferenceSelected(int key) { + Fragment fragment; + switch (key) { + case R.xml.preferences_account: + fragment = new AccountFragment(); + break; + case R.xml.preferences_network: + fragment = new NetworkFragment(); + break; + case R.xml.preferences_network_push: + fragment = new NetworkPushFragment(); + break; + case R.xml.preferences_messaging: + fragment = new MessagingFragment(); + break; + case R.xml.privacy_preferences: + fragment = new PrivacyPreferences(); + break; + case R.xml.preferences_appearance: + fragment = new AppearanceFragment(); + break; + case R.xml.preferences_media: + fragment = new MediaFragment(); + break; + case R.xml.preferences_location: + fragment = new LocationFragment(); + break; + case R.xml.preferences_notification: + fragment = new NotificationFragment(); + break; + case R.xml.preferences_maintenance: + fragment = new MaintenanceFragment(); + break; + default: + throw new IllegalArgumentException("unknown preference screen: " + key); + } + + getSupportFragmentManager().beginTransaction() + .replace(R.id.container, fragment, TAG_NESTED) + .addToBackStack(TAG_NESTED).commit(); + } + + @Override + public void onFolderSelection(@NonNull FolderChooserDialog folderChooserDialog, @NonNull File folder) { + final String tag = folderChooserDialog.getTag(); + if (tag.equals(CopyDatabasePreference.class.getName())) { + CopyDatabasePreference.copyDatabase(this, + new File(folder, CopyDatabasePreference.DBFILE_NAME)); + } + else if (tag.equals(AccountFragment.class.getName())) { + try { + AccountFragment f = (AccountFragment) getSupportFragmentManager() + .findFragmentById(R.id.container); + f.exportPersonalKey(this, + new FileOutputStream(new File(folder, PersonalKeyPack.KEYPACK_FILENAME))); + } + catch (FileNotFoundException e) { + Log.e(PreferencesFragment.TAG, "error exporting keys", e); + Toast.makeText(this, + R.string.err_keypair_export_write, + Toast.LENGTH_LONG).show(); + } + } + } + + @Override + public void onFolderChooserDismissed(@NonNull FolderChooserDialog dialog) { + } + + public static void start(Activity context) { + Intent intent = new Intent(context, PreferencesActivity.class); + context.startActivityIfNeeded(intent, -1); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/PreferencesFragment.java b/app/src/main/java/org/kontalk/ui/prefs/PreferencesFragment.java new file mode 100644 index 000000000..5a0aa40bb --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/PreferencesFragment.java @@ -0,0 +1,120 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.annotation.SuppressLint; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.view.MenuItem; + +import androidx.preference.Preference; +import androidx.preference.PreferenceManager; + +import org.kontalk.BuildConfig; +import org.kontalk.Kontalk; +import org.kontalk.R; + + +/** + * Preferences overview fragment. + * @author Daniele Ricci + * @author Andrea Cappelli + */ +public final class PreferencesFragment extends RootPreferenceFragment { + static final String TAG = Kontalk.TAG; + + @SuppressLint("ApplySharedPref") + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + // upgrade from old version: pref_text_enter becomes string + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); + try { + prefs.getString("pref_text_enter", null); + } + catch (ClassCastException e) { + // legacy mode + prefs.edit() + .putString("pref_text_enter", + prefs.getBoolean("pref_text_enter", false) ? + "newline" : "default") + .commit(); + } + + addPreferencesFromResource(R.xml.preferences); + + // remove reporting preference in debug builds + if (BuildConfig.DEBUG) { + Preference prefReporting = findPreference("pref_reporting"); + if (prefReporting != null) + getPreferenceScreen().removePreference(prefReporting); + } + + if (Kontalk.get().getDefaultAccount() == null) { + // no account, hide some stuff + Preference prefAccount = findPreference("pref_account_settings"); + getPreferenceScreen().removePreference(prefAccount); + } + } + + @Override + public void onResume() { + super.onResume(); + ((PreferencesActivity) getActivity()).getSupportActionBar() + .setTitle(R.string.menu_settings); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // don't use up navigation here + getActivity().finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + protected void setupPreferences() { + setupPreferences("pref_account_settings", R.xml.preferences_account); + setupPreferences("pref_network_settings", R.xml.preferences_network); + setupPreferences("pref_messaging_settings", R.xml.preferences_messaging); + setupPreferences("pref_appearance_settings", R.xml.preferences_appearance); + setupPreferences("pref_media_settings", R.xml.preferences_media); + setupPreferences("pref_location_settings", R.xml.preferences_location); + setupPreferences("pref_notification_settings", R.xml.preferences_notification); + setupPreferences("pref_maintenance_settings", R.xml.preferences_maintenance); + } + + private void setupPreferences(String pref, final int xml) { + final Preference preference = findPreference(pref); + if (preference != null) { + // null preference means it's been removed + preference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + invokeCallback(xml); + return true; + } + }); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/PrivacyPreferences.java b/app/src/main/java/org/kontalk/ui/prefs/PrivacyPreferences.java new file mode 100644 index 000000000..c01d8eb87 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/PrivacyPreferences.java @@ -0,0 +1,51 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.os.Bundle; +import android.view.MenuItem; + +import androidx.preference.PreferenceFragmentCompat; + +import org.kontalk.R; + + +/** + * Preference fragment for privacy preferences. + * @author Daniele Ricci + */ +public class PrivacyPreferences extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.privacy_preferences); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + getActivity().onBackPressed(); + return true; + } + + return super.onOptionsItemSelected(item); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/PushNotificationsPreference.java b/app/src/main/java/org/kontalk/ui/prefs/PushNotificationsPreference.java new file mode 100644 index 000000000..d6c617188 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/PushNotificationsPreference.java @@ -0,0 +1,107 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; + +import androidx.preference.CheckBoxPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import android.util.AttributeSet; +import android.view.View; + +import org.kontalk.R; +import org.kontalk.service.msgcenter.IPushService; +import org.kontalk.service.msgcenter.PushServiceManager; +import org.kontalk.util.Preferences; + + +/** + * Preference for push notifications. + * @author Daniele Ricci + */ +public class PushNotificationsPreference extends CheckBoxPreference implements View.OnClickListener { + + public PushNotificationsPreference(Context context) { + super(context); + init(); + } + + public PushNotificationsPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public PushNotificationsPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public PushNotificationsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init() { + setWidgetLayoutResource(R.layout.preference_switch_layout); + + // disable and uncheck preference + IPushService service = PushServiceManager.getInstance(getContext()); + if (service == null || !service.isServiceAvailable()) { + setEnabled(false); + setChecked(false); + } + } + + @Override + public void onBindViewHolder(PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + View checkbox = holder.findViewById(android.R.id.checkbox); + checkbox.setOnClickListener(this); + } + + public void onClick(@SuppressWarnings("UnusedParameters") View view) { + super.onClick(); + OnPreferenceClickListener listener = super.getOnPreferenceClickListener(); + if (listener != null) + listener.onPreferenceClick(this); + } + + @Override + public void setChecked(boolean checked) { + super.setChecked(checked); + setTitle(isChecked() ? R.string.pref_on : R.string.pref_off); + } + + public static void setState(Preference parent) { + boolean enabled = Preferences.getPushNotificationsEnabled(parent.getContext()); + IPushService service = PushServiceManager.getInstance(parent.getContext()); + if (service == null) { + parent.setSummary(R.string.pref_push_notifications_disabled); + } + else if (service.isServiceAvailable()) { + parent.setSummary(enabled ? R.string.pref_on : R.string.pref_off); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/ResetHintsPreference.kt b/app/src/main/java/org/kontalk/ui/prefs/ResetHintsPreference.kt new file mode 100644 index 000000000..a13f7c376 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/ResetHintsPreference.kt @@ -0,0 +1,51 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs + +import android.annotation.TargetApi +import android.content.Context +import android.os.Build +import android.util.AttributeSet +import android.widget.Toast +import androidx.preference.Preference +import org.kontalk.R +import org.kontalk.util.Showcase + + +class ResetHintsPreference : Preference { + + constructor(context: Context?): + super(context) + + constructor(context: Context?, attrs: AttributeSet?): + super(context, attrs) + + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int): + super(context, attrs, defStyleAttr) + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int): + super(context, attrs, defStyleAttr, defStyleRes) + + override fun onClick() { + Showcase.resetAllHints() + Toast.makeText(context, R.string.msg_hints_reset, Toast.LENGTH_SHORT).show() + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/RootPreferenceFragment.java b/app/src/main/java/org/kontalk/ui/prefs/RootPreferenceFragment.java new file mode 100644 index 000000000..1e59651c9 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/RootPreferenceFragment.java @@ -0,0 +1,84 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.content.Context; +import android.os.Bundle; +import android.view.MenuItem; + +import androidx.preference.PreferenceFragmentCompat; + + +/** + * Base class for root preference fragments. + * @author Daniele Ricci + */ +public abstract class RootPreferenceFragment extends PreferenceFragmentCompat { + + private Callback mCallback; + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + setupPreferences(); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (context instanceof Callback) { + mCallback = (Callback) context; + } + // with the exception of the notification and account preferences activities... + else if (!(context instanceof NotificationPreferencesActivity) && + !(context instanceof AccountPreferencesActivity)) { + throw new IllegalStateException("Owner must implement Callback interface"); + } + } + + @Override + public void onDetach() { + super.onDetach(); + mCallback = null; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + getActivity().onBackPressed(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + void invokeCallback(int key) { + if (mCallback != null) + mCallback.onNestedPreferenceSelected(key); + } + + protected void setupPreferences() { + } + + public interface Callback { + void onNestedPreferenceSelected(int key); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/SendDebugLogPreference.java b/app/src/main/java/org/kontalk/ui/prefs/SendDebugLogPreference.java new file mode 100644 index 000000000..aa362c360 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/SendDebugLogPreference.java @@ -0,0 +1,79 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import java.io.File; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import androidx.preference.Preference; +import android.util.AttributeSet; + +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.util.MediaStorage; + + +/** + * Preference for sending the debug log. + * @author Daniele Ricci + */ +public class SendDebugLogPreference extends Preference { + + public SendDebugLogPreference(Context context) { + super(context); + } + + public SendDebugLogPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SendDebugLogPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public SendDebugLogPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected void onClick() { + super.onClick(); + sendDebugLog(getContext()); + } + + private void sendDebugLog(Context context) { + File file = Log.getLogFile(); + if (file != null && file.isFile()) { + Intent i = new Intent(Intent.ACTION_SEND); + i.setType("text/plain"); + i.putExtra(Intent.EXTRA_EMAIL, new String[] { context.getString(R.string.mailto) }); + i.putExtra(Intent.EXTRA_SUBJECT, "Kontalk debug log"); + Uri uri = MediaStorage.getWorldReadableUri(context, + Uri.fromFile(file), i); + i.putExtra(Intent.EXTRA_STREAM, uri); + context.startActivity(i); + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/ServiceTermsPreference.java b/app/src/main/java/org/kontalk/ui/prefs/ServiceTermsPreference.java new file mode 100644 index 000000000..4544d461c --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/ServiceTermsPreference.java @@ -0,0 +1,79 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.prefs; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import androidx.preference.Preference; +import android.util.AttributeSet; + +import org.kontalk.Kontalk; +import org.kontalk.util.SystemUtils; + + +/** + * Preference for the service terms link. + * @author Daniele Ricci + */ +public class ServiceTermsPreference extends Preference { + + private String mServiceTermsUrl; + + public ServiceTermsPreference(Context context) { + super(context); + init(); + } + + public ServiceTermsPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ServiceTermsPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public ServiceTermsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init() { + mServiceTermsUrl = Kontalk.get().getDefaultAccount().getServiceTermsURL(); + setEnabled(mServiceTermsUrl != null); + + if (mServiceTermsUrl != null) { + setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + openServiceTerms(getContext()); + return true; + } + }); + } + } + + private void openServiceTerms(Context context) { + SystemUtils.openURL(context, mServiceTermsUrl); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/prefs/TextMessagePreference.java b/app/src/main/java/org/kontalk/ui/prefs/TextMessagePreference.java new file mode 100644 index 000000000..80b2753b8 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/prefs/TextMessagePreference.java @@ -0,0 +1,37 @@ +// Copyright 2014 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package org.kontalk.ui.prefs; + +import android.content.Context; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import android.text.method.LinkMovementMethod; +import android.util.AttributeSet; +import android.widget.TextView; + + +/** + * A preference that displays informational text. + */ +public class TextMessagePreference extends Preference { + + /** + * Constructor for inflating from XML. + */ + public TextMessagePreference(Context context, AttributeSet attrs) { + super(context, attrs); + setSelectable(false); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + TextView textView = (TextView) holder.findViewById(android.R.id.title); + textView.setSingleLine(false); + textView.setMaxLines(Integer.MAX_VALUE); + textView.setMovementMethod(LinkMovementMethod.getInstance()); + } +} diff --git a/app/src/main/java/org/kontalk/ui/view/AbsListViewScrollDetector.java b/app/src/main/java/org/kontalk/ui/view/AbsListViewScrollDetector.java new file mode 100644 index 000000000..3966c1685 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/AbsListViewScrollDetector.java @@ -0,0 +1,92 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2014 Oleksandr Melnykov + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.kontalk.ui.view; + +import androidx.annotation.NonNull; +import android.view.View; +import android.widget.AbsListView; + + +/** + * https://github.com/makovkastar/FloatingActionButton + * @author makovkastar + */ +public abstract class AbsListViewScrollDetector implements AbsListView.OnScrollListener { + private int mLastScrollY; + private int mPreviousFirstVisibleItem; + private AbsListView mListView; + private int mScrollThreshold; + + public abstract void onScrollUp(); + + public abstract void onScrollDown(); + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + } + + @Override + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + if(totalItemCount == 0) return; + if (isSameRow(firstVisibleItem)) { + int newScrollY = getTopItemScrollY(); + boolean isSignificantDelta = Math.abs(mLastScrollY - newScrollY) > mScrollThreshold; + if (isSignificantDelta) { + if (mLastScrollY > newScrollY) { + onScrollUp(); + } else { + onScrollDown(); + } + } + mLastScrollY = newScrollY; + } else { + if (firstVisibleItem > mPreviousFirstVisibleItem) { + onScrollUp(); + } else { + onScrollDown(); + } + + mLastScrollY = getTopItemScrollY(); + mPreviousFirstVisibleItem = firstVisibleItem; + } + } + + public void setScrollThreshold(int scrollThreshold) { + mScrollThreshold = scrollThreshold; + } + + public void setListView(@NonNull AbsListView listView) { + mListView = listView; + } + + private boolean isSameRow(int firstVisibleItem) { + return firstVisibleItem == mPreviousFirstVisibleItem; + } + + private int getTopItemScrollY() { + if (mListView == null || mListView.getChildAt(0) == null) return 0; + View topChild = mListView.getChildAt(0); + return topChild.getTop(); + } +} diff --git a/app/src/main/java/org/kontalk/ui/view/AttachmentRevealFrameLayout.java b/app/src/main/java/org/kontalk/ui/view/AttachmentRevealFrameLayout.java new file mode 100644 index 000000000..19a029329 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/AttachmentRevealFrameLayout.java @@ -0,0 +1,117 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import android.animation.Animator; +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; + +import io.codetail.animation.ViewAnimationUtils; +import io.codetail.widget.RevealFrameLayout; + +import org.kontalk.R; + + +/** + * Composite view for the attachment reveal frame layout. + * @author Daniele Ricci + */ +public class AttachmentRevealFrameLayout extends RevealFrameLayout { + + private View mContent; + + public AttachmentRevealFrameLayout(Context context) { + super(context); + } + + public AttachmentRevealFrameLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public AttachmentRevealFrameLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mContent = findViewById(R.id.circular_card); + } + + public void show() { + // stop all animations! + mContent.clearAnimation(); + + setVisibility(VISIBLE); + int right = mContent.getRight(); + int top = mContent.getTop(); + float f = (float) Math.sqrt(Math.pow(mContent.getWidth(), 2D) + Math.pow(mContent.getHeight(), 2D)); + Animator showAnimator = ViewAnimationUtils.createCircularReveal(mContent, right, top, 0, f); + setAnimatorParams(showAnimator); + showAnimator.addListener(new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animation) { + } + + @Override + public void onAnimationEnd(Animator animation) { + } + + @Override + public void onAnimationCancel(Animator animation) { + } + + @Override + public void onAnimationRepeat(Animator animation) { + } + }); + showAnimator.start(); + } + + public void hide() { + // cancel show animation (if any) + mContent.clearAnimation(); + // hide immediately + hideNow(); + } + + public void toggle() { + if (getVisibility() == VISIBLE) { + hide(); + } + else { + show(); + } + } + + private void hideNow() { + setVisibility(INVISIBLE); + } + + private void setAnimatorParams(Animator anim) { + anim.setInterpolator(new AccelerateDecelerateInterpolator()); + anim.setDuration(250); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/AudioContentView.java b/app/src/main/java/org/kontalk/ui/view/AudioContentView.java index cae248430..04d67fc9d 100644 --- a/app/src/main/java/org/kontalk/ui/view/AudioContentView.java +++ b/app/src/main/java/org/kontalk/ui/view/AudioContentView.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -38,7 +38,6 @@ import android.widget.TextView; import org.kontalk.R; -import org.kontalk.data.Contact; import org.kontalk.message.AudioComponent; @@ -83,13 +82,18 @@ public AudioContentView(Context context, AttributeSet attrs, int defStyle) { protected void onFinishInflate() { super.onFinishInflate(); - mPlayButton = (ImageButton) findViewById(R.id.balloon_audio_player); - mSeekBar = (SeekBar) findViewById(R.id.balloon_audio_seekbar); - mDownloadButton = (ImageView) findViewById(R.id.balloon_audio_download); - mTime = (TextView) findViewById(R.id.balloon_audio_time); + mPlayButton = findViewById(R.id.balloon_audio_player); + mSeekBar = findViewById(R.id.balloon_audio_seekbar); + mDownloadButton = findViewById(R.id.balloon_audio_download); + mTime = findViewById(R.id.balloon_audio_time); + + if (isInEditMode()) { + mDownloadButton.setVisibility(GONE); + } } - public void bind(long messageId, AudioComponent component, Contact contact, Pattern highlight) { + @Override + public void bind(long messageId, AudioComponent component, Pattern highlight) { mComponent = component; mMessageId = messageId; @@ -105,11 +109,13 @@ public void bind(long messageId, AudioComponent component, Contact contact, Patt mAudioPlayerControl.onBind(messageId, this); } + @Override public void unbind() { clear(); mAudioPlayerControl.onUnbind(mMessageId, this); } + @Override public AudioComponent getComponent() { return mComponent; } @@ -119,13 +125,24 @@ public int getPriority() { return 5; } + @Override + public void onApplyTheme(MessageListItemTheme theme) { + if (theme.isFullWidth()) { + RelativeLayout.LayoutParams params = (LayoutParams) mSeekBar.getLayoutParams(); + params.width = LayoutParams.MATCH_PARENT; + // no layout has been requested yet - mSeekBar.setLayoutParams(params); + } + } + private void clear() { mComponent = null; } @Override public void onClick(View v) { - mAudioPlayerControl.buttonClick(new File(mComponent.getLocalUri().getPath()), this, mMessageId); + Uri localUri = mComponent != null ? mComponent.getLocalUri() : null; + if (localUri != null) + mAudioPlayerControl.buttonClick(new File(localUri.getPath()), this, mMessageId); } @Override @@ -192,23 +209,20 @@ public int getPosition() { } private int getAudioDuration(Uri uri) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD_MR1) { - try { - MediaMetadataRetriever retriever = new MediaMetadataRetriever(); - retriever.setDataSource(getContext(), uri); - String time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); - return Integer.parseInt(time); - } - catch (Exception a) { - // ignored - } + try { + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + retriever.setDataSource(getContext(), uri); + String time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + return Integer.parseInt(time); + } + catch (Exception a) { + return -1; } - return -1; } private int getAudioDuration() { if (mDuration < 0) { - Uri uri = mComponent.getLocalUri(); + Uri uri = mComponent != null ? mComponent.getLocalUri() : null; if (uri != null) { mDuration = getAudioDuration(uri); } @@ -229,11 +243,13 @@ private void setTimeText(long duration) { } } - public static AudioContentView create(LayoutInflater inflater, ViewGroup parent, AudioPlayerControl control) { + public static AudioContentView create(LayoutInflater inflater, ViewGroup parent, AudioPlayerControl control, MessageListItemTheme theme) { AudioContentView view = (AudioContentView) inflater.inflate(R.layout.message_content_audio, parent, false); - if (view != null) + if (view != null) { view.mAudioPlayerControl = control; + } return view; } + } diff --git a/app/src/main/java/org/kontalk/ui/view/AudioContentViewControl.java b/app/src/main/java/org/kontalk/ui/view/AudioContentViewControl.java index 2c25d17e0..369279f92 100644 --- a/app/src/main/java/org/kontalk/ui/view/AudioContentViewControl.java +++ b/app/src/main/java/org/kontalk/ui/view/AudioContentViewControl.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/ui/view/AudioPlayerControl.java b/app/src/main/java/org/kontalk/ui/view/AudioPlayerControl.java index f7219c247..c23bfd60e 100644 --- a/app/src/main/java/org/kontalk/ui/view/AudioPlayerControl.java +++ b/app/src/main/java/org/kontalk/ui/view/AudioPlayerControl.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/kontalk/ui/view/AvatarListItem.java b/app/src/main/java/org/kontalk/ui/view/AvatarListItem.java index d59c392f1..7ce1e6a92 100644 --- a/app/src/main/java/org/kontalk/ui/view/AvatarListItem.java +++ b/app/src/main/java/org/kontalk/ui/view/AvatarListItem.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,17 +25,23 @@ import android.content.Context; import android.graphics.drawable.Drawable; import android.os.Handler; +import androidx.core.content.ContextCompat; import android.util.AttributeSet; -import android.widget.QuickContactBadge; import android.widget.RelativeLayout; + +/** + * A list item for avatar use. + * @author Daniele Ricci + */ public abstract class AvatarListItem extends RelativeLayout implements ContactCallback { - protected QuickContactBadge mAvatarView; + protected CircleContactBadge mAvatarView; private Handler mHandler; static protected Drawable sDefaultContactImage; + static protected Drawable sDefaultGroupImage; public AvatarListItem(Context context) { super(context); @@ -49,38 +55,56 @@ public AvatarListItem(Context context, AttributeSet attrs) { private void init(Context context) { mHandler = new Handler(); + } + private Drawable getDefaultContactImage() { if (sDefaultContactImage == null) - sDefaultContactImage = context.getResources() - .getDrawable(R.drawable.ic_contact_picture); + sDefaultContactImage = ContextCompat + .getDrawable(getContext(), R.drawable.ic_default_contact); + return sDefaultContactImage; + } + + private Drawable getDefaultGroupImage() { + if (sDefaultGroupImage == null) + sDefaultGroupImage = ContextCompat + .getDrawable(getContext(), R.drawable.ic_default_group); + return sDefaultGroupImage; } @Override protected void onFinishInflate() { super.onFinishInflate(); - mAvatarView = (QuickContactBadge) findViewById(R.id.avatar); + mAvatarView = findViewById(R.id.avatar); if (isInEditMode()) { - mAvatarView.setImageDrawable(sDefaultContactImage); + mAvatarView.setImageDrawable(getDefaultContactImage()); mAvatarView.setVisibility(VISIBLE); } } protected void loadAvatar(Contact contact) { - if (contact != null) { + Drawable defaultIcon = isGroupChat() ? + getDefaultGroupImage() : getDefaultContactImage(); + + // TODO group chats don't have avatars yet + if (contact != null && !isGroupChat()) { + // null tag - first time loading: set default + if (mAvatarView.getTag() == null) + mAvatarView.setImageDrawable(defaultIcon); // we mark this with the contact's hash code for the async avatar mAvatarView.setTag(contact.hashCode()); mAvatarView.assignContactUri(contact.getUri()); - mAvatarView.setImageDrawable(sDefaultContactImage); // laod avatar asynchronously contact.getAvatarAsync(getContext(), this); } else { mAvatarView.setTag(null); - mAvatarView.setImageDrawable(sDefaultContactImage); + mAvatarView.setImageDrawable(defaultIcon); } } + protected abstract boolean isGroupChat(); + @Override public void avatarLoaded(final Contact contact, final Drawable avatar) { if (avatar != null) { @@ -97,12 +121,12 @@ public void run() { } } - private void updateAvatar(Contact contact, Drawable avatar) { + void updateAvatar(Contact contact, Drawable avatar) { try { // be sure the contact is still the same // this is an insane workaround against race conditions Integer contactTag = (Integer) mAvatarView.getTag(); - if (contactTag != null && contactTag.intValue() == contact.hashCode()) + if (contactTag != null && contactTag == contact.hashCode()) mAvatarView.setImageDrawable(avatar); } catch (Exception e) { diff --git a/app/src/main/java/org/kontalk/ui/view/CircleCheckBox.java b/app/src/main/java/org/kontalk/ui/view/CircleCheckBox.java new file mode 100644 index 000000000..c648cc1eb --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/CircleCheckBox.java @@ -0,0 +1,116 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.Parcelable; +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; +import android.util.AttributeSet; +import android.widget.Checkable; + +import de.hdodenhof.circleimageview.CircleImageView; + +import org.kontalk.R; + + +/** + * A simple image view to handle checked state through use of checkbox image. + * @author Andrea Cappelli + */ +public class CircleCheckBox extends CircleImageView implements Checkable { + + private static final String KEY_INSTANCE_STATE = "InstanceState"; + + private boolean mChecked; + private OnCheckedChangeListener mListener; + + public CircleCheckBox(Context context) { + this(context, null); + } + + public CircleCheckBox(Context context, AttributeSet attrs) { + this(context, attrs, 0); + init(); + } + + public CircleCheckBox(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(); + } + + private void init() { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.N) { + Drawable d = VectorDrawableCompat.create(getResources(), R.drawable.ic_checkbox, getContext().getTheme()); + setImageDrawable(d); + } + else { + setImageResource(R.drawable.ic_checkbox); + } + } + + @Override + protected Parcelable onSaveInstanceState() { + Bundle bundle = new Bundle(); + bundle.putParcelable(KEY_INSTANCE_STATE, super.onSaveInstanceState()); + bundle.putBoolean(KEY_INSTANCE_STATE, isChecked()); + return bundle; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + if (state instanceof Bundle) { + Bundle bundle = (Bundle) state; + boolean isChecked = bundle.getBoolean(KEY_INSTANCE_STATE); + setChecked(isChecked); + super.onRestoreInstanceState(bundle.getParcelable(KEY_INSTANCE_STATE)); + return; + } + super.onRestoreInstanceState(state); + } + + + @Override + public boolean isChecked() { + return mChecked; + } + + @Override + public void toggle() { + this.setChecked(!isChecked()); + } + + @Override + public void setChecked(boolean checked) { + setVisibility(checked ? VISIBLE : GONE); + mChecked = checked; + if (mListener != null) { + mListener.onCheckedChanged(CircleCheckBox.this, mChecked); + } + } + + public void setOnCheckedChangeListener(CircleCheckBox.OnCheckedChangeListener l) { + this.mListener = l; + } + + public interface OnCheckedChangeListener { + void onCheckedChanged(CircleCheckBox checkBox, boolean isChecked); + } +} diff --git a/app/src/main/java/org/kontalk/ui/view/CircleContactBadge.java b/app/src/main/java/org/kontalk/ui/view/CircleContactBadge.java new file mode 100644 index 000000000..fb0c712c2 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/CircleContactBadge.java @@ -0,0 +1,82 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import android.content.Context; +import android.net.Uri; +import android.provider.ContactsContract; +import android.util.AttributeSet; +import android.view.View; + +import de.hdodenhof.circleimageview.CircleImageView; + + +/** + * A circle image view supporting the quick contact badge. + * @author Daniele Ricci + */ +public class CircleContactBadge extends CircleImageView implements View.OnClickListener { + private Uri mContactUri; + protected String[] mExcludeMimes = null; + + public CircleContactBadge(Context context) { + this(context, null); + } + + public CircleContactBadge(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CircleContactBadge(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + setOnClickListener(this); + } + + public void assignContactUri(Uri contactUri) { + mContactUri = contactUri; + onContactUriChanged(); + } + + private void onContactUriChanged() { + setEnabled(isAssigned()); + } + + private boolean isAssigned() { + return mContactUri != null; + } + + @Override + public void onClick(View v) { + if (mContactUri != null) { + ContactsContract.QuickContact.showQuickContact(getContext(), this, + mContactUri, ContactsContract.QuickContact.MODE_LARGE, mExcludeMimes); + } + } + + /** + * Set a list of specific MIME-types to exclude and not display. For + * example, this can be used to hide the {@link ContactsContract.Contacts#CONTENT_ITEM_TYPE} + * profile icon. + */ + public void setExcludeMimes(String[] excludeMimes) { + mExcludeMimes = excludeMimes; + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/CircularSeekBar.java b/app/src/main/java/org/kontalk/ui/view/CircularSeekBar.java index bdf977e81..52cd71fcd 100644 --- a/app/src/main/java/org/kontalk/ui/view/CircularSeekBar.java +++ b/app/src/main/java/org/kontalk/ui/view/CircularSeekBar.java @@ -855,7 +855,7 @@ public void setOnSeekBarChangeListener(OnCircularSeekBarChangeListener l) { public interface OnCircularSeekBarChangeListener { - public abstract void onProgressChanged(CircularSeekBar circularSeekBar, float mProgress, boolean fromUser); + void onProgressChanged(CircularSeekBar circularSeekBar, float mProgress, boolean fromUser); } diff --git a/app/src/main/java/org/kontalk/ui/view/ComposerBar.java b/app/src/main/java/org/kontalk/ui/view/ComposerBar.java index 9364012da..dd6f3f7f1 100644 --- a/app/src/main/java/org/kontalk/ui/view/ComposerBar.java +++ b/app/src/main/java/org/kontalk/ui/view/ComposerBar.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2011 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,18 +20,19 @@ import java.io.File; import java.io.IOException; +import java.util.concurrent.TimeUnit; -import com.nineoldandroids.animation.Animator; -import com.nineoldandroids.view.ViewHelper; -import com.nineoldandroids.view.ViewPropertyAnimator; -import com.rockerhieu.emojicon.EmojiconsView; -import com.rockerhieu.emojicon.OnEmojiconClickedListener; -import com.rockerhieu.emojicon.emoji.Emojicon; +import com.vanniktech.emoji.EmojiEditText; +import com.vanniktech.emoji.EmojiPopup; +import com.vanniktech.emoji.listeners.OnEmojiPopupDismissListener; +import com.vanniktech.emoji.listeners.OnEmojiPopupShownListener; +import android.animation.Animator; import android.annotation.TargetApi; import android.app.Activity; import android.content.Context; import android.content.pm.ActivityInfo; +import android.graphics.Rect; import android.media.MediaRecorder; import android.net.Uri; import android.os.Build; @@ -44,28 +45,27 @@ import android.text.TextWatcher; import android.text.format.DateUtils; import android.util.AttributeSet; -import android.util.Log; -import android.view.Gravity; import android.view.KeyEvent; -import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; -import android.view.WindowManager; import android.view.animation.AccelerateDecelerateInterpolator; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; -import android.widget.EditText; import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.RelativeLayout; import android.widget.TextView; import android.widget.Toast; +import org.kontalk.Log; import org.kontalk.R; import org.kontalk.message.AudioComponent; import org.kontalk.ui.AudioDialog; import org.kontalk.ui.ComposeMessage; +import org.kontalk.util.AudioRecording; import org.kontalk.util.MediaStorage; +import org.kontalk.util.MessageUtils; +import org.kontalk.util.Permissions; import org.kontalk.util.Preferences; import org.kontalk.util.SystemUtils; @@ -76,54 +76,58 @@ * @author Andrea Cappelli */ public class ComposerBar extends RelativeLayout implements - EmojiconsView.OnEmojiconBackspaceClickedListener, OnEmojiconClickedListener { + OnEmojiPopupShownListener, OnEmojiPopupDismissListener { private static final String TAG = ComposeMessage.TAG; private static final int MIN_RECORDING_TIME = 900; - private static final int MAX_RECORDING_TIME = 60000; + static final long MAX_RECORDING_TIME = TimeUnit.MINUTES.toMillis(2); private static final int AUDIO_RECORD_VIBRATION = 20; private static final int AUDIO_RECORD_ANIMATION = 300; - private Context mContext; + private static final String MAX_RECORDING_TIME_TEXT = DateUtils + .formatElapsedTime(MAX_RECORDING_TIME / 1000); + + Context mContext; // for the text entry - private EditText mTextEntry; - private View mSendButton; - private ComposerListener mListener; + boolean mSendEnabled = true; + EmojiEditText mTextEntry; + private View mAttachButton; + View mSendButton; + ComposerListener mListener; private TextWatcher mChatStateListener; + boolean mEnterSend; + /** Used during audio recording to restore focus status of the text entry. */ private boolean mTextEntryFocus; - private boolean mComposeSent; + boolean mComposeSent; // for Emoji drawer private ImageButton mEmojiButton; - private EmojiconsView mEmojiView; - private boolean mEmojiVisible; - private KeyboardAwareRelativeLayout mRootView; - private WindowManager.LayoutParams mWindowLayoutParams; + private EmojiPopup mEmojiView; // for PTT message - private Handler mHandler; + Handler mHandler; private Runnable mMediaPlayerUpdater; - private View mAudioButton; - private View mRecordLayout; - private View mSlideText; - private float mDraggingX = -1; - private float mDistMove; - private boolean mIsRecordingAudio; + View mAudioButton; + View mRecordLayout; + View mSlideText; + float mDraggingX = -1; + float mDistMove; + boolean mIsRecordingAudio; private TextView mRecordText; private File mRecordFile; private MediaRecorder mRecord; - private long startTime; - private long elapsedTime; - private boolean mCheckMove; - private int mOrientation; + long startTime; + long elapsedTime; + boolean mCheckMove; + int mOrientation; private Vibrator mVibrator; // initialized in onCreate - private int mMoveThreshold; - private int mMoveOffset; - private int mMoveOffset2; + int mMoveThreshold; + int mMoveOffset; + int mMoveOffset2; public ComposerBar(Context context) { super(context); @@ -154,7 +158,7 @@ private void init(Context context) { @Override protected void onFinishInflate() { super.onFinishInflate(); - mTextEntry = (EditText) findViewById(R.id.text_editor); + mTextEntry = findViewById(R.id.text_editor); // enter key flag int inputTypeFlags; @@ -167,6 +171,7 @@ else if ("newline_send".equals(enterKeyMode)) { InputType.TYPE_TEXT_VARIATION_LONG_MESSAGE; mTextEntry.setImeOptions(EditorInfo.IME_ACTION_SEND); mTextEntry.setInputType(inputTypeFlags); + mEnterSend = true; } else { inputTypeFlags = mTextEntry.getInputType() | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE; @@ -177,6 +182,8 @@ else if ("newline_send".equals(enterKeyMode)) { mTextEntry.addTextChangedListener(new TextWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { + if (mListener != null) + mListener.onTextEntryFocus(); } @Override @@ -191,15 +198,32 @@ public void afterTextChanged(Editable s) { mAudioButton.setVisibility(textPresent ? View.INVISIBLE : View.VISIBLE); mSendButton.setVisibility(textPresent ? View.VISIBLE : View.INVISIBLE); } - mSendButton.setEnabled(textPresent); + // audio button should already be disabled if needed + mSendButton.setEnabled(textPresent && mSendEnabled); + + if (mListener != null) + mListener.textChanged(s); + + // convert ascii to emojis if preference set + if (Preferences.getEmojiConverter(mContext)) { + mTextEntry.removeTextChangedListener(this); + if (MessageUtils.convertSmileys(s)) { + // restart IME to solve problems with deleting emojis + InputMethodManager imm = (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.restartInput(mTextEntry); + } + mTextEntry.addTextChangedListener(this); + } } }); mTextEntry.setOnEditorActionListener(new TextView.OnEditorActionListener() { public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { - if (actionId == EditorInfo.IME_ACTION_SEND) { - InputMethodManager imm = (InputMethodManager) mContext - .getSystemService(Context.INPUT_METHOD_SERVICE); - imm.hideSoftInputFromWindow(v.getApplicationWindowToken(), 0); + if (actionId == EditorInfo.IME_ACTION_SEND && mSendEnabled) { + if (!mEnterSend) { + InputMethodManager imm = (InputMethodManager) mContext + .getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(v.getApplicationWindowToken(), 0); + } submitSend(); return true; } @@ -208,9 +232,9 @@ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { }); mChatStateListener = new TextWatcher() { public void onTextChanged(CharSequence s, int start, int before, int count) { - if (Preferences.getSendTyping(mContext)) { + if (mSendEnabled && Preferences.getSendTyping(mContext)) { // send typing notification if necessary - if (!mComposeSent && mListener.sendTyping()) { + if (!mComposeSent && mListener != null && mListener.sendTyping()) { mComposeSent = true; } } @@ -223,17 +247,25 @@ public void beforeTextChanged(CharSequence s, int start, int count, int after) { public void afterTextChanged(Editable s) { } }; + mTextEntry.addTextChangedListener(mChatStateListener); mTextEntry.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { if (isEmojiVisible()) hideEmojiDrawer(false); + if (mListener != null) + mListener.onTextEntryFocus(); } }); mTextEntry.setOnFocusChangeListener(new View.OnFocusChangeListener() { public void onFocusChange(View v, boolean hasFocus) { - if (hasFocus && isEmojiVisible()) - hideEmojiDrawer(false); + if (hasFocus) { + if (isEmojiVisible()) { + hideEmojiDrawer(false); + } + if (mListener != null) + mListener.onTextEntryFocus(); + } } }); @@ -245,6 +277,15 @@ public void onClick(View v) { submitSend(); } }); + findViewById(R.id.attach_button).setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if (mListener != null) + mListener.onAttachClick(); + } + }); + + mAttachButton = findViewById(R.id.attach_button); if (AudioDialog.isSupported(mContext)) { mAudioButton = findViewById(R.id.audio_send_button); @@ -255,7 +296,7 @@ public void onClick(View v) { } mSlideText = findViewById(R.id.slide_text); - mRecordText = (TextView) findViewById(R.id.recording_time); + mRecordText = findViewById(R.id.recording_time); int screenWidth = SystemUtils.getDisplaySize(mContext).x; // position of "slide to cancel" label (actually left margin; screen dependent) @@ -279,10 +320,11 @@ public boolean onTouch(View view, MotionEvent motionEvent) { animateRecordFrame(); mAudioButton.getParent().requestDisallowInterceptTouchEvent(true); } - else if ((motionEvent.getAction() == MotionEvent.ACTION_UP || motionEvent.getAction() == MotionEvent.ACTION_CANCEL) && !mCheckMove) { + else if ((motionEvent.getAction() == MotionEvent.ACTION_UP || motionEvent.getAction() == MotionEvent.ACTION_CANCEL) && + !mCheckMove && mIsRecordingAudio) { if (mOrientation == SystemUtils.getDisplayRotation(mContext)) { mDraggingX = -1; - stopRecording(true); + stopRecording(motionEvent.getAction() == MotionEvent.ACTION_UP); animateRecordFrame(); } } @@ -293,7 +335,7 @@ else if (motionEvent.getAction() == MotionEvent.ACTION_MOVE && mIsRecordingAudio stopRecording(false); animateRecordFrame(); } - float currentX = ViewHelper.getX(mAudioButton); + float currentX = mAudioButton.getX(); x = x + currentX; FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) mSlideText.getLayoutParams(); if (mDraggingX != -1) { @@ -307,7 +349,7 @@ else if (motionEvent.getAction() == MotionEvent.ACTION_MOVE && mIsRecordingAudio else if (alpha < 0) { alpha = 0; } - ViewHelper.setAlpha(mSlideText, alpha); + mSlideText.setAlpha(alpha); } if (x <= currentX + mSlideText.getWidth() + mMoveThreshold) { if (mDraggingX == -1) { @@ -324,7 +366,7 @@ else if (mDistMove > mMoveOffset) { if (params.leftMargin > mMoveThreshold) { params.leftMargin = mMoveThreshold; mSlideText.setLayoutParams(params); - ViewHelper.setAlpha(mSlideText, 1); + mSlideText.setAlpha(1); mDraggingX = -1; } } @@ -335,13 +377,16 @@ else if (mDistMove > mMoveOffset) { mVibrator = (Vibrator) mContext.getSystemService(Context.VIBRATOR_SERVICE); } - mEmojiButton = (ImageButton) findViewById(R.id.emoji_button); + mEmojiButton = findViewById(R.id.emoji_button); mEmojiButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - toggleEmojiDrawer(); + if (mListener != null && mListener.canOpenEmoji()) + toggleEmojiDrawer(); } }); + + doSetSendEnabled(); } public void onPause() { @@ -350,10 +395,26 @@ public void onPause() { } } + public boolean isSendEnabled() { + return mSendEnabled; + } + + public void setSendEnabled(boolean enabled) { + mSendEnabled = enabled; + doSetSendEnabled(); + } + + private void doSetSendEnabled() { + mSendButton.setEnabled(mSendEnabled); + mAttachButton.setEnabled(mSendEnabled); + if (mAudioButton != null) + mAudioButton.setEnabled(mSendEnabled); + } + public void setRootView(View rootView) { - mRootView = (KeyboardAwareRelativeLayout) rootView; // this will handle closing of keyboard while emoji drawer is open - mRootView.setOnKeyboardShownListener(new KeyboardAwareRelativeLayout.OnKeyboardShownListener() { + /* TODO port to EmojiPopup listener? + rootView.setOnKeyboardShownListener(new KeyboardAwareRelativeLayout.OnKeyboardShownListener() { @Override public void onKeyboardShown(boolean visible) { if (!visible && mRootView.getPaddingBottom() == 0 && isEmojiVisible()) { @@ -361,6 +422,19 @@ public void onKeyboardShown(boolean visible) { } } }); + */ + + // initialize emoji popup immediately because the library asked that + mEmojiView = EmojiPopup.Builder.fromRootView(rootView) + .setOnEmojiPopupShownListener(this) + .setOnEmojiPopupDismissListener(this) + .build(mTextEntry); + } + + public void forceHideKeyboard() { + InputMethodManager imm = (InputMethodManager) mContext + .getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mTextEntry.getApplicationWindowToken(), 0); } public void onSaveInstanceState(Bundle out) { @@ -380,20 +454,25 @@ private void enableTextEntry() { mTextEntry.requestFocus(); } - private void animateRecordFrame() { + @Override + public boolean requestFocus(int direction, Rect previouslyFocusedRect) { + return mTextEntry.requestFocus(direction, previouslyFocusedRect); + } + + void animateRecordFrame() { int screenWidth = SystemUtils.getDisplaySize(mContext).x; if (mIsRecordingAudio) { mRecordLayout.setVisibility(View.VISIBLE); - mRecordText.setText(DateUtils.formatElapsedTime(0)); + setRecordText(0); FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) mSlideText.getLayoutParams(); params.leftMargin = mMoveThreshold; mSlideText.setLayoutParams(params); - ViewHelper.setAlpha(mSlideText, 1); - ViewHelper.setX(mRecordLayout, screenWidth); - ViewPropertyAnimator.animate(mRecordLayout) + mSlideText.setAlpha(1); + mRecordLayout.setX(screenWidth); + mRecordLayout.animate() .setInterpolator(new AccelerateDecelerateInterpolator()) .setListener(new Animator.AnimatorListener() { @Override @@ -402,7 +481,7 @@ public void onAnimationStart(Animator animator) { @Override public void onAnimationEnd(Animator animator) { - ViewHelper.setX(mRecordLayout, 0); + mRecordLayout.setX(0); } @Override @@ -418,7 +497,7 @@ public void onAnimationRepeat(Animator animator) { .start(); } else { - ViewPropertyAnimator.animate(mRecordLayout) + mRecordLayout.animate() .setInterpolator(new AccelerateDecelerateInterpolator()) .setListener(new Animator.AnimatorListener() { @Override @@ -431,7 +510,7 @@ public void onAnimationEnd(Animator animator) { (FrameLayout.LayoutParams) mSlideText.getLayoutParams(); params.leftMargin = mMoveThreshold; mSlideText.setLayoutParams(params); - ViewHelper.setAlpha(mSlideText, 1); + mSlideText.setAlpha(1); mRecordLayout.setVisibility(View.GONE); } @@ -449,38 +528,42 @@ public void onAnimationRepeat(Animator animator) { } } - private void startRecording() { + void startRecording() { // ask parent to stop all sounds - mListener.stopAllSounds(); + if (mListener != null) + mListener.stopAllSounds(); + + if (!Permissions.canRecordAudio(getContext())) { + Permissions.requestRecordAudio((Activity) getContext(), + getContext().getString(R.string.err_audio_or_storage_denied)); + } + else { + doStartRecording(); + } + } + private void doStartRecording() { try { - mRecordFile = MediaStorage.getOutgoingAudioFile(); + mRecordFile = MediaStorage.getOutgoingAudioFile(mContext); } catch (IOException e) { Log.e(TAG, "error creating audio file", e); - Toast.makeText(mContext, R.string.err_audio_record_writing, - Toast.LENGTH_LONG).show(); + Toast.makeText(mContext, R.string.err_audio_record_writing, Toast.LENGTH_LONG).show(); return; } mRecord = new MediaRecorder(); - mRecord.setAudioSource(MediaRecorder.AudioSource.MIC); - mRecord.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP); - mRecord.setOutputFile(mRecordFile.getAbsolutePath()); - mRecord.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB); try { + AudioRecording.setupMediaRecorder(mRecord, mRecordFile); mVibrator.vibrate(AUDIO_RECORD_VIBRATION); startTimer(); mRecord.prepare(); // Start recording mRecord.start(); mIsRecordingAudio = true; - lockOrientation(); + lockScreen(); disableTextEntry(); } - catch (IllegalStateException e) { - Log.e(TAG, "error starting audio recording:", e); - } catch (IOException e) { Log.e(TAG, "error writing on external storage:", e); Toast.makeText(mContext, R.string.err_audio_record_writing, @@ -488,30 +571,44 @@ private void startRecording() { } catch (RuntimeException e) { Log.e(TAG, "error starting audio recording:", e); - Toast.makeText(mContext, R.string.err_audio_record, - Toast.LENGTH_LONG).show(); + + int resId; + if (Permissions.canRecordAudio(getContext())) { + resId = R.string.err_audio_record; + } + else { + resId = R.string.err_audio_record_permission; + } + Toast.makeText(mContext, resId, Toast.LENGTH_LONG).show(); } } - private void stopRecording(boolean send) { + void stopRecording(boolean send) { mIsRecordingAudio = false; - unlockOrientation(); + unlockScreen(); enableTextEntry(); mVibrator.vibrate(AUDIO_RECORD_VIBRATION); if (mMediaPlayerUpdater != null) mHandler.removeCallbacks(mMediaPlayerUpdater); - boolean canSend = send && (elapsedTime > MIN_RECORDING_TIME); + boolean minDuration = (elapsedTime > MIN_RECORDING_TIME); + boolean canSend = send && minDuration; + // reset elapsed recording time + elapsedTime = 0; try { if (mRecord != null) { mRecord.stop(); - mRecord.reset(); - mRecord.release(); if (canSend) { - mListener.sendBinaryMessage(Uri.fromFile(mRecordFile), - AudioDialog.DEFAULT_MIME, true, AudioComponent.class); + if (mListener != null) { + mListener.sendBinaryMessage(Uri.fromFile(mRecordFile), + AudioRecording.MIME_TYPE, false, AudioComponent.class); + } + } + else if (send) { + Toast.makeText(mContext, R.string.hint_ptt, + Toast.LENGTH_LONG).show(); } } } @@ -520,12 +617,25 @@ private void stopRecording(boolean send) { canSend = false; } catch (RuntimeException e) { - Log.w(TAG, "no audio data received", e); + if (send) { + int msgId; + if (!minDuration) { + msgId = R.string.hint_ptt; + } + else { + Log.w(TAG, "no audio data received", e); + msgId = R.string.err_audio_record_noaudio; + } + Toast.makeText(mContext, msgId, Toast.LENGTH_LONG).show(); + } canSend = false; - Toast.makeText(mContext, R.string.err_audio_record, - Toast.LENGTH_LONG).show(); } finally { + if (mRecord != null) { + mRecord.reset(); + mRecord.release(); + } + if (!canSend && mRecordFile != null) mRecordFile.delete(); } @@ -541,16 +651,18 @@ private void abortRecording() { mRecordLayout.setVisibility(View.GONE); } - private void lockOrientation() { + private void lockScreen() { int orientation = SystemUtils.getScreenOrientation((Activity) mContext); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR2) orientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED; //noinspection ResourceType ((Activity) mContext).setRequestedOrientation(orientation); + SystemUtils.acquireScreenOn((Activity) mContext); } - private void unlockOrientation() { + private void unlockScreen() { ((Activity) mContext).setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + SystemUtils.releaseScreenOn((Activity) mContext); } private void startTimer() { @@ -559,7 +671,7 @@ private void startTimer() { @Override public void run() { elapsedTime = SystemClock.uptimeMillis() - startTime; - mRecordText.setText(DateUtils.formatElapsedTime(elapsedTime / 1000)); + setRecordText(elapsedTime); mHandler.postDelayed(this, 100); if (elapsedTime >= MAX_RECORDING_TIME) { mAudioButton.setPressed(false); @@ -571,10 +683,18 @@ public void run() { mHandler.postDelayed(mMediaPlayerUpdater, 100); } - private void submitSend() { + void setRecordText(long millis) { + mRecordText.setText(mContext.getString(R.string.audio_duration_max, + DateUtils.formatElapsedTime(millis / 1000), + MAX_RECORDING_TIME_TEXT)); + } + + void submitSend() { mTextEntry.removeTextChangedListener(mChatStateListener); // send message - mListener.sendTextMessage(mTextEntry.getText().toString()); + if (mListener != null) { + mListener.sendTextMessage(mTextEntry.getText().toString()); + } // empty text mTextEntry.setText(""); // hide softkeyboard @@ -588,23 +708,26 @@ private void submitSend() { } } + /** Returns true if typing message was sent. */ + public boolean isComposeSent() { + return mComposeSent; + } + @Override - public void onEmojiconBackspaceClicked(View v) { - EmojiconsView.backspace(mTextEntry); + public void onEmojiPopupShown() { + mEmojiButton.setImageResource(R.drawable.ic_keyboard); } @Override - public void onEmojiconClicked(Emojicon emojicon) { - EmojiconsView.input(mTextEntry, emojicon); + public void onEmojiPopupDismiss() { + mEmojiButton.setImageResource(R.drawable.ic_emoji); } public boolean isEmojiVisible() { - return mEmojiVisible; + return mEmojiView != null && mEmojiView.isShowing(); } - private void toggleEmojiDrawer() { - // TODO animate drawer enter & exit - + void toggleEmojiDrawer() { if (isEmojiVisible()) { hideEmojiDrawer(); } @@ -614,53 +737,9 @@ private void toggleEmojiDrawer() { } private void showEmojiDrawer() { - int keyboardHeight = mRootView.getKeyboardHeight(); - - mEmojiVisible = true; - - if (mEmojiView == null) { - mEmojiView = (EmojiconsView) LayoutInflater - .from(mContext).inflate(R.layout.emojicons, mRootView, false); - mEmojiView.setId(R.id.emoji_drawer); - mEmojiView.setOnEmojiconBackspaceClickedListener(this); - mEmojiView.setOnEmojiconClickedListener(this); - - mWindowLayoutParams = new WindowManager.LayoutParams(); - mWindowLayoutParams.gravity = Gravity.BOTTOM | Gravity.LEFT; - mWindowLayoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL; - mWindowLayoutParams.token = ((Activity) mContext).getWindow().getDecorView().getWindowToken(); - mWindowLayoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | - WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL; - } - - mWindowLayoutParams.height = keyboardHeight; - mWindowLayoutParams.width = SystemUtils.getDisplaySize(mContext).x; - - WindowManager wm = (WindowManager) mContext.getSystemService(Activity.WINDOW_SERVICE); - - try { - if (mEmojiView.getParent() != null) { - wm.removeViewImmediate(mEmojiView); - } - } - catch (Exception e) { - Log.e(TAG, "error removing emoji view", e); - } - - try { - wm.addView(mEmojiView, mWindowLayoutParams); - } - catch (Exception e) { - Log.e(TAG, "error adding emoji view", e); - return; - } - - if (!mRootView.isKeyboardVisible()) { - mRootView.setPadding(0, 0, 0, keyboardHeight); - // TODO mEmojiButton.setImageResource(R.drawable.ic_msg_panel_hide); - } - - mEmojiButton.setImageResource(R.drawable.ic_keyboard_dark); + // this is called only when isEmojiVisible() returns false + // so it's guaranteed to always do the show and not the hide + mEmojiView.toggle(); } private void hideEmojiDrawer() { @@ -674,15 +753,7 @@ public void hideEmojiDrawer(boolean showKeyboard) { input.showSoftInput(mTextEntry, 0); } - if (mEmojiView != null && mEmojiView.getParent() != null) { - WindowManager wm = (WindowManager) mContext - .getSystemService(Context.WINDOW_SERVICE); - wm.removeViewImmediate(mEmojiView); - } - - mEmojiButton.setImageResource(R.drawable.ic_emoji_dark); - mRootView.setPadding(0, 0, 0, 0); - mEmojiVisible = false; + mEmojiView.dismiss(); } public void setComposerListener(ComposerListener listener) { diff --git a/app/src/main/java/org/kontalk/ui/view/ComposerListener.java b/app/src/main/java/org/kontalk/ui/view/ComposerListener.java index 8c5599782..c84b491af 100644 --- a/app/src/main/java/org/kontalk/ui/view/ComposerListener.java +++ b/app/src/main/java/org/kontalk/ui/view/ComposerListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2011 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -32,6 +32,8 @@ public interface ComposerListener { /** Sends a text message. */ void sendTextMessage(String message); + void sendLocationMessage(String message, double lat, double lon, String geoText, String geoStreet); + /** Sends a binary message. */ void sendBinaryMessage(Uri uri, String mime, boolean media, Class> klass); @@ -45,4 +47,16 @@ void sendBinaryMessage(Uri uri, String mime, boolean media, /** Asks the parent to stop all sounds. */ void stopAllSounds(); + /** Text has been changed. */ + void textChanged(CharSequence text); + + /** The attach button has been clicked. */ + void onAttachClick(); + + /** The text entry has gained focus. */ + void onTextEntryFocus(); + + /** Return false if for whatever reason the emoji panel can't be opened. */ + boolean canOpenEmoji(); + } diff --git a/app/src/main/java/org/kontalk/ui/view/ContactInfoBanner.java b/app/src/main/java/org/kontalk/ui/view/ContactInfoBanner.java new file mode 100644 index 000000000..bf76d02a1 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/ContactInfoBanner.java @@ -0,0 +1,75 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.TextView; + +import org.kontalk.data.Contact; + + +public class ContactInfoBanner extends AvatarListItem { + + private Contact mContact; + private TextView mText1; + private TextView mText2; + + public ContactInfoBanner(Context context) { + super(context); + } + + public ContactInfoBanner(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mText1 = findViewById(android.R.id.text1); + mText2 = findViewById(android.R.id.text2); + } + + public final void bind(Context context, final Contact contact) { + mContact = contact; + + loadAvatar(contact); + + mText1.setText(contact.getDisplayName()); + } + + public final void unbind() { + mContact = null; + } + + public void setSummary(CharSequence summary) { + mText2.setText(summary); + } + + public Contact getContact() { + return mContact; + } + + @Override + protected boolean isGroupChat() { + return false; + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/ContactPickerListener.java b/app/src/main/java/org/kontalk/ui/view/ContactPickerListener.java index 717a46293..0fb44fafe 100644 --- a/app/src/main/java/org/kontalk/ui/view/ContactPickerListener.java +++ b/app/src/main/java/org/kontalk/ui/view/ContactPickerListener.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,6 +18,8 @@ package org.kontalk.ui.view; +import java.util.List; + import org.kontalk.data.Contact; import org.kontalk.ui.ContactsListFragment; @@ -29,6 +31,12 @@ public interface ContactPickerListener { /** Called when a contact has been selected from a {@link org.kontalk.ui.ContactsListFragment}. */ - public void onContactSelected(ContactsListFragment fragment, Contact contact); + void onContactSelected(ContactsListFragment fragment, Contact contact); + + /** + * Called when one or more contacts have been selected from a + * {@link org.kontalk.ui.ContactsListFragment}. + */ + void onContactsSelected(ContactsListFragment fragment, List contacts); } diff --git a/app/src/main/java/org/kontalk/ui/view/ContactsListItem.java b/app/src/main/java/org/kontalk/ui/view/ContactsListItem.java index cf9202ac1..48fa34e7e 100644 --- a/app/src/main/java/org/kontalk/ui/view/ContactsListItem.java +++ b/app/src/main/java/org/kontalk/ui/view/ContactsListItem.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,17 +20,34 @@ import org.kontalk.R; import org.kontalk.data.Contact; +import org.kontalk.provider.MyUsers; +import org.kontalk.util.SystemUtils; +import android.annotation.SuppressLint; import android.content.Context; +import androidx.core.content.ContextCompat; +import androidx.core.widget.TextViewCompat; + +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.CharacterStyle; import android.util.AttributeSet; +import android.widget.Checkable; +import android.widget.ImageView; import android.widget.TextView; -public class ContactsListItem extends AvatarListItem { +public class ContactsListItem extends AvatarListItem implements Checkable { + + private static final int[] CHECKED_STATE_SET = { android.R.attr.state_checked }; private Contact mContact; private TextView mText1; private TextView mText2; + private ImageView mTrustStatus; + private CircleCheckBox mCheckbox; + + private boolean mChecked; public ContactsListItem(Context context) { super(context); @@ -40,12 +57,15 @@ public ContactsListItem(Context context, AttributeSet attrs) { super(context, attrs); } + @SuppressLint("SetTextI18n") @Override protected void onFinishInflate() { super.onFinishInflate(); - mText1 = (TextView) findViewById(android.R.id.text1); - mText2 = (TextView) findViewById(android.R.id.text2); + mText1 = findViewById(android.R.id.text1); + mText2 = findViewById(android.R.id.text2); + mTrustStatus = findViewById(R.id.trust_status); + mCheckbox = findViewById(R.id.checkbox); if (isInEditMode()) { mText1.setText("Test contact"); @@ -54,20 +74,92 @@ protected void onFinishInflate() { } public final void bind(Context context, final Contact contact) { + bind(context, contact, null, null); + } + + public final void bind(Context context, final Contact contact, String prependStatus, CharacterStyle prependStyle) { + bind(context, contact, prependStatus, prependStyle, true); + } + + public final void bind(Context context, final Contact contact, String prependStatus, CharacterStyle prependStyle, Boolean subscribed) { mContact = contact; + setChecked(false); + loadAvatar(contact); - mText1.setText(contact.getName()); - String text2 = contact.getStatus(); - if (text2 == null) { - text2 = contact.getNumber(); - mText2.setTextColor(getResources().getColor(R.color.grayed_out)); + mText1.setText(contact.getDisplayName()); + + if (isGroupChat()) { + // group chats have no subtitle + mText2.setText(""); + + // enable group chat marker + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(mText1, + R.drawable.ic_indicator_group, 0, 0, 0); } else { - mText2.setTextColor(getResources().getColor(android.R.color.secondary_text_light)); + CharSequence text2 = contact.getStatus(); + if (text2 == null) { + text2 = contact.getNumber(); + if (text2 == null) + text2 = contact.getJID(); + mText2.setTextColor(ContextCompat.getColor(context, R.color.grayed_out)); + } + else { + int color = ContextCompat.getColor(context, + SystemUtils.getThemedResource(getContext(), android.R.attr.textColorSecondary)); + mText2.setTextColor(color); + } + if (prependStatus != null) { + if (prependStyle != null) { + text2 = new SpannableString(prependStatus + " " + text2); + ((SpannableString) text2).setSpan(prependStyle, 0, prependStatus.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + else { + text2 = prependStatus + " " + text2; + } + } + mText2.setText(text2); + } + + if (mTrustStatus != null) { + int resId; + + if (subscribed == null) { + resId = 0; + } + else if (!subscribed) { + resId = R.drawable.ic_denied; + } + else if (contact.isKeyChanged()) { + // the key has changed and was not trusted yet + resId = R.drawable.ic_trust_unknown; + } + else { + switch (contact.getTrustedLevel()) { + case MyUsers.Keys.TRUST_UNKNOWN: + resId = R.drawable.ic_trust_unknown; + break; + case MyUsers.Keys.TRUST_IGNORED: + resId = R.drawable.ic_trust_ignored; + break; + case MyUsers.Keys.TRUST_VERIFIED: + resId = R.drawable.ic_trust_verified; + break; + default: + resId = -1; + } + } + + if (resId > 0) { + mTrustStatus.setImageResource(resId); + } + else { + mTrustStatus.setImageDrawable(null); + } } - mText2.setText(text2); + } public final void unbind() { @@ -86,4 +178,40 @@ public Contact getContact() { return mContact; } + @Override + protected boolean isGroupChat() { + return mContact.isGroup(); + } + + @Override + public boolean isChecked() { + return mChecked; + } + + @Override + public void setChecked(boolean checked) { + if (checked != mChecked) { + mChecked = checked; + if (mCheckbox != null) { + mCheckbox.setChecked(checked); + mAvatarView.setVisibility(checked ? GONE : VISIBLE); + } + refreshDrawableState(); + } + } + + @Override + protected int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + if (isChecked()) { + mergeDrawableStates(drawableState, CHECKED_STATE_SET); + } + return drawableState; + } + + @Override + public void toggle() { + setChecked(!mChecked); + } + } diff --git a/app/src/main/java/org/kontalk/ui/view/ConversationListItem.java b/app/src/main/java/org/kontalk/ui/view/ConversationListItem.java index 5352d2a77..faa9371ee 100644 --- a/app/src/main/java/org/kontalk/ui/view/ConversationListItem.java +++ b/app/src/main/java/org/kontalk/ui/view/ConversationListItem.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,41 +18,46 @@ package org.kontalk.ui.view; -import org.kontalk.BuildConfig; -import org.kontalk.R; -import org.kontalk.data.Contact; -import org.kontalk.data.Conversation; -import org.kontalk.message.CompositeMessage; -import org.kontalk.provider.MyMessages.Messages; -import org.kontalk.provider.MyMessages.Threads; -import org.kontalk.util.MessageUtils; - +import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Typeface; +import androidx.core.content.ContextCompat; +import androidx.core.widget.TextViewCompat; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; +import android.text.TextUtils; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; import android.util.AttributeSet; -import android.widget.Checkable; import android.widget.ImageView; import android.widget.TextView; -public class ConversationListItem extends AvatarListItem implements Checkable { +import org.kontalk.BuildConfig; +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.data.Conversation; +import org.kontalk.message.CompositeMessage; +import org.kontalk.message.GroupCommandComponent; +import org.kontalk.provider.MessagesProviderClient.GroupThreadContent; +import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.provider.MyMessages.Threads; +import org.kontalk.util.MessageUtils; + + +public class ConversationListItem extends AvatarListItem { private static final StyleSpan STYLE_BOLD = new StyleSpan(Typeface.BOLD); + private static final StyleSpan STYLE_ITALIC = new StyleSpan(Typeface.ITALIC); private Conversation mConversation; private TextView mSubjectView; private TextView mFromView; private TextView mDateView; - //private View mAttachmentView; + private ImageView mSticky; private ImageView mErrorIndicator; private TextView mCounterView; - private boolean mChecked = false; - public ConversationListItem(Context context) { super(context); } @@ -61,17 +66,18 @@ public ConversationListItem(Context context, AttributeSet attrs) { super(context, attrs); } + @SuppressLint("SetTextI18n") @Override protected void onFinishInflate() { super.onFinishInflate(); - mFromView = (TextView) findViewById(R.id.from); - mSubjectView = (TextView) findViewById(R.id.subject); + mFromView = findViewById(R.id.from); + mSubjectView = findViewById(R.id.subject); - mDateView = (TextView) findViewById(R.id.date); - //mAttachmentView = findViewById(R.id.attachment); - mErrorIndicator = (ImageView) findViewById(R.id.error); - mCounterView = (TextView) findViewById(R.id.counter); + mDateView = findViewById(R.id.date); + mSticky = findViewById(R.id.sticky); + mErrorIndicator = findViewById(R.id.error); + mCounterView = findViewById(R.id.counter); if (isInEditMode()) { mFromView.setText("Test zio"); @@ -86,28 +92,49 @@ protected void onFinishInflate() { } } - public final void bind(Context context, final Conversation conv) { + public final void bind(Context context, final Conversation conv, boolean selected) { mConversation = conv; - mChecked = false; + setActivated(selected); + + Contact contact; + // used for the conversation subject: either group subject or contact name String recipient = null; - Contact contact = mConversation.getContact(); + if (mConversation.isGroupChat()) { + recipient = mConversation.getGroupSubject(); + if (TextUtils.isEmpty(recipient)) + recipient = context.getString(R.string.group_untitled); - if (contact != null) { - recipient = contact.getName(); + // enable group chat marker + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(mFromView, + R.drawable.ic_indicator_group, 0, 0, 0); + + loadAvatar(null); } + else { + contact = mConversation.getContact(); - if (recipient == null) { - if (BuildConfig.DEBUG) { - recipient = conv.getRecipient(); + if (contact != null) { + recipient = contact.getDisplayName(); } - else { - recipient = context.getString(R.string.peer_unknown); + + if (recipient == null) { + if (BuildConfig.DEBUG) { + recipient = conv.getRecipient(); + } + else { + recipient = context.getString(R.string.peer_unknown); + } } + + // disable group chat marker + TextViewCompat.setCompoundDrawablesRelative(mFromView, + null, null, null, null); + + loadAvatar(contact); } - loadAvatar(contact); SpannableStringBuilder from = new SpannableStringBuilder(recipient); if (conv.getUnreadCount() > 0) @@ -120,49 +147,23 @@ public final void bind(Context context, final Conversation conv) { from.append(" "); from.append(context.getResources().getString(R.string.has_draft)); from.setSpan(new ForegroundColorSpan( - context.getResources().getColor(R.color.text_color_draft)), - lastpos, from.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + ContextCompat.getColor(context, R.color.text_color_draft)), + lastpos, from.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); } mFromView.setText(from); mDateView.setText(MessageUtils.formatTimeStampString(context, conv.getDate())); - - CharSequence text; - - // last message or draft?? - if (conv.getRequestStatus() == Threads.REQUEST_WAITING) { - // TODO i18n and italic (?) - text = "(chat invitation)"; - } - else { - String source = (draft != null) ? draft : conv.getSubject(); - - if (source != null) { - text = new SpannableString(source); - if (conv.getUnreadCount() > 0) - ((Spannable) text).setSpan(STYLE_BOLD, 0, text.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); - } - - else if (conv.isEncrypted()) { - text = context.getString(R.string.text_encrypted); - } - - else { - // determine from mime type - text = CompositeMessage.getSampleTextContent(conv.getMime()); - } - } - - mSubjectView.setText(text); + mSticky.setVisibility(conv.isSticky() ? VISIBLE : GONE); // error indicator int resId = -1; int statusId = -1; switch (conv.getStatus()) { case Messages.STATUS_SENDING: - // use pending icon even for errors + // use pending icon even for errors case Messages.STATUS_ERROR: case Messages.STATUS_PENDING: + case Messages.STATUS_QUEUED: resId = R.drawable.ic_msg_pending; statusId = R.string.msg_status_sending; break; @@ -186,7 +187,8 @@ else if (conv.isEncrypted()) { } // no matching resource or draft - hide status icon - if (resId < 0 || mConversation.getDraft() != null) { + boolean incoming = resId < 0; + if (incoming || draft != null) { mErrorIndicator.setVisibility(GONE); int unread = mConversation.getUnreadCount(); @@ -204,6 +206,91 @@ else if (conv.isEncrypted()) { mErrorIndicator.setImageResource(resId); mErrorIndicator.setContentDescription(getResources().getString(statusId)); } + + CharSequence text; + + // last message or draft?? + if (conv.getRequestStatus() == Threads.REQUEST_WAITING) { + text = new SpannableString(context.getString(R.string.text_invitation_info)); + ((Spannable) text).setSpan(STYLE_ITALIC, 0, text.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + } + else { + String subject = conv.getSubject(); + String source = (draft != null) ? draft : subject; + + if (source != null) { + if (draft == null) { + if (GroupCommandComponent.supportsMimeType(conv.getMime())) { + if (incoming) { + // content is in a special format + GroupThreadContent parsed = GroupThreadContent.parseIncoming(subject); + subject = parsed.command; + } + text = new SpannableString(GroupCommandComponent.getTextContent(getContext(), subject, incoming)); + ((Spannable) text).setSpan(STYLE_ITALIC, 0, text.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + } + else { + if (incoming && conv.isGroupChat()) { + // content is in a special format + GroupThreadContent parsed = GroupThreadContent.parseIncoming(subject); + contact = parsed.sender != null ? Contact.findByUserId(context, parsed.sender) : null; + source = parsed.command; + + String displayName = null; + if (contact != null) + displayName = contact.getDisplayName(); + + if (displayName == null) { + if (BuildConfig.DEBUG) { + displayName = conv.getRecipient(); + } + else { + displayName = context.getString(R.string.peer_unknown); + } + } + + if (source == null) { + // determine from mime type + source = CompositeMessage.getSampleTextContent(conv.getMime()); + } + + text = new SpannableString(displayName + ": " + source); + ((Spannable) text).setSpan(STYLE_ITALIC, 0, displayName.length() + 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + } + else { + text = source; + } + } + } + else { + text = draft; + } + } + + else if (conv.isEncrypted()) { + text = context.getString(R.string.text_encrypted); + } + + else { + // determine from mime type + text = CompositeMessage.getSampleTextContent(conv.getMime()); + } + } + + if (conv.getUnreadCount() > 0) { + text = new SpannableString(text); + ((Spannable) text).setSpan(STYLE_BOLD, 0, text.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + mSubjectView.setSingleLine(false); + mSubjectView.setMaxLines(3); + mSubjectView.setEllipsize(TextUtils.TruncateAt.END); + } + else { + mSubjectView.setSingleLine(true); + mSubjectView.setMaxLines(1); + mSubjectView.setEllipsize(TextUtils.TruncateAt.MARQUEE); + } + + mSubjectView.setText(text); } public final void unbind() { @@ -215,25 +302,8 @@ public Conversation getConversation() { } @Override - public boolean isChecked() { - return mChecked; - } - - @Override - public void setChecked(boolean checked) { - mChecked = checked; - - /** TODO checked conversation item - if (mChecked) - setBackgroundResource(R.color.conversation_active_background); - else - setBackgroundResource(0); - */ - } - - @Override - public void toggle() { - setChecked(!mChecked); + protected boolean isGroupChat() { + return mConversation != null && mConversation.isGroupChat(); } } diff --git a/app/src/main/java/org/kontalk/ui/view/DefaultAttachmentContentView.java b/app/src/main/java/org/kontalk/ui/view/DefaultAttachmentContentView.java new file mode 100644 index 000000000..619561fb0 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/DefaultAttachmentContentView.java @@ -0,0 +1,93 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import java.util.regex.Pattern; + +import android.content.Context; +import androidx.appcompat.widget.AppCompatTextView; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import org.kontalk.R; +import org.kontalk.message.CompositeMessage; +import org.kontalk.message.DefaultAttachmentComponent; +import org.kontalk.util.ViewUtils; + + +/** + * Message component for {@link DefaultAttachmentComponent}. + * @author Daniele Ricci + */ +public class DefaultAttachmentContentView extends AppCompatTextView + implements MessageContentView { + + private DefaultAttachmentComponent mComponent; + + public DefaultAttachmentContentView(Context context) { + super(context); + } + + public DefaultAttachmentContentView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public DefaultAttachmentContentView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public void bind(long id, DefaultAttachmentComponent component, Pattern highlight) { + mComponent = component; + String text = CompositeMessage.getSampleTextContent(component.getMime()); + setText(text); + } + + @Override + public void unbind() { + clear(); + } + + @Override + public DefaultAttachmentComponent getComponent() { + return mComponent; + } + + @Override + public int getPriority() { + return 6; + } + + @Override + public void onApplyTheme(MessageListItemTheme theme) { + ViewUtils.setMessageBodyTextStyle(this, true); + setTextColor(theme.getTextColor()); + } + + private void clear() { + mComponent = null; + } + + public static DefaultAttachmentContentView create(LayoutInflater inflater, ViewGroup parent) { + return (DefaultAttachmentContentView) inflater.inflate(R.layout.message_content_default_att, + parent, false); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/EventMessageTheme.java b/app/src/main/java/org/kontalk/ui/view/EventMessageTheme.java new file mode 100644 index 000000000..333356bcb --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/EventMessageTheme.java @@ -0,0 +1,150 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import java.util.List; +import java.util.regex.Pattern; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewStub; + +import androidx.annotation.ColorInt; + +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.message.MessageComponent; +import org.kontalk.message.TextComponent; + + +/** + * Event message theme. + * @author Daniele Ricci + */ +public class EventMessageTheme implements MessageListItemTheme { + + private final int mLayoutId; + protected Context mContext; + protected LayoutInflater mInflater; + + private MessageContentLayout mContent; + + protected EventMessageTheme(int layoutId) { + mLayoutId = layoutId; + } + + @Override + public View inflate(ViewStub stub) { + stub.setLayoutResource(mLayoutId); + View view = stub.inflate(); + // save the inflater for later + mContext = stub.getContext(); + mInflater = LayoutInflater.from(mContext); + + mContent = view.findViewById(R.id.content); + return view; + } + + @Override + public MessageContentLayout getContent() { + return mContent; + } + + @Override + public void setEncryptedContent(long databaseId) { + // FIXME this is not good + TextContentView view = TextContentView.obtain(mInflater, mContent, true); + + String text = mContext.getResources().getString(R.string.text_encrypted); + view.bind(databaseId, new TextComponent(text), null); + mContent.addContent(view); + } + + @Override + public void processComponents(long databaseId, Pattern highlight, + List> components, Object... args) { + for (MessageComponent cmp : components) { + MessageContentView view = MessageContentViewFactory + .createContent(mInflater, mContent, cmp, databaseId, + highlight, args); + + if (view != null) { + view.onApplyTheme(this); + processComponentView(view); + mContent.addContent(view); + } + } + } + + public void processComponentView(MessageContentView view) { + if (view instanceof TextContentView) { + ((TextContentView) view).enableMeasureHack(true); + } + } + + @Override + public void setSecurityFlags(int securityFlags) { + } + + @Override + public void setIncoming(Contact contact, boolean sameMessageBlock) { + } + + @Override + public void setOutgoing(Contact contact, int status, boolean sameMessageBlock) { + } + + @Override + public void setTimestamp(CharSequence timestamp) { + } + + @Override + public TextContentView getTextContentView() { + int c = mContent.getChildCount(); + for (int i = 0; i < c; i++) { + MessageContentView view = (MessageContentView) mContent.getChildAt(i); + if (view instanceof TextContentView) { + return (TextContentView) view; + } + } + return null; + } + + @Override + public boolean isFullWidth() { + return false; + } + + @Override + public void unload() { + int c = mContent.getChildCount(); + for (int i = 0; i < c; i++) { + MessageContentView view = (MessageContentView) mContent.getChildAt(0); + mContent.removeView((View) view); + view.unbind(); + } + } + + @ColorInt + @Override + public int getTextColor() { + return -1; + } +} diff --git a/app/src/main/java/org/kontalk/ui/view/GroupContentView.java b/app/src/main/java/org/kontalk/ui/view/GroupContentView.java new file mode 100644 index 000000000..87917e5b8 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/GroupContentView.java @@ -0,0 +1,161 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import java.util.regex.Pattern; + +import com.vanniktech.emoji.EmojiTextView; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.message.GroupCommandComponent; + + +/** + * Message component for {@link GroupCommandComponent}. + * @author Daniele Ricci + */ +public class GroupContentView extends EmojiTextView + implements MessageContentView { + + private GroupCommandComponent mComponent; + + public GroupContentView(Context context) { + super(context); + } + + public GroupContentView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void bind(long id, GroupCommandComponent component, Pattern highlight) { + mComponent = component; + + CharSequence text = null; + + // create group + if (component.isCreateCommand()) { + text = new StringBuilder(); + ((StringBuilder) text).append(getResources().getString(R.string.group_command_create)); + for (String member : component.getCreateMembers()) { + // TODO use something more "colorful" + ((StringBuilder) text).append("\n"); + + Contact c = Contact.findByUserId(getContext(), member); + ((StringBuilder) text).append(c.getDisplayName()); + } + } + + // member left group + else if (component.isPartCommand()) { + // sending to the group JID, this is our own part command + if (component.getContent().getJid().equals(component.getFrom())) { + text = getResources().getString(R.string.group_command_self_parted); + } + else { + Contact c = Contact.findByUserId(getContext(), component.getFrom()); + boolean isOwner = mComponent.getContent().getOwner().equals(component.getFrom()); + text = getResources().getString(isOwner ? + R.string.group_command_owner_parted : R.string.group_command_user_parted, + c.getDisplayName()); + } + } + + // add/remove members and set subject + else if (component.isSetSubjectCommand()) { + text = new StringBuilder(); + + String subject = component.getContent().getSubject(); + if (subject != null) { + if (text.length() > 0) + ((StringBuilder) text).append("\n"); + ((StringBuilder) text).append(getResources().getString(R.string.group_command_subject, subject)); + } + } + + else if (component.isAddOrRemoveCommand()) { + text = new StringBuilder(); + + // add member(s) + String[] added = component.getAddedMembers(); + if (added != null && added.length > 0) { + ((StringBuilder) text).append(getResources().getString(R.string.group_command_users_added)); + for (String member : added) { + // TODO use something more "colorful" + ((StringBuilder) text).append("\n"); + + Contact c = Contact.findByUserId(getContext(), member); + ((StringBuilder) text).append(c.getDisplayName()); + } + } + + // remove member(s) + String[] removed = component.getRemovedMembers(); + if (removed != null && removed.length > 0) { + if (text.length() > 0) + ((StringBuilder) text).append("\n"); + ((StringBuilder) text).append(getResources().getString(R.string.group_command_users_removed)); + for (String member : removed) { + // TODO use something more "colorful" + ((StringBuilder) text).append("\n"); + + Contact c = Contact.findByUserId(getContext(), member); + ((StringBuilder) text).append(c.getDisplayName()); + } + } + } + + setText(text); + } + + @Override + public void unbind() { + clear(); + } + + @Override + public GroupCommandComponent getComponent() { + return mComponent; + } + + @Override + public int getPriority() { + return 7; + } + + @Override + public void onApplyTheme(MessageListItemTheme theme) { + } + + private void clear() { + mComponent = null; + } + + public static GroupContentView create(LayoutInflater inflater, ViewGroup parent) { + return (GroupContentView) inflater + .inflate(R.layout.message_content_group, parent, false); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/IconContextMenu.java b/app/src/main/java/org/kontalk/ui/view/IconContextMenu.java deleted file mode 100644 index 08498d866..000000000 --- a/app/src/main/java/org/kontalk/ui/view/IconContextMenu.java +++ /dev/null @@ -1,263 +0,0 @@ -/* - * Copyright (C) 2010 Tani Group - * http://android-demo.blogspot.com/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.kontalk.ui.view; - -import java.util.ArrayList; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.Dialog; -import android.content.Context; -import android.content.DialogInterface; -import android.content.res.Resources; -import android.content.res.Resources.Theme; -import android.graphics.drawable.Drawable; -import android.util.TypedValue; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AbsListView; -import android.widget.BaseAdapter; -import android.widget.TextView; - -/** - * @author nguyendt - * - */ -public class IconContextMenu implements DialogInterface.OnCancelListener, - DialogInterface.OnDismissListener{ - - private static final int LIST_PREFERED_HEIGHT = 65; - - private IconMenuAdapter menuAdapter = null; - private Activity parentActivity = null; - private int dialogId = 0; - - private IconContextMenuOnClickListener clickHandler = null; - - /** - * constructor - * @param parent - * @param id - */ - public IconContextMenu(Activity parent, int id) { - this.parentActivity = parent; - this.dialogId = id; - - menuAdapter = new IconMenuAdapter(parentActivity); - } - - /** - * Add menu item - * @param menuItem - */ - public void addItem(Resources res, CharSequence title, - int imageResourceId, int actionTag) { - menuAdapter.addItem(new IconContextMenuItem(res, title, imageResourceId, actionTag)); - } - - public void addItem(Resources res, int textResourceId, - int imageResourceId, int actionTag) { - menuAdapter.addItem(new IconContextMenuItem(res, textResourceId, imageResourceId, actionTag)); - } - - /** - * Set menu onclick listener - * @param listener - */ - public void setOnClickListener(IconContextMenuOnClickListener listener) { - clickHandler = listener; - } - - /** - * Create menu - * @return - */ - public Dialog createMenu(String menuItitle) { - final AlertDialog.Builder builder = new AlertDialog.Builder(parentActivity); - builder.setTitle(menuItitle); - builder.setAdapter(menuAdapter, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialoginterface, int i) { - IconContextMenuItem item = (IconContextMenuItem) menuAdapter.getItem(i); - - if (clickHandler != null) { - clickHandler.onClick(item.actionTag); - } - } - }); - - builder.setInverseBackgroundForced(true); - - AlertDialog dialog = builder.create(); - dialog.setOnCancelListener(this); - dialog.setOnDismissListener(this); - return dialog; - } - - public void onCancel(DialogInterface dialog) { - cleanup(); - } - - public void onDismiss(DialogInterface dialog) { - } - - private void cleanup() { - try { - parentActivity.dismissDialog(dialogId); - } - catch (IllegalArgumentException e) { - // ignore - } - } - - /** - * IconContextMenu On Click Listener interface - */ - public interface IconContextMenuOnClickListener { - public abstract void onClick(int menuId); - } - - /** - * Menu-like list adapter with icon - */ - protected class IconMenuAdapter extends BaseAdapter { - private Context context = null; - - private ArrayList mItems = new ArrayList(); - - public IconMenuAdapter(Context context) { - this.context = context; - } - - /** - * add item to adapter - * @param menuItem - */ - public void addItem(IconContextMenuItem menuItem) { - mItems.add(menuItem); - } - - @Override - public int getCount() { - return mItems.size(); - } - - @Override - public Object getItem(int position) { - return mItems.get(position); - } - - @Override - public long getItemId(int position) { - IconContextMenuItem item = (IconContextMenuItem) getItem(position); - return item.actionTag; - } - - @Override - public View getView(int position, View convertView, ViewGroup parent) { - IconContextMenuItem item = (IconContextMenuItem) getItem(position); - - Resources res = parentActivity.getResources(); - - if (convertView == null) { - TextView temp = new TextView(context); - AbsListView.LayoutParams param = new AbsListView.LayoutParams(AbsListView.LayoutParams.FILL_PARENT, - AbsListView.LayoutParams.WRAP_CONTENT); - temp.setLayoutParams(param); - temp.setPadding((int)toPixel(res, 15), 0, (int)toPixel(res, 15), 0); - temp.setGravity(android.view.Gravity.CENTER_VERTICAL); - - Theme th = context.getTheme(); - TypedValue tv = new TypedValue(); - - if (th.resolveAttribute(android.R.attr.textAppearanceLarge, tv, true)) { - temp.setTextAppearance(context, tv.resourceId); - } - - temp.setMinHeight((int)toPixel(res, LIST_PREFERED_HEIGHT)); - temp.setCompoundDrawablePadding((int)toPixel(res, 14)); - convertView = temp; - } - - TextView textView = (TextView) convertView; - textView.setTag(item); - textView.setText(item.text); - textView.setCompoundDrawablesWithIntrinsicBounds(item.image, null, null, null); - - return textView; - } - - private float toPixel(Resources res, int dip) { - float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dip, res.getDisplayMetrics()); - return px; - } - } - - /** - * menu-like list item with icon - */ - protected class IconContextMenuItem { - public final CharSequence text; - public final Drawable image; - public final int actionTag; - - /** - * public constructor - * - * @param res - * resource handler - * @param textResourceId - * id of title in resource - * @param imageResourceId - * id of icon in resource - * @param actionTag - * indicate action of menu item - */ - public IconContextMenuItem(Resources res, int textResourceId, - int imageResourceId, int actionTag) { - text = res.getString(textResourceId); - if (imageResourceId != -1) { - image = res.getDrawable(imageResourceId); - } else { - image = null; - } - this.actionTag = actionTag; - } - - /** - * public constructor - * @param res - * resource handler - * @param title - * menu item title - * @param imageResourceId - * id of icon in resource - * @param actionTag - * indicate action of menu item - */ - public IconContextMenuItem(Resources res, CharSequence title, - int imageResourceId, int actionTag) { - text = title; - if (imageResourceId != -1) { - image = res.getDrawable(imageResourceId); - } else { - image = null; - } - this.actionTag = actionTag; - } - } -} diff --git a/app/src/main/java/org/kontalk/ui/view/ImageContentView.java b/app/src/main/java/org/kontalk/ui/view/ImageContentView.java index c36c8be62..08f3bd39d 100644 --- a/app/src/main/java/org/kontalk/ui/view/ImageContentView.java +++ b/app/src/main/java/org/kontalk/ui/view/ImageContentView.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,28 +18,44 @@ package org.kontalk.ui.view; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; +import java.util.regex.Pattern; + import android.content.Context; import android.graphics.Bitmap; +import android.net.Uri; +import android.os.AsyncTask; import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.ViewGroup; +import android.widget.FrameLayout; import android.widget.ImageView; +import android.widget.TextView; +import org.kontalk.Log; import org.kontalk.R; -import org.kontalk.data.Contact; +import org.kontalk.message.CompositeMessage; import org.kontalk.message.ImageComponent; - -import java.util.regex.Pattern; +import org.kontalk.ui.ComposeMessage; +import org.kontalk.util.MediaStorage; +import org.kontalk.util.ViewUtils; /** * Message component for {@link ImageComponent}. * @author Daniele Ricci */ -public class ImageContentView extends ImageView +public class ImageContentView extends FrameLayout implements MessageContentView { + static final String TAG = ComposeMessage.TAG; private ImageComponent mComponent; + private ImageView mContent; + private TextView mPlaceholder; public ImageContentView(Context context) { super(context); @@ -53,24 +69,106 @@ public ImageContentView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } - public void bind(long messageId, ImageComponent component, Contact contact, Pattern highlight) { + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mContent = findViewById(R.id.content); + mPlaceholder = findViewById(R.id.placeholder); + } + + @Override + public void bind(long messageId, ImageComponent component, Pattern highlight) { mComponent = component; - // prepend some text for the ImageSpan - //String placeholder = CompositeMessage.getSampleTextContent(component.getContent().getMime()); + Bitmap bitmap = getBitmap(); + showBitmap(bitmap); + } + + @Override + public void onApplyTheme(MessageListItemTheme theme) { + if (mPlaceholder.getVisibility() == VISIBLE) { + ViewUtils.setMessageBodyTextStyle(mPlaceholder, true); + mPlaceholder.setTextColor(theme.getTextColor()); + } + } - Bitmap bitmap = mComponent.getBitmap(); - if (bitmap != null) - setImageBitmap(bitmap); + /** This method might be called from a thread other than the main thread. */ + void showBitmap(Bitmap bitmap) { + // this method might be called from another thread + final ImageComponent component = mComponent; + if (component == null) + return; + + if (bitmap != null) { + mContent.setImageBitmap(bitmap); + mPlaceholder.setVisibility(GONE); + mContent.setVisibility(VISIBLE); + } + else { + String placeholder = CompositeMessage.getSampleTextContent(component.getContent().getMime()); + mPlaceholder.setText(placeholder); + mContent.setVisibility(GONE); + mPlaceholder.setVisibility(VISIBLE); + } + } - // TODO else: maybe some placeholder like Image: image/jpeg + Bitmap loadPreview(File previewFile) throws IOException { + InputStream in = new FileInputStream(previewFile); + Bitmap bitmap = MediaStorage.loadBitmapSimple(in); + in.close(); + return bitmap; + } + private Bitmap getBitmap() { + /* + * local_uri is used for referencing the original media. + * preview_uri is used to load the media thumbnail. + * If preview_uri is null or cannot be found, a thumbnail is + * generated on the fly from local_uri - if possible. + */ + + File previewFile = mComponent.getPreviewFile(); + Uri localUri = mComponent.getLocalUri(); + try { + // preview path + if (previewFile != null) { + // load from file - we know it's a file uri + return loadPreview(previewFile); + } + } + catch (Exception e) { + Log.w(TAG, "unable to load thumbnail, generating one"); + + /* + * unable to load preview - generate thumbnail + * Of course a thumbnail can be generated only if the image has + * already been downloaded. + */ + if (localUri != null) { + new GenerateThumbnailTask(getContext(), localUri, previewFile, new ThumbnailListener() { + @Override + public void onThumbnailGenerated(File previewFile) { + try { + Bitmap bitmap = loadPreview(previewFile); + showBitmap(bitmap); + } + catch (IOException e) { + // since at this point anything can happen, just ignore any errors + Log.w(TAG, "unable to load generated thumbnail", e); + } + } + }).execute(); + } + } + return null; } + @Override public void unbind() { clear(); } + @Override public ImageComponent getComponent() { return mComponent; } @@ -78,12 +176,12 @@ public ImageComponent getComponent() { /** Image is always on top. */ @Override public int getPriority() { - return 1; + return 4; } private void clear() { mComponent = null; - setImageBitmap(null); + mContent.setImageBitmap(null); } public static ImageContentView create(LayoutInflater inflater, ViewGroup parent) { @@ -91,4 +189,44 @@ public static ImageContentView create(LayoutInflater inflater, ViewGroup parent) parent, false); } + interface ThumbnailListener { + void onThumbnailGenerated(File previewFile); + } + + final static class GenerateThumbnailTask extends AsyncTask { + private final Uri mLocalUri; + private final File mPreviewFile; + private final WeakReference mContext; + private final ThumbnailListener mListener; + + GenerateThumbnailTask(Context context, Uri localUri, File previewFile, ThumbnailListener listener) { + mContext = new WeakReference<>(context); + mLocalUri = localUri; + mPreviewFile = previewFile; + mListener = listener; + } + + @Override + protected Boolean doInBackground(Void... params) { + Context context = mContext.get(); + if (context != null) { + try { + MediaStorage.cacheThumbnail(context, mLocalUri, mPreviewFile, false); + return true; + } + catch (Exception e) { + Log.e(TAG, "unable to generate thumbnail", e); + } + } + return false; + } + + @Override + protected void onPostExecute(Boolean result) { + if (result != null && result) { + mListener.onThumbnailGenerated(mPreviewFile); + } + } + } + } diff --git a/app/src/main/java/org/kontalk/ui/view/KeyboardAwareRelativeLayout.java b/app/src/main/java/org/kontalk/ui/view/KeyboardAwareRelativeLayout.java deleted file mode 100644 index 34963d4a6..000000000 --- a/app/src/main/java/org/kontalk/ui/view/KeyboardAwareRelativeLayout.java +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Copyright (C) 2015 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.kontalk.ui.view; - -import android.annotation.TargetApi; -import android.app.Activity; -import android.content.Context; -import android.content.SharedPreferences; -import android.graphics.Rect; -import android.os.Build; -import android.preference.PreferenceManager; -import android.util.AttributeSet; -import android.util.Log; -import android.view.Surface; -import android.view.WindowManager; -import android.widget.FrameLayout; - -import org.kontalk.R; - - -/** - * RelativeLayout that, when a view container, will report back when it thinks a soft keyboard - * has been opened and what its height would be. - * https://github.com/WhisperSystems/TextSecure - */ -public class KeyboardAwareRelativeLayout extends FrameLayout { - private static final Rect rect = new Rect(); - private boolean mKeyboardVisible; - - private OnKeyboardShownListener mListener; - - public KeyboardAwareRelativeLayout(Context context) { - super(context); - } - - public KeyboardAwareRelativeLayout(Context context, AttributeSet attrs) { - super(context, attrs); - } - - @TargetApi(Build.VERSION_CODES.HONEYCOMB) - public KeyboardAwareRelativeLayout(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - } - - public void setOnKeyboardShownListener(OnKeyboardShownListener listener) { - mListener = listener; - } - - /** - * inspired by http://stackoverflow.com/a/7104303 - * @param widthMeasureSpec width measure - * @param heightMeasureSpec height measure - */ - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - int res = getResources().getIdentifier("status_bar_height", "dimen", "android"); - int statusBarHeight = res > 0 ? getResources().getDimensionPixelSize(res) : 0; - - final int availableHeight = this.getRootView().getHeight() - statusBarHeight; - getWindowVisibleDisplayFrame(rect); - - final int keyboardHeight = availableHeight - (rect.bottom - rect.top); - - if (keyboardHeight > getResources().getDimensionPixelSize(R.dimen.min_emoji_drawer_height)) { - onKeyboardShown(keyboardHeight); - mKeyboardVisible = true; - } - else { - mKeyboardVisible = false; - } - - if (mListener != null) - mListener.onKeyboardShown(mKeyboardVisible); - - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } - - protected void onKeyboardShown(int keyboardHeight) { - WindowManager wm = (WindowManager) getContext().getSystemService(Activity.WINDOW_SERVICE); - if (wm == null || wm.getDefaultDisplay() == null) { - return; - } - int rotation = wm.getDefaultDisplay().getRotation(); - - switch (rotation) { - case Surface.ROTATION_270: - case Surface.ROTATION_90: - setKeyboardLandscapeHeight(keyboardHeight); - break; - case Surface.ROTATION_0: - case Surface.ROTATION_180: - setKeyboardPortraitHeight(keyboardHeight); - } - } - - public boolean isKeyboardVisible() { - return mKeyboardVisible; - } - - public int getKeyboardHeight() { - WindowManager wm = (WindowManager)getContext().getSystemService(Context.WINDOW_SERVICE); - if (wm == null || wm.getDefaultDisplay() == null) { - throw new AssertionError("WindowManager was null or there is no default display"); - } - - int rotation = wm.getDefaultDisplay().getRotation(); - - switch (rotation) { - case Surface.ROTATION_270: - case Surface.ROTATION_90: - return getKeyboardLandscapeHeight(); - case Surface.ROTATION_0: - case Surface.ROTATION_180: - default: - return getKeyboardPortraitHeight(); - } - } - - public int getKeyboardLandscapeHeight() { - return PreferenceManager.getDefaultSharedPreferences(getContext()) - .getInt("keyboard_height_landscape", - getResources().getDimensionPixelSize(R.dimen.emoji_drawer_default_height)); - } - - public int getKeyboardPortraitHeight() { - return PreferenceManager.getDefaultSharedPreferences(getContext()) - .getInt("keyboard_height_portrait", - getResources().getDimensionPixelSize(R.dimen.emoji_drawer_default_height)); - } - - private void setKeyboardLandscapeHeight(int height) { - SharedPreferences.Editor editor = PreferenceManager - .getDefaultSharedPreferences(getContext()) - .edit().putInt("keyboard_height_landscape", height); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.GINGERBREAD) - editor.commit(); - else - editor.apply(); - } - - private void setKeyboardPortraitHeight(int height) { - SharedPreferences.Editor editor = PreferenceManager - .getDefaultSharedPreferences(getContext()) - .edit().putInt("keyboard_height_portrait", height); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.GINGERBREAD) - editor.commit(); - else - editor.apply(); - } - - public interface OnKeyboardShownListener { - public void onKeyboardShown(boolean visible); - } - -} diff --git a/app/src/main/java/org/kontalk/ui/view/LocationContentView.java b/app/src/main/java/org/kontalk/ui/view/LocationContentView.java new file mode 100644 index 000000000..1cef9124c --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/LocationContentView.java @@ -0,0 +1,168 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import java.util.regex.Pattern; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.model.GlideUrl; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RoundRectShape; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.kontalk.R; +import org.kontalk.message.LocationComponent; +import org.kontalk.position.PositionManager; +import org.kontalk.position.RequestDetails; +import org.kontalk.ui.ComposeMessage; +import org.kontalk.util.CombinedDrawable; +import org.kontalk.util.ViewUtils; + +/** + * Message component for {@link LocationComponent}. + * @author andreacappelli + */ + +public class LocationContentView extends RelativeLayout + implements MessageContentView { + static final String TAG = ComposeMessage.TAG; + + private LocationComponent mComponent; + private ImageView mContent; + private TextView mPlaceholder; + private TextView mName; + private TextView mAddress; + + public LocationContentView(Context context) { + super(context); + } + + public LocationContentView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public LocationContentView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mContent = findViewById(R.id.content); + mPlaceholder = findViewById(R.id.placeholder); + mName = findViewById(R.id.name); + mAddress = findViewById(R.id.address); + } + + @Override + public void bind(long messageId, LocationComponent component, Pattern highlight) { + mComponent = component; + + showMap(); + } + + private void showMap() { + final LocationComponent component = mComponent; + if (component == null) + return; + + mPlaceholder.setVisibility(GONE); + mContent.setVisibility(VISIBLE); + + RequestDetails imageURL = PositionManager.getStaticMapUrl(getContext(), + mComponent.getLatitude(), mComponent.getLongitude(), 15, + 200, 100, (int) getContext().getResources().getDisplayMetrics().density); + + Drawable drawable = createRoundRectDrawableWithIcon(0, R.drawable.ic_pin); + + mContent.setBackground(drawable); + + if (mComponent.getText() != null) { + mName.setVisibility(VISIBLE); + mName.setText(mComponent.getText()); + } + + if (mComponent.getStreet() != null) { + mAddress.setVisibility(VISIBLE); + mAddress.setText(mComponent.getStreet()); + } + + if (imageURL != null) { + GlideUrl url = new GlideUrl(imageURL.url, imageURL.headers); + Glide.with(getContext()) + .load(url) + .into(mContent); + } + } + + @Override + public void unbind() { + clear(); + } + + @Override + public LocationComponent getComponent() { + return mComponent; + } + + /** + * Image is always on top. + */ + @Override + public int getPriority() { + return 3; + } + + @Override + public void onApplyTheme(MessageListItemTheme theme) { + if (mPlaceholder.getVisibility() == VISIBLE) { + ViewUtils.setMessageBodyTextStyle(mPlaceholder, true); + mPlaceholder.setTextColor(theme.getTextColor()); + } + } + + private void clear() { + mComponent = null; + mContent.setImageBitmap(null); + } + + public static LocationContentView create(LayoutInflater inflater, ViewGroup parent) { + return (LocationContentView) inflater.inflate(R.layout.message_content_location, + parent, false); + } + + public Drawable createRoundRectDrawableWithIcon(int rad, int iconRes) { + ShapeDrawable defaultDrawable = new ShapeDrawable(new RoundRectShape(new float[]{rad, rad, rad, rad, rad, rad, rad, rad}, null, null)); + defaultDrawable.getPaint().setColor(ContextCompat.getColor(getContext(), R.color.map_placeholder_background)); + Drawable drawable = getContext().getResources().getDrawable(iconRes).mutate(); + DrawableCompat.setTint(drawable, ContextCompat.getColor(getContext(), R.color.position_send_color)); + + return new CombinedDrawable(defaultDrawable, drawable); + } +} diff --git a/app/src/main/java/org/kontalk/ui/view/MessageContentLayout.java b/app/src/main/java/org/kontalk/ui/view/MessageContentLayout.java index dc02c0d70..30b663720 100644 --- a/app/src/main/java/org/kontalk/ui/view/MessageContentLayout.java +++ b/app/src/main/java/org/kontalk/ui/view/MessageContentLayout.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -42,7 +42,6 @@ private int getPositionFromPriority(MessageContentView view) { int prio = view.getPriority(); int count = getChildCount(); - int currentPrio = -1; int position = count; // search for the right position based on priority @@ -52,13 +51,9 @@ private int getPositionFromPriority(MessageContentView view) { int childPrio = ((MessageContentView) child).getPriority(); // we have a chance to be added at this index if (childPrio >= prio) { - // we should be added as the last in our priority - if (currentPrio >= 0 && currentPrio != prio) { - position = i; - break; - } - - currentPrio = childPrio; + position = i; + break; + // TODO items with same priority should be ordered as inserted } } diff --git a/app/src/main/java/org/kontalk/ui/view/MessageContentView.java b/app/src/main/java/org/kontalk/ui/view/MessageContentView.java index 38f604706..07a1c9848 100644 --- a/app/src/main/java/org/kontalk/ui/view/MessageContentView.java +++ b/app/src/main/java/org/kontalk/ui/view/MessageContentView.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ package org.kontalk.ui.view; -import org.kontalk.data.Contact; - import java.util.regex.Pattern; @@ -30,17 +28,18 @@ public interface MessageContentView { /** Binds the given component with this view. */ - public void bind(long id, T component, Contact contact, Pattern highlight); + void bind(long id, T component, Pattern highlight); /** Unbinds and release all resources from this view. */ - public void unbind(); + void unbind(); /** Returns the component associated with this view. */ - public T getComponent(); + T getComponent(); /** Returns the priority of the view. Lower values means higher position. */ - public int getPriority(); + int getPriority(); - // TODO + /** Called by the theme to apply post-binding style modifications. */ + void onApplyTheme(MessageListItemTheme theme); } diff --git a/app/src/main/java/org/kontalk/ui/view/MessageContentViewFactory.java b/app/src/main/java/org/kontalk/ui/view/MessageContentViewFactory.java index 0fe1f71cf..9d392edfe 100644 --- a/app/src/main/java/org/kontalk/ui/view/MessageContentViewFactory.java +++ b/app/src/main/java/org/kontalk/ui/view/MessageContentViewFactory.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,9 +21,12 @@ import android.view.LayoutInflater; import android.view.ViewGroup; -import org.kontalk.data.Contact; import org.kontalk.message.AudioComponent; +import org.kontalk.message.DefaultAttachmentComponent; +import org.kontalk.message.GroupCommandComponent; import org.kontalk.message.ImageComponent; +import org.kontalk.message.InReplyToComponent; +import org.kontalk.message.LocationComponent; import org.kontalk.message.MessageComponent; import org.kontalk.message.TextComponent; import org.kontalk.message.VCardComponent; @@ -45,7 +48,7 @@ private MessageContentViewFactory() { @SuppressWarnings("unchecked") public static MessageContentView createContent(LayoutInflater inflater, ViewGroup parent, T component, long messageId, - Contact contact, Pattern highlight, Object... args) { + Pattern highlight, Object... args) { // using conditionals to avoid reflection MessageContentView view = null; @@ -57,14 +60,27 @@ else if (component instanceof ImageComponent) { view = (MessageContentView) ImageContentView.create(inflater, parent); } else if (component instanceof AudioComponent) { - view = (MessageContentView) AudioContentView.create(inflater, parent, (AudioPlayerControl) args[0]); + view = (MessageContentView) AudioContentView.create(inflater, parent, + (AudioPlayerControl) args[0], (MessageListItemTheme) args[1]); } else if (component instanceof VCardComponent) { view = (MessageContentView) VCardContentView.create(inflater, parent); } + else if (component instanceof DefaultAttachmentComponent) { + view = (MessageContentView) DefaultAttachmentContentView.create(inflater, parent); + } + else if (component instanceof GroupCommandComponent) { + view = (MessageContentView) GroupContentView.create(inflater, parent); + } + else if (component instanceof LocationComponent) { + view = (MessageContentView) LocationContentView.create(inflater, parent); + } + else if (component instanceof InReplyToComponent) { + view = (MessageContentView) QuoteContentView.create(inflater, parent); + } if (view != null) - view.bind(messageId, component, contact, highlight); + view.bind(messageId, component, highlight); return view; } diff --git a/app/src/main/java/org/kontalk/ui/view/MessageItemTextView.java b/app/src/main/java/org/kontalk/ui/view/MessageItemTextView.java deleted file mode 100644 index db80b3244..000000000 --- a/app/src/main/java/org/kontalk/ui/view/MessageItemTextView.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.ui.view; - -import android.content.Context; -import android.text.Layout; -import android.util.AttributeSet; -import android.widget.TextView; - - -/** - * Text view for text message content. Used by {@link TextContentView}. - * @author Daniele Ricci - */ -public class MessageItemTextView extends TextView { - - public MessageItemTextView(Context context) { - super(context); - } - - public MessageItemTextView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public MessageItemTextView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - } - - /* - * Hack for fixing extra space took by the TextView. - * I still have to understand why this works and plain getWidth() doesn't. - * http://stackoverflow.com/questions/7439748/why-is-wrap-content-in-multiple-line-textview-filling-parent - */ - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - - Layout layout = getLayout(); - if (layout != null) { - int width = (int) Math.ceil(getMaxLineWidth(layout)) - + getCompoundPaddingLeft() + getCompoundPaddingRight(); - int height = getMeasuredHeight(); - setMeasuredDimension(width, height); - } - } - - private float getMaxLineWidth(Layout layout) { - float max_width = 0.0f; - int lines = layout.getLineCount(); - for (int i = 0; i < lines; i++) { - if (layout.getLineWidth(i) > max_width) { - max_width = layout.getLineWidth(i); - } - } - return max_width; - } -} diff --git a/app/src/main/java/org/kontalk/ui/view/MessageListItem.java b/app/src/main/java/org/kontalk/ui/view/MessageListItem.java index dbf637f59..321457e23 100644 --- a/app/src/main/java/org/kontalk/ui/view/MessageListItem.java +++ b/app/src/main/java/org/kontalk/ui/view/MessageListItem.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,62 +18,54 @@ package org.kontalk.ui.view; -import android.app.AlertDialog; +import java.util.regex.Pattern; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.afollestad.materialdialogs.simplelist.MaterialSimpleListAdapter; +import com.afollestad.materialdialogs.simplelist.MaterialSimpleListItem; + import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; import android.graphics.drawable.Drawable; import android.net.Uri; +import androidx.annotation.NonNull; import android.text.style.URLSpan; import android.util.AttributeSet; -import android.view.Gravity; -import android.view.LayoutInflater; import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.ImageView; -import android.widget.LinearLayout; +import android.view.ViewStub; +import android.widget.Checkable; import android.widget.RelativeLayout; import android.widget.TextView; import org.kontalk.R; -import org.kontalk.crypto.Coder; import org.kontalk.data.Contact; import org.kontalk.message.CompositeMessage; -import org.kontalk.message.MessageComponent; -import org.kontalk.message.TextComponent; -import org.kontalk.provider.MyMessages.Messages; +import org.kontalk.message.GroupComponent; import org.kontalk.util.MessageUtils; import org.kontalk.util.Preferences; -import java.util.List; -import java.util.regex.Pattern; - /** * A message list item to be used in {@link org.kontalk.ui.ComposeMessage} activity. * @author Daniele Ricci * @version 1.0 */ -public class MessageListItem extends RelativeLayout { +public class MessageListItem extends RelativeLayout implements Checkable { - static private Drawable sDefaultContactImage; - - private LayoutInflater mInflater; + private static final int[] CHECKED_STATE_SET = { android.R.attr.state_checked }; private CompositeMessage mMessage; - private MessageContentLayout mContent; - private ImageView mStatusIcon; - private ImageView mWarningIcon; - private TextView mDateView; - private LinearLayout mBalloonView; - private LinearLayout mParentView; + // for message details + private String mPeer; + private String mDisplayName; - private ImageView mAvatarIncoming; - private ImageView mAvatarOutgoing; + private MessageListItemTheme mBalloonTheme; private TextView mDateHeader; + private boolean mChecked; + /* private LeadingMarginSpan mLeadingMarginSpan; @@ -90,188 +82,91 @@ public void chooseHeight(CharSequence text, int start, public MessageListItem(Context context) { super(context); - init(context); } public MessageListItem(final Context context, AttributeSet attrs) { super(context, attrs); - init(context); - } - - private void init(Context context) { - if (sDefaultContactImage == null) { - sDefaultContactImage = context.getResources().getDrawable(R.drawable.ic_contact_picture); - } - - mInflater = LayoutInflater.from(context); } @Override protected void onFinishInflate() { super.onFinishInflate(); + mDateHeader = findViewById(R.id.date_header); + } - mContent = (MessageContentLayout) findViewById(R.id.content); - mStatusIcon = (ImageView) findViewById(R.id.status_indicator); - mWarningIcon = (ImageView) findViewById(R.id.warning_icon); - mBalloonView = (LinearLayout) findViewById(R.id.balloon_view); - mDateView = (TextView) findViewById(R.id.date_view); - mAvatarIncoming = (ImageView) findViewById(R.id.avatar_incoming); - mAvatarOutgoing = (ImageView) findViewById(R.id.avatar_outgoing); - mParentView = (LinearLayout) findViewById(R.id.message_view_parent); - - mDateHeader = (TextView) findViewById(R.id.date_header); - - if (isInEditMode()) { - //mTextView.setText("Test messaggio\nCiao zio!\nBelluuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu!!"); - //mTextView.setText("TEST"); - //mTextView.setText(":-)"); - /* INCOMING - //setGravity(Gravity.LEFT); - if (mBalloonView != null) { - mBalloonView.setBackgroundResource(R.drawable.balloon_classic_incoming); - } - mDateView.setText("28 Nov"); - */ - - /* OUTGOING */ - if (mStatusIcon != null) { - mStatusIcon.setImageResource(R.drawable.ic_msg_delivered); - mStatusIcon.setVisibility(VISIBLE); - } - if (mStatusIcon != null) - mStatusIcon.setImageResource(R.drawable.ic_msg_delivered); - mWarningIcon.setVisibility(VISIBLE); - setGravity(Gravity.RIGHT); - if (mBalloonView != null) { - mBalloonView.setBackgroundResource(R.drawable.balloon_classic_outgoing); - } - mDateView.setText("16:25"); - if (mAvatarIncoming != null) { - mAvatarIncoming.setVisibility(GONE); - mAvatarOutgoing.setVisibility(VISIBLE); - mAvatarOutgoing.setImageResource(R.drawable.ic_contact_picture); - } - } + public void afterInflate(int direction, boolean event, boolean groupChat) { + ViewStub stub = findViewById(R.id.balloon_stub); + String theme = groupChat ? + Preferences.getBalloonGroupsTheme(getContext()) : + Preferences.getBalloonTheme(getContext()); + mBalloonTheme = MessageListItemThemeFactory.createTheme(getContext(), theme, direction, event, groupChat); + mBalloonTheme.inflate(stub); } - public final void bind(Context context, final CompositeMessage msg, - final Contact contact, final Pattern highlight, long previous, + public final void bind(Context context, final CompositeMessage msg, final Pattern highlight, + int itemType, int previousItemType, long previousTimestamp, String previousPeer, Object... args) { mMessage = msg; - if (MessageUtils.isSameDate(mMessage.getTimestamp(), previous)) { + setChecked(false); + + boolean sameMessageBlock = false; + long msgTs = MessageUtils.getMessageTimestamp(mMessage); + boolean sameDate = MessageUtils.isSameDate(msgTs, previousTimestamp); + if (sameDate) { mDateHeader.setVisibility(View.GONE); + // same day, check if it's also same direction and user + // some themes will use this information to group messages together + String msgPeer = MessageUtils.getMessagePeer(mMessage); + sameMessageBlock = (itemType == previousItemType && msgPeer.equals(previousPeer)); } else { - mDateHeader.setText(MessageUtils.formatDateString(context, mMessage.getTimestamp())); + mDateHeader.setText(MessageUtils.formatDateString(context, msgTs)); mDateHeader.setVisibility(View.VISIBLE); } - if (msg.isEncrypted()) { - // FIXME this is not good - TextContentView view = TextContentView.obtain(mInflater, mContent, true); - - String text = getResources().getString(R.string.text_encrypted); - view.bind(mMessage.getDatabaseId(), new TextComponent(text), contact, highlight); - mContent.addContent(view); - } - - else { - // process components - List> components = msg.getComponents(); - for (MessageComponent cmp : components) { - MessageContentView view = MessageContentViewFactory - .createContent(mInflater, mContent, cmp, mMessage.getDatabaseId(), - contact, highlight, args); - - mContent.addContent(view); - } - } - - int resId = 0; - int statusId = 0; - - int securityFlags = mMessage.getSecurityFlags(); - - if (Coder.isError(securityFlags)) { - mWarningIcon.setImageResource(R.drawable.ic_msg_security); - mWarningIcon.setVisibility(VISIBLE); - } - else { - mWarningIcon.setImageResource(R.drawable.ic_msg_warning); - mWarningIcon.setVisibility((securityFlags != Coder.SECURITY_CLEARTEXT) ? GONE : VISIBLE); - } + mBalloonTheme.setSecurityFlags(mMessage.getSecurityFlags()); if (mMessage.getSender() != null) { - if (mBalloonView != null) { - mBalloonView.setBackgroundResource(Preferences - .getBalloonResource(getContext(), Messages.DIRECTION_IN)); - } - mParentView.setGravity(Gravity.LEFT); + Contact contact = Contact.findByUserId(context, msg.getSender(true)); + mBalloonTheme.setIncoming(contact, sameMessageBlock); - if (mAvatarIncoming != null) { - mAvatarOutgoing.setVisibility(GONE); - mAvatarIncoming.setVisibility(VISIBLE); - mAvatarIncoming.setImageDrawable(contact != null ? - contact.getAvatar(context, sDefaultContactImage) : sDefaultContactImage); - } + mPeer = contact.getNumber(); + mDisplayName = contact.getDisplayName(); + if (mPeer == null) + mPeer = msg.getSender(true); } else { - if (mBalloonView != null) { - mBalloonView.setBackgroundResource(Preferences - .getBalloonResource(getContext(), Messages.DIRECTION_OUT)); + Contact contact = null; + if (!msg.hasComponent(GroupComponent.class)) + contact = Contact.findByUserId(context, msg.getRecipient()); + mBalloonTheme.setOutgoing(contact, mMessage.getStatus(), sameMessageBlock); + if (contact != null) { + mPeer = contact.getNumber(); + mDisplayName = contact.getName(); } - mParentView.setGravity(Gravity.RIGHT); + if (mPeer == null) + mPeer = msg.getRecipient(); + } - if (mAvatarOutgoing != null) { - mAvatarIncoming.setVisibility(GONE); - mAvatarOutgoing.setVisibility(VISIBLE); - // TODO show own profile picture - mAvatarOutgoing.setImageDrawable(sDefaultContactImage); - } + mBalloonTheme.setTimestamp(formatTimestamp()); - // status icon - if (mMessage.getSender() == null) - switch (mMessage.getStatus()) { - case Messages.STATUS_SENDING: - // use pending icon even for errors - case Messages.STATUS_ERROR: - case Messages.STATUS_PENDING: - resId = R.drawable.ic_msg_pending; - statusId = R.string.msg_status_sending; - break; - case Messages.STATUS_RECEIVED: - resId = R.drawable.ic_msg_delivered; - statusId = R.string.msg_status_delivered; - break; - // here we use the error icon - case Messages.STATUS_NOTACCEPTED: - resId = R.drawable.ic_msg_error; - statusId = R.string.msg_status_notaccepted; - break; - case Messages.STATUS_SENT: - resId = R.drawable.ic_msg_sent; - statusId = R.string.msg_status_sent; - break; - case Messages.STATUS_NOTDELIVERED: - resId = R.drawable.ic_msg_notdelivered; - statusId = R.string.msg_status_notdelivered; - break; - } + if (msg.isEncrypted()) { + mBalloonTheme.setEncryptedContent(mMessage.getDatabaseId()); } - if (resId > 0) { - mStatusIcon.setImageResource(resId); - mStatusIcon.setVisibility(VISIBLE); - mStatusIcon.setContentDescription(getResources().getString(statusId)); - } else { - mStatusIcon.setImageDrawable(null); - mStatusIcon.setVisibility(GONE); + // process components + Object[] argsAppend = null; + if (args != null) { + argsAppend = new Object[args.length+1]; + System.arraycopy(args, 0, argsAppend, 0, args.length); + argsAppend[args.length] = mBalloonTheme; + } + mBalloonTheme.processComponents(mMessage.getDatabaseId(), + highlight, msg.getComponents(), argsAppend); } - - mDateView.setText(formatTimestamp()); } /* @@ -352,34 +247,45 @@ private SpannableStringBuilder formatMessage(final Contact contact, final Patter */ private CharSequence formatTimestamp() { - long serverTime = mMessage.getServerTimestamp(); - long ts = serverTime > 0 ? serverTime : mMessage.getTimestamp(); - - return MessageUtils.formatTimeString(getContext(), ts); + return MessageUtils.formatTimeString(getContext(), MessageUtils.getMessageTimestamp(mMessage)); } public final void unbind() { // TODO mMessage.recycle(); mMessage = null; + mBalloonTheme.unload(); + } - int c = mContent.getChildCount(); - for (int i = 0; i < c; i++) { - MessageContentView view = (MessageContentView) mContent.getChildAt(0); - mContent.removeView((View) view); - view.unbind(); + @Override + public boolean isChecked() { + return mChecked; + } + + @Override + public void setChecked(boolean checked) { + if (checked != mChecked) { + mChecked = checked; + refreshDrawableState(); } } + @Override + protected int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + if (isChecked()) { + mergeDrawableStates(drawableState, CHECKED_STATE_SET); + } + return drawableState; + } + + @Override + public void toggle() { + setChecked(!mChecked); + } + // Thanks to Google Mms app :) public void onClick() { - TextContentView textContent = null; - int c = mContent.getChildCount(); - for (int i = 0; i < c; i++) { - MessageContentView view = (MessageContentView) mContent.getChildAt(0); - if (view instanceof TextContentView) { - textContent = (TextContentView) view; - } - } + TextContentView textContent = mBalloonTheme.getTextContentView(); if (textContent == null) return; @@ -388,73 +294,78 @@ public void onClick() { final URLSpan[] spans = textContent.getUrls(); if (spans.length == 0) { - // TODO show the message details dialog + // show the message details dialog + MessageUtils.showMessageDetails(getContext(), mMessage, mPeer, mDisplayName); } else if (spans.length == 1) { + // show link opener spans[0].onClick(textContent); } else { - ArrayAdapter adapter = - new ArrayAdapter(mInflater.getContext(), android.R.layout.select_dialog_item, spans) { - @Override - public View getView(int position, View convertView, ViewGroup parent) { - View v = super.getView(position, convertView, parent); - Context context = mInflater.getContext(); - try { - URLSpan span = getItem(position); - String url = span.getURL(); - Uri uri = Uri.parse(url); - TextView tv = (TextView) v; - Drawable d = context.getPackageManager().getActivityIcon( - new Intent(Intent.ACTION_VIEW, uri)); - if (d != null) { - d.setBounds(0, 0, d.getIntrinsicHeight(), d.getIntrinsicHeight()); - tv.setCompoundDrawablePadding(10); - tv.setCompoundDrawables(d, null, null, null); - } - final String telPrefix = "tel:"; - if (url.startsWith(telPrefix)) { - // TODO handle country code - url = url.substring(telPrefix.length()); - } - tv.setText(url); - } catch (android.content.pm.PackageManager.NameNotFoundException ex) { - // it's ok if we're unable to set the drawable for this view - the user - // can still use it - } - return v; + // complex stuff (media) + URLSpanAdapterCallback click = new URLSpanAdapterCallback(textContent); + final MaterialSimpleListAdapter adapter = new MaterialSimpleListAdapter(click); + for (URLSpan span : spans) { + MaterialSimpleListItem.Builder builder = new MaterialSimpleListItem.Builder(getContext()) + .tag(span); + try { + String url = span.getURL(); + Uri uri = Uri.parse(url); + + final String telPrefix = "tel:"; + if (url.startsWith(telPrefix)) { + // TODO handle country code + url = url.substring(telPrefix.length()); } - }; - AlertDialog.Builder b = new AlertDialog.Builder(mInflater.getContext()); + builder.content(url); - final TextContentView textView = textContent; - DialogInterface.OnClickListener click = new DialogInterface.OnClickListener() { - @Override - public final void onClick(DialogInterface dialog, int which) { - if (which >= 0) { - spans[which].onClick(textView); + Drawable d = getContext().getPackageManager().getActivityIcon( + new Intent(Intent.ACTION_VIEW, uri)); + if (d != null) { + builder.icon(d).iconPadding(10); } - dialog.dismiss(); - } - }; - - b.setTitle(R.string.chooser_select_link); - b.setCancelable(true); - b.setAdapter(adapter, click); - b.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { - @Override - public final void onClick(DialogInterface dialog, int which) { - dialog.dismiss(); } - }); + catch (android.content.pm.PackageManager.NameNotFoundException ex) { + // it's ok if we're unable to set the drawable for this view - the user + // can still use it + } + + adapter.add(builder.build()); + } - b.show(); + new MaterialDialog.Builder(getContext()) + .title(R.string.chooser_select_link) + .cancelable(true) + .adapter(adapter, null) + .negativeText(android.R.string.cancel) + .onNegative(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + dialog.dismiss(); + } + }) + .show(); } } public CompositeMessage getMessage() { return mMessage; } + + private static final class URLSpanAdapterCallback implements MaterialSimpleListAdapter.Callback { + private TextView mParent; + + URLSpanAdapterCallback(TextView parent) { + mParent = parent; + } + + @Override + public void onMaterialListItemSelected(MaterialDialog dialog, int index, MaterialSimpleListItem item) { + if (item != null && item.getTag() != null) + ((URLSpan) item.getTag()).onClick(mParent); + dialog.dismiss(); + } + } } diff --git a/app/src/main/java/org/kontalk/ui/view/MessageListItemTheme.java b/app/src/main/java/org/kontalk/ui/view/MessageListItemTheme.java new file mode 100644 index 000000000..e09d5be1b --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/MessageListItemTheme.java @@ -0,0 +1,67 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import java.util.List; +import java.util.regex.Pattern; + +import android.view.View; +import android.view.ViewStub; + +import androidx.annotation.ColorInt; + +import org.kontalk.data.Contact; +import org.kontalk.message.MessageComponent; + + +/** + * Interface implemented by message list item themes. + * @author Daniele Ricci + */ +public interface MessageListItemTheme { + + View inflate(ViewStub stub); + + MessageContentLayout getContent(); + + void setEncryptedContent(long databaseId); + + void processComponents(long databaseId, Pattern highlight, + List> components, Object... args); + + void setSecurityFlags(int securityFlags); + + void setIncoming(Contact contact, boolean sameMessageBlock); + + void setOutgoing(Contact contact, int status, boolean sameMessageBlock); + + void setTimestamp(CharSequence timestamp); + + TextContentView getTextContentView(); + + void unload(); + + // theme information + + boolean isFullWidth(); + + @ColorInt + int getTextColor(); + +} diff --git a/app/src/main/java/org/kontalk/ui/view/MessageListItemThemeFactory.java b/app/src/main/java/org/kontalk/ui/view/MessageListItemThemeFactory.java new file mode 100644 index 000000000..8d451f0c5 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/MessageListItemThemeFactory.java @@ -0,0 +1,85 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import java.util.LinkedHashMap; +import java.util.Map; + +import android.content.Context; + +import org.kontalk.R; +import org.kontalk.ui.view.themes.ClassicMessageTheme; +import org.kontalk.ui.view.themes.HangoutMessageTheme; +import org.kontalk.ui.view.themes.IPhoneMessageTheme; +import org.kontalk.ui.view.themes.OldClassicMessageTheme; +import org.kontalk.ui.view.themes.SilenceMessageTheme; + + +/** + * Factory to build balloon themes. + * @author Daniele Ricci + */ +public class MessageListItemThemeFactory { + + public interface FactoryCreator { + MessageListItemTheme create(int direction, boolean groupChat); + + // creating a theme involves creating theme instances possibly for nothing + // so we put these here so we are able to make an early decision + + boolean supportsLightTheme(); + + boolean supportsDarkTheme(); + } + + private static final Map mThemes = new LinkedHashMap<>(); + + static { + mThemes.put("hangout", new HangoutMessageTheme.FactoryCreator()); + mThemes.put("silence", new SilenceMessageTheme.FactoryCreator()); + mThemes.put("classic", new ClassicMessageTheme.FactoryCreator()); + mThemes.put("old_classic", new OldClassicMessageTheme.FactoryCreator()); + mThemes.put("iphone", new IPhoneMessageTheme.FactoryCreator()); + } + + private MessageListItemThemeFactory() { + } + + public static MessageListItemTheme createTheme(Context context, String theme, + int direction, boolean event, boolean groupChat) { + if (event) { + return new EventMessageTheme(R.layout.balloon_event); + } + else { + FactoryCreator factory = mThemes.get(theme); + if (factory == null) + throw new IllegalArgumentException("theme not found: " + theme); + + return factory.create(direction, groupChat); + } + } + + public static boolean isDayNightSupported(Context context, String theme) { + FactoryCreator factory = mThemes.get(theme); + if (factory == null) + throw new IllegalArgumentException("theme not found: " + theme); + + return factory.supportsLightTheme() && factory.supportsDarkTheme(); + } +} diff --git a/app/src/main/java/org/kontalk/ui/view/QuoteContentView.java b/app/src/main/java/org/kontalk/ui/view/QuoteContentView.java new file mode 100644 index 000000000..57be45afe --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/QuoteContentView.java @@ -0,0 +1,168 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import java.util.regex.Pattern; + +import android.content.Context; +import android.graphics.Typeface; + +import androidx.annotation.NonNull; +import androidx.core.text.util.LinkifyCompat; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.util.Linkify; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.kontalk.Kontalk; +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.message.InReplyToComponent; +import org.kontalk.message.ReferencedMessage; +import org.kontalk.provider.MyMessages; +import org.kontalk.util.SystemUtils; +import org.kontalk.util.ViewUtils; +import org.kontalk.util.XMPPUtils; + + +/** + * Component view for {@link InReplyToComponent}. + * @author Daniele Ricci + */ +public class QuoteContentView extends RelativeLayout + implements MessageContentView { + + private InReplyToComponent mComponent; + + private TextView mSender; + private TextView mContent; + + public QuoteContentView(Context context) { + super(context); + } + + public QuoteContentView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mSender = findViewById(R.id.sender); + mContent = findViewById(R.id.content); + } + + @Override + public void bind(long databaseId, InReplyToComponent component, Pattern highlight) { + mComponent = component; + + CharSequence msgSender; + CharSequence msgText; + + ReferencedMessage referencedMsg = mComponent.getContent(); + if (referencedMsg != null) { + SpannableStringBuilder formattedMessage = + new SpannableStringBuilder(referencedMsg.getTextContent()); + + // linkify! + if (formattedMessage.length() < TextContentView.MAX_AFFORDABLE_SIZE) { + try { + LinkifyCompat.addLinks(formattedMessage, Linkify.ALL); + } + catch (Throwable e) { + // working around some crappy firmwares + } + } + + TextContentView.applyTextWorkarounds(formattedMessage); + msgText = formattedMessage; + + Contact sender = getReferencedContact(referencedMsg); + msgSender = sender.getDisplayName(); + } + else { + Spannable formattedMessage = new SpannableString(getContext() + .getString(R.string.reply_message_deleted)); + formattedMessage.setSpan(SystemUtils.getTypefaceSpan(Typeface.ITALIC), + 0, formattedMessage.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + msgText = formattedMessage; + + msgSender = getContext().getString(R.string.peer_unknown); + } + + mSender.setText(msgSender); + mContent.setText(msgText); + } + + @Override + public void unbind() { + clear(); + } + + @Override + public InReplyToComponent getComponent() { + return mComponent; + } + + /** Quote is always first. */ + @Override + public int getPriority() { + return 1; + } + + @Override + public void onApplyTheme(MessageListItemTheme theme) { + ViewUtils.setMessageBodyTextStyle(mSender, false); + ReferencedMessage referencedMessage = mComponent.getContent(); + if (referencedMessage != null) { + mSender.setTextColor(XMPPUtils + .getJIDColor(getReferencedContact(referencedMessage).getJID())); + } + + ViewUtils.setMessageBodyTextStyle(mContent, false); + } + + private void clear() { + mComponent = null; + } + + private Contact getReferencedContact(@NonNull ReferencedMessage referencedMsg) { + String senderId; + if (referencedMsg.getDirection() == MyMessages.Messages.DIRECTION_OUT) { + senderId = Kontalk.get().getDefaultAccount().getSelfJID(); + } + else { + senderId = referencedMsg.getPeer(); + } + + return Contact.findByUserId(getContext(), senderId); + } + + public static QuoteContentView create(LayoutInflater inflater, ViewGroup parent) { + return (QuoteContentView) inflater.inflate(R.layout.message_content_quote, parent, false); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/ReplyBar.java b/app/src/main/java/org/kontalk/ui/view/ReplyBar.java new file mode 100644 index 000000000..0f2a5f941 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/ReplyBar.java @@ -0,0 +1,144 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Build; +import androidx.annotation.IdRes; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewParent; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.kontalk.R; +import org.kontalk.util.XMPPUtils; + + +/** + * The reply view for the composer bar. + * @author Daniele Ricci + */ +public class ReplyBar extends RelativeLayout { + + /** The referenced message database id. */ + private long mMessageId; + + private TextView mSender; + private TextView mText; + + OnCancelListener mOnCancelListener; + + // this is actually out of our view group + private View mDividerView; + @IdRes + private int mDividerViewId; + + public ReplyBar(Context context) { + super(context); + init(context, null, 0); + } + + public ReplyBar(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs, 0); + } + + public ReplyBar(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs, defStyleAttr); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public ReplyBar(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs, defStyleAttr); + } + + private void init(Context context, AttributeSet attrs, int defStyle) { + inflate(context, R.layout.reply_bar, this); + + final TypedArray attrArray = context.obtainStyledAttributes(attrs, + R.styleable.ReplyBar, defStyle, 0); + initAttributes(attrArray); + attrArray.recycle(); + } + + private void initAttributes(TypedArray attrs) { + mDividerViewId = attrs.getResourceId(R.styleable.ReplyBar_dividerView, View.NO_ID); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mSender = findViewById(R.id.sender); + mText = findViewById(R.id.text); + + findViewById(R.id.btn_cancel).setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + if (mOnCancelListener != null) + mOnCancelListener.onCancel(ReplyBar.this); + } + }); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (mDividerViewId != View.NO_ID) { + ViewParent parent = getParent(); + if (parent instanceof View) { + mDividerView = ((View) parent).findViewById(mDividerViewId); + } + } + } + + public void setOnCancelListener(OnCancelListener listener) { + mOnCancelListener = listener; + } + + public long getMessageId() { + return mMessageId; + } + + public void show(long msgId, String jid, CharSequence sender, CharSequence text) { + mMessageId = msgId; + mSender.setText(sender); + mSender.setTextColor(XMPPUtils.getJIDColor(jid)); + mText.setText(text); + setVisibility(VISIBLE); + if (mDividerView != null) + mDividerView.setVisibility(VISIBLE); + } + + public void hide() { + mMessageId = 0; + setVisibility(GONE); + if (mDividerView != null) + mDividerView.setVisibility(GONE); + } + + public interface OnCancelListener { + void onCancel(ReplyBar view); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/SearchListItem.java b/app/src/main/java/org/kontalk/ui/view/SearchListItem.java index 66ff7293e..922114ac6 100644 --- a/app/src/main/java/org/kontalk/ui/view/SearchListItem.java +++ b/app/src/main/java/org/kontalk/ui/view/SearchListItem.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,9 +18,9 @@ package org.kontalk.ui.view; -import org.kontalk.data.Contact; import org.kontalk.data.SearchItem; +import android.annotation.SuppressLint; import android.content.Context; import android.util.AttributeSet; import android.widget.RelativeLayout; @@ -41,12 +41,13 @@ public SearchListItem(Context context, AttributeSet attrs) { super(context, attrs); } + @SuppressLint("SetTextI18n") @Override protected void onFinishInflate() { super.onFinishInflate(); - mText1 = (TextView) findViewById(android.R.id.text1); - mText2 = (TextView) findViewById(android.R.id.text2); + mText1 = findViewById(android.R.id.text1); + mText2 = findViewById(android.R.id.text2); if (isInEditMode()) { mText1.setText("Test contact"); @@ -56,15 +57,7 @@ protected void onFinishInflate() { public final void bind(Context context, final SearchItem found) { mFound = found; - - final Contact contact = found.getContact(); - String name; - if (contact != null) - name = contact.getName() + " <" + contact.getNumber() + ">"; - else - name = found.getUserId(); - - mText1.setText(name); + mText1.setText(found.getUserDisplayName()); mText2.setText(found.getText()); } diff --git a/app/src/main/java/org/kontalk/ui/view/TextContentView.java b/app/src/main/java/org/kontalk/ui/view/TextContentView.java index 090f7ab26..9e3bb4a5c 100644 --- a/app/src/main/java/org/kontalk/ui/view/TextContentView.java +++ b/app/src/main/java/org/kontalk/ui/view/TextContentView.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,7 +18,17 @@ package org.kontalk.ui.view; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.vanniktech.emoji.EmojiTextView; + import android.content.Context; + +import androidx.core.content.ContextCompat; +import androidx.core.text.util.LinkifyCompat; + +import android.text.Editable; import android.text.Layout; import android.text.SpannableStringBuilder; import android.text.style.BackgroundColorSpan; @@ -28,21 +38,15 @@ import android.view.ViewGroup; import org.kontalk.R; -import org.kontalk.data.Contact; import org.kontalk.message.TextComponent; -import org.kontalk.util.Preferences; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import com.rockerhieu.emojicon.EmojiconTextView; +import org.kontalk.util.ViewUtils; /** * Message component for {@link TextComponent}. * @author Daniele Ricci */ -public class TextContentView extends EmojiconTextView +public class TextContentView extends EmojiTextView implements MessageContentView { // pool-related stuff @@ -62,12 +66,14 @@ public class TextContentView extends EmojiconTextView * Maximum affordable size of a text message to make complex stuff * (e.g. emoji, linkify, etc.) */ - private static final int MAX_AFFORDABLE_SIZE = 10240; // 10 KB + static final int MAX_AFFORDABLE_SIZE = 10240; // 10 KB private TextComponent mComponent; private boolean mEncryptionPlaceholder; private BackgroundColorSpan mHighlightColorSpan; // set in ctor + private boolean mMeasureHack; + public TextContentView(Context context) { super(context); init(context); @@ -78,84 +84,72 @@ public TextContentView(Context context, AttributeSet attrs) { init(context); } - public TextContentView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - init(context); - } - private void init(Context context) { - int color = context.getResources().getColor(R.color.highlight_color); + int color = ContextCompat.getColor(context, R.color.highlight_color); mHighlightColorSpan = new BackgroundColorSpan(color); } - /* + /** * Hack for fixing extra space took by the TextView. * I still have to understand why this works and plain getHeight() doesn't. * http://stackoverflow.com/questions/7439748/why-is-wrap-content-in-multiple-line-textview-filling-parent + * https://github.com/qklabs/qksms/blob/master/QKSMS/src/main/java/com/moez/QKSMS/ui/view/QKTextView.java */ + public void enableMeasureHack(boolean enabled) { + mMeasureHack = enabled; + } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); - Layout layout = getLayout(); - if (layout != null) { - int width = (int) Math.ceil(getMaxLineWidth(layout)) - + getCompoundPaddingLeft() + getCompoundPaddingRight(); - int height = getMeasuredHeight(); - setMeasuredDimension(width, height); - } - } - - private float getMaxLineWidth(Layout layout) { - float max_width = 0.0f; - int lines = layout.getLineCount(); - for (int i = 0; i < lines; i++) { - if (layout.getLineWidth(i) > max_width) { - max_width = layout.getLineWidth(i); + if (mMeasureHack) { + int specModeW = MeasureSpec.getMode(widthMeasureSpec); + if (specModeW != MeasureSpec.EXACTLY) { + Layout layout = getLayout(); + int linesCount = layout.getLineCount(); + if (linesCount > 1) { + float textRealMaxWidth = 0; + for (int n = 0; n < linesCount; ++n) { + textRealMaxWidth = Math.max(textRealMaxWidth, layout.getLineWidth(n)); + } + int w = Math.round(textRealMaxWidth); + if (w < getMeasuredWidth()) { + super.onMeasure(MeasureSpec.makeMeasureSpec(w, MeasureSpec.AT_MOST), + heightMeasureSpec); + } + } } } - return max_width; } - public void bind(long databaseId, TextComponent component, Contact contact, Pattern highlight) { + @Override + public void bind(long databaseId, TextComponent component, Pattern highlight) { mComponent = component; - Context context = getContext(); - - SpannableStringBuilder formattedMessage = formatMessage(contact, highlight); - String size = Preferences.getFontSize(context); - int sizeId; - if (size.equals("small")) - sizeId = android.R.style.TextAppearance_Small; - else if (size.equals("large")) - sizeId = android.R.style.TextAppearance_Large; - else - sizeId = android.R.style.TextAppearance; - setTextAppearance(context, sizeId); - //setEmojiconSize((int) getTextSize()); + + SpannableStringBuilder formattedMessage = formatMessage(highlight); // linkify! - if (formattedMessage.length() < MAX_AFFORDABLE_SIZE) - Linkify.addLinks(formattedMessage, Linkify.ALL); + if (formattedMessage.length() < MAX_AFFORDABLE_SIZE) { + try { + LinkifyCompat.addLinks(formattedMessage, Linkify.ALL); + } + catch (Throwable e) { + // working around some crappy firmwares + } + } - /* - * workaround for bugs: - * http://code.google.com/p/android/issues/detail?id=17343 - * http://code.google.com/p/android/issues/detail?id=22493 - * applies from Honeycomb to JB 4.2.2 afaik - */ - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB && - android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) - // from http://stackoverflow.com/a/12303155/1045199 - formattedMessage.append("\u200b"); // was: \u2060 + TextContentView.applyTextWorkarounds(formattedMessage); setText(formattedMessage); } + @Override public void unbind() { recycle(); } + @Override public TextComponent getComponent() { return mComponent; } @@ -166,11 +160,18 @@ public int getPriority() { return 10; } + @Override + public void onApplyTheme(MessageListItemTheme theme) { + ViewUtils.setMessageBodyTextStyle(this, true); + setTextColor(theme.getTextColor()); + setLinkTextColor(theme.getTextColor()); + } + public boolean isEncryptionPlaceholder() { return mEncryptionPlaceholder; } - private SpannableStringBuilder formatMessage(final Contact contact, final Pattern highlight) { + private SpannableStringBuilder formatMessage(final Pattern highlight) { SpannableStringBuilder buf; String textContent = mComponent.getContent(); @@ -235,4 +236,16 @@ public static TextContentView create(LayoutInflater inflater, ViewGroup parent, return view; } + static void applyTextWorkarounds(Editable formattedMessage) { + /* + * workaround for bugs: + * http://code.google.com/p/android/issues/detail?id=17343 + * http://code.google.com/p/android/issues/detail?id=22493 + * applies from Honeycomb to JB 4.2.2 afaik + */ + if (android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) + // from http://stackoverflow.com/a/12303155/1045199 + formattedMessage.append("\u200b"); // was: \u2060 + } + } diff --git a/app/src/main/java/org/kontalk/ui/view/VCardContentView.java b/app/src/main/java/org/kontalk/ui/view/VCardContentView.java index de5d4ce43..c72da1e63 100644 --- a/app/src/main/java/org/kontalk/ui/view/VCardContentView.java +++ b/app/src/main/java/org/kontalk/ui/view/VCardContentView.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,22 +21,22 @@ import java.util.regex.Pattern; import android.content.Context; +import androidx.appcompat.widget.AppCompatTextView; import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.ViewGroup; -import android.widget.TextView; import org.kontalk.R; -import org.kontalk.data.Contact; import org.kontalk.message.CompositeMessage; import org.kontalk.message.VCardComponent; +import org.kontalk.util.ViewUtils; /** * Message component for {@link org.kontalk.message.VCardComponent}. * @author Daniele Ricci */ -public class VCardContentView extends TextView +public class VCardContentView extends AppCompatTextView implements MessageContentView { private VCardComponent mComponent; @@ -53,18 +53,19 @@ public VCardContentView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } - public void bind(long id, VCardComponent component, Contact contact, Pattern highlight) { + @Override + public void bind(long id, VCardComponent component, Pattern highlight) { mComponent = component; - - // TODO set text appearance (since this is text) String text = CompositeMessage.getSampleTextContent(component.getMime()); setText(text); } + @Override public void unbind() { clear(); } + @Override public VCardComponent getComponent() { return mComponent; } @@ -74,14 +75,19 @@ public int getPriority() { return 7; } + @Override + public void onApplyTheme(MessageListItemTheme theme) { + ViewUtils.setMessageBodyTextStyle(this, true); + setTextColor(theme.getTextColor()); + } + private void clear() { mComponent = null; } public static VCardContentView create(LayoutInflater inflater, ViewGroup parent) { - VCardContentView view = (VCardContentView) inflater.inflate(R.layout.message_content_vcard, + return (VCardContentView) inflater.inflate(R.layout.message_content_vcard, parent, false); - return view; } } diff --git a/app/src/main/java/org/kontalk/ui/view/themes/AvatarMessageTheme.java b/app/src/main/java/org/kontalk/ui/view/themes/AvatarMessageTheme.java new file mode 100644 index 000000000..4d92b1083 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/themes/AvatarMessageTheme.java @@ -0,0 +1,188 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view.themes; + +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.Handler; + +import androidx.annotation.AttrRes; +import androidx.core.content.ContextCompat; +import android.view.View; +import android.view.ViewStub; +import android.widget.LinearLayout; + +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.ui.view.CircleContactBadge; +import org.kontalk.ui.view.MessageContentView; +import org.kontalk.ui.view.TextContentView; +import org.kontalk.util.SystemUtils; + + +/** + * Avatar-based message balloon theme. + * @author Daniele Ricci + */ +public abstract class AvatarMessageTheme extends BaseMessageTheme implements Contact.ContactCallback { + + private static Drawable sDefaultContactImage; + + private final int mDrawableId; + /** If true, handles collapsed message blocks (hides adjacent avatars). */ + private final boolean mMessageBlocks; + + protected LinearLayout mBalloonView; + + protected CircleContactBadge mAvatar; + + private Handler mHandler; + + protected AvatarMessageTheme(int layoutId, int drawableId, boolean messageBlocks, boolean groupChat, + @AttrRes int textColorRes, @AttrRes int dateColorRes) { + super(layoutId, groupChat, textColorRes, dateColorRes); + mDrawableId = drawableId; + mMessageBlocks = messageBlocks; + } + + @Override + public View inflate(ViewStub stub) { + View view = super.inflate(stub); + + mBalloonView = view.findViewById(R.id.balloon_view); + + mAvatar = view.findViewById(R.id.avatar); + + mHandler = new Handler(); + + if (sDefaultContactImage == null) { + sDefaultContactImage = ContextCompat + .getDrawable(mContext, R.drawable.ic_default_contact); + } + + return view; + } + + @Override + public boolean isFullWidth() { + return false; + } + + @Override + public void processComponentView(MessageContentView view) { + if (view instanceof TextContentView) { + ((TextContentView) view).enableMeasureHack(true); + } + } + + protected void setView(boolean sameMessageBlock) { + if (mBalloonView != null) { + mBalloonView.setBackgroundResource(mDrawableId); + } + } + + @Override + public void setIncoming(Contact contact, boolean sameMessageBlock) { + setView(sameMessageBlock); + + if (mAvatar != null) { + if (mMessageBlocks && sameMessageBlock) { + mAvatar.setVisibility(View.INVISIBLE); + mAvatar.setImageDrawable(null); + } + else { + mAvatar.setImageDrawable(sDefaultContactImage); + if (contact != null) { + // we mark this with the contact's hash code for the async avatar + mAvatar.setTag(contact.hashCode()); + mAvatar.assignContactUri(contact.getUri()); + contact.getAvatarAsync(mContext, this); + } + else { + mAvatar.setTag(null); + mAvatar.assignContactUri(null); + } + mAvatar.setVisibility(View.VISIBLE); + } + } + + super.setIncoming(contact, sameMessageBlock); + } + + @Override + public void setOutgoing(Contact contact, int status, boolean sameMessageBlock) { + setView(sameMessageBlock); + + if (mAvatar != null) { + if (mMessageBlocks && sameMessageBlock) { + mAvatar.setVisibility(View.INVISIBLE); + mAvatar.setImageDrawable(null); + } + else { + Drawable avatar; + Bitmap profile = SystemUtils.getProfilePhoto(mContext); + if (profile != null) { + avatar = new BitmapDrawable(mContext.getResources(), profile); + } + else { + avatar = sDefaultContactImage; + } + + mAvatar.setImageDrawable(avatar); + mAvatar.assignContactUri(SystemUtils.getProfileUri(mContext)); + mAvatar.setVisibility(View.VISIBLE); + } + } + + super.setOutgoing(contact, status, sameMessageBlock); + } + + @Override + public void avatarLoaded(final Contact contact, final Drawable avatar) { + if (avatar != null) { + if (mHandler.getLooper().getThread() != Thread.currentThread()) { + mHandler.post(new Runnable() { + public void run() { + updateAvatar(contact, avatar); + } + }); + } + else { + updateAvatar(contact, avatar); + } + } + } + + void updateAvatar(Contact contact, Drawable avatar) { + try { + // be sure the contact is still the same + // this is an insane workaround against race conditions + Integer contactTag = (Integer) mAvatar.getTag(); + if (contactTag != null && contactTag == contact.hashCode()) + mAvatar.setImageDrawable(avatar); + } + catch (Exception e) { + // we are deliberately ignoring any exception here + // because an error here could happen only if something + // weird is happening, e.g. user leaving the activity + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/themes/BaseMessageTheme.java b/app/src/main/java/org/kontalk/ui/view/themes/BaseMessageTheme.java new file mode 100644 index 000000000..126ee90ff --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/themes/BaseMessageTheme.java @@ -0,0 +1,257 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view.themes; + +import java.util.List; +import java.util.regex.Pattern; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewStub; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.AttrRes; +import androidx.annotation.ColorInt; +import androidx.annotation.LayoutRes; +import androidx.core.content.res.ResourcesCompat; + +import org.kontalk.R; +import org.kontalk.crypto.Coder; +import org.kontalk.data.Contact; +import org.kontalk.message.MessageComponent; +import org.kontalk.message.TextComponent; +import org.kontalk.provider.MyMessages; +import org.kontalk.ui.view.MessageContentLayout; +import org.kontalk.ui.view.MessageContentView; +import org.kontalk.ui.view.MessageContentViewFactory; +import org.kontalk.ui.view.MessageListItemTheme; +import org.kontalk.ui.view.TextContentView; +import org.kontalk.util.SystemUtils; +import org.kontalk.util.XMPPUtils; + + +/** + * Base message balloon theme with no avatars. + * @author Daniele Ricci + */ +public abstract class BaseMessageTheme implements MessageListItemTheme { + + @LayoutRes + private final int mLayoutId; + @AttrRes + private final int mTextColorId; + @AttrRes + private final int mDateColorRes; + + protected Context mContext; + protected LayoutInflater mInflater; + + protected TextView mContactNameView; + private MessageContentLayout mContent; + private ImageView mStatusIcon; + private ImageView mWarningIcon; + private TextView mDateView; + + /** If true, we will show the contact name above the message content. */ + protected final boolean mGroupChat; + + protected BaseMessageTheme(@LayoutRes int layoutId, boolean groupChat, @AttrRes int textColorRes, @AttrRes int dateColorRes) { + mLayoutId = layoutId; + mTextColorId = textColorRes; + mDateColorRes = dateColorRes; + mGroupChat = groupChat; + } + + @Override + public View inflate(ViewStub stub) { + stub.setLayoutResource(mLayoutId); + View view = stub.inflate(); + // save the inflater for later + mContext = stub.getContext(); + mInflater = LayoutInflater.from(mContext); + + mContactNameView = view.findViewById(R.id.contact_name); + mContent = view.findViewById(R.id.content); + mStatusIcon = view.findViewById(R.id.status_indicator); + mWarningIcon = view.findViewById(R.id.warning_icon); + mDateView = view.findViewById(R.id.date_view); + + int dateTextColor = ResourcesCompat.getColor(mContext.getResources(), + SystemUtils.getThemedResource(mContext, mDateColorRes), mContext.getTheme()); + mDateView.setTextColor(dateTextColor); + + return view; + } + + @Override + public MessageContentLayout getContent() { + return mContent; + } + + @Override + public void setEncryptedContent(long databaseId) { + // FIXME this is not good + TextContentView view = TextContentView.obtain(mInflater, mContent, true); + + String text = mContext.getResources().getString(R.string.text_encrypted); + view.bind(databaseId, new TextComponent(text), null); + mContent.addContent(view); + } + + @Override + public void processComponents(long databaseId, Pattern highlight, + List> components, Object... args) { + for (MessageComponent cmp : components) { + MessageContentView view = MessageContentViewFactory + .createContent(mInflater, mContent, cmp, databaseId, + highlight, args); + + if (view != null) { + view.onApplyTheme(this); + processComponentView(view); + mContent.addContent(view); + } + } + } + + /** Override to modify a component view before adding it. */ + public void processComponentView(MessageContentView view) { + } + + @Override + public void setSecurityFlags(int securityFlags) { + if (Coder.isError(securityFlags)) { + mWarningIcon.setImageResource(R.drawable.ic_msg_security); + mWarningIcon.setVisibility(View.VISIBLE); + } + else { + mWarningIcon.setImageResource(R.drawable.ic_msg_warning); + mWarningIcon.setVisibility((securityFlags != Coder.SECURITY_CLEARTEXT) ? + View.GONE : View.VISIBLE); + } + } + + @Override + public void setIncoming(Contact contact, boolean sameMessageBlock) { + // no status icon for incoming messages + mStatusIcon.setImageDrawable(null); + mStatusIcon.setVisibility(View.GONE); + + if (mGroupChat) { + if (contact != null) { + mContactNameView.setText(contact.getDisplayName()); + mContactNameView.setTextColor(XMPPUtils.getJIDColor(contact.getJID())); + mContactNameView.setVisibility(View.VISIBLE); + } + else { + // FIXME awkard situation + mContactNameView.setVisibility(View.GONE); + } + } + else { + mContactNameView.setVisibility(View.GONE); + } + } + + @Override + public void setOutgoing(Contact contact, int status, boolean sameMessageBlock) { + int resId = 0; + int statusId = 0; + + // status icon + switch (status) { + case MyMessages.Messages.STATUS_SENDING: + // use pending icon even for errors + case MyMessages.Messages.STATUS_ERROR: + case MyMessages.Messages.STATUS_PENDING: + case MyMessages.Messages.STATUS_QUEUED: + resId = R.drawable.ic_msg_pending; + statusId = R.string.msg_status_sending; + break; + case MyMessages.Messages.STATUS_RECEIVED: + resId = R.drawable.ic_msg_delivered; + statusId = R.string.msg_status_delivered; + break; + // here we use the error icon + case MyMessages.Messages.STATUS_NOTACCEPTED: + resId = R.drawable.ic_msg_error; + statusId = R.string.msg_status_notaccepted; + break; + case MyMessages.Messages.STATUS_SENT: + resId = R.drawable.ic_msg_sent; + statusId = R.string.msg_status_sent; + break; + case MyMessages.Messages.STATUS_NOTDELIVERED: + resId = R.drawable.ic_msg_notdelivered; + statusId = R.string.msg_status_notdelivered; + break; + } + + if (resId > 0) { + mStatusIcon.setImageResource(resId); + mStatusIcon.setVisibility(View.VISIBLE); + mStatusIcon.setContentDescription(mContext.getResources().getString(statusId)); + } + else { + mStatusIcon.setImageDrawable(null); + mStatusIcon.setVisibility(View.GONE); + } + } + + protected boolean isIncoming() { + return mStatusIcon.getVisibility() == View.GONE; + } + + @Override + public void setTimestamp(CharSequence timestamp) { + mDateView.setText(timestamp); + } + + @Override + public TextContentView getTextContentView() { + int c = mContent.getChildCount(); + for (int i = 0; i < c; i++) { + MessageContentView view = (MessageContentView) mContent.getChildAt(i); + if (view instanceof TextContentView) { + return (TextContentView) view; + } + } + return null; + } + + @Override + public void unload() { + int c = mContent.getChildCount(); + for (int i = 0; i < c; i++) { + MessageContentView view = (MessageContentView) mContent.getChildAt(0); + mContent.removeView((View) view); + view.unbind(); + } + } + + @Override + @ColorInt + public int getTextColor() { + return ResourcesCompat.getColor(mContext.getResources(), + SystemUtils.getThemedResource(mContext, mTextColorId), mContext.getTheme()); + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/themes/ClassicMessageTheme.java b/app/src/main/java/org/kontalk/ui/view/themes/ClassicMessageTheme.java new file mode 100644 index 000000000..482637d3c --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/themes/ClassicMessageTheme.java @@ -0,0 +1,56 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view.themes; + +import org.kontalk.R; +import org.kontalk.ui.view.MessageListItemTheme; +import org.kontalk.ui.view.MessageListItemThemeFactory; + + +/** + * Classic theme. + * @author Daniele Ricci + */ +public class ClassicMessageTheme extends SimpleMessageTheme { + + private ClassicMessageTheme(int direction, boolean groupChat) { + super(R.drawable.balloon_classic_incoming, + R.drawable.balloon_classic_outgoing, groupChat, + R.attr.chatThemeDayOnlyMessageTextColor, R.attr.chatThemeDayOnlyDateTextColor); + } + + public static final class FactoryCreator implements MessageListItemThemeFactory.FactoryCreator { + + @Override + public MessageListItemTheme create(int direction, boolean groupChat) { + return new ClassicMessageTheme(direction, groupChat); + } + + @Override + public boolean supportsLightTheme() { + return true; + } + + @Override + public boolean supportsDarkTheme() { + return false; + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/themes/HangoutMessageTheme.java b/app/src/main/java/org/kontalk/ui/view/themes/HangoutMessageTheme.java new file mode 100644 index 000000000..8e2d021a7 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/themes/HangoutMessageTheme.java @@ -0,0 +1,94 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view.themes; + +import android.view.View; + +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.provider.MyMessages; +import org.kontalk.ui.view.MessageListItemTheme; +import org.kontalk.ui.view.MessageListItemThemeFactory; + + +/** + * Theme based on Google Hangouts :) + * @author Daniele Ricci + */ +public class HangoutMessageTheme extends AvatarMessageTheme { + + private final int mDirection; + + private HangoutMessageTheme(int direction, boolean groupChat) { + super(direction == MyMessages.Messages.DIRECTION_IN ? + R.layout.balloon_avatar_in_top : R.layout.balloon_avatar_out, + direction == MyMessages.Messages.DIRECTION_IN ? + R.drawable.balloon_hangout_incoming : + R.drawable.balloon_hangout_outgoing, true, groupChat, + R.attr.chatThemeDayNightMessageTextColor, R.attr.chatThemeDayNightDateTextColor); + mDirection = direction; + } + + @Override + protected void setView(boolean sameMessageBlock) { + if (sameMessageBlock) { + if (mBalloonView != null) { + int drawable = mDirection == MyMessages.Messages.DIRECTION_IN ? + R.drawable.balloon_hangout_block_incoming : + R.drawable.balloon_hangout_block_outgoing; + mBalloonView.setBackgroundResource(drawable); + } + } + else { + super.setView(false); + } + } + + @Override + public void setIncoming(Contact contact, boolean sameMessageBlock) { + super.setIncoming(contact, sameMessageBlock); + + // hide contact name if message is in the same block + if (mGroupChat && !sameMessageBlock) { + mContactNameView.setVisibility(View.VISIBLE); + } + else { + mContactNameView.setVisibility(View.GONE); + } + } + + public static final class FactoryCreator implements MessageListItemThemeFactory.FactoryCreator { + + @Override + public MessageListItemTheme create(int direction, boolean groupChat) { + return new HangoutMessageTheme(direction, groupChat); + } + + @Override + public boolean supportsLightTheme() { + return true; + } + + @Override + public boolean supportsDarkTheme() { + return true; + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/themes/IPhoneMessageTheme.java b/app/src/main/java/org/kontalk/ui/view/themes/IPhoneMessageTheme.java new file mode 100644 index 000000000..8924cece6 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/themes/IPhoneMessageTheme.java @@ -0,0 +1,56 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view.themes; + +import org.kontalk.R; +import org.kontalk.ui.view.MessageListItemTheme; +import org.kontalk.ui.view.MessageListItemThemeFactory; + + +/** + * Theme based on (a somewhat old) iPhone. + * @author Daniele Ricci + */ +public class IPhoneMessageTheme extends SimpleMessageTheme { + + private IPhoneMessageTheme(int direction, boolean groupChat) { + super(R.drawable.balloon_iphone_incoming, + R.drawable.balloon_iphone_outgoing, groupChat, + R.attr.chatThemeDayOnlyMessageTextColor, R.attr.chatThemeDayOnlyDateTextColor); + } + + public static final class FactoryCreator implements MessageListItemThemeFactory.FactoryCreator { + + @Override + public MessageListItemTheme create(int direction, boolean groupChat) { + return new IPhoneMessageTheme(direction, groupChat); + } + + @Override + public boolean supportsLightTheme() { + return true; + } + + @Override + public boolean supportsDarkTheme() { + return false; + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/themes/OldClassicMessageTheme.java b/app/src/main/java/org/kontalk/ui/view/themes/OldClassicMessageTheme.java new file mode 100644 index 000000000..53f795ff4 --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/themes/OldClassicMessageTheme.java @@ -0,0 +1,56 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view.themes; + +import org.kontalk.R; +import org.kontalk.ui.view.MessageListItemTheme; +import org.kontalk.ui.view.MessageListItemThemeFactory; + + +/** + * Classic (old) theme. + * @author Daniele Ricci + */ +public class OldClassicMessageTheme extends SimpleMessageTheme { + + private OldClassicMessageTheme(int direction, boolean groupChat) { + super(R.drawable.balloon_old_classic_incoming, + R.drawable.balloon_old_classic_outgoing, groupChat, + R.attr.chatThemeDayOnlyMessageTextColor, R.attr.chatThemeDayOnlyDateTextColor); + } + + public static final class FactoryCreator implements MessageListItemThemeFactory.FactoryCreator { + + @Override + public MessageListItemTheme create(int direction, boolean groupChat) { + return new OldClassicMessageTheme(direction, groupChat); + } + + @Override + public boolean supportsLightTheme() { + return true; + } + + @Override + public boolean supportsDarkTheme() { + return false; + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/themes/SilenceMessageTheme.java b/app/src/main/java/org/kontalk/ui/view/themes/SilenceMessageTheme.java new file mode 100644 index 000000000..ffe29600f --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/themes/SilenceMessageTheme.java @@ -0,0 +1,60 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view.themes; + +import org.kontalk.R; +import org.kontalk.provider.MyMessages; +import org.kontalk.ui.view.MessageListItemTheme; +import org.kontalk.ui.view.MessageListItemThemeFactory; + + +/** + * Theme based on Silence. + * @author Daniele Ricci + */ +public class SilenceMessageTheme extends AvatarMessageTheme { + + private SilenceMessageTheme(int direction, boolean groupChat) { + super(direction == MyMessages.Messages.DIRECTION_IN ? + R.layout.balloon_avatar_in_bottom : R.layout.balloon_avatar_out, + direction == MyMessages.Messages.DIRECTION_IN ? + R.drawable.balloon_silence_incoming : + R.drawable.balloon_silence_outgoing, false, groupChat, + R.attr.chatThemeDayOnlyMessageTextColor, R.attr.chatThemeDayOnlyDateTextColor); + } + + public static final class FactoryCreator implements MessageListItemThemeFactory.FactoryCreator { + + @Override + public MessageListItemTheme create(int direction, boolean groupChat) { + return new SilenceMessageTheme(direction, groupChat); + } + + @Override + public boolean supportsLightTheme() { + return true; + } + + @Override + public boolean supportsDarkTheme() { + return false; + } + } + +} diff --git a/app/src/main/java/org/kontalk/ui/view/themes/SimpleMessageTheme.java b/app/src/main/java/org/kontalk/ui/view/themes/SimpleMessageTheme.java new file mode 100644 index 000000000..45da25deb --- /dev/null +++ b/app/src/main/java/org/kontalk/ui/view/themes/SimpleMessageTheme.java @@ -0,0 +1,98 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui.view.themes; + +import android.view.Gravity; +import android.view.View; +import android.view.ViewStub; +import android.widget.LinearLayout; + +import androidx.annotation.AttrRes; + +import org.kontalk.R; +import org.kontalk.data.Contact; +import org.kontalk.ui.view.MessageContentView; +import org.kontalk.ui.view.TextContentView; + + +/** + * Simple drawable-based message theme. + * @author Daniele Ricci + */ +public abstract class SimpleMessageTheme extends BaseMessageTheme { + + private final int mIncomingDrawableId; + private final int mOutgoingDrawableId; + + private LinearLayout mBalloonView; + private LinearLayout mParentView; + + protected SimpleMessageTheme(int incomingDrawableId, int outgoingDrawableId, boolean groupChat, + @AttrRes int textColorRes, @AttrRes int dateColorRes) { + this(R.layout.balloon_base_noavatar, incomingDrawableId, outgoingDrawableId, groupChat, textColorRes, dateColorRes); + } + + protected SimpleMessageTheme(int layoutId, int incomingDrawableId, int outgoingDrawableId, boolean groupChat, + @AttrRes int textColorRes, @AttrRes int dateColorRes) { + super(layoutId, groupChat, textColorRes, dateColorRes); + mIncomingDrawableId = incomingDrawableId; + mOutgoingDrawableId = outgoingDrawableId; + } + + @Override + public View inflate(ViewStub stub) { + View view = super.inflate(stub); + mBalloonView = view.findViewById(R.id.balloon_view); + mParentView = view.findViewById(R.id.message_view_parent); + return view; + } + + @Override + public boolean isFullWidth() { + return false; + } + + @Override + public void processComponentView(MessageContentView view) { + if (view instanceof TextContentView) { + ((TextContentView) view).enableMeasureHack(true); + } + } + + @Override + public void setIncoming(Contact contact, boolean sameMessageBlock) { + if (mBalloonView != null) { + mBalloonView.setBackgroundResource(mIncomingDrawableId); + } + mParentView.setGravity(Gravity.START); + + super.setIncoming(contact, sameMessageBlock); + } + + @Override + public void setOutgoing(Contact contact, int status, boolean sameMessageBlock) { + if (mBalloonView != null) { + mBalloonView.setBackgroundResource(mOutgoingDrawableId); + } + mParentView.setGravity(Gravity.END); + + super.setOutgoing(contact, status, sameMessageBlock); + } + +} diff --git a/app/src/main/java/org/kontalk/upload/HTPPFileUploadConnection.java b/app/src/main/java/org/kontalk/upload/HTPPFileUploadConnection.java new file mode 100644 index 000000000..c39cd8f02 --- /dev/null +++ b/app/src/main/java/org/kontalk/upload/HTPPFileUploadConnection.java @@ -0,0 +1,162 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.upload; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; + +import javax.net.ssl.HttpsURLConnection; + +import org.apache.http.conn.ssl.AllowAllHostnameVerifier; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.net.Uri; +import android.os.Build; + +import org.kontalk.client.ClientHTTPConnection; +import org.kontalk.service.ProgressListener; +import org.kontalk.util.Preferences; +import org.kontalk.util.ProgressInputStreamEntity; + + +/** + * Simple HTTP upload via PUT. + * @author Daniele Ricci + */ +public class HTPPFileUploadConnection implements UploadConnection { + + private final Context mContext; + private final String mUrl; + + private HttpURLConnection currentRequest; + + private final static int CONNECT_TIMEOUT = 15000; + private final static int READ_TIMEOUT = 40000; + /** Minimum delay for progress notification updates in milliseconds. */ + private static final int PROGRESS_PUBLISH_DELAY = 1000; + + public HTPPFileUploadConnection(Context context, String url) { + mContext = context; + mUrl = url; + } + + @Override + public void abort() { + try { + currentRequest.disconnect(); + } + catch (Exception ignored) { + } + } + + @Override + public String upload(Uri uri, long length, String mime, ProgressListener listener) throws IOException { + InputStream inMessage = null; + try { + inMessage = mContext.getContentResolver().openInputStream(uri); + + // http request! + boolean acceptAnyCertificate = Preferences.getAcceptAnyCertificate(mContext); + currentRequest = prepareMessage(length, mime, acceptAnyCertificate); + + // execute! + ProgressInputStreamEntity entity = new ProgressInputStreamEntity(inMessage, this, listener, PROGRESS_PUBLISH_DELAY); + entity.writeTo(currentRequest.getOutputStream(), length); + + if (currentRequest.getResponseCode() != 200) + throw new IOException(currentRequest.getResponseCode() + " " + currentRequest.getResponseMessage()); + + // no media url returned + return null; + } + catch (Exception e) { + throw new IOException("upload error", e); + } + finally { + currentRequest.disconnect(); + currentRequest = null; + if (inMessage != null) { + try { + inMessage.close(); + } + catch (Exception e) { + // ignore + } + } + } + } + + @SuppressLint("AllowAllHostnameVerifier") + private void setupClient(HttpURLConnection conn, long length, String mime, boolean acceptAnyCertificate) + throws CertificateException, UnrecoverableKeyException, + NoSuchAlgorithmException, KeyStoreException, + KeyManagementException, NoSuchProviderException, + IOException { + + if (conn instanceof HttpsURLConnection) { + ((HttpsURLConnection) conn).setSSLSocketFactory(ClientHTTPConnection.setupSSLSocketFactory(mContext, + null, null, acceptAnyCertificate)); + if (acceptAnyCertificate) + ((HttpsURLConnection) conn).setHostnameVerifier(new AllowAllHostnameVerifier()); + } + conn.setRequestProperty("Content-Type", mime != null ? mime + : "application/octet-stream"); + // bug caused by Lighttpd + //conn.setRequestProperty("Expect", "100-continue"); + + conn.setConnectTimeout(CONNECT_TIMEOUT); + conn.setReadTimeout(READ_TIMEOUT); + conn.setDoOutput(true); + conn.setDoInput(true); + conn.setRequestProperty("Content-Length", String.valueOf(length)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + conn.setFixedLengthStreamingMode(length); + } + else { + conn.setFixedLengthStreamingMode((int) length); + } + conn.setRequestMethod("PUT"); + } + + /** A message posting method. */ + private HttpURLConnection prepareMessage(long length, String mime, boolean acceptAnyCertificate) + throws IOException { + + // create uri + HttpURLConnection conn = (HttpURLConnection) new URL(mUrl).openConnection(); + try { + setupClient(conn, length, mime, acceptAnyCertificate); + } + catch (Exception e) { + throw new IOException("error setting up SSL connection", e); + } + + return conn; + } + +} diff --git a/app/src/main/java/org/kontalk/upload/HTTPFileUploadService.java b/app/src/main/java/org/kontalk/upload/HTTPFileUploadService.java new file mode 100644 index 000000000..77201a0e5 --- /dev/null +++ b/app/src/main/java/org/kontalk/upload/HTTPFileUploadService.java @@ -0,0 +1,79 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.upload; + +import java.lang.ref.WeakReference; + +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.packet.Stanza; +import org.jxmpp.jid.BareJid; + +import org.kontalk.client.HTTPFileUpload; +import org.kontalk.service.msgcenter.IUploadService; + + +/** + * Implements XEP-0363: HTTP File Upload. + * @author Daniele Ricci + */ +public class HTTPFileUploadService implements IUploadService { + + private final WeakReference mConnection; + private final BareJid mService; + + public HTTPFileUploadService(XMPPConnection connection, BareJid service) { + mConnection = new WeakReference<>(connection); + mService = service; + } + + protected final XMPPConnection connection() { + return mConnection.get(); + } + + @Override + public boolean requiresCertificate() { + return false; + } + + @Override + public void getPostUrl(String filename, long size, String mime, final UrlCallback callback) { + HTTPFileUpload.Request request = new HTTPFileUpload.Request(filename, size, mime); + request.setTo(mService); + try { + connection().sendIqWithResponseCallback(request, new StanzaListener() { + @Override + public void processStanza(Stanza packet) throws SmackException.NotConnectedException { + if (packet instanceof HTTPFileUpload.Slot) { + HTTPFileUpload.Slot slot = (HTTPFileUpload.Slot) packet; + callback.callback(slot.getPutUrl(), slot.getGetUrl()); + } + } + }); + } + catch (SmackException.NotConnectedException e) { + // ignored + } + catch (InterruptedException e) { + // ignored + } + } + +} diff --git a/app/src/main/java/org/kontalk/upload/KontalkBoxUploadConnection.java b/app/src/main/java/org/kontalk/upload/KontalkBoxUploadConnection.java deleted file mode 100644 index 1fde598f0..000000000 --- a/app/src/main/java/org/kontalk/upload/KontalkBoxUploadConnection.java +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam - - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.kontalk.upload; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.security.PrivateKey; -import java.security.cert.X509Certificate; -import java.util.List; - -import org.apache.http.HttpException; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.NameValuePair; -import org.apache.http.client.ClientProtocolException; -import org.apache.http.client.HttpClient; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.methods.HttpRequestBase; -import org.apache.http.client.params.ClientPNames; -import org.apache.http.client.utils.URLEncodedUtils; -import org.apache.http.conn.ClientConnectionManager; -import org.apache.http.conn.scheme.PlainSocketFactory; -import org.apache.http.conn.scheme.Scheme; -import org.apache.http.conn.scheme.SchemeRegistry; -import org.apache.http.entity.ByteArrayEntity; -import org.apache.http.impl.client.DefaultHttpClient; -import org.apache.http.impl.conn.SingleClientConnManager; -import org.apache.http.params.BasicHttpParams; -import org.apache.http.params.HttpParams; -import org.apache.http.util.EntityUtils; - -import android.content.Context; -import android.content.res.AssetFileDescriptor; -import android.net.Uri; - -import org.kontalk.Kontalk; -import org.kontalk.client.ClientHTTPConnection; -import org.kontalk.client.EndpointServer; -import org.kontalk.crypto.Coder; -import org.kontalk.crypto.PersonalKey; -import org.kontalk.provider.UsersProvider; -import org.kontalk.service.ProgressListener; -import org.kontalk.util.Preferences; -import org.kontalk.util.ProgressInputStreamEntity; - - -/** - * Upload service implementation for Kontalk Box dropbox service. - * @author Daniele Ricci - */ -public class KontalkBoxUploadConnection implements UploadConnection { - private static final String TAG = KontalkBoxUploadConnection.class.getSimpleName(); - - /** The authentication token header. */ - private static final String HEADER_NAME_AUTHORIZATION = "Authorization"; - private static final String HEADER_VALUE_AUTHORIZATION = "KontalkToken auth="; - - /** Message flags header. */ - private static final String HEADER_MESSAGE_FLAGS = "X-Message-Flags"; - - protected final Context mContext; - - protected HttpRequestBase currentRequest; - protected HttpClient mConnection; - - private final PrivateKey mPrivateKey; - private final X509Certificate mCertificate; - - private final String mBaseUrl; - - public KontalkBoxUploadConnection(Context context, String url, - PrivateKey privateKey, X509Certificate bridgeCert) { - mContext = context; - mBaseUrl = url; - mPrivateKey = privateKey; - mCertificate = bridgeCert; - } - - @Override - public void abort() { - if (currentRequest != null) - currentRequest.abort(); - } - - @Override - public String upload(Uri uri, String mime, boolean encrypt, String to, ProgressListener listener) - throws IOException { - - HttpResponse response = null; - InputStream inMessage = null; - try { - AssetFileDescriptor stat = mContext.getContentResolver() - .openAssetFileDescriptor(uri, "r"); - long inLength = stat.getLength(); - stat.close(); - - inMessage = mContext.getContentResolver().openInputStream(uri); - - boolean encrypted = false; - // check if we have to encrypt the message - if (encrypt) { - PersonalKey key = Kontalk.get(mContext).getPersonalKey(); - EndpointServer server = Preferences.getEndpointServer(mContext); - Coder coder = UsersProvider.getEncryptCoder(mContext, server, key, new String[] { to }); - if (coder != null) { - // create a temporary file to store encrypted data - File temp = File.createTempFile("media", null, mContext.getCacheDir()); - FileOutputStream out = new FileOutputStream(temp); - - coder.encryptFile(inMessage, out); - // close original file and encrypted file - inMessage.close(); - out.close(); - - // open the encrypted file - inMessage = new FileInputStream(temp); - inLength = temp.length(); - encrypted = true; - - // delete the encrypted file - // it will stay until all streams are closed - temp.delete(); - } - } - - // http request! - currentRequest = prepareMessage(listener, - mime, inMessage, inLength, encrypted); - response = execute(currentRequest); - if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) - throw new HttpException(response.getStatusLine().getReasonPhrase()); - - return EntityUtils.toString(response.getEntity()); - } - catch (Exception e) { - throw innerException("upload error", e); - } - finally { - currentRequest = null; - try { - response.getEntity().consumeContent(); - } - catch (Exception e) { - // ignore - } - try { - inMessage.close(); - } - catch (Exception e) { - // ignore - } - } - } - - private IOException innerException(String detail, Throwable cause) { - IOException ie = new IOException(detail); - ie.initCause(cause); - return ie; - } - - /** A generic endpoint request method for the messaging server. */ - public HttpRequestBase prepare(List params, - String mime, byte[] content, boolean forcePost) - throws IOException { - - HttpRequestBase req; - - // compose uri - StringBuilder uri = new StringBuilder(mBaseUrl); - if (params != null) - uri.append("?").append(URLEncodedUtils.format(params, "UTF-8")); - - // request type - if (content != null || forcePost) { - req = new HttpPost(uri.toString()); - req.setHeader("Content-Type", mime != null ? mime - : "application/octet-stream"); - if (content != null) - ((HttpPost) req).setEntity(new ByteArrayEntity(content)); - } - else - req = new HttpGet(uri.toString()); - - return req; - } - - /** A message posting method. */ - private HttpRequestBase prepareMessage(ProgressListener listener, - String mime, InputStream data, long length, boolean encrypted) - throws IOException { - - HttpPost req = (HttpPost) prepare(null, mime, null, true); - req.setEntity(new ProgressInputStreamEntity(data, length, this, listener)); - - if (encrypted) - req.addHeader(HEADER_MESSAGE_FLAGS, "encrypted"); - - return req; - } - - /** - * Executes the given request. - * - * @param request - * the request - * @return the response - * @throws IOException - */ - private HttpResponse execute(HttpRequestBase request) throws IOException { - // execute! - try { - if (mConnection == null) { - SchemeRegistry registry = new SchemeRegistry(); - try { - registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80)); - - boolean acceptAnyCertificate = Preferences.getAcceptAnyCertificate(mContext); - registry.register(new Scheme("https", ClientHTTPConnection - .setupSSLSocketFactory(mContext, mPrivateKey, mCertificate, acceptAnyCertificate), 443)); - } - catch (Exception e) { - IOException ie = new IOException("unable to create keystore"); - ie.initCause(e); - throw ie; - } - - HttpParams params = new BasicHttpParams(); - // handle redirects :) - params.setBooleanParameter(ClientPNames.HANDLE_REDIRECTS, true); - // HttpClient bug caused by Lighttpd - params.setBooleanParameter("http.protocol.expect-continue", false); - - // create connection manager - ClientConnectionManager connMgr = new SingleClientConnManager(params, registry); - - mConnection = new DefaultHttpClient(connMgr, params); - } - return mConnection.execute(request); - } - catch (ClientProtocolException e) { - IOException ie = new IOException("client protocol error"); - ie.initCause(e); - throw ie; - } - - } - -} diff --git a/app/src/main/java/org/kontalk/upload/UploadConnection.java b/app/src/main/java/org/kontalk/upload/UploadConnection.java index 1661b9de1..a35b3c4d6 100644 --- a/app/src/main/java/org/kontalk/upload/UploadConnection.java +++ b/app/src/main/java/org/kontalk/upload/UploadConnection.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -31,9 +31,9 @@ */ public interface UploadConnection { - public void abort(); + void abort(); - public String upload(Uri uri, String mime, boolean encrypt, String to, ProgressListener listener) + String upload(Uri uri, long length, String mime, ProgressListener listener) throws IOException; } diff --git a/app/src/main/java/org/kontalk/util/AudioRecording.kt b/app/src/main/java/org/kontalk/util/AudioRecording.kt new file mode 100644 index 000000000..865d4e464 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/AudioRecording.kt @@ -0,0 +1,51 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util + +import android.media.MediaRecorder +import java.io.File + + +/** + * Audio recording utilities. + * @author Daniele Ricci + */ +class AudioRecording { + + companion object { + /** TODO change to m4a after some time (this is for backward compatibility) */ + const val MIME_TYPE = "audio/mp4" + const val FILE_EXTENSION = "mp4"; + + @JvmStatic + fun setupMediaRecorder(recorder: MediaRecorder, outputFile: File): MediaRecorder { + recorder.setAudioSource(MediaRecorder.AudioSource.MIC); + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); + recorder.setOutputFile(outputFile.absolutePath); + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); + recorder.setAudioChannels(1); + // TODO we need further study for these - using "CD quality" + recorder.setAudioSamplingRate(44100); + recorder.setAudioEncodingBitRate(192000); + return recorder; + } + + } + +} diff --git a/app/src/main/java/org/kontalk/util/ByteArrayInOutStream.java b/app/src/main/java/org/kontalk/util/ByteArrayInOutStream.java new file mode 100644 index 000000000..c969db0b0 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/ByteArrayInOutStream.java @@ -0,0 +1,65 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + + +/** + * This class extends the ByteArrayOutputStream by + * providing a method that returns a new ByteArrayInputStream + * which uses the internal byte array buffer. This buffer + * is not copied, so no additional memory is used. + *

+ * The ByteArrayInputStream can be retrieved using getInputStream(). + * @author Nick Russler https://github.com/nickrussler/ByteArrayInOutStream/ + */ +public class ByteArrayInOutStream extends ByteArrayOutputStream { + /** + * Creates a new ByteArrayInOutStream. The buffer capacity is + * initially 32 bytes, though its size increases if necessary. + */ + public ByteArrayInOutStream() { + super(); + } + + /** + * Creates a new ByteArrayInOutStream, with a buffer capacity of + * the specified size, in bytes. + * + * @param size the initial size. + * @exception IllegalArgumentException if size is negative. + */ + public ByteArrayInOutStream(int size) { + super(size); + } + + /** + * Creates a new ByteArrayInputStream that uses the internal byte array buffer + * of this ByteArrayInOutStream instance as its buffer array. The initial value + * of pos is set to zero and the initial value of count is the number of bytes + * that can be read from the byte array. The buffer array is not copied. + * @return the ByteArrayInputStream instance + */ + public ByteArrayInputStream getInputStream() { + // create new ByteArrayInputStream that respects the current count + return new ByteArrayInputStream(this.buf, 0, this.count); + } +} diff --git a/app/src/main/java/org/kontalk/util/CombinedDrawable.java b/app/src/main/java/org/kontalk/util/CombinedDrawable.java new file mode 100644 index 000000000..35c6f51b9 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/CombinedDrawable.java @@ -0,0 +1,181 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.drawable.Drawable; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +public class CombinedDrawable extends Drawable implements Drawable.Callback { + + private Drawable background; + private Drawable icon; + private int left; + private int top; + private int iconWidth; + private int iconHeight; + private int backWidth; + private int backHeight; + private boolean fullSize; + + public CombinedDrawable(Drawable backgroundDrawable, Drawable iconDrawable, int leftOffset, int topOffset) { + background = backgroundDrawable; + icon = iconDrawable; + left = leftOffset; + top = topOffset; + iconDrawable.setCallback(this); + } + + public void setIconSize(int width, int height) { + iconWidth = width; + iconHeight = height; + } + + public CombinedDrawable(Drawable backgroundDrawable, Drawable iconDrawable) { + background = backgroundDrawable; + icon = iconDrawable; + iconDrawable.setCallback(this); + } + + public void setCustomSize(int width, int height) { + backWidth = width; + backHeight = height; + } + + public Drawable getIcon() { + return icon; + } + + public Drawable getBackground() { + return background; + } + + public void setFullsize(boolean value) { + fullSize = value; + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + icon.setColorFilter(colorFilter); + } + + @Override + public boolean isStateful() { + return icon.isStateful(); + } + + @Override + public boolean setState(int[] stateSet) { + icon.setState(stateSet); + return true; + } + + @Override + public int[] getState() { + return icon.getState(); + } + + @Override + protected boolean onStateChange(int[] state) { + return true; + } + + @RequiresApi(api = Build.VERSION_CODES.HONEYCOMB) + @Override + public void jumpToCurrentState() { + icon.jumpToCurrentState(); + } + + @Override + public ConstantState getConstantState() { + return icon.getConstantState(); + } + + @Override + public void draw(Canvas canvas) { + background.setBounds(getBounds()); + background.draw(canvas); + int x; + int y; + if (fullSize) { + icon.setBounds(getBounds()); + } else { + if (iconWidth != 0) { + x = getBounds().centerX() - iconWidth / 2 + left; + y = getBounds().centerY() - iconHeight / 2 + top; + icon.setBounds(x, y, x + iconWidth, y + iconHeight); + } else { + x = getBounds().centerX() - icon.getIntrinsicWidth() / 2 + left; + y = getBounds().centerY() - icon.getIntrinsicHeight() / 2 + top; + icon.setBounds(x, y, x + icon.getIntrinsicWidth(), y + icon.getIntrinsicHeight()); + } + } + icon.draw(canvas); + } + + @Override + public void setAlpha(int alpha) { + icon.setAlpha(alpha); + background.setAlpha(alpha); + } + + @Override + public int getIntrinsicWidth() { + return backWidth != 0 ? backWidth : background.getIntrinsicWidth(); + } + + @Override + public int getIntrinsicHeight() { + return backHeight != 0 ? backHeight : background.getIntrinsicHeight(); + } + + @Override + public int getMinimumWidth() { + return backWidth != 0 ? backWidth : background.getMinimumWidth(); + } + + @Override + public int getMinimumHeight() { + return backHeight != 0 ? backHeight : background.getMinimumHeight(); + } + + @Override + public int getOpacity() { + return icon.getOpacity(); + } + + @Override + public void invalidateDrawable(@NonNull Drawable who) { + invalidateSelf(); + } + + @Override + public void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when) { + scheduleSelf(what, when); + } + + @Override + public void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what) { + unscheduleSelf(what); + } +} + diff --git a/app/src/main/java/org/kontalk/util/ConsistentColorGeneration.java b/app/src/main/java/org/kontalk/util/ConsistentColorGeneration.java new file mode 100644 index 000000000..5ea6cabc0 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/ConsistentColorGeneration.java @@ -0,0 +1,105 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import org.jivesoftware.smack.util.SHA1; + + +/** + * Implementation of XEP-0392: Consistent Color Generation
+ * TODO this class is a brutal porting from JavaScript code and should be optimized + * @see XEP-0392 + * @see Ported from JavaScript implementation + */ +public class ConsistentColorGeneration { + + public static final int CORRECTION_NONE = 0; + public static final int CORRECTION_REDGREEN = 1; + public static final int CORRECTION_BLUE = 2; + + private static final double KR = 0.299; + private static final double KG = 0.587; + private static final double KB = 0.114; + + private ConsistentColorGeneration() {} + + public static double[] getRGB(String identifier, int correction) { + return getRGB(identifier, correction, 0.732); + } + + public static double[] getRGB(String identifier, int correction, double y) { + double angle = generateAngle(identifier, correction); + double[] space = generateCbCr(angle); + + return cbCrToRGB(space[0], space[1], y); + } + + private static double[] generateCbCr(double angle) { + double cr = Math.sin(angle); + double cb = Math.cos(angle); + double factor; + + if (Math.abs(cr) > Math.abs(cb)) { + factor = 0.5d / Math.abs(cr); + } + else { + factor = 0.5d / Math.abs(cb); + } + + cb = cb * factor; + cr = cr * factor; + + return new double[] { cb, cr }; + } + + private static double generateAngle(String identifier, int correction) { + String hash = SHA1.hex(identifier); + String first16bits = hash.substring(0, 4); + String littleEndian = first16bits.substring(2) + first16bits.substring(0, 2); + double angle = (double) Integer.parseInt(littleEndian, 16) / 65535 * 2 * Math.PI; + + switch (correction) { + case CORRECTION_REDGREEN: + angle /= 2; + break; + case CORRECTION_BLUE: + angle = (angle / 2) + (Math.PI / 2); + break; + } + + return angle; + } + + private static double clipValueToRangeFrom0To1(double value) { + return Math.max(0, Math.min(value, 1)); + } + + private static double[] cbCrToRGB(double cb, double cr, double y) { + double r = 2 * (1 - KR) * cr + y; + double b = 2 * (1 - KB) * cb + y; + double g = (y - KR * r - KB * b) / KG; + + return new double[] { + clipValueToRangeFrom0To1(r), + clipValueToRangeFrom0To1(g), + clipValueToRangeFrom0To1(b), + }; + } + +} diff --git a/app/src/main/java/org/kontalk/util/CustomSimpleXmppStringprep.java b/app/src/main/java/org/kontalk/util/CustomSimpleXmppStringprep.java new file mode 100644 index 000000000..0271fb00a --- /dev/null +++ b/app/src/main/java/org/kontalk/util/CustomSimpleXmppStringprep.java @@ -0,0 +1,107 @@ +/** + * + * Copyright © 2014-2015 Florian Schmaus + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.kontalk.util; + +import org.jxmpp.stringprep.XmppStringPrepUtil; +import org.jxmpp.stringprep.XmppStringprep; +import org.jxmpp.stringprep.XmppStringprepException; + + +/** + * A hack over {@link org.jxmpp.stringprep.simple.SimpleXmppStringprep} to + * avoid altering the case of the localpart. Used for old clients that require + * the group ID of Kontalk groups to keep its case. + * @deprecated This is just a hack to be dropped as soon as possible + */ +@Deprecated +public final class CustomSimpleXmppStringprep implements XmppStringprep { + + private static CustomSimpleXmppStringprep instance; + + /** + * Setup Simple XMPP Stringprep as implementation to use. + */ + public static void setup() { + XmppStringPrepUtil.setXmppStringprep(getInstance()); + } + + /** + * Get the Simple XMPP Stringprep singleton. + * + * @return the simple XMPP Stringprep singleton. + */ + public static CustomSimpleXmppStringprep getInstance() { + if (instance == null) { + instance = new CustomSimpleXmppStringprep(); + } + return instance; + } + + private CustomSimpleXmppStringprep() { + } + + /** + * From 6122bis-18 § 3.3.1 and PRECIS IdentifierClass which forbids U+0020 + */ + // @formatter:off + private static final char[] LOCALPART_FURTHER_EXCLUDED_CHARACTERS = new char[] { + '"', // U+0022 (QUOTATION MARK) , i.e., " + '&', // U+0026 (AMPERSAND), i.e., & + '\'', // U+0027 (APOSTROPHE), i.e., ' + '/', // U+002F (SOLIDUS), i.e., / + ',', // U+003A (COLON), i.e., : + '<', // U+003C (LESS-THAN SIGN), i.e., < + '>', // U+003E (GREATER-THAN SIGN), i.e., > + '@', // U+0040 (COMMERCIAL AT), i.e., @ + ' ', // U+0020 (SPACE) + }; + // @formatter:on + + @Override + public String localprep(String string) throws XmppStringprepException { + string = simpleStringprep(string); + for (char charFromString : string.toCharArray()) { + for (char forbiddenChar : LOCALPART_FURTHER_EXCLUDED_CHARACTERS) { + if (charFromString == forbiddenChar) { + throw new XmppStringprepException(string, "Localpart must not contain '" + forbiddenChar + "'"); + } + } + } + return string; + } + + @Override + public String domainprep(String string) throws XmppStringprepException { + return simpleStringprep(string); + } + + @Override + public String resourceprep(String string) throws XmppStringprepException { + // rfc6122-bis specifies that resourceprep uses saslprep-bis OpaqueString Profile which says that + // "Uppercase and titlecase characters MUST NOT be mapped to their lowercase equivalents." + + // TODO apply Unicode Normalization Form C (NFC) with help of java.text.Normalize + // but unfortunately this is API is only available on Android API 9 or higher and Smack is currently API 8 + return string; + } + + private static String simpleStringprep(String string) { + // once upon a time: string.toLowerCase(Locale.US); + return string; + } +} diff --git a/app/src/main/java/org/kontalk/util/DataUtils.java b/app/src/main/java/org/kontalk/util/DataUtils.java new file mode 100644 index 000000000..3540a2af1 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/DataUtils.java @@ -0,0 +1,157 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.StringReader; +import java.io.StringWriter; +import java.lang.reflect.Array; +import java.util.Properties; + +import android.content.res.AssetFileDescriptor; +import android.util.SparseBooleanArray; + + +/** + * Put all utility methods related to data manipulation here. + */ +public class DataUtils { + + /** + * Provides clone functionality for the {@link SparseBooleanArray}. + * See https://code.google.com/p/android/issues/detail?id=39242 + */ + public static SparseBooleanArray cloneSparseBooleanArray(SparseBooleanArray array) { + final SparseBooleanArray clone = new SparseBooleanArray(); + + synchronized (array) { + final int size = array.size(); + for (int i = 0; i < size; i++) { + int key = array.keyAt(i); + clone.put(key, array.get(key)); + } + } + + return clone; + } + + public static T[] concatenate (T[] a, T[] b) { + int aLen = a.length; + int bLen = b.length; + + @SuppressWarnings("unchecked") + T[] c = (T[]) Array.newInstance(a.getClass().getComponentType(), aLen+bLen); + System.arraycopy(a, 0, c, 0, aLen); + System.arraycopy(b, 0, c, aLen, bLen); + + return c; + } + + public static T[] concatenate (T[] a, T b) { + int aLen = a.length; + + @SuppressWarnings("unchecked") + T[] c = (T[]) Array.newInstance(a.getClass().getComponentType(), aLen + 1); + System.arraycopy(a, 0, c, 0, aLen); + c[aLen] = b; + + return c; + } + + public static boolean contains(final T[] array, final T v) { + for (final T e : array) + if (e == v || v != null && v.equals(e)) + return true; + + return false; + } + + /** Mainly for converting arrays of Jids to arrays of String :) */ + public static String[] toString(final T[] array) { + String[] out = new String[array.length]; + for (int i = 0; i < array.length; i++) { + out[i] = array[i] != null ? array[i].toString() : null; + } + return out; + } + + /** Instead of importing the whole commons-io :) */ + public static long copy(final InputStream input, final OutputStream output) throws IOException { + byte[] buffer = new byte[4096]; + long count = 0; + int n; + while (-1 != (n = input.read(buffer))) { + output.write(buffer, 0, n); + count += n; + } + return count; + } + + /** Closes the given closeable object, ignoring any errors. */ + public static void close(Closeable object) { + if (object != null) { + try { + object.close(); + } + catch (Exception ignored) { + } + } + } + + public static String serializeProperties(Properties properties) { + try { + StringWriter writer = new StringWriter(); + properties.store(writer, null); + return writer.toString(); + } + catch (IOException e) { + throw new AssertionError("this can't happen"); + } + } + + public static Properties unserializeProperties(String data) { + try { + StringReader reader = new StringReader(data); + Properties properties = new Properties(); + properties.load(reader); + return properties; + } + catch (IOException e) { + throw new AssertionError("this can't happen"); + } + } + + /** + * Closes the given stream, ignoring any errors. + * This method can be safely deleted once we'll have min SDK set to 19, + * because AssetFileDescriptor will implement Closeable. + */ + public static void closeStream(AssetFileDescriptor stream) { + if (stream != null) { + try { + stream.close(); + } + catch (Exception ignored) { + } + } + } +} diff --git a/app/src/main/java/org/kontalk/util/InternalTrustStore.java b/app/src/main/java/org/kontalk/util/InternalTrustStore.java index 3627c0e67..ec80f3125 100644 --- a/app/src/main/java/org/kontalk/util/InternalTrustStore.java +++ b/app/src/main/java/org/kontalk/util/InternalTrustStore.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,23 +18,25 @@ package org.kontalk.util; -import java.io.File; -import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.util.Enumeration; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; + import org.kontalk.R; import org.kontalk.crypto.PGP; import android.content.Context; -import android.os.Build; /** Some trust store utilities. */ @@ -42,13 +44,33 @@ public class InternalTrustStore { private static KeyStore sTrustStore; + private static boolean sInitialized; + + /** Sets all {@link HttpsURLConnection}s to use our trust store. */ + public static void initUrlConnections(Context context) + throws NoSuchAlgorithmException, CertificateException, + KeyStoreException, IOException, + KeyManagementException { + if (!sInitialized) { + sInitialized = true; + TrustManagerFactory tmFactory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmFactory.init(getTrustStore(context)); + TrustManager[] tm = tmFactory.getTrustManagers(); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, tm, null); + + HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory()); + } + } + /** * Returns a trust store merged from the internal keystore and system * keystore. */ public static KeyStore getTrustStore(Context context) throws KeyStoreException, - NoSuchProviderException, NoSuchAlgorithmException, CertificateException, IOException { @@ -86,23 +108,8 @@ private static KeyStore loadSystemTrustStore() CertificateException, IOException { - KeyStore ks; - - if (Build.VERSION.SDK_INT >= 14) { - ks = KeyStore.getInstance("AndroidCAStore"); - ks.load(null, null); - } - - else { - ks = KeyStore.getInstance("BKS"); - String path = System.getProperty("javax.net.ssl.trustStore"); - if (path == null) - path = System.getProperty("java.home") + File.separator + "etc" - + File.separator + "security" + File.separator - + "cacerts.bks"; - ks.load(new FileInputStream(path), null); - } - + KeyStore ks = KeyStore.getInstance("AndroidCAStore"); + ks.load(null, null); return ks; } } diff --git a/app/src/main/java/org/kontalk/util/MediaStorage.java b/app/src/main/java/org/kontalk/util/MediaStorage.java index b25327062..c48398a15 100644 --- a/app/src/main/java/org/kontalk/util/MediaStorage.java +++ b/app/src/main/java/org/kontalk/util/MediaStorage.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,10 +19,12 @@ package org.kontalk.util; 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.net.URLConnection; import java.text.DateFormat; import java.text.SimpleDateFormat; @@ -31,22 +33,41 @@ import android.annotation.TargetApi; import android.content.ContentResolver; +import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.res.AssetFileDescriptor; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Color; import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Shader; +import android.media.AudioManager; +import android.media.MediaPlayer; import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.provider.MediaStore; -import android.util.Log; + +import androidx.annotation.CheckResult; +import androidx.annotation.NonNull; +import androidx.annotation.RawRes; +import androidx.exifinterface.media.ExifInterface; +import androidx.fragment.app.Fragment; +import androidx.core.content.FileProvider; import android.webkit.MimeTypeMap; +import org.kontalk.BuildConfig; import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.R; +import org.kontalk.reporting.ReportingManager; /** @@ -56,41 +77,139 @@ public abstract class MediaStorage { private static final String TAG = Kontalk.TAG; - public static final File MEDIA_ROOT = new File(Environment.getExternalStorageDirectory(), "Kontalk"); + public enum MediaStoreType { + PHOTO, + IMAGE, + AUDIO, + RECORDING, + VIDEO, + DOCUMENT, + OTHER, + } + + public static final String FILE_AUTHORITY = BuildConfig.APPLICATION_ID + ".fileprovider"; public static final String UNKNOWN_FILENAME = "unknown_file.bin"; - private static final File DCIM_ROOT = new File(Environment - .getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), - "Kontalk"); - private static final File PICTURES_ROOT = new File(Environment - .getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), - "Kontalk"); - private static final File AUDIO_ROOT = new File(Environment - .getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), - "Kontalk"); + private static final File AUDIO_PUBLIC_PATH = Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC); + + private static final String RECORDINGS_ROOT = "Recordings"; + private static final String RECORDINGS_SENT_ROOT = new File(RECORDINGS_ROOT, "Sent").toString(); + private static final String RECORDINGS_PUBLIC_RELATIVE_PATH = + new File(Environment.DIRECTORY_MUSIC, RECORDINGS_ROOT).toString(); + private static final File RECORDINGS_PUBLIC_PATH = new File(Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), RECORDINGS_ROOT); + + private static final String PICTURES_ROOT = Environment.DIRECTORY_PICTURES; + private static final String PICTURES_SENT_ROOT = new File(PICTURES_ROOT, "Sent").toString(); + private static final File PICTURES_PUBLIC_PATH = Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); + + private static final String PHOTOS_ROOT = Environment.DIRECTORY_DCIM; + private static final String PHOTOS_PUBLIC_RELATIVE_PATH = new File + (Environment.DIRECTORY_DCIM).toString(); + private static final File PHOTOS_PUBLIC_PATH = Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM); + + private static final File VIDEO_PUBLIC_PATH = Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); + + private static final String DOCUMENTS_ROOT; + private static final String DOCUMENTS_PUBLIC_RELATIVE_PATH; + private static final File DOCUMENTS_PUBLIC_PATH; + + static { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + DOCUMENTS_ROOT = Environment.DIRECTORY_DOCUMENTS; + DOCUMENTS_PUBLIC_RELATIVE_PATH = Environment.DIRECTORY_DOCUMENTS; + DOCUMENTS_PUBLIC_PATH = Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS); + } + else { + DOCUMENTS_ROOT = "Documents"; + DOCUMENTS_PUBLIC_RELATIVE_PATH = "Documents"; + DOCUMENTS_PUBLIC_PATH = Environment + .getExternalStoragePublicDirectory("Documents"); + } + } + + private static final String DOWNLOADS_ROOT = Environment.DIRECTORY_DOWNLOADS; + private static final String DOWNLOADS_PUBLIC_RELATIVE_PATH = Environment.DIRECTORY_DOWNLOADS; + private static final File DOWNLOADS_PUBLIC_PATH = Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); private static final DateFormat sDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US); - private static final int THUMBNAIL_WIDTH = 256; - private static final int THUMBNAIL_HEIGHT = 256; + private static final int THUMBNAIL_WIDTH = 512; + private static final int THUMBNAIL_HEIGHT = 512; public static final String THUMBNAIL_MIME = "image/png"; public static final String THUMBNAIL_MIME_NETWORK = "image/jpeg"; - public static final int THUMBNAIL_MIME_COMPRESSION = 60; + public static final int THUMBNAIL_MIME_COMPRESSION = 50; public static final String COMPRESS_MIME = "image/jpeg"; - private static final String COMPRESS_FILENAME_FORMAT = "compress_%d.jpg"; private static final int COMPRESSION_QUALITY = 85; + public static final int OUTGOING_MESSAGE_SOUND = R.raw.sound_outgoing; + // TODO + public static final int INCOMING_MESSAGE_SOUND = 0; + + /** Media player used by {@link #playNotificationSound}. */ + private static QuickMediaPlayer mMediaPlayer; + public static boolean isExternalStorageAvailable() { return Environment.getExternalStorageState() .equals(Environment.MEDIA_MOUNTED); } + public static File getInternalMediaFile(Context context, String filename) { + return new File(context.getCacheDir(), filename); + } + + @Deprecated + private static boolean isFileUriAllowed() { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.N; + } + + /** + * Returns a world-readable Uri if the given Uri is file-based. + * @param context any context, used for {@link FileProvider#getUriForFile} + * @param uri Uri to be converted + * @param intent if not null, appropriate flags will be added + * @return the converted Uri, or the original one if not needed to be converted + */ + public static Uri getWorldReadableUri(Context context, Uri uri, Intent intent) { + return getWorldAccessibleUri(context, uri, intent, + Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + + /** + * Returns a world-writable Uri if the given Uri is file-based. + * @param context any context, used for {@link FileProvider#getUriForFile} + * @param uri Uri to be converted + * @param intent if not null, appropriate flags will be added + * @return the converted Uri, or the original one if not needed to be converted + */ + public static Uri getWorldWritableUri(Context context, Uri uri, Intent intent) { + return getWorldAccessibleUri(context, uri, intent, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + + private static Uri getWorldAccessibleUri(Context context, Uri uri, Intent intent, int flags) { + if ("file".equals(uri.getScheme())) { + uri = FileProvider.getUriForFile(context, MediaStorage.FILE_AUTHORITY, + new File(uri.getPath())); + if (intent != null) { + intent.addFlags(flags); + } + } + return uri; + } + /** Writes a media to the internal cache. */ public static File writeInternalMedia(Context context, String filename, byte[] contents) throws IOException { - File file = new File(context.getCacheDir(), filename); + File file = getInternalMediaFile(context, filename); FileOutputStream fout = new FileOutputStream(file); fout.write(contents); fout.close(); @@ -116,11 +235,26 @@ else if (h > scaleHeight) /** Generates {@link BitmapFactory.Options} for the given {@link InputStream}. */ public static BitmapFactory.Options preloadBitmap(InputStream in, int scaleWidth, int scaleHeight) { + BitmapFactory.Options options = bitmapOptionsDecodeBounds(); + BitmapFactory.decodeStream(in, null, options); + return processOptions(options, scaleWidth, scaleHeight); + } + + private static BitmapFactory.Options bitmapOptionsDecodeBounds() { BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; - BitmapFactory.decodeStream(in, null, options); + return options; + } - return processOptions(options, scaleWidth, scaleHeight); + private static BitmapFactory.Options bitmapOptions() { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Bitmap.Config.RGB_565; + return options; + } + + public static Bitmap loadBitmapSimple(InputStream in) { + BitmapFactory.Options options = bitmapOptions(); + return BitmapFactory.decodeStream(in, null, options); } /** Writes a thumbnail of a media to the internal cache. */ @@ -133,54 +267,98 @@ public static File cacheThumbnail(Context context, Uri media, String filename, b /** Writes a thumbnail of a media to a {@link File}. */ public static void cacheThumbnail(Context context, Uri media, File destination, boolean forNetwork) throws IOException { FileOutputStream fout = new FileOutputStream(destination); - cacheThumbnail(context, media, fout, forNetwork); - fout.close(); + try { + cacheThumbnail(context, media, fout, forNetwork); + } + finally { + fout.close(); + } } private static void cacheThumbnail(Context context, Uri media, FileOutputStream fout, boolean forNetwork) throws IOException { - ContentResolver cr = context.getContentResolver(); - InputStream in = cr.openInputStream(media); - BitmapFactory.Options options = preloadBitmap(in, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); - in.close(); - - // open again - in = cr.openInputStream(media); - Bitmap bitmap = BitmapFactory.decodeStream(in, null, options); - in.close(); - - Bitmap thumbnail = ThumbnailUtils - .extractThumbnail(bitmap, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); - bitmap.recycle(); + resizeImage(context, media, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, + forNetwork ? Bitmap.CompressFormat.JPEG : Bitmap.CompressFormat.PNG, + forNetwork ? THUMBNAIL_MIME_COMPRESSION : 0, + fout); + } - thumbnail = bitmapOrientation(context, media, thumbnail); + /** + * Tries various methods for obtaining the rotation of the image. + * @return a matrix to rotate the image (if any) + */ + private static Matrix getRotation(Context context, Uri media) throws IOException { + // method 1: query the media storage + Cursor cursor = context.getContentResolver().query(media, + new String[] { MediaStore.Images.ImageColumns.ORIENTATION }, null, null, null); - // write down to file - thumbnail.compress(forNetwork ? Bitmap.CompressFormat.JPEG : - Bitmap.CompressFormat.PNG, - forNetwork ? THUMBNAIL_MIME_COMPRESSION : 0, fout); - thumbnail.recycle(); - } + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + int orientation = cursor.getInt(0); - public static Bitmap bitmapOrientation(Context context, Uri media, Bitmap bitmap) { - // check if we have to (and can) rotate the thumbnail - try { - Cursor cursor = context.getContentResolver().query(media, - new String[] { MediaStore.Images.ImageColumns.ORIENTATION }, null, null, null); + if (orientation != 0) { + Matrix m = new Matrix(); + m.postRotate(orientation); - if (cursor != null) { - cursor.moveToFirst(); - int orientation = cursor.getInt(0); + return m; + } + } + } + catch (Exception e) { + ReportingManager.logException(e); + // we'll try the next method + } + finally { cursor.close(); + } + } - if (orientation != 0) { - Matrix m = new Matrix(); - m.postRotate(orientation); + // method 2: write media contents to a temporary file and run ExifInterface + InputStream in = context.getContentResolver().openInputStream(media); + OutputStream out = null; + File tmp = null; + try { + tmp = File.createTempFile("rotation", null, context.getCacheDir()); + out = new FileOutputStream(tmp); + + DataUtils.copy(in, out); + // flush the file + out.close(); + + ExifInterface exif = new ExifInterface(tmp.toString()); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 1); + Matrix matrix = new Matrix(); + switch (orientation) { + case ExifInterface.ORIENTATION_ROTATE_90: + matrix.postRotate(90); + break; + case ExifInterface.ORIENTATION_ROTATE_180: + matrix.postRotate(180); + break; + case ExifInterface.ORIENTATION_ROTATE_270: + matrix.postRotate(270); + break; + default: + return null; + } - Bitmap rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), m, true); - bitmap.recycle(); - bitmap = rotated; + return matrix; + } + finally { + if (tmp != null) + tmp.delete(); + DataUtils.close(in); + DataUtils.close(out); + } + } - } + /** Apply a rotation matrix respecting the image orientation. */ + static Bitmap bitmapOrientation(Context context, Uri media, Bitmap bitmap) { + // check if we have to (and can) rotate the thumbnail + try { + Matrix m = getRotation(context, media); + if (m != null) { + bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), m, true); } } catch (Exception e) { @@ -190,100 +368,163 @@ public static Bitmap bitmapOrientation(Context context, Uri media, Bitmap bitmap return bitmap; } - public static File writeMedia(String filename, InputStream source) throws IOException { - MEDIA_ROOT.mkdirs(); - File f = new File(MEDIA_ROOT, filename); - FileOutputStream fout = new FileOutputStream(f); - byte[] buffer = new byte[1024]; - int len; - while ((len = source.read(buffer)) != -1) - fout.write(buffer, 0, len); - fout.close(); - return f; - } - - public static File writeMedia(String filename, byte[] contents) throws IOException { - MEDIA_ROOT.mkdirs(); - File f = new File(MEDIA_ROOT, filename); - FileOutputStream fout = new FileOutputStream(f); - fout.write(contents); - fout.close(); - return f; - } - public static long getLength(Context context, Uri media) throws IOException { AssetFileDescriptor stat = null; + long length = 0; try { stat = context.getContentResolver().openAssetFileDescriptor(media, "r"); - return stat.getLength(); + if (stat != null) + length = stat.getLength(); } finally { try { - stat.close(); + if (stat != null) + stat.close(); } - catch (Exception e) { + catch (IOException e) { // ignored } } + + if (length == 0) { + // try to count bytes by reading it + InputStream in = null; + try { + in = context.getContentResolver().openInputStream(media); + CountingInputStream counter = new CountingInputStream(in); + counter.consume(); + length = counter.getByteCount(); + } + finally { + try { + if (in != null) + in.close(); + } + catch (IOException e) { + // ignored + } + } + } + + return length; + } + + private static final class CountingInputStream extends InputStream { + private final InputStream mInputStream; + private long mBytes; + + public CountingInputStream(InputStream in) { + mInputStream = in; + } + + @Override + public int available() throws IOException { + return mInputStream.available(); + } + + @Override + public void close() throws IOException { + mInputStream.close(); + } + + @Override + public int read() throws IOException { + int data = mInputStream.read(); + if (data >= 0) + mBytes++; + return data; + } + + public long getByteCount() { + return mBytes; + } + + public void consume() throws IOException { + while (read() >= 0); + } + } + + /** Creates a temporary JPEG file for a photo (DCIM). */ + public static File getOutgoingPhotoFile(Context context) throws IOException { + return getOutgoingPhotoFile(context, new Date()); } - /** Creates a temporary JPEG file. */ - public static File getOutgoingImageFile() throws IOException { - return getOutgoingImageFile(new Date()); + private static File getOutgoingPhotoFile(Context context, Date date) throws IOException { + File path = new File(context.getCacheDir(), PHOTOS_ROOT); + createDirectories(path); + return createImageFile(path, date); } - private static File getOutgoingImageFile(Date date) throws IOException { - createMedia(DCIM_ROOT); + /** Creates a temporary JPEG file for a picture (Pictures). */ + public static File getOutgoingPictureFile(Context context) throws IOException { + return getOutgoingPictureFile(context, new Date()); + } + + private static File getOutgoingPictureFile(Context context, Date date) throws IOException { + File path = new File(context.getFilesDir(), PICTURES_SENT_ROOT); + createDirectories(path); + return createImageFile(path, date); + } + + private static File createImageFile(File path, Date date) throws IOException { + createDirectories(path); String timeStamp = sDateFormat.format(date); - File f = new File(DCIM_ROOT, "IMG_" + timeStamp + ".jpg"); + File f = new File(path, "IMG_" + timeStamp + ".jpg"); f.createNewFile(); return f; } + public static String getOutgoingPictureFilename(Date date, String extension) { + String timeStamp = sDateFormat.format(date); + return "IMG_" + timeStamp + "." + extension; + } + /** Creates a file object for an incoming image file. */ - public static File getIncomingImageFile(Date date, String extension) { - createNoMedia(PICTURES_ROOT); + public static File getIncomingImageFile(Context context, Date date, String extension) { + File path = new File(context.getFilesDir(), PICTURES_ROOT); + createDirectories(path); String timeStamp = sDateFormat.format(date); - return new File(PICTURES_ROOT, "IMG_" + timeStamp + "." + extension); + return new File(path, "IMG_" + timeStamp + "." + extension); } - /** Creates a temporary 3gp file. */ - public static File getOutgoingAudioFile() throws IOException { - return getOutgoingAudioFile(new Date()); + /** Creates a temporary audio file. */ + public static File getOutgoingAudioFile(Context context) throws IOException { + return getOutgoingAudioFile(context, new Date()); } - private static File getOutgoingAudioFile(Date date) throws IOException { - createNoMedia(AUDIO_ROOT); + private static File getOutgoingAudioFile(Context context, Date date) throws IOException { + File path = new File(context.getFilesDir(), RECORDINGS_SENT_ROOT); + createDirectories(path); String timeStamp = sDateFormat.format(date); - File f = new File(AUDIO_ROOT, "record_" + timeStamp + ".3gp"); + File f = new File(path, "record_" + timeStamp + "." + AudioRecording.FILE_EXTENSION); f.createNewFile(); return f; } + public static String getOutgoingAudioFilename(Date date, String extension) { + String timeStamp = sDateFormat.format(date); + return "audio_" + timeStamp + "." + extension; + } + /** Creates a file object for an incoming audio file. */ - public static File getIncomingAudioFile(Date date, String extension) { - createNoMedia(AUDIO_ROOT); + public static File getIncomingAudioFile(Context context, Date date, String extension) { + File path = new File(context.getFilesDir(), RECORDINGS_ROOT); + createDirectories(path); String timeStamp = sDateFormat.format(date); - return new File(AUDIO_ROOT, "audio_" + timeStamp + "." + extension); + return new File(path, "audio_" + timeStamp + "." + extension); } - /** Ensures that the given path exists. */ - private static boolean createMedia(File path) { - return path.isDirectory() || path.mkdirs(); + public static File getIncomingFile(Context context, Date date, String extension) { + File path = new File(context.getFilesDir(), DOWNLOADS_ROOT); + createDirectories(path); + String timeStamp = sDateFormat.format(date); + return new File(path, "file_" + timeStamp + "." + extension); } - /** Ensures that the given path exists and a .nomedia file exists. */ - private static boolean createNoMedia(File path) { - try { - if (path.isDirectory() || path.mkdirs()) { - File nomedia = new File(path, ".nomedia"); - return nomedia.isFile() || nomedia.createNewFile(); - } - return false; - } - catch (Exception e) { - return false; - } + /** Ensures that the given path exists. */ + @CheckResult + private static boolean createDirectories(File path) { + return path.isDirectory() || path.mkdirs(); } /** Guesses the MIME type of an {@link Uri}. */ @@ -307,13 +548,197 @@ public static String getType(Context context, Uri uri) { return mime; } - public static File resizeImage(Context context, Uri uri, long msgId, int maxSize) - throws FileNotFoundException { - return resizeImage(context, uri, msgId, maxSize, maxSize, COMPRESSION_QUALITY); + /** Guesses the MIME type of an URL. */ + public static String getType(String url) { + String mime; + + // the following methods actually use the same underlying implementation + // (libcore.net.MimeUtils), but that could change in the future so no + // hurt in trying them all just in case. + // Lowercasing the filename seems to help in detecting the correct MIME. + + // try WebKit detection + mime = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(MimeTypeMap + .getFileExtensionFromUrl(url).toLowerCase()); + if (mime == null) + // try Java detection + mime = URLConnection.guessContentTypeFromName(url.toLowerCase()); + return mime; + } + + /** + * Publishes some media to the {@link MediaStore}. + */ + public static Uri publishMedia(Context context, File file, @NonNull MediaStoreType type) throws IOException { + if (!isExternalStorageAvailable()) { + throw new IOException("external storage not available."); + } + + if (SystemUtils.supportsScopedStorage()) { + // publish to the media store + ContentResolver resolver = context.getContentResolver(); + + Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + ContentValues values = new ContentValues(); + + values.put(MediaStore.MediaColumns.DISPLAY_NAME, file.getName()); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + + switch (type) { + case PHOTO: + values.put(MediaStore.Images.Media.RELATIVE_PATH, PHOTOS_PUBLIC_RELATIVE_PATH); + // a photo is also an image + case IMAGE: + collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + break; + case RECORDING: + values.put(MediaStore.Files.FileColumns.RELATIVE_PATH, RECORDINGS_PUBLIC_RELATIVE_PATH); + // a recording is also an audio + case AUDIO: + collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + break; + case VIDEO: + collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + break; + case DOCUMENT: + // TODO is this supported? + collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + values.put(MediaStore.Files.FileColumns.RELATIVE_PATH, DOCUMENTS_PUBLIC_RELATIVE_PATH); + } + break; + case OTHER: + // TODO is this supported? + collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + values.put(MediaStore.Files.FileColumns.RELATIVE_PATH, DOWNLOADS_PUBLIC_RELATIVE_PATH); + break; + } + // TODO other attributes maybe? + + Uri mediaFile = resolver.insert(collection, values); + if (mediaFile == null) { + throw new FileNotFoundException("Unable to create media"); + } + + OutputStream mediaOut = null; + InputStream mediaIn = null; + try { + mediaOut = resolver.openOutputStream(mediaFile); + if (mediaOut == null) { + throw new FileNotFoundException("Unable to create media"); + } + + mediaIn = new FileInputStream(file); + DataUtils.copy(mediaIn, mediaOut); + + values.clear(); + values.put(MediaStore.MediaColumns.IS_PENDING, 0); + context.getContentResolver().update(mediaFile, values, null, null); + return mediaFile; + } + catch (RuntimeException e) { + // something went wrong, delete dangling media + try { + resolver.delete(mediaFile, null, null); + } + catch (Exception ignored) { + } + throw e; + } + finally { + DataUtils.close(mediaIn); + DataUtils.close(mediaOut); + } + } + else { + // copy file to external storage + // requires external storage write permission + + File path; + switch (type) { + case PHOTO: + path = PHOTOS_PUBLIC_PATH; + break; + case IMAGE: + path = PICTURES_PUBLIC_PATH; + break; + case AUDIO: + path = AUDIO_PUBLIC_PATH; + break; + case RECORDING: + path = RECORDINGS_PUBLIC_PATH; + break; + case VIDEO: + path = VIDEO_PUBLIC_PATH; + break; + case DOCUMENT: + path = DOCUMENTS_PUBLIC_PATH; + break; + case OTHER: + path = DOWNLOADS_PUBLIC_PATH; + break; + default: + throw new IllegalArgumentException("invalid media type"); + } + + if (!createDirectories(path)) { + throw new IOException("unable to write to external storage."); + } + + File publicFile = new File(path, file.getName()); + InputStream in = new FileInputStream(file); + OutputStream out = new FileOutputStream(publicFile); + try { + DataUtils.copy(in, out); + } + catch (IOException e) { + // try to delete the public file + publicFile.delete(); + } + finally { + DataUtils.close(in); + DataUtils.close(out); + } + + // notify media scanner + // TODO actually just notify MediaStore about the new file + Uri uri = Uri.fromFile(publicFile); + Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + mediaScanIntent.setData(uri); + context.sendBroadcast(mediaScanIntent); + return uri; + } + } + + public static File resizeImage(Context context, Uri uri, int maxSize) throws IOException { + return resizeImage(context, uri, maxSize, maxSize, COMPRESSION_QUALITY); + } + + public static File resizeImage(Context context, Uri uri, int maxWidth, int maxHeight, int quality) + throws IOException { + + FileOutputStream stream = null; + try { + final File file = getOutgoingPictureFile(context); + stream = new FileOutputStream(file); + resizeImage(context, uri, maxWidth, maxHeight, + Bitmap.CompressFormat.JPEG, quality, stream); + return file; + } + finally { + try { + stream.close(); + } + catch (Exception e) { + // ignored + } + } } - public static File resizeImage(Context context, Uri uri, long msgId, int maxWidth, int maxHeight, int quality) - throws FileNotFoundException { + public static void resizeImage(Context context, Uri uri, int maxWidth, int maxHeight, + Bitmap.CompressFormat format, int quality, FileOutputStream output) + throws IOException { final int MAX_IMAGE_SIZE = 1200000; // 1.2MP @@ -325,8 +750,7 @@ public static File resizeImage(Context context, Uri uri, long msgId, int maxWidt try { // decode image size - BitmapFactory.Options o = new BitmapFactory.Options(); - o.inJustDecodeBounds = true; + BitmapFactory.Options o = bitmapOptionsDecodeBounds(); BitmapFactory.decodeStream(in, null, o); in.close(); @@ -349,7 +773,7 @@ public static File resizeImage(Context context, Uri uri, long msgId, int maxWidt } // open image again for the actual scaling - Bitmap bitmap = null; + Bitmap bitmap; try { in = cr.openInputStream(uri); @@ -371,44 +795,69 @@ public static File resizeImage(Context context, Uri uri, long msgId, int maxWidt } if (bitmap == null) { - return null; + return; } float photoW = bitmap.getWidth(); float photoH = bitmap.getHeight(); if (photoW == 0 || photoH == 0) { - return null; + return; } float scaleFactor = Math.max(photoW / maxWidth, photoH / maxHeight); int w = (int) (photoW / scaleFactor); int h = (int) (photoH / scaleFactor); if (h == 0 || w == 0) { - return null; + return; } - Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, w, h, true); + Bitmap scaledBitmap = null; + try { + scaledBitmap = Bitmap.createScaledBitmap(bitmap, w, h, true); + } + finally { + if (scaledBitmap != bitmap) + bitmap.recycle(); + } // check for rotation data - scaledBitmap = bitmapOrientation(context, uri, scaledBitmap); - - String filename = String.format(COMPRESS_FILENAME_FORMAT, msgId); - final File compressedFile = new File(context.getCacheDir(), filename); - - FileOutputStream stream = null; + Bitmap rotatedScaledBitmap = bitmapOrientation(context, uri, scaledBitmap); + if (rotatedScaledBitmap != scaledBitmap) + scaledBitmap.recycle(); try { - stream = new FileOutputStream(compressedFile); - scaledBitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream); + rotatedScaledBitmap.compress(format, quality, output); + } + finally { + rotatedScaledBitmap.recycle(); + } + } - return compressedFile; + public static File copyOutgoingMedia(Context context, Uri media) throws IOException { + final File outFile = getOutgoingPictureFile(context); + InputStream in = context.getContentResolver().openInputStream(media); + OutputStream out = null; + try { + out = new FileOutputStream(outFile); + DataUtils.copy(in, out); + return outFile; } finally { - try { - stream.close(); - } - catch (Exception e) { - // ignored - } + DataUtils.close(in); + DataUtils.close(out); } + + } + + public static Bitmap createRoundBitmap(@NonNull Bitmap source) { + Bitmap result = Bitmap.createBitmap(source.getWidth(), source.getHeight(), Bitmap.Config.ARGB_8888); + result.eraseColor(Color.TRANSPARENT); + Canvas canvas = new Canvas(result); + BitmapShader shader = new BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP); + Paint roundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + RectF bitmapRect = new RectF(); + roundPaint.setShader(shader); + bitmapRect.set(0, 0, source.getWidth(), source.getHeight()); + canvas.drawRoundRect(bitmapRect, source.getWidth(), source.getHeight(), roundPaint); + return result; } /** @@ -420,11 +869,9 @@ public static boolean isStorageAccessFrameworkAvailable() { } @TargetApi(Build.VERSION_CODES.KITKAT) - public static void requestPersistablePermissions(Context context, Intent intent) { - final int takeFlags = intent.getFlags() - & (Intent.FLAG_GRANT_READ_URI_PERMISSION); - final Uri uri = intent.getData(); - context.getContentResolver().takePersistableUriPermission(uri, takeFlags); + public static void requestPersistablePermissions(Context context, Uri uri) { + context.getContentResolver().takePersistableUriPermission(uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION); } public static void scanFile(Context context, File file, String mime) { @@ -433,4 +880,88 @@ public static void scanFile(Context context, File file, String mime) { new String[] { mime }, null); } + @TargetApi(Build.VERSION_CODES.KITKAT) + public static void createFile(Fragment fragment, String mimeType, String fileName, int requestCode) { + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + + // Filter to only show results that can be "opened", such as + // a file (as opposed to a list of contacts or timezones). + intent.addCategory(Intent.CATEGORY_OPENABLE); + + // Create a file with the requested MIME type. + intent.setType(mimeType); + // Note: This is not documented, but works + intent.putExtra("android.content.extra.SHOW_ADVANCED", true); + intent.putExtra(Intent.EXTRA_TITLE, fileName); + fragment.startActivityForResult(intent, requestCode); + } + + public static synchronized void playNotificationSound(Context context, @RawRes int resId) { + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (!audioManager.isMusicActive() && audioManager.getRingerMode() == AudioManager.RINGER_MODE_NORMAL) { + if (mMediaPlayer == null) + mMediaPlayer = new QuickMediaPlayer(); + + mMediaPlayer.play(context, resId); + } + } + + private static final class QuickMediaPlayer { + private MediaPlayer mMediaPlayer; + @RawRes + private int mResId; + + public void play(Context context, @RawRes int resId) { + if (mMediaPlayer != null && mResId == resId) { + // same file, just play again + mMediaPlayer.stop(); + try { + mMediaPlayer.prepare(); + play(); + return; + } + catch (IOException e) { + // will simply re-create the media player + } + } + + if (mMediaPlayer != null) { + mMediaPlayer.release(); + } + + mMediaPlayer = create(context, resId); + if (mMediaPlayer != null) { + mResId = resId; + play(); + } + } + + private MediaPlayer create(Context context, int resId) { + MediaPlayer player = new MediaPlayer(); + AssetFileDescriptor afd = null; + try { + player.setAudioStreamType(AudioManager.STREAM_NOTIFICATION); + + afd = context.getResources().openRawResourceFd(resId); + if (afd != null) { + player.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); + player.prepare(); + return player; + } + } + catch (Exception e) { + player.release(); + } + finally { + DataUtils.closeStream(afd); + } + + return null; + } + + private void play() { + mMediaPlayer.start(); + } + } + } diff --git a/app/src/main/java/org/kontalk/util/MessageUtils.java b/app/src/main/java/org/kontalk/util/MessageUtils.java index a594e4d53..ffae7c286 100644 --- a/app/src/main/java/org/kontalk/util/MessageUtils.java +++ b/app/src/main/java/org/kontalk/util/MessageUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,60 +18,113 @@ package org.kontalk.util; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +import org.jivesoftware.smack.util.StringUtils; +import org.bouncycastle.openpgp.PGPException; + import android.content.ContentValues; import android.content.Context; import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import androidx.annotation.NonNull; +import android.text.Editable; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.format.DateUtils; import android.text.format.Time; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; -import android.util.Log; - -import org.jivesoftware.smack.packet.ExtensionElement; -import org.jivesoftware.smack.packet.Message; -import org.jivesoftware.smack.util.StringUtils; +import org.kontalk.BuildConfig; import org.kontalk.Kontalk; import org.kontalk.R; -import org.kontalk.client.BitsOfBinary; import org.kontalk.client.EndpointServer; -import org.kontalk.client.OutOfBandData; import org.kontalk.crypto.Coder; -import org.kontalk.crypto.DecryptException; import org.kontalk.crypto.PersonalKey; +import org.kontalk.data.Contact; import org.kontalk.message.AttachmentComponent; import org.kontalk.message.AudioComponent; import org.kontalk.message.CompositeMessage; +import org.kontalk.message.DefaultAttachmentComponent; +import org.kontalk.message.GroupCommandComponent; +import org.kontalk.message.GroupComponent; import org.kontalk.message.ImageComponent; -import org.kontalk.message.MessageComponent; +import org.kontalk.message.LocationComponent; import org.kontalk.message.RawComponent; import org.kontalk.message.TextComponent; import org.kontalk.message.VCardComponent; +import org.kontalk.provider.Keyring; import org.kontalk.provider.MyMessages.Messages; -import org.kontalk.provider.UsersProvider; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.LinkedList; -import java.util.List; -import java.util.Locale; -import java.util.TimeZone; public final class MessageUtils { // TODO convert these to XML styles + // these spans can't be used more than once in a Spanned because it's the same object reference!!! + @Deprecated public static final StyleSpan STYLE_BOLD = new StyleSpan(Typeface.BOLD); + @Deprecated private static final ForegroundColorSpan STYLE_RED = new ForegroundColorSpan(Color.RED); + @Deprecated private static final ForegroundColorSpan STYLE_GREEN = new ForegroundColorSpan(Color.rgb(0, 0xAA, 0)); + /** For ascii to emoji converter. */ + private static Map sEmojiConverterMap = new HashMap<>(); + + static { + //http://apps.timwhitlock.info/emoji/tables/unicode + //http://unicode.org/emoji/charts/full-emoji-list.html + //use this to get UTF-16 from UTF-32: http://www.fileformat.info/info/unicode/char/search.htm + //you have to use UTF-16 here! + sEmojiConverterMap.put(":)", "\uD83D\uDE42"); + sEmojiConverterMap.put(":-)", "\uD83D\uDE42"); + sEmojiConverterMap.put(":(", "\uD83D\uDE41"); + sEmojiConverterMap.put(":-(", "\uD83D\uDE41"); + sEmojiConverterMap.put(":'(", "\uD83D\uDE22"); + sEmojiConverterMap.put("<3", "\u0000\u2764"); + sEmojiConverterMap.put(";-)", "\uD83D\uDE09"); + sEmojiConverterMap.put(";)", "\uD83D\uDE09"); + sEmojiConverterMap.put(":p", "\uD83D\uDE1B"); + sEmojiConverterMap.put(":P", "\uD83D\uDE1B"); + sEmojiConverterMap.put(":b", "\uD83D\uDE1B"); + sEmojiConverterMap.put(";p", "\uD83D\uDE1C"); + sEmojiConverterMap.put(";P", "\uD83D\uDE1C"); + sEmojiConverterMap.put(";b", "\uD83D\uDE1C"); + sEmojiConverterMap.put("xp", "\uD83D\uDE1D"); + sEmojiConverterMap.put("xP", "\uD83D\uDE1D"); + sEmojiConverterMap.put("xb", "\uD83D\uDE1D"); + sEmojiConverterMap.put("Xp", "\uD83D\uDE1D"); + sEmojiConverterMap.put("XP", "\uD83D\uDE1D"); + sEmojiConverterMap.put("Xb", "\uD83D\uDE1D"); + sEmojiConverterMap.put("B)", "\uD83D\uDE0E"); + sEmojiConverterMap.put(":/", "\uD83D\uDE15"); + sEmojiConverterMap.put(":\\", "\uD83D\uDE15"); + sEmojiConverterMap.put(":|", "\uD83D\uDE10"); + sEmojiConverterMap.put(":o", "\uD83D\uDE2E"); + sEmojiConverterMap.put(":O", "\uD83D\uDE2E"); + sEmojiConverterMap.put(";(", "\uD83D\uDE20"); + sEmojiConverterMap.put(";-(", "\uD83D\uDE20"); + } + public static final int MILLISECONDS_IN_DAY = 86400000; private MessageUtils() {} @@ -82,8 +135,8 @@ public static CharSequence formatRelativeTimeSpan(Context context, long when) { DateUtils.FORMAT_CAP_AMPM; return DateUtils.getRelativeDateTimeString(context, when, - DateUtils.SECOND_IN_MILLIS, DateUtils.DAY_IN_MILLIS * 2, - format_flags); + DateUtils.SECOND_IN_MILLIS, DateUtils.DAY_IN_MILLIS * 2, + format_flags); } public static String formatDateString(Context context, long when) { @@ -159,6 +212,24 @@ public static String formatTimeString(Context context, long when) { return DateUtils.formatDateTime(context, when, format_flags); } + @NonNull + public static CharSequence formatLastSeen(Context context, Contact contact, long seconds) { + if (seconds > 0) { + long stamp = System.currentTimeMillis() - (seconds * 1000); + + if (contact != null) { + contact.setLastSeen(stamp); + } + + // seconds ago relative to our time + return formatRelativeTimeSpan(context, stamp); + } + else { + // it's improbable, but whatever... + return context.getText(R.string.seen_moment_ago_label); + } + } + public static boolean isSameDate(long a, long b) { TimeZone tm = TimeZone.getDefault(); a += tm.getOffset(a); @@ -166,41 +237,32 @@ public static boolean isSameDate(long a, long b) { return (a / MILLISECONDS_IN_DAY) == (b / MILLISECONDS_IN_DAY); } - public static String bytesToHex(byte[] data) { - StringBuffer buf = new StringBuffer(); - for (int i = 0; i < data.length; i++) { - int halfbyte = (data[i] >>> 4) & 0x0F; - int two_halfs = 0; - do { - if ((0 <= halfbyte) && (halfbyte <= 9)) - buf.append((char) ('0' + halfbyte)); - else - buf.append((char) ('a' + (halfbyte - 10))); - halfbyte = data[i] & 0x0F; - } while(two_halfs++ < 1); - } - return buf.toString(); + public static long getMessageTimestamp(CompositeMessage msg) { + long serverTime = msg.getServerTimestamp(); + return serverTime > 0 ? serverTime : msg.getTimestamp(); } - /** TODO move somewhere else */ - public static String sha1(String text) { - try { - MessageDigest md; - md = MessageDigest.getInstance("SHA-1"); - md.update(text.getBytes(), 0, text.length()); - byte[] sha1hash = md.digest(); + public static long getMessageTimestamp(Cursor c) { + long serverTime = c.getLong(CompositeMessage.COLUMN_SERVER_TIMESTAMP); + return serverTime > 0 ? serverTime : c.getLong(CompositeMessage.COLUMN_TIMESTAMP); + } - return bytesToHex(sha1hash); - } - catch (NoSuchAlgorithmException e) { - // no SHA-1?? WWWHHHHAAAAAATTTT???!?!?!?!?! - throw new RuntimeException("no SHA-1 available. What the crap of a device do you have?"); - } + public static String getMessagePeer(CompositeMessage msg) { + return msg.getDirection() == Messages.DIRECTION_IN ? + msg.getSender(true) : msg.getRecipient(); + } + + public static String getMessagePeer(Cursor c) { + return c.getString(CompositeMessage.COLUMN_PEER); + } + + public static int getMessageDirection(Cursor c) { + return c.getInt(CompositeMessage.COLUMN_DIRECTION); } - public static ByteArrayOutputStream readFully(InputStream in, long maxSize) throws IOException { + public static ByteArrayInOutStream readFully(InputStream in, long maxSize) throws IOException { byte[] buf = new byte[1024]; - ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayInOutStream out = new ByteArrayInOutStream(); int l; while ((l = in.read(buf, 0, 1024)) > 0 && out.size() < maxSize) out.write(buf, 0, l); @@ -225,7 +287,7 @@ public static CharSequence getFileInfoMessage(Context context, CompositeMessage details.append(res.getString(R.string.message_type_label)); int resId = R.string.text_message; - AttachmentComponent attachment = (AttachmentComponent) msg + AttachmentComponent attachment = msg .getComponent(AttachmentComponent.class); if (attachment != null) { @@ -250,7 +312,7 @@ else if (attachment instanceof AudioComponent) } else { // text content length (if found) - TextComponent txt = (TextComponent) msg + TextComponent txt = msg .getComponent(TextComponent.class); if (txt != null) @@ -265,7 +327,17 @@ else if (attachment instanceof AudioComponent) return details.toString(); } - public static CharSequence getMessageDetails(Context context, CompositeMessage msg, String decodedPeer) { + public static void showMessageDetails(Context context, CompositeMessage msg, String decodedPeer, String decodedName) { + CharSequence messageDetails = getMessageDetails( + context, msg, decodedPeer, decodedName); + new MaterialDialog.Builder(context) + .title(R.string.title_message_details) + .content(messageDetails) + .cancelable(true) + .show(); + } + + private static CharSequence getMessageDetails(Context context, CompositeMessage msg, String decodedPeer, String decodedName) { SpannableStringBuilder details = new SpannableStringBuilder(); Resources res = context.getResources(); int direction = msg.getDirection(); @@ -274,7 +346,12 @@ public static CharSequence getMessageDetails(Context context, CompositeMessage m details.append(res.getString(R.string.message_type_label)); int resId = R.string.text_message; - AttachmentComponent attachment = (AttachmentComponent) msg + + if (msg.hasComponent(LocationComponent.class)) { + resId = R.string.location_message; + } + + AttachmentComponent attachment = msg .getComponent(AttachmentComponent.class); if (attachment != null) { @@ -289,13 +366,18 @@ else if (attachment instanceof AudioComponent) details.append(res.getString(resId)); // To/From - details.append('\n'); - if (direction == Messages.DIRECTION_OUT) - details.append(res.getString(R.string.to_address_label)); - else - details.append(res.getString(R.string.from_label)); - - details.append(decodedPeer); + if (!msg.hasComponent(GroupComponent.class) || direction == Messages.DIRECTION_IN) { + details.append('\n'); + if (direction == Messages.DIRECTION_OUT) + details.append(res.getString(R.string.to_address_label)); + else + details.append(res.getString(R.string.from_label)); + + String displayName = (decodedName != null) ? + decodedName + "\n<" + decodedPeer + ">" : + decodedPeer; + details.append(displayName); + } // Encrypted int securityFlags = msg.getSecurityFlags(); @@ -381,7 +463,7 @@ else if ((securityFlags & Coder.SECURITY_ERROR_PUBLIC_KEY_UNAVAILABLE) != 0) { } else { // text content length (if found) - TextComponent txt = (TextComponent) msg + TextComponent txt = msg .getComponent(TextComponent.class); if (txt != null) @@ -393,6 +475,13 @@ else if ((securityFlags & Coder.SECURITY_ERROR_PUBLIC_KEY_UNAVAILABLE) != 0) { humanReadableByteCount(length, false) : res.getString(R.string.size_unknown)); + // message Id (debug only) + if (BuildConfig.DEBUG) { + details.append('\n') + .append("ID: ") + .append(msg.getId()); + } + // Date int status = msg.getStatus(); @@ -462,7 +551,7 @@ private static void appendTimestamp(Context context, Appendable details, String label, long time, boolean fullFormat) { try { details.append(label); - details.append(MessageUtils.formatTimeStampString(context, time, fullFormat)); + details.append(formatTimeStampString(context, time, fullFormat)); } catch (IOException e) { // ignored @@ -481,251 +570,22 @@ public static String humanReadableByteCount(long bytes, boolean si) { return String.format(Locale.US, "%.1f %sB", bytes / Math.pow(unit, exp), pre); } - /** Converts a Kontalk user id to a JID. */ - public static String toJID(String userId, String network) { - StringBuilder jid = new StringBuilder(); - - // this is for avoiding a useless call to subSequence - int l = userId.length(); - if (l > CompositeMessage.USERID_LENGTH) - jid.append(userId.subSequence(0, CompositeMessage.USERID_LENGTH)); - else - jid.append(userId); - - jid.append('@'); - jid.append(network); - - if (l > CompositeMessage.USERID_LENGTH) - jid.append(userId.subSequence(CompositeMessage.USERID_LENGTH, l)); - - return jid.toString(); - } - - public static boolean compareUserId(String a, String b, boolean full) throws IllegalArgumentException { - int aLen = a.length(); - int bLen = b.length(); - // validate :) - if ((aLen != CompositeMessage.USERID_LENGTH && aLen != CompositeMessage.USERID_LENGTH_RESOURCE) || - (bLen != CompositeMessage.USERID_LENGTH && bLen != CompositeMessage.USERID_LENGTH_RESOURCE) || - a.contains("@") || b.contains("@")) - throw new IllegalArgumentException("either one or both parameters are not valid user id."); - - if (full) - // full comparison - just equals - return a.equalsIgnoreCase(b); - else - // user id comparison - return a.substring(0, CompositeMessage.USERID_LENGTH) - .equalsIgnoreCase(b.substring(0, CompositeMessage.USERID_LENGTH)); - } - public static String messageId() { return StringUtils.randomString(30); } - /** Decrypts a message, modifying the object in place. */ - public static void decryptMessage(Context context, EndpointServer server, CompositeMessage msg) throws Exception { - // encrypted messages have a single encrypted raw component - RawComponent raw = (RawComponent) msg - .getComponent(RawComponent.class); - - if (raw != null) - decryptMessage(context, server, msg, raw.getContent()); - } - - /** Decrypts a message, modifying the object in place. */ - public static void decryptMessage(Context context, EndpointServer server, CompositeMessage msg, byte[] encryptedData) - throws Exception { - - // message stanza - Message m = null; - - try { - PersonalKey key = Kontalk.get(context).getPersonalKey(); - - if (server == null) - server = Preferences.getEndpointServer(context); - - Coder coder = UsersProvider.getDecryptCoder(context, server, key, msg.getSender(true)); - - // decrypt - StringBuilder mimeFound = new StringBuilder(); - StringBuilder clearText = new StringBuilder(); - List errors = new LinkedList(); - coder.decryptText(encryptedData, true, clearText, mimeFound, errors); - - String contentText; - - if (XMPPUtils.XML_XMPP_TYPE.equalsIgnoreCase(mimeFound.toString())) { - m = XMPPUtils.parseMessageStanza(clearText.toString()); - contentText = m.getBody(); - } - else { - contentText = clearText.toString(); - } - - // clear componenets (we are adding new ones) - msg.clearComponents(); - // decrypted text - if (contentText != null) - msg.addComponent(new TextComponent(contentText)); - - if (errors.size() > 0) { - - int securityFlags = msg.getSecurityFlags(); - - for (DecryptException err : errors) { - - int code = err.getCode(); - switch (code) { - - case DecryptException.DECRYPT_EXCEPTION_INTEGRITY_CHECK: - securityFlags |= Coder.SECURITY_ERROR_INTEGRITY_CHECK; - break; - - case DecryptException.DECRYPT_EXCEPTION_VERIFICATION_FAILED: - securityFlags |= Coder.SECURITY_ERROR_INVALID_SIGNATURE; - break; - - case DecryptException.DECRYPT_EXCEPTION_INVALID_DATA: - securityFlags |= Coder.SECURITY_ERROR_INVALID_DATA; - break; - - case DecryptException.DECRYPT_EXCEPTION_INVALID_SENDER: - securityFlags |= Coder.SECURITY_ERROR_INVALID_SENDER; - break; - - case DecryptException.DECRYPT_EXCEPTION_INVALID_RECIPIENT: - securityFlags |= Coder.SECURITY_ERROR_INVALID_RECIPIENT; - break; - - case DecryptException.DECRYPT_EXCEPTION_INVALID_TIMESTAMP: - securityFlags |= Coder.SECURITY_ERROR_INVALID_TIMESTAMP; - break; - - } - - } - - msg.setSecurityFlags(securityFlags); - } - - msg.setEncrypted(false); - - } - catch (Exception exc) { - // pass over the message even if encrypted - // UI will warn the user about that and wait - // for user decisions - int securityFlags = msg.getSecurityFlags(); - - if (exc instanceof DecryptException) { - - int code = ((DecryptException) exc).getCode(); - switch (code) { - - case DecryptException.DECRYPT_EXCEPTION_DECRYPT_FAILED: - case DecryptException.DECRYPT_EXCEPTION_PRIVATE_KEY_NOT_FOUND: - securityFlags |= Coder.SECURITY_ERROR_DECRYPT_FAILED; - break; - - case DecryptException.DECRYPT_EXCEPTION_INTEGRITY_CHECK: - securityFlags |= Coder.SECURITY_ERROR_INTEGRITY_CHECK; - break; - - case DecryptException.DECRYPT_EXCEPTION_INVALID_DATA: - securityFlags |= Coder.SECURITY_ERROR_INVALID_DATA; - break; - - } - - msg.setSecurityFlags(securityFlags); - } - - throw exc; - } - - // we have a decrypted message stanza, process it - if (m != null) { - - // TODO duplicated code (MessageListener#processPacket) - - // out of band data - ExtensionElement _media = m.getExtension(OutOfBandData.ELEMENT_NAME, OutOfBandData.NAMESPACE); - if (_media != null && _media instanceof OutOfBandData) { - File previewFile = null; - - OutOfBandData media = (OutOfBandData) _media; - String mime = media.getMime(); - String fetchUrl = media.getUrl(); - long length = media.getLength(); - boolean encrypted = media.isEncrypted(); - - // bits-of-binary for preview - ExtensionElement _preview = m.getExtension(BitsOfBinary.ELEMENT_NAME, BitsOfBinary.NAMESPACE); - if (_preview != null && _preview instanceof BitsOfBinary) { - BitsOfBinary preview = (BitsOfBinary) _preview; - String previewMime = preview.getType(); - if (previewMime == null) - previewMime = MediaStorage.THUMBNAIL_MIME_NETWORK; - - String filename = null; - - if (ImageComponent.supportsMimeType(mime)) { - filename = ImageComponent.buildMediaFilename(msg.getId(), previewMime); - } - - else if (VCardComponent.supportsMimeType(mime)) { - filename = VCardComponent.buildMediaFilename(msg.getId(), previewMime); - } - - try { - if (filename != null) previewFile = - MediaStorage.writeInternalMedia(context, - filename, preview.getContents()); - } - catch (IOException e) { - Log.w(Kontalk.TAG, "error storing thumbnail", e); - } - } - - MessageComponent attachment = null; - - if (ImageComponent.supportsMimeType(mime)) { - // cleartext only for now - attachment = new ImageComponent(mime, previewFile, null, fetchUrl, length, - encrypted, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); - } - - else if (VCardComponent.supportsMimeType(mime)) { - // cleartext only for now - attachment = new VCardComponent(previewFile, null, fetchUrl, length, - encrypted, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); - } - - else if (AudioComponent.supportsMimeType(mime)) { - attachment = new AudioComponent(mime, null, fetchUrl, length, - encrypted, encrypted ? Coder.SECURITY_BASIC : Coder.SECURITY_CLEARTEXT); - } - - - // TODO other types - - if (attachment != null) - msg.addComponent(attachment); - - // add a dummy body if none was found - /* - if (body == null) { - msg.addComponent(new TextComponent(CompositeMessage - .getSampleTextContent((Class>) - attachment.getClass(), mime))); - } - */ - - } - - } + public static File encryptFile(Context context, InputStream in, String[] users) + throws GeneralSecurityException, IOException, PGPException { + PersonalKey key = Kontalk.get().getPersonalKey(); + EndpointServer server = Kontalk.get().getEndpointServer(); + Coder coder = Keyring.getEncryptCoder(context, server, key, users); + // create a temporary file to store encrypted data + File temp = File.createTempFile("media", null, context.getCacheDir()); + FileOutputStream out = new FileOutputStream(temp); + coder.encryptFile(in, out); + // close encrypted file + out.close(); + return temp; } /** Fills in a {@link ContentValues} object from the given message. */ @@ -737,7 +597,7 @@ public static void fillContentValues(ContentValues values, CompositeMessage msg) // message still encrypted - use whole body of raw component if (msg.isEncrypted()) { - RawComponent raw = (RawComponent) msg.getComponent(RawComponent.class); + RawComponent raw = msg.getComponent(RawComponent.class); // if raw it's null it's a bug content = raw.getContent(); mime = null; @@ -746,31 +606,36 @@ public static void fillContentValues(ContentValues values, CompositeMessage msg) } else { + GroupCommandComponent group = msg.getComponent(GroupCommandComponent.class); + if (group != null) { + content = group.getTextContent().getBytes(); + mime = GroupCommandComponent.MIME_TYPE; + } + else { + TextComponent txt = msg.getComponent(TextComponent.class); - TextComponent txt = (TextComponent) msg.getComponent(TextComponent.class); - - if (txt != null) { - content = txt.getContent().getBytes(); - mime = TextComponent.MIME_TYPE; + if (txt != null) { + content = txt.getContent().getBytes(); + mime = TextComponent.MIME_TYPE; + } } checkAttachment = true; - } // selective components detection if (checkAttachment) { - @SuppressWarnings("unchecked") Class[] tryComponents = new Class[] { ImageComponent.class, VCardComponent.class, AudioComponent.class, + DefaultAttachmentComponent.class, }; for (Class klass : tryComponents) { - AttachmentComponent att = (AttachmentComponent) msg.getComponent(klass); + AttachmentComponent att = msg.getComponent(klass); if (att != null) { values.put(Messages.ATTACHMENT_MIME, att.getMime()); @@ -786,9 +651,7 @@ public static void fillContentValues(ContentValues values, CompositeMessage msg) // only one attachment is supported break; } - } - } values.put(Messages.BODY_CONTENT, content); @@ -801,4 +664,115 @@ public static void fillContentValues(ContentValues values, CompositeMessage msg) values.put(Messages.SERVER_TIMESTAMP, msg.getServerTimestamp()); } + @NonNull + public static Bitmap drawableToBitmap(@NonNull Drawable drawable) { + Bitmap bitmap; + + if (drawable instanceof BitmapDrawable) { + BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable; + if(bitmapDrawable.getBitmap() != null) { + return bitmapDrawable.getBitmap(); + } + } + + if(drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0) { + bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); // Single color bitmap will be created of 1x1 pixel + } else { + bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + } + + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + return bitmap; + } + + public static String toString(byte[] text) { + return new String(trimNul(text)); + } + + public static byte[] trimNul(byte[] text) { + if (text.length > 0 && text[text.length - 1] == '\0') { + byte[] nulBody = new byte[text.length - 1]; + System.arraycopy(text, 0, nulBody, 0, nulBody.length); + text = nulBody; + } + return text; + } + + // checks for ASCII-smileys and replace them + public static boolean convertSmileys(Editable input) { + boolean converted = false; + for (String key : sEmojiConverterMap.keySet()) { + // order of arguments of OR is essential here! + converted = replaceEditable(input, key, sEmojiConverterMap.get(key)) || converted; + } + return converted; + } + + // actual replacement ASCII -> UTF-16 if necessary + private static boolean replaceEditable(Editable text, String in, String out) { + boolean replaced = false; + int notReplaced = 0; + for (int position = text.toString().indexOf(in + " "); position >= 0; position = text.toString().indexOf(in + " ")){ + + // check if there are symbols that should not be converted, e.g. in "http://" or "expert" + // if only these last, exit the loop + if (notReplaced > 0){ + position = ordinalIndexOf(text.toString(), " " + in + " ", notReplaced) + 1; + if (position < 1){ + break; + } + } + // emoji at beginning is okay, emoji with other char than space in front is not okay + if (position > 0 && text.charAt(position - 1) > 32 && text.charAt(position - 1) < 255) { + notReplaced++; + continue; + } + text.replace(position, position + in.length(), out); + replaced = true; + } + return replaced; + } + + //can't find such a function in java api, so take it from StringUtils.java + public static int ordinalIndexOf(String str, String searchStr, int ordinal) { + if (str == null || searchStr == null || ordinal <= 0) { + return -1; + } + if (searchStr.length() == 0) { + return 0; + } + int found = 0; + int index = -1; + do { + index = str.indexOf(searchStr, index + 1); + if (index < 0) { + return index; + } + found++; + } while (found < ordinal); + return index; + } + + public static boolean sendEncrypted(Context context, boolean chatEncryptionEnabled) { + return Preferences.getEncryptionEnabled(context) && chatEncryptionEnabled; + } + + /** + * Reformats an E.164 phone number properly. + * @param phoneNumber an E.164 phone number + * @return a properly formatted phone number + */ + public static String reformatPhoneNumber(String phoneNumber) { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + try { + final Phonenumber.PhoneNumber phone = util.parse(phoneNumber, null); + return util.format(phone, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL); + } + catch (NumberParseException e) { + return phoneNumber; + } + } + } diff --git a/app/src/main/java/org/kontalk/util/ParameterRunnable.java b/app/src/main/java/org/kontalk/util/ParameterRunnable.java new file mode 100644 index 000000000..76cbf9249 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/ParameterRunnable.java @@ -0,0 +1,30 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + + +/** + * A parametrized runnable. + * @author Daniele Ricci + */ +public interface ParameterRunnable { + + void run(T parameter); + +} diff --git a/app/src/main/java/org/kontalk/util/Permissions.java b/app/src/main/java/org/kontalk/util/Permissions.java new file mode 100644 index 000000000..c6e57aa82 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/Permissions.java @@ -0,0 +1,219 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import androidx.fragment.app.Fragment; + +import pub.devrel.easypermissions.AppSettingsDialog; +import pub.devrel.easypermissions.EasyPermissions; + + +/** + * Permission utilities. + * @author Daniele Ricci + */ +public class Permissions { + + public static final int RC_CALL_PHONE = 201; + public static final int RC_CONTACTS = 202; + public static final int RC_READ_EXT_STORAGE = 203; + public static final int RC_WRITE_EXT_STORAGE = 204; + public static final int RC_CAMERA = 205; + public static final int RC_RECORD_AUDIO = 206; + public static final int RC_LOCATION = 207; + public static final int RC_PHONE_STATE = 208; + + public static boolean canCallPhone(Context context) { + return EasyPermissions.hasPermissions(context, + Manifest.permission.CALL_PHONE); + } + + public static void requestCallPhone(Fragment fragment) { + EasyPermissions.requestPermissions(fragment, null, RC_CALL_PHONE, + Manifest.permission.CALL_PHONE); + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public static boolean canWriteContacts(Context context) { + return EasyPermissions.hasPermissions(context, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS); + } + + public static boolean canReadContacts(Context context) { + return EasyPermissions.hasPermissions(context, + Manifest.permission.READ_CONTACTS); + } + + public static void requestContacts(Activity activity, String rationale) { + EasyPermissions.requestPermissions(activity, rationale, RC_CONTACTS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS); + } + + @SuppressLint("InlinedApi") + public static boolean canReadExternalStorage(Context context) { + return EasyPermissions.hasPermissions(context, + Manifest.permission.READ_EXTERNAL_STORAGE); + } + + public static void requestReadExternalStorage(Activity activity, String rationale) { + requestReadExternalStorage(activity, rationale, 0); + } + + @SuppressLint("InlinedApi") + public static void requestReadExternalStorage(Activity activity, String rationale, int index) { + EasyPermissions.requestPermissions(activity, rationale, RC_READ_EXT_STORAGE + (index * 100), + Manifest.permission.READ_EXTERNAL_STORAGE); + } + + @SuppressLint("InlinedApi") + public static boolean canWriteExternalStorage(Context context) { + return EasyPermissions.hasPermissions(context, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + + @SuppressLint("InlinedApi") + public static void requestWriteExternalStorage(Activity activity, String rationale) { + EasyPermissions.requestPermissions(activity, rationale, RC_WRITE_EXT_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + + @SuppressLint("InlinedApi") + public static void requestWriteExternalStorage(Fragment fragment, String rationale) { + if (EasyPermissions.permissionPermanentlyDenied(fragment, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + new AppSettingsDialog.Builder(fragment) + .setRationale(rationale) + .build() + .show(); + } + else { + EasyPermissions.requestPermissions(fragment, rationale, RC_WRITE_EXT_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + } + + public static boolean canUseCamera(Context context) { + return EasyPermissions.hasPermissions(context, + Manifest.permission.CAMERA); + } + + public static void requestCamera(Activity activity, String rationale) { + if (EasyPermissions.permissionPermanentlyDenied(activity, Manifest.permission.CAMERA)) { + new AppSettingsDialog.Builder(activity) + .setRationale(rationale) + .build() + .show(); + } + else { + EasyPermissions.requestPermissions(activity, rationale, RC_CAMERA, + Manifest.permission.CAMERA); + } + } + + public static void requestCamera(Fragment fragment, String rationale, boolean externalStorage) { + if (EasyPermissions.permissionPermanentlyDenied(fragment, Manifest.permission.CAMERA)) { + new AppSettingsDialog.Builder(fragment) + .setRationale(rationale) + .build() + .show(); + } + else { + String[] permissions; + if (externalStorage) { + permissions = new String[] { + Manifest.permission.CAMERA, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + }; + } + else { + permissions = new String[] { + Manifest.permission.CAMERA + }; + } + EasyPermissions.requestPermissions(fragment, rationale, RC_CAMERA, + permissions); + } + } + + public static boolean canRecordAudio(Context context) { + return EasyPermissions.hasPermissions(context, + Manifest.permission.RECORD_AUDIO); + } + + @SuppressLint("InlinedApi") + public static void requestRecordAudio(Activity activity, String rationale) { + if (recordAudioPermissionPermanentlyDenied(activity)) { + new AppSettingsDialog.Builder(activity) + .setRationale(rationale) + .build() + .show(); + } + else { + EasyPermissions.requestPermissions(activity, rationale, RC_RECORD_AUDIO, + Manifest.permission.RECORD_AUDIO); + } + } + + private static boolean recordAudioPermissionPermanentlyDenied(Activity activity) { + if (!EasyPermissions.hasPermissions(activity, Manifest.permission.RECORD_AUDIO)) { + return !Preferences.isPermissionAsked(Manifest.permission.RECORD_AUDIO) && + EasyPermissions.permissionPermanentlyDenied(activity, Manifest.permission.RECORD_AUDIO); + } + return false; + } + + public static boolean canAccessLocation(Context context) { + return EasyPermissions.hasPermissions(context, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION); + } + + public static boolean canAccessSomeLocation(Context context) { + return EasyPermissions.hasPermissions(context, + Manifest.permission.ACCESS_COARSE_LOCATION) || + EasyPermissions.hasPermissions(context, + Manifest.permission.ACCESS_FINE_LOCATION); + } + + public static boolean canAccessFineLocation(Context context) { + return EasyPermissions.hasPermissions(context, + Manifest.permission.ACCESS_FINE_LOCATION); + } + + public static void requestLocation(Fragment fragment, String rationale) { + EasyPermissions.requestPermissions(fragment, rationale, RC_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION); + } + + public static void requestPhoneState(Activity activity, String rationale) { + EasyPermissions.requestPermissions(activity, rationale, RC_PHONE_STATE, + Manifest.permission.READ_PHONE_STATE); + } + +} diff --git a/app/src/main/java/org/kontalk/util/Preferences.java b/app/src/main/java/org/kontalk/util/Preferences.java index f49a3b5e9..1d2e7eae3 100644 --- a/app/src/main/java/org/kontalk/util/Preferences.java +++ b/app/src/main/java/org/kontalk/util/Preferences.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,18 +20,14 @@ import java.io.File; import java.io.FileOutputStream; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import org.kontalk.R; -import org.kontalk.authenticator.Authenticator; -import org.kontalk.client.EndpointServer; -import org.kontalk.client.ServerList; -import org.kontalk.crypto.PersonalKey; -import org.kontalk.provider.MyMessages.Messages; -import org.kontalk.service.ServerListUpdater; -import org.kontalk.service.msgcenter.MessageCenterService; +import com.vanniktech.emoji.EmojiManager; +import com.vanniktech.emoji.ios.IosEmojiProvider; +import android.annotation.SuppressLint; import android.content.ContentValues; import android.content.Context; import android.content.SharedPreferences; @@ -44,14 +40,24 @@ import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.net.Uri; -import android.preference.Preference; -import android.preference.PreferenceManager; import android.provider.BaseColumns; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.preference.Preference; +import androidx.preference.PreferenceManager; + import android.text.TextUtils; -import android.util.Base64; import android.view.Display; import android.view.WindowManager; +import org.kontalk.Kontalk; +import org.kontalk.R; +import org.kontalk.client.EndpointServer; +import org.kontalk.client.ServerList; +import org.kontalk.service.ServerListUpdater; +import org.kontalk.service.msgcenter.MessageCenterService; + /** * Access to application preferences. @@ -62,9 +68,56 @@ public final class Preferences { private static SharedPreferences sPreferences; private static Drawable sCustomBackground; private static String sBalloonTheme; + private static String sBalloonGroupsTheme; + + @SuppressLint("ApplySharedPref") + public static void init(@NonNull Context context) { + if (sPreferences == null) { + sPreferences = PreferenceManager.getDefaultSharedPreferences(context); + + // set the new default theme if this is the first upgrade + String newTheme = context.getString(R.string.pref_default_balloons); + if (!getBooleanOnce("has_new_theme." + newTheme)) + sPreferences.edit().putString("pref_balloons", newTheme) + .commit(); + } + } + + public static void initUI(@NonNull Context context) { + applyTheme(context); + + // init emoji manager + // FIXME this is taking a very long time + EmojiManager.install(new IosEmojiProvider()); + } + + public static void applyTheme(@NonNull Context context) { + // setup theme + // https://medium.com/androiddevelopers/appcompat-v23-2-daynight-d10f90c83e94 + String uiTheme = getString("pref_ui_theme", context + .getResources().getString(R.string.pref_default_ui_theme)); + + int nightMode; + switch (uiTheme) { + case "light": + nightMode = AppCompatDelegate.MODE_NIGHT_NO; + break; + case "dark": + nightMode = AppCompatDelegate.MODE_NIGHT_YES; + break; + case "default": + default: + nightMode = SystemUtils.supportsNativeNightMode() ? + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM : + AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY; + break; + } - public static void init(Context context) { - sPreferences = PreferenceManager.getDefaultSharedPreferences(context); + AppCompatDelegate.setDefaultNightMode(nightMode); + } + + public static SharedPreferences getInstance() { + return sPreferences; } public static void setCachedCustomBackground(Drawable customBackground) { @@ -75,25 +128,29 @@ public static void setCachedBalloonTheme(String balloonTheme) { sBalloonTheme = balloonTheme; } + public static void setCachedBalloonGroupsTheme(String balloonTheme) { + sBalloonGroupsTheme = balloonTheme; + } + public static void updateServerListLastUpdate(Preference pref, ServerList list) { Context context = pref.getContext(); String timestamp = MessageUtils.formatTimeStampString(context, list.getDate().getTime(), true); pref.setSummary(context.getString(R.string.server_list_last_update, timestamp)); } - private static String getString(Context context, String key, String defaultValue) { + public static String getString(String key, String defaultValue) { return sPreferences.getString(key, defaultValue); } - private static int getInt(Context context, String key, int defaultValue) { + private static int getInt(String key, int defaultValue) { return sPreferences.getInt(key, defaultValue); } - private static int getIntMinValue(Context context, String key, int minValue, int defaultValue) { - String val = getString(context, key, null); + private static int getIntMinValue(String key, int minValue, int defaultValue) { + String val = getString(key, null); int nval; try { - nval = Integer.valueOf(val); + nval = Integer.parseInt(val); } catch (Exception e) { nval = defaultValue; @@ -101,59 +158,65 @@ private static int getIntMinValue(Context context, String key, int minValue, int return (nval < minValue) ? minValue : nval; } - private static long getLong(Context context, String key, long defaultValue) { + private static long getLong(String key, long defaultValue) { return sPreferences.getLong(key, defaultValue); } /** Retrieves a long and if >= 0 it sets it to -1. */ - private static long getLongOnce(Context context, String key) { + @SuppressLint("ApplySharedPref") + private static long getLongOnce(String key) { long value = sPreferences.getLong(key, -1); if (value >= 0) sPreferences.edit().putLong(key, -1).commit(); return value; } - private static boolean getBoolean(Context context, String key, boolean defaultValue) { + public static boolean getBoolean(String key, boolean defaultValue) { return sPreferences.getBoolean(key, defaultValue); } + private static String getShowcaseKey(String parent, String key) { + return "showcase_" + parent + "_" + key; + } + + public static boolean getShowcaseShowed(String parent, String key) { + return Preferences.getBoolean(getShowcaseKey(parent, key), false); + } + + public static boolean setShowcaseShowed(String parent, String key, boolean value) { + return sPreferences.edit() + .putBoolean(getShowcaseKey(parent, key), value) + .commit(); + } + /** Retrieve a boolean and if false set it to true. */ - private static boolean getBooleanOnce(Context context, String key) { + @SuppressLint("ApplySharedPref") + private static boolean getBooleanOnce(String key) { boolean value = sPreferences.getBoolean(key, false); if (!value) sPreferences.edit().putBoolean(key, true).commit(); return value; } - public static String getServerURI(Context context) { - return getString(context, "pref_network_uri", null); - } - - public static boolean setServerURI(Context context, String serverURI) { + public static boolean setRingtone(String uri) { return sPreferences.edit() - .putString("pref_network_uri", serverURI) + .putString("pref_ringtone", uri) .commit(); } - /** Returns a random server from the cached list or the user-defined server. */ - public static EndpointServer getEndpointServer(Context context) { - String customUri = getServerURI(context); - if (!TextUtils.isEmpty(customUri)) { - try { - return new EndpointServer(customUri); - } - catch (Exception e) { - // custom is not valid - take one from list - } - } + public static String getServerURI() { + return getString("pref_network_uri", null); + } - // return server stored in the default account - return Authenticator.getDefaultServer(context); + public static boolean setServerURI(String serverURI) { + return sPreferences.edit() + .putString("pref_network_uri", serverURI) + .commit(); } /** Returns a server provider reflecting the current settings. */ public static EndpointServer.EndpointServerProvider getEndpointServerProvider(Context context) { - final String customUri = getServerURI(context); + final String customUri = getServerURI(); if (!TextUtils.isEmpty(customUri)) { return new EndpointServer.SingleServerProvider(customUri); } @@ -163,112 +226,155 @@ public static EndpointServer.EndpointServerProvider getEndpointServerProvider(Co } } + public static boolean getForegroundServiceEnabled(Context context) { + return getBoolean("pref_foreground_service", context + .getResources().getBoolean(R.bool.pref_default_foreground_service)); + } + public static boolean getEncryptionEnabled(Context context) { - return getBoolean(context, "pref_encrypt", true); + return getBoolean("pref_encrypt", context + .getResources().getBoolean(R.bool.pref_default_encrypt)); } public static boolean getSyncSIMContacts(Context context) { - return getBoolean(context, "pref_sync_sim_contacts", false); + return getBoolean("pref_sync_sim_contacts", context + .getResources().getBoolean(R.bool.pref_default_sync_sim_contacts)); } public static boolean getSyncInvisibleContacts(Context context) { - return getBoolean(context, "pref_sync_invisible_contacts", false); + return getBoolean("pref_sync_invisible_contacts", context + .getResources().getBoolean(R.bool.pref_default_sync_invisible_contacts)); } public static boolean getAutoAcceptSubscriptions(Context context) { - return getBoolean(context, "pref_auto_accept_subscriptions", false); + return getBoolean("pref_auto_accept_subscriptions", context + .getResources().getBoolean(R.bool.pref_default_auto_accept_subscriptions)); } public static boolean getPushNotificationsEnabled(Context context) { - return getBoolean(context, "pref_push_notifications", true); + return getBoolean("pref_push_notifications", context + .getResources().getBoolean(R.bool.pref_default_push_notifications)); } public static boolean getNotificationsEnabled(Context context) { - return getBoolean(context, "pref_enable_notifications", true); + return getBoolean("pref_enable_notifications", context + .getResources().getBoolean(R.bool.pref_default_enable_notifications)); } public static String getNotificationVibrate(Context context) { - return getString(context, "pref_vibrate", "never"); + return getString("pref_vibrate", context + .getString(R.string.pref_default_vibrate)); } public static String getNotificationRingtone(Context context) { - return getString(context, "pref_ringtone", null); + return getString("pref_ringtone", context + .getString(R.string.pref_default_ringtone)); } - public static int getImageCompression(Context context) { - return Integer.parseInt(getString(context, "pref_image_resize", "1024")); + public static boolean getNotificationLED(Context context) { + return getBoolean("pref_enable_notification_led", + context.getResources().getBoolean(R.bool.pref_default_enable_notification_led)); + } + + public static int getNotificationLEDColor(Context context) { + return getInt("pref_notification_led_color", + context.getResources().getInteger(R.integer.pref_default_notification_led_color)); } - public static boolean setLastCountryCode(Context context, int countryCode) { + public static boolean setNotificationLEDColor(int color) { return sPreferences.edit() - .putInt("pref_countrycode", countryCode) + .putInt("pref_notification_led_color", color) .commit(); } - public static int getLastCountryCode(Context context) { - return getInt(context, "pref_countrycode", 0); + public static boolean getOutgoingSoundEnabled(Context context) { + return getBoolean("pref_enable_outgoing_sound", context + .getResources().getBoolean(R.bool.pref_default_enable_outgoing_sound)); + } + + public static int getImageCompression(Context context) { + return Integer.parseInt(getString("pref_image_resize", String + .valueOf(context.getResources().getInteger(R.integer.pref_default_image_resize)))); + } + + /** Returns true if, as per settings, we can autodownload a file of the given size. */ + public static boolean canAutodownloadMedia(Context context, long size) { + int threshold = Integer.parseInt(getString("pref_media_autodownload_threshold", String + .valueOf(context.getResources().getInteger(R.integer.pref_default_media_autodownload_threshold)))); + String autodownload = getString("pref_media_autodownload", + context.getResources().getString(R.string.pref_default_media_autodownload)); + return (size / 1024) < threshold || "always".equals(autodownload) || + ("wifi".equals(autodownload) && SystemUtils.isOnWifi(context)); + } + + public static String getMapsProvider(Context context) { + return getString("pref_maps_service", context.getResources() + .getString(R.string.pref_default_maps_service)); } - public static boolean getContactsListVisited(Context context) { - return getBooleanOnce(context, "pref_contacts_visited"); + public static boolean getContactsListVisited() { + return getBooleanOnce("pref_contacts_visited"); } - public static long getLastSyncTimestamp(Context context) { - return getLong(context, "pref_last_sync", -1); + public static long getLastSyncTimestamp() { + return getLong("pref_last_sync", -1); } - public static boolean setLastSyncTimestamp(Context context, long timestamp) { + public static boolean setLastSyncTimestamp(long timestamp) { return sPreferences.edit() .putLong("pref_last_sync", timestamp) .commit(); } - public static boolean setLastPushNotification(Context context, long timestamp) { + public static boolean setLastPushNotification(long timestamp) { return sPreferences.edit() .putLong("pref_last_push_notification", timestamp) .commit(); } - public static long getLastPushNotification(Context context) { - return getLongOnce(context, "pref_last_push_notification"); + public static long getLastPushNotification() { + return getLong("pref_last_push_notification", -1); } /** TODO cache value */ public static String getFontSize(Context context) { - return getString(context, "pref_font_size", "medium"); + return getString("pref_font_size", context + .getString(R.string.pref_default_font_size)); } - public static int getBalloonResource(Context context, int direction) { + public static String getBalloonTheme(Context context) { if (sBalloonTheme == null) - sBalloonTheme = getString(context, "pref_balloons", "classic"); + sBalloonTheme = getString("pref_balloons", context + .getString(R.string.pref_default_balloons)); + return sBalloonTheme; + } - if ("iphone".equals(sBalloonTheme)) - return direction == Messages.DIRECTION_IN ? - R.drawable.balloon_iphone_incoming : - R.drawable.balloon_iphone_outgoing; - else if ("old_classic".equals(sBalloonTheme)) - return direction == Messages.DIRECTION_IN ? - R.drawable.balloon_old_classic_incoming : - R.drawable.balloon_old_classic_outgoing; + public static String getBalloonGroupsTheme(Context context) { + if (sBalloonGroupsTheme == null) + sBalloonGroupsTheme = getString("pref_balloons_groups", context + .getString(R.string.pref_default_balloons_groups)); + return sBalloonGroupsTheme; + } - // all other cases - return direction == Messages.DIRECTION_IN ? - R.drawable.balloon_classic_incoming : - R.drawable.balloon_classic_outgoing; + /** Still unused. */ + public static boolean getEmojiConverter(Context context) { + return getBoolean("pref_emoji_converter", + context.getResources().getBoolean(R.bool.pref_default_emoji_converter)); } - public static String getStatusMessage(Context context) { - return getString(context, "pref_status_message", null); + public static String getStatusMessage() { + return getString("pref_status_message", null); } - public static boolean setStatusMessage(Context context, String message) { - return sPreferences.edit() + public static void setStatusMessage(String message) { + sPreferences.edit() .putString("pref_status_message", message) - .commit(); + .apply(); } /** Loads and stores a cached version of the given conversation background. */ - public static File cacheConversationBackground(Context context, Uri uri) { + @SuppressWarnings("deprecation") + public static File cacheConversationBackground(Context context, Uri uri) throws IOException { InputStream in = null; OutputStream out = null; try { @@ -276,25 +382,35 @@ public static File cacheConversationBackground(Context context, Uri uri) { Display display = wm.getDefaultDisplay(); int width; int height; - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB_MR2) { - Point size = new Point(); - display.getSize(size); - width = size.x; - height = size.y; + Point size = new Point(); + display.getSize(size); + width = size.x; + height = size.y; + + BitmapFactory.Options options; + try { + in = context.getContentResolver().openInputStream(uri); + options = MediaStorage.preloadBitmap(in, width, height); } - else { - width = display.getWidth(); - height = display.getHeight(); + catch (Exception e) { + throw new IOException(e); + } + finally { + DataUtils.close(in); } - in = context.getContentResolver().openInputStream(uri); - BitmapFactory.Options options = MediaStorage.preloadBitmap(in, width, height); - in.close(); - - // open again - in = context.getContentResolver().openInputStream(uri); - Bitmap bitmap = BitmapFactory.decodeStream(in, null, options); - in.close(); + Bitmap bitmap; + try { + // open again + in = context.getContentResolver().openInputStream(uri); + bitmap = BitmapFactory.decodeStream(in, null, options); + } + catch (Exception e) { + throw new IOException(e); + } + finally { + DataUtils.close(in); + } Bitmap tn = ThumbnailUtils.extractThumbnail(bitmap, width, height); bitmap.recycle(); @@ -309,36 +425,22 @@ public static File cacheConversationBackground(Context context, Uri uri) { return outFile; } - catch (Exception e) { - // ignored - } finally { - try { - in.close(); - } - catch (Exception e) { - // ignored - } - try { - out.close(); - } - catch (Exception e) { - // ignored - } + DataUtils.close(out); } - return null; } public static Drawable getConversationBackground(Context context) { InputStream in = null; try { - if (getBoolean(context, "pref_custom_background", false)) { + if (getBoolean("pref_custom_background", false)) { if (sCustomBackground == null) { - String _customBg = getString(context, "pref_background_uri", null); + String _customBg = getString("pref_background_uri", null); in = context.getContentResolver().openInputStream(Uri.parse(_customBg)); - - Bitmap bmap = BitmapFactory.decodeStream(in, null, null); - sCustomBackground = new BitmapDrawable(context.getResources(), bmap); + if (in != null) { + Bitmap bmap = BitmapFactory.decodeStream(in, null, null); + sCustomBackground = new BitmapDrawable(context.getResources(), bmap); + } } return sCustomBackground; } @@ -347,20 +449,11 @@ public static Drawable getConversationBackground(Context context) { // ignored } finally { - try { - in.close(); - } - catch (Exception e) { - // ignored - } + DataUtils.close(in); } return null; } - public static boolean getBigUpgrade1(Context context) { - return getBooleanOnce(context, "bigupgrade1"); - } - /** * Switches offline mode on or off. * @return offline mode status before the switch @@ -369,13 +462,17 @@ public static boolean switchOfflineMode(Context context) { boolean old = sPreferences.getBoolean("offline_mode", false); // set flag again! boolean offline = !old; - sPreferences.edit().putBoolean("offline_mode", offline).commit(); + sPreferences.edit() + .putBoolean("offline_mode", offline) + .apply(); if (offline) { // stop the message center and never start it again MessageCenterService.stop(context); + Kontalk.setBackendEnabled(context, false); } else { + Kontalk.setBackendEnabled(context, true); MessageCenterService.start(context); } @@ -384,7 +481,9 @@ public static boolean switchOfflineMode(Context context) { /** Enable/disable offline mode. */ public static void setOfflineMode(Context context, boolean enabled) { - sPreferences.edit().putBoolean("offline_mode", enabled).commit(); + sPreferences.edit() + .putBoolean("offline_mode", enabled) + .apply(); if (enabled) { // stop the message center and never start it again @@ -395,75 +494,92 @@ public static void setOfflineMode(Context context, boolean enabled) { } } - public static boolean getOfflineMode(Context context) { - return getBoolean(context, "offline_mode", false); + public static boolean getOfflineMode() { + return getBoolean("offline_mode", false); } - public static boolean getOfflineModeUsed(Context context) { - return getBoolean(context, "offline_mode_used", false); + public static boolean getOfflineModeUsed() { + return getBoolean("offline_mode_used", false); } - public static boolean setOfflineModeUsed(Context context) { - return sPreferences.edit() + public static void setOfflineModeUsed() { + sPreferences.edit() .putBoolean("offline_mode_used", true) - .commit(); + .apply(); } public static boolean getSendTyping(Context context) { - return getBoolean(context, "pref_send_typing", true); + return getBoolean("pref_send_typing", context.getResources() + .getBoolean(R.bool.pref_default_send_typing)); } - public static String getDialPrefix(Context context) { - String pref = getString(context, "pref_remove_prefix", null); + public static String getDialPrefix() { + String pref = getString("pref_remove_prefix", null); return (pref != null && !TextUtils.isEmpty(pref.trim())) ? pref: null; } - public static String getPushSenderId(Context context) { - return getString(context, "pref_push_sender", null); + public static String getPushSenderId() { + return getString("pref_push_sender", null); } - public static boolean setPushSenderId(Context context, String senderId) { + public static boolean setPushSenderId(String senderId) { return sPreferences.edit() .putString("pref_push_sender", senderId) .commit(); } public static boolean getAcceptAnyCertificate(Context context) { - return getBoolean(context, "pref_accept_any_certificate", false); + return getBoolean("pref_accept_any_certificate", context.getResources() + .getBoolean(R.bool.pref_default_accept_any_certificate)); + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + public static boolean setAcceptAnyCertificate(boolean acceptAnyCertificate) { + return sPreferences.edit() + .putBoolean("pref_accept_any_certificate", acceptAnyCertificate) + .commit(); } - public static int getIdleTimeMillis(Context context, int minValue, int defaultValue) { - return getIntMinValue(context, "pref_idle_time", minValue, defaultValue); + public static int getIdleTimeMillis(Context context, int minValue) { + return getIntMinValue("pref_idle_time", minValue, context + .getResources().getInteger(R.integer.pref_default_idle_time)); } - public static int getWakeupTimeMillis(Context context, int minValue, int defaultValue) { - return getIntMinValue(context, "pref_wakeup_time", minValue, defaultValue); + public static int getWakeupTimeMillis(Context context, int minValue) { + return getIntMinValue("pref_wakeup_time", minValue, context + .getResources().getInteger(R.integer.pref_default_wakeup_time)); } - public static long getLastConnection(Context context) { - return getLong(context, "pref_last_connection", -1); + public static long getLastConnection() { + return getLong("pref_last_connection", -1); } // TODO why isn't this used? - public static boolean setLastConnection(Context context) { - return sPreferences.edit() + public static void setLastConnection() { + sPreferences.edit() .putLong("pref_last_connection", System.currentTimeMillis()) - .commit(); + .apply(); } public static String getEnterKeyMode(Context context) { try { - return getString(context, "pref_text_enter", "default"); + return getString("pref_text_enter", context + .getString(R.string.pref_default_text_enter)); } catch (ClassCastException e) { // legacy mode - return getBoolean(context, "pref_text_enter", false) ? + return getBoolean("pref_text_enter", false) ? "newline" : "default"; } } - public static String getRosterVersion(Context context) { - return getString(context, "roster_version", ""); + public static boolean getShowBlockedUsers(Context context) { + return getBoolean("pref_show_blocked_users", context + .getResources().getBoolean(R.bool.pref_default_show_blocked_users)); + } + + public static String getRosterVersion() { + return getString("roster_version", ""); } public static boolean setRosterVersion(String version) { @@ -472,10 +588,45 @@ public static boolean setRosterVersion(String version) { .commit(); } + public static boolean isSkipDozeMode() { + return getBoolean("skip_doze_mode", false); + } + + public static boolean setSkipDozeMode(boolean value) { + return sPreferences.edit() + .putBoolean("skip_doze_mode", value) + .commit(); + } + + public static boolean isSkipHuaweiProtectedApps() { + return getBoolean("huawei_skip_protected_apps", false); + } + + public static boolean setSkipHuaweiProtectedApps(boolean value) { + return sPreferences.edit() + .putBoolean("huawei_skip_protected_apps", value) + .commit(); + } + + public static boolean isReportingEnabled(Context context) { + return getBoolean("pref_reporting", context + .getResources().getBoolean(R.bool.pref_default_reporting)); + } + + public static boolean isDebugLogEnabled(Context context) { + return getBoolean("pref_debug_log", context + .getResources().getBoolean(R.bool.pref_default_debug_log)); + } + + public static boolean isServerMessagesEnabled(Context context) { + return getBoolean("pref_server_messages", context + .getResources().getBoolean(R.bool.pref_default_server_messages)); + } + public static long getPingAlarmInterval(Context context, long defaultValue) { String networkType = SystemUtils.getCurrentNetworkName(context); return (networkType != null) ? - getLong(context, "ping_alarm_interval_" + networkType, defaultValue) : + getLong("ping_alarm_interval_" + networkType, defaultValue) : defaultValue; } @@ -489,7 +640,7 @@ public static boolean setPingAlarmInterval(Context context, long intervalMillis) public static long getPingAlarmBackoff(Context context, long defaultValue) { String networkType = SystemUtils.getCurrentNetworkName(context); return (networkType != null) ? - getLong(context, "ping_alarm_backoff_" + networkType, defaultValue) : + getLong("ping_alarm_backoff_" + networkType, defaultValue) : defaultValue; } @@ -500,74 +651,14 @@ public static boolean setPingAlarmBackoff(Context context, long intervalMillis) .commit(); } - /** - * Saves the current registration progress data. Used for recoverying a - * registration after a restart or in very low memory situations. - */ - public static boolean saveRegistrationProgress(Context context, String name, - String phoneNumber, PersonalKey key, String passphrase, - byte[] importedPublicKey, byte[] importedPrivateKey, String serverUri, - String sender) { - return sPreferences.edit() - .putString("registration_name", name) - .putString("registration_phone", phoneNumber) - .putString("registration_key", key.toBase64()) - .putString("registration_importedpublickey", importedPublicKey != null ? - Base64.encodeToString(importedPublicKey, Base64.NO_WRAP) : null) - .putString("registration_importedprivatekey", importedPrivateKey != null ? - Base64.encodeToString(importedPrivateKey, Base64.NO_WRAP) : null) - .putString("registration_passphrase", passphrase) - .putString("registration_server", serverUri) - .putString("registration_sender", sender) - .commit(); - } - - public static RegistrationProgress getRegistrationProgress(Context context) { - String name = getString(context, "registration_name", null); - if (name != null) { - RegistrationProgress p = new RegistrationProgress(); - p.name = name; - p.phone = getString(context, "registration_phone", null); - String serverUri = getString(context, "registration_server", null); - p.server = serverUri != null ? new EndpointServer(serverUri) : null; - p.key = PersonalKey.fromBase64(getString(context, "registration_key", null)); - p.passphrase = getString(context, "registration_passphrase", null); - - String importedPublicKey = getString(context, "registration_importedpublickey", null); - if (importedPublicKey != null) - p.importedPublicKey = Base64.decode(importedPublicKey, Base64.NO_WRAP); - String importedPrivateKey = getString(context, "registration_importedprivatekey", null); - if (importedPrivateKey != null) - p.importedPrivateKey = Base64.decode(importedPrivateKey, Base64.NO_WRAP); - - p.sender = getString(context, "registration_sender", null); - - return p; - } - return null; - } - - public static boolean clearRegistrationProgress(Context context) { - return sPreferences.edit() - .remove("registration_name") - .remove("registration_phone") - .remove("registration_key") - .remove("registration_importedpublickey") - .remove("registration_importedprivatekey") - .remove("registration_passphrase") - .remove("registration_server") - .commit(); + public static boolean isPermissionAsked(String permission) { + return sPreferences.getBoolean("permission_asked_" + permission, false); } - public static final class RegistrationProgress { - public String name; - public String phone; - public PersonalKey key; - public String passphrase; - public byte[] importedPublicKey; - public byte[] importedPrivateKey; - public EndpointServer server; - public String sender; + public static void setPermissionAsked(String permission) { + sPreferences.edit() + .putBoolean("permission_asked_" + permission, true) + .apply(); } /** Recent statuses database helper. */ @@ -582,7 +673,7 @@ private static final class RecentStatusDbHelper extends SQLiteOpenHelper { "timestamp INTEGER" + ")"; - public RecentStatusDbHelper(Context context) { + RecentStatusDbHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); } diff --git a/app/src/main/java/org/kontalk/util/ProgressInputStreamEntity.java b/app/src/main/java/org/kontalk/util/ProgressInputStreamEntity.java index 171f03f13..4bf9d83cb 100644 --- a/app/src/main/java/org/kontalk/util/ProgressInputStreamEntity.java +++ b/app/src/main/java/org/kontalk/util/ProgressInputStreamEntity.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,50 +23,80 @@ import java.io.InputStream; import java.io.OutputStream; -import org.apache.http.entity.InputStreamEntity; +import androidx.annotation.NonNull; + import org.kontalk.service.ProgressListener; import org.kontalk.upload.UploadConnection; -public class ProgressInputStreamEntity extends InputStreamEntity { - protected final UploadConnection mConn; - protected final ProgressListener mListener; +public class ProgressInputStreamEntity { + private static final int BUFFER_SIZE = 1024 * 8; + + private final InputStream mStream; + private final UploadConnection mConn; + private final ProgressListener mListener; + private final long mPublishDelay; - public ProgressInputStreamEntity(InputStream instream, long length, - final UploadConnection conn, final ProgressListener listener) { - super(instream, length); + public ProgressInputStreamEntity(InputStream instream, + final UploadConnection conn, final ProgressListener listener, + final long publishDelay) { + mStream = instream; mConn = conn; mListener = listener; + mPublishDelay = publishDelay; + } + + private void _writeTo(OutputStream outstream) throws IOException { + InputStream instream = mStream; + try { + final byte[] buffer = new byte[BUFFER_SIZE]; + int l; + while ((l = instream.read(buffer)) != -1) { + outstream.write(buffer, 0, l); + } + } + finally { + if (instream != null) { + try { + instream.close(); + } + catch (IOException ignored) { + } + } + } } - @Override - public void writeTo(final OutputStream outstream) throws IOException { + public void writeTo(final OutputStream outstream, long length) throws IOException { mListener.start(mConn); - super.writeTo(new CountingOutputStream(outstream, mConn, mListener)); + _writeTo(new CountingOutputStream(outstream, mConn, mListener, length, mPublishDelay)); } private static final class CountingOutputStream extends FilterOutputStream { private final UploadConnection conn; private final ProgressListener listener; + private final StepTimer publishTimer; + private final long size; private long transferred; - public CountingOutputStream(OutputStream out, UploadConnection conn, - ProgressListener listener) { + CountingOutputStream(OutputStream out, UploadConnection conn, + ProgressListener listener, long size, long publishDelay) { super(out); this.listener = listener; this.conn = conn; + this.size = size; + this.publishTimer = new StepTimer(publishDelay); this.transferred = 0; } @Override - public void write(byte[] buffer) throws IOException { + public void write(@NonNull byte[] buffer) throws IOException { out.write(buffer); publishProgress(buffer.length); } @Override - public void write(byte[] b, int off, int len) throws IOException { + public void write(@NonNull byte[] b, int off, int len) throws IOException { out.write(b, off, len); publishProgress(len); } @@ -79,7 +109,8 @@ public void write(int b) throws IOException { private void publishProgress(long add) { this.transferred += add; - this.listener.progress(conn, this.transferred); + if (this.transferred >= this.size || publishTimer.isStep(true)) + this.listener.progress(conn, this.transferred); } } diff --git a/app/src/main/java/org/kontalk/util/ProgressOutputStreamEntity.java b/app/src/main/java/org/kontalk/util/ProgressOutputStreamEntity.java index d4f291934..ef2afb1f5 100644 --- a/app/src/main/java/org/kontalk/util/ProgressOutputStreamEntity.java +++ b/app/src/main/java/org/kontalk/util/ProgressOutputStreamEntity.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,98 +23,87 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.HttpURLConnection; + +import androidx.annotation.NonNull; -import org.apache.http.Header; -import org.apache.http.HttpEntity; import org.kontalk.service.DownloadListener; -public class ProgressOutputStreamEntity implements HttpEntity { +public class ProgressOutputStreamEntity { + private static final int BUFFER_SIZE = 10240 * 10; - private final HttpEntity mParent; + private final HttpURLConnection mParent; private final String mUrl; private final File mFile; private final DownloadListener mListener; + private final long mPublishDelay; - public ProgressOutputStreamEntity(HttpEntity parent, - String url, File file, final DownloadListener listener) { + public ProgressOutputStreamEntity(HttpURLConnection parent, + String url, File file, final DownloadListener listener, + final long publishDelay) { mParent = parent; mUrl = url; mFile = file; mListener = listener; + mPublishDelay = publishDelay; } - @Override - public void consumeContent() throws IOException { - mParent.consumeContent(); - } - - @Override - public InputStream getContent() throws IOException, IllegalStateException { - return mParent.getContent(); - } - - @Override - public Header getContentEncoding() { - return mParent.getContentEncoding(); - } - - @Override - public long getContentLength() { - return mParent.getContentLength(); - } - - @Override - public Header getContentType() { - return mParent.getContentType(); - } - - @Override - public boolean isChunked() { - return mParent.isChunked(); - } - - @Override - public boolean isRepeatable() { - return mParent.isRepeatable(); - } - - @Override - public boolean isStreaming() { - return mParent.isStreaming(); + private void _writeTo(OutputStream outstream) throws IOException { + InputStream instream = mParent.getInputStream(); + try { + final byte[] buffer = new byte[BUFFER_SIZE]; + int l; + while ((l = instream.read(buffer)) != -1) { + outstream.write(buffer, 0, l); + } + } + finally { + if (instream != null) { + try { + instream.close(); + } + catch (IOException ignored) { + } + } + } } - @Override public void writeTo(OutputStream outstream) throws IOException { mListener.start(mUrl, mFile, mParent.getContentLength()); - mParent.writeTo(new CountingOutputStream(outstream, mUrl, mFile, mListener)); - Header mime = mParent.getContentType(); - mListener.completed(mUrl, mime != null ? mime.getValue() : null, mFile); + _writeTo(new CountingOutputStream(outstream, mUrl, mFile, mListener, mParent.getContentLength(), mPublishDelay)); + String mime = mParent.getContentType(); + mListener.completed(mUrl, mime, mFile); } private static final class CountingOutputStream extends FilterOutputStream { private final DownloadListener listener; private final String url; private final File file; + private final StepTimer publishTimer; + private final long size; private long transferred; - public CountingOutputStream(final OutputStream out, - final String url, final File file, final DownloadListener listener) { + CountingOutputStream(final OutputStream out, + final String url, final File file, final DownloadListener listener, + long size, long publishDelay) { super(out); this.url = url; this.file = file; this.listener = listener; + this.size = size; + this.publishTimer = new StepTimer(publishDelay); this.transferred = 0; } @Override - public void write(byte[] buffer) throws IOException { + public void write(@NonNull byte[] buffer) throws IOException { out.write(buffer); publishProgress(buffer.length); } @Override - public void write(byte[] b, int off, int len) throws IOException { + public void write(@NonNull byte[] b, int off, int len) throws IOException { out.write(b, off, len); publishProgress(len); } @@ -127,7 +116,8 @@ public void write(int b) throws IOException { private void publishProgress(long add) { this.transferred += add; - this.listener.progress(url, file, this.transferred); + if (this.transferred >= this.size || publishTimer.isStep(true)) + this.listener.progress(url, file, this.transferred); } } diff --git a/app/src/main/java/org/kontalk/util/ProximityScreenLocker.java b/app/src/main/java/org/kontalk/util/ProximityScreenLocker.java new file mode 100644 index 000000000..28e8e3b36 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/ProximityScreenLocker.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2014 The Simlar Authors. + * + * This file is part of Simlar. (https://www.simlar.org) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package org.kontalk.util; + + +public interface ProximityScreenLocker +{ + void acquire(); + + /// Not immediately means we wait till the proximity sensor signals that the user is not near anymore + @SuppressWarnings("SameParameterValue") + void release(final boolean immediately); +} diff --git a/app/src/main/java/org/kontalk/util/ProximityScreenLockerFallback.java b/app/src/main/java/org/kontalk/util/ProximityScreenLockerFallback.java new file mode 100644 index 000000000..1c772da5d --- /dev/null +++ b/app/src/main/java/org/kontalk/util/ProximityScreenLockerFallback.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2014 The Simlar Authors. + * + * This file is part of Simlar. (https://www.simlar.org) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package org.kontalk.util; + +import android.app.Activity; +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.os.Build; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; + +import org.kontalk.Log; + + +public class ProximityScreenLockerFallback implements ProximityScreenLocker, SensorEventListener { + private static final String TAG = ProximityScreenLocker.class.getSimpleName(); + private static final float PROXIMITY_DISTANCE_THRESHOLD = 4.0f; + + private final Activity mActivity; + // handled by caller + //private final SensorManager mSensorManager; + + public ProximityScreenLockerFallback(final Activity activity) + { + mActivity = activity; + // handled by caller + //mSensorManager = (SensorManager) mActivity.getSystemService(Context.SENSOR_SERVICE); + } + + @Override + public final void acquire() + { + // handled by caller + //mSensorManager.registerListener(this, mSensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY), SensorManager.SENSOR_DELAY_NORMAL); + } + + @Override + public final void release(final boolean immediately) + { + // handler by caller + //mSensorManager.unregisterListener(this); + /// TODO: handle immediately + } + + private void showNavigationBar(final boolean visible) + { + mActivity.getWindow().getDecorView() + .setSystemUiVisibility(visible ? + View.SYSTEM_UI_FLAG_VISIBLE : View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); + } + + // + // SensorEventListener overloaded member functions + // + @Override + public void onAccuracyChanged(final Sensor sensor, final int accuracy) + { + } + + @Override + public final void onSensorChanged(final SensorEvent event) + { + final float distance = event.values[0]; + + if (distance > event.sensor.getMaximumRange()) { + Log.v(TAG, "proximity sensors distance=" + distance + " out of range=" + event.sensor.getMaximumRange()); + /// do not return, e.g. the galaxy s2 won't work otherwise + } + + final Window window = mActivity.getWindow(); + @SuppressWarnings("UnnecessaryFullyQualifiedName") /// this would require to 'import android.R' + final View view = ((ViewGroup) mActivity.findViewById(android.R.id.content)).getChildAt(0); + + final WindowManager.LayoutParams params = window.getAttributes(); + if (distance <= PROXIMITY_DISTANCE_THRESHOLD) { + Log.v(TAG, "proximity sensors distance=" + distance + " below threshold=" + PROXIMITY_DISTANCE_THRESHOLD + + " => dimming screen in order to disable touch events"); + params.screenBrightness = 0.1f; + view.setVisibility(View.INVISIBLE); + showNavigationBar(false); + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } else { + Log.v(TAG, "proximity sensors distance=" + distance + " above threshold=" + PROXIMITY_DISTANCE_THRESHOLD + + " => enabling touch events (no screen dimming)"); + params.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE; + view.setVisibility(View.VISIBLE); + showNavigationBar(true); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + window.setAttributes(params); + } +} diff --git a/app/src/main/java/org/kontalk/util/ProximityScreenLockerNative.java b/app/src/main/java/org/kontalk/util/ProximityScreenLockerNative.java new file mode 100644 index 000000000..85b2ec29b --- /dev/null +++ b/app/src/main/java/org/kontalk/util/ProximityScreenLockerNative.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2014 The Simlar Authors. + * + * This file is part of Simlar. (https://www.simlar.org) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package org.kontalk.util; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.os.PowerManager; + +import org.kontalk.Log; + + +/** + * This class capsules the undocumented native proximity screen off wake lock. + */ +public final class ProximityScreenLockerNative implements ProximityScreenLocker { + private static final String TAG = ProximityScreenLocker.class.getSimpleName(); + private final PowerManager.WakeLock mProximityWakeLock; + private final Method mPowerLockReleaseMethod; + + public static ProximityScreenLocker create(final Context context) + { + final PowerManager.WakeLock proximityWakeLock = initProximitySensor(context); + + if (proximityWakeLock == null) { + return null; + } + + return new ProximityScreenLockerNative(proximityWakeLock); + } + + private ProximityScreenLockerNative(final PowerManager.WakeLock proximityWakeLock) + { + mProximityWakeLock = proximityWakeLock; + mPowerLockReleaseMethod = initPowerLockReleaseMethod(mProximityWakeLock); + } + + private static PowerManager.WakeLock initProximitySensor(final Context context) + { + final PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + try { + final int proximityScreenOffWakeLock = (Integer) PowerManager.class + .getDeclaredField("PROXIMITY_SCREEN_OFF_WAKE_LOCK").get(null); + + if (!checkNativeSupport(powerManager, proximityScreenOffWakeLock)) { + return null; + } + + return powerManager.newWakeLock(proximityScreenOffWakeLock, "Kontalk:SimlarProximityWakeLock"); + } + catch (final NoSuchFieldException ex) { + Log.w(TAG, "NoSuchFieldException while accessing PROXIMITY_SCREEN_OFF_WAKE_LOCK"); + } + catch (final IllegalAccessException ex) { + Log.w(TAG, "IllegalAccessException while accessing PROXIMITY_SCREEN_OFF_WAKE_LOCK"); + } + return null; + } + + private static boolean checkNativeSupport(final PowerManager powerManager, final int proximityScreenOffWakeLock) + { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + final Method method = powerManager.getClass().getDeclaredMethod("isWakeLockLevelSupported", int.class); + return (Boolean) method.invoke(powerManager, proximityScreenOffWakeLock); + } + + final Method method = powerManager.getClass().getDeclaredMethod("getSupportedWakeLockFlags"); + final int supportedFlags = (Integer) method.invoke(powerManager); + return (supportedFlags & proximityScreenOffWakeLock) != 0x0; + } + catch (final NoSuchMethodException ex) { + Log.w(TAG, "NoSuchMethodException while checking native support"); + } + catch (final IllegalAccessException ex) { + Log.w(TAG, "IllegalAccessException while checking native support"); + } + catch (final InvocationTargetException ex) { + Log.w(TAG, "InvocationTargetException while checking native support"); + } + return false; + } + + private static Method initPowerLockReleaseMethod(final PowerManager.WakeLock proximityWakeLock) + { + if (proximityWakeLock == null) { + return null; + } + + try { + return proximityWakeLock.getClass().getDeclaredMethod("release", int.class); + } + catch (final NoSuchMethodException ex) { + Log.w(TAG, "NoSuchMethodException release"); + return null; + } + } + + @SuppressLint("WakelockTimeout") + @Override + public void acquire() + { + if (mProximityWakeLock == null) { + return; + } + + if (mProximityWakeLock.isHeld()) { + Log.v(TAG, "acquire triggered but already acquired"); + return; + } + + Log.v(TAG, "acquiring"); + mProximityWakeLock.acquire(); + } + + @Override + public void release(final boolean immediately) + { + if (mProximityWakeLock == null) { + return; + } + + if (!mProximityWakeLock.isHeld()) { + Log.v(TAG, "release triggered but not held"); + return; + } + + if (releaseWithNativeFunction(immediately)) { + Log.v(TAG, "released using native function"); + } + else { + mProximityWakeLock.release(); + Log.v(TAG, "released using old function"); + } + } + + private boolean releaseWithNativeFunction(final boolean immediately) + { + if (mPowerLockReleaseMethod == null) { + return false; + } + + try { + mPowerLockReleaseMethod.invoke(mProximityWakeLock, immediately ? 0 : 1); + return true; + } + catch (final IllegalAccessException ex) { + Log.w(TAG, "IllegalAccessException calling native release method"); + } + catch (final IllegalArgumentException ex) { + Log.w(TAG, "IllegalArgumentException calling native release method"); + } + catch (final InvocationTargetException ex) { + Log.w(TAG, "InvocationTargetException calling native release method"); + } + + return false; + } +} diff --git a/app/src/main/java/org/kontalk/util/RecyclerItemClickListener.java b/app/src/main/java/org/kontalk/util/RecyclerItemClickListener.java new file mode 100644 index 000000000..1f33cc9a5 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/RecyclerItemClickListener.java @@ -0,0 +1,62 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import android.content.Context; +import androidx.recyclerview.widget.RecyclerView; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; + +public class RecyclerItemClickListener implements RecyclerView.OnItemTouchListener { + private OnItemClickListener mListener; + + public interface OnItemClickListener { + void onItemClick(View view, int position); + } + + GestureDetector mGestureDetector; + + public RecyclerItemClickListener(Context context, OnItemClickListener listener) { + mListener = listener; + mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onSingleTapUp(MotionEvent e) { + return true; + } + }); + } + + @Override + public boolean onInterceptTouchEvent(RecyclerView view, MotionEvent e) { + View childView = view.findChildViewUnder(e.getX(), e.getY()); + if (childView != null && mListener != null && mGestureDetector.onTouchEvent(e)) { + mListener.onItemClick(childView, view.getChildAdapterPosition(childView)); + } + return false; + } + + @Override + public void onTouchEvent(RecyclerView view, MotionEvent motionEvent) { + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { + } +} diff --git a/app/src/main/java/org/kontalk/util/RotatingFileWriter.java b/app/src/main/java/org/kontalk/util/RotatingFileWriter.java new file mode 100644 index 000000000..f6794e802 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/RotatingFileWriter.java @@ -0,0 +1,150 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.Flushable; +import java.io.IOException; + + +/** + * A simplified FileWriter capable of rotating a file after a given + * amount of bytes have been written to the underlying file. + * @author Daniele Ricci + */ +public class RotatingFileWriter implements Flushable, Closeable { + + private static final String LINE_SEPARATOR = System.getProperty("line.separator"); + + /** Rotate at this amount of bytes. */ + private static final long ROTATE_AT = 1048576; + /** How many old lines to delete when rotating. */ + private static final int DELETE_OLD = 100; + + private final long mRotateAt; + private final long mDeleteOld; + private final File mLogFile; + private FileWriter mWriter; + /** Caches the amount of bytes written in the current file. */ + private long mSize; + + public RotatingFileWriter(File logFile) throws IOException { + this(logFile, ROTATE_AT, DELETE_OLD); + } + + public RotatingFileWriter(File logFile, long rotateAt, int deleteOld) throws IOException { + super(); + mLogFile = logFile; + mWriter = new FileWriter(logFile, true); + mSize = mLogFile.length(); + mRotateAt = rotateAt; + mDeleteOld = deleteOld; + } + + @Override + public synchronized void flush() throws IOException { + mWriter.flush(); + } + + @Override + public synchronized void close() throws IOException { + mWriter.close(); + mWriter = null; + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + public synchronized void abort() throws IOException { + close(); + mLogFile.delete(); + } + + private void write(String str) throws IOException { + mWriter.write(str); + mSize += str.length(); + } + + private void newLine() throws IOException { + write(LINE_SEPARATOR); + } + + private void print(String s) throws IOException { + if (s == null) { + s = "null"; + } + write(s); + } + + public synchronized void println(String x) throws IOException { + print(x); + newLine(); + checkRotate(); + } + + private void checkRotate() throws IOException { + if (mSize >= mRotateAt) { + mWriter.close(); + if (!rotate()) + throw new IOException("Unable to rotate log file"); + mWriter = new FileWriter(mLogFile, true); + } + } + + private boolean rotate() throws IOException { + // rename the current file + File oldFile = new File(mLogFile.toString() + ".old"); + File newFile = new File(mLogFile.toString()); + if (newFile.renameTo(oldFile)) { + BufferedReader oldLog = null; + FileWriter rotatedLog = null; + try { + // open the old file and start writing lines + oldLog = new BufferedReader(new FileReader(oldFile)); + rotatedLog = new FileWriter(mLogFile.toString()); + long lineCount = 0; + String line; + while ((line = oldLog.readLine()) != null) { + lineCount++; + // skip up to DELETE_OLD lines, then start writing lines + if (lineCount > mDeleteOld) { + rotatedLog.write(line); + rotatedLog.write(LINE_SEPARATOR); + } + } + } + finally { + DataUtils.close(oldLog); + DataUtils.close(rotatedLog); + oldFile.delete(); + } + + mSize = mLogFile.length(); + return true; + } + else { + // we couldn't rename the old log file + // we just delete it to make space for a new one + return mLogFile.delete(); + } + } + +} diff --git a/app/src/main/java/org/kontalk/util/RunnableBroadcastReceiver.java b/app/src/main/java/org/kontalk/util/RunnableBroadcastReceiver.java index f58700f2d..b0f328ae7 100644 --- a/app/src/main/java/org/kontalk/util/RunnableBroadcastReceiver.java +++ b/app/src/main/java/org/kontalk/util/RunnableBroadcastReceiver.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -57,7 +57,7 @@ public void run() { } public interface ActionRunnable { - public void run(String action); + void run(String action); } } diff --git a/app/src/main/java/org/kontalk/util/Showcase.kt b/app/src/main/java/org/kontalk/util/Showcase.kt new file mode 100644 index 000000000..38d2e737c --- /dev/null +++ b/app/src/main/java/org/kontalk/util/Showcase.kt @@ -0,0 +1,176 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util + +import android.graphics.Rect +import android.view.View +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.Fragment +import com.getkeepsafe.taptargetview.TapTarget +import com.getkeepsafe.taptargetview.TapTargetView +import com.github.clans.fab.FloatingActionMenu +import org.kontalk.R +import org.kontalk.ui.ComposeMessageFragment +import org.kontalk.ui.ConversationsFragment +import org.kontalk.ui.GroupMessageFragment + + +class Showcase { + + private open class Hint(open val key: String, open val view: Int, open val title: Int, open val message: Int) + private class FloatingActionMenuHint(override val key: String, override val view: Int, override val title: Int, override val message: Int): + Hint(key, view, title, message) + private class ViewHint(override val key: String, override val view: Int, override val title: Int, override val message: Int): + Hint(key, view, title, message) + // FIXME this one still doesn't work properly + private class MenuHint(override val key: String, override val view: Int, override val title: Int, override val message: Int): + Hint(key, view, title, message) { + val toolbarViewId = R.id.toolbar + } + private class ToolbarHint(override val key: String, override val title: Int, override val message: Int): + Hint(key, R.id.toolbar, title, message) + + companion object { + + private val HINTS: MutableMap> = HashMap() + + init { + HINTS[ConversationsFragment::class.java.name] = arrayOf( + FloatingActionMenuHint("fab", R.id.action, + R.string.showcase_conversations_compose_title, + R.string.showcase_conversations_compose_message), + MenuHint("dark_theme", R.id.menu_settings, + R.string.showcase_conversations_dark_mode_title, + R.string.showcase_conversations_dark_mode_message) + ) + HINTS[ComposeMessageFragment::class.java.name] = arrayOf( + ViewHint("attach", R.id.attach_button, + R.string.showcase_composer_attach_title, + R.string.showcase_composer_attach_message), + ToolbarHint("contact_info", + R.string.showcase_composer_toolbar_single_title, + R.string.showcase_composer_toolbar_single_message) + ) + HINTS[GroupMessageFragment::class.java.name] = arrayOf( + ToolbarHint("group_info", + R.string.showcase_composer_toolbar_group_title, + R.string.showcase_composer_toolbar_group_message) + ) + } + + /** + * Checks if the given hint was already showed by looking in preferences. + */ + private fun isHintShowed(parent: String, hint: Hint): Boolean { + return Preferences.getShowcaseShowed(parent, hint.key) + } + + private fun setHintShowed(parent: String, hint: Hint): Boolean { + return Preferences.setShowcaseShowed(parent, hint.key, true) + } + + fun resetAllHints() { + HINTS.forEach { hints -> + hints.value.forEach { hint -> + Preferences.setShowcaseShowed(hints.key, hint.key, false) + } + } + } + + @JvmStatic + fun disableAllHints() { + HINTS.forEach { hints -> + hints.value.forEach { hint -> + setHintShowed(hints.key, hint) + } + } + } + + /** + * Show the next proper showcase in the given fragment. + * Call this from {@link Fragment#onResume()} on {@link Fragment#onStart()} + * @param fragment a fragment that was just opened + */ + @JvmStatic + fun showNextHint(fragment: Fragment) { + val parent = fragment::class.java.name + HINTS[parent]?.let { hints -> + val nextHint = hints.find { !isHintShowed(parent, it) } + + nextHint?.let { hint -> + val view: View? = when (hint) { + is FloatingActionMenuHint -> { + val fab = fragment.view?.findViewById(hint.view) + fab?.menuIconView + } + is ViewHint -> { + fragment.view?.findViewById(hint.view) + } + is ToolbarHint -> { + fragment.activity?.findViewById(hint.view) + } + is MenuHint -> { + fragment.activity?.findViewById(hint.toolbarViewId) + } + else -> null + } + // for when we'll need it... + val rect: Rect? = null + val menu: Int = when (hint) { + is MenuHint -> { + hint.view + } + else -> 0 + } + + val targetConfig: TapTarget? = when { + view != null && menu == 0 -> { + TapTarget.forView(view, + fragment.getString(hint.title), fragment.getString(hint.message)) + } + rect != null -> { + TapTarget.forBounds(rect, + fragment.getString(hint.title), fragment.getString(hint.message)) + } + menu > 0 -> { + TapTarget.forToolbarOverflow(view as Toolbar, + fragment.getString(hint.title), fragment.getString(hint.message)) + } + else -> { + null + } + } + + targetConfig?.let { + TapTargetView.showFor(fragment.activity, it + .transparentTarget(true) + .cancelable(true) + .textColor(R.color.showcase_text_color) + // TODO other settings + .targetRadius(60) + ) + } + setHintShowed(parent, hint) + } + } + } + + } + +} diff --git a/app/src/main/java/org/kontalk/util/StepTimer.java b/app/src/main/java/org/kontalk/util/StepTimer.java index 9af3bd3a9..f5d7dd845 100644 --- a/app/src/main/java/org/kontalk/util/StepTimer.java +++ b/app/src/main/java/org/kontalk/util/StepTimer.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -35,12 +35,23 @@ public class StepTimer { /** Delay accumulator. */ private long mDelay; + /** The delay for the step in milliseconds. */ public StepTimer(long delay) { mMinDelay = delay; + reset(); } public void reset() { mTimestamp = TimeUnit.NANOSECONDS.toMillis(System.nanoTime()); + mDelay = 0; + } + + /** Returns true if the defined step delay has passed, optionally resetting the timer if that's true. */ + public boolean isStep(boolean resetIfPassed) { + boolean r = isStep(); + if (r && resetIfPassed) + reset(); + return r; } /** Returns true if the defined step delay has passed. */ diff --git a/app/src/main/java/org/kontalk/util/SystemUtils.java b/app/src/main/java/org/kontalk/util/SystemUtils.java index 08ed2321c..69c251924 100644 --- a/app/src/main/java/org/kontalk/util/SystemUtils.java +++ b/app/src/main/java/org/kontalk/util/SystemUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,19 +18,50 @@ package org.kontalk.util; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import android.annotation.SuppressLint; import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ContentResolver; import android.content.Context; +import android.content.Intent; import android.content.pm.ActivityInfo; -import android.content.pm.PackageInfo; import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; import android.content.res.Configuration; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.graphics.Point; import android.net.ConnectivityManager; import android.net.NetworkInfo; +import android.net.Uri; +import android.os.Build; +import android.os.PowerManager; +import android.provider.ContactsContract; +import androidx.annotation.AttrRes; +import androidx.annotation.ColorRes; +import androidx.core.content.ContextCompat; +import android.text.style.CharacterStyle; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.util.TypedValue; import android.view.Display; import android.view.Surface; import android.view.WindowManager; +import android.widget.Toast; + +import org.kontalk.BuildConfig; +import org.kontalk.Kontalk; +import org.kontalk.Log; +import org.kontalk.R; + /** * System-related utilities. @@ -38,34 +69,36 @@ */ public final class SystemUtils { + private static final Pattern VERSION_CODE_MATCH = Pattern + .compile("\\(([0-9]+)\\)$"); + private SystemUtils() { } - public static PackageInfo getPackageInfo(Context context) throws PackageManager.NameNotFoundException { - return context.getPackageManager() - .getPackageInfo(context.getPackageName(), 0); - } + public static boolean isOlderVersion(Context context, String version) { + Matcher m = VERSION_CODE_MATCH.matcher(version); + if (m.find() && m.groupCount() > 0) { + try { + int versionCode = Integer.parseInt(m.group(1)); + int currentVersion = getVersionCode(); + return versionCode < currentVersion; + } + catch (Exception ignored) { + } - public static String getVersionName(Context context) { - try { - PackageInfo pInfo = SystemUtils.getPackageInfo(context); - return pInfo.versionName; - } - catch (PackageManager.NameNotFoundException e) { - // shouldn't happen - return null; } + + // no version code found at the end - assume older version + return true; } - public static int getVersionCode(Context context) { - try { - PackageInfo pInfo = SystemUtils.getPackageInfo(context); - return pInfo.versionCode; - } - catch (PackageManager.NameNotFoundException e) { - // shouldn't happen - return 0; - } + public static int getVersionCode() { + return BuildConfig.VERSION_CODE; + } + + public static String getVersionFullName(Context context) { + return context.getString(R.string.about_version, + BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE); } public static Point getDisplaySize(Context context) { @@ -74,12 +107,7 @@ public static Point getDisplaySize(Context context) { Display display = manager.getDefaultDisplay(); if (display != null) { displaySize = new Point(); - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.HONEYCOMB_MR2) { - displaySize.set(display.getWidth(), display.getHeight()); - } - else { - display.getSize(displaySize); - } + display.getSize(displaySize); } return displaySize; @@ -137,6 +165,16 @@ public static int getScreenOrientation(Activity activity) { return ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; } + public static void acquireScreenOn(Activity activity) { + activity.getWindow().addFlags(WindowManager + .LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + public static void releaseScreenOn(Activity activity) { + activity.getWindow().clearFlags(WindowManager + .LayoutParams.FLAG_KEEP_SCREEN_ON); + } + /** Returns the type name of the current network, or null. */ public static String getCurrentNetworkName(Context context) { ConnectivityManager connMgr = (ConnectivityManager) context @@ -158,4 +196,212 @@ public static boolean isOnWifi(Context context) { return getCurrentNetworkType(context) == ConnectivityManager.TYPE_WIFI; } + /** Checks for network availability. */ + public static boolean isNetworkConnectionAvailable(Context context) { + final ConnectivityManager cm = (ConnectivityManager) context + .getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm.getBackgroundDataSetting()) { + NetworkInfo info = cm.getActiveNetworkInfo(); + if (info != null && info.getState() == NetworkInfo.State.CONNECTED) + return true; + } + + return false; + } + + public static Bitmap getProfilePhoto(Context context) { + // profile photo is available only since API level 14 + ContentResolver cr = context.getContentResolver(); + InputStream input = ContactsContract.Contacts + .openContactPhotoInputStream(cr, ContactsContract.Profile.CONTENT_URI); + if (input != null) { + try { + return BitmapFactory.decodeStream(input); + } + finally { + DataUtils.close(input); + } + } + + return null; + } + + /** + * Returns the system profile Uri. This will be replaced by the users's + * personal avatar - when there will be such a feature. + * @param context not used really + * @return the profile Uri + */ + public static Uri getProfileUri(Context context) { + return ContactsContract.Profile.CONTENT_URI; + } + + public static Uri lookupPhoneNumber(Context context, String phoneNumber) { + Uri uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, + Uri.encode(phoneNumber)); + Cursor cur = context.getContentResolver().query(uri, + new String[] { ContactsContract.PhoneLookup._ID, + ContactsContract.PhoneLookup.LOOKUP_KEY }, + null, null, null); + if (cur != null) { + try { + if (cur.moveToNext()) { + long id = cur.getLong(0); + String lookupKey = cur.getString(1); + return ContactsContract.Contacts.getLookupUri(id, lookupKey); + } + } + finally { + cur.close(); + } + } + + return null; + } + + public static void openURL(Context context, String url) { + try { + context.startActivity(externalIntent(Intent.ACTION_VIEW, + Uri.parse(url))); + } + catch (ActivityNotFoundException e) { + Toast.makeText(context, R.string.chooser_error_no_browser, + Toast.LENGTH_LONG).show(); + } + } + + public static void call(final Context context, final CharSequence phone) { + try { + context.startActivity(externalIntent(Intent.ACTION_CALL, + Uri.parse("tel:" + phone))); + } + catch (ActivityNotFoundException e) { + Toast.makeText(context, R.string.chooser_error_no_dialer, + Toast.LENGTH_LONG).show(); + } + } + + public static void dial(Context context, CharSequence phone) { + try { + context.startActivity(externalIntent(Intent.ACTION_DIAL, + Uri.parse("tel:" + phone))); + } + catch (ActivityNotFoundException e) { + Toast.makeText(context, R.string.chooser_error_no_dialer, + Toast.LENGTH_LONG).show(); + } + } + + public static boolean isCallable(Context context, Intent intent) { + List list = context.getPackageManager().queryIntentActivities(intent, + PackageManager.MATCH_DEFAULT_ONLY); + return list.size() > 0; + } + + public static Intent externalIntent(String action) { + return externalIntent(action, null); + } + + public static Intent externalIntent(String action, Uri data) { + Intent i = new Intent(action, data); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + i.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); + } + else { + i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); + } + return i; + } + + public static String getUserSerial(Context context) { + //noinspection ResourceType + @SuppressLint("WrongConstant") + Object userManager = context.getSystemService("user"); + if (null == userManager) return ""; + + try { + Method myUserHandleMethod = android.os.Process.class.getMethod("myUserHandle", (Class[]) null); + Object myUserHandle = myUserHandleMethod.invoke(android.os.Process.class, (Object[]) null); + Method getSerialNumberForUser = userManager.getClass().getMethod("getSerialNumberForUser", myUserHandle.getClass()); + Long userSerial = (Long) getSerialNumberForUser.invoke(userManager, myUserHandle); + if (userSerial != null) { + return String.valueOf(userSerial); + } else { + return ""; + } + } + catch (Exception ignored) { + } + return ""; + } + + public static CharacterStyle getColoredSpan(Context context, @ColorRes int colorResId) { + return new ForegroundColorSpan(ContextCompat.getColor(context, colorResId)); + } + + public static CharacterStyle getTypefaceSpan(int typeface) { + return new StyleSpan(typeface); + } + + public static int getThemedResource(Context context, @AttrRes int attrResId) { + TypedValue value = new TypedValue(); + if (!context.getTheme().resolveAttribute(attrResId, value, true)) + throw new Resources.NotFoundException(); + return value.resourceId; + } + + public static ProximityScreenLocker createProximityScreenLocker(final Activity activity) + { + final ProximityScreenLocker proximityScreenLockerNative = ProximityScreenLockerNative.create(activity); + if (proximityScreenLockerNative == null) { + Log.d(Kontalk.TAG, "native proximity screen locking is not supported => using fallback"); + return new ProximityScreenLockerFallback(activity); + } + + Log.d(Kontalk.TAG, "native proximity screen locking is supported"); + return proximityScreenLockerNative; + } + + public static PowerManager.WakeLock createPartialWakeLock(Context context, String tag, boolean referenceCounted) { + PowerManager pwm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + PowerManager.WakeLock lock = pwm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag); + lock.setReferenceCounted(referenceCounted); + return lock; + } + + public static boolean isIgnoringBatteryOptimizations(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) + return true; + + PowerManager pwm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + return pwm.isIgnoringBatteryOptimizations(context.getPackageName()); + } + + public static boolean supportsJobScheduler() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP; + } + + public static boolean supportsMultiWindow() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N; + } + + /** Return true if the platform can and will broadcast network state change *implicit* intents. */ + public static boolean isReceivingNetworkStateChanges() { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.N; + } + + public static boolean supportsScopedStorage() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + } + + public static boolean supportsNativeNightMode() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + } + + public static boolean isLightTheme(Context context) { + TypedValue value = new TypedValue(); + return context.getTheme().resolveAttribute(R.attr.isLightTheme, value, true) + && value.data != 0; + } + } diff --git a/app/src/main/java/org/kontalk/util/ViewUtils.java b/app/src/main/java/org/kontalk/util/ViewUtils.java new file mode 100644 index 000000000..3b28633c8 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/ViewUtils.java @@ -0,0 +1,182 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import java.util.HashMap; +import java.util.Map; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import com.vanniktech.emoji.EmojiManager; + +import org.jivesoftware.smack.util.Async; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Point; +import android.text.SpannableStringBuilder; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewTreeObserver; +import android.widget.TextView; + +import androidx.core.widget.TextViewCompat; + +import org.kontalk.R; + + +/** + * View utilities + * @author andreacappelli + */ +public class ViewUtils { + + public static int dp(Context context, float value) { + if (value == 0) { + return 0; + } + return (int) Math.ceil(context.getResources().getDisplayMetrics().density * value); + } + + /** + * Waits for global layout to happen and set a QR code for the minimum size + * between height and width of the given container. The QR code will be + * returned to the given listener. + */ + public static void getQRCodeBitmapAsync(final Context context, final View container, final String text, final OnQRCodeGeneratedListener listener) { + // no need to handle memory leaks because the view tree observer + // is created on every activity restart + container.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int size = Math.min(container.getHeight(), container.getWidth()); + if (size == 0) { + // try display size as last resort + Point displaySize = SystemUtils.getDisplaySize(context); + size = Math.min(displaySize.x, displaySize.y); + } + + final int qrSize = size; + Async.go(new Runnable() { + @Override + public void run() { + try { + final Bitmap qrCode = getQRCodeBitmap(qrSize, text); + container.post(new Runnable() { + @Override + public void run() { + listener.onQRCodeGenerated(qrCode); + } + }); + } + catch (WriterException e) { + listener.onQRCodeError(e); + } + } + }); + } + }); + } + + public static Bitmap getQRCodeBitmap(int size, String text) throws WriterException { + QRCodeWriter writer = new QRCodeWriter(); + Map hints = new HashMap<>(); + hints.put(EncodeHintType.MARGIN, 2); + BitMatrix matrix = writer.encode(text, BarcodeFormat.QR_CODE, size, size, hints); + return toBitmap(matrix); + } + + public interface OnQRCodeGeneratedListener { + void onQRCodeGenerated(Bitmap qrCode); + + void onQRCodeError(Exception e); + } + + /** + * Writes the given Matrix on a new Bitmap object. + * http://codeisland.org/2013/generating-qr-codes-with-zxing/ + * @param matrix the matrix to write. + * @return the new {@link Bitmap}-object. + */ + private static Bitmap toBitmap(BitMatrix matrix){ + int height = matrix.getHeight(); + int width = matrix.getWidth(); + Bitmap bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); + for (int x = 0; x < width; x++){ + for (int y = 0; y < height; y++){ + bmp.setPixel(x, y, matrix.get(x, y) ? Color.BLACK : Color.WHITE); + } + } + return bmp; + } + + public static CharSequence injectEmojis(TextView view, CharSequence text) { + final SpannableStringBuilder emojiSpannable = new SpannableStringBuilder(text); + final Paint.FontMetrics fontMetrics = view.getPaint().getFontMetrics(); + final float defaultEmojiSize = fontMetrics.descent - fontMetrics.ascent; + EmojiManager.getInstance().replaceWithImages(view.getContext(), + emojiSpannable, defaultEmojiSize, defaultEmojiSize); + return emojiSpannable; + } + + /** + * Sets the text style based on the text size user preference. + * @param textView the view to apply the style + * @param applyBaseTheme true to apply a base TextAppearance first + * @deprecated applyBaseTheme should not exist, a style is the solution here + */ + @Deprecated + public static void setMessageBodyTextStyle(TextView textView, boolean applyBaseTheme) { + Context context = textView.getContext(); + String size = Preferences.getFontSize(context); + int sizeId; + switch (size) { + case "small": + sizeId = R.dimen.message_font_size_small; + break; + case "large": + sizeId = R.dimen.message_font_size_large; + break; + default: + sizeId = R.dimen.message_font_size_normal; + break; + } + + if (applyBaseTheme) { + // set a baseline theme + int[] attrs = {android.R.attr.textAppearance}; + TypedArray ta = context.getTheme().obtainStyledAttributes(R.style.AppTheme, attrs); + TextViewCompat.setTextAppearance(textView, ta.getResourceId(0, 0)); + ta.recycle(); + } + + // now apply the text size + float textSize = context.getResources().getDimension(sizeId); + if (textSize > 0) { + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); + } + } + +} diff --git a/app/src/main/java/org/kontalk/util/WakefulHashMap.java b/app/src/main/java/org/kontalk/util/WakefulHashMap.java new file mode 100644 index 000000000..4e2332499 --- /dev/null +++ b/app/src/main/java/org/kontalk/util/WakefulHashMap.java @@ -0,0 +1,77 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import java.util.HashMap; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.PowerManager; + + +/** + * A hash map that acquires a wake lock whenever an item is inserted + * and releases a wake lock whenever an item is removed. + */ +public class WakefulHashMap extends HashMap { + + private final PowerManager.WakeLock mWakeLock; + + public WakefulHashMap(Context context, int levelAndFlags, String tag) { + super(); + + PowerManager pwr = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + mWakeLock = pwr.newWakeLock(levelAndFlags, tag); + } + + @Override + @SuppressLint("WakelockTimeout") + public V put(K key, V value) { + V prev = super.put(key, value); + if (prev == null) { + mWakeLock.acquire(); + } + return prev; + } + + public V put(K key, V value, long timeout) { + V prev = super.put(key, value); + if (prev == null) { + mWakeLock.acquire(timeout); + } + return prev; + } + + @Override + public V remove(Object key) { + V prev = super.remove(key); + if (prev != null) { + mWakeLock.release(); + } + return prev; + } + + @Override + public void clear() { + super.clear(); + while (mWakeLock.isHeld()) + mWakeLock.release(); + } + +} diff --git a/app/src/main/java/org/kontalk/util/WakefulHashSet.java b/app/src/main/java/org/kontalk/util/WakefulHashSet.java new file mode 100644 index 000000000..2839f98eb --- /dev/null +++ b/app/src/main/java/org/kontalk/util/WakefulHashSet.java @@ -0,0 +1,77 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import java.util.HashSet; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.PowerManager; + + +/** + * A hash set that acquires a wake lock whenever an item is inserted + * and releases a wake lock whenever an item is removed. + */ +public class WakefulHashSet extends HashSet { + + private final PowerManager.WakeLock mWakeLock; + + public WakefulHashSet(int initialCapacity, Context context, int levelAndFlags, String tag) { + super(initialCapacity); + + PowerManager pwr = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + mWakeLock = pwr.newWakeLock(levelAndFlags, tag); + } + + @Override + @SuppressLint("WakelockTimeout") + public boolean add(E e) { + boolean added = super.add(e); + if (added) { + mWakeLock.acquire(); + } + return added; + } + + public boolean add(E e, long timeout) { + boolean added = super.add(e); + if (added) { + mWakeLock.acquire(timeout); + } + return added; + } + + @Override + public boolean remove(Object key) { + boolean removed = super.remove(key); + if (removed) { + mWakeLock.release(); + } + return removed; + } + + @Override + public void clear() { + super.clear(); + while (mWakeLock.isHeld()) + mWakeLock.release(); + } + +} diff --git a/app/src/main/java/org/kontalk/util/XMPPUtils.java b/app/src/main/java/org/kontalk/util/XMPPUtils.java index 4eac4c44a..da74acb0d 100644 --- a/app/src/main/java/org/kontalk/util/XMPPUtils.java +++ b/app/src/main/java/org/kontalk/util/XMPPUtils.java @@ -1,6 +1,6 @@ /* * Kontalk Android client - * Copyright (C) 2015 Kontalk Devteam + * Copyright (C) 2020 Kontalk Devteam * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,17 +19,25 @@ package org.kontalk.util; import java.io.StringReader; +import java.util.Collection; +import java.util.Date; +import org.jivesoftware.smack.packet.ExtensionElement; import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.packet.StanzaError; import org.jivesoftware.smack.util.PacketParserUtils; +import org.jivesoftware.smack.util.SHA1; +import org.jivesoftware.smackx.delay.packet.DelayInformation; +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; import org.jxmpp.util.XmppStringUtils; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlPullParserFactory; -import android.content.Context; - -import org.kontalk.client.EndpointServer; +import android.graphics.Color; +import androidx.annotation.ColorInt; /** @@ -72,7 +80,7 @@ public static Message parseMessageStanza(String data) throws Exception { in_xmpp = true; else if ("message".equals(parser.getName()) && in_xmpp) { - msg = (Message) PacketParserUtils.parseMessage(parser); + msg = PacketParserUtils.parseMessage(parser); } } @@ -86,15 +94,31 @@ else if (eventType == XmlPullParser.END_TAG) { return msg; } - public static boolean isLocalJID(String jid, String host) { - return XmppStringUtils.parseDomain(jid).equalsIgnoreCase(host); + public static Date getStanzaDelay(Stanza packet) { + ExtensionElement _delay = packet.getExtension("delay", "urn:xmpp:delay"); + if (_delay == null) + _delay = packet.getExtension("x", "jabber:x:delay"); + + Date stamp = null; + if (_delay != null) { + if (_delay instanceof DelayInformation) { + stamp = ((DelayInformation) _delay).getStamp(); + } + } + + return stamp; + } + + public static StanzaError.Condition getErrorCondition(Stanza packet) { + return packet.getError() != null ? packet.getError().getCondition() : null; } - public static String createLocalJID(Context context, String name) { - EndpointServer server = Preferences.getEndpointServer(context); - if (server == null) - throw new IllegalArgumentException("server is null"); - return XmppStringUtils.completeJidFrom(name, server.getNetwork()); + public static boolean checkError(Stanza packet, StanzaError.Condition condition) { + return packet.getError() != null && packet.getError().getCondition() == condition; + } + + public static boolean isLocalJID(String jid, String host) { + return XmppStringUtils.parseDomain(jid).equalsIgnoreCase(host); } public static boolean equalsBareJID(String full, String bare) { @@ -102,7 +126,47 @@ public static boolean equalsBareJID(String full, String bare) { } public static String createLocalpart(String uid) { - return MessageUtils.sha1(uid); + return SHA1.hex(uid); + } + + /** Returns true if the given JID is a domain JID (e.g. beta.kontalk.net). */ + public static boolean isDomainJID(String jid) { + return XmppStringUtils.parseDomain(jid) + .equalsIgnoreCase(jid); + } + + public static Jid[] parseJids(Collection jids) { + Jid[] list = new Jid[jids.size()]; + int i = 0; + for (String jid : jids) { + list[i++] = JidCreate.fromOrThrowUnchecked(jid); + } + return list; + } + + public static Jid[] parseJids(String[] jids) { + Jid[] list = new Jid[jids.length]; + int i = 0; + for (String jid : jids) { + list[i++] = JidCreate.fromOrThrowUnchecked(jid); + } + return list; + } + + /** + * Returns a color for the given JID calculated with the rules of XEP-0392 + * @see XEP-0392 + */ + @ColorInt + public static int getJIDColor(String jid) { + // TODO get correction from accessibility settings + double[] rgb = ConsistentColorGeneration.getRGB(jid, + ConsistentColorGeneration.CORRECTION_NONE); + + return Color.argb(0xff, + (int) (rgb[0] * 255), + (int) (rgb[1] * 255), + (int) (rgb[2] * 255)); } } diff --git a/app/src/main/play/contact-email.txt b/app/src/main/play/contact-email.txt new file mode 100644 index 000000000..eee829d3b --- /dev/null +++ b/app/src/main/play/contact-email.txt @@ -0,0 +1 @@ +devteam@kontalk.org diff --git a/app/src/main/play/contact-website.txt b/app/src/main/play/contact-website.txt new file mode 100644 index 000000000..e8f1837df --- /dev/null +++ b/app/src/main/play/contact-website.txt @@ -0,0 +1 @@ +https://forum.kontalk.org/ diff --git a/app/src/main/play/default-language.txt b/app/src/main/play/default-language.txt new file mode 100644 index 000000000..beb9970be --- /dev/null +++ b/app/src/main/play/default-language.txt @@ -0,0 +1 @@ +en-US diff --git a/app/src/main/play/listings/af/full-description.txt b/app/src/main/play/listings/af/full-description.txt new file mode 100644 index 000000000..b115afef7 --- /dev/null +++ b/app/src/main/play/listings/af/full-description.txt @@ -0,0 +1,14 @@ +'''Volg @kontalknet op Twitter of identi.ca om op datum te bly met die nuutste veranderinge en op diens status''' + +Kontalk is 'n private kitsboodskapper, jy kan teks stuur en ontvang, prentjie en stem boodskappe (ander lêertipes binnekort) kosteloos stuur(*). + +* '''Jou foon nommer is jou ID.''' Geen gebruikersname of wagwoorde * '''Dit vind outomaties ander Kontalk gebruikers''' deur te kyk na jou adresboek en maak dit nog makliker om te begin gesels op Kontalk met jou vriende * '''Punt-tot-punt enkripsie''' verseker sekuur en private gesprekke, sodat net jy en die persoon wat jy is om te praat kan hulle lees * Jou en alle telefoonnommers op die netwerk gebruik is onomkeerbaar geënkripteer * Dit ondersteun meervoudige toestelle, wat dit baie maklik maak om te begin gesels op jou foon en hou die gesprek op jou tablet * Gebaseer op oop standaarde: XMPP en OpenPGP + +Die netwerk maak staat op donasiesom die infrastruktuur aan die gang te hou. Indien moontlik, maak asseblief 'n donasie op [http://kontalk.net/ kontalk.net]. Selfs klein bedraggies maak 'n daatwerklike verskil. + +Jy kan alle toestemmings gebruik deur die program vind by [https://github.com/kontalk/androidclient/wiki/Android-client-permissions hierdie bladsy]. + +Hierdie weergawe is gestroop van alle Google komponente. Dus beteken geen ondersteuning vir stoot kennisgewings nie. + +(*) Draer onkostes vir Internet verkeer mag van toepassing wees + diff --git a/app/src/main/play/listings/af/short-description.txt b/app/src/main/play/listings/af/short-description.txt new file mode 100644 index 000000000..7f4083fbc --- /dev/null +++ b/app/src/main/play/listings/af/short-description.txt @@ -0,0 +1 @@ +Gemeenskap-gedrewe Boodskapdiens \ No newline at end of file diff --git a/app/src/main/play/listings/af/title.txt b/app/src/main/play/listings/af/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/af/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/ar/short-description.txt b/app/src/main/play/listings/ar/short-description.txt new file mode 100644 index 000000000..ca19a8e06 --- /dev/null +++ b/app/src/main/play/listings/ar/short-description.txt @@ -0,0 +1 @@ +الرسائل المجتمعية \ No newline at end of file diff --git a/app/src/main/play/listings/ar/title.txt b/app/src/main/play/listings/ar/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/ar/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/be/short-description.txt b/app/src/main/play/listings/be/short-description.txt new file mode 100644 index 000000000..781fdc02c --- /dev/null +++ b/app/src/main/play/listings/be/short-description.txt @@ -0,0 +1 @@ +Абмен паведамленнямі, кіруемы супольнасцю \ No newline at end of file diff --git a/app/src/main/play/listings/be/title.txt b/app/src/main/play/listings/be/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/be/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/ca/full-description.txt b/app/src/main/play/listings/ca/full-description.txt new file mode 100644 index 000000000..47b5989a2 --- /dev/null +++ b/app/src/main/play/listings/ca/full-description.txt @@ -0,0 +1,20 @@ +Per favor ajudeu-nos a millorar Kontalk! Si teniu algun problema envieu-nos un email a devteam@kontalk.org!! Deixar un comentari negatiu no ajudarà gaire a un projecte sense ànim de lucre! Gràcies! +NOTA: NO INSTAL·LI L'APP A LA TARGETA SD! + +Segueix @kontalknet a Twitter i identi.ca per estar al corrent dels darrers canvis i de l'estat del servei! + +Kontalk és una aplicació de missatgeria instantània segura, que permet enviar i rebre text, imatges i missatges d'àudio (properament altres tipus de fitxers) entre diferents usuaris Kontalk de manera completament gratuita (*). + +•  El seu número de telèfon és la seva ID. Sense utilitzar noms d'suari ni contrasenyes
+•  L'app troba automàticament altres usuaris Kontalk de la seva llibreta de contactes, el que fa encara més fàcil xatejar amb els seus amics i amigues
+•  Xifratge d'extrem a extrem garanteix converses segures i privades, de manera que només vosté i la persona amb qui xateja pot llegir-les.
+•  El xifratge dels números de telèfons utilitzats a la xarxa és irreversible (no es poden descodificar)
+•  El suport de diferents dispositius permet començar un xat amb un mòbil i continuar amb una tauleta
+•  Basat en estàndards oberts: XMPP i OpenPGP + +Kontalk és un projecte de la comunitat de codi obert, amb tot el seu codi font allotjat a GitHub (https://github.com/kontalk), permetent a tothom estudiar la seva seguretat i millorar-la. +La xarxa depèn de les donacions de la comunitat per mantenir la infraestructura en funcionament, pel que si vostè pot, si us plau doni a http://kontalk.net/. Fins i tot una petita donació és molt important. + +Pot trobar tots els permisos que requereix l'app, explicats en aquesta pàgina: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) El seu operador mòbil pot aplicar càrrecs per serveis de connexió a Internet \ No newline at end of file diff --git a/app/src/main/play/listings/ca/short-description.txt b/app/src/main/play/listings/ca/short-description.txt new file mode 100644 index 000000000..3a4cd9bac --- /dev/null +++ b/app/src/main/play/listings/ca/short-description.txt @@ -0,0 +1 @@ +Una nova forma de comunicació: xarxa oberta de missatgeria instantània. \ No newline at end of file diff --git a/app/src/main/play/listings/ca/title.txt b/app/src/main/play/listings/ca/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/ca/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/cs-CZ/full-description.txt b/app/src/main/play/listings/cs-CZ/full-description.txt new file mode 100644 index 000000000..0cc80db88 --- /dev/null +++ b/app/src/main/play/listings/cs-CZ/full-description.txt @@ -0,0 +1,20 @@ +Pomozte nám prosím Kontalk vylepšit! Pokud máte nějaké potíže, pošlete e-mail na devteam@kontalk.org!! Pokud pouze zanecháte negativní hodnocení, tomuto neziskovému projektu to nijak nepomůže! Díky! +POZNÁMKA: NEINSTALUJTE NA SD KARTU! + +Pro sledování nejnovějších změn a stavu služby sledujte @kontalknet na Twitteru nebo identi.ca! + +Kontalk je bezpečný chatovací program, který umožňuje odesílat a přijímat text, obrázky a hlasové zprávy (ostatní typy souborů budou následovat) mezi vámi a ostatními uživateli Kontalku zcela bezplatně (*). + +•  Vaše telefonní číslo je vaše ID. Žádná uživatelská jména a hesla
+•  Aplikace automaticky najde ostatní uživatele Kontalku ve vašem adresáři, takže povídání s vašimi přáteli přes Kontalk bude ještě jednodušší
+•  End-to-end šifrování zajišťuje bezpečné a soukromé konverzace tak, že pouze vy a osoba, se kterou komunikujete, je může přečíst
+•  Vaše i všechna ostatní telefonní čísla používaná v síti Kontalk jsou nevratně zašifrovaná
+•  Aplikace podporuje více zařízení, takže je velmi jednoduché začít konverzaci na vašem telefonu a pokračovat v ní na vašem tabletu
+•  Založeno na otevřených standardech: XMPP a OpenPGP + +Kontalk je kompletně otevřený komunitní projekt, veškeré zdrojové kódy jsou uloženy na GitHubu (https://github.com/kontalk), takže každý může zkontrolovat jeho bezpečnost a zlepšit ji. +Aby bylo možné udržet Kontalk v provozu, síť spoléhá na dary komunity. Pokud můžete, darujte prosím příspěvek na http://kontalk.net/. I malé příspěvky mají smysl. + +Vysvětlení ke všem oprávněním, které Kontalk využívá, můžete najít na této stránce: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) Váš operátor si může účtovat poplatky za přenesená data \ No newline at end of file diff --git a/app/src/main/play/listings/cs-CZ/short-description.txt b/app/src/main/play/listings/cs-CZ/short-description.txt new file mode 100644 index 000000000..b68485fe7 --- /dev/null +++ b/app/src/main/play/listings/cs-CZ/short-description.txt @@ -0,0 +1 @@ +Nový způsob komunikace: síť rychlých zpráv provozovaná komunitou. \ No newline at end of file diff --git a/app/src/main/play/listings/cs-CZ/title.txt b/app/src/main/play/listings/cs-CZ/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/cs-CZ/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/de-DE/full-description.txt b/app/src/main/play/listings/de-DE/full-description.txt new file mode 100644 index 000000000..8ebc7eff5 --- /dev/null +++ b/app/src/main/play/listings/de-DE/full-description.txt @@ -0,0 +1,21 @@ +Bitte helfen Sie Kontalk weiter zu verbessern! Senden Sie eine E-mail an devteam@kontalk.org wenn Sie ein Problem mit Kontalk haben oder einen Fehler finden!! Eine negative Bewertung abzugeben hilft einem Non-Profit-Projekt nicht! +Danke!! +ACHTUNG: NICHT AUF SD KARTE INSTALLIEREN! + +Folgen Sie uns via @kontalknet auf Twitter oder identi.ca, um auf dem aktuellen Entwicklungsstand zu bleiben und über neueste Änderungen informiert zu werden! + +Kontalk ist ein sicherer Instant Messenger, der es Ihnen erlaubt, Text-, Bild- und Sprachnachrichten komplett kostenlos (*) mit anderen Kontalk-Nutzern auszutauschen. + +•  Ihre Telefonnummer ist Ihre ID. Kein Nutzername oder Passwort
+•  Kontalk findet andere Kontalk-Nutzer automatisch durch Abgleich Ihres Adressbuchs. Dies macht es Ihnen noch einfacher mit Ihren Freunden zu chatten
+•  Ende-zu-Ende-Verschlüsselung ermöglicht eine sichere und private Kommunikation
+•  Alle im Netzwerk verwendeten Telefonnummern werden irreversibel verschlüsselt
+•  Unterstützt die gleichzeitige Nutzung auf mehreren Geräten
+•  Basierend auf offenen Standards wie XMPP und OpenPGP + +Kontalk ist ein Open-Source-Gemeinschaftsprojekt, gehosted auf GitHub (https://github.com/kontalk). Es ist erlaubt und erwünscht, die Sicherheit zu überprüfen und zu verbessern. +Das Netzwerk ist auf Spenden angewiesen, um die Infrastruktur bereitstellen und erweitern zu können. Bitte spenden Sie auf http://kontalk.net/. Auch kleine Spenden helfen. + +Alle Berechtigungen welche die App benötigt, werden auf dieser Seite erklärt: https://github.com/kontalk/androidclient/wiki/[de]-Android-App-Berechtigungen + +(*) Evtl. fallen Gebühren des Netzbetreibers für den Internet-Verkehr an \ No newline at end of file diff --git a/app/src/main/play/listings/de-DE/short-description.txt b/app/src/main/play/listings/de-DE/short-description.txt new file mode 100644 index 000000000..33d592ff4 --- /dev/null +++ b/app/src/main/play/listings/de-DE/short-description.txt @@ -0,0 +1 @@ +Eine neue Art der Kommunikation: Gemeinschaftlich betriebenes IM Netzwerk. \ No newline at end of file diff --git a/app/src/main/play/listings/de-DE/title.txt b/app/src/main/play/listings/de-DE/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/de-DE/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/el-GR/full-description.txt b/app/src/main/play/listings/el-GR/full-description.txt new file mode 100644 index 000000000..2cc56c717 --- /dev/null +++ b/app/src/main/play/listings/el-GR/full-description.txt @@ -0,0 +1,20 @@ +Παρακαλούμε βοηθήστε μας να κάνουμε την εφαρμογή Kontalk καλύτερη! Στείλτε μας email στο devteam@kontalk.org αν έχετε οποιοδήποτε πρόβλημα! Το να αφήσετε μια αρνητική κριτική δε θα βοηθήσει ένα μη κερδοσκοπικό πρόγραμμα! Σας ευχαριστούμε! +ΣΗΜΕΙΩΣΗ: ΜΗΝ ΕΓΚΑΤΑΣΤΗΣΕΤΕ ΣΤΗΝ ΚΑΡΤΑ SD + +Ακολουθήστε μας @kontalknet στο Twitter ή identi.ca για να μένετε ενήμεροι για τις τελευταίες αλλαγές και την κατάσταση των υπηρεσιών μας! + +Η kontalk είναι μια ασφαλής εφαρμογή ανταλλαγής άμεσων μηνυμάτων, που σας επιτρέπει να στέλνετε και να λαμβάνετε κείμενο, εικόνα και φωνητικά μηνύματα (επιπρόσθετοι τύποι αρχείων θα είναι σύντομα διαθέσιμοι) προς και από άλλους χρήστες της Kontalk εντελώς δωρεάν (*). + +• Ο αριθμός τηλεφώνου σας είναι η ταυτότητά σας (ID).Δεν χρειάζονται ψευδώνυμα και κωδικοί πρόσβασης
+• Η Kontalk βρίσκει άλλους χρήστες αυτόματα αναζητώντας στις επαφές σας, κάνοντας ακόμα πιο εύκολο να ξεκινήσετε να μιλάτε μέσω Kontalk με τους φίλους σας.
+• Η από άκρο σε άκρο κρυπτογράφηση διασφαλίζει την ασφάλεια και την ιδιωτικότητα των συνομιλιών, ούτως ώστε μόνο εσείς και το άτομο με το οποίο μιλάτε να μπορεί να τις διαβάσει
+•  Εσείς και όλοι οι αριθμοί τηλεφώνου που χρησιμοποιούνται στο δίκτυο είναι αμετάκλητα κρυπτογραφημένοι
+•  Υποστηρίζει πολλαπλές συσκευές, κάνοντας πολύ πιο εύκολη την έναρξη συζητήσεων στο τηλέφωνό σας και τη συνέχειά τους στην ταμπλέτα σας
+•  Βασισμένο σε ανοιχτά πρότυπα: XMPP και ΟpenPGP + +Η Kontalk είναι μια εντελώς ανοιχτού κώδικα κοινοτική εφαρμογή, και όλος ο πηγαίος κώδικας είναι διαθέσιμος στο GitHub (https://github.com/kontalk), επιτρέποντας σε όλους να επιθεωρήσουν την ασφάλεια της και να τη βελτιώσουν. +Το δίκτυο βασίζεται σε δωρεές από την κοινότητα για να διατηρείται η λειτουργία των υποδομών, γι' αυτό αν μπορείτε, παρακαλούμε κάντε μια δωρεά στο http://kontalk.net/. Ακόμα και οι πιο μικρές δωρεές είναι σημαντικές. + +Μπορείτε να βρείτε την αιτιολόγηση για όλα τα δικαιώματα που χρησιμοποιεί αυτή η εφαρμογή σε αυτή τη σελίδα: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) Μπορεί να ισχύουν τέλη φορέα για Διαδικτυακές χρεώσεις \ No newline at end of file diff --git a/app/src/main/play/listings/el-GR/short-description.txt b/app/src/main/play/listings/el-GR/short-description.txt new file mode 100644 index 000000000..d95733748 --- /dev/null +++ b/app/src/main/play/listings/el-GR/short-description.txt @@ -0,0 +1 @@ +Ένας νέος τρόπος επικοινωνίας: κοινοτικά βασισμένο δίκτυο ανταλλαγής μηνυμάτων. \ No newline at end of file diff --git a/app/src/main/play/listings/el-GR/title.txt b/app/src/main/play/listings/el-GR/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/el-GR/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/en-US/full-description.txt b/app/src/main/play/listings/en-US/full-description.txt new file mode 100644 index 000000000..e4b9607fc --- /dev/null +++ b/app/src/main/play/listings/en-US/full-description.txt @@ -0,0 +1,20 @@ +Please help us making Kontalk better! E-mail us at devteam@kontalk.org if you have any issues!! Leaving a negative review won't help a non-profit project! Thank you! +NOTE: DO NOT INSTALL ON SD CARD! + +Follow @kontalknet on Twitter or identi.ca to keep up to date with the latest changes and on service status! + +Kontalk is a secure instant messenger, allowing you to send and receive text, image and voice messages (other file types coming soon) to and from other Kontalk users completely free of charge (*). + +•  Your phone number is your ID. No usernames or passwords
+•  It automatically finds other Kontalk users by looking at your address book, making it even easier to start chatting on Kontalk with your friends
+•  End-to-end encryption ensures safe and private conversations, so that only you and the person you are talking to can read them
+•  Your and all phone numbers used on the network are irreversibly encrypted
+•  It supports multiple devices, making it very easy to start chatting on your phone and continue the conversation on your tablet
+•  Based on open standards: XMPP and OpenPGP + +Kontalk is a completely Open Source community project, with all the source code hosted on GitHub (https://github.com/kontalk), allowing everyone to inspect its security and improve it. +The network relies on community donations to keep the infrastructure running, so if you can, please donate on http://kontalk.net/. Even small donations matter. + +You can find all permissions used by the app explained at this page: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) Carrier fees for Internet traffic may apply \ No newline at end of file diff --git a/app/src/main/play/listings/en-US/graphics/feature-graphic/googleplay_banner.png b/app/src/main/play/listings/en-US/graphics/feature-graphic/googleplay_banner.png new file mode 100644 index 000000000..93bdc25cb Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/feature-graphic/googleplay_banner.png differ diff --git a/app/src/main/play/listings/en-US/graphics/icon/appicon.png b/app/src/main/play/listings/en-US/graphics/icon/appicon.png new file mode 100644 index 000000000..2a9d06c3e Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/icon/appicon.png differ diff --git a/app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/01-chat1.png b/app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/01-chat1.png new file mode 100644 index 000000000..3b810ee94 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/01-chat1.png differ diff --git a/app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/02-chat2.png b/app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/02-chat2.png new file mode 100644 index 000000000..e844da5d7 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/02-chat2.png differ diff --git a/app/src/main/play/listings/en-US/graphics/phone-screenshots/01-main.png b/app/src/main/play/listings/en-US/graphics/phone-screenshots/01-main.png new file mode 100644 index 000000000..3ed48e661 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/phone-screenshots/01-main.png differ diff --git a/app/src/main/play/listings/en-US/graphics/phone-screenshots/02-chat1.png b/app/src/main/play/listings/en-US/graphics/phone-screenshots/02-chat1.png new file mode 100644 index 000000000..14b966ce9 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/phone-screenshots/02-chat1.png differ diff --git a/app/src/main/play/listings/en-US/graphics/phone-screenshots/03-chat2.png b/app/src/main/play/listings/en-US/graphics/phone-screenshots/03-chat2.png new file mode 100644 index 000000000..fcf53ed97 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/phone-screenshots/03-chat2.png differ diff --git a/app/src/main/play/listings/en-US/graphics/phone-screenshots/04-chat3.png b/app/src/main/play/listings/en-US/graphics/phone-screenshots/04-chat3.png new file mode 100644 index 000000000..d9a6e4a8c Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/phone-screenshots/04-chat3.png differ diff --git a/app/src/main/play/listings/en-US/graphics/phone-screenshots/05-contacts.png b/app/src/main/play/listings/en-US/graphics/phone-screenshots/05-contacts.png new file mode 100644 index 000000000..5fc6ec810 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/phone-screenshots/05-contacts.png differ diff --git a/app/src/main/play/listings/en-US/graphics/phone-screenshots/06-settings.png b/app/src/main/play/listings/en-US/graphics/phone-screenshots/06-settings.png new file mode 100644 index 000000000..f2103b253 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/phone-screenshots/06-settings.png differ diff --git a/app/src/main/play/listings/en-US/graphics/tablet-screenshots/01-chat1.png b/app/src/main/play/listings/en-US/graphics/tablet-screenshots/01-chat1.png new file mode 100644 index 000000000..a9e3215e2 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/tablet-screenshots/01-chat1.png differ diff --git a/app/src/main/play/listings/en-US/graphics/tablet-screenshots/02-chat2.png b/app/src/main/play/listings/en-US/graphics/tablet-screenshots/02-chat2.png new file mode 100644 index 000000000..8929e9568 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/tablet-screenshots/02-chat2.png differ diff --git a/app/src/main/play/listings/en-US/short-description.txt b/app/src/main/play/listings/en-US/short-description.txt new file mode 100644 index 000000000..014267235 --- /dev/null +++ b/app/src/main/play/listings/en-US/short-description.txt @@ -0,0 +1 @@ +A new way of communicating: community-driven instant messaging network. \ No newline at end of file diff --git a/app/src/main/play/listings/en-US/title.txt b/app/src/main/play/listings/en-US/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/en-US/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/es-ES/full-description.txt b/app/src/main/play/listings/es-ES/full-description.txt new file mode 100644 index 000000000..16813fbb5 --- /dev/null +++ b/app/src/main/play/listings/es-ES/full-description.txt @@ -0,0 +1,21 @@ +Por favor, ¡ayúdenos a hacer Kontalk mejor! Si tiene algún problema ¡¡envíenos un correo electrónico a devteam@kontalk.org!! ¡Dejar un comentario negativo no ayudará a un proyecto sin fines de lucro! ¡Gracias! +NOTA: ¡NO INSTALE LA APP EN LA TARJETA SD! + +¡Siga @kontalknet en Twitter o identi.ca para estar al corriente de las últimas modificaciones, así como del estado del servicio! + +Kontalk es un programa de mensajería instantánea segura, lo que le permite enviar y recibir texto, imagen y mensajes de voz (próximamente otros tipos de archivos) entre sus usuarios de forma totalmente gratuita (*). + +•  Su número de teléfono es su ID. Sin nombres de usuario o contraseñas
+•  La aplicación encuentra automáticamente a otros usuarios Kontalk buscando en su libreta de contactos, haciendo más sencillo chatear con sus amigos
+•  El cifrado de extremo a extremo garantiza conversaciones privadas y seguras, de modo que sólo usted y la persona con la que está chateando puedan leerlas
+•  El método de cifrado de su número de teléfono y del resto de números participantes en la red de Kontalk es irreversible
+•  El amplio soporte de diferentes dispositivos permite empezar un chat en un teléfono móvil y continuar la conversación en una tableta
+•  Basado en estándares abiertos: XMPP y OpenPGP + +Kontalk es un proyecto de la comunidad de código abierto, con todo el código fuente alojado en GitHub (https://github.com/kontalk), permitiendo a todo el mundo estudiar su seguridad y mejorarla. + +La red depende de las donaciones de la comunidad para mantener la infraestructura en funcionamiento, así que si puede, por favor done en http://kontalk.net/. Incluso una pequeña donación es importante. + +Los permisos utilizados por la aplicación, los encontrará detallados en la página: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) Los servicios de conexión a Internet pueden originar cargos de su operador telefónico \ No newline at end of file diff --git a/app/src/main/play/listings/es-ES/short-description.txt b/app/src/main/play/listings/es-ES/short-description.txt new file mode 100644 index 000000000..2ad1bef09 --- /dev/null +++ b/app/src/main/play/listings/es-ES/short-description.txt @@ -0,0 +1 @@ +Una nueva forma de comunicación: red colaborativa de mensajería instantánea. \ No newline at end of file diff --git a/app/src/main/play/listings/es-ES/title.txt b/app/src/main/play/listings/es-ES/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/es-ES/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/et/short-description.txt b/app/src/main/play/listings/et/short-description.txt new file mode 100644 index 000000000..04136d575 --- /dev/null +++ b/app/src/main/play/listings/et/short-description.txt @@ -0,0 +1 @@ +Kogukonna juhitud sõnumivahetus \ No newline at end of file diff --git a/app/src/main/play/listings/et/title.txt b/app/src/main/play/listings/et/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/et/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/fa/full-description.txt b/app/src/main/play/listings/fa/full-description.txt new file mode 100644 index 000000000..07d58b43d --- /dev/null +++ b/app/src/main/play/listings/fa/full-description.txt @@ -0,0 +1,14 @@ +'''برای با خبر ماندن از آخرین تغییرات و وضعیت خدمت، kontalknet را روی توییتر یا identi.ca دنیال کنید!''' + +Kontalk یک پیام دهنده امن است، که به شما امکان ارسال نوشته، عکس و صدا ( فایل‌های دیگر به زودی اضافه ‌می‌شوند) را به دیگر کاربران Kontalk بدون هیچ هزینه‌ای می‌دهد(*). + +* ''' شماره تلفن شما نام کاربری‌ شما است.''' نیازی به نام‌کاربری و رمز عبور نیست * ''' بطور خودکار دیگر کاربران Kontalk را پیدا می‌کند''' با دسترسی به اطلاعات تماس شما، تماس گرفتن به دیگر کاربران را Kontalk را بسیار ساده تر می‌کند. * ''' رمزگذاری سرتاسر''' مکالمات امن و خصوصی را تضمین می کند تا شما به راحتی بدون اضطراب اینکه دیگران پیام ‌های شما را بخوانند با دوستانتان گفتگو کنید* شماره شما و دیگرانی که از این برنامه استفاده می‌کنند رمزگزاری شده‌اند* همچنین این امکان را دارید که همزمان از چند دستگاه برای مکالمه استفاده کنید. بطور مثال می‌توانید ادامه گفتگو بر روی موبایلتان را بر روی تبلت انجام دهید* بر اساس استانداردهای باز: XMPP و OpenPGP + +این شبکه نیازمند به کمک‌های مالی شماست تا بتواند ادامه کار بدهد. اگر امکانش برایتان میسر است از طریق لینک مقابل لطفا کمک کنید: [http://kontalk.net/ kontalk.net]. حتی کمک‌های کوچک. + +تمام اجازه‌نامه‌های استفاده شده دز اپلیکیشن اینجا توضیح داده شده [https://github.com/kontalk/androidclient/wiki/Android-client-permissions this page]. + +برای این نسخه تمامی اجزاء Google حذف شده است در نتیجه هیچ اعلانی دریافت نمی‌کنید. + +(*) هزینه موبایل برای استفاده از ترافیک اینترنت شاید اضافه شود + diff --git a/app/src/main/play/listings/fa/short-description.txt b/app/src/main/play/listings/fa/short-description.txt new file mode 100644 index 000000000..77ac18488 --- /dev/null +++ b/app/src/main/play/listings/fa/short-description.txt @@ -0,0 +1 @@ +پیام‌رسانی اجتماعی \ No newline at end of file diff --git a/app/src/main/play/listings/fa/title.txt b/app/src/main/play/listings/fa/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/fa/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/fi-FI/short-description.txt b/app/src/main/play/listings/fi-FI/short-description.txt new file mode 100644 index 000000000..90e3952c2 --- /dev/null +++ b/app/src/main/play/listings/fi-FI/short-description.txt @@ -0,0 +1 @@ +Yhteisön ajamaa viestintää \ No newline at end of file diff --git a/app/src/main/play/listings/fi-FI/title.txt b/app/src/main/play/listings/fi-FI/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/fi-FI/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/fr-FR/full-description.txt b/app/src/main/play/listings/fr-FR/full-description.txt new file mode 100644 index 000000000..bc92ba3e7 --- /dev/null +++ b/app/src/main/play/listings/fr-FR/full-description.txt @@ -0,0 +1,20 @@ +S'il vous plait aidez-nous à améliorer Kontalk ! Envoyez-nous un email à devteam@kontalk.org si vous avez un problème !! Laisser un commentaire négatif ne vas pas aider un projet bénévole ! Merci ! +NOTE: NE PAS INSTALLER SUR CARTE SD! + +Suivez @kontalknet sur Twitter ou identi.ca pour rester à jour avec les derniers changements! + +Kontalk est une messagerie sécurisée, vous permettant d'envoyez et recevoir des messages textes, photos et messages audio (d'autres formats de fichier seront prochainement disponible) entre utilisateurs de Kontalk completement gratuitement (*). + +•  Votre numéro de téléphone est votre ID.Pas besoin de noms d'utilisateur ou de mots de passe
+•  Il détecte automatiquement les autres utilisateurs Kontalk en regardant votre carnet d'adresses, ce qui rend encore plus facile de tchatter sur Kontalk avec vos amis
+•  Chiffrement de bout en bout assure des conversations sûres et privées, afin que seuls vous et la personne à qui vous parlez peut les lire
+•  Tous les numéros de téléphone et le votre utilisés sur le réseau sont chiffrés de manière irréversible
+•  Il prend en charge plusieurs appareils, c'est très facile de commencer une conversation sur votre téléphone et de la continuer sur votre tablette
+•  Basé sur des standards ouverts: XMPP et OpenPGP + +Kontalk est un projet de la communauté du logiciel libre, tout le code source est hébergé sur GitHub (https://github.com/kontalk), permettant à chacun d'inspecter la sécurité et de l'améliorer. +Le réseau compte sur les dons de la communauté pour maintenir l'infrastructure à jour, donc si vous pouvez, s'il vous plaît faites un don sur http://kontalk.net/. Même les petits dons comptent. + +Vous pouvez trouver toutes les autorisations utilisées par l'application expliqué à cette page: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) Des frais de connexion pour le traffic Internet peuvent s'appliquer \ No newline at end of file diff --git a/app/src/main/play/listings/fr-FR/short-description.txt b/app/src/main/play/listings/fr-FR/short-description.txt new file mode 100644 index 000000000..bb68b9e60 --- /dev/null +++ b/app/src/main/play/listings/fr-FR/short-description.txt @@ -0,0 +1 @@ +Une nouvelle façon de communiquer: une messagerie instantanée communautaire. \ No newline at end of file diff --git a/app/src/main/play/listings/fr-FR/title.txt b/app/src/main/play/listings/fr-FR/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/fr-FR/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/hu-HU/short-description.txt b/app/src/main/play/listings/hu-HU/short-description.txt new file mode 100644 index 000000000..a20eb1f43 --- /dev/null +++ b/app/src/main/play/listings/hu-HU/short-description.txt @@ -0,0 +1 @@ +Közösségközpontú üzenetküldés \ No newline at end of file diff --git a/app/src/main/play/listings/hu-HU/title.txt b/app/src/main/play/listings/hu-HU/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/hu-HU/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/id/short-description.txt b/app/src/main/play/listings/id/short-description.txt new file mode 100644 index 000000000..0a0092feb --- /dev/null +++ b/app/src/main/play/listings/id/short-description.txt @@ -0,0 +1 @@ +Perpesanan berbasis-komunitas \ No newline at end of file diff --git a/app/src/main/play/listings/id/title.txt b/app/src/main/play/listings/id/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/id/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/is-IS/short-description.txt b/app/src/main/play/listings/is-IS/short-description.txt new file mode 100644 index 000000000..b8c1008f5 --- /dev/null +++ b/app/src/main/play/listings/is-IS/short-description.txt @@ -0,0 +1 @@ +Samfélagsdrifið skilaboðakerfi \ No newline at end of file diff --git a/app/src/main/play/listings/is-IS/title.txt b/app/src/main/play/listings/is-IS/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/is-IS/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/it-IT/full-description.txt b/app/src/main/play/listings/it-IT/full-description.txt new file mode 100644 index 000000000..795b17099 --- /dev/null +++ b/app/src/main/play/listings/it-IT/full-description.txt @@ -0,0 +1,20 @@ +Aiutaci a migliorare Kontalk! Invia una mail a devteam@kontalk.org se trovi un bug o un difetto!! Lasciare una recensione negativa non aiuta un progetto no-profit! Grazie mille! +NOTE: NON INSTALLARE SU SD! + +Segui @kontalknet su Twitter o identi.ca per rimanere aggiornato su gli ultimi cambiamenti e sullo stato del servizio! + +Kontalk è un servizio di messaggistica istantanea sicuro che ti permette di inviare e ricevere testo, immagini e messaggi vocali (altri tipi di file arriveranno presto) con altri utenti Kontalk gratuitamente (*). + +•  Il tuo numero di telefono è il tuo ID. Nessun nome utente o password
+•  Cerca in automatico altri utenti Kontalk guardando nella tua rubrica, rendendo ancora più semplice iniziare a chattare con i tuoi amici
+•  La cifratura diretta a chiave pubblica assicura conversazioni sicure e private: solo tu e la persona con cui stai chattando possono leggere la conversazione
+•  Il tuo e tutti i numeri di telefono usati nel network sono cifrati irreversibilmente
+•  Supporto per dispositivi multipli: potrai continuare a conversare sul tuo tablet dal telefono
+•  Basato su standard aperti: XMPP e OpenPGP + +Kontalk è un progetto completamente Open Source: tutto il codice sorgente è ospitato su GitHub (https://github.com/kontalk), permettendo a chiunque di analizzare il codice e migliorarlo. +La rete si affida alle donazioni della comunità per continuare a vivere. Ogni piccola donazione è importante, se puoi dona qualcosa su http://kontalk.net/. + +Trovi tutti i permessi richiesti dall'app spiegati a questa pagina: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) Potrebbero essere applicate tariffe per il traffico Internet dal tuo gestore \ No newline at end of file diff --git a/app/src/main/play/listings/it-IT/short-description.txt b/app/src/main/play/listings/it-IT/short-description.txt new file mode 100644 index 000000000..1d5e30d1e --- /dev/null +++ b/app/src/main/play/listings/it-IT/short-description.txt @@ -0,0 +1 @@ +Un nuovo modo di comunicare: messaggistica istantanea gestita dalla comunità. \ No newline at end of file diff --git a/app/src/main/play/listings/it-IT/title.txt b/app/src/main/play/listings/it-IT/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/it-IT/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/ja-JP/full-description.txt b/app/src/main/play/listings/ja-JP/full-description.txt new file mode 100644 index 000000000..c8d025c91 --- /dev/null +++ b/app/src/main/play/listings/ja-JP/full-description.txt @@ -0,0 +1,20 @@ +私たちが Kontalk を改良する支援をしてください! 何か問題がある場合は devteam@kontalk.org までメールでお問い合わせください! 否定的なレビューを残すことは非営利プロジェクトの支援にはなりません! ありがとうございます! +注: SDカードにインストールしないでください + +ツイッター @kontalknet や identi.ca をフォローして、最新の変更と、サービスの状態を最新に保ちましょう! + +Kontalk は完全に無料(*)の、他の Kontalk ユーザーに/から、テキスト、画像、音声メッセージ (近日中に他のファイルタイプも) を送信および受信することができる、安全なインスタントメッセンジャーです。 + +•  電話番号があなたのIDです。 ユーザー名やパスワードはありません
+•  あなたのアドレス帳を見て、自動的に他の Kontalk ユーザーを見つけます。 さらに簡単に友達と Kontalk でチャットを開始することができます
+•  エンド ツー エンドの暗号化で、あなたとあなたがに話している人だけが読むことができる、安全でプライベートな会話を保証します。
+•  お使いのネットワーク上で使用されるすべての電話番号が、不可逆的に暗号化されます
+•  複数のデバイスをサポートし、お使いの携帯電話でチャットを開始したり、タブレットで会話を続けることが非常に容易になります。
+•  オープンな標準に基づいています: XMPP と OpenPGP + +Kontalk は、完全にオープンソースコミュニティのプロジェクトで、ソースコードはすべて GitHub (https://github.com/kontalk) でホストされ、誰でもセキュリティを検査し、改善することができます。 +ネットワークはコミュニティの寄付に依存してインフラ実行を継続しているので、もし可能であれば、http://kontalk.net/ に寄付してください。少しの寄付で構いませんので。 + +このアプリで使用されるすべての権限は、このページで説明しています: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) インターネット通信のキャリア料がかかる場合があります \ No newline at end of file diff --git a/app/src/main/play/listings/ja-JP/short-description.txt b/app/src/main/play/listings/ja-JP/short-description.txt new file mode 100644 index 000000000..0d1b04169 --- /dev/null +++ b/app/src/main/play/listings/ja-JP/short-description.txt @@ -0,0 +1 @@ +新しいコミュニケーションの方法: コミュニティ主導型のインスタント メッセージング ネットワーク。 \ No newline at end of file diff --git a/app/src/main/play/listings/ja-JP/title.txt b/app/src/main/play/listings/ja-JP/title.txt new file mode 100644 index 000000000..f44b13c58 --- /dev/null +++ b/app/src/main/play/listings/ja-JP/title.txt @@ -0,0 +1 @@ +Kontalk メッセンジャー \ No newline at end of file diff --git a/app/src/main/play/listings/ko-KR/short-description.txt b/app/src/main/play/listings/ko-KR/short-description.txt new file mode 100644 index 000000000..15a090042 --- /dev/null +++ b/app/src/main/play/listings/ko-KR/short-description.txt @@ -0,0 +1 @@ +공동체 기반 메시징 \ No newline at end of file diff --git a/app/src/main/play/listings/ko-KR/title.txt b/app/src/main/play/listings/ko-KR/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/ko-KR/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/nl-NL/full-description.txt b/app/src/main/play/listings/nl-NL/full-description.txt new file mode 100644 index 000000000..e4329f0be --- /dev/null +++ b/app/src/main/play/listings/nl-NL/full-description.txt @@ -0,0 +1,20 @@ +Help ons Kontalk beter te maken! Stuur een e-mail naar devteam@kontalk.org (in het Engels) als je problemen ondervindt!! Een negatieve waardering helpt een non-profit project niet! Bedankt! +LET OP: NIET OP SD KAART INSTALLEREN! + +Volg @kontalknet op Twitter of identi.ca om up-to-date te blijven met de laatste ontwikkelingen en de status van het netwerk! + +Kontalk is een veilige instant messenger, waarmee je tekst, afbeeldingen en stemberichten (andere bestandstypen komen binnenkort) gratis (*) van en naar andere Kontalk-gebruikers kan sturen. + +•  Je telefoonnummer is je ID. Geen gebruikersnamen of wachtwoorden
+•  Andere Kontalk-gebruikers worden automatisch gevonden door naar je contactenlijst te kijken, waardoor het nog gemakkelijker is om via Kontalk met vrienden te chatten
+•  End-to-end-versleuteling zorgt voor veilige en private gesprekken. Enkel jij en je contactpersoon kunnen deze lezen
+•  Alle telefoonnummers worden versleuteld opgeslagen
+•  Meerdere apparaten worden ondersteund, waardoor je heel gemakkelijk op je telefoon kan beginnen met chatten en verder kan chatten op je tablet
+•  Gebaseerd op open standaarden: XMPP en OpenPGP + +Kontalk is een openbronproject, met alle broncode op GitHub (https://github.com/kontalk), waardoor iedereen de beveiliging kan inspecteren en Kontalk kan verbeteren. +Het netwerk heeft donaties nodig om te blijven draaien, dus als je kan, doneer dan alsjeblieft op http://kontalk.net/. Zelfs kleine donaties worden op prijs gesteld. + +Je kan uitleg voor alle permissies die de app gebruikt lezen op https://github.com/kontalk/androidclient/wiki/[nl]-Android-App-permissies + +(*) Kosten voor internetgebruik van de provider niet meegerekend \ No newline at end of file diff --git a/app/src/main/play/listings/nl-NL/short-description.txt b/app/src/main/play/listings/nl-NL/short-description.txt new file mode 100644 index 000000000..03f8947a7 --- /dev/null +++ b/app/src/main/play/listings/nl-NL/short-description.txt @@ -0,0 +1 @@ +Een nieuwe manier van communiceren: een chatnetwerk gedreven door vrijwilligers. \ No newline at end of file diff --git a/app/src/main/play/listings/nl-NL/title.txt b/app/src/main/play/listings/nl-NL/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/nl-NL/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/no-NO/full-description.txt b/app/src/main/play/listings/no-NO/full-description.txt new file mode 100644 index 000000000..140fefa9a --- /dev/null +++ b/app/src/main/play/listings/no-NO/full-description.txt @@ -0,0 +1,20 @@ +Hjelp oss å gjøre Kontalk bedre! Semd e-post til devteam@kontalk.org hvis du har noen problemer!! Å legge igjen negativ poenggivining er ikke noe som hjelper et ikke-kommersielt prosjekt! Takk! +MERK: IKKE INSTALLER PÅ SD-KORT! + +Følg @kontalknet på identi.ca eller Twitter for å se de siste endringene og tjenestestatus! + +Kontalk er en sikker lynmeldingsklient, som lar deg sende og motta tekst, bilde og stemmemeldinger (andre filtyper kommer snart) til og fra andre Kontalk-brukere, helt gratis (*). + +•  Ditt telefonnummer er din ID. Ingen brukernavn eller passord
+•  Det finner automatisk andre Kontalk-brukere ved å se gjennom kontaktlista di, noe som gjør det enda enklere for din vennekrets å komme igang med å sludre på Kontalk
+•  Ende-til-ende -kryptering sørger for sikre og private samtaler, slik at bare du og personen du snakker med kan lese dem
+•  Du og alle telefonnummer brukt på nettverket er kryptert uten mulighet for opplåsing
+•  Det støtter flerfoldige enheter, noe som gjør det enkelt å komme igang med sludring på telefonen, for så å fullføre seansen på ditt nettbrett!
+•  Based på åpne standarder: XMPP og OpenPGP + +Kontalk er i sin helhet et friprog-prosjekt, med all kildekode gjort tilgjengelig på GitHub (https://github.com/kontalk), noe som lar alle overgå og forbedre sikkerheten. +Nettverket avhenger av donasjoner for fortsatt drift, så hvis du kan, vær så snill å doner på http://kontalk.net/. Selv små donasjoner monner. + +Du kan se alle tilgangene brukt av dette programmet forklart på denne siden: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) Kostnader fra tjenestetilbyder for datatrafikk kan tilkomme \ No newline at end of file diff --git a/app/src/main/play/listings/no-NO/short-description.txt b/app/src/main/play/listings/no-NO/short-description.txt new file mode 100644 index 000000000..97cf3a476 --- /dev/null +++ b/app/src/main/play/listings/no-NO/short-description.txt @@ -0,0 +1 @@ +En ny måte å kommunisere på: Gemenskapsdrevet lydneldingsnettverk. \ No newline at end of file diff --git a/app/src/main/play/listings/no-NO/title.txt b/app/src/main/play/listings/no-NO/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/no-NO/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/pl-PL/short-description.txt b/app/src/main/play/listings/pl-PL/short-description.txt new file mode 100644 index 000000000..586c7a040 --- /dev/null +++ b/app/src/main/play/listings/pl-PL/short-description.txt @@ -0,0 +1 @@ +Komunikacja rozwijana przez społeczność \ No newline at end of file diff --git a/app/src/main/play/listings/pl-PL/title.txt b/app/src/main/play/listings/pl-PL/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/pl-PL/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/pt-BR/full-description.txt b/app/src/main/play/listings/pt-BR/full-description.txt new file mode 100644 index 000000000..0a3112d48 --- /dev/null +++ b/app/src/main/play/listings/pt-BR/full-description.txt @@ -0,0 +1,21 @@ +Por favor, ajude-nos a melhorar o Kontalk! Envie um e-mail para devteam@kontalk.org caso você tenha algum problema ou sugestão. Avaliações negativas não ajudam em nada um projeto sem fins lucrativos. Obrigado! +ATENÇÃO: NÃO INSTALE NO CARTÃO DE MEMÓRIA! + +Siga @kontalknet no Twitter ou no identi.ca para se manter atualizado com as últimas alterações e a situação do serviço! + +Kontalk é um mensageiro instantâneo seguro, que permite que você envie e receba textos, imagens e mensagens de voz (em breve, novos tipos de arquivo) de e para outros usuários Kontalk, completamente livre de taxas (*). + +•  O seu número de telefone é sua identificação. Não são necessários nomes de usuário ou senhas.
+•  Outros usuários Kontalk são encontrados automaticamente através de buscas no seu livro de endereços, o que torna ainda mais fácil começar a conversar com seus amigos.
+•  Criptografia ponto-a-ponto que garante conversas seguras e privadas, dessa forma, somente você e a pessoa com a qual está conversando podem ler as mensagens.
+•  O seu número de telefone e todos os outros usados na rede são criptografados de forma irreversível.
+•  Suporte a múltiplos dispositivos, o que permite que você inicie uma conversa no seu telefone e continue no tablet.
+•  Baseado em padrões abertos: XMPP e OpenPGP. + +Kontalk é um projeto de código aberto, desenvolvido pela comunidade, com todos os fontes hospedados no GitHub (https://github.com/kontalk), o que permite que qualquer um possa verificar sua segurança e aprimorá-lo. + +A infraestrutura da nossa rede é mantida a partir de doações da comunidade. Por isso, caso tenha interesse, você pode fazer doações no site http://kontalk.net/. Qualquer valor conta! + +Você pode encontrar todas as permissões utilizadas por esse aplicativo explicadas nesta página: https://github.com/kontalk/androidclient/wiki/Android-client-permissions . + +(*) Podem ocorrer cobranças de tráfego de dados por parte das operadoras \ No newline at end of file diff --git a/app/src/main/play/listings/pt-BR/short-description.txt b/app/src/main/play/listings/pt-BR/short-description.txt new file mode 100644 index 000000000..18da88adb --- /dev/null +++ b/app/src/main/play/listings/pt-BR/short-description.txt @@ -0,0 +1 @@ +Uma nova forma de comunicação: rede de mensagens mantida pela comunidade. \ No newline at end of file diff --git a/app/src/main/play/listings/pt-BR/title.txt b/app/src/main/play/listings/pt-BR/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/pt-BR/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/pt-PT/short-description.txt b/app/src/main/play/listings/pt-PT/short-description.txt new file mode 100644 index 000000000..0f27ddaec --- /dev/null +++ b/app/src/main/play/listings/pt-PT/short-description.txt @@ -0,0 +1 @@ +Mensagens para a comunidade \ No newline at end of file diff --git a/app/src/main/play/listings/pt-PT/title.txt b/app/src/main/play/listings/pt-PT/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/pt-PT/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/ru-RU/full-description.txt b/app/src/main/play/listings/ru-RU/full-description.txt new file mode 100644 index 000000000..81d044d0a --- /dev/null +++ b/app/src/main/play/listings/ru-RU/full-description.txt @@ -0,0 +1,20 @@ +Давайте развивать Kontalk вместе! Если вы столкнулись с трудностями, пишите нам на devteam@kontalk. org. Плохой отзыв не поможет некоммерческому проекту стать лучше. Спасибо! +ВНИМАНИЕ: НЕ УСТАНАВЛИВАЙТЕ ПРИЛОЖЕНИЕ НА SD КАРТУ! + +Подписывайтесь на @kontalknet в Twitter или identi.ca, чтобы быть в курсе обновлений и состояния сервиса! + +Kontalk — это безопасный клиент мгновенного обмена сообщениями, позволяющий принимать и отправлять текст, картинки и голосовые сообщения (в ближайшем будущем и прочие типы файлов) другим пользователям Kontalk совершенно бесплатно (*). + +•  Ваш номер телефона — ваш идентификатор. Никаких логинов и паролей
+•  Kontalk автоматически находит других пользователей из вашей адресной книги, начать общаться с друзьями в Kontalk очень просто
+•  Шифрование точка-точка гарантирует безопасность личной переписки, её сможете прочесть только вы и ваш адресат
+•  Ваш и все остальные номера телефонов зашифрованы
+•  Поддерживается одновременный вход с многих устройств: начните переписку на телефоне и продолжите её на планшете
+•  Основан на открытых стандартах: XMPP и OpenPGP + +Kontalk — полностью открытый проект сообщества, все исходные коды доступны на GitHub (https://github.com/kontalk).Любой пользователь может проверить безопасность Kontalk и улучшить её. +Сетевая часть поддерживается на деньги от пожертвований сообщества, пожалуйста, пожертвуйте проекту на http://kontalk.net/, если можете. Мы будем рады даже небольшим суммам. + +Приложение просит разрешения получить доступ к некоторым ресурсам, подробные объяснения можно найти здесь: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) Ваш оператор может снимать деньги за обмен данными по интернету \ No newline at end of file diff --git a/app/src/main/play/listings/ru-RU/short-description.txt b/app/src/main/play/listings/ru-RU/short-description.txt new file mode 100644 index 000000000..2f77ec42e --- /dev/null +++ b/app/src/main/play/listings/ru-RU/short-description.txt @@ -0,0 +1 @@ +Общаться по-новому: развиваем сеть обмена сообщениями все вместе. \ No newline at end of file diff --git a/app/src/main/play/listings/ru-RU/title.txt b/app/src/main/play/listings/ru-RU/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/ru-RU/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/sr/full-description.txt b/app/src/main/play/listings/sr/full-description.txt new file mode 100644 index 000000000..8fb924846 --- /dev/null +++ b/app/src/main/play/listings/sr/full-description.txt @@ -0,0 +1,20 @@ +Помозите нам да Контак учинимо бољим! Пошаљите нам поруку на devteam@kontalk.org ако имате проблема! Остављање негативне рецензије неће помоћи непрофитабилном пројекту! Хвала вам! +НАПОМЕНА: НЕ ИНСТАЛИРАЈТЕ НА СД КАРТИЦУ! + +Пратите @kontalknet на Твитеру или на identi.ca да бисте били у току са најновијим изменама и стањем услуге! + +Контак је апликација за безбедно брзогласништво, помоћу које можете да размењујете поруке, слике и гласовне поруке (и остали типови фајлова ће бити убрзо могући) другим корисницима Контака у потпуности бесплатно (*). + +•  Ваш број телефона је ваш ИД. Нема корисничких имена или лозинки
+•  Аутоматски проналази остале кориснике Контака прегледом вашег адресара, па је веома лако ћаскати са вашим пријатељима на Контаку
+•  С-краја-на-крај шифровање обезбеђује сигурне и приватне преписке, само ви и особа са којом се дописујете можете да их прочитате
+•  Ваш и бројеви телефона свих на мрежи су једносмерно шифровани
+•  Подржава много уређаја, па започето ћаскање на телефону можете да наставите и на таблету
+•  Заснован на отвореним стандардима: XMPP и OpenPGP + +Контак је у потпуности пројекат отвореног кôда, изворни кôд је на Гитхабу (https://github.com/kontalk) па било ко може да прегледа његову безбедност и побољша га. +Мрежа се ослања на донације заједнице, да би инфраструктура радила, па вас молимо да донирате на http://kontalk.net/. И најмања донација нам значи. + +Све дозволе које користи апликација су објашњене овде: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) Ваш мрежни провајдер можда наплаћује за интернет услуге \ No newline at end of file diff --git a/app/src/main/play/listings/sr/short-description.txt b/app/src/main/play/listings/sr/short-description.txt new file mode 100644 index 000000000..ce3421985 --- /dev/null +++ b/app/src/main/play/listings/sr/short-description.txt @@ -0,0 +1 @@ +Нови начин комуникације: заједницом потпомогнута брзогласничка мрежа. \ No newline at end of file diff --git a/app/src/main/play/listings/sr/title.txt b/app/src/main/play/listings/sr/title.txt new file mode 100644 index 000000000..7920d949a --- /dev/null +++ b/app/src/main/play/listings/sr/title.txt @@ -0,0 +1 @@ +Контак брзи гласник \ No newline at end of file diff --git a/app/src/main/play/listings/sv-SE/short-description.txt b/app/src/main/play/listings/sv-SE/short-description.txt new file mode 100644 index 000000000..0bd6ac8ac --- /dev/null +++ b/app/src/main/play/listings/sv-SE/short-description.txt @@ -0,0 +1 @@ +Gemenskapsdriven kommunikation \ No newline at end of file diff --git a/app/src/main/play/listings/sv-SE/title.txt b/app/src/main/play/listings/sv-SE/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/sv-SE/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/tr-TR/short-description.txt b/app/src/main/play/listings/tr-TR/short-description.txt new file mode 100644 index 000000000..593fa978c --- /dev/null +++ b/app/src/main/play/listings/tr-TR/short-description.txt @@ -0,0 +1 @@ +Topluluk odaklı mesajlaşma \ No newline at end of file diff --git a/app/src/main/play/listings/tr-TR/title.txt b/app/src/main/play/listings/tr-TR/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/tr-TR/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/uk/full-description.txt b/app/src/main/play/listings/uk/full-description.txt new file mode 100644 index 000000000..470b8c08f --- /dev/null +++ b/app/src/main/play/listings/uk/full-description.txt @@ -0,0 +1,14 @@ +'''Стежте за @kontalknet на Twitter або identi.ca, щоб бути в курсі останніх змін та стану служби!''' + +Kontalk - це безпечний обмін миттєвими повідомленнями, що дозволяє вам надсилати та отримувати текстові, графічні та голосові повідомлення (інші типи файлів незабаром) до та з іншими користувачами Kontalk безкоштовно (*). + +* '''Ваш номер телефону - ваш ідентифікатор.''' Немає імен користувачів або паролів * '''Він автоматично знаходить інших користувачів Kontalk' '', дивлячись на вашу адресну книгу, зробити це ще простіше почавши спілкуватися в Kontalk з друзями * '''Кінцевий рівень шифрування''' забезпечує безпечні та приватні розмови, так що тільки ти та особа, з якою ти розмовляєш, зможе їх прочитати * Ваші та всі телефонні номери, що використовуються в мережі, незворотно зашифровані * Він підтримує кілька пристроїв, що дозволяє дуже легко розпочати чат на телефоні а продовжити розмову на планшеті * Виходячи з відкритих стандартів: XMPP і OpenPGP + +Мережа спирається на пожертви громади, щоб тримати інфраструктуру, тому, якщо можете пожертвуйте на [http://kontalk.net/ kontalk.net]. Навіть невеликі пожертви важливі. + +Ви можете знайти всі дозволи, які використовуються у застосункові, роз'яснення на сторінці [https://github.com/kontalk/androidclient/wiki/Android-client-permissions this page]. + +Ця версія була позбавлена всіх компонентів Google, що означає відсутність підтримки push-сповіщень. + +(*) Вартість послуг, що стягується за Інтернет-трафік, може застосовуватися + diff --git a/app/src/main/play/listings/uk/short-description.txt b/app/src/main/play/listings/uk/short-description.txt new file mode 100644 index 000000000..913bd4999 --- /dev/null +++ b/app/src/main/play/listings/uk/short-description.txt @@ -0,0 +1 @@ +Обмін повідомленнями спільноти \ No newline at end of file diff --git a/app/src/main/play/listings/uk/title.txt b/app/src/main/play/listings/uk/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/uk/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/zh-CN/full-description.txt b/app/src/main/play/listings/zh-CN/full-description.txt new file mode 100644 index 000000000..b901451b1 --- /dev/null +++ b/app/src/main/play/listings/zh-CN/full-description.txt @@ -0,0 +1,20 @@ +请帮助我们让Kontalk变得更好!如果你有任何问题,请发送电子邮件至devteam@kontalk.org!!留下负面评论并不能帮助一个非盈利性的项目!谢谢! +注意:不要安装在SD卡上! + +关注Twitter上的@kontalknet或者identi.ca来追踪最新的更改以及服务器状态! + +Kontalk是一个安全的即时通信工具,她允许你完全免费地发送和接收文本、图片、语音消息(其他文件类型不久后支持)给/从其他Kontalk用户。 + +•  你的手机号码即是你的ID。没有用户名或者密码
+•  她会自动找到其他Kontalk用户通过查询你的地址簿, 这样就能更容易地在Kontalk上与你的朋友联系
+•  端对端加密保证安全与隐私会话,使得只有你和你正在联系的人能阅读它们
+•  你自己和你在网络中使用的全部手机号码都采用的是不可逆的加密
+•  她支持多设备,这样在手机上开始聊天,尔后继续在你的平板上继续会话会变得非常容易
+•  基于开放标准: XMPP与OpenPGP + +Kontalk是一个完全开源的社区项目,所有的源代码都被放置在Github (https://github.com/kontalk),她允许所有人来检查她的安全性并改进她。 +Kontalk网络依赖社区捐赠来保证基础架构的运营,所以如果可能,请考虑在 http://kontalk.net/ 捐赠。即便是一小点也无所谓。 + +你可以在这个页面发现该应用使用的所有权限: https://github.com/kontalk/androidclient/wiki/Android-client-permissions + +(*) 使用运营商网络可能会产生流量费 \ No newline at end of file diff --git a/app/src/main/play/listings/zh-CN/short-description.txt b/app/src/main/play/listings/zh-CN/short-description.txt new file mode 100644 index 000000000..f6ded2f55 --- /dev/null +++ b/app/src/main/play/listings/zh-CN/short-description.txt @@ -0,0 +1 @@ +新的沟通方式:社区驱动的即时消息网络。 \ No newline at end of file diff --git a/app/src/main/play/listings/zh-CN/title.txt b/app/src/main/play/listings/zh-CN/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/zh-CN/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/listings/zh-TW/full-description.txt b/app/src/main/play/listings/zh-TW/full-description.txt new file mode 100644 index 000000000..01f34fcf1 --- /dev/null +++ b/app/src/main/play/listings/zh-TW/full-description.txt @@ -0,0 +1,14 @@ +'''瞭解最新更改與服務狀態,請在 Twitter 或 identi.ca 上關注 @kontalknet!''' + +Kontalk 是一款安全即時通訊工具,可用於在 Kontalk 使用者之間發與接收文字、圖片和語音資訊(很快將支援其他格式檔案),同時完全免費 (*)。 + +* '''電話號碼即 ID。''' 無需使用者名稱和密碼 * 根據通訊錄'''自動查詢其他 Kontalk 使用者''',與 Kontalk 好友聊天更方便 * '''端到端加密''' 確保通話安全與隱私,只有您與對方可檢視對話資訊 * 在本平臺上的全部電話號碼經不可逆加密處理 * 支援多個裝置,方便在手機或平板繼續之前的對話 * 基於開放標準:XMPP 和 OpenPGP + +Kontalk 網路需要來自社群的捐贈,來保持基礎設施的持續運行。如果願意,請贊助我們[http://kontalk.net/ kontalk.net]。即使小額捐贈也會帶來大的變化。 + +有關該應用所需許可權的說明,請訪問[https://github.com/kontalk/androidclient/wiki/Android-client-permissions 本頁面]。 + +該版本已刪除所有谷歌元件,這表示不支援通知推送功能。 + +(*) 可能需向電信商支付流量費用 + diff --git a/app/src/main/play/listings/zh-TW/short-description.txt b/app/src/main/play/listings/zh-TW/short-description.txt new file mode 100644 index 000000000..19dab88c9 --- /dev/null +++ b/app/src/main/play/listings/zh-TW/short-description.txt @@ -0,0 +1 @@ +社群驅動的通訊軟體 \ No newline at end of file diff --git a/app/src/main/play/listings/zh-TW/title.txt b/app/src/main/play/listings/zh-TW/title.txt new file mode 100644 index 000000000..969394449 --- /dev/null +++ b/app/src/main/play/listings/zh-TW/title.txt @@ -0,0 +1 @@ +Kontalk Messenger \ No newline at end of file diff --git a/app/src/main/play/release-notes/ca/default.txt b/app/src/main/play/release-notes/ca/default.txt new file mode 100644 index 000000000..d7ef8bd1d --- /dev/null +++ b/app/src/main/play/release-notes/ca/default.txt @@ -0,0 +1,6 @@ +Nou proveïdor de registre, ofert amablement per https://JMP.Chat +Ajuda'ns a millorar i corregir els errors informant a GitHub o a discutir-los al nou fòrum: https://forum.kontalk.org + +* algunes millores importants en el rendiment +* solucionats certs problemes de permisos +* altres correccions d'errors \ No newline at end of file diff --git a/app/src/main/play/release-notes/cs-CZ/default.txt b/app/src/main/play/release-notes/cs-CZ/default.txt new file mode 100644 index 000000000..11407044f --- /dev/null +++ b/app/src/main/play/release-notes/cs-CZ/default.txt @@ -0,0 +1,8 @@ +This release features our new registration provider kindly offered by https://JMP.Chat +Help us improve and fix bugs by reporting them on GitHub or discussing them on our forum: https://forum.kontalk.org + +* NEW: reply directly from notification (Android 7 and later) +* NEW: support for direct share (Android 6 and later) +* NEW: listen to audio messages directly from the phone speaker +* image thumbnails now respect original image ratio +* many other bugfixes and minor improvements \ No newline at end of file diff --git a/app/src/main/play/release-notes/de-DE/default.txt b/app/src/main/play/release-notes/de-DE/default.txt new file mode 100644 index 000000000..a897a7a40 --- /dev/null +++ b/app/src/main/play/release-notes/de-DE/default.txt @@ -0,0 +1,9 @@ +Registrierungsverfahren angeboten von https://JMP.Chat +Helfen Sie uns bei Verbesserungen und mit Fehlerreports: https://forum.kontalk.org +* Gruppen-Chat-Anzeige in der Chat-Liste +* Unterstützung für Android Oreo +* Unterstützung für mehrere Fenster +* Verbessere den Registrierungsbildschirm +* Schnellere Wiederverbindung bei schlechtem Netzwerk +* Mehrere Abstürze und andere Fehler behoben +* Kartenanzeige repariert \ No newline at end of file diff --git a/app/src/main/play/release-notes/el-GR/default.txt b/app/src/main/play/release-notes/el-GR/default.txt new file mode 100644 index 000000000..654f9d6d4 --- /dev/null +++ b/app/src/main/play/release-notes/el-GR/default.txt @@ -0,0 +1,6 @@ +Έφτασαν οι συνομιλίες σε ομάδες! Βοηθήστε μας με το να αναφέρετε σφάλματα στο GitHub ή στο νέο μας φόρουμ: https://forum.kontalk.org + +* ΝΕΟ: απάντηση σε συγκεκριμένο μήνυμα +* Διόρθωση στο σφάλμα που προκαλούσε κύκλους επανεκκίνησης σε κάποιες συσκευές +* Επιδιόρθωση καθυστερημένης απόδειξης παραλαβής +* πολλές διορθώσεις σφαλμάτων και γενικότερες βελτιώσεις \ No newline at end of file diff --git a/app/src/main/play/release-notes/en-US/default.txt b/app/src/main/play/release-notes/en-US/default.txt new file mode 100644 index 000000000..5039ffd57 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/default.txt @@ -0,0 +1,10 @@ +Registration provider kindly offered by https://JMP.Chat +Help us improve and fix bugs by reporting them on GitHub or discussing them on our forum! + +* Dark theme +* Sharing to groups +* Support for Android 10 +* Media is no longer saved to public storage automatically +* Improve audio recording quality +* Fix contact pictures in notifications for Android 9+ +* Fix several crashes and other bugs \ No newline at end of file diff --git a/app/src/main/play/release-notes/es-ES/default.txt b/app/src/main/play/release-notes/es-ES/default.txt new file mode 100644 index 000000000..3e49e77f4 --- /dev/null +++ b/app/src/main/play/release-notes/es-ES/default.txt @@ -0,0 +1,7 @@ +Esta versión incorpora nuestro nuevo proveedor de registro, ofrecido amablemente por https://JMP.Chat +Ayúdenos a mejorar y corregir los errores informando en GitHub o discutiendo en el nuevo foro: https://forum.kontalk.org + +* NUEVO: respuesta contextual +* workaround para el error de bootloop en los firmware de algunos dispositivos +* solucionado el retraso en la entrega de mensajes +* otros errores solucionados \ No newline at end of file diff --git a/app/src/main/play/release-notes/fr-FR/default.txt b/app/src/main/play/release-notes/fr-FR/default.txt new file mode 100644 index 000000000..12ba53bf4 --- /dev/null +++ b/app/src/main/play/release-notes/fr-FR/default.txt @@ -0,0 +1,7 @@ +* Indication du mode hors ligne sur l'écran principale +* Voir les changements de contact en temps réel +* Problème de perte de message corrigé +* Supprimer automatiquement les utilisateurs expirés +* Autres corrections et améliorations + +Pour voir tous les détails de cette version : https://github.com/kontalk/androidclient/releases/tag/v3.1.1 \ No newline at end of file diff --git a/app/src/main/play/release-notes/it-IT/default.txt b/app/src/main/play/release-notes/it-IT/default.txt new file mode 100644 index 000000000..af19202c3 --- /dev/null +++ b/app/src/main/play/release-notes/it-IT/default.txt @@ -0,0 +1,10 @@ +Questa versione è supportata dal servizio di registrazione offerto da https://JMP.Chat +Aiutaci a migliorarla e correggere bug segnalandoli su GitHub o discutendoli sul nostro forum: https://forum.kontalk.org + +* NUOVO: archivia chat +* NUOVO: copia message multipli negli appunti +* Categorie di notifiche +* Nuove emoji +* Migliorata schermata principale +* Svariati miglioramenti di performance +* Altre correzioni \ No newline at end of file diff --git a/app/src/main/play/release-notes/ja-JP/default.txt b/app/src/main/play/release-notes/ja-JP/default.txt new file mode 100644 index 000000000..350d02060 --- /dev/null +++ b/app/src/main/play/release-notes/ja-JP/default.txt @@ -0,0 +1,8 @@ +このリリースは、親切に https://JMP.Chat によって提供される新しい登録プロバイダを紹介します +GitHub で報告したり、議論したりすることで、バグを改善、修正するのに役立ちます。フォーラム: https://forum.kontalk.org + +* 新規: 通知から直接返信する (Android 7 以降) +* 新規: 直接共有のサポート (Android 6 以降) +* 新規: 電話のスピーカーから直接音声メッセージを聞く +* 画像のサムネイルが元の画像の比率を反映するようになりました +* その他多くのバグフィックスとマイナーな改善 \ No newline at end of file diff --git a/app/src/main/play/release-notes/nl-NL/default.txt b/app/src/main/play/release-notes/nl-NL/default.txt new file mode 100644 index 000000000..291f8941a --- /dev/null +++ b/app/src/main/play/release-notes/nl-NL/default.txt @@ -0,0 +1,9 @@ +Groepsgesprekken zijn er! Help ons verbeteringen aan te brengen of bugs op te lossen door ze te rapporteren op GitHub of ons nieuwe forum: https://forum.kontalk.org + +* NIEUW: schakel versleuteling per chat aan of uit +* NIEUW: intern logboek +* NIEUW: zet favoriete gesprekken vast aan de top van de gesprekkenlijst +* NIEUW: applogo door Giovanni Lauricella +* rangschik voorkeuren op categorie +* betrouwbaardere registratieprocedure +* veel probleemoplossingen en kleine verbeteringen \ No newline at end of file diff --git a/app/src/main/play/release-notes/no-NO/default.txt b/app/src/main/play/release-notes/no-NO/default.txt new file mode 100644 index 000000000..a7ba2b8ad --- /dev/null +++ b/app/src/main/play/release-notes/no-NO/default.txt @@ -0,0 +1,7 @@ +Denne versjonen introduserer en ny registreringstilbyder, snilt tilbudt av https://JMP.Chat +Hjelp oss å forbedre og fiks feil ved å rapportere dem på GitHub eller disktuter dem på vårt forum: https://forum.kontalk.org + +* Nyhet: plasseringsdeling +* Nyhet: Automatisk konvertering av smilefjes til emoji +* Fiks for minnelekasje som førte til kræsj +* Mange andre feilrettinger og små forbedringer \ No newline at end of file diff --git a/app/src/main/play/release-notes/pt-BR/default.txt b/app/src/main/play/release-notes/pt-BR/default.txt new file mode 100644 index 000000000..0ae93418f --- /dev/null +++ b/app/src/main/play/release-notes/pt-BR/default.txt @@ -0,0 +1,8 @@ +Essa versão usa nosso novo provedor de registros, oferecido por https://JMP.Chat +Sugestões e erros podem ser enviados para nosso GitHub ou nosso fórum: https://forum.kontalk.org + +* NOVO: responda diretamente a partir de notificações (Android 7 ou posterior) +* NOVO: suporte a compartilhamento direto (Android 6 ou posterior) +* NOVO: escute as mensagens de áudio diretamente do alto-falante do telefone +* miniaturas nas proporções da imagem original +* várias outras correções de bugs e melhorias \ No newline at end of file diff --git a/app/src/main/play/release-notes/ru-RU/default.txt b/app/src/main/play/release-notes/ru-RU/default.txt new file mode 100644 index 000000000..177ad3976 --- /dev/null +++ b/app/src/main/play/release-notes/ru-RU/default.txt @@ -0,0 +1,11 @@ +В этом релизе новый registration provider (разработка компании https://JMP.Chat) +Помогайте нам исправлять баги, сообщая он них на GitHub или обсуждая их на нашем форуме: https://forum. kontalk.org + +* Возможность сохранять разговоры +* В буфер теперь можно копировать несколько сообщений +* У сообщений появились категории +* Первый разговор: приняв приглашение, необязательно посылать сообщение +* Новые эмоджи +* Доработали экран списка разговоров +* Повысили производительность +* Починили гору багов \ No newline at end of file diff --git a/app/src/main/play/release-notes/sr/default.txt b/app/src/main/play/release-notes/sr/default.txt new file mode 100644 index 000000000..61b59699f --- /dev/null +++ b/app/src/main/play/release-notes/sr/default.txt @@ -0,0 +1,8 @@ +Главна новост овог издања је нови провајдер за регистрацију којег нам је са љубазношћу понудио https://JMP.Chat +Помозите нам да побољшамо и исправимо грешке пријавама на Гитхабу или дискусијом на нашем форуму: https://forum.kontalk.org + +* НОВО: дељење локације +* НОВО: аутоматско претварање текстуалних смешкова у емоџије +* поправљено цурење меморије које је узроковало крахове +* поправљена грешка слике контакта на неким Моторолиним уређајима +* мноштво осталих исправки побољшања перформанси \ No newline at end of file diff --git a/app/src/main/play/release-notes/zh-CN/default.txt b/app/src/main/play/release-notes/zh-CN/default.txt new file mode 100644 index 000000000..231ac8f8e --- /dev/null +++ b/app/src/main/play/release-notes/zh-CN/default.txt @@ -0,0 +1,8 @@ +本次更新包含了新的服务商,它是由https://JMP.Chat慷慨提供的 +你在可以在Github或是在我们的论坛https://forum.kontalk.org来帮助我们提高软件质量和修复Bug + +* 更新:直接从推送回复消息 (安卓7+) +* 更新:对直接分享的支持 (安卓6+) +* 更新:能直接从手机的扬声器听到语音消息的内容 +* 图片缩略图现在将保持原始比例 +* 许多其他的Bug修正和些许质量提升 \ No newline at end of file diff --git a/app/src/main/play/update.sh b/app/src/main/play/update.sh new file mode 100755 index 000000000..d8f2866f5 --- /dev/null +++ b/app/src/main/play/update.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Copy JSON-based store listing into data files for gradle-play-publisher +# Locales with no short or full description will be skipped. +# Licensed under Public Domain + +set -e + +DEFAULT_LOCALE="en-US" +MAX_TITLE_LEN=30 +MAX_SHORT_DESC_LEN=80 +MAX_FULL_DESC_LEN=4000 +MAX_WHATSNEW_LEN=500 + +function extract_json() { + INFILE="$1" + KEY="$2" + cat ${INFILE} | perl -MJSON -Mutf8 -n0777 \ + -E "binmode(STDIN, \":encoding(UTF-8)\"); binmode(STDOUT, \":encoding(UTF-8)\"); \$r = decode_json(\$_); print \$r->{${KEY}}" - +} + +function check_lang() { + INFILE="$1" + LOCALE="$2" + SHORT_DESC=$(cat ${INFILE} | perl -MJSON -Mutf8 -n0777 \ + -E 'binmode(STDIN, ":encoding(UTF-8)"); binmode(STDOUT, ":encoding(UTF-8)"); $r = decode_json($_); print $r->{short_description}' -) + FULL_DESC=$(cat ${INFILE} | perl -MJSON -Mutf8 -n0777 \ + -E 'binmode(STDIN, ":encoding(UTF-8)"); binmode(STDOUT, ":encoding(UTF-8)"); $r = decode_json($_); print $r->{full_description}' -) + SHORT_DESC_LEN=$(expr length "${SHORT_DESC}") + [[ -n "${SHORT_DESC}" ]] && [[ "${SHORT_DESC_LEN}" -le "${MAX_SHORT_DESC_LEN}" ]] && [[ -n "${FULL_DESC}" ]] +} + +function extract_check() { + INFILE="$1" + KEY="$2" + MAX_LEN="$3" + + OUT=$(extract_json ${INFILE} ${KEY}) + OUT_LEN=$(expr length "${OUT}") + [[ -n "${OUT}" ]] && [[ "${OUT_LEN}" -le "${MAX_LEN}" ]] && echo "${OUT}" +} + +function copy_lang() { + INFILE="$1" + LOCALE="$2" + + TITLE=$(extract_check ${INFILE} "title" ${MAX_TITLE_LEN}) + SHORT_DESC=$(extract_check ${INFILE} "short_description" ${MAX_SHORT_DESC_LEN}) + FULL_DESC=$(extract_check ${INFILE} "full_description" ${MAX_FULL_DESC_LEN}) + WHATSNEW=$(extract_check ${INFILE} "recent_changes" ${MAX_WHATSNEW_LEN}) + + if [[ -n "${SHORT_DESC}" ]] && [[ -n "${FULL_DESC}" ]]; then + echo "Updating locale ${LOCALE}" + mkdir -p listings/${LOCALE} release-notes/${LOCALE} + [[ -n "${TITLE}" ]] && echo -n "${TITLE}" >listings/${LOCALE}/title.txt + echo -n "${SHORT_DESC}" >listings/${LOCALE}/short-description.txt + echo -n "${FULL_DESC}" >listings/${LOCALE}/full-description.txt + [[ -n "${WHATSNEW}" ]] && echo -n "${WHATSNEW}" >release-notes/${LOCALE}/default.txt + else + echo "Skipping locale ${LOCALE}" + fi +} + +copy_lang "../../../../dist/google_play.json" ${DEFAULT_LOCALE} + +for IN in ../../../../dist/google_play_*.json; +do + LOCALE=$(basename ${IN} .json | sed 's/google_play_//') + check_lang ${IN} ${LOCALE} && copy_lang ${IN} ${LOCALE} || + echo "Skipping locale ${LOCALE}" +done diff --git a/app/src/main/res/anim/progressbar_interpolator.xml b/app/src/main/res/anim/progressbar_interpolator.xml new file mode 100644 index 000000000..82779497b --- /dev/null +++ b/app/src/main/res/anim/progressbar_interpolator.xml @@ -0,0 +1,22 @@ + + + + diff --git a/app/src/main/res/anim/rotate_indeterminate.xml b/app/src/main/res/anim/rotate_indeterminate.xml new file mode 100644 index 000000000..14cc82575 --- /dev/null +++ b/app/src/main/res/anim/rotate_indeterminate.xml @@ -0,0 +1,7 @@ + + diff --git a/app/src/main/res/anim/slide_down.xml b/app/src/main/res/anim/slide_down.xml new file mode 100644 index 000000000..818e88dc9 --- /dev/null +++ b/app/src/main/res/anim/slide_down.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/anim/slide_up.xml b/app/src/main/res/anim/slide_up.xml new file mode 100644 index 000000000..2211ecbf0 --- /dev/null +++ b/app/src/main/res/anim/slide_up.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/anim/stay.xml b/app/src/main/res/anim/stay.xml new file mode 100644 index 000000000..6c4f6621b --- /dev/null +++ b/app/src/main/res/anim/stay.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/drawable-anydpi/ic_attach.xml b/app/src/main/res/drawable-anydpi/ic_attach.xml new file mode 100644 index 000000000..f81506589 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_attach.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_emoji.xml b/app/src/main/res/drawable-anydpi/ic_emoji.xml new file mode 100644 index 000000000..257b80f04 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_emoji.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_keyboard.xml b/app/src/main/res/drawable-anydpi/ic_keyboard.xml new file mode 100644 index 000000000..04e0a85da --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_keyboard.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_menu_save.xml b/app/src/main/res/drawable-anydpi/ic_menu_save.xml new file mode 100644 index 000000000..9977bf432 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_menu_save.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_mic.xml b/app/src/main/res/drawable-anydpi/ic_mic.xml new file mode 100644 index 000000000..86b836a5b --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_mic.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_mic_disabled.xml b/app/src/main/res/drawable-anydpi/ic_mic_disabled.xml new file mode 100644 index 000000000..f08c95366 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_mic_disabled.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_mic_pressed.xml b/app/src/main/res/drawable-anydpi/ic_mic_pressed.xml new file mode 100644 index 000000000..01a9f4dd5 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_mic_pressed.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_about.xml b/app/src/main/res/drawable-anydpi/ic_pref_about.xml new file mode 100644 index 000000000..6b7a4b47b --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_about.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_account.xml b/app/src/main/res/drawable-anydpi/ic_pref_account.xml new file mode 100644 index 000000000..8f19db73b --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_account.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_appearance.xml b/app/src/main/res/drawable-anydpi/ic_pref_appearance.xml new file mode 100644 index 000000000..7daf90fa4 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_appearance.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_balloons.xml b/app/src/main/res/drawable-anydpi/ic_pref_balloons.xml new file mode 100644 index 000000000..b1557a19c --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_balloons.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_delete_account.xml b/app/src/main/res/drawable-anydpi/ic_pref_delete_account.xml new file mode 100644 index 000000000..39775aa33 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_delete_account.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_donate.xml b/app/src/main/res/drawable-anydpi/ic_pref_donate.xml new file mode 100644 index 000000000..1057b74c3 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_donate.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_font_size.xml b/app/src/main/res/drawable-anydpi/ic_pref_font_size.xml new file mode 100644 index 000000000..519d65eec --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_font_size.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_help.xml b/app/src/main/res/drawable-anydpi/ic_pref_help.xml new file mode 100644 index 000000000..55959bd7c --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_help.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_image_resize.xml b/app/src/main/res/drawable-anydpi/ic_pref_image_resize.xml new file mode 100644 index 000000000..65637058a --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_image_resize.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_location.xml b/app/src/main/res/drawable-anydpi/ic_pref_location.xml new file mode 100644 index 000000000..837d4390c --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_location.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_maintenance.xml b/app/src/main/res/drawable-anydpi/ic_pref_maintenance.xml new file mode 100644 index 000000000..5f15831e0 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_maintenance.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_maps_service.xml b/app/src/main/res/drawable-anydpi/ic_pref_maps_service.xml new file mode 100644 index 000000000..303d126ca --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_maps_service.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_media.xml b/app/src/main/res/drawable-anydpi/ic_pref_media.xml new file mode 100644 index 000000000..c0c99931b --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_media.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_messaging.xml b/app/src/main/res/drawable-anydpi/ic_pref_messaging.xml new file mode 100644 index 000000000..e86560fc4 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_messaging.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_network.xml b/app/src/main/res/drawable-anydpi/ic_pref_network.xml new file mode 100644 index 000000000..07afc6c90 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_network.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_notification.xml b/app/src/main/res/drawable-anydpi/ic_pref_notification.xml new file mode 100644 index 000000000..34539fdf3 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_notification.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_privacy.xml b/app/src/main/res/drawable-anydpi/ic_pref_privacy.xml new file mode 100644 index 000000000..e1c484f6f --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_privacy.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_register_device.xml b/app/src/main/res/drawable-anydpi/ic_pref_register_device.xml new file mode 100644 index 000000000..850ef0d1c --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_register_device.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_restart_msgcenter.xml b/app/src/main/res/drawable-anydpi/ic_pref_restart_msgcenter.xml new file mode 100644 index 000000000..ce71cf6ca --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_restart_msgcenter.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_send_debug_log.xml b/app/src/main/res/drawable-anydpi/ic_pref_send_debug_log.xml new file mode 100644 index 000000000..c0e358675 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_send_debug_log.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_service_terms.xml b/app/src/main/res/drawable-anydpi/ic_pref_service_terms.xml new file mode 100644 index 000000000..04aabc7a3 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_service_terms.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_pref_vibrate.xml b/app/src/main/res/drawable-anydpi/ic_pref_vibrate.xml new file mode 100644 index 000000000..066dd2ede --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_pref_vibrate.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_send.xml b/app/src/main/res/drawable-anydpi/ic_send.xml new file mode 100644 index 000000000..829e0568d --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_send.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_send_disabled.xml b/app/src/main/res/drawable-anydpi/ic_send_disabled.xml new file mode 100644 index 000000000..1cfd19428 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_send_disabled.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-hdpi-v10/ic_stat_notify.png b/app/src/main/res/drawable-hdpi-v10/ic_stat_notify.png deleted file mode 100644 index 72ec69701..000000000 Binary files a/app/src/main/res/drawable-hdpi-v10/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi-v11/ic_menu_contact.png b/app/src/main/res/drawable-hdpi-v11/ic_menu_contact.png deleted file mode 100644 index 97fa61a19..000000000 Binary files a/app/src/main/res/drawable-hdpi-v11/ic_menu_contact.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi-v11/ic_stat_notify.png b/app/src/main/res/drawable-hdpi-v11/ic_stat_notify.png deleted file mode 100644 index 48982d5e2..000000000 Binary files a/app/src/main/res/drawable-hdpi-v11/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi-v9/ic_stat_notify.png b/app/src/main/res/drawable-hdpi-v9/ic_stat_notify.png deleted file mode 100644 index 72ec69701..000000000 Binary files a/app/src/main/res/drawable-hdpi-v9/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/app_background.png b/app/src/main/res/drawable-hdpi/app_background.png new file mode 100644 index 000000000..3a94a3f70 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/app_background.png differ diff --git a/app/src/main/res/drawable-hdpi/attach_audio.png b/app/src/main/res/drawable-hdpi/attach_audio.png new file mode 100644 index 000000000..a5c7a37ca Binary files /dev/null and b/app/src/main/res/drawable-hdpi/attach_audio.png differ diff --git a/app/src/main/res/drawable-hdpi/attach_camera.png b/app/src/main/res/drawable-hdpi/attach_camera.png new file mode 100644 index 000000000..1c38eae49 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/attach_camera.png differ diff --git a/app/src/main/res/drawable-hdpi/attach_contact.png b/app/src/main/res/drawable-hdpi/attach_contact.png new file mode 100644 index 000000000..147f8b75e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/attach_contact.png differ diff --git a/app/src/main/res/drawable-hdpi/attach_file.png b/app/src/main/res/drawable-hdpi/attach_file.png new file mode 100644 index 000000000..26abdde73 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/attach_file.png differ diff --git a/app/src/main/res/drawable-hdpi/attach_gallery.png b/app/src/main/res/drawable-hdpi/attach_gallery.png new file mode 100644 index 000000000..e207259c6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/attach_gallery.png differ diff --git a/app/src/main/res/drawable-hdpi/attach_hide1.png b/app/src/main/res/drawable-hdpi/attach_hide1.png new file mode 100644 index 000000000..0c8ad0b7a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/attach_hide1.png differ diff --git a/app/src/main/res/drawable-hdpi/attach_location.png b/app/src/main/res/drawable-hdpi/attach_location.png new file mode 100644 index 000000000..8e76cff4f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/attach_location.png differ diff --git a/app/src/main/res/drawable-hdpi/attach_video.png b/app/src/main/res/drawable-hdpi/attach_video.png new file mode 100644 index 000000000..7bb7aca4f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/attach_video.png differ diff --git a/app/src/main/res/drawable-hdpi/balloon_classic_incoming.9.png b/app/src/main/res/drawable-hdpi/balloon_classic_incoming.9.png new file mode 100644 index 000000000..52520357a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/balloon_classic_incoming.9.png differ diff --git a/app/src/main/res/drawable-hdpi/balloon_classic_outgoing.9.png b/app/src/main/res/drawable-hdpi/balloon_classic_outgoing.9.png new file mode 100644 index 000000000..edcc8cee1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/balloon_classic_outgoing.9.png differ diff --git a/app/src/main/res/drawable-hdpi/balloon_iphone_incoming.9.png b/app/src/main/res/drawable-hdpi/balloon_iphone_incoming.9.png new file mode 100644 index 000000000..6133ee543 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/balloon_iphone_incoming.9.png differ diff --git a/app/src/main/res/drawable-hdpi/balloon_iphone_outgoing.9.png b/app/src/main/res/drawable-hdpi/balloon_iphone_outgoing.9.png new file mode 100644 index 000000000..d0dabdd56 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/balloon_iphone_outgoing.9.png differ diff --git a/app/src/main/res/drawable-hdpi/balloon_old_classic_incoming.9.png b/app/src/main/res/drawable-hdpi/balloon_old_classic_incoming.9.png new file mode 100644 index 000000000..93e56a33d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/balloon_old_classic_incoming.9.png differ diff --git a/app/src/main/res/drawable-hdpi/balloon_old_classic_outgoing.9.png b/app/src/main/res/drawable-hdpi/balloon_old_classic_outgoing.9.png new file mode 100644 index 000000000..83d9f930f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/balloon_old_classic_outgoing.9.png differ diff --git a/app/src/main/res/drawable-hdpi/balloon_silence_incoming.9.png b/app/src/main/res/drawable-hdpi/balloon_silence_incoming.9.png new file mode 100644 index 000000000..8a4d76321 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/balloon_silence_incoming.9.png differ diff --git a/app/src/main/res/drawable-hdpi/balloon_silence_outgoing.9.png b/app/src/main/res/drawable-hdpi/balloon_silence_outgoing.9.png new file mode 100644 index 000000000..a56260481 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/balloon_silence_outgoing.9.png differ diff --git a/app/src/main/res/drawable-hdpi/bar_mic.png b/app/src/main/res/drawable-hdpi/bar_mic.png deleted file mode 100755 index 75e7bbf83..000000000 Binary files a/app/src/main/res/drawable-hdpi/bar_mic.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/bar_mic_pressed.png b/app/src/main/res/drawable-hdpi/bar_mic_pressed.png deleted file mode 100755 index 3cf43e04b..000000000 Binary files a/app/src/main/res/drawable-hdpi/bar_mic_pressed.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/bar_rec.png b/app/src/main/res/drawable-hdpi/bar_rec.png deleted file mode 100755 index a3ed5835a..000000000 Binary files a/app/src/main/res/drawable-hdpi/bar_rec.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/bottombar_landscape_565.png b/app/src/main/res/drawable-hdpi/bottombar_landscape_565.png deleted file mode 100644 index 970ebaa0e..000000000 Binary files a/app/src/main/res/drawable-hdpi/bottombar_landscape_565.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/download_audio.png b/app/src/main/res/drawable-hdpi/download_audio.png old mode 100755 new mode 100644 diff --git a/app/src/main/res/drawable-hdpi/ic_attach.png b/app/src/main/res/drawable-hdpi/ic_attach.png new file mode 100644 index 000000000..41b328f26 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_attach.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_attach_audio.png b/app/src/main/res/drawable-hdpi/ic_attach_audio.png new file mode 100644 index 000000000..f714c92f6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_attach_audio.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_attach_contact.png b/app/src/main/res/drawable-hdpi/ic_attach_contact.png new file mode 100644 index 000000000..ffb92c809 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_attach_contact.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_attach_picture.png b/app/src/main/res/drawable-hdpi/ic_attach_picture.png new file mode 100644 index 000000000..9650d848d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_attach_picture.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_close.png b/app/src/main/res/drawable-hdpi/ic_close.png new file mode 100644 index 000000000..ef87ce844 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_close.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_contact_picture.png b/app/src/main/res/drawable-hdpi/ic_contact_picture.png deleted file mode 100644 index 1ea44c675..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_contact_picture.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_create_white.png b/app/src/main/res/drawable-hdpi/ic_create_white.png new file mode 100644 index 000000000..d3ff58dcc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_create_white.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_denied.png b/app/src/main/res/drawable-hdpi/ic_denied.png new file mode 100644 index 000000000..2a7592144 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_denied.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_emoji.png b/app/src/main/res/drawable-hdpi/ic_emoji.png new file mode 100644 index 000000000..ecedd33cd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_emoji.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_globe.png b/app/src/main/res/drawable-hdpi/ic_globe.png new file mode 100644 index 000000000..609f7ee8b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_globe.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_indicator_group.png b/app/src/main/res/drawable-hdpi/ic_indicator_group.png new file mode 100644 index 000000000..f3a5667ff Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_indicator_group.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_keyboard.png b/app/src/main/res/drawable-hdpi/ic_keyboard.png new file mode 100644 index 000000000..760ee9810 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_keyboard.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_keyboard_dark.png b/app/src/main/res/drawable-hdpi/ic_keyboard_dark.png deleted file mode 100644 index bb6a88a35..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_keyboard_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_launcher.png b/app/src/main/res/drawable-hdpi/ic_launcher.png deleted file mode 100644 index df7cb5ebe..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_launcher_audio.png b/app/src/main/res/drawable-hdpi/ic_launcher_audio.png deleted file mode 100644 index 6f276ebaf..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_launcher_audio.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_launcher_contacts.png b/app/src/main/res/drawable-hdpi/ic_launcher_contacts.png deleted file mode 100644 index 69a72c4d9..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_launcher_contacts.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_launcher_gallery.png b/app/src/main/res/drawable-hdpi/ic_launcher_gallery.png deleted file mode 100644 index 3e39baa38..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_launcher_gallery.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_live_location_shadow.png b/app/src/main/res/drawable-hdpi/ic_live_location_shadow.png new file mode 100644 index 000000000..92e315466 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_live_location_shadow.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_map_pin_google.png b/app/src/main/res/drawable-hdpi/ic_map_pin_google.png new file mode 100644 index 000000000..d3b369f31 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_map_pin_google.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_archive.png b/app/src/main/res/drawable-hdpi/ic_menu_archive.png new file mode 100644 index 000000000..bb72e890f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_archive.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_attach.png b/app/src/main/res/drawable-hdpi/ic_menu_attach.png index dab4d2db5..29dfa5da2 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_attach.png and b/app/src/main/res/drawable-hdpi/ic_menu_attach.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_block.png b/app/src/main/res/drawable-hdpi/ic_menu_block.png index 7e716c410..2ccc89d24 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_block.png and b/app/src/main/res/drawable-hdpi/ic_menu_block.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_call.png b/app/src/main/res/drawable-hdpi/ic_menu_call.png index 2ccc087f6..4dc506515 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_call.png and b/app/src/main/res/drawable-hdpi/ic_menu_call.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_call_black.png b/app/src/main/res/drawable-hdpi/ic_menu_call_black.png new file mode 100644 index 000000000..d4077acf9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_call_black.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_cancel.png b/app/src/main/res/drawable-hdpi/ic_menu_cancel.png new file mode 100644 index 000000000..73f5116bd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_cancel.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_chat.png b/app/src/main/res/drawable-hdpi/ic_menu_chat.png new file mode 100644 index 000000000..d2cfdf1f6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_chat.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_check.png b/app/src/main/res/drawable-hdpi/ic_menu_check.png new file mode 100644 index 000000000..729f29010 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_check.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_compose.png b/app/src/main/res/drawable-hdpi/ic_menu_compose.png deleted file mode 100644 index 591045815..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_compose.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_contact.png b/app/src/main/res/drawable-hdpi/ic_menu_contact.png index 6afda6c40..56708b0ba 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_contact.png and b/app/src/main/res/drawable-hdpi/ic_menu_contact.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_copy.png b/app/src/main/res/drawable-hdpi/ic_menu_copy.png new file mode 100644 index 000000000..70eb07378 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_copy.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_delete.png b/app/src/main/res/drawable-hdpi/ic_menu_delete.png new file mode 100644 index 000000000..4a9f76947 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_delete.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_donate.png b/app/src/main/res/drawable-hdpi/ic_menu_donate.png new file mode 100644 index 000000000..ee833c667 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_donate.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_download.png b/app/src/main/res/drawable-hdpi/ic_menu_download.png new file mode 100644 index 000000000..c8a2039c5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_download.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_exit.png b/app/src/main/res/drawable-hdpi/ic_menu_exit.png new file mode 100644 index 000000000..52565d09d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_exit.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_favorite.png b/app/src/main/res/drawable-hdpi/ic_menu_favorite.png new file mode 100644 index 000000000..86eecdd4a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_favorite.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_group.png b/app/src/main/res/drawable-hdpi/ic_menu_group.png new file mode 100644 index 000000000..ff698afc0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_group.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_help.png b/app/src/main/res/drawable-hdpi/ic_menu_help.png new file mode 100644 index 000000000..5664f9532 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_help.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_info_details.png b/app/src/main/res/drawable-hdpi/ic_menu_info_details.png new file mode 100644 index 000000000..c7b1113cf Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_info_details.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_invite.png b/app/src/main/res/drawable-hdpi/ic_menu_invite.png index 3cb129fd3..10ae5a70c 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_invite.png and b/app/src/main/res/drawable-hdpi/ic_menu_invite.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_key.png b/app/src/main/res/drawable-hdpi/ic_menu_key.png new file mode 100644 index 000000000..0b6a1dfd0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_key.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_maps.png b/app/src/main/res/drawable-hdpi/ic_menu_maps.png new file mode 100644 index 000000000..b7631d7d7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_maps.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_offline.png b/app/src/main/res/drawable-hdpi/ic_menu_offline.png new file mode 100644 index 000000000..4f8165f13 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_offline.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_online.png b/app/src/main/res/drawable-hdpi/ic_menu_online.png new file mode 100644 index 000000000..718945c5e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_online.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_open.png b/app/src/main/res/drawable-hdpi/ic_menu_open.png new file mode 100644 index 000000000..49726bcbf Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_open.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_overflow_normal.png b/app/src/main/res/drawable-hdpi/ic_menu_overflow_normal.png deleted file mode 100644 index 5f4d43b6d..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_overflow_normal.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_preferences.png b/app/src/main/res/drawable-hdpi/ic_menu_preferences.png index 4ed8119e3..97ded33b5 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_preferences.png and b/app/src/main/res/drawable-hdpi/ic_menu_preferences.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_refresh.png b/app/src/main/res/drawable-hdpi/ic_menu_refresh.png index 58da6aa52..ffa7be933 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_refresh.png and b/app/src/main/res/drawable-hdpi/ic_menu_refresh.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_reply.png b/app/src/main/res/drawable-hdpi/ic_menu_reply.png new file mode 100644 index 000000000..66c46dd40 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_reply.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_save.png b/app/src/main/res/drawable-hdpi/ic_menu_save.png new file mode 100644 index 000000000..ad56bbf02 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_save.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_search.png b/app/src/main/res/drawable-hdpi/ic_menu_search.png index 0740a7068..bbfbc96cb 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_search.png and b/app/src/main/res/drawable-hdpi/ic_menu_search.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_send.png b/app/src/main/res/drawable-hdpi/ic_menu_send.png new file mode 100644 index 000000000..0f0017201 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_send.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_share.png b/app/src/main/res/drawable-hdpi/ic_menu_share.png new file mode 100644 index 000000000..b09a6926d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_share.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_start_conversation.png b/app/src/main/res/drawable-hdpi/ic_menu_start_conversation.png deleted file mode 100644 index 0dfc02470..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_start_conversation.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_status.png b/app/src/main/res/drawable-hdpi/ic_menu_status.png index 602dd1041..11d4e655b 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_status.png and b/app/src/main/res/drawable-hdpi/ic_menu_status.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_unarchive.png b/app/src/main/res/drawable-hdpi/ic_menu_unarchive.png new file mode 100644 index 000000000..18730f12f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_unarchive.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_unlock.png b/app/src/main/res/drawable-hdpi/ic_menu_unlock.png new file mode 100644 index 000000000..6bae68f56 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_unlock.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_mic.png b/app/src/main/res/drawable-hdpi/ic_mic.png new file mode 100644 index 000000000..f70c7b100 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_mic.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_mic_disabled.png b/app/src/main/res/drawable-hdpi/ic_mic_disabled.png new file mode 100644 index 000000000..b014508b0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_mic_disabled.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_mic_pressed.png b/app/src/main/res/drawable-hdpi/ic_mic_pressed.png new file mode 100644 index 000000000..1eee5da47 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_mic_pressed.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_msg_delivered.png b/app/src/main/res/drawable-hdpi/ic_msg_delivered.png new file mode 100644 index 000000000..67f4d788f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_msg_error.png b/app/src/main/res/drawable-hdpi/ic_msg_error.png new file mode 100644 index 000000000..d39054a3b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-hdpi/ic_msg_notdelivered.png new file mode 100644 index 000000000..4daa90ff6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_msg_pending.png b/app/src/main/res/drawable-hdpi/ic_msg_pending.png new file mode 100644 index 000000000..1abc666a2 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_msg_security.png b/app/src/main/res/drawable-hdpi/ic_msg_security.png new file mode 100644 index 000000000..13e58f907 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_msg_security.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_msg_sent.png b/app/src/main/res/drawable-hdpi/ic_msg_sent.png new file mode 100644 index 000000000..39040cb10 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_msg_star.png b/app/src/main/res/drawable-hdpi/ic_msg_star.png new file mode 100644 index 000000000..c65855c6f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_msg_warning.png b/app/src/main/res/drawable-hdpi/ic_msg_warning.png new file mode 100644 index 000000000..7f6e45da0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_my_location.png b/app/src/main/res/drawable-hdpi/ic_my_location.png new file mode 100755 index 000000000..a663921d9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_my_location.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_navigate.png b/app/src/main/res/drawable-hdpi/ic_navigate.png new file mode 100644 index 000000000..5bf5640aa Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_navigate.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pin.png b/app/src/main/res/drawable-hdpi/ic_pin.png new file mode 100755 index 000000000..81fe1629a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pin.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pin_x.png b/app/src/main/res/drawable-hdpi/ic_pin_x.png new file mode 100755 index 000000000..734a1466a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pin_x.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_about.png b/app/src/main/res/drawable-hdpi/ic_pref_about.png new file mode 100644 index 000000000..420de21dd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_about.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_account.png b/app/src/main/res/drawable-hdpi/ic_pref_account.png new file mode 100644 index 000000000..6885255a1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_account.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_appearance.png b/app/src/main/res/drawable-hdpi/ic_pref_appearance.png new file mode 100644 index 000000000..6b39b1b51 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_appearance.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_balloons.png b/app/src/main/res/drawable-hdpi/ic_pref_balloons.png new file mode 100644 index 000000000..1d4af401d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_balloons.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_delete_account.png b/app/src/main/res/drawable-hdpi/ic_pref_delete_account.png new file mode 100644 index 000000000..4613b5b67 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_delete_account.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_donate.png b/app/src/main/res/drawable-hdpi/ic_pref_donate.png new file mode 100644 index 000000000..dba2a6498 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_donate.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_font_size.png b/app/src/main/res/drawable-hdpi/ic_pref_font_size.png new file mode 100644 index 000000000..099a4b696 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_font_size.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_help.png b/app/src/main/res/drawable-hdpi/ic_pref_help.png new file mode 100644 index 000000000..6ab6dc855 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_help.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_image_resize.png b/app/src/main/res/drawable-hdpi/ic_pref_image_resize.png new file mode 100644 index 000000000..9cf2ce776 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_image_resize.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_location.png b/app/src/main/res/drawable-hdpi/ic_pref_location.png new file mode 100644 index 000000000..903060a4c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_location.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_maintenance.png b/app/src/main/res/drawable-hdpi/ic_pref_maintenance.png new file mode 100644 index 000000000..07abef7f7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_maintenance.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_maps_service.png b/app/src/main/res/drawable-hdpi/ic_pref_maps_service.png new file mode 100644 index 000000000..e98a9c8df Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_maps_service.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_media.png b/app/src/main/res/drawable-hdpi/ic_pref_media.png new file mode 100644 index 000000000..f2fb2b58d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_media.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_messaging.png b/app/src/main/res/drawable-hdpi/ic_pref_messaging.png new file mode 100644 index 000000000..506d4413b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_messaging.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_network.png b/app/src/main/res/drawable-hdpi/ic_pref_network.png new file mode 100644 index 000000000..496d4e264 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_network.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_notification.png b/app/src/main/res/drawable-hdpi/ic_pref_notification.png new file mode 100644 index 000000000..d969880bb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_notification.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_privacy.png b/app/src/main/res/drawable-hdpi/ic_pref_privacy.png new file mode 100644 index 000000000..b7aa83343 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_privacy.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_register_device.png b/app/src/main/res/drawable-hdpi/ic_pref_register_device.png new file mode 100644 index 000000000..c42ee6a8c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_register_device.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_restart_msgcenter.png b/app/src/main/res/drawable-hdpi/ic_pref_restart_msgcenter.png new file mode 100644 index 000000000..f18eb5ef8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_restart_msgcenter.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_send_debug_log.png b/app/src/main/res/drawable-hdpi/ic_pref_send_debug_log.png new file mode 100644 index 000000000..36a39f2c4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_send_debug_log.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_service_terms.png b/app/src/main/res/drawable-hdpi/ic_pref_service_terms.png new file mode 100644 index 000000000..e50b76b0f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_service_terms.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pref_vibrate.png b/app/src/main/res/drawable-hdpi/ic_pref_vibrate.png new file mode 100644 index 000000000..2617b13c9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pref_vibrate.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_rec.png b/app/src/main/res/drawable-hdpi/ic_rec.png new file mode 100644 index 000000000..37319a2c1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_rec.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_send.png b/app/src/main/res/drawable-hdpi/ic_send.png new file mode 100644 index 000000000..1ee558ad7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_send.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_send_disabled.png b/app/src/main/res/drawable-hdpi/ic_send_disabled.png new file mode 100644 index 000000000..8de60ae02 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_send_disabled.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_send_disabled_holo_light.png b/app/src/main/res/drawable-hdpi/ic_send_disabled_holo_light.png deleted file mode 100644 index 81bd9a150..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_send_disabled_holo_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_send_holo_light.png b/app/src/main/res/drawable-hdpi/ic_send_holo_light.png deleted file mode 100644 index 4e5d51c9e..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_send_holo_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_stat_notify.png b/app/src/main/res/drawable-hdpi/ic_stat_notify.png index 929207d3f..48982d5e2 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_stat_notify.png and b/app/src/main/res/drawable-hdpi/ic_stat_notify.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_trust_ignored.png b/app/src/main/res/drawable-hdpi/ic_trust_ignored.png new file mode 100644 index 000000000..2cee4e1b6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_trust_ignored.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_trust_unknown.png b/app/src/main/res/drawable-hdpi/ic_trust_unknown.png new file mode 100644 index 000000000..d166ae59a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_trust_unknown.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_trust_verified.png b/app/src/main/res/drawable-hdpi/ic_trust_verified.png new file mode 100644 index 000000000..2c9b3335d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_trust_verified.png differ diff --git a/app/src/main/res/drawable-hdpi/list_item_focused_background.9.png b/app/src/main/res/drawable-hdpi/list_item_focused_background.9.png new file mode 100644 index 000000000..e20b02d52 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/list_item_focused_background.9.png differ diff --git a/app/src/main/res/drawable-hdpi/list_item_pressed_background.9.png b/app/src/main/res/drawable-hdpi/list_item_pressed_background.9.png new file mode 100644 index 000000000..30591e423 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/list_item_pressed_background.9.png differ diff --git a/app/src/main/res/drawable-hdpi/pause_balloon.png b/app/src/main/res/drawable-hdpi/pause_balloon.png old mode 100755 new mode 100644 index 60e19f033..898e71bb6 Binary files a/app/src/main/res/drawable-hdpi/pause_balloon.png and b/app/src/main/res/drawable-hdpi/pause_balloon.png differ diff --git a/app/src/main/res/drawable-hdpi/play_balloon.png b/app/src/main/res/drawable-hdpi/play_balloon.png old mode 100755 new mode 100644 index eaeddee24..2ad7a45a3 Binary files a/app/src/main/res/drawable-hdpi/play_balloon.png and b/app/src/main/res/drawable-hdpi/play_balloon.png differ diff --git a/app/src/main/res/drawable-hdpi/slide_arrow.png b/app/src/main/res/drawable-hdpi/slide_arrow.png old mode 100755 new mode 100644 diff --git a/app/src/main/res/drawable-ldpi-v10/ic_stat_notify.png b/app/src/main/res/drawable-ldpi-v10/ic_stat_notify.png deleted file mode 100644 index 55b7188b2..000000000 Binary files a/app/src/main/res/drawable-ldpi-v10/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldpi-v11/ic_menu_contact.png b/app/src/main/res/drawable-ldpi-v11/ic_menu_contact.png deleted file mode 100644 index d1ffb9506..000000000 Binary files a/app/src/main/res/drawable-ldpi-v11/ic_menu_contact.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldpi-v11/ic_stat_notify.png b/app/src/main/res/drawable-ldpi-v11/ic_stat_notify.png deleted file mode 100644 index 0754378a5..000000000 Binary files a/app/src/main/res/drawable-ldpi-v11/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldpi-v9/ic_stat_notify.png b/app/src/main/res/drawable-ldpi-v9/ic_stat_notify.png deleted file mode 100644 index 55b7188b2..000000000 Binary files a/app/src/main/res/drawable-ldpi-v9/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldpi/app_background.png b/app/src/main/res/drawable-ldpi/app_background.png new file mode 100644 index 000000000..2b79a7a60 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/app_background.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_attach_audio.png b/app/src/main/res/drawable-ldpi/ic_attach_audio.png new file mode 100644 index 000000000..a77ed5197 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_attach_audio.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_attach_contact.png b/app/src/main/res/drawable-ldpi/ic_attach_contact.png new file mode 100644 index 000000000..78901c9b1 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_attach_contact.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_attach_picture.png b/app/src/main/res/drawable-ldpi/ic_attach_picture.png new file mode 100644 index 000000000..eec2c5ef9 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_attach_picture.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_create_white.png b/app/src/main/res/drawable-ldpi/ic_create_white.png new file mode 100644 index 000000000..9fd99be75 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_create_white.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_launcher.png b/app/src/main/res/drawable-ldpi/ic_launcher.png deleted file mode 100644 index bbeb1be5d..000000000 Binary files a/app/src/main/res/drawable-ldpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_attach.png b/app/src/main/res/drawable-ldpi/ic_menu_attach.png index 300ece6fb..b98b79c7c 100644 Binary files a/app/src/main/res/drawable-ldpi/ic_menu_attach.png and b/app/src/main/res/drawable-ldpi/ic_menu_attach.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_block.png b/app/src/main/res/drawable-ldpi/ic_menu_block.png index c8d80cd10..948b98aeb 100644 Binary files a/app/src/main/res/drawable-ldpi/ic_menu_block.png and b/app/src/main/res/drawable-ldpi/ic_menu_block.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_call.png b/app/src/main/res/drawable-ldpi/ic_menu_call.png new file mode 100644 index 000000000..6aa91ce05 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_call.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_cancel.png b/app/src/main/res/drawable-ldpi/ic_menu_cancel.png new file mode 100644 index 000000000..5a15bb1b1 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_cancel.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_chat.png b/app/src/main/res/drawable-ldpi/ic_menu_chat.png new file mode 100644 index 000000000..901aa80bf Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_chat.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_compose.png b/app/src/main/res/drawable-ldpi/ic_menu_compose.png deleted file mode 100644 index 7f118500d..000000000 Binary files a/app/src/main/res/drawable-ldpi/ic_menu_compose.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_contact.png b/app/src/main/res/drawable-ldpi/ic_menu_contact.png new file mode 100644 index 000000000..3259ecf20 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_contact.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_copy.png b/app/src/main/res/drawable-ldpi/ic_menu_copy.png new file mode 100644 index 000000000..4360683ee Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_copy.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_delete.png b/app/src/main/res/drawable-ldpi/ic_menu_delete.png new file mode 100644 index 000000000..9ff2034c9 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_delete.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_donate.png b/app/src/main/res/drawable-ldpi/ic_menu_donate.png new file mode 100644 index 000000000..f621c5a61 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_donate.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_download.png b/app/src/main/res/drawable-ldpi/ic_menu_download.png new file mode 100644 index 000000000..f079e9b11 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_download.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_exit.png b/app/src/main/res/drawable-ldpi/ic_menu_exit.png new file mode 100644 index 000000000..fd21a093c Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_exit.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_info_details.png b/app/src/main/res/drawable-ldpi/ic_menu_info_details.png new file mode 100644 index 000000000..747eec0ea Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_info_details.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_invite.png b/app/src/main/res/drawable-ldpi/ic_menu_invite.png new file mode 100644 index 000000000..b40e2f855 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_invite.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_key.png b/app/src/main/res/drawable-ldpi/ic_menu_key.png new file mode 100644 index 000000000..b6f214681 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_key.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_offline.png b/app/src/main/res/drawable-ldpi/ic_menu_offline.png new file mode 100644 index 000000000..fa85741e1 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_offline.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_online.png b/app/src/main/res/drawable-ldpi/ic_menu_online.png new file mode 100644 index 000000000..712bc0180 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_online.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_open.png b/app/src/main/res/drawable-ldpi/ic_menu_open.png new file mode 100644 index 000000000..f92a8036f Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_open.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_overflow_normal.png b/app/src/main/res/drawable-ldpi/ic_menu_overflow_normal.png deleted file mode 100644 index 3e5687750..000000000 Binary files a/app/src/main/res/drawable-ldpi/ic_menu_overflow_normal.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_preferences.png b/app/src/main/res/drawable-ldpi/ic_menu_preferences.png index 26cfea4ca..7b80eefc1 100644 Binary files a/app/src/main/res/drawable-ldpi/ic_menu_preferences.png and b/app/src/main/res/drawable-ldpi/ic_menu_preferences.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_refresh.png b/app/src/main/res/drawable-ldpi/ic_menu_refresh.png index 537f4eab4..7ef5f5c15 100644 Binary files a/app/src/main/res/drawable-ldpi/ic_menu_refresh.png and b/app/src/main/res/drawable-ldpi/ic_menu_refresh.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_reply.png b/app/src/main/res/drawable-ldpi/ic_menu_reply.png new file mode 100644 index 000000000..4a168af9d Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_reply.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_search.png b/app/src/main/res/drawable-ldpi/ic_menu_search.png index ded02b6d5..5eaa8c416 100644 Binary files a/app/src/main/res/drawable-ldpi/ic_menu_search.png and b/app/src/main/res/drawable-ldpi/ic_menu_search.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_send.png b/app/src/main/res/drawable-ldpi/ic_menu_send.png new file mode 100644 index 000000000..e0a654215 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_send.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_share.png b/app/src/main/res/drawable-ldpi/ic_menu_share.png new file mode 100644 index 000000000..b01cd9610 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_share.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_status.png b/app/src/main/res/drawable-ldpi/ic_menu_status.png new file mode 100644 index 000000000..274071f5b Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_status.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_menu_unlock.png b/app/src/main/res/drawable-ldpi/ic_menu_unlock.png new file mode 100644 index 000000000..aba4cffef Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_menu_unlock.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_msg_delivered.png b/app/src/main/res/drawable-ldpi/ic_msg_delivered.png new file mode 100644 index 000000000..cc542e4de Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_msg_error.png b/app/src/main/res/drawable-ldpi/ic_msg_error.png new file mode 100644 index 000000000..6722a1b96 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-ldpi/ic_msg_notdelivered.png new file mode 100644 index 000000000..b7a727e85 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_msg_pending.png b/app/src/main/res/drawable-ldpi/ic_msg_pending.png new file mode 100644 index 000000000..e6b83a59e Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_msg_security.png b/app/src/main/res/drawable-ldpi/ic_msg_security.png new file mode 100644 index 000000000..6a11fe2b1 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_msg_security.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_msg_sent.png b/app/src/main/res/drawable-ldpi/ic_msg_sent.png new file mode 100644 index 000000000..e92151b4a Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_msg_star.png b/app/src/main/res/drawable-ldpi/ic_msg_star.png new file mode 100644 index 000000000..a7c3c9569 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_msg_warning.png b/app/src/main/res/drawable-ldpi/ic_msg_warning.png new file mode 100644 index 000000000..2576599a9 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_rec.png b/app/src/main/res/drawable-ldpi/ic_rec.png new file mode 100644 index 000000000..73f20f400 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_rec.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_stat_notify.png b/app/src/main/res/drawable-ldpi/ic_stat_notify.png index 6190970a4..0754378a5 100644 Binary files a/app/src/main/res/drawable-ldpi/ic_stat_notify.png and b/app/src/main/res/drawable-ldpi/ic_stat_notify.png differ diff --git a/app/src/main/res/drawable-ldrtl-hdpi/ic_menu_copy.png b/app/src/main/res/drawable-ldrtl-hdpi/ic_menu_copy.png new file mode 100644 index 000000000..a262b0c87 Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-hdpi/ic_menu_copy.png differ diff --git a/app/src/main/res/drawable-ldrtl-hdpi/ic_menu_send.png b/app/src/main/res/drawable-ldrtl-hdpi/ic_menu_send.png new file mode 100644 index 000000000..0cc14e754 Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-hdpi/ic_menu_send.png differ diff --git a/app/src/main/res/drawable-ldrtl-mdpi/ic_menu_copy.png b/app/src/main/res/drawable-ldrtl-mdpi/ic_menu_copy.png new file mode 100644 index 000000000..05b1e119c Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-mdpi/ic_menu_copy.png differ diff --git a/app/src/main/res/drawable-ldrtl-mdpi/ic_menu_send.png b/app/src/main/res/drawable-ldrtl-mdpi/ic_menu_send.png new file mode 100644 index 000000000..d5fe50b36 Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-mdpi/ic_menu_send.png differ diff --git a/app/src/main/res/drawable-ldrtl-xhdpi/ic_menu_copy.png b/app/src/main/res/drawable-ldrtl-xhdpi/ic_menu_copy.png new file mode 100644 index 000000000..ee1c3d104 Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-xhdpi/ic_menu_copy.png differ diff --git a/app/src/main/res/drawable-ldrtl-xhdpi/ic_menu_send.png b/app/src/main/res/drawable-ldrtl-xhdpi/ic_menu_send.png new file mode 100644 index 000000000..4735a7d71 Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-xhdpi/ic_menu_send.png differ diff --git a/app/src/main/res/drawable-ldrtl-xxhdpi/ic_menu_copy.png b/app/src/main/res/drawable-ldrtl-xxhdpi/ic_menu_copy.png new file mode 100644 index 000000000..ac86165d5 Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-xxhdpi/ic_menu_copy.png differ diff --git a/app/src/main/res/drawable-ldrtl-xxhdpi/ic_menu_send.png b/app/src/main/res/drawable-ldrtl-xxhdpi/ic_menu_send.png new file mode 100644 index 000000000..9f64e5b38 Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-xxhdpi/ic_menu_send.png differ diff --git a/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_menu_copy.png b/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_menu_copy.png new file mode 100644 index 000000000..f5104146d Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_menu_copy.png differ diff --git a/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_menu_send.png b/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_menu_send.png new file mode 100644 index 000000000..76d135b90 Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_menu_send.png differ diff --git a/app/src/main/res/drawable-mdpi-v10/ic_stat_notify.png b/app/src/main/res/drawable-mdpi-v10/ic_stat_notify.png deleted file mode 100644 index 3c5890bf0..000000000 Binary files a/app/src/main/res/drawable-mdpi-v10/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi-v11/ic_menu_contact.png b/app/src/main/res/drawable-mdpi-v11/ic_menu_contact.png deleted file mode 100644 index 0a70f6896..000000000 Binary files a/app/src/main/res/drawable-mdpi-v11/ic_menu_contact.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi-v11/ic_stat_notify.png b/app/src/main/res/drawable-mdpi-v11/ic_stat_notify.png deleted file mode 100644 index c03ae4728..000000000 Binary files a/app/src/main/res/drawable-mdpi-v11/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi-v9/ic_stat_notify.png b/app/src/main/res/drawable-mdpi-v9/ic_stat_notify.png deleted file mode 100644 index 3c5890bf0..000000000 Binary files a/app/src/main/res/drawable-mdpi-v9/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/app_background.png b/app/src/main/res/drawable-mdpi/app_background.png new file mode 100644 index 000000000..490f47699 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/app_background.png differ diff --git a/app/src/main/res/drawable-mdpi/attach_audio.png b/app/src/main/res/drawable-mdpi/attach_audio.png new file mode 100644 index 000000000..6c576c5b7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/attach_audio.png differ diff --git a/app/src/main/res/drawable-mdpi/attach_camera.png b/app/src/main/res/drawable-mdpi/attach_camera.png new file mode 100644 index 000000000..cf44d89e4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/attach_camera.png differ diff --git a/app/src/main/res/drawable-mdpi/attach_contact.png b/app/src/main/res/drawable-mdpi/attach_contact.png new file mode 100644 index 000000000..6d32ff13c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/attach_contact.png differ diff --git a/app/src/main/res/drawable-mdpi/attach_file.png b/app/src/main/res/drawable-mdpi/attach_file.png new file mode 100644 index 000000000..3fad1cebb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/attach_file.png differ diff --git a/app/src/main/res/drawable-mdpi/attach_gallery.png b/app/src/main/res/drawable-mdpi/attach_gallery.png new file mode 100644 index 000000000..d70963a25 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/attach_gallery.png differ diff --git a/app/src/main/res/drawable-mdpi/attach_hide1.png b/app/src/main/res/drawable-mdpi/attach_hide1.png new file mode 100644 index 000000000..932377782 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/attach_hide1.png differ diff --git a/app/src/main/res/drawable-mdpi/attach_location.png b/app/src/main/res/drawable-mdpi/attach_location.png new file mode 100644 index 000000000..cc02b3201 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/attach_location.png differ diff --git a/app/src/main/res/drawable-mdpi/attach_video.png b/app/src/main/res/drawable-mdpi/attach_video.png new file mode 100644 index 000000000..67ac2e3c7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/attach_video.png differ diff --git a/app/src/main/res/drawable-mdpi/balloon_classic_incoming.9.png b/app/src/main/res/drawable-mdpi/balloon_classic_incoming.9.png new file mode 100644 index 000000000..e95907399 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/balloon_classic_incoming.9.png differ diff --git a/app/src/main/res/drawable-mdpi/balloon_classic_outgoing.9.png b/app/src/main/res/drawable-mdpi/balloon_classic_outgoing.9.png new file mode 100644 index 000000000..c2b1d6b6f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/balloon_classic_outgoing.9.png differ diff --git a/app/src/main/res/drawable-mdpi/balloon_iphone_incoming.9.png b/app/src/main/res/drawable-mdpi/balloon_iphone_incoming.9.png new file mode 100644 index 000000000..c03c94797 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/balloon_iphone_incoming.9.png differ diff --git a/app/src/main/res/drawable-mdpi/balloon_iphone_outgoing.9.png b/app/src/main/res/drawable-mdpi/balloon_iphone_outgoing.9.png new file mode 100644 index 000000000..ed30e6b79 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/balloon_iphone_outgoing.9.png differ diff --git a/app/src/main/res/drawable-mdpi/balloon_old_classic_incoming.9.png b/app/src/main/res/drawable-mdpi/balloon_old_classic_incoming.9.png new file mode 100644 index 000000000..2c1392580 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/balloon_old_classic_incoming.9.png differ diff --git a/app/src/main/res/drawable-mdpi/balloon_old_classic_outgoing.9.png b/app/src/main/res/drawable-mdpi/balloon_old_classic_outgoing.9.png new file mode 100644 index 000000000..c01ad32e3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/balloon_old_classic_outgoing.9.png differ diff --git a/app/src/main/res/drawable-mdpi/balloon_silence_incoming.9.png b/app/src/main/res/drawable-mdpi/balloon_silence_incoming.9.png new file mode 100644 index 000000000..be83e3cd2 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/balloon_silence_incoming.9.png differ diff --git a/app/src/main/res/drawable-mdpi/balloon_silence_outgoing.9.png b/app/src/main/res/drawable-mdpi/balloon_silence_outgoing.9.png new file mode 100644 index 000000000..4320b7dcf Binary files /dev/null and b/app/src/main/res/drawable-mdpi/balloon_silence_outgoing.9.png differ diff --git a/app/src/main/res/drawable-mdpi/bottombar_landscape_565.png b/app/src/main/res/drawable-mdpi/bottombar_landscape_565.png deleted file mode 100644 index 612185670..000000000 Binary files a/app/src/main/res/drawable-mdpi/bottombar_landscape_565.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/download_audio.png b/app/src/main/res/drawable-mdpi/download_audio.png old mode 100755 new mode 100644 diff --git a/app/src/main/res/drawable-mdpi/ic_attach.png b/app/src/main/res/drawable-mdpi/ic_attach.png new file mode 100644 index 000000000..33ead9a8d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_attach.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_attach_audio.png b/app/src/main/res/drawable-mdpi/ic_attach_audio.png new file mode 100644 index 000000000..3070937a0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_attach_audio.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_attach_contact.png b/app/src/main/res/drawable-mdpi/ic_attach_contact.png new file mode 100644 index 000000000..11c11e626 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_attach_contact.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_attach_picture.png b/app/src/main/res/drawable-mdpi/ic_attach_picture.png new file mode 100644 index 000000000..ca342f2b9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_attach_picture.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_close.png b/app/src/main/res/drawable-mdpi/ic_close.png new file mode 100644 index 000000000..8f174de08 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_close.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_contact_picture.png b/app/src/main/res/drawable-mdpi/ic_contact_picture.png deleted file mode 100644 index 3a338e8e2..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_contact_picture.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_create_white.png b/app/src/main/res/drawable-mdpi/ic_create_white.png new file mode 100644 index 000000000..12b09f1d9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_create_white.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_denied.png b/app/src/main/res/drawable-mdpi/ic_denied.png new file mode 100644 index 000000000..351cf302b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_denied.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_emoji.png b/app/src/main/res/drawable-mdpi/ic_emoji.png new file mode 100644 index 000000000..e56e84049 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_emoji.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_globe.png b/app/src/main/res/drawable-mdpi/ic_globe.png new file mode 100644 index 000000000..24e9a6156 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_globe.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_indicator_group.png b/app/src/main/res/drawable-mdpi/ic_indicator_group.png new file mode 100644 index 000000000..9ede210d0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_indicator_group.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_keyboard.png b/app/src/main/res/drawable-mdpi/ic_keyboard.png new file mode 100644 index 000000000..718d46b94 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_keyboard.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_keyboard_dark.png b/app/src/main/res/drawable-mdpi/ic_keyboard_dark.png deleted file mode 100644 index 806d3a1ea..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_keyboard_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher.png b/app/src/main/res/drawable-mdpi/ic_launcher.png deleted file mode 100644 index 3b1447b29..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher_contacts.png b/app/src/main/res/drawable-mdpi/ic_launcher_contacts.png deleted file mode 100644 index 08468fdab..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_launcher_contacts.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher_gallery.png b/app/src/main/res/drawable-mdpi/ic_launcher_gallery.png deleted file mode 100644 index 7ea0b8c86..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_launcher_gallery.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_map_pin_google.png b/app/src/main/res/drawable-mdpi/ic_map_pin_google.png new file mode 100644 index 000000000..6b685404b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_map_pin_google.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_archive.png b/app/src/main/res/drawable-mdpi/ic_menu_archive.png new file mode 100644 index 000000000..f6aa3f966 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_archive.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_attach.png b/app/src/main/res/drawable-mdpi/ic_menu_attach.png index 10099037a..ac433ef73 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_attach.png and b/app/src/main/res/drawable-mdpi/ic_menu_attach.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_block.png b/app/src/main/res/drawable-mdpi/ic_menu_block.png index fce929742..ec1b33f0e 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_block.png and b/app/src/main/res/drawable-mdpi/ic_menu_block.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_call.png b/app/src/main/res/drawable-mdpi/ic_menu_call.png index a63f86b16..77f9de5e3 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_call.png and b/app/src/main/res/drawable-mdpi/ic_menu_call.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_call_black.png b/app/src/main/res/drawable-mdpi/ic_menu_call_black.png new file mode 100644 index 000000000..55ed026bc Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_call_black.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_cancel.png b/app/src/main/res/drawable-mdpi/ic_menu_cancel.png new file mode 100644 index 000000000..787e25937 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_cancel.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_chat.png b/app/src/main/res/drawable-mdpi/ic_menu_chat.png new file mode 100644 index 000000000..dac4cb9fc Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_chat.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_check.png b/app/src/main/res/drawable-mdpi/ic_menu_check.png new file mode 100644 index 000000000..dfcb55d02 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_check.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_compose.png b/app/src/main/res/drawable-mdpi/ic_menu_compose.png deleted file mode 100644 index 2874aec4c..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_compose.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_contact.png b/app/src/main/res/drawable-mdpi/ic_menu_contact.png index 29b7cd98b..f0b1c725d 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_contact.png and b/app/src/main/res/drawable-mdpi/ic_menu_contact.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_copy.png b/app/src/main/res/drawable-mdpi/ic_menu_copy.png new file mode 100644 index 000000000..80c069557 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_copy.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_delete.png b/app/src/main/res/drawable-mdpi/ic_menu_delete.png new file mode 100644 index 000000000..e2f5f3555 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_delete.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_donate.png b/app/src/main/res/drawable-mdpi/ic_menu_donate.png new file mode 100644 index 000000000..707d16761 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_donate.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_download.png b/app/src/main/res/drawable-mdpi/ic_menu_download.png new file mode 100644 index 000000000..d400472fd Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_download.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_exit.png b/app/src/main/res/drawable-mdpi/ic_menu_exit.png new file mode 100644 index 000000000..461be00a9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_exit.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_favorite.png b/app/src/main/res/drawable-mdpi/ic_menu_favorite.png new file mode 100644 index 000000000..d2cbe4c92 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_favorite.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_group.png b/app/src/main/res/drawable-mdpi/ic_menu_group.png new file mode 100644 index 000000000..270e4de2e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_group.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_help.png b/app/src/main/res/drawable-mdpi/ic_menu_help.png new file mode 100644 index 000000000..db699622b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_help.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_info_details.png b/app/src/main/res/drawable-mdpi/ic_menu_info_details.png new file mode 100644 index 000000000..353e06495 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_info_details.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_invite.png b/app/src/main/res/drawable-mdpi/ic_menu_invite.png index 7577e6d52..38e0a2882 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_invite.png and b/app/src/main/res/drawable-mdpi/ic_menu_invite.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_key.png b/app/src/main/res/drawable-mdpi/ic_menu_key.png new file mode 100644 index 000000000..21ba92179 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_key.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_maps.png b/app/src/main/res/drawable-mdpi/ic_menu_maps.png new file mode 100644 index 000000000..4edb178dc Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_maps.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_offline.png b/app/src/main/res/drawable-mdpi/ic_menu_offline.png new file mode 100644 index 000000000..aca66b288 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_offline.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_online.png b/app/src/main/res/drawable-mdpi/ic_menu_online.png new file mode 100644 index 000000000..c77486dd8 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_online.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_open.png b/app/src/main/res/drawable-mdpi/ic_menu_open.png new file mode 100644 index 000000000..474cc8092 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_open.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_overflow_normal.png b/app/src/main/res/drawable-mdpi/ic_menu_overflow_normal.png deleted file mode 100644 index 165342a07..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_overflow_normal.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_preferences.png b/app/src/main/res/drawable-mdpi/ic_menu_preferences.png index 5a48d1ac7..8909c3553 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_preferences.png and b/app/src/main/res/drawable-mdpi/ic_menu_preferences.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_refresh.png b/app/src/main/res/drawable-mdpi/ic_menu_refresh.png index cf0dac458..97e42b525 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_refresh.png and b/app/src/main/res/drawable-mdpi/ic_menu_refresh.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_reply.png b/app/src/main/res/drawable-mdpi/ic_menu_reply.png new file mode 100644 index 000000000..c9a26e75f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_reply.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_save.png b/app/src/main/res/drawable-mdpi/ic_menu_save.png new file mode 100644 index 000000000..0ef0d656d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_save.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_search.png b/app/src/main/res/drawable-mdpi/ic_menu_search.png index 9360b3d5c..faefc59c8 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_search.png and b/app/src/main/res/drawable-mdpi/ic_menu_search.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_send.png b/app/src/main/res/drawable-mdpi/ic_menu_send.png new file mode 100644 index 000000000..048d3eb29 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_send.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_share.png b/app/src/main/res/drawable-mdpi/ic_menu_share.png new file mode 100644 index 000000000..e944fd70c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_share.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_start_conversation.png b/app/src/main/res/drawable-mdpi/ic_menu_start_conversation.png deleted file mode 100644 index 10ef64978..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_start_conversation.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_status.png b/app/src/main/res/drawable-mdpi/ic_menu_status.png index 41a9c2e20..e6cc505f9 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_status.png and b/app/src/main/res/drawable-mdpi/ic_menu_status.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_unarchive.png b/app/src/main/res/drawable-mdpi/ic_menu_unarchive.png new file mode 100644 index 000000000..8ec62cd34 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_unarchive.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_unlock.png b/app/src/main/res/drawable-mdpi/ic_menu_unlock.png new file mode 100644 index 000000000..3f47b54cf Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_unlock.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_mic.png b/app/src/main/res/drawable-mdpi/ic_mic.png new file mode 100644 index 000000000..68a2db1c3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_mic.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_mic_disabled.png b/app/src/main/res/drawable-mdpi/ic_mic_disabled.png new file mode 100644 index 000000000..37585da33 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_mic_disabled.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_mic_pressed.png b/app/src/main/res/drawable-mdpi/ic_mic_pressed.png new file mode 100644 index 000000000..1e4932214 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_mic_pressed.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_msg_crypt.png b/app/src/main/res/drawable-mdpi/ic_msg_crypt.png deleted file mode 100644 index 7223a5bba..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_msg_crypt.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_msg_delivered.png b/app/src/main/res/drawable-mdpi/ic_msg_delivered.png index 7009c78d6..f62ad53e9 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_msg_delivered.png and b/app/src/main/res/drawable-mdpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_msg_error.png b/app/src/main/res/drawable-mdpi/ic_msg_error.png index 5eb4fc199..c33fdfea3 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_msg_error.png and b/app/src/main/res/drawable-mdpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-mdpi/ic_msg_notdelivered.png index 90fff87f3..2ed3117db 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_msg_notdelivered.png and b/app/src/main/res/drawable-mdpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_msg_pending.png b/app/src/main/res/drawable-mdpi/ic_msg_pending.png index 16ef4cf02..502ca8812 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_msg_pending.png and b/app/src/main/res/drawable-mdpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_msg_security.png b/app/src/main/res/drawable-mdpi/ic_msg_security.png index 49457a2e6..3b7984b0a 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_msg_security.png and b/app/src/main/res/drawable-mdpi/ic_msg_security.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_msg_sent.png b/app/src/main/res/drawable-mdpi/ic_msg_sent.png index 90a908037..dd96b451a 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_msg_sent.png and b/app/src/main/res/drawable-mdpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_msg_star.png b/app/src/main/res/drawable-mdpi/ic_msg_star.png new file mode 100644 index 000000000..26a30f12c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_msg_warning.png b/app/src/main/res/drawable-mdpi/ic_msg_warning.png index 5eb4fc199..6bb912e19 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_msg_warning.png and b/app/src/main/res/drawable-mdpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_my_location.png b/app/src/main/res/drawable-mdpi/ic_my_location.png new file mode 100755 index 000000000..b08f66ed7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_my_location.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_navigate.png b/app/src/main/res/drawable-mdpi/ic_navigate.png new file mode 100644 index 000000000..872d18fa7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_navigate.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pin.png b/app/src/main/res/drawable-mdpi/ic_pin.png new file mode 100755 index 000000000..176d9a099 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pin.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pin_x.png b/app/src/main/res/drawable-mdpi/ic_pin_x.png new file mode 100755 index 000000000..bf940d8cf Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pin_x.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_about.png b/app/src/main/res/drawable-mdpi/ic_pref_about.png new file mode 100644 index 000000000..5b787e292 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_about.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_account.png b/app/src/main/res/drawable-mdpi/ic_pref_account.png new file mode 100644 index 000000000..ca92f54cc Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_account.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_appearance.png b/app/src/main/res/drawable-mdpi/ic_pref_appearance.png new file mode 100644 index 000000000..870f42946 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_appearance.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_balloons.png b/app/src/main/res/drawable-mdpi/ic_pref_balloons.png new file mode 100644 index 000000000..ac35bd422 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_balloons.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_delete_account.png b/app/src/main/res/drawable-mdpi/ic_pref_delete_account.png new file mode 100644 index 000000000..a406bba41 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_delete_account.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_donate.png b/app/src/main/res/drawable-mdpi/ic_pref_donate.png new file mode 100644 index 000000000..9f0d80b12 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_donate.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_font_size.png b/app/src/main/res/drawable-mdpi/ic_pref_font_size.png new file mode 100644 index 000000000..de3900c5d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_font_size.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_help.png b/app/src/main/res/drawable-mdpi/ic_pref_help.png new file mode 100644 index 000000000..20b1d4c9e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_help.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_image_resize.png b/app/src/main/res/drawable-mdpi/ic_pref_image_resize.png new file mode 100644 index 000000000..ae6270016 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_image_resize.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_location.png b/app/src/main/res/drawable-mdpi/ic_pref_location.png new file mode 100644 index 000000000..e84aa671b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_location.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_maintenance.png b/app/src/main/res/drawable-mdpi/ic_pref_maintenance.png new file mode 100644 index 000000000..17901a36e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_maintenance.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_maps_service.png b/app/src/main/res/drawable-mdpi/ic_pref_maps_service.png new file mode 100644 index 000000000..1a866d94a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_maps_service.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_media.png b/app/src/main/res/drawable-mdpi/ic_pref_media.png new file mode 100644 index 000000000..2f59a0773 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_media.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_messaging.png b/app/src/main/res/drawable-mdpi/ic_pref_messaging.png new file mode 100644 index 000000000..cc97a1dab Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_messaging.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_network.png b/app/src/main/res/drawable-mdpi/ic_pref_network.png new file mode 100644 index 000000000..35ea431c7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_network.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_notification.png b/app/src/main/res/drawable-mdpi/ic_pref_notification.png new file mode 100644 index 000000000..fe35bf32a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_notification.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_privacy.png b/app/src/main/res/drawable-mdpi/ic_pref_privacy.png new file mode 100644 index 000000000..bc4db89a2 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_privacy.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_register_device.png b/app/src/main/res/drawable-mdpi/ic_pref_register_device.png new file mode 100644 index 000000000..9656bbd71 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_register_device.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_restart_msgcenter.png b/app/src/main/res/drawable-mdpi/ic_pref_restart_msgcenter.png new file mode 100644 index 000000000..fa9ddf7f4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_restart_msgcenter.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_send_debug_log.png b/app/src/main/res/drawable-mdpi/ic_pref_send_debug_log.png new file mode 100644 index 000000000..492e759bb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_send_debug_log.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_service_terms.png b/app/src/main/res/drawable-mdpi/ic_pref_service_terms.png new file mode 100644 index 000000000..3ebaad5bb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_service_terms.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pref_vibrate.png b/app/src/main/res/drawable-mdpi/ic_pref_vibrate.png new file mode 100644 index 000000000..cc8e381c9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pref_vibrate.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_rec.png b/app/src/main/res/drawable-mdpi/ic_rec.png new file mode 100644 index 000000000..d0dae9559 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_rec.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_send.png b/app/src/main/res/drawable-mdpi/ic_send.png new file mode 100644 index 000000000..132ebaa29 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_send.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_send_disabled.png b/app/src/main/res/drawable-mdpi/ic_send_disabled.png new file mode 100644 index 000000000..160ed25ac Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_send_disabled.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_send_disabled_holo_light.png b/app/src/main/res/drawable-mdpi/ic_send_disabled_holo_light.png deleted file mode 100644 index 994ec51ec..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_send_disabled_holo_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_send_holo_light.png b/app/src/main/res/drawable-mdpi/ic_send_holo_light.png deleted file mode 100644 index e80ea0362..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_send_holo_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_send_location.png b/app/src/main/res/drawable-mdpi/ic_send_location.png new file mode 100755 index 000000000..071b5c8ec Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_send_location.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_notify.png b/app/src/main/res/drawable-mdpi/ic_stat_notify.png index 6ce9fa667..c03ae4728 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_stat_notify.png and b/app/src/main/res/drawable-mdpi/ic_stat_notify.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_trust_ignored.png b/app/src/main/res/drawable-mdpi/ic_trust_ignored.png new file mode 100644 index 000000000..0afc81129 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_trust_ignored.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_trust_unknown.png b/app/src/main/res/drawable-mdpi/ic_trust_unknown.png new file mode 100644 index 000000000..2b65df7be Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_trust_unknown.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_trust_verified.png b/app/src/main/res/drawable-mdpi/ic_trust_verified.png new file mode 100644 index 000000000..b008c625d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_trust_verified.png differ diff --git a/app/src/main/res/drawable-mdpi/list_item_focused_background.9.png b/app/src/main/res/drawable-mdpi/list_item_focused_background.9.png new file mode 100644 index 000000000..13cb1317e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/list_item_focused_background.9.png differ diff --git a/app/src/main/res/drawable-mdpi/list_item_pressed_background.9.png b/app/src/main/res/drawable-mdpi/list_item_pressed_background.9.png new file mode 100644 index 000000000..1459feab0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/list_item_pressed_background.9.png differ diff --git a/app/src/main/res/drawable-mdpi/pause_balloon.png b/app/src/main/res/drawable-mdpi/pause_balloon.png old mode 100755 new mode 100644 index 5354edb25..3f13d02a5 Binary files a/app/src/main/res/drawable-mdpi/pause_balloon.png and b/app/src/main/res/drawable-mdpi/pause_balloon.png differ diff --git a/app/src/main/res/drawable-mdpi/play_balloon.png b/app/src/main/res/drawable-mdpi/play_balloon.png old mode 100755 new mode 100644 index dd022c741..9a31a3ad6 Binary files a/app/src/main/res/drawable-mdpi/play_balloon.png and b/app/src/main/res/drawable-mdpi/play_balloon.png differ diff --git a/app/src/main/res/drawable-night-anydpi/ic_attach.xml b/app/src/main/res/drawable-night-anydpi/ic_attach.xml new file mode 100644 index 000000000..245cf2f0b --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_attach.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_emoji.xml b/app/src/main/res/drawable-night-anydpi/ic_emoji.xml new file mode 100644 index 000000000..2b8a68cef --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_emoji.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_keyboard.xml b/app/src/main/res/drawable-night-anydpi/ic_keyboard.xml new file mode 100644 index 000000000..c7a387676 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_keyboard.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_mic.xml b/app/src/main/res/drawable-night-anydpi/ic_mic.xml new file mode 100644 index 000000000..f5678d748 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_mic.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_about.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_about.xml new file mode 100644 index 000000000..bd7a1baf7 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_about.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_account.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_account.xml new file mode 100644 index 000000000..aa31f4c0c --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_account.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_appearance.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_appearance.xml new file mode 100644 index 000000000..480a9ce4c --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_appearance.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_balloons.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_balloons.xml new file mode 100644 index 000000000..b4d1cead4 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_balloons.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_donate.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_donate.xml new file mode 100644 index 000000000..4adf853c3 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_donate.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_font_size.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_font_size.xml new file mode 100644 index 000000000..5134d5093 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_font_size.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_help.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_help.xml new file mode 100644 index 000000000..28aedb7ad --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_help.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_image_resize.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_image_resize.xml new file mode 100644 index 000000000..83bd3216c --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_image_resize.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_location.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_location.xml new file mode 100644 index 000000000..d375aa253 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_location.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_maintenance.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_maintenance.xml new file mode 100644 index 000000000..894e26583 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_maintenance.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_maps_service.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_maps_service.xml new file mode 100644 index 000000000..116113299 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_maps_service.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_media.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_media.xml new file mode 100644 index 000000000..b3721d053 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_media.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_messaging.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_messaging.xml new file mode 100644 index 000000000..45e594ea1 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_messaging.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_network.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_network.xml new file mode 100644 index 000000000..fbf6e7381 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_network.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_notification.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_notification.xml new file mode 100644 index 000000000..e786b18e0 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_notification.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_privacy.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_privacy.xml new file mode 100644 index 000000000..3e8a5f3c6 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_privacy.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_register_device.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_register_device.xml new file mode 100644 index 000000000..c7cf20af6 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_register_device.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_restart_msgcenter.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_restart_msgcenter.xml new file mode 100644 index 000000000..d582af6cf --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_restart_msgcenter.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_send_debug_log.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_send_debug_log.xml new file mode 100644 index 000000000..8a6511b78 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_send_debug_log.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_service_terms.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_service_terms.xml new file mode 100644 index 000000000..871246b28 --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_service_terms.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_pref_vibrate.xml b/app/src/main/res/drawable-night-anydpi/ic_pref_vibrate.xml new file mode 100644 index 000000000..c958890be --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_pref_vibrate.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-anydpi/ic_send.xml b/app/src/main/res/drawable-night-anydpi/ic_send.xml new file mode 100644 index 000000000..8b513063f --- /dev/null +++ b/app/src/main/res/drawable-night-anydpi/ic_send.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night-hdpi/app_background.png b/app/src/main/res/drawable-night-hdpi/app_background.png new file mode 100644 index 000000000..5bfbbd8fe Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/app_background.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_attach.png b/app/src/main/res/drawable-night-hdpi/ic_attach.png new file mode 100644 index 000000000..39e047034 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_attach.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_close.png b/app/src/main/res/drawable-night-hdpi/ic_close.png new file mode 100644 index 000000000..e7dce2d07 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_close.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_emoji.png b/app/src/main/res/drawable-night-hdpi/ic_emoji.png new file mode 100644 index 000000000..9dd14b01d Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_emoji.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_indicator_group.png b/app/src/main/res/drawable-night-hdpi/ic_indicator_group.png new file mode 100644 index 000000000..44c8c30b2 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_indicator_group.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_keyboard.png b/app/src/main/res/drawable-night-hdpi/ic_keyboard.png new file mode 100644 index 000000000..e2483b57b Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_keyboard.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_mic.png b/app/src/main/res/drawable-night-hdpi/ic_mic.png new file mode 100644 index 000000000..9a425361e Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_mic.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_msg_delivered.png b/app/src/main/res/drawable-night-hdpi/ic_msg_delivered.png new file mode 100644 index 000000000..af50b4c8d Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_msg_error.png b/app/src/main/res/drawable-night-hdpi/ic_msg_error.png new file mode 100644 index 000000000..1464ac5a5 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-night-hdpi/ic_msg_notdelivered.png new file mode 100644 index 000000000..60a4b4778 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_msg_pending.png b/app/src/main/res/drawable-night-hdpi/ic_msg_pending.png new file mode 100644 index 000000000..cc132920a Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_msg_sent.png b/app/src/main/res/drawable-night-hdpi/ic_msg_sent.png new file mode 100644 index 000000000..8a57ed276 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_msg_star.png b/app/src/main/res/drawable-night-hdpi/ic_msg_star.png new file mode 100644 index 000000000..37d2d96b8 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_msg_warning.png b/app/src/main/res/drawable-night-hdpi/ic_msg_warning.png new file mode 100644 index 000000000..ebf72ec28 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_about.png b/app/src/main/res/drawable-night-hdpi/ic_pref_about.png new file mode 100644 index 000000000..9c61b4513 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_about.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_account.png b/app/src/main/res/drawable-night-hdpi/ic_pref_account.png new file mode 100644 index 000000000..fcd0827ac Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_account.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_appearance.png b/app/src/main/res/drawable-night-hdpi/ic_pref_appearance.png new file mode 100644 index 000000000..9fab01c79 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_appearance.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_balloons.png b/app/src/main/res/drawable-night-hdpi/ic_pref_balloons.png new file mode 100644 index 000000000..0fa8ed9f3 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_balloons.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_donate.png b/app/src/main/res/drawable-night-hdpi/ic_pref_donate.png new file mode 100644 index 000000000..0162030e4 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_donate.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_font_size.png b/app/src/main/res/drawable-night-hdpi/ic_pref_font_size.png new file mode 100644 index 000000000..8a1645e52 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_font_size.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_help.png b/app/src/main/res/drawable-night-hdpi/ic_pref_help.png new file mode 100644 index 000000000..88254eef9 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_help.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_image_resize.png b/app/src/main/res/drawable-night-hdpi/ic_pref_image_resize.png new file mode 100644 index 000000000..b644f1bdd Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_image_resize.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_location.png b/app/src/main/res/drawable-night-hdpi/ic_pref_location.png new file mode 100644 index 000000000..3c989cc16 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_location.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_maintenance.png b/app/src/main/res/drawable-night-hdpi/ic_pref_maintenance.png new file mode 100644 index 000000000..5ac4edc6f Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_maintenance.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_maps_service.png b/app/src/main/res/drawable-night-hdpi/ic_pref_maps_service.png new file mode 100644 index 000000000..400c2ca31 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_maps_service.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_media.png b/app/src/main/res/drawable-night-hdpi/ic_pref_media.png new file mode 100644 index 000000000..3254cd798 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_media.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_messaging.png b/app/src/main/res/drawable-night-hdpi/ic_pref_messaging.png new file mode 100644 index 000000000..cc7e52311 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_messaging.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_network.png b/app/src/main/res/drawable-night-hdpi/ic_pref_network.png new file mode 100644 index 000000000..43aaaad5a Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_network.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_notification.png b/app/src/main/res/drawable-night-hdpi/ic_pref_notification.png new file mode 100644 index 000000000..66055f832 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_notification.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_privacy.png b/app/src/main/res/drawable-night-hdpi/ic_pref_privacy.png new file mode 100644 index 000000000..9128aec52 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_privacy.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_register_device.png b/app/src/main/res/drawable-night-hdpi/ic_pref_register_device.png new file mode 100644 index 000000000..099e31eff Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_register_device.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_restart_msgcenter.png b/app/src/main/res/drawable-night-hdpi/ic_pref_restart_msgcenter.png new file mode 100644 index 000000000..0a6ddb9d3 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_restart_msgcenter.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_send_debug_log.png b/app/src/main/res/drawable-night-hdpi/ic_pref_send_debug_log.png new file mode 100644 index 000000000..0171e6a7c Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_send_debug_log.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_service_terms.png b/app/src/main/res/drawable-night-hdpi/ic_pref_service_terms.png new file mode 100644 index 000000000..342a0e227 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_service_terms.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_pref_vibrate.png b/app/src/main/res/drawable-night-hdpi/ic_pref_vibrate.png new file mode 100644 index 000000000..0cbe3a2cf Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_pref_vibrate.png differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_send.png b/app/src/main/res/drawable-night-hdpi/ic_send.png new file mode 100644 index 000000000..fb9e7ed4e Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_send.png differ diff --git a/app/src/main/res/drawable-night-hdpi/pause_balloon.png b/app/src/main/res/drawable-night-hdpi/pause_balloon.png new file mode 100644 index 000000000..0a1c3062a Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/pause_balloon.png differ diff --git a/app/src/main/res/drawable-night-hdpi/play_balloon.png b/app/src/main/res/drawable-night-hdpi/play_balloon.png new file mode 100644 index 000000000..171f98f3d Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/play_balloon.png differ diff --git a/app/src/main/res/drawable-night-ldpi/app_background.png b/app/src/main/res/drawable-night-ldpi/app_background.png new file mode 100644 index 000000000..812a9191b Binary files /dev/null and b/app/src/main/res/drawable-night-ldpi/app_background.png differ diff --git a/app/src/main/res/drawable-night-ldpi/ic_msg_delivered.png b/app/src/main/res/drawable-night-ldpi/ic_msg_delivered.png new file mode 100644 index 000000000..2d990cd1e Binary files /dev/null and b/app/src/main/res/drawable-night-ldpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-night-ldpi/ic_msg_error.png b/app/src/main/res/drawable-night-ldpi/ic_msg_error.png new file mode 100644 index 000000000..c5bb2b606 Binary files /dev/null and b/app/src/main/res/drawable-night-ldpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-night-ldpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-night-ldpi/ic_msg_notdelivered.png new file mode 100644 index 000000000..7c192fea1 Binary files /dev/null and b/app/src/main/res/drawable-night-ldpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-night-ldpi/ic_msg_pending.png b/app/src/main/res/drawable-night-ldpi/ic_msg_pending.png new file mode 100644 index 000000000..1ac758eb5 Binary files /dev/null and b/app/src/main/res/drawable-night-ldpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-night-ldpi/ic_msg_sent.png b/app/src/main/res/drawable-night-ldpi/ic_msg_sent.png new file mode 100644 index 000000000..eb0674638 Binary files /dev/null and b/app/src/main/res/drawable-night-ldpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-night-ldpi/ic_msg_star.png b/app/src/main/res/drawable-night-ldpi/ic_msg_star.png new file mode 100644 index 000000000..4cc5fb840 Binary files /dev/null and b/app/src/main/res/drawable-night-ldpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-night-ldpi/ic_msg_warning.png b/app/src/main/res/drawable-night-ldpi/ic_msg_warning.png new file mode 100644 index 000000000..f75445421 Binary files /dev/null and b/app/src/main/res/drawable-night-ldpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-night-mdpi/app_background.png b/app/src/main/res/drawable-night-mdpi/app_background.png new file mode 100644 index 000000000..0e77a4f26 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/app_background.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_attach.png b/app/src/main/res/drawable-night-mdpi/ic_attach.png new file mode 100644 index 000000000..20b1bb7b1 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_attach.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_close.png b/app/src/main/res/drawable-night-mdpi/ic_close.png new file mode 100644 index 000000000..ba31b6605 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_close.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_emoji.png b/app/src/main/res/drawable-night-mdpi/ic_emoji.png new file mode 100644 index 000000000..57456df39 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_emoji.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_indicator_group.png b/app/src/main/res/drawable-night-mdpi/ic_indicator_group.png new file mode 100644 index 000000000..d71d4cae6 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_indicator_group.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_keyboard.png b/app/src/main/res/drawable-night-mdpi/ic_keyboard.png new file mode 100644 index 000000000..a9966cea7 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_keyboard.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_mic.png b/app/src/main/res/drawable-night-mdpi/ic_mic.png new file mode 100644 index 000000000..15d0962c5 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_mic.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_msg_delivered.png b/app/src/main/res/drawable-night-mdpi/ic_msg_delivered.png new file mode 100644 index 000000000..263dc4e15 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_msg_error.png b/app/src/main/res/drawable-night-mdpi/ic_msg_error.png new file mode 100644 index 000000000..9d46dd09d Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-night-mdpi/ic_msg_notdelivered.png new file mode 100644 index 000000000..225b927f7 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_msg_pending.png b/app/src/main/res/drawable-night-mdpi/ic_msg_pending.png new file mode 100644 index 000000000..2a2a6b4f5 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_msg_sent.png b/app/src/main/res/drawable-night-mdpi/ic_msg_sent.png new file mode 100644 index 000000000..ea5dcb78a Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_msg_star.png b/app/src/main/res/drawable-night-mdpi/ic_msg_star.png new file mode 100644 index 000000000..77419e1e3 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_msg_warning.png b/app/src/main/res/drawable-night-mdpi/ic_msg_warning.png new file mode 100644 index 000000000..5f69219dc Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_about.png b/app/src/main/res/drawable-night-mdpi/ic_pref_about.png new file mode 100644 index 000000000..f8078162a Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_about.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_account.png b/app/src/main/res/drawable-night-mdpi/ic_pref_account.png new file mode 100644 index 000000000..a2bde6b64 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_account.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_appearance.png b/app/src/main/res/drawable-night-mdpi/ic_pref_appearance.png new file mode 100644 index 000000000..294bbcf1a Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_appearance.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_balloons.png b/app/src/main/res/drawable-night-mdpi/ic_pref_balloons.png new file mode 100644 index 000000000..db828631b Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_balloons.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_donate.png b/app/src/main/res/drawable-night-mdpi/ic_pref_donate.png new file mode 100644 index 000000000..5471d97ee Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_donate.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_font_size.png b/app/src/main/res/drawable-night-mdpi/ic_pref_font_size.png new file mode 100644 index 000000000..be66eba92 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_font_size.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_help.png b/app/src/main/res/drawable-night-mdpi/ic_pref_help.png new file mode 100644 index 000000000..182c290c1 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_help.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_image_resize.png b/app/src/main/res/drawable-night-mdpi/ic_pref_image_resize.png new file mode 100644 index 000000000..03c483f3c Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_image_resize.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_location.png b/app/src/main/res/drawable-night-mdpi/ic_pref_location.png new file mode 100644 index 000000000..34667f626 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_location.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_maintenance.png b/app/src/main/res/drawable-night-mdpi/ic_pref_maintenance.png new file mode 100644 index 000000000..465b292b0 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_maintenance.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_maps_service.png b/app/src/main/res/drawable-night-mdpi/ic_pref_maps_service.png new file mode 100644 index 000000000..499557049 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_maps_service.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_media.png b/app/src/main/res/drawable-night-mdpi/ic_pref_media.png new file mode 100644 index 000000000..f45fb3c16 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_media.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_messaging.png b/app/src/main/res/drawable-night-mdpi/ic_pref_messaging.png new file mode 100644 index 000000000..292dfc99b Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_messaging.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_network.png b/app/src/main/res/drawable-night-mdpi/ic_pref_network.png new file mode 100644 index 000000000..4d1bb4157 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_network.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_notification.png b/app/src/main/res/drawable-night-mdpi/ic_pref_notification.png new file mode 100644 index 000000000..53926efd3 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_notification.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_privacy.png b/app/src/main/res/drawable-night-mdpi/ic_pref_privacy.png new file mode 100644 index 000000000..74031e47c Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_privacy.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_register_device.png b/app/src/main/res/drawable-night-mdpi/ic_pref_register_device.png new file mode 100644 index 000000000..7992e13da Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_register_device.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_restart_msgcenter.png b/app/src/main/res/drawable-night-mdpi/ic_pref_restart_msgcenter.png new file mode 100644 index 000000000..6d89f7494 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_restart_msgcenter.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_send_debug_log.png b/app/src/main/res/drawable-night-mdpi/ic_pref_send_debug_log.png new file mode 100644 index 000000000..afd9369a9 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_send_debug_log.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_service_terms.png b/app/src/main/res/drawable-night-mdpi/ic_pref_service_terms.png new file mode 100644 index 000000000..c7fb31568 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_service_terms.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_pref_vibrate.png b/app/src/main/res/drawable-night-mdpi/ic_pref_vibrate.png new file mode 100644 index 000000000..d0fb103b9 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_pref_vibrate.png differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_send.png b/app/src/main/res/drawable-night-mdpi/ic_send.png new file mode 100644 index 000000000..f3d26b069 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_send.png differ diff --git a/app/src/main/res/drawable-night-mdpi/pause_balloon.png b/app/src/main/res/drawable-night-mdpi/pause_balloon.png new file mode 100644 index 000000000..1e289243f Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/pause_balloon.png differ diff --git a/app/src/main/res/drawable-night-mdpi/play_balloon.png b/app/src/main/res/drawable-night-mdpi/play_balloon.png new file mode 100644 index 000000000..11d55e089 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/play_balloon.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/app_background.png b/app/src/main/res/drawable-night-xhdpi/app_background.png new file mode 100644 index 000000000..533cfd897 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/app_background.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_attach.png b/app/src/main/res/drawable-night-xhdpi/ic_attach.png new file mode 100644 index 000000000..463cbbac5 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_attach.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_close.png b/app/src/main/res/drawable-night-xhdpi/ic_close.png new file mode 100644 index 000000000..0c9337fbe Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_close.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_emoji.png b/app/src/main/res/drawable-night-xhdpi/ic_emoji.png new file mode 100644 index 000000000..3cd2d5d15 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_emoji.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_indicator_group.png b/app/src/main/res/drawable-night-xhdpi/ic_indicator_group.png new file mode 100644 index 000000000..681f6e6c7 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_indicator_group.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_keyboard.png b/app/src/main/res/drawable-night-xhdpi/ic_keyboard.png new file mode 100644 index 000000000..4d7133c2f Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_keyboard.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_mic.png b/app/src/main/res/drawable-night-xhdpi/ic_mic.png new file mode 100644 index 000000000..c782ce56b Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_mic.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_msg_delivered.png b/app/src/main/res/drawable-night-xhdpi/ic_msg_delivered.png new file mode 100644 index 000000000..6d980b798 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_msg_error.png b/app/src/main/res/drawable-night-xhdpi/ic_msg_error.png new file mode 100644 index 000000000..b353945b5 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-night-xhdpi/ic_msg_notdelivered.png new file mode 100644 index 000000000..b66819f03 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_msg_pending.png b/app/src/main/res/drawable-night-xhdpi/ic_msg_pending.png new file mode 100644 index 000000000..82968c008 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_msg_sent.png b/app/src/main/res/drawable-night-xhdpi/ic_msg_sent.png new file mode 100644 index 000000000..cacbc1659 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_msg_star.png b/app/src/main/res/drawable-night-xhdpi/ic_msg_star.png new file mode 100644 index 000000000..12965cace Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_msg_warning.png b/app/src/main/res/drawable-night-xhdpi/ic_msg_warning.png new file mode 100644 index 000000000..5a3dfa623 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_about.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_about.png new file mode 100644 index 000000000..d8576df44 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_about.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_account.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_account.png new file mode 100644 index 000000000..696d907bd Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_account.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_appearance.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_appearance.png new file mode 100644 index 000000000..254a8d8dc Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_appearance.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_balloons.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_balloons.png new file mode 100644 index 000000000..8ddab60f8 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_balloons.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_donate.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_donate.png new file mode 100644 index 000000000..405172ebe Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_donate.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_font_size.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_font_size.png new file mode 100644 index 000000000..3faef61d8 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_font_size.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_help.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_help.png new file mode 100644 index 000000000..8bbeee9e2 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_help.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_image_resize.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_image_resize.png new file mode 100644 index 000000000..a44e658fe Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_image_resize.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_location.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_location.png new file mode 100644 index 000000000..891e131c5 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_location.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_maintenance.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_maintenance.png new file mode 100644 index 000000000..6b009d28f Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_maintenance.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_maps_service.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_maps_service.png new file mode 100644 index 000000000..76c571ab6 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_maps_service.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_media.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_media.png new file mode 100644 index 000000000..b9edc472e Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_media.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_messaging.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_messaging.png new file mode 100644 index 000000000..4851dc43a Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_messaging.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_network.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_network.png new file mode 100644 index 000000000..ac0dd1c8d Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_network.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_notification.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_notification.png new file mode 100644 index 000000000..a8023f406 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_notification.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_privacy.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_privacy.png new file mode 100644 index 000000000..0331f6464 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_privacy.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_register_device.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_register_device.png new file mode 100644 index 000000000..64b5091c3 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_register_device.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_restart_msgcenter.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_restart_msgcenter.png new file mode 100644 index 000000000..4af6786c5 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_restart_msgcenter.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_send_debug_log.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_send_debug_log.png new file mode 100644 index 000000000..6c16e6fbc Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_send_debug_log.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_service_terms.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_service_terms.png new file mode 100644 index 000000000..80660c738 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_service_terms.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_pref_vibrate.png b/app/src/main/res/drawable-night-xhdpi/ic_pref_vibrate.png new file mode 100644 index 000000000..8c6383086 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_pref_vibrate.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_send.png b/app/src/main/res/drawable-night-xhdpi/ic_send.png new file mode 100644 index 000000000..205c7a33c Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_send.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/pause_balloon.png b/app/src/main/res/drawable-night-xhdpi/pause_balloon.png new file mode 100644 index 000000000..2105446bb Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/pause_balloon.png differ diff --git a/app/src/main/res/drawable-night-xhdpi/play_balloon.png b/app/src/main/res/drawable-night-xhdpi/play_balloon.png new file mode 100644 index 000000000..b3afb8a85 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/play_balloon.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/app_background.png b/app/src/main/res/drawable-night-xxhdpi/app_background.png new file mode 100644 index 000000000..9a95ddb9d Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/app_background.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_attach.png b/app/src/main/res/drawable-night-xxhdpi/ic_attach.png new file mode 100644 index 000000000..ff98c1951 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_attach.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_close.png b/app/src/main/res/drawable-night-xxhdpi/ic_close.png new file mode 100644 index 000000000..edac08bb8 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_close.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_emoji.png b/app/src/main/res/drawable-night-xxhdpi/ic_emoji.png new file mode 100644 index 000000000..5f2617938 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_emoji.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_indicator_group.png b/app/src/main/res/drawable-night-xxhdpi/ic_indicator_group.png new file mode 100644 index 000000000..777db50c2 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_indicator_group.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_keyboard.png b/app/src/main/res/drawable-night-xxhdpi/ic_keyboard.png new file mode 100644 index 000000000..e051b9742 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_keyboard.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_mic.png b/app/src/main/res/drawable-night-xxhdpi/ic_mic.png new file mode 100644 index 000000000..5f5c773e2 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_mic.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_msg_delivered.png b/app/src/main/res/drawable-night-xxhdpi/ic_msg_delivered.png new file mode 100644 index 000000000..f03c4abb6 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_msg_error.png b/app/src/main/res/drawable-night-xxhdpi/ic_msg_error.png new file mode 100644 index 000000000..5c119d9c4 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-night-xxhdpi/ic_msg_notdelivered.png new file mode 100644 index 000000000..e973401a5 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_msg_pending.png b/app/src/main/res/drawable-night-xxhdpi/ic_msg_pending.png new file mode 100644 index 000000000..d20dbc106 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_msg_sent.png b/app/src/main/res/drawable-night-xxhdpi/ic_msg_sent.png new file mode 100644 index 000000000..7cedfed53 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_msg_star.png b/app/src/main/res/drawable-night-xxhdpi/ic_msg_star.png new file mode 100644 index 000000000..2363478fd Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_msg_warning.png b/app/src/main/res/drawable-night-xxhdpi/ic_msg_warning.png new file mode 100644 index 000000000..fe64b2184 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_about.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_about.png new file mode 100644 index 000000000..28beb9d7e Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_about.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_account.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_account.png new file mode 100644 index 000000000..336f5416d Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_account.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_appearance.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_appearance.png new file mode 100644 index 000000000..d35035357 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_appearance.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_balloons.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_balloons.png new file mode 100644 index 000000000..135a96ca3 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_balloons.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_donate.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_donate.png new file mode 100644 index 000000000..532f65beb Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_donate.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_font_size.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_font_size.png new file mode 100644 index 000000000..999b7cb2c Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_font_size.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_help.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_help.png new file mode 100644 index 000000000..e9c00e89c Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_help.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_image_resize.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_image_resize.png new file mode 100644 index 000000000..36f093007 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_image_resize.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_location.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_location.png new file mode 100644 index 000000000..5dd7000a6 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_location.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_maintenance.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_maintenance.png new file mode 100644 index 000000000..45b561227 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_maintenance.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_maps_service.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_maps_service.png new file mode 100644 index 000000000..f8a03ae32 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_maps_service.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_media.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_media.png new file mode 100644 index 000000000..d620c5ccf Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_media.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_messaging.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_messaging.png new file mode 100644 index 000000000..53b93911a Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_messaging.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_network.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_network.png new file mode 100644 index 000000000..ef4f0ab8a Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_network.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_notification.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_notification.png new file mode 100644 index 000000000..b203777a4 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_notification.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_privacy.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_privacy.png new file mode 100644 index 000000000..9547b20c6 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_privacy.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_register_device.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_register_device.png new file mode 100644 index 000000000..f1d9b5d53 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_register_device.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_restart_msgcenter.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_restart_msgcenter.png new file mode 100644 index 000000000..639489951 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_restart_msgcenter.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_send_debug_log.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_send_debug_log.png new file mode 100644 index 000000000..8a4210d7e Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_send_debug_log.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_service_terms.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_service_terms.png new file mode 100644 index 000000000..d5e8a9c45 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_service_terms.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_pref_vibrate.png b/app/src/main/res/drawable-night-xxhdpi/ic_pref_vibrate.png new file mode 100644 index 000000000..c841e03cd Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_pref_vibrate.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_send.png b/app/src/main/res/drawable-night-xxhdpi/ic_send.png new file mode 100644 index 000000000..4cf52592e Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_send.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/pause_balloon.png b/app/src/main/res/drawable-night-xxhdpi/pause_balloon.png new file mode 100644 index 000000000..21920189e Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/pause_balloon.png differ diff --git a/app/src/main/res/drawable-night-xxhdpi/play_balloon.png b/app/src/main/res/drawable-night-xxhdpi/play_balloon.png new file mode 100644 index 000000000..e3ed8a4c4 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/play_balloon.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/app_background.png b/app/src/main/res/drawable-night-xxxhdpi/app_background.png new file mode 100644 index 000000000..867126c6b Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/app_background.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_close.png b/app/src/main/res/drawable-night-xxxhdpi/ic_close.png new file mode 100644 index 000000000..b3ad62f27 Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_close.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_indicator_group.png b/app/src/main/res/drawable-night-xxxhdpi/ic_indicator_group.png new file mode 100644 index 000000000..fe8362e84 Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_indicator_group.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_msg_delivered.png b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_delivered.png new file mode 100644 index 000000000..a68f146ee Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_msg_error.png b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_error.png new file mode 100644 index 000000000..baacf8cef Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_notdelivered.png new file mode 100644 index 000000000..009dfe9d2 Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_msg_pending.png b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_pending.png new file mode 100644 index 000000000..626bc1729 Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_msg_sent.png b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_sent.png new file mode 100644 index 000000000..8aff3d79e Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_msg_star.png b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_star.png new file mode 100644 index 000000000..6820e49a7 Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_msg_warning.png b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_warning.png new file mode 100644 index 000000000..6dd3950b4 Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/pause_balloon.png b/app/src/main/res/drawable-night-xxxhdpi/pause_balloon.png new file mode 100644 index 000000000..090e46090 Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/pause_balloon.png differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/play_balloon.png b/app/src/main/res/drawable-night-xxxhdpi/play_balloon.png new file mode 100644 index 000000000..d5b45a01c Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/play_balloon.png differ diff --git a/app/src/main/res/drawable-nodpi/flag_ad.png b/app/src/main/res/drawable-nodpi/flag_ad.png deleted file mode 100644 index 625ca84f9..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ad.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ae.png b/app/src/main/res/drawable-nodpi/flag_ae.png deleted file mode 100644 index ef3a1ecfc..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ae.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_af.png b/app/src/main/res/drawable-nodpi/flag_af.png deleted file mode 100644 index a4742e299..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_af.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ag.png b/app/src/main/res/drawable-nodpi/flag_ag.png deleted file mode 100644 index 556d5504d..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ag.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ai.png b/app/src/main/res/drawable-nodpi/flag_ai.png deleted file mode 100644 index 74ed29d92..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ai.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_al.png b/app/src/main/res/drawable-nodpi/flag_al.png deleted file mode 100644 index 92354cb6e..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_al.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_am.png b/app/src/main/res/drawable-nodpi/flag_am.png deleted file mode 100644 index 344a2a86c..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_am.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_an.png b/app/src/main/res/drawable-nodpi/flag_an.png deleted file mode 100644 index 633e4b89f..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_an.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ao.png b/app/src/main/res/drawable-nodpi/flag_ao.png deleted file mode 100644 index bcbd1d6d4..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ao.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ar.png b/app/src/main/res/drawable-nodpi/flag_ar.png deleted file mode 100644 index e5ef8f1fc..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ar.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_as.png b/app/src/main/res/drawable-nodpi/flag_as.png deleted file mode 100644 index 32f30e4ce..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_as.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_at.png b/app/src/main/res/drawable-nodpi/flag_at.png deleted file mode 100644 index 0f15f34f2..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_at.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_au.png b/app/src/main/res/drawable-nodpi/flag_au.png deleted file mode 100644 index a01389a74..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_au.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_aw.png b/app/src/main/res/drawable-nodpi/flag_aw.png deleted file mode 100644 index a3579c2d6..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_aw.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ax.png b/app/src/main/res/drawable-nodpi/flag_ax.png deleted file mode 100644 index 1eea80a7b..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ax.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_az.png b/app/src/main/res/drawable-nodpi/flag_az.png deleted file mode 100644 index 4ee9fe5ce..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_az.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ba.png b/app/src/main/res/drawable-nodpi/flag_ba.png deleted file mode 100644 index c77499249..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ba.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bb.png b/app/src/main/res/drawable-nodpi/flag_bb.png deleted file mode 100644 index 0df19c71d..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bb.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bd.png b/app/src/main/res/drawable-nodpi/flag_bd.png deleted file mode 100644 index 076a8bf87..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bd.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_be.png b/app/src/main/res/drawable-nodpi/flag_be.png deleted file mode 100644 index d86ebc800..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_be.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bf.png b/app/src/main/res/drawable-nodpi/flag_bf.png deleted file mode 100644 index ab5ce8fe1..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bf.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bg.png b/app/src/main/res/drawable-nodpi/flag_bg.png deleted file mode 100644 index 0469f0607..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bg.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bh.png b/app/src/main/res/drawable-nodpi/flag_bh.png deleted file mode 100644 index ea8ce6876..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bh.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bi.png b/app/src/main/res/drawable-nodpi/flag_bi.png deleted file mode 100644 index 5cc2e30cf..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bi.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bj.png b/app/src/main/res/drawable-nodpi/flag_bj.png deleted file mode 100644 index 1cc8b458a..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bj.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bm.png b/app/src/main/res/drawable-nodpi/flag_bm.png deleted file mode 100644 index c0c7aead8..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bn.png b/app/src/main/res/drawable-nodpi/flag_bn.png deleted file mode 100644 index 8fb09849e..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bn.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bo.png b/app/src/main/res/drawable-nodpi/flag_bo.png deleted file mode 100644 index ce7ba522a..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bo.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_br.png b/app/src/main/res/drawable-nodpi/flag_br.png deleted file mode 100644 index 9b1a5538b..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_br.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bs.png b/app/src/main/res/drawable-nodpi/flag_bs.png deleted file mode 100644 index 639fa6cfa..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bs.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bt.png b/app/src/main/res/drawable-nodpi/flag_bt.png deleted file mode 100644 index 1d512dfff..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bt.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bv.png b/app/src/main/res/drawable-nodpi/flag_bv.png deleted file mode 100644 index 160b6b5b7..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bv.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bw.png b/app/src/main/res/drawable-nodpi/flag_bw.png deleted file mode 100644 index fcb103941..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bw.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_by.png b/app/src/main/res/drawable-nodpi/flag_by.png deleted file mode 100644 index 504774ec1..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_by.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_bz.png b/app/src/main/res/drawable-nodpi/flag_bz.png deleted file mode 100644 index be63ee1c6..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_bz.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ca.png b/app/src/main/res/drawable-nodpi/flag_ca.png deleted file mode 100644 index 1f204193a..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ca.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cc.png b/app/src/main/res/drawable-nodpi/flag_cc.png deleted file mode 100644 index aed3d3b4e..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cc.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cd.png b/app/src/main/res/drawable-nodpi/flag_cd.png deleted file mode 100644 index 5e4894248..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cd.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cf.png b/app/src/main/res/drawable-nodpi/flag_cf.png deleted file mode 100644 index da687bdce..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cf.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cg.png b/app/src/main/res/drawable-nodpi/flag_cg.png deleted file mode 100644 index a859792ef..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cg.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ch.png b/app/src/main/res/drawable-nodpi/flag_ch.png deleted file mode 100644 index 242ec01aa..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ch.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ci.png b/app/src/main/res/drawable-nodpi/flag_ci.png deleted file mode 100644 index 3f2c62eb4..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ci.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ck.png b/app/src/main/res/drawable-nodpi/flag_ck.png deleted file mode 100644 index 746d3d6f7..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ck.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cl.png b/app/src/main/res/drawable-nodpi/flag_cl.png deleted file mode 100644 index 29c6d61bd..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cl.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cm.png b/app/src/main/res/drawable-nodpi/flag_cm.png deleted file mode 100644 index f65c5bd5a..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cn.png b/app/src/main/res/drawable-nodpi/flag_cn.png deleted file mode 100644 index 891441462..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cn.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_co.png b/app/src/main/res/drawable-nodpi/flag_co.png deleted file mode 100644 index a118ff4a1..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_co.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cr.png b/app/src/main/res/drawable-nodpi/flag_cr.png deleted file mode 100644 index c7a373179..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cr.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cs.png b/app/src/main/res/drawable-nodpi/flag_cs.png deleted file mode 100644 index 8254790ca..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cs.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cu.png b/app/src/main/res/drawable-nodpi/flag_cu.png deleted file mode 100644 index 083f1d611..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cu.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cv.png b/app/src/main/res/drawable-nodpi/flag_cv.png deleted file mode 100644 index a63f7eaf6..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cv.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cx.png b/app/src/main/res/drawable-nodpi/flag_cx.png deleted file mode 100644 index 48e31adbf..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cx.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cy.png b/app/src/main/res/drawable-nodpi/flag_cy.png deleted file mode 100644 index 5b1ad6c07..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cy.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_cz.png b/app/src/main/res/drawable-nodpi/flag_cz.png deleted file mode 100644 index c8403dd21..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_cz.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_de.png b/app/src/main/res/drawable-nodpi/flag_de.png deleted file mode 100644 index ac4a97736..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_de.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_dj.png b/app/src/main/res/drawable-nodpi/flag_dj.png deleted file mode 100644 index 582af364f..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_dj.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_dk.png b/app/src/main/res/drawable-nodpi/flag_dk.png deleted file mode 100644 index e2993d3c5..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_dk.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_dm.png b/app/src/main/res/drawable-nodpi/flag_dm.png deleted file mode 100644 index 5fbffcba3..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_dm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_do.png b/app/src/main/res/drawable-nodpi/flag_do.png deleted file mode 100644 index 5a04932d8..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_do.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_dz.png b/app/src/main/res/drawable-nodpi/flag_dz.png deleted file mode 100644 index 335c2391d..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_dz.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ec.png b/app/src/main/res/drawable-nodpi/flag_ec.png deleted file mode 100644 index 0caa0b1e7..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ec.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ee.png b/app/src/main/res/drawable-nodpi/flag_ee.png deleted file mode 100644 index 0c82efb7d..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ee.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_eg.png b/app/src/main/res/drawable-nodpi/flag_eg.png deleted file mode 100644 index 8a3f7a10b..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_eg.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_eh.png b/app/src/main/res/drawable-nodpi/flag_eh.png deleted file mode 100644 index 90a1195b4..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_eh.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_er.png b/app/src/main/res/drawable-nodpi/flag_er.png deleted file mode 100644 index 13065ae99..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_er.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_es.png b/app/src/main/res/drawable-nodpi/flag_es.png deleted file mode 100644 index c2de2d711..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_es.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_et.png b/app/src/main/res/drawable-nodpi/flag_et.png deleted file mode 100644 index 2e893fa05..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_et.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_fi.png b/app/src/main/res/drawable-nodpi/flag_fi.png deleted file mode 100644 index 14ec091b8..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_fi.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_fj.png b/app/src/main/res/drawable-nodpi/flag_fj.png deleted file mode 100644 index cee998892..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_fj.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_fk.png b/app/src/main/res/drawable-nodpi/flag_fk.png deleted file mode 100644 index ceaeb27de..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_fk.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_fm.png b/app/src/main/res/drawable-nodpi/flag_fm.png deleted file mode 100644 index 066bb2473..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_fm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_fo.png b/app/src/main/res/drawable-nodpi/flag_fo.png deleted file mode 100644 index cbceb809e..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_fo.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_fr.png b/app/src/main/res/drawable-nodpi/flag_fr.png deleted file mode 100644 index 8332c4ec2..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_fr.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ga.png b/app/src/main/res/drawable-nodpi/flag_ga.png deleted file mode 100644 index 0e0d43436..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ga.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gb.png b/app/src/main/res/drawable-nodpi/flag_gb.png deleted file mode 100644 index ff701e19f..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gb.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gd.png b/app/src/main/res/drawable-nodpi/flag_gd.png deleted file mode 100644 index 9ab57f548..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gd.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ge.png b/app/src/main/res/drawable-nodpi/flag_ge.png deleted file mode 100644 index 728d97078..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ge.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gf.png b/app/src/main/res/drawable-nodpi/flag_gf.png deleted file mode 100644 index 8332c4ec2..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gf.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gh.png b/app/src/main/res/drawable-nodpi/flag_gh.png deleted file mode 100644 index 4e2f89659..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gh.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gi.png b/app/src/main/res/drawable-nodpi/flag_gi.png deleted file mode 100644 index e76797f62..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gi.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gl.png b/app/src/main/res/drawable-nodpi/flag_gl.png deleted file mode 100644 index ef12a73bf..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gl.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gm.png b/app/src/main/res/drawable-nodpi/flag_gm.png deleted file mode 100644 index 0720b667a..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gn.png b/app/src/main/res/drawable-nodpi/flag_gn.png deleted file mode 100644 index ea660b01f..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gn.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gp.png b/app/src/main/res/drawable-nodpi/flag_gp.png deleted file mode 100644 index dbb086d00..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gp.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gq.png b/app/src/main/res/drawable-nodpi/flag_gq.png deleted file mode 100644 index ebe20a28d..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gq.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gr.png b/app/src/main/res/drawable-nodpi/flag_gr.png deleted file mode 100644 index 8651ade7c..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gr.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gs.png b/app/src/main/res/drawable-nodpi/flag_gs.png deleted file mode 100644 index 7ef0bf598..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gs.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gt.png b/app/src/main/res/drawable-nodpi/flag_gt.png deleted file mode 100644 index c43a70d36..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gt.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gu.png b/app/src/main/res/drawable-nodpi/flag_gu.png deleted file mode 100644 index 92f37c053..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gu.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gw.png b/app/src/main/res/drawable-nodpi/flag_gw.png deleted file mode 100644 index b37bcf06b..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gw.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_gy.png b/app/src/main/res/drawable-nodpi/flag_gy.png deleted file mode 100644 index 22cbe2f59..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_gy.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_hk.png b/app/src/main/res/drawable-nodpi/flag_hk.png deleted file mode 100644 index d5c380ca9..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_hk.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_hm.png b/app/src/main/res/drawable-nodpi/flag_hm.png deleted file mode 100644 index a01389a74..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_hm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_hn.png b/app/src/main/res/drawable-nodpi/flag_hn.png deleted file mode 100644 index 96f838859..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_hn.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_hr.png b/app/src/main/res/drawable-nodpi/flag_hr.png deleted file mode 100644 index 696b51546..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_hr.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ht.png b/app/src/main/res/drawable-nodpi/flag_ht.png deleted file mode 100644 index 416052af7..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ht.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_hu.png b/app/src/main/res/drawable-nodpi/flag_hu.png deleted file mode 100644 index 7baafe44d..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_hu.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_id.png b/app/src/main/res/drawable-nodpi/flag_id.png deleted file mode 100644 index c6bc0fafa..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_id.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ie.png b/app/src/main/res/drawable-nodpi/flag_ie.png deleted file mode 100644 index 26baa31e1..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ie.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_il.png b/app/src/main/res/drawable-nodpi/flag_il.png deleted file mode 100644 index 2ca772d0b..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_il.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_in.png b/app/src/main/res/drawable-nodpi/flag_in.png deleted file mode 100644 index e4d7e81a9..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_in.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_io.png b/app/src/main/res/drawable-nodpi/flag_io.png deleted file mode 100644 index 3e74b6a31..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_io.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_iq.png b/app/src/main/res/drawable-nodpi/flag_iq.png deleted file mode 100644 index 878a35140..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_iq.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ir.png b/app/src/main/res/drawable-nodpi/flag_ir.png deleted file mode 100644 index c5fd136ae..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ir.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_is.png b/app/src/main/res/drawable-nodpi/flag_is.png deleted file mode 100644 index b8f6d0f06..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_is.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_it.png b/app/src/main/res/drawable-nodpi/flag_it.png deleted file mode 100644 index 89692f74f..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_it.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_jm.png b/app/src/main/res/drawable-nodpi/flag_jm.png deleted file mode 100644 index 7be119e03..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_jm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_jo.png b/app/src/main/res/drawable-nodpi/flag_jo.png deleted file mode 100644 index 11bd4972b..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_jo.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_jp.png b/app/src/main/res/drawable-nodpi/flag_jp.png deleted file mode 100644 index 325fbad3f..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_jp.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ke.png b/app/src/main/res/drawable-nodpi/flag_ke.png deleted file mode 100644 index 51879adf1..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ke.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_kg.png b/app/src/main/res/drawable-nodpi/flag_kg.png deleted file mode 100644 index 0a818f67e..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_kg.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_kh.png b/app/src/main/res/drawable-nodpi/flag_kh.png deleted file mode 100644 index 30f6bb1b9..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_kh.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ki.png b/app/src/main/res/drawable-nodpi/flag_ki.png deleted file mode 100644 index 2dcce4b33..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ki.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_km.png b/app/src/main/res/drawable-nodpi/flag_km.png deleted file mode 100644 index 812b2f56c..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_km.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_kn.png b/app/src/main/res/drawable-nodpi/flag_kn.png deleted file mode 100644 index febd5b486..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_kn.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_kp.png b/app/src/main/res/drawable-nodpi/flag_kp.png deleted file mode 100644 index d3d509aa8..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_kp.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_kr.png b/app/src/main/res/drawable-nodpi/flag_kr.png deleted file mode 100644 index 9c0a78eb9..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_kr.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_kw.png b/app/src/main/res/drawable-nodpi/flag_kw.png deleted file mode 100644 index 96546da32..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_kw.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ky.png b/app/src/main/res/drawable-nodpi/flag_ky.png deleted file mode 100644 index 15c5f8e47..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ky.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_kz.png b/app/src/main/res/drawable-nodpi/flag_kz.png deleted file mode 100644 index 45a8c8874..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_kz.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_la.png b/app/src/main/res/drawable-nodpi/flag_la.png deleted file mode 100644 index e28acd018..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_la.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_lb.png b/app/src/main/res/drawable-nodpi/flag_lb.png deleted file mode 100644 index d0d452bf8..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_lb.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_lc.png b/app/src/main/res/drawable-nodpi/flag_lc.png deleted file mode 100644 index a47d06554..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_lc.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_li.png b/app/src/main/res/drawable-nodpi/flag_li.png deleted file mode 100644 index 6469909c0..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_li.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_lk.png b/app/src/main/res/drawable-nodpi/flag_lk.png deleted file mode 100644 index 088aad6db..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_lk.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_lr.png b/app/src/main/res/drawable-nodpi/flag_lr.png deleted file mode 100644 index 89a5bc7e7..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_lr.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ls.png b/app/src/main/res/drawable-nodpi/flag_ls.png deleted file mode 100644 index 33fdef101..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ls.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_lt.png b/app/src/main/res/drawable-nodpi/flag_lt.png deleted file mode 100644 index c8ef0da09..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_lt.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_lu.png b/app/src/main/res/drawable-nodpi/flag_lu.png deleted file mode 100644 index 4cabba98a..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_lu.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_lv.png b/app/src/main/res/drawable-nodpi/flag_lv.png deleted file mode 100644 index 49b699810..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_lv.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ly.png b/app/src/main/res/drawable-nodpi/flag_ly.png deleted file mode 100644 index b163a9f8a..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ly.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ma.png b/app/src/main/res/drawable-nodpi/flag_ma.png deleted file mode 100644 index f38677028..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ma.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mc.png b/app/src/main/res/drawable-nodpi/flag_mc.png deleted file mode 100644 index 1aa830f12..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mc.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_md.png b/app/src/main/res/drawable-nodpi/flag_md.png deleted file mode 100644 index 4e92c1890..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_md.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_me.png b/app/src/main/res/drawable-nodpi/flag_me.png deleted file mode 100644 index ac7253558..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_me.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mg.png b/app/src/main/res/drawable-nodpi/flag_mg.png deleted file mode 100644 index d2715b3d0..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mg.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mh.png b/app/src/main/res/drawable-nodpi/flag_mh.png deleted file mode 100644 index fb523a8c3..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mh.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mk.png b/app/src/main/res/drawable-nodpi/flag_mk.png deleted file mode 100644 index db173aaff..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mk.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ml.png b/app/src/main/res/drawable-nodpi/flag_ml.png deleted file mode 100644 index 2cec8ba44..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ml.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mm.png b/app/src/main/res/drawable-nodpi/flag_mm.png deleted file mode 100644 index f464f67ff..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mn.png b/app/src/main/res/drawable-nodpi/flag_mn.png deleted file mode 100644 index 9396355db..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mn.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mo.png b/app/src/main/res/drawable-nodpi/flag_mo.png deleted file mode 100644 index deb801dda..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mo.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mp.png b/app/src/main/res/drawable-nodpi/flag_mp.png deleted file mode 100644 index 298d588b1..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mp.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mq.png b/app/src/main/res/drawable-nodpi/flag_mq.png deleted file mode 100644 index 010143b38..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mq.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mr.png b/app/src/main/res/drawable-nodpi/flag_mr.png deleted file mode 100644 index 319546b10..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mr.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ms.png b/app/src/main/res/drawable-nodpi/flag_ms.png deleted file mode 100644 index d4cbb433d..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ms.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mt.png b/app/src/main/res/drawable-nodpi/flag_mt.png deleted file mode 100644 index 00af94871..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mt.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mu.png b/app/src/main/res/drawable-nodpi/flag_mu.png deleted file mode 100644 index b7fdce1bd..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mu.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mv.png b/app/src/main/res/drawable-nodpi/flag_mv.png deleted file mode 100644 index 5073d9ec4..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mv.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mw.png b/app/src/main/res/drawable-nodpi/flag_mw.png deleted file mode 100644 index 13886e9f8..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mw.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mx.png b/app/src/main/res/drawable-nodpi/flag_mx.png deleted file mode 100644 index 5bc58ab3e..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mx.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_my.png b/app/src/main/res/drawable-nodpi/flag_my.png deleted file mode 100644 index 9034cbab2..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_my.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_mz.png b/app/src/main/res/drawable-nodpi/flag_mz.png deleted file mode 100644 index 76405e063..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_mz.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_na.png b/app/src/main/res/drawable-nodpi/flag_na.png deleted file mode 100644 index 63358c67d..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_na.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_nc.png b/app/src/main/res/drawable-nodpi/flag_nc.png deleted file mode 100644 index 2cad28378..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_nc.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ne.png b/app/src/main/res/drawable-nodpi/flag_ne.png deleted file mode 100644 index d85f424f3..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ne.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_nf.png b/app/src/main/res/drawable-nodpi/flag_nf.png deleted file mode 100644 index f9bcdda12..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_nf.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ng.png b/app/src/main/res/drawable-nodpi/flag_ng.png deleted file mode 100644 index 3eea2e020..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ng.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ni.png b/app/src/main/res/drawable-nodpi/flag_ni.png deleted file mode 100644 index 3969aaaae..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ni.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_nl.png b/app/src/main/res/drawable-nodpi/flag_nl.png deleted file mode 100644 index fe44791e3..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_nl.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_no.png b/app/src/main/res/drawable-nodpi/flag_no.png deleted file mode 100644 index 160b6b5b7..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_no.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_np.png b/app/src/main/res/drawable-nodpi/flag_np.png deleted file mode 100644 index aeb058b7e..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_np.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_nr.png b/app/src/main/res/drawable-nodpi/flag_nr.png deleted file mode 100644 index 705fc337c..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_nr.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_nu.png b/app/src/main/res/drawable-nodpi/flag_nu.png deleted file mode 100644 index c3ce4aedd..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_nu.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_nz.png b/app/src/main/res/drawable-nodpi/flag_nz.png deleted file mode 100644 index 10d6306d1..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_nz.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_om.png b/app/src/main/res/drawable-nodpi/flag_om.png deleted file mode 100644 index 2ffba7e8c..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_om.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_pa.png b/app/src/main/res/drawable-nodpi/flag_pa.png deleted file mode 100644 index 9b2ee9a78..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_pa.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_pe.png b/app/src/main/res/drawable-nodpi/flag_pe.png deleted file mode 100644 index 62a04977f..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_pe.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_pf.png b/app/src/main/res/drawable-nodpi/flag_pf.png deleted file mode 100644 index 771a0f652..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_pf.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_pg.png b/app/src/main/res/drawable-nodpi/flag_pg.png deleted file mode 100644 index 10d623349..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_pg.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ph.png b/app/src/main/res/drawable-nodpi/flag_ph.png deleted file mode 100644 index b89e15935..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ph.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_pk.png b/app/src/main/res/drawable-nodpi/flag_pk.png deleted file mode 100644 index e9df70ca4..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_pk.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_pl.png b/app/src/main/res/drawable-nodpi/flag_pl.png deleted file mode 100644 index d413d010b..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_pl.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_pm.png b/app/src/main/res/drawable-nodpi/flag_pm.png deleted file mode 100644 index ba91d2c7a..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_pm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_pn.png b/app/src/main/res/drawable-nodpi/flag_pn.png deleted file mode 100644 index aa9344f57..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_pn.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_pr.png b/app/src/main/res/drawable-nodpi/flag_pr.png deleted file mode 100644 index 82d9130da..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_pr.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ps.png b/app/src/main/res/drawable-nodpi/flag_ps.png deleted file mode 100644 index f5f547762..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ps.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_pt.png b/app/src/main/res/drawable-nodpi/flag_pt.png deleted file mode 100644 index ece798015..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_pt.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_pw.png b/app/src/main/res/drawable-nodpi/flag_pw.png deleted file mode 100644 index 6178b254a..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_pw.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_py.png b/app/src/main/res/drawable-nodpi/flag_py.png deleted file mode 100644 index cb8723c06..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_py.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_qa.png b/app/src/main/res/drawable-nodpi/flag_qa.png deleted file mode 100644 index ed4c621fa..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_qa.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_re.png b/app/src/main/res/drawable-nodpi/flag_re.png deleted file mode 100644 index 8332c4ec2..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_re.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ro.png b/app/src/main/res/drawable-nodpi/flag_ro.png deleted file mode 100644 index 57e74a651..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ro.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_rs.png b/app/src/main/res/drawable-nodpi/flag_rs.png deleted file mode 100644 index 9439a5b60..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_rs.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ru.png b/app/src/main/res/drawable-nodpi/flag_ru.png deleted file mode 100644 index 47da4214f..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ru.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_rw.png b/app/src/main/res/drawable-nodpi/flag_rw.png deleted file mode 100644 index 535649178..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_rw.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sa.png b/app/src/main/res/drawable-nodpi/flag_sa.png deleted file mode 100644 index b4641c7e8..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sa.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sb.png b/app/src/main/res/drawable-nodpi/flag_sb.png deleted file mode 100644 index a9937ccf0..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sb.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sc.png b/app/src/main/res/drawable-nodpi/flag_sc.png deleted file mode 100644 index 39ee37184..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sc.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sd.png b/app/src/main/res/drawable-nodpi/flag_sd.png deleted file mode 100644 index eaab69eb7..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sd.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_se.png b/app/src/main/res/drawable-nodpi/flag_se.png deleted file mode 100644 index 1994653da..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_se.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sg.png b/app/src/main/res/drawable-nodpi/flag_sg.png deleted file mode 100644 index dd34d6121..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sg.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sh.png b/app/src/main/res/drawable-nodpi/flag_sh.png deleted file mode 100644 index 4b1d2a291..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sh.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_si.png b/app/src/main/res/drawable-nodpi/flag_si.png deleted file mode 100644 index bb1476ff5..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_si.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sj.png b/app/src/main/res/drawable-nodpi/flag_sj.png deleted file mode 100644 index 160b6b5b7..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sj.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sk.png b/app/src/main/res/drawable-nodpi/flag_sk.png deleted file mode 100644 index 7ccbc8274..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sk.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sl.png b/app/src/main/res/drawable-nodpi/flag_sl.png deleted file mode 100644 index 12d812d29..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sl.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sm.png b/app/src/main/res/drawable-nodpi/flag_sm.png deleted file mode 100644 index 3df2fdcf8..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sn.png b/app/src/main/res/drawable-nodpi/flag_sn.png deleted file mode 100644 index eabb71db4..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sn.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_so.png b/app/src/main/res/drawable-nodpi/flag_so.png deleted file mode 100644 index 4a1ea4b29..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_so.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sr.png b/app/src/main/res/drawable-nodpi/flag_sr.png deleted file mode 100644 index 5eff9271d..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sr.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_st.png b/app/src/main/res/drawable-nodpi/flag_st.png deleted file mode 100644 index 2978557b1..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_st.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sv.png b/app/src/main/res/drawable-nodpi/flag_sv.png deleted file mode 100644 index 24987990b..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sv.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sy.png b/app/src/main/res/drawable-nodpi/flag_sy.png deleted file mode 100644 index f5ce30dcb..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sy.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_sz.png b/app/src/main/res/drawable-nodpi/flag_sz.png deleted file mode 100644 index 914ee861d..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_sz.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tc.png b/app/src/main/res/drawable-nodpi/flag_tc.png deleted file mode 100644 index 8fc1156be..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tc.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_td.png b/app/src/main/res/drawable-nodpi/flag_td.png deleted file mode 100644 index 667f21fd9..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_td.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tf.png b/app/src/main/res/drawable-nodpi/flag_tf.png deleted file mode 100644 index 80529a436..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tf.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tg.png b/app/src/main/res/drawable-nodpi/flag_tg.png deleted file mode 100644 index 3aa00ad4d..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tg.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_th.png b/app/src/main/res/drawable-nodpi/flag_th.png deleted file mode 100644 index dd8ba9171..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_th.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tj.png b/app/src/main/res/drawable-nodpi/flag_tj.png deleted file mode 100644 index 617bf6455..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tj.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tk.png b/app/src/main/res/drawable-nodpi/flag_tk.png deleted file mode 100644 index 67b8c8cb5..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tk.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tl.png b/app/src/main/res/drawable-nodpi/flag_tl.png deleted file mode 100644 index 77da181e9..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tl.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tm.png b/app/src/main/res/drawable-nodpi/flag_tm.png deleted file mode 100644 index 828020ecd..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tn.png b/app/src/main/res/drawable-nodpi/flag_tn.png deleted file mode 100644 index 183cdd3dc..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tn.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_to.png b/app/src/main/res/drawable-nodpi/flag_to.png deleted file mode 100644 index f89b8ba75..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_to.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tr.png b/app/src/main/res/drawable-nodpi/flag_tr.png deleted file mode 100644 index be32f77e9..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tr.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tt.png b/app/src/main/res/drawable-nodpi/flag_tt.png deleted file mode 100644 index 2a11c1e20..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tt.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tv.png b/app/src/main/res/drawable-nodpi/flag_tv.png deleted file mode 100644 index 28274c5fb..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tv.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tw.png b/app/src/main/res/drawable-nodpi/flag_tw.png deleted file mode 100644 index f31c654c9..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tw.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_tz.png b/app/src/main/res/drawable-nodpi/flag_tz.png deleted file mode 100644 index c00ff7961..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_tz.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ua.png b/app/src/main/res/drawable-nodpi/flag_ua.png deleted file mode 100644 index 09563a219..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ua.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ug.png b/app/src/main/res/drawable-nodpi/flag_ug.png deleted file mode 100644 index 33f4affad..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ug.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_um.png b/app/src/main/res/drawable-nodpi/flag_um.png deleted file mode 100644 index c1dd9654b..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_um.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_us.png b/app/src/main/res/drawable-nodpi/flag_us.png deleted file mode 100644 index 10f451fe8..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_us.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_uy.png b/app/src/main/res/drawable-nodpi/flag_uy.png deleted file mode 100644 index 31d948a06..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_uy.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_uz.png b/app/src/main/res/drawable-nodpi/flag_uz.png deleted file mode 100644 index fef5dc170..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_uz.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_va.png b/app/src/main/res/drawable-nodpi/flag_va.png deleted file mode 100644 index b31eaf225..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_va.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_vc.png b/app/src/main/res/drawable-nodpi/flag_vc.png deleted file mode 100644 index 8fa17b061..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_vc.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ve.png b/app/src/main/res/drawable-nodpi/flag_ve.png deleted file mode 100644 index 00c90f9af..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ve.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_vg.png b/app/src/main/res/drawable-nodpi/flag_vg.png deleted file mode 100644 index 415690798..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_vg.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_vi.png b/app/src/main/res/drawable-nodpi/flag_vi.png deleted file mode 100644 index ed26915a3..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_vi.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_vn.png b/app/src/main/res/drawable-nodpi/flag_vn.png deleted file mode 100644 index ec7cd48a3..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_vn.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_vu.png b/app/src/main/res/drawable-nodpi/flag_vu.png deleted file mode 100644 index b3397bc63..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_vu.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_wf.png b/app/src/main/res/drawable-nodpi/flag_wf.png deleted file mode 100644 index 9f9558734..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_wf.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ws.png b/app/src/main/res/drawable-nodpi/flag_ws.png deleted file mode 100644 index c16950802..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ws.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_ye.png b/app/src/main/res/drawable-nodpi/flag_ye.png deleted file mode 100644 index 468dfad03..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_ye.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_yt.png b/app/src/main/res/drawable-nodpi/flag_yt.png deleted file mode 100644 index c298f378b..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_yt.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_za.png b/app/src/main/res/drawable-nodpi/flag_za.png deleted file mode 100644 index 57c58e211..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_za.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_zm.png b/app/src/main/res/drawable-nodpi/flag_zm.png deleted file mode 100644 index c25b07bee..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_zm.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/flag_zw.png b/app/src/main/res/drawable-nodpi/flag_zw.png deleted file mode 100644 index 53c97259b..000000000 Binary files a/app/src/main/res/drawable-nodpi/flag_zw.png and /dev/null differ diff --git a/app/src/main/res/drawable-sw600dp/message_list_item_in_border.xml b/app/src/main/res/drawable-sw600dp/message_list_item_in_border.xml deleted file mode 100644 index 9136129bd..000000000 --- a/app/src/main/res/drawable-sw600dp/message_list_item_in_border.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable-sw600dp/message_list_item_in_fill.xml b/app/src/main/res/drawable-sw600dp/message_list_item_in_fill.xml deleted file mode 100644 index e222f8de0..000000000 --- a/app/src/main/res/drawable-sw600dp/message_list_item_in_fill.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable-sw600dp/message_list_item_out_border.xml b/app/src/main/res/drawable-sw600dp/message_list_item_out_border.xml deleted file mode 100644 index 64886d6aa..000000000 --- a/app/src/main/res/drawable-sw600dp/message_list_item_out_border.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable-sw600dp/message_list_item_out_fill.xml b/app/src/main/res/drawable-sw600dp/message_list_item_out_fill.xml deleted file mode 100644 index 2b63ce0d3..000000000 --- a/app/src/main/res/drawable-sw600dp/message_list_item_out_fill.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable-xhdpi-v10/ic_stat_notify.png b/app/src/main/res/drawable-xhdpi-v10/ic_stat_notify.png deleted file mode 100644 index 65cb90f9a..000000000 Binary files a/app/src/main/res/drawable-xhdpi-v10/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi-v11/ic_menu_contact.png b/app/src/main/res/drawable-xhdpi-v11/ic_menu_contact.png deleted file mode 100644 index a62858e73..000000000 Binary files a/app/src/main/res/drawable-xhdpi-v11/ic_menu_contact.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi-v11/ic_stat_notify.png b/app/src/main/res/drawable-xhdpi-v11/ic_stat_notify.png deleted file mode 100644 index c3ad313b4..000000000 Binary files a/app/src/main/res/drawable-xhdpi-v11/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi-v9/ic_stat_notify.png b/app/src/main/res/drawable-xhdpi-v9/ic_stat_notify.png deleted file mode 100644 index 65cb90f9a..000000000 Binary files a/app/src/main/res/drawable-xhdpi-v9/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/app_background.png b/app/src/main/res/drawable-xhdpi/app_background.png new file mode 100644 index 000000000..9b9b58f1e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/app_background.png differ diff --git a/app/src/main/res/drawable-xhdpi/attach_audio.png b/app/src/main/res/drawable-xhdpi/attach_audio.png new file mode 100644 index 000000000..eec779844 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/attach_audio.png differ diff --git a/app/src/main/res/drawable-xhdpi/attach_camera.png b/app/src/main/res/drawable-xhdpi/attach_camera.png new file mode 100644 index 000000000..8a2056eca Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/attach_camera.png differ diff --git a/app/src/main/res/drawable-xhdpi/attach_contact.png b/app/src/main/res/drawable-xhdpi/attach_contact.png new file mode 100644 index 000000000..b81f418de Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/attach_contact.png differ diff --git a/app/src/main/res/drawable-xhdpi/attach_file.png b/app/src/main/res/drawable-xhdpi/attach_file.png new file mode 100644 index 000000000..ef152d004 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/attach_file.png differ diff --git a/app/src/main/res/drawable-xhdpi/attach_gallery.png b/app/src/main/res/drawable-xhdpi/attach_gallery.png new file mode 100644 index 000000000..8c2fe6748 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/attach_gallery.png differ diff --git a/app/src/main/res/drawable-xhdpi/attach_hide1.png b/app/src/main/res/drawable-xhdpi/attach_hide1.png new file mode 100644 index 000000000..24611da40 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/attach_hide1.png differ diff --git a/app/src/main/res/drawable-xhdpi/attach_location.png b/app/src/main/res/drawable-xhdpi/attach_location.png new file mode 100644 index 000000000..0294c218f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/attach_location.png differ diff --git a/app/src/main/res/drawable-xhdpi/attach_video.png b/app/src/main/res/drawable-xhdpi/attach_video.png new file mode 100644 index 000000000..f4c08020c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/attach_video.png differ diff --git a/app/src/main/res/drawable-xhdpi/balloon_classic_incoming.9.png b/app/src/main/res/drawable-xhdpi/balloon_classic_incoming.9.png new file mode 100644 index 000000000..f0e3a1df0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/balloon_classic_incoming.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/balloon_classic_outgoing.9.png b/app/src/main/res/drawable-xhdpi/balloon_classic_outgoing.9.png new file mode 100644 index 000000000..a4de0c43f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/balloon_classic_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/balloon_iphone_incoming.9.png b/app/src/main/res/drawable-xhdpi/balloon_iphone_incoming.9.png new file mode 100644 index 000000000..7125fe701 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/balloon_iphone_incoming.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/balloon_iphone_outgoing.9.png b/app/src/main/res/drawable-xhdpi/balloon_iphone_outgoing.9.png new file mode 100644 index 000000000..76d24d961 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/balloon_iphone_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/balloon_old_classic_incoming.9.png b/app/src/main/res/drawable-xhdpi/balloon_old_classic_incoming.9.png new file mode 100644 index 000000000..64c02f3f2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/balloon_old_classic_incoming.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/balloon_old_classic_outgoing.9.png b/app/src/main/res/drawable-xhdpi/balloon_old_classic_outgoing.9.png new file mode 100644 index 000000000..4d28c026c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/balloon_old_classic_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/balloon_silence_incoming.9.png b/app/src/main/res/drawable-xhdpi/balloon_silence_incoming.9.png new file mode 100644 index 000000000..a78ff29b7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/balloon_silence_incoming.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/balloon_silence_outgoing.9.png b/app/src/main/res/drawable-xhdpi/balloon_silence_outgoing.9.png new file mode 100644 index 000000000..2581affe1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/balloon_silence_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/donwload_audip.png b/app/src/main/res/drawable-xhdpi/donwload_audip.png deleted file mode 100755 index b3d13b565..000000000 Binary files a/app/src/main/res/drawable-xhdpi/donwload_audip.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_attach.png b/app/src/main/res/drawable-xhdpi/ic_attach.png new file mode 100644 index 000000000..e6eb9355c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_attach.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_attach_audio.png b/app/src/main/res/drawable-xhdpi/ic_attach_audio.png new file mode 100644 index 000000000..f50200787 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_attach_audio.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_attach_contact.png b/app/src/main/res/drawable-xhdpi/ic_attach_contact.png new file mode 100644 index 000000000..1ebd1a9f7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_attach_contact.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_attach_picture.png b/app/src/main/res/drawable-xhdpi/ic_attach_picture.png new file mode 100644 index 000000000..bb701f0ba Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_attach_picture.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_close.png b/app/src/main/res/drawable-xhdpi/ic_close.png new file mode 100644 index 000000000..966b546f8 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_close.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_create_white.png b/app/src/main/res/drawable-xhdpi/ic_create_white.png new file mode 100644 index 000000000..5a06bff5a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_create_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_denied.png b/app/src/main/res/drawable-xhdpi/ic_denied.png new file mode 100644 index 000000000..9cc414b4a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_denied.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_emoji.png b/app/src/main/res/drawable-xhdpi/ic_emoji.png new file mode 100644 index 000000000..89ff06777 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_emoji.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_emoji_dark.png b/app/src/main/res/drawable-xhdpi/ic_emoji_dark.png deleted file mode 100644 index bfa252c28..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_emoji_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_globe.png b/app/src/main/res/drawable-xhdpi/ic_globe.png new file mode 100644 index 000000000..cb22398c1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_globe.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_indicator_group.png b/app/src/main/res/drawable-xhdpi/ic_indicator_group.png new file mode 100644 index 000000000..b2f3129b3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_indicator_group.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_keyboard.png b/app/src/main/res/drawable-xhdpi/ic_keyboard.png new file mode 100644 index 000000000..fce325beb Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_keyboard.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_keyboard_dark.png b/app/src/main/res/drawable-xhdpi/ic_keyboard_dark.png deleted file mode 100644 index 8ab53d974..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_keyboard_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher.png b/app/src/main/res/drawable-xhdpi/ic_launcher.png deleted file mode 100644 index 5c07678ca..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_map_pin_google.png b/app/src/main/res/drawable-xhdpi/ic_map_pin_google.png new file mode 100644 index 000000000..b72b803f0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_map_pin_google.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_archive.png b/app/src/main/res/drawable-xhdpi/ic_menu_archive.png new file mode 100644 index 000000000..3513bd9fe Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_archive.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_attach.png b/app/src/main/res/drawable-xhdpi/ic_menu_attach.png index 3b0bcb068..c3ff2bda3 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_menu_attach.png and b/app/src/main/res/drawable-xhdpi/ic_menu_attach.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_block.png b/app/src/main/res/drawable-xhdpi/ic_menu_block.png new file mode 100644 index 000000000..7aba97b65 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_block.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_call.png b/app/src/main/res/drawable-xhdpi/ic_menu_call.png new file mode 100644 index 000000000..ef45e933a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_call.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_call_black.png b/app/src/main/res/drawable-xhdpi/ic_menu_call_black.png new file mode 100644 index 000000000..99f28bbec Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_call_black.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_cancel.png b/app/src/main/res/drawable-xhdpi/ic_menu_cancel.png new file mode 100644 index 000000000..6ebbc8316 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_cancel.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_chat.png b/app/src/main/res/drawable-xhdpi/ic_menu_chat.png new file mode 100644 index 000000000..ace0bef32 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_chat.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_check.png b/app/src/main/res/drawable-xhdpi/ic_menu_check.png new file mode 100644 index 000000000..3b2b65d26 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_check.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_compose.png b/app/src/main/res/drawable-xhdpi/ic_menu_compose.png deleted file mode 100644 index b6ab05d16..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_menu_compose.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_contact.png b/app/src/main/res/drawable-xhdpi/ic_menu_contact.png new file mode 100644 index 000000000..aea15f0be Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_contact.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_copy.png b/app/src/main/res/drawable-xhdpi/ic_menu_copy.png new file mode 100644 index 000000000..537fd4e8b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_copy.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_delete.png b/app/src/main/res/drawable-xhdpi/ic_menu_delete.png new file mode 100644 index 000000000..388b5b060 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_delete.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_donate.png b/app/src/main/res/drawable-xhdpi/ic_menu_donate.png new file mode 100644 index 000000000..20efc6cb7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_donate.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_download.png b/app/src/main/res/drawable-xhdpi/ic_menu_download.png new file mode 100644 index 000000000..f53cc0c62 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_download.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_exit.png b/app/src/main/res/drawable-xhdpi/ic_menu_exit.png new file mode 100644 index 000000000..07847bd22 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_exit.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_favorite.png b/app/src/main/res/drawable-xhdpi/ic_menu_favorite.png new file mode 100644 index 000000000..914340683 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_favorite.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_group.png b/app/src/main/res/drawable-xhdpi/ic_menu_group.png new file mode 100644 index 000000000..323981ccf Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_group.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_help.png b/app/src/main/res/drawable-xhdpi/ic_menu_help.png new file mode 100644 index 000000000..2d11cf47a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_help.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_info_details.png b/app/src/main/res/drawable-xhdpi/ic_menu_info_details.png new file mode 100644 index 000000000..c571b2e3e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_info_details.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_invite.png b/app/src/main/res/drawable-xhdpi/ic_menu_invite.png new file mode 100644 index 000000000..7e7c289d4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_invite.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_key.png b/app/src/main/res/drawable-xhdpi/ic_menu_key.png new file mode 100644 index 000000000..93e7b0712 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_key.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_maps.png b/app/src/main/res/drawable-xhdpi/ic_menu_maps.png new file mode 100644 index 000000000..301162e71 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_maps.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_offline.png b/app/src/main/res/drawable-xhdpi/ic_menu_offline.png new file mode 100644 index 000000000..13663b229 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_offline.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_online.png b/app/src/main/res/drawable-xhdpi/ic_menu_online.png new file mode 100644 index 000000000..6cf035495 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_online.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_open.png b/app/src/main/res/drawable-xhdpi/ic_menu_open.png new file mode 100644 index 000000000..a2caac511 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_open.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_overflow_normal.png b/app/src/main/res/drawable-xhdpi/ic_menu_overflow_normal.png deleted file mode 100644 index faf077ceb..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_menu_overflow_normal.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_preferences.png b/app/src/main/res/drawable-xhdpi/ic_menu_preferences.png index 1edfc6923..5caedc8e5 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_menu_preferences.png and b/app/src/main/res/drawable-xhdpi/ic_menu_preferences.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_refresh.png b/app/src/main/res/drawable-xhdpi/ic_menu_refresh.png index e1f4ff81b..1989184b1 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_menu_refresh.png and b/app/src/main/res/drawable-xhdpi/ic_menu_refresh.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_reply.png b/app/src/main/res/drawable-xhdpi/ic_menu_reply.png new file mode 100644 index 000000000..0b1c9890f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_reply.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_save.png b/app/src/main/res/drawable-xhdpi/ic_menu_save.png new file mode 100644 index 000000000..03baaf566 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_save.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_search.png b/app/src/main/res/drawable-xhdpi/ic_menu_search.png index acafef8b3..bfc3e3939 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_menu_search.png and b/app/src/main/res/drawable-xhdpi/ic_menu_search.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_send.png b/app/src/main/res/drawable-xhdpi/ic_menu_send.png new file mode 100644 index 000000000..ef59e7767 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_send.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_share.png b/app/src/main/res/drawable-xhdpi/ic_menu_share.png new file mode 100644 index 000000000..22a8783e7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_share.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_status.png b/app/src/main/res/drawable-xhdpi/ic_menu_status.png new file mode 100644 index 000000000..c97abc49f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_status.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_unarchive.png b/app/src/main/res/drawable-xhdpi/ic_menu_unarchive.png new file mode 100644 index 000000000..a0a1509a1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_unarchive.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_unlock.png b/app/src/main/res/drawable-xhdpi/ic_menu_unlock.png new file mode 100644 index 000000000..cbe9e1cd0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_unlock.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_mic.png b/app/src/main/res/drawable-xhdpi/ic_mic.png new file mode 100644 index 000000000..ebc877178 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_mic.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_mic_disabled.png b/app/src/main/res/drawable-xhdpi/ic_mic_disabled.png new file mode 100644 index 000000000..813cc7572 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_mic_disabled.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_mic_pressed.png b/app/src/main/res/drawable-xhdpi/ic_mic_pressed.png new file mode 100644 index 000000000..99439e39c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_mic_pressed.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_msg_delivered.png b/app/src/main/res/drawable-xhdpi/ic_msg_delivered.png new file mode 100644 index 000000000..c95313c2b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_msg_error.png b/app/src/main/res/drawable-xhdpi/ic_msg_error.png new file mode 100644 index 000000000..89daee804 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-xhdpi/ic_msg_notdelivered.png new file mode 100644 index 000000000..8db50c909 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_msg_pending.png b/app/src/main/res/drawable-xhdpi/ic_msg_pending.png new file mode 100644 index 000000000..a90ab6f44 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_msg_security.png b/app/src/main/res/drawable-xhdpi/ic_msg_security.png new file mode 100644 index 000000000..089dcbdbf Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_msg_security.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_msg_sent.png b/app/src/main/res/drawable-xhdpi/ic_msg_sent.png new file mode 100644 index 000000000..912e0bca9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_msg_star.png b/app/src/main/res/drawable-xhdpi/ic_msg_star.png new file mode 100644 index 000000000..84acdf33d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_msg_warning.png b/app/src/main/res/drawable-xhdpi/ic_msg_warning.png new file mode 100644 index 000000000..a9eb2a899 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_my_location.png b/app/src/main/res/drawable-xhdpi/ic_my_location.png new file mode 100755 index 000000000..d52a6cbe1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_my_location.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_navigate.png b/app/src/main/res/drawable-xhdpi/ic_navigate.png new file mode 100644 index 000000000..6c7b7e134 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_navigate.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pin.png b/app/src/main/res/drawable-xhdpi/ic_pin.png new file mode 100755 index 000000000..a23c6f9b2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pin.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pin_x.png b/app/src/main/res/drawable-xhdpi/ic_pin_x.png new file mode 100755 index 000000000..be7548eb3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pin_x.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_about.png b/app/src/main/res/drawable-xhdpi/ic_pref_about.png new file mode 100644 index 000000000..03d63e6c4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_about.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_account.png b/app/src/main/res/drawable-xhdpi/ic_pref_account.png new file mode 100644 index 000000000..38334bd68 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_account.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_appearance.png b/app/src/main/res/drawable-xhdpi/ic_pref_appearance.png new file mode 100644 index 000000000..3ff86be96 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_appearance.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_balloons.png b/app/src/main/res/drawable-xhdpi/ic_pref_balloons.png new file mode 100644 index 000000000..fb5dc6d6c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_balloons.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_delete_account.png b/app/src/main/res/drawable-xhdpi/ic_pref_delete_account.png new file mode 100644 index 000000000..ff2218540 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_delete_account.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_donate.png b/app/src/main/res/drawable-xhdpi/ic_pref_donate.png new file mode 100644 index 000000000..00f451da3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_donate.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_font_size.png b/app/src/main/res/drawable-xhdpi/ic_pref_font_size.png new file mode 100644 index 000000000..677d24d9c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_font_size.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_help.png b/app/src/main/res/drawable-xhdpi/ic_pref_help.png new file mode 100644 index 000000000..3e18365b8 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_help.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_image_resize.png b/app/src/main/res/drawable-xhdpi/ic_pref_image_resize.png new file mode 100644 index 000000000..75b05a907 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_image_resize.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_location.png b/app/src/main/res/drawable-xhdpi/ic_pref_location.png new file mode 100644 index 000000000..8700a1d8d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_location.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_maintenance.png b/app/src/main/res/drawable-xhdpi/ic_pref_maintenance.png new file mode 100644 index 000000000..de833fa2f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_maintenance.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_maps_service.png b/app/src/main/res/drawable-xhdpi/ic_pref_maps_service.png new file mode 100644 index 000000000..354455d0a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_maps_service.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_media.png b/app/src/main/res/drawable-xhdpi/ic_pref_media.png new file mode 100644 index 000000000..db8a1040a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_media.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_messaging.png b/app/src/main/res/drawable-xhdpi/ic_pref_messaging.png new file mode 100644 index 000000000..559ffc5ef Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_messaging.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_network.png b/app/src/main/res/drawable-xhdpi/ic_pref_network.png new file mode 100644 index 000000000..825d8fed7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_network.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_notification.png b/app/src/main/res/drawable-xhdpi/ic_pref_notification.png new file mode 100644 index 000000000..16e26952a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_notification.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_privacy.png b/app/src/main/res/drawable-xhdpi/ic_pref_privacy.png new file mode 100644 index 000000000..ea1866164 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_privacy.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_register_device.png b/app/src/main/res/drawable-xhdpi/ic_pref_register_device.png new file mode 100644 index 000000000..fe75b759a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_register_device.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_restart_msgcenter.png b/app/src/main/res/drawable-xhdpi/ic_pref_restart_msgcenter.png new file mode 100644 index 000000000..b7682495f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_restart_msgcenter.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_send_debug_log.png b/app/src/main/res/drawable-xhdpi/ic_pref_send_debug_log.png new file mode 100644 index 000000000..a52167316 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_send_debug_log.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_service_terms.png b/app/src/main/res/drawable-xhdpi/ic_pref_service_terms.png new file mode 100644 index 000000000..c82ea762e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_service_terms.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pref_vibrate.png b/app/src/main/res/drawable-xhdpi/ic_pref_vibrate.png new file mode 100644 index 000000000..cfad0497a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pref_vibrate.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_rec.png b/app/src/main/res/drawable-xhdpi/ic_rec.png new file mode 100644 index 000000000..dd5752c46 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_rec.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_send.png b/app/src/main/res/drawable-xhdpi/ic_send.png new file mode 100644 index 000000000..f8f313b42 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_send.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_send_disabled.png b/app/src/main/res/drawable-xhdpi/ic_send_disabled.png new file mode 100644 index 000000000..a2b979a8a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_send_disabled.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_send_disabled_holo_light.png b/app/src/main/res/drawable-xhdpi/ic_send_disabled_holo_light.png deleted file mode 100644 index 8c8aa657b..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_send_disabled_holo_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_send_holo_light.png b/app/src/main/res/drawable-xhdpi/ic_send_holo_light.png deleted file mode 100755 index 5f0493d22..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_send_holo_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_send_location.png b/app/src/main/res/drawable-xhdpi/ic_send_location.png new file mode 100755 index 000000000..fe3aef699 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_send_location.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_notify.png b/app/src/main/res/drawable-xhdpi/ic_stat_notify.png index f31da1e1e..c3ad313b4 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_stat_notify.png and b/app/src/main/res/drawable-xhdpi/ic_stat_notify.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_trust_ignored.png b/app/src/main/res/drawable-xhdpi/ic_trust_ignored.png new file mode 100644 index 000000000..7fc03d1a3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_trust_ignored.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_trust_unknown.png b/app/src/main/res/drawable-xhdpi/ic_trust_unknown.png new file mode 100644 index 000000000..511acb069 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_trust_unknown.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_trust_verified.png b/app/src/main/res/drawable-xhdpi/ic_trust_verified.png new file mode 100644 index 000000000..963af8e5f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_trust_verified.png differ diff --git a/app/src/main/res/drawable-xhdpi/list_item_focused_background.9.png b/app/src/main/res/drawable-xhdpi/list_item_focused_background.9.png new file mode 100644 index 000000000..ee5eb6ffa Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/list_item_focused_background.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/list_item_pressed_background.9.png b/app/src/main/res/drawable-xhdpi/list_item_pressed_background.9.png new file mode 100644 index 000000000..eef607186 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/list_item_pressed_background.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/mic.png b/app/src/main/res/drawable-xhdpi/mic.png deleted file mode 100644 index ad666c2f0..000000000 Binary files a/app/src/main/res/drawable-xhdpi/mic.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/pause_balloon.png b/app/src/main/res/drawable-xhdpi/pause_balloon.png old mode 100755 new mode 100644 index 9b428c2c1..b0e1841de Binary files a/app/src/main/res/drawable-xhdpi/pause_balloon.png and b/app/src/main/res/drawable-xhdpi/pause_balloon.png differ diff --git a/app/src/main/res/drawable-xhdpi/play_balloon.png b/app/src/main/res/drawable-xhdpi/play_balloon.png old mode 100755 new mode 100644 index a0005ccf0..bd344404f Binary files a/app/src/main/res/drawable-xhdpi/play_balloon.png and b/app/src/main/res/drawable-xhdpi/play_balloon.png differ diff --git a/app/src/main/res/drawable-xxhdpi-v10/ic_stat_notify.png b/app/src/main/res/drawable-xxhdpi-v10/ic_stat_notify.png deleted file mode 100644 index 69f6bf350..000000000 Binary files a/app/src/main/res/drawable-xxhdpi-v10/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi-v11/ic_stat_notify.png b/app/src/main/res/drawable-xxhdpi-v11/ic_stat_notify.png deleted file mode 100644 index f7a35b7fc..000000000 Binary files a/app/src/main/res/drawable-xxhdpi-v11/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi-v9/ic_stat_notify.png b/app/src/main/res/drawable-xxhdpi-v9/ic_stat_notify.png deleted file mode 100644 index 69f6bf350..000000000 Binary files a/app/src/main/res/drawable-xxhdpi-v9/ic_stat_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/app_background.png b/app/src/main/res/drawable-xxhdpi/app_background.png new file mode 100644 index 000000000..db139287a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/app_background.png differ diff --git a/app/src/main/res/drawable-xxhdpi/attach_audio.png b/app/src/main/res/drawable-xxhdpi/attach_audio.png new file mode 100644 index 000000000..e6383e095 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/attach_audio.png differ diff --git a/app/src/main/res/drawable-xxhdpi/attach_camera.png b/app/src/main/res/drawable-xxhdpi/attach_camera.png new file mode 100644 index 000000000..824b9f6ef Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/attach_camera.png differ diff --git a/app/src/main/res/drawable-xxhdpi/attach_contact.png b/app/src/main/res/drawable-xxhdpi/attach_contact.png new file mode 100644 index 000000000..71f9be5bc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/attach_contact.png differ diff --git a/app/src/main/res/drawable-xxhdpi/attach_file.png b/app/src/main/res/drawable-xxhdpi/attach_file.png new file mode 100644 index 000000000..50294ed72 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/attach_file.png differ diff --git a/app/src/main/res/drawable-xxhdpi/attach_gallery.png b/app/src/main/res/drawable-xxhdpi/attach_gallery.png new file mode 100644 index 000000000..7aaa8baa6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/attach_gallery.png differ diff --git a/app/src/main/res/drawable-xxhdpi/attach_hide1.png b/app/src/main/res/drawable-xxhdpi/attach_hide1.png new file mode 100644 index 000000000..41b01433c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/attach_hide1.png differ diff --git a/app/src/main/res/drawable-xxhdpi/attach_location.png b/app/src/main/res/drawable-xxhdpi/attach_location.png new file mode 100644 index 000000000..13ba2eaa7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/attach_location.png differ diff --git a/app/src/main/res/drawable-xxhdpi/attach_video.png b/app/src/main/res/drawable-xxhdpi/attach_video.png new file mode 100644 index 000000000..fd4e0ce8e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/attach_video.png differ diff --git a/app/src/main/res/drawable-xxhdpi/balloon_classic_incoming.9.png b/app/src/main/res/drawable-xxhdpi/balloon_classic_incoming.9.png new file mode 100644 index 000000000..16203cb35 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/balloon_classic_incoming.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/balloon_classic_outgoing.9.png b/app/src/main/res/drawable-xxhdpi/balloon_classic_outgoing.9.png new file mode 100644 index 000000000..0ea0be803 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/balloon_classic_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/balloon_iphone_incoming.9.png b/app/src/main/res/drawable-xxhdpi/balloon_iphone_incoming.9.png new file mode 100644 index 000000000..16a73579e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/balloon_iphone_incoming.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/balloon_iphone_outgoing.9.png b/app/src/main/res/drawable-xxhdpi/balloon_iphone_outgoing.9.png new file mode 100644 index 000000000..b4301ccde Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/balloon_iphone_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/balloon_old_classic_incoming.9.png b/app/src/main/res/drawable-xxhdpi/balloon_old_classic_incoming.9.png new file mode 100644 index 000000000..8554cbab5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/balloon_old_classic_incoming.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/balloon_old_classic_outgoing.9.png b/app/src/main/res/drawable-xxhdpi/balloon_old_classic_outgoing.9.png new file mode 100644 index 000000000..aada1429d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/balloon_old_classic_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/balloon_silence_incoming.9.png b/app/src/main/res/drawable-xxhdpi/balloon_silence_incoming.9.png new file mode 100644 index 000000000..61c8035f7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/balloon_silence_incoming.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/balloon_silence_outgoing.9.png b/app/src/main/res/drawable-xxhdpi/balloon_silence_outgoing.9.png new file mode 100644 index 000000000..53365c2c0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/balloon_silence_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/download_audio.png b/app/src/main/res/drawable-xxhdpi/download_audio.png old mode 100755 new mode 100644 diff --git a/app/src/main/res/drawable-xxhdpi/ic_attach.png b/app/src/main/res/drawable-xxhdpi/ic_attach.png new file mode 100644 index 000000000..6637297b9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_attach.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_attach_audio.png b/app/src/main/res/drawable-xxhdpi/ic_attach_audio.png new file mode 100644 index 000000000..5374ecd94 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_attach_audio.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_attach_contact.png b/app/src/main/res/drawable-xxhdpi/ic_attach_contact.png new file mode 100644 index 000000000..42e5a25fd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_attach_contact.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_attach_picture.png b/app/src/main/res/drawable-xxhdpi/ic_attach_picture.png new file mode 100644 index 000000000..071192f58 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_attach_picture.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close.png b/app/src/main/res/drawable-xxhdpi/ic_close.png new file mode 100644 index 000000000..153681082 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_close.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_create_white.png b/app/src/main/res/drawable-xxhdpi/ic_create_white.png new file mode 100644 index 000000000..02e19d045 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_create_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_denied.png b/app/src/main/res/drawable-xxhdpi/ic_denied.png new file mode 100644 index 000000000..ba4257121 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_denied.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_emoji.png b/app/src/main/res/drawable-xxhdpi/ic_emoji.png new file mode 100644 index 000000000..f2fa46020 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_emoji.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_globe.png b/app/src/main/res/drawable-xxhdpi/ic_globe.png new file mode 100644 index 000000000..9838a0c08 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_globe.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_indicator_group.png b/app/src/main/res/drawable-xxhdpi/ic_indicator_group.png new file mode 100644 index 000000000..bcd443747 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_indicator_group.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_keyboard.png b/app/src/main/res/drawable-xxhdpi/ic_keyboard.png new file mode 100644 index 000000000..bee0fd41a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_keyboard.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_live_location_shadow.png b/app/src/main/res/drawable-xxhdpi/ic_live_location_shadow.png new file mode 100644 index 000000000..c2954e4da Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_live_location_shadow.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_map_pin_google.png b/app/src/main/res/drawable-xxhdpi/ic_map_pin_google.png new file mode 100644 index 000000000..7592078d7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_map_pin_google.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_archive.png b/app/src/main/res/drawable-xxhdpi/ic_menu_archive.png new file mode 100644 index 000000000..00e04e42b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_archive.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_attach.png b/app/src/main/res/drawable-xxhdpi/ic_menu_attach.png new file mode 100644 index 000000000..7091eca06 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_attach.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_block.png b/app/src/main/res/drawable-xxhdpi/ic_menu_block.png new file mode 100644 index 000000000..fddfa54b8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_block.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_call.png b/app/src/main/res/drawable-xxhdpi/ic_menu_call.png new file mode 100644 index 000000000..90ead2e45 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_call.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_call_black.png b/app/src/main/res/drawable-xxhdpi/ic_menu_call_black.png new file mode 100644 index 000000000..7c9d1b09c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_call_black.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_cancel.png b/app/src/main/res/drawable-xxhdpi/ic_menu_cancel.png new file mode 100644 index 000000000..dc32a14f9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_cancel.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_chat.png b/app/src/main/res/drawable-xxhdpi/ic_menu_chat.png new file mode 100644 index 000000000..424509055 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_chat.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_check.png b/app/src/main/res/drawable-xxhdpi/ic_menu_check.png new file mode 100644 index 000000000..2c2ad771f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_check.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_contact.png b/app/src/main/res/drawable-xxhdpi/ic_menu_contact.png new file mode 100644 index 000000000..184f7418d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_contact.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_copy.png b/app/src/main/res/drawable-xxhdpi/ic_menu_copy.png new file mode 100644 index 000000000..9dff893e7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_copy.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_delete.png b/app/src/main/res/drawable-xxhdpi/ic_menu_delete.png new file mode 100644 index 000000000..3fcdfdb55 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_delete.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_donate.png b/app/src/main/res/drawable-xxhdpi/ic_menu_donate.png new file mode 100644 index 000000000..d4bde8132 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_donate.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_download.png b/app/src/main/res/drawable-xxhdpi/ic_menu_download.png new file mode 100644 index 000000000..78aa59166 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_download.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_exit.png b/app/src/main/res/drawable-xxhdpi/ic_menu_exit.png new file mode 100644 index 000000000..c04fe6e0e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_exit.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_favorite.png b/app/src/main/res/drawable-xxhdpi/ic_menu_favorite.png new file mode 100644 index 000000000..aa5879215 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_favorite.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_group.png b/app/src/main/res/drawable-xxhdpi/ic_menu_group.png new file mode 100644 index 000000000..6c68435fb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_group.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_help.png b/app/src/main/res/drawable-xxhdpi/ic_menu_help.png new file mode 100644 index 000000000..d49181785 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_help.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_info_details.png b/app/src/main/res/drawable-xxhdpi/ic_menu_info_details.png new file mode 100644 index 000000000..c41a5fcff Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_info_details.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_invite.png b/app/src/main/res/drawable-xxhdpi/ic_menu_invite.png new file mode 100644 index 000000000..8f744f039 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_invite.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_key.png b/app/src/main/res/drawable-xxhdpi/ic_menu_key.png new file mode 100644 index 000000000..54c92ace4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_key.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_maps.png b/app/src/main/res/drawable-xxhdpi/ic_menu_maps.png new file mode 100644 index 000000000..0c38abeec Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_maps.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_offline.png b/app/src/main/res/drawable-xxhdpi/ic_menu_offline.png new file mode 100644 index 000000000..6defc9a9b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_offline.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_online.png b/app/src/main/res/drawable-xxhdpi/ic_menu_online.png new file mode 100644 index 000000000..199be1620 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_online.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_open.png b/app/src/main/res/drawable-xxhdpi/ic_menu_open.png new file mode 100644 index 000000000..2ed5b0e18 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_open.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_preferences.png b/app/src/main/res/drawable-xxhdpi/ic_menu_preferences.png new file mode 100644 index 000000000..eabb0a2ba Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_preferences.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_refresh.png b/app/src/main/res/drawable-xxhdpi/ic_menu_refresh.png new file mode 100644 index 000000000..1692d8a24 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_refresh.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_reply.png b/app/src/main/res/drawable-xxhdpi/ic_menu_reply.png new file mode 100644 index 000000000..4468bdac2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_reply.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_save.png b/app/src/main/res/drawable-xxhdpi/ic_menu_save.png new file mode 100644 index 000000000..f500f4898 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_save.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_search.png b/app/src/main/res/drawable-xxhdpi/ic_menu_search.png new file mode 100644 index 000000000..abbb98951 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_search.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_send.png b/app/src/main/res/drawable-xxhdpi/ic_menu_send.png new file mode 100644 index 000000000..1bc7552a9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_send.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_share.png b/app/src/main/res/drawable-xxhdpi/ic_menu_share.png new file mode 100644 index 000000000..a35b3cd14 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_share.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_status.png b/app/src/main/res/drawable-xxhdpi/ic_menu_status.png new file mode 100644 index 000000000..4bfd75186 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_status.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_unarchive.png b/app/src/main/res/drawable-xxhdpi/ic_menu_unarchive.png new file mode 100644 index 000000000..20d015751 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_unarchive.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_unlock.png b/app/src/main/res/drawable-xxhdpi/ic_menu_unlock.png new file mode 100644 index 000000000..1d1b0f4d3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_unlock.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_mic.png b/app/src/main/res/drawable-xxhdpi/ic_mic.png new file mode 100644 index 000000000..252829545 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_mic.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_mic_disabled.png b/app/src/main/res/drawable-xxhdpi/ic_mic_disabled.png new file mode 100644 index 000000000..c393cee6a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_mic_disabled.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_mic_pressed.png b/app/src/main/res/drawable-xxhdpi/ic_mic_pressed.png new file mode 100644 index 000000000..bdea80aa9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_mic_pressed.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_msg_delivered.png b/app/src/main/res/drawable-xxhdpi/ic_msg_delivered.png new file mode 100644 index 000000000..426a71962 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_msg_error.png b/app/src/main/res/drawable-xxhdpi/ic_msg_error.png new file mode 100644 index 000000000..f9af25f3b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-xxhdpi/ic_msg_notdelivered.png new file mode 100644 index 000000000..e976c09dc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_msg_pending.png b/app/src/main/res/drawable-xxhdpi/ic_msg_pending.png new file mode 100644 index 000000000..bee2ca6f7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_msg_security.png b/app/src/main/res/drawable-xxhdpi/ic_msg_security.png new file mode 100644 index 000000000..4df47df33 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_msg_security.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_msg_sent.png b/app/src/main/res/drawable-xxhdpi/ic_msg_sent.png new file mode 100644 index 000000000..f668f297e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_msg_star.png b/app/src/main/res/drawable-xxhdpi/ic_msg_star.png new file mode 100644 index 000000000..2b4c4ef4e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_msg_warning.png b/app/src/main/res/drawable-xxhdpi/ic_msg_warning.png new file mode 100644 index 000000000..5d0901983 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_my_location.png b/app/src/main/res/drawable-xxhdpi/ic_my_location.png new file mode 100755 index 000000000..9ec46b30f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_my_location.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_navigate.png b/app/src/main/res/drawable-xxhdpi/ic_navigate.png new file mode 100644 index 000000000..ffe55f7fe Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_navigate.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pin.png b/app/src/main/res/drawable-xxhdpi/ic_pin.png new file mode 100755 index 000000000..fc080b46d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pin.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pin_x.png b/app/src/main/res/drawable-xxhdpi/ic_pin_x.png new file mode 100755 index 000000000..f0d94e6df Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pin_x.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_about.png b/app/src/main/res/drawable-xxhdpi/ic_pref_about.png new file mode 100644 index 000000000..13de9f71a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_about.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_account.png b/app/src/main/res/drawable-xxhdpi/ic_pref_account.png new file mode 100644 index 000000000..e56c848ce Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_account.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_appearance.png b/app/src/main/res/drawable-xxhdpi/ic_pref_appearance.png new file mode 100644 index 000000000..98e6c6374 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_appearance.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_balloons.png b/app/src/main/res/drawable-xxhdpi/ic_pref_balloons.png new file mode 100644 index 000000000..883be24ed Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_balloons.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_delete_account.png b/app/src/main/res/drawable-xxhdpi/ic_pref_delete_account.png new file mode 100644 index 000000000..195b9f1a3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_delete_account.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_donate.png b/app/src/main/res/drawable-xxhdpi/ic_pref_donate.png new file mode 100644 index 000000000..5155db62c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_donate.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_font_size.png b/app/src/main/res/drawable-xxhdpi/ic_pref_font_size.png new file mode 100644 index 000000000..ef4067b6b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_font_size.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_help.png b/app/src/main/res/drawable-xxhdpi/ic_pref_help.png new file mode 100644 index 000000000..ec05f95e9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_help.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_image_resize.png b/app/src/main/res/drawable-xxhdpi/ic_pref_image_resize.png new file mode 100644 index 000000000..d728dbb22 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_image_resize.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_location.png b/app/src/main/res/drawable-xxhdpi/ic_pref_location.png new file mode 100644 index 000000000..6bb5c7d29 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_location.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_maintenance.png b/app/src/main/res/drawable-xxhdpi/ic_pref_maintenance.png new file mode 100644 index 000000000..901ce2d8e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_maintenance.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_maps_service.png b/app/src/main/res/drawable-xxhdpi/ic_pref_maps_service.png new file mode 100644 index 000000000..c91d43e66 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_maps_service.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_media.png b/app/src/main/res/drawable-xxhdpi/ic_pref_media.png new file mode 100644 index 000000000..6af64db1c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_media.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_messaging.png b/app/src/main/res/drawable-xxhdpi/ic_pref_messaging.png new file mode 100644 index 000000000..f7c0be60d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_messaging.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_network.png b/app/src/main/res/drawable-xxhdpi/ic_pref_network.png new file mode 100644 index 000000000..ff1c4e999 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_network.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_notification.png b/app/src/main/res/drawable-xxhdpi/ic_pref_notification.png new file mode 100644 index 000000000..74d42a184 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_notification.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_privacy.png b/app/src/main/res/drawable-xxhdpi/ic_pref_privacy.png new file mode 100644 index 000000000..93454b39e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_privacy.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_register_device.png b/app/src/main/res/drawable-xxhdpi/ic_pref_register_device.png new file mode 100644 index 000000000..1a06b7009 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_register_device.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_restart_msgcenter.png b/app/src/main/res/drawable-xxhdpi/ic_pref_restart_msgcenter.png new file mode 100644 index 000000000..5cd780f85 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_restart_msgcenter.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_send_debug_log.png b/app/src/main/res/drawable-xxhdpi/ic_pref_send_debug_log.png new file mode 100644 index 000000000..65f838038 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_send_debug_log.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_service_terms.png b/app/src/main/res/drawable-xxhdpi/ic_pref_service_terms.png new file mode 100644 index 000000000..ecbdaca13 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_service_terms.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pref_vibrate.png b/app/src/main/res/drawable-xxhdpi/ic_pref_vibrate.png new file mode 100644 index 000000000..9f485aadb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pref_vibrate.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_rec.png b/app/src/main/res/drawable-xxhdpi/ic_rec.png new file mode 100644 index 000000000..0c8998992 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_rec.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_send.png b/app/src/main/res/drawable-xxhdpi/ic_send.png new file mode 100644 index 000000000..0aa239249 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_send.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_send_disabled.png b/app/src/main/res/drawable-xxhdpi/ic_send_disabled.png new file mode 100644 index 000000000..b93b9ec06 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_send_disabled.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_send_location.png b/app/src/main/res/drawable-xxhdpi/ic_send_location.png new file mode 100755 index 000000000..388a57ef4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_send_location.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_notify.png b/app/src/main/res/drawable-xxhdpi/ic_stat_notify.png index 02332d51b..f7a35b7fc 100644 Binary files a/app/src/main/res/drawable-xxhdpi/ic_stat_notify.png and b/app/src/main/res/drawable-xxhdpi/ic_stat_notify.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_trust_ignored.png b/app/src/main/res/drawable-xxhdpi/ic_trust_ignored.png new file mode 100644 index 000000000..24205d643 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_trust_ignored.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_trust_unknown.png b/app/src/main/res/drawable-xxhdpi/ic_trust_unknown.png new file mode 100644 index 000000000..5ed5d1629 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_trust_unknown.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_trust_verified.png b/app/src/main/res/drawable-xxhdpi/ic_trust_verified.png new file mode 100644 index 000000000..b69aa7dbc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_trust_verified.png differ diff --git a/app/src/main/res/drawable-xxhdpi/list_item_focused_background.9.png b/app/src/main/res/drawable-xxhdpi/list_item_focused_background.9.png new file mode 100644 index 000000000..7cfb33dc2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/list_item_focused_background.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/list_item_pressed_background.9.png b/app/src/main/res/drawable-xxhdpi/list_item_pressed_background.9.png new file mode 100644 index 000000000..f38472b66 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/list_item_pressed_background.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/pause_balloon.png b/app/src/main/res/drawable-xxhdpi/pause_balloon.png old mode 100755 new mode 100644 index 6e84637d0..f339dac63 Binary files a/app/src/main/res/drawable-xxhdpi/pause_balloon.png and b/app/src/main/res/drawable-xxhdpi/pause_balloon.png differ diff --git a/app/src/main/res/drawable-xxhdpi/play_balloon.png b/app/src/main/res/drawable-xxhdpi/play_balloon.png old mode 100755 new mode 100644 index 322410a42..826b76ec9 Binary files a/app/src/main/res/drawable-xxhdpi/play_balloon.png and b/app/src/main/res/drawable-xxhdpi/play_balloon.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/app_background.png b/app/src/main/res/drawable-xxxhdpi/app_background.png new file mode 100644 index 000000000..5f1aa9c94 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/app_background.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/balloon_classic_incoming.9.png b/app/src/main/res/drawable-xxxhdpi/balloon_classic_incoming.9.png new file mode 100644 index 000000000..8cba6084d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/balloon_classic_incoming.9.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/balloon_classic_outgoing.9.png b/app/src/main/res/drawable-xxxhdpi/balloon_classic_outgoing.9.png new file mode 100644 index 000000000..734197db7 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/balloon_classic_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/balloon_iphone_incoming.9.png b/app/src/main/res/drawable-xxxhdpi/balloon_iphone_incoming.9.png new file mode 100644 index 000000000..99f83a623 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/balloon_iphone_incoming.9.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/balloon_iphone_outgoing.9.png b/app/src/main/res/drawable-xxxhdpi/balloon_iphone_outgoing.9.png new file mode 100644 index 000000000..688562967 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/balloon_iphone_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/balloon_old_classic_incoming.9.png b/app/src/main/res/drawable-xxxhdpi/balloon_old_classic_incoming.9.png new file mode 100644 index 000000000..9ab212ef0 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/balloon_old_classic_incoming.9.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/balloon_old_classic_outgoing.9.png b/app/src/main/res/drawable-xxxhdpi/balloon_old_classic_outgoing.9.png new file mode 100644 index 000000000..dbe31838f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/balloon_old_classic_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/balloon_silence_incoming.9.png b/app/src/main/res/drawable-xxxhdpi/balloon_silence_incoming.9.png new file mode 100644 index 000000000..3ffae4e70 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/balloon_silence_incoming.9.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/balloon_silence_outgoing.9.png b/app/src/main/res/drawable-xxxhdpi/balloon_silence_outgoing.9.png new file mode 100644 index 000000000..aed74795b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/balloon_silence_outgoing.9.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_attach_audio.png b/app/src/main/res/drawable-xxxhdpi/ic_attach_audio.png new file mode 100644 index 000000000..0cf10c17f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_attach_audio.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_attach_contact.png b/app/src/main/res/drawable-xxxhdpi/ic_attach_contact.png new file mode 100644 index 000000000..52120e6ed Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_attach_contact.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_attach_picture.png b/app/src/main/res/drawable-xxxhdpi/ic_attach_picture.png new file mode 100644 index 000000000..e6a09a217 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_attach_picture.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_close.png b/app/src/main/res/drawable-xxxhdpi/ic_close.png new file mode 100644 index 000000000..2a677683e Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_close.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_create_white.png b/app/src/main/res/drawable-xxxhdpi/ic_create_white.png new file mode 100644 index 000000000..d6668a051 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_create_white.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_denied.png b/app/src/main/res/drawable-xxxhdpi/ic_denied.png new file mode 100644 index 000000000..aca640f9c Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_denied.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_emoji.png b/app/src/main/res/drawable-xxxhdpi/ic_emoji.png new file mode 100644 index 000000000..eeacbddd6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_emoji.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_indicator_group.png b/app/src/main/res/drawable-xxxhdpi/ic_indicator_group.png new file mode 100644 index 000000000..0d267c1c8 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_indicator_group.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_keyboard.png b/app/src/main/res/drawable-xxxhdpi/ic_keyboard.png new file mode 100644 index 000000000..30a8b3581 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_keyboard.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_archive.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_archive.png new file mode 100644 index 000000000..34cd3fd80 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_archive.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_attach.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_attach.png new file mode 100644 index 000000000..771425c91 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_attach.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_block.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_block.png new file mode 100644 index 000000000..0378d1bed Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_block.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_call.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_call.png new file mode 100644 index 000000000..b0e020573 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_call.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_call_black.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_call_black.png new file mode 100644 index 000000000..61b0e7043 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_call_black.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_cancel.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_cancel.png new file mode 100644 index 000000000..dc775b243 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_cancel.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_chat.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_chat.png new file mode 100644 index 000000000..d708e1580 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_chat.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_check.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_check.png new file mode 100644 index 000000000..d670618c7 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_check.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_contact.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_contact.png new file mode 100644 index 000000000..33d40d8b6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_contact.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_copy.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_copy.png new file mode 100644 index 000000000..4ddee9ef0 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_copy.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_delete.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_delete.png new file mode 100644 index 000000000..8d322aa9b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_delete.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_donate.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_donate.png new file mode 100644 index 000000000..f3c568542 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_donate.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_download.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_download.png new file mode 100644 index 000000000..ded5652e4 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_download.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_exit.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_exit.png new file mode 100644 index 000000000..27a9d7b05 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_exit.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_favorite.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_favorite.png new file mode 100644 index 000000000..58d71b392 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_favorite.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_group.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_group.png new file mode 100644 index 000000000..5676f7041 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_group.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_help.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_help.png new file mode 100644 index 000000000..8eb7241da Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_help.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_info_details.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_info_details.png new file mode 100644 index 000000000..3a82cab3b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_info_details.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_invite.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_invite.png new file mode 100644 index 000000000..2fa2cca80 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_invite.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_key.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_key.png new file mode 100644 index 000000000..3d2593219 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_key.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_maps.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_maps.png new file mode 100644 index 000000000..0cbfe89e5 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_maps.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_offline.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_offline.png new file mode 100644 index 000000000..5bf419903 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_offline.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_online.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_online.png new file mode 100644 index 000000000..436fb34c6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_online.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_open.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_open.png new file mode 100644 index 000000000..bab25f300 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_open.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_preferences.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_preferences.png new file mode 100644 index 000000000..507c5edd4 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_preferences.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_refresh.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_refresh.png new file mode 100644 index 000000000..f5beca251 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_refresh.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_search.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_search.png new file mode 100644 index 000000000..dd5adfc7f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_search.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_send.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_send.png new file mode 100644 index 000000000..6aeaa850b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_send.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_share.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_share.png new file mode 100644 index 000000000..e351c7beb Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_share.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_status.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_status.png new file mode 100644 index 000000000..319a13a38 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_status.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_unarchive.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_unarchive.png new file mode 100644 index 000000000..a789520ba Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_unarchive.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_unlock.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_unlock.png new file mode 100644 index 000000000..8ab410727 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_unlock.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_mic.png b/app/src/main/res/drawable-xxxhdpi/ic_mic.png new file mode 100644 index 000000000..4608e3243 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_mic.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_mic_disabled.png b/app/src/main/res/drawable-xxxhdpi/ic_mic_disabled.png new file mode 100644 index 000000000..330970570 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_mic_disabled.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_mic_pressed.png b/app/src/main/res/drawable-xxxhdpi/ic_mic_pressed.png new file mode 100644 index 000000000..725b4af8a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_mic_pressed.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_msg_delivered.png b/app/src/main/res/drawable-xxxhdpi/ic_msg_delivered.png new file mode 100644 index 000000000..79b5f314e Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_msg_delivered.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_msg_error.png b/app/src/main/res/drawable-xxxhdpi/ic_msg_error.png new file mode 100644 index 000000000..5e2eb6d62 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_msg_error.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_msg_notdelivered.png b/app/src/main/res/drawable-xxxhdpi/ic_msg_notdelivered.png new file mode 100644 index 000000000..c704dbc9d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_msg_notdelivered.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_msg_pending.png b/app/src/main/res/drawable-xxxhdpi/ic_msg_pending.png new file mode 100644 index 000000000..87992cf96 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_msg_pending.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_msg_security.png b/app/src/main/res/drawable-xxxhdpi/ic_msg_security.png new file mode 100644 index 000000000..93c5b07c8 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_msg_security.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_msg_sent.png b/app/src/main/res/drawable-xxxhdpi/ic_msg_sent.png new file mode 100644 index 000000000..3a099f8a4 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_msg_sent.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_msg_star.png b/app/src/main/res/drawable-xxxhdpi/ic_msg_star.png new file mode 100644 index 000000000..d3585497d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_msg_star.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_msg_warning.png b/app/src/main/res/drawable-xxxhdpi/ic_msg_warning.png new file mode 100644 index 000000000..926dc1ea9 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_msg_warning.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_my_location.png b/app/src/main/res/drawable-xxxhdpi/ic_my_location.png new file mode 100755 index 000000000..9c0a9bb0c Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_my_location.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_rec.png b/app/src/main/res/drawable-xxxhdpi/ic_rec.png new file mode 100644 index 000000000..fe0f73a1a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_rec.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_send.png b/app/src/main/res/drawable-xxxhdpi/ic_send.png new file mode 100644 index 000000000..5c2d258d2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_send.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_send_disabled.png b/app/src/main/res/drawable-xxxhdpi/ic_send_disabled.png new file mode 100644 index 000000000..50d273914 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_send_disabled.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_send_location.png b/app/src/main/res/drawable-xxxhdpi/ic_send_location.png new file mode 100755 index 000000000..23196e1a2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_send_location.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_trust_ignored.png b/app/src/main/res/drawable-xxxhdpi/ic_trust_ignored.png new file mode 100644 index 000000000..00ddd5497 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_trust_ignored.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_trust_unknown.png b/app/src/main/res/drawable-xxxhdpi/ic_trust_unknown.png new file mode 100644 index 000000000..ede4f96df Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_trust_unknown.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_trust_verified.png b/app/src/main/res/drawable-xxxhdpi/ic_trust_verified.png new file mode 100644 index 000000000..28a2f2ee5 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_trust_verified.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/pause_balloon.png b/app/src/main/res/drawable-xxxhdpi/pause_balloon.png new file mode 100644 index 000000000..4d152976b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/pause_balloon.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/play_balloon.png b/app/src/main/res/drawable-xxxhdpi/play_balloon.png new file mode 100644 index 000000000..5158fc7a1 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/play_balloon.png differ diff --git a/app/src/main/res/drawable/app_background_tile.xml b/app/src/main/res/drawable/app_background_tile.xml new file mode 100644 index 000000000..976f41220 --- /dev/null +++ b/app/src/main/res/drawable/app_background_tile.xml @@ -0,0 +1,22 @@ + + + + diff --git a/app/src/main/res/drawable/arrow_down.png b/app/src/main/res/drawable/arrow_down.png deleted file mode 100755 index 05e6a5965..000000000 Binary files a/app/src/main/res/drawable/arrow_down.png and /dev/null differ diff --git a/app/src/main/res/drawable/arrow_up.png b/app/src/main/res/drawable/arrow_up.png deleted file mode 100755 index 4412938cf..000000000 Binary files a/app/src/main/res/drawable/arrow_up.png and /dev/null differ diff --git a/app/src/main/res/drawable/balloon_classic_incoming.9.png b/app/src/main/res/drawable/balloon_classic_incoming.9.png deleted file mode 100644 index 4fb41748c..000000000 Binary files a/app/src/main/res/drawable/balloon_classic_incoming.9.png and /dev/null differ diff --git a/app/src/main/res/drawable/balloon_classic_outgoing.9.png b/app/src/main/res/drawable/balloon_classic_outgoing.9.png deleted file mode 100644 index f468c3a60..000000000 Binary files a/app/src/main/res/drawable/balloon_classic_outgoing.9.png and /dev/null differ diff --git a/app/src/main/res/drawable/balloon_iphone_incoming.9.png b/app/src/main/res/drawable/balloon_iphone_incoming.9.png deleted file mode 100644 index dd4aedbdb..000000000 Binary files a/app/src/main/res/drawable/balloon_iphone_incoming.9.png and /dev/null differ diff --git a/app/src/main/res/drawable/balloon_iphone_outgoing.9.png b/app/src/main/res/drawable/balloon_iphone_outgoing.9.png deleted file mode 100644 index ac45b38b1..000000000 Binary files a/app/src/main/res/drawable/balloon_iphone_outgoing.9.png and /dev/null differ diff --git a/app/src/main/res/drawable/balloon_old_classic_incoming.9.png b/app/src/main/res/drawable/balloon_old_classic_incoming.9.png deleted file mode 100644 index 29cc3ad38..000000000 Binary files a/app/src/main/res/drawable/balloon_old_classic_incoming.9.png and /dev/null differ diff --git a/app/src/main/res/drawable/balloon_old_classic_outgoing.9.png b/app/src/main/res/drawable/balloon_old_classic_outgoing.9.png deleted file mode 100644 index e30ee5d98..000000000 Binary files a/app/src/main/res/drawable/balloon_old_classic_outgoing.9.png and /dev/null differ diff --git a/app/src/main/res/drawable/bg_default_contact.xml b/app/src/main/res/drawable/bg_default_contact.xml new file mode 100644 index 000000000..9f82f4135 --- /dev/null +++ b/app/src/main/res/drawable/bg_default_contact.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_spinner_left.xml b/app/src/main/res/drawable/bg_spinner_left.xml new file mode 100644 index 000000000..ccbc31098 --- /dev/null +++ b/app/src/main/res/drawable/bg_spinner_left.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/circular_drawable_gray.xml b/app/src/main/res/drawable/circular_drawable_gray.xml new file mode 100644 index 000000000..b87f6e55c --- /dev/null +++ b/app/src/main/res/drawable/circular_drawable_gray.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/circular_drawable_primary.xml b/app/src/main/res/drawable/circular_drawable_primary.xml new file mode 100644 index 000000000..53aa21b65 --- /dev/null +++ b/app/src/main/res/drawable/circular_drawable_primary.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/conversation_badge.xml b/app/src/main/res/drawable/conversation_badge.xml index 56df7323a..ee48e6077 100644 --- a/app/src/main/res/drawable/conversation_badge.xml +++ b/app/src/main/res/drawable/conversation_badge.xml @@ -1,12 +1,29 @@ + + - - - \ No newline at end of file + android:orientation="vertical" + android:layout_width="fill_parent" + android:layout_height="fill_parent" + android:shape="rectangle"> + + + diff --git a/app/src/main/res/drawable/date_stamp.xml b/app/src/main/res/drawable/date_stamp.xml index 3dd52de40..1a7293e6f 100644 --- a/app/src/main/res/drawable/date_stamp.xml +++ b/app/src/main/res/drawable/date_stamp.xml @@ -1,4 +1,21 @@ + + - \ No newline at end of file + diff --git a/app/src/main/res/drawable/datebox.9.png b/app/src/main/res/drawable/datebox.9.png deleted file mode 100644 index cfd680404..000000000 Binary files a/app/src/main/res/drawable/datebox.9.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_arrow_drop_down_circle_grey_16dp.png b/app/src/main/res/drawable/ic_arrow_drop_down_circle_grey_16dp.png new file mode 100644 index 000000000..ab9b169a0 Binary files /dev/null and b/app/src/main/res/drawable/ic_arrow_drop_down_circle_grey_16dp.png differ diff --git a/app/src/main/res/drawable/ic_checkbox.xml b/app/src/main/res/drawable/ic_checkbox.xml new file mode 100644 index 000000000..ebac54a43 --- /dev/null +++ b/app/src/main/res/drawable/ic_checkbox.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_default_contact.xml b/app/src/main/res/drawable/ic_default_contact.xml new file mode 100644 index 000000000..812ba4efa --- /dev/null +++ b/app/src/main/res/drawable/ic_default_contact.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_default_group.xml b/app/src/main/res/drawable/ic_default_group.xml new file mode 100644 index 000000000..73850db8e --- /dev/null +++ b/app/src/main/res/drawable/ic_default_group.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_menu_overflow.xml b/app/src/main/res/drawable/ic_menu_overflow.xml deleted file mode 100644 index 71a551d64..000000000 --- a/app/src/main/res/drawable/ic_menu_overflow.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/list_item_background.xml b/app/src/main/res/drawable/list_item_background.xml new file mode 100644 index 000000000..4c25d3e38 --- /dev/null +++ b/app/src/main/res/drawable/list_item_background.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/popup.9.png b/app/src/main/res/drawable/popup.9.png deleted file mode 100755 index 334227234..000000000 Binary files a/app/src/main/res/drawable/popup.9.png and /dev/null differ diff --git a/app/src/main/res/drawable/progress_ring.xml b/app/src/main/res/drawable/progress_ring.xml new file mode 100644 index 000000000..242f86fd5 --- /dev/null +++ b/app/src/main/res/drawable/progress_ring.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ptt_button_state.xml b/app/src/main/res/drawable/ptt_button_state.xml index 2a71d38ab..c164f72ee 100644 --- a/app/src/main/res/drawable/ptt_button_state.xml +++ b/app/src/main/res/drawable/ptt_button_state.xml @@ -1,6 +1,6 @@ + android:drawable="@drawable/ic_send_disabled" /> + diff --git a/app/src/main/res/drawable/titlebar_bg.xml b/app/src/main/res/drawable/titlebar_bg.xml deleted file mode 100644 index b6974d84c..000000000 --- a/app/src/main/res/drawable/titlebar_bg.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout-sw600dp/compose_message.xml b/app/src/main/res/layout-sw600dp/compose_message.xml index d57a8374e..bb1c9ba40 100644 --- a/app/src/main/res/layout-sw600dp/compose_message.xml +++ b/app/src/main/res/layout-sw600dp/compose_message.xml @@ -1,6 +1,6 @@ - + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent"> - + + - - + android:layout_weight="1.0"> + + + + + + + - + + + + + + - + diff --git a/app/src/main/res/layout-sw600dp/conversation_list_screen.xml b/app/src/main/res/layout-sw600dp/conversation_list_screen.xml index 763ca69f7..6e81f2134 100644 --- a/app/src/main/res/layout-sw600dp/conversation_list_screen.xml +++ b/app/src/main/res/layout-sw600dp/conversation_list_screen.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/layout-sw600dp/message_list_item.xml b/app/src/main/res/layout-sw600dp/message_list_item.xml deleted file mode 100644 index d8689fe9d..000000000 --- a/app/src/main/res/layout-sw600dp/message_list_item.xml +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-v10/progress_notification.xml b/app/src/main/res/layout-v10/progress_notification.xml deleted file mode 100644 index 8370cb9a4..000000000 --- a/app/src/main/res/layout-v10/progress_notification.xml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-v11/composer_bar.xml b/app/src/main/res/layout-v11/composer_bar.xml deleted file mode 100644 index c1645bb06..000000000 --- a/app/src/main/res/layout-v11/composer_bar.xml +++ /dev/null @@ -1,154 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-v11/contacts_list_item.xml b/app/src/main/res/layout-v11/contacts_list_item.xml deleted file mode 100644 index 5037d2328..000000000 --- a/app/src/main/res/layout-v11/contacts_list_item.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout-v14/status_ok_cancel.xml b/app/src/main/res/layout-v14/status_ok_cancel.xml deleted file mode 100644 index b1519bbf0..000000000 --- a/app/src/main/res/layout-v14/status_ok_cancel.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - -