From ccca31529c07970e89419fb85a9e8153a5396838 Mon Sep 17 00:00:00 2001 From: Eric Erfanian Date: Wed, 22 Feb 2017 16:32:36 -0800 Subject: Update dialer sources. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test: Built package and system image. This change clobbers the old source, and is an export from an internal Google repository. The internal repository was forked form Android in March, and this change includes modifications since then, to near the v8 release. Since the fork, we've moved code from monolithic to independent modules. In addition, we've switched to Blaze/Bazel as the build sysetm. This export, however, still uses make. New dependencies have been added: - Dagger - Auto-Value - Glide - Libshortcutbadger Going forward, development will still be in Google3, and the Gerrit release will become an automated export, with the next drop happening in ~ two weeks. Android.mk includes local modifications from ToT. Abridged changelog: Bug fixes ● Not able to mute, add a call when using Phone app in multiwindow mode ● Double tap on keypad triggering multiple key and tones ● Reported spam numbers not showing as spam in the call log ● Crash when user tries to block number while Phone app is not set as default ● Crash when user picks a number from search auto-complete list Visual Voicemail (VVM) improvements ● Share Voicemail audio via standard exporting mechanisms that support file attachment (email, MMS, etc.) ● Make phone number, email and web sites in VVM transcript clickable ● Set PIN before declining VVM Terms of Service {Carrier} ● Set client type for outbound visual voicemail SMS {Carrier} New incoming call and incall UI on older devices (Android M) ● Updated Phone app icon ● New incall UI (large buttons, button labels) ● New and animated Answer/Reject gestures Accessibility ● Add custom answer/decline call buttons on answer screen for touch exploration accessibility services ● Increase size of touch target ● Add verbal feedback when a Voicemail fails to load ● Fix pressing of Phone buttons while in a phone call using Switch Access ● Fix selecting and opening contacts in talkback mode ● Split focus for ‘Learn More’ link in caller id & spam to help distinguish similar text Other ● Backup & Restore for App Preferences ● Prompt user to enable Wi-Fi calling if the call ends due to out of service and Wi-Fi is connected ● Rename “Dialpad” to “Keypad” ● Show "Private number" for restricted calls ● Delete unused items (vcard, add contact, call history) from Phone menu Change-Id: I2a7e53532a24c21bf308bf0a6d178d7ddbca4958 --- java/com/android/voicemailomtp/ActivationTask.java | 305 +++++ java/com/android/voicemailomtp/AndroidManifest.xml | 105 ++ java/com/android/voicemailomtp/Assert.java | 62 + .../voicemailomtp/DefaultOmtpEventHandler.java | 202 ++++ .../android/voicemailomtp/NeededForTesting.java | 25 + java/com/android/voicemailomtp/OmtpConstants.java | 248 ++++ java/com/android/voicemailomtp/OmtpEvents.java | 156 +++ java/com/android/voicemailomtp/OmtpService.java | 65 + .../voicemailomtp/OmtpVvmCarrierConfigHelper.java | 423 +++++++ .../voicemailomtp/SubscriptionInfoHelper.java | 75 ++ .../voicemailomtp/TelephonyManagerStub.java | 80 ++ .../voicemailomtp/TelephonyVvmConfigManager.java | 154 +++ .../voicemailomtp/VisualVoicemailPreferences.java | 143 +++ java/com/android/voicemailomtp/Voicemail.java | 330 ++++++ .../com/android/voicemailomtp/VoicemailStatus.java | 158 +++ java/com/android/voicemailomtp/VvmLog.java | 179 +++ .../voicemailomtp/VvmPackageInstallReceiver.java | 70 ++ .../voicemailomtp/VvmPhoneStateListener.java | 103 ++ .../fetch/FetchVoicemailReceiver.java | 219 ++++ .../fetch/VoicemailFetchedCallback.java | 101 ++ .../com/android/voicemailomtp/imap/ImapHelper.java | 711 +++++++++++ .../voicemailomtp/imap/VoicemailPayload.java | 38 + java/com/android/voicemailomtp/mail/Address.java | 541 +++++++++ .../mail/AuthenticationFailedException.java | 33 + .../com/android/voicemailomtp/mail/Base64Body.java | 62 + java/com/android/voicemailomtp/mail/Body.java | 25 + java/com/android/voicemailomtp/mail/BodyPart.java | 24 + .../mail/CertificateValidationException.java | 29 + .../android/voicemailomtp/mail/FetchProfile.java | 84 ++ java/com/android/voicemailomtp/mail/Fetchable.java | 23 + .../voicemailomtp/mail/FixedLengthInputStream.java | 79 ++ java/com/android/voicemailomtp/mail/Flag.java | 29 + .../android/voicemailomtp/mail/MailTransport.java | 344 ++++++ .../android/voicemailomtp/mail/MeetingInfo.java | 29 + java/com/android/voicemailomtp/mail/Message.java | 144 +++ .../voicemailomtp/mail/MessageDateComparator.java | 34 + .../voicemailomtp/mail/MessagingException.java | 139 +++ java/com/android/voicemailomtp/mail/Multipart.java | 62 + .../android/voicemailomtp/mail/PackedString.java | 175 +++ java/com/android/voicemailomtp/mail/Part.java | 51 + .../voicemailomtp/mail/PeekableInputStream.java | 80 ++ .../android/voicemailomtp/mail/TempDirectory.java | 41 + .../mail/internet/BinaryTempFileBody.java | 91 ++ .../voicemailomtp/mail/internet/MimeBodyPart.java | 207 ++++ .../voicemailomtp/mail/internet/MimeHeader.java | 161 +++ .../voicemailomtp/mail/internet/MimeMessage.java | 675 +++++++++++ .../voicemailomtp/mail/internet/MimeMultipart.java | 112 ++ .../voicemailomtp/mail/internet/MimeUtility.java | 416 +++++++ .../voicemailomtp/mail/internet/TextBody.java | 63 + .../voicemailomtp/mail/store/ImapConnection.java | 413 +++++++ .../voicemailomtp/mail/store/ImapFolder.java | 784 ++++++++++++ .../voicemailomtp/mail/store/ImapStore.java | 176 +++ .../mail/store/imap/DigestMd5Utils.java | 335 ++++++ .../mail/store/imap/ImapConstants.java | 144 +++ .../voicemailomtp/mail/store/imap/ImapElement.java | 120 ++ .../voicemailomtp/mail/store/imap/ImapList.java | 235 ++++ .../mail/store/imap/ImapMemoryLiteral.java | 76 ++ .../mail/store/imap/ImapResponse.java | 158 +++ .../mail/store/imap/ImapResponseParser.java | 432 +++++++ .../mail/store/imap/ImapSimpleString.java | 62 + .../voicemailomtp/mail/store/imap/ImapString.java | 192 +++ .../mail/store/imap/ImapTempFileLiteral.java | 123 ++ .../voicemailomtp/mail/store/imap/ImapUtility.java | 125 ++ .../mail/utility/CountingOutputStream.java | 48 + .../mail/utility/EOLConvertingOutputStream.java | 48 + .../android/voicemailomtp/mail/utils/LogUtils.java | 413 +++++++ .../android/voicemailomtp/mail/utils/Utility.java | 80 ++ java/com/android/voicemailomtp/permissions.xml | 21 + .../voicemailomtp/protocol/CvvmProtocol.java | 59 + .../voicemailomtp/protocol/OmtpProtocol.java | 37 + .../voicemailomtp/protocol/ProtocolHelper.java | 43 + .../protocol/VisualVoicemailProtocol.java | 100 ++ .../protocol/VisualVoicemailProtocolFactory.java | 47 + .../voicemailomtp/protocol/Vvm3EventHandler.java | 271 +++++ .../voicemailomtp/protocol/Vvm3Protocol.java | 301 +++++ .../voicemailomtp/protocol/Vvm3Subscriber.java | 326 +++++ .../res/layout/voicemail_change_pin.xml | 97 ++ .../android/voicemailomtp/res/values/arrays.xml | 19 + .../com/android/voicemailomtp/res/values/attrs.xml | 20 + .../android/voicemailomtp/res/values/colors.xml | 19 + .../android/voicemailomtp/res/values/config.xml | 19 + .../android/voicemailomtp/res/values/dimens.xml | 19 + java/com/android/voicemailomtp/res/values/ids.xml | 20 + .../android/voicemailomtp/res/values/strings.xml | 86 ++ .../android/voicemailomtp/res/values/styles.xml | 19 + .../voicemailomtp/res/xml/voicemail_settings.xml | 27 + .../android/voicemailomtp/res/xml/vvm_config.xml | 134 +++ .../android/voicemailomtp/scheduling/BaseTask.java | 206 ++++ .../voicemailomtp/scheduling/BlockerTask.java | 55 + .../scheduling/MinimalIntervalPolicy.java | 69 ++ .../android/voicemailomtp/scheduling/Policy.java | 36 + .../voicemailomtp/scheduling/PostponePolicy.java | 69 ++ .../voicemailomtp/scheduling/RetryPolicy.java | 117 ++ .../com/android/voicemailomtp/scheduling/Task.java | 133 +++ .../scheduling/TaskSchedulerService.java | 392 ++++++ .../settings/VisualVoicemailSettingsUtil.java | 77 ++ .../settings/VoicemailChangePinActivity.java | 634 ++++++++++ .../settings/VoicemailSettingsActivity.java | 222 ++++ .../voicemailomtp/sms/LegacyModeSmsHandler.java | 67 ++ .../voicemailomtp/sms/OmtpCvvmMessageSender.java | 55 + .../voicemailomtp/sms/OmtpMessageReceiver.java | 162 +++ .../voicemailomtp/sms/OmtpMessageSender.java | 89 ++ .../sms/OmtpStandardMessageSender.java | 119 ++ .../android/voicemailomtp/sms/StatusMessage.java | 209 ++++ .../voicemailomtp/sms/StatusSmsFetcher.java | 162 +++ .../com/android/voicemailomtp/sms/SyncMessage.java | 166 +++ .../voicemailomtp/sms/Vvm3MessageSender.java | 56 + .../src/org/apache/commons/io/IOUtils.java | 1202 +++++++++++++++++++ .../org/apache/james/mime4j/BodyDescriptor.java | 392 ++++++ .../james/mime4j/CloseShieldInputStream.java | 129 ++ .../org/apache/james/mime4j/ContentHandler.java | 177 +++ .../james/mime4j/EOLConvertingInputStream.java | 139 +++ .../src/org/apache/james/mime4j/Log.java | 114 ++ .../src/org/apache/james/mime4j/LogFactory.java | 29 + .../james/mime4j/MimeBoundaryInputStream.java | 184 +++ .../org/apache/james/mime4j/MimeStreamParser.java | 324 +++++ .../org/apache/james/mime4j/RootInputStream.java | 111 ++ .../org/apache/james/mime4j/codec/EncoderUtil.java | 630 ++++++++++ .../james/mime4j/decoder/Base64InputStream.java | 151 +++ .../org/apache/james/mime4j/decoder/ByteQueue.java | 62 + .../apache/james/mime4j/decoder/DecoderUtil.java | 284 +++++ .../mime4j/decoder/QuotedPrintableInputStream.java | 229 ++++ .../mime4j/decoder/UnboundedFifoByteBuffer.java | 272 +++++ .../james/mime4j/field/AddressListField.java | 65 + .../mime4j/field/ContentTransferEncodingField.java | 88 ++ .../james/mime4j/field/ContentTypeField.java | 259 ++++ .../apache/james/mime4j/field/DateTimeField.java | 73 ++ .../james/mime4j/field/DefaultFieldParser.java | 45 + .../james/mime4j/field/DelegatingFieldParser.java | 47 + .../src/org/apache/james/mime4j/field/Field.java | 192 +++ .../org/apache/james/mime4j/field/FieldParser.java | 21 + .../apache/james/mime4j/field/MailboxField.java | 70 ++ .../james/mime4j/field/MailboxListField.java | 67 ++ .../james/mime4j/field/UnstructuredField.java | 49 + .../apache/james/mime4j/field/address/Address.java | 52 + .../james/mime4j/field/address/AddressList.java | 138 +++ .../apache/james/mime4j/field/address/Builder.java | 243 ++++ .../james/mime4j/field/address/DomainList.java | 76 ++ .../apache/james/mime4j/field/address/Group.java | 75 ++ .../apache/james/mime4j/field/address/Mailbox.java | 121 ++ .../james/mime4j/field/address/MailboxList.java | 71 ++ .../james/mime4j/field/address/NamedMailbox.java | 71 ++ .../mime4j/field/address/parser/ASTaddr_spec.java | 19 + .../mime4j/field/address/parser/ASTaddress.java | 19 + .../field/address/parser/ASTaddress_list.java | 19 + .../mime4j/field/address/parser/ASTangle_addr.java | 19 + .../mime4j/field/address/parser/ASTdomain.java | 19 + .../mime4j/field/address/parser/ASTgroup_body.java | 19 + .../mime4j/field/address/parser/ASTlocal_part.java | 19 + .../mime4j/field/address/parser/ASTmailbox.java | 19 + .../mime4j/field/address/parser/ASTname_addr.java | 19 + .../mime4j/field/address/parser/ASTphrase.java | 19 + .../mime4j/field/address/parser/ASTroute.java | 19 + .../field/address/parser/AddressListParser.java | 977 +++++++++++++++ .../field/address/parser/AddressListParser.jj | 595 ++++++++++ .../address/parser/AddressListParserConstants.java | 76 ++ .../parser/AddressListParserTokenManager.java | 1009 ++++++++++++++++ .../parser/AddressListParserTreeConstants.java | 35 + .../address/parser/AddressListParserVisitor.java | 19 + .../mime4j/field/address/parser/BaseNode.java | 30 + .../address/parser/JJTAddressListParserState.java | 123 ++ .../james/mime4j/field/address/parser/Node.java | 37 + .../field/address/parser/ParseException.java | 207 ++++ .../field/address/parser/SimpleCharStream.java | 454 +++++++ .../mime4j/field/address/parser/SimpleNode.java | 87 ++ .../james/mime4j/field/address/parser/Token.java | 96 ++ .../mime4j/field/address/parser/TokenMgrError.java | 148 +++ .../contenttype/parser/ContentTypeParser.java | 268 +++++ .../parser/ContentTypeParserConstants.java | 62 + .../parser/ContentTypeParserTokenManager.java | 877 ++++++++++++++ .../field/contenttype/parser/ParseException.java | 207 ++++ .../field/contenttype/parser/SimpleCharStream.java | 454 +++++++ .../mime4j/field/contenttype/parser/Token.java | 96 ++ .../field/contenttype/parser/TokenMgrError.java | 148 +++ .../james/mime4j/field/datetime/DateTime.java | 127 ++ .../field/datetime/parser/DateTimeParser.java | 570 +++++++++ .../datetime/parser/DateTimeParserConstants.java | 86 ++ .../parser/DateTimeParserTokenManager.java | 882 ++++++++++++++ .../field/datetime/parser/ParseException.java | 207 ++++ .../field/datetime/parser/SimpleCharStream.java | 454 +++++++ .../james/mime4j/field/datetime/parser/Token.java | 96 ++ .../field/datetime/parser/TokenMgrError.java | 148 +++ .../org/apache/james/mime4j/util/CharsetUtil.java | 1249 ++++++++++++++++++++ .../voicemailomtp/sync/OmtpVvmSourceManager.java | 120 ++ .../voicemailomtp/sync/OmtpVvmSyncReceiver.java | 61 + .../voicemailomtp/sync/OmtpVvmSyncService.java | 278 +++++ .../android/voicemailomtp/sync/SyncOneTask.java | 82 ++ java/com/android/voicemailomtp/sync/SyncTask.java | 79 ++ .../com/android/voicemailomtp/sync/UploadTask.java | 68 ++ .../sync/VoicemailProviderChangeReceiver.java | 41 + .../sync/VoicemailStatusQueryHelper.java | 113 ++ .../voicemailomtp/sync/VoicemailsQueryHelper.java | 244 ++++ .../voicemailomtp/sync/VvmNetworkRequest.java | 118 ++ .../sync/VvmNetworkRequestCallback.java | 171 +++ .../voicemailomtp/utils/IndentingPrintWriter.java | 160 +++ .../voicemailomtp/utils/VoicemailDatabaseUtil.java | 90 ++ .../voicemailomtp/utils/VvmDumpHandler.java | 46 + java/com/android/voicemailomtp/utils/XmlUtils.java | 245 ++++ 198 files changed, 34649 insertions(+) create mode 100644 java/com/android/voicemailomtp/ActivationTask.java create mode 100644 java/com/android/voicemailomtp/AndroidManifest.xml create mode 100644 java/com/android/voicemailomtp/Assert.java create mode 100644 java/com/android/voicemailomtp/DefaultOmtpEventHandler.java create mode 100644 java/com/android/voicemailomtp/NeededForTesting.java create mode 100644 java/com/android/voicemailomtp/OmtpConstants.java create mode 100644 java/com/android/voicemailomtp/OmtpEvents.java create mode 100644 java/com/android/voicemailomtp/OmtpService.java create mode 100644 java/com/android/voicemailomtp/OmtpVvmCarrierConfigHelper.java create mode 100644 java/com/android/voicemailomtp/SubscriptionInfoHelper.java create mode 100644 java/com/android/voicemailomtp/TelephonyManagerStub.java create mode 100644 java/com/android/voicemailomtp/TelephonyVvmConfigManager.java create mode 100644 java/com/android/voicemailomtp/VisualVoicemailPreferences.java create mode 100644 java/com/android/voicemailomtp/Voicemail.java create mode 100644 java/com/android/voicemailomtp/VoicemailStatus.java create mode 100644 java/com/android/voicemailomtp/VvmLog.java create mode 100644 java/com/android/voicemailomtp/VvmPackageInstallReceiver.java create mode 100644 java/com/android/voicemailomtp/VvmPhoneStateListener.java create mode 100644 java/com/android/voicemailomtp/fetch/FetchVoicemailReceiver.java create mode 100644 java/com/android/voicemailomtp/fetch/VoicemailFetchedCallback.java create mode 100644 java/com/android/voicemailomtp/imap/ImapHelper.java create mode 100644 java/com/android/voicemailomtp/imap/VoicemailPayload.java create mode 100644 java/com/android/voicemailomtp/mail/Address.java create mode 100644 java/com/android/voicemailomtp/mail/AuthenticationFailedException.java create mode 100644 java/com/android/voicemailomtp/mail/Base64Body.java create mode 100644 java/com/android/voicemailomtp/mail/Body.java create mode 100644 java/com/android/voicemailomtp/mail/BodyPart.java create mode 100644 java/com/android/voicemailomtp/mail/CertificateValidationException.java create mode 100644 java/com/android/voicemailomtp/mail/FetchProfile.java create mode 100644 java/com/android/voicemailomtp/mail/Fetchable.java create mode 100644 java/com/android/voicemailomtp/mail/FixedLengthInputStream.java create mode 100644 java/com/android/voicemailomtp/mail/Flag.java create mode 100644 java/com/android/voicemailomtp/mail/MailTransport.java create mode 100644 java/com/android/voicemailomtp/mail/MeetingInfo.java create mode 100644 java/com/android/voicemailomtp/mail/Message.java create mode 100644 java/com/android/voicemailomtp/mail/MessageDateComparator.java create mode 100644 java/com/android/voicemailomtp/mail/MessagingException.java create mode 100644 java/com/android/voicemailomtp/mail/Multipart.java create mode 100644 java/com/android/voicemailomtp/mail/PackedString.java create mode 100644 java/com/android/voicemailomtp/mail/Part.java create mode 100644 java/com/android/voicemailomtp/mail/PeekableInputStream.java create mode 100644 java/com/android/voicemailomtp/mail/TempDirectory.java create mode 100644 java/com/android/voicemailomtp/mail/internet/BinaryTempFileBody.java create mode 100644 java/com/android/voicemailomtp/mail/internet/MimeBodyPart.java create mode 100644 java/com/android/voicemailomtp/mail/internet/MimeHeader.java create mode 100644 java/com/android/voicemailomtp/mail/internet/MimeMessage.java create mode 100644 java/com/android/voicemailomtp/mail/internet/MimeMultipart.java create mode 100644 java/com/android/voicemailomtp/mail/internet/MimeUtility.java create mode 100644 java/com/android/voicemailomtp/mail/internet/TextBody.java create mode 100644 java/com/android/voicemailomtp/mail/store/ImapConnection.java create mode 100644 java/com/android/voicemailomtp/mail/store/ImapFolder.java create mode 100644 java/com/android/voicemailomtp/mail/store/ImapStore.java create mode 100644 java/com/android/voicemailomtp/mail/store/imap/DigestMd5Utils.java create mode 100644 java/com/android/voicemailomtp/mail/store/imap/ImapConstants.java create mode 100644 java/com/android/voicemailomtp/mail/store/imap/ImapElement.java create mode 100644 java/com/android/voicemailomtp/mail/store/imap/ImapList.java create mode 100644 java/com/android/voicemailomtp/mail/store/imap/ImapMemoryLiteral.java create mode 100644 java/com/android/voicemailomtp/mail/store/imap/ImapResponse.java create mode 100644 java/com/android/voicemailomtp/mail/store/imap/ImapResponseParser.java create mode 100644 java/com/android/voicemailomtp/mail/store/imap/ImapSimpleString.java create mode 100644 java/com/android/voicemailomtp/mail/store/imap/ImapString.java create mode 100644 java/com/android/voicemailomtp/mail/store/imap/ImapTempFileLiteral.java create mode 100644 java/com/android/voicemailomtp/mail/store/imap/ImapUtility.java create mode 100644 java/com/android/voicemailomtp/mail/utility/CountingOutputStream.java create mode 100644 java/com/android/voicemailomtp/mail/utility/EOLConvertingOutputStream.java create mode 100644 java/com/android/voicemailomtp/mail/utils/LogUtils.java create mode 100644 java/com/android/voicemailomtp/mail/utils/Utility.java create mode 100644 java/com/android/voicemailomtp/permissions.xml create mode 100644 java/com/android/voicemailomtp/protocol/CvvmProtocol.java create mode 100644 java/com/android/voicemailomtp/protocol/OmtpProtocol.java create mode 100644 java/com/android/voicemailomtp/protocol/ProtocolHelper.java create mode 100644 java/com/android/voicemailomtp/protocol/VisualVoicemailProtocol.java create mode 100644 java/com/android/voicemailomtp/protocol/VisualVoicemailProtocolFactory.java create mode 100644 java/com/android/voicemailomtp/protocol/Vvm3EventHandler.java create mode 100644 java/com/android/voicemailomtp/protocol/Vvm3Protocol.java create mode 100644 java/com/android/voicemailomtp/protocol/Vvm3Subscriber.java create mode 100644 java/com/android/voicemailomtp/res/layout/voicemail_change_pin.xml create mode 100644 java/com/android/voicemailomtp/res/values/arrays.xml create mode 100644 java/com/android/voicemailomtp/res/values/attrs.xml create mode 100644 java/com/android/voicemailomtp/res/values/colors.xml create mode 100644 java/com/android/voicemailomtp/res/values/config.xml create mode 100644 java/com/android/voicemailomtp/res/values/dimens.xml create mode 100644 java/com/android/voicemailomtp/res/values/ids.xml create mode 100644 java/com/android/voicemailomtp/res/values/strings.xml create mode 100644 java/com/android/voicemailomtp/res/values/styles.xml create mode 100644 java/com/android/voicemailomtp/res/xml/voicemail_settings.xml create mode 100644 java/com/android/voicemailomtp/res/xml/vvm_config.xml create mode 100644 java/com/android/voicemailomtp/scheduling/BaseTask.java create mode 100644 java/com/android/voicemailomtp/scheduling/BlockerTask.java create mode 100644 java/com/android/voicemailomtp/scheduling/MinimalIntervalPolicy.java create mode 100644 java/com/android/voicemailomtp/scheduling/Policy.java create mode 100644 java/com/android/voicemailomtp/scheduling/PostponePolicy.java create mode 100644 java/com/android/voicemailomtp/scheduling/RetryPolicy.java create mode 100644 java/com/android/voicemailomtp/scheduling/Task.java create mode 100644 java/com/android/voicemailomtp/scheduling/TaskSchedulerService.java create mode 100644 java/com/android/voicemailomtp/settings/VisualVoicemailSettingsUtil.java create mode 100644 java/com/android/voicemailomtp/settings/VoicemailChangePinActivity.java create mode 100644 java/com/android/voicemailomtp/settings/VoicemailSettingsActivity.java create mode 100644 java/com/android/voicemailomtp/sms/LegacyModeSmsHandler.java create mode 100644 java/com/android/voicemailomtp/sms/OmtpCvvmMessageSender.java create mode 100644 java/com/android/voicemailomtp/sms/OmtpMessageReceiver.java create mode 100644 java/com/android/voicemailomtp/sms/OmtpMessageSender.java create mode 100644 java/com/android/voicemailomtp/sms/OmtpStandardMessageSender.java create mode 100644 java/com/android/voicemailomtp/sms/StatusMessage.java create mode 100644 java/com/android/voicemailomtp/sms/StatusSmsFetcher.java create mode 100644 java/com/android/voicemailomtp/sms/SyncMessage.java create mode 100644 java/com/android/voicemailomtp/sms/Vvm3MessageSender.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/commons/io/IOUtils.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/BodyDescriptor.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/CloseShieldInputStream.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/ContentHandler.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/EOLConvertingInputStream.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/Log.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/LogFactory.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/MimeBoundaryInputStream.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/MimeStreamParser.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/RootInputStream.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/codec/EncoderUtil.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/decoder/Base64InputStream.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/decoder/ByteQueue.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/decoder/DecoderUtil.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/decoder/QuotedPrintableInputStream.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/decoder/UnboundedFifoByteBuffer.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/AddressListField.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/ContentTransferEncodingField.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/ContentTypeField.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/DateTimeField.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/DefaultFieldParser.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/DelegatingFieldParser.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/Field.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/FieldParser.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/MailboxField.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/MailboxListField.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/UnstructuredField.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/Address.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/AddressList.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/Builder.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/DomainList.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/Group.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/Mailbox.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/MailboxList.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/NamedMailbox.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ASTaddr_spec.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ASTaddress.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ASTaddress_list.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ASTangle_addr.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ASTdomain.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ASTgroup_body.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ASTlocal_part.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ASTmailbox.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ASTname_addr.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ASTphrase.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ASTroute.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/AddressListParser.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/AddressListParser.jj create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/AddressListParserConstants.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/AddressListParserTokenManager.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/AddressListParserTreeConstants.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/AddressListParserVisitor.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/BaseNode.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/JJTAddressListParserState.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/Node.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/ParseException.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/SimpleCharStream.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/SimpleNode.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/Token.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/address/parser/TokenMgrError.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/contenttype/parser/ContentTypeParser.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/contenttype/parser/ContentTypeParserConstants.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/contenttype/parser/ContentTypeParserTokenManager.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/contenttype/parser/ParseException.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/contenttype/parser/SimpleCharStream.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/contenttype/parser/Token.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/contenttype/parser/TokenMgrError.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/datetime/DateTime.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/datetime/parser/DateTimeParser.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/datetime/parser/DateTimeParserConstants.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/datetime/parser/DateTimeParserTokenManager.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/datetime/parser/ParseException.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/datetime/parser/SimpleCharStream.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/datetime/parser/Token.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/field/datetime/parser/TokenMgrError.java create mode 100644 java/com/android/voicemailomtp/src/org/apache/james/mime4j/util/CharsetUtil.java create mode 100644 java/com/android/voicemailomtp/sync/OmtpVvmSourceManager.java create mode 100644 java/com/android/voicemailomtp/sync/OmtpVvmSyncReceiver.java create mode 100644 java/com/android/voicemailomtp/sync/OmtpVvmSyncService.java create mode 100644 java/com/android/voicemailomtp/sync/SyncOneTask.java create mode 100644 java/com/android/voicemailomtp/sync/SyncTask.java create mode 100644 java/com/android/voicemailomtp/sync/UploadTask.java create mode 100644 java/com/android/voicemailomtp/sync/VoicemailProviderChangeReceiver.java create mode 100644 java/com/android/voicemailomtp/sync/VoicemailStatusQueryHelper.java create mode 100644 java/com/android/voicemailomtp/sync/VoicemailsQueryHelper.java create mode 100644 java/com/android/voicemailomtp/sync/VvmNetworkRequest.java create mode 100644 java/com/android/voicemailomtp/sync/VvmNetworkRequestCallback.java create mode 100644 java/com/android/voicemailomtp/utils/IndentingPrintWriter.java create mode 100644 java/com/android/voicemailomtp/utils/VoicemailDatabaseUtil.java create mode 100644 java/com/android/voicemailomtp/utils/VvmDumpHandler.java create mode 100644 java/com/android/voicemailomtp/utils/XmlUtils.java (limited to 'java/com/android/voicemailomtp') diff --git a/java/com/android/voicemailomtp/ActivationTask.java b/java/com/android/voicemailomtp/ActivationTask.java new file mode 100644 index 000000000..7de81e685 --- /dev/null +++ b/java/com/android/voicemailomtp/ActivationTask.java @@ -0,0 +1,305 @@ +/* + * Copyright (C) 2016 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.voicemailomtp; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.Intent; +import android.database.ContentObserver; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.provider.Settings; +import android.provider.Settings.Global; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.telecom.PhoneAccountHandle; +import android.telephony.ServiceState; +import android.telephony.TelephonyManager; +import com.android.voicemailomtp.protocol.VisualVoicemailProtocol; +import com.android.voicemailomtp.scheduling.BaseTask; +import com.android.voicemailomtp.scheduling.RetryPolicy; +import com.android.voicemailomtp.sms.StatusMessage; +import com.android.voicemailomtp.sms.StatusSmsFetcher; +import com.android.voicemailomtp.sync.OmtpVvmSourceManager; +import com.android.voicemailomtp.sync.OmtpVvmSyncService; +import com.android.voicemailomtp.sync.SyncTask; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +/** + * Task to activate the visual voicemail service. A request to activate VVM will be sent to the + * carrier, which will respond with a STATUS SMS. The credentials will be updated from the SMS. If + * the user is not provisioned provisioning will be attempted. Activation happens when the phone + * boots, the SIM is inserted, signal returned when VVM is not activated yet, and when the carrier + * spontaneously sent a STATUS SMS. + */ +@TargetApi(VERSION_CODES.CUR_DEVELOPMENT) +public class ActivationTask extends BaseTask { + + private static final String TAG = "VvmActivationTask"; + + private static final int RETRY_TIMES = 4; + private static final int RETRY_INTERVAL_MILLIS = 5_000; + + private static final String EXTRA_MESSAGE_DATA_BUNDLE = "extra_message_data_bundle"; + + @Nullable + private static DeviceProvisionedObserver sDeviceProvisionedObserver; + + private final RetryPolicy mRetryPolicy; + + private Bundle mMessageData; + + public ActivationTask() { + super(TASK_ACTIVATION); + mRetryPolicy = new RetryPolicy(RETRY_TIMES, RETRY_INTERVAL_MILLIS); + addPolicy(mRetryPolicy); + } + + /** + * Has the user gone through the setup wizard yet. + */ + private static boolean isDeviceProvisioned(Context context) { + return Settings.Global.getInt( + context.getContentResolver(), Settings.Global.DEVICE_PROVISIONED, 0) == 1; + } + + /** + * @param messageData The optional bundle from {@link android.provider.VoicemailContract# + * EXTRA_VOICEMAIL_SMS_FIELDS}, if the task is initiated by a status SMS. If null the task will + * request a status SMS itself. + */ + public static void start(Context context, PhoneAccountHandle phoneAccountHandle, + @Nullable Bundle messageData) { + if (!isDeviceProvisioned(context)) { + VvmLog.i(TAG, "Activation requested while device is not provisioned, postponing"); + // Activation might need information such as system language to be set, so wait until + // the setup wizard is finished. The data bundle from the SMS will be re-requested upon + // activation. + queueActivationAfterProvisioned(context, phoneAccountHandle); + return; + } + + Intent intent = BaseTask.createIntent(context, ActivationTask.class, phoneAccountHandle); + if (messageData != null) { + intent.putExtra(EXTRA_MESSAGE_DATA_BUNDLE, messageData); + } + context.startService(intent); + } + + public void onCreate(Context context, Intent intent, int flags, int startId) { + super.onCreate(context, intent, flags, startId); + mMessageData = intent.getParcelableExtra(EXTRA_MESSAGE_DATA_BUNDLE); + } + + @Override + public Intent createRestartIntent() { + Intent intent = super.createRestartIntent(); + // mMessageData is discarded, request a fresh STATUS SMS for retries. + return intent; + } + + @Override + @WorkerThread + public void onExecuteInBackgroundThread() { + Assert.isNotMainThread(); + + PhoneAccountHandle phoneAccountHandle = getPhoneAccountHandle(); + if (phoneAccountHandle == null) { + // This should never happen + VvmLog.e(TAG, "null PhoneAccountHandle"); + return; + } + + OmtpVvmCarrierConfigHelper helper = + new OmtpVvmCarrierConfigHelper(getContext(), phoneAccountHandle); + if (!helper.isValid()) { + VvmLog.i(TAG, "VVM not supported on phoneAccountHandle " + phoneAccountHandle); + VoicemailStatus.disable(getContext(), phoneAccountHandle); + return; + } + + // OmtpVvmCarrierConfigHelper can start the activation process; it will pass in a vvm + // content provider URI which we will use. On some occasions, setting that URI will + // fail, so we will perform a few attempts to ensure that the vvm content provider has + // a good chance of being started up. + if (!VoicemailStatus.edit(getContext(), phoneAccountHandle) + .setType(helper.getVvmType()) + .apply()) { + VvmLog.e(TAG, "Failed to configure content provider - " + helper.getVvmType()); + fail(); + } + VvmLog.i(TAG, "VVM content provider configured - " + helper.getVvmType()); + + if (!OmtpVvmSourceManager.getInstance(getContext()) + .isVvmSourceRegistered(phoneAccountHandle)) { + // This account has not been activated before during the lifetime of this boot. + VisualVoicemailPreferences preferences = new VisualVoicemailPreferences(getContext(), + phoneAccountHandle); + if (preferences.getString(OmtpConstants.SERVER_ADDRESS, null) == null) { + // Only show the "activating" message if activation has not been completed before. + // Subsequent activations are more of a status check and usually does not + // concern the user. + helper.handleEvent(VoicemailStatus.edit(getContext(), phoneAccountHandle), + OmtpEvents.CONFIG_ACTIVATING); + } else { + // The account has been activated on this device before. Pretend it is already + // activated. If there are any activation error it will overwrite this status. + helper.handleEvent(VoicemailStatus.edit(getContext(), phoneAccountHandle), + OmtpEvents.CONFIG_ACTIVATING_SUBSEQUENT); + } + + } + if (!hasSignal(getContext(), phoneAccountHandle)) { + VvmLog.i(TAG, "Service lost during activation, aborting"); + // Restore the "NO SIGNAL" state since it will be overwritten by the CONFIG_ACTIVATING + // event. + helper.handleEvent(VoicemailStatus.edit(getContext(), phoneAccountHandle), + OmtpEvents.NOTIFICATION_SERVICE_LOST); + // Don't retry, a new activation will be started after the signal returned. + return; + } + + helper.activateSmsFilter(); + VoicemailStatus.Editor status = mRetryPolicy.getVoicemailStatusEditor(); + + VisualVoicemailProtocol protocol = helper.getProtocol(); + + Bundle data; + if (mMessageData != null) { + // The content of STATUS SMS is provided to launch this task, no need to request it + // again. + data = mMessageData; + } else { + try (StatusSmsFetcher fetcher = new StatusSmsFetcher(getContext(), + phoneAccountHandle)) { + protocol.startActivation(helper, fetcher.getSentIntent()); + // Both the fetcher and OmtpMessageReceiver will be triggered, but + // OmtpMessageReceiver will just route the SMS back to ActivationTask, which will be + // rejected because the task is still running. + data = fetcher.get(); + } catch (TimeoutException e) { + // The carrier is expected to return an STATUS SMS within STATUS_SMS_TIMEOUT_MILLIS + // handleEvent() will do the logging. + helper.handleEvent(status, OmtpEvents.CONFIG_STATUS_SMS_TIME_OUT); + fail(); + return; + } catch (CancellationException e) { + VvmLog.e(TAG, "Unable to send status request SMS"); + fail(); + return; + } catch (InterruptedException | ExecutionException | IOException e) { + VvmLog.e(TAG, "can't get future STATUS SMS", e); + fail(); + return; + } + } + + StatusMessage message = new StatusMessage(data); + VvmLog.d(TAG, "STATUS SMS received: st=" + message.getProvisioningStatus() + + ", rc=" + message.getReturnCode()); + + if (message.getProvisioningStatus().equals(OmtpConstants.SUBSCRIBER_READY)) { + VvmLog.d(TAG, "subscriber ready, no activation required"); + updateSource(getContext(), phoneAccountHandle, status, message); + } else { + if (helper.supportsProvisioning()) { + VvmLog.i(TAG, "Subscriber not ready, start provisioning"); + helper.startProvisioning(this, phoneAccountHandle, status, message, data); + + } else if (message.getProvisioningStatus().equals(OmtpConstants.SUBSCRIBER_NEW)) { + VvmLog.i(TAG, "Subscriber new but provisioning is not supported"); + // Ignore the non-ready state and attempt to use the provided info as is. + // This is probably caused by not completing the new user tutorial. + updateSource(getContext(), phoneAccountHandle, status, message); + } else { + VvmLog.i(TAG, "Subscriber not ready but provisioning is not supported"); + helper.handleEvent(status, OmtpEvents.CONFIG_SERVICE_NOT_AVAILABLE); + } + } + } + + public static void updateSource(Context context, PhoneAccountHandle phone, + VoicemailStatus.Editor status, StatusMessage message) { + OmtpVvmSourceManager vvmSourceManager = + OmtpVvmSourceManager.getInstance(context); + + if (OmtpConstants.SUCCESS.equals(message.getReturnCode())) { + OmtpVvmCarrierConfigHelper helper = new OmtpVvmCarrierConfigHelper(context, phone); + helper.handleEvent(status, OmtpEvents.CONFIG_REQUEST_STATUS_SUCCESS); + + // Save the IMAP credentials in preferences so they are persistent and can be retrieved. + VisualVoicemailPreferences prefs = new VisualVoicemailPreferences(context, phone); + message.putStatus(prefs.edit()).apply(); + + // Add the source to indicate that it is active. + vvmSourceManager.addSource(phone); + + SyncTask.start(context, phone, OmtpVvmSyncService.SYNC_FULL_SYNC); + } else { + VvmLog.e(TAG, "Visual voicemail not available for subscriber."); + } + } + + private static boolean hasSignal(Context context, PhoneAccountHandle phoneAccountHandle) { + TelephonyManager telephonyManager = context.getSystemService(TelephonyManager.class) + .createForPhoneAccountHandle(phoneAccountHandle); + return telephonyManager.getServiceState().getState() == ServiceState.STATE_IN_SERVICE; + } + + private static void queueActivationAfterProvisioned(Context context, + PhoneAccountHandle phoneAccountHandle) { + if (sDeviceProvisionedObserver == null) { + sDeviceProvisionedObserver = new DeviceProvisionedObserver(context); + context.getContentResolver() + .registerContentObserver(Settings.Global.getUriFor(Global.DEVICE_PROVISIONED), + false, sDeviceProvisionedObserver); + } + sDeviceProvisionedObserver.addPhoneAcountHandle(phoneAccountHandle); + } + + private static class DeviceProvisionedObserver extends ContentObserver { + + private final Context mContext; + private final Set mPhoneAccountHandles = new HashSet<>(); + + private DeviceProvisionedObserver(Context context) { + super(null); + mContext = context; + } + + public void addPhoneAcountHandle(PhoneAccountHandle phoneAccountHandle) { + mPhoneAccountHandles.add(phoneAccountHandle); + } + + @Override + public void onChange(boolean selfChange) { + if (isDeviceProvisioned(mContext)) { + VvmLog.i(TAG, "device provisioned, resuming activation"); + for (PhoneAccountHandle phoneAccountHandle : mPhoneAccountHandles) { + start(mContext, phoneAccountHandle, null); + } + mContext.getContentResolver().unregisterContentObserver(sDeviceProvisionedObserver); + sDeviceProvisionedObserver = null; + } + } + } +} diff --git a/java/com/android/voicemailomtp/AndroidManifest.xml b/java/com/android/voicemailomtp/AndroidManifest.xml new file mode 100644 index 000000000..282a923d2 --- /dev/null +++ b/java/com/android/voicemailomtp/AndroidManifest.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/java/com/android/voicemailomtp/Assert.java b/java/com/android/voicemailomtp/Assert.java new file mode 100644 index 000000000..1d295bed1 --- /dev/null +++ b/java/com/android/voicemailomtp/Assert.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2016 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.voicemailomtp; + +import android.os.Looper; + +/** + * Assertions which will result in program termination. + */ +public class Assert { + + private static Boolean sIsMainThreadForTest; + + public static void isTrue(boolean condition) { + if (!condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + public static void isMainThread() { + if (sIsMainThreadForTest != null) { + isTrue(sIsMainThreadForTest); + return; + } + isTrue(Looper.getMainLooper().equals(Looper.myLooper())); + } + + public static void isNotMainThread() { + if (sIsMainThreadForTest != null) { + isTrue(!sIsMainThreadForTest); + return; + } + isTrue(!Looper.getMainLooper().equals(Looper.myLooper())); + } + + public static void fail() { + throw new AssertionError("Fail"); + } + + /** + * Override the main thread status for tests. Set to null to revert to normal behavior + */ + @NeededForTesting + public static void setIsMainThreadForTesting(Boolean isMainThread) { + sIsMainThreadForTest = isMainThread; + } +} diff --git a/java/com/android/voicemailomtp/DefaultOmtpEventHandler.java b/java/com/android/voicemailomtp/DefaultOmtpEventHandler.java new file mode 100644 index 000000000..6a4b5104a --- /dev/null +++ b/java/com/android/voicemailomtp/DefaultOmtpEventHandler.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2016 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.voicemailomtp; + +import android.content.Context; +import android.provider.VoicemailContract; +import android.provider.VoicemailContract.Status; + +import com.android.voicemailomtp.VoicemailStatus; +import com.android.voicemailomtp.OmtpEvents.Type; + +public class DefaultOmtpEventHandler { + + private static final String TAG = "DefErrorCodeHandler"; + + public static void handleEvent(Context context, OmtpVvmCarrierConfigHelper config, + VoicemailStatus.Editor status, OmtpEvents event) { + switch (event.getType()) { + case Type.CONFIGURATION: + handleConfigurationEvent(context, status, event); + break; + case Type.DATA_CHANNEL: + handleDataChannelEvent(context, status, event); + break; + case Type.NOTIFICATION_CHANNEL: + handleNotificationChannelEvent(context, config, status, event); + break; + case Type.OTHER: + handleOtherEvent(context, status, event); + break; + default: + VvmLog.wtf(TAG, "invalid event type " + event.getType() + " for " + event); + } + } + + private static void handleConfigurationEvent(Context context, VoicemailStatus.Editor status, + OmtpEvents event) { + switch (event) { + case CONFIG_DEFAULT_PIN_REPLACED: + case CONFIG_REQUEST_STATUS_SUCCESS: + case CONFIG_PIN_SET: + status + .setConfigurationState(VoicemailContract.Status.CONFIGURATION_STATE_OK) + .setNotificationChannelState(Status.NOTIFICATION_CHANNEL_STATE_OK) + .apply(); + break; + case CONFIG_ACTIVATING: + // Wipe all errors from the last activation. All errors shown should be new errors + // for this activation. + status + .setConfigurationState(Status.CONFIGURATION_STATE_CONFIGURING) + .setNotificationChannelState(Status.NOTIFICATION_CHANNEL_STATE_OK) + .setDataChannelState(Status.DATA_CHANNEL_STATE_OK).apply(); + break; + case CONFIG_ACTIVATING_SUBSEQUENT: + status + .setConfigurationState(Status.CONFIGURATION_STATE_OK) + .setNotificationChannelState(Status.NOTIFICATION_CHANNEL_STATE_OK) + .setDataChannelState(Status.DATA_CHANNEL_STATE_OK).apply(); + break; + case CONFIG_SERVICE_NOT_AVAILABLE: + status + .setConfigurationState(Status.CONFIGURATION_STATE_FAILED) + .apply(); + break; + case CONFIG_STATUS_SMS_TIME_OUT: + status + .setConfigurationState(Status.CONFIGURATION_STATE_FAILED) + .apply(); + break; + default: + VvmLog.wtf(TAG, "invalid configuration event " + event); + } + } + + private static void handleDataChannelEvent(Context context, VoicemailStatus.Editor status, + OmtpEvents event) { + switch (event) { + case DATA_IMAP_OPERATION_STARTED: + case DATA_IMAP_OPERATION_COMPLETED: + status + .setDataChannelState(Status.DATA_CHANNEL_STATE_OK) + .apply(); + break; + + case DATA_NO_CONNECTION: + status + .setDataChannelState(Status.DATA_CHANNEL_STATE_NO_CONNECTION) + .apply(); + break; + + case DATA_NO_CONNECTION_CELLULAR_REQUIRED: + status + .setDataChannelState( + Status.DATA_CHANNEL_STATE_NO_CONNECTION_CELLULAR_REQUIRED) + .apply(); + break; + case DATA_INVALID_PORT: + status + .setDataChannelState( + VoicemailContract.Status.DATA_CHANNEL_STATE_BAD_CONFIGURATION) + .apply(); + break; + case DATA_CANNOT_RESOLVE_HOST_ON_NETWORK: + status + .setDataChannelState( + VoicemailContract.Status.DATA_CHANNEL_STATE_SERVER_CONNECTION_ERROR) + .apply(); + break; + case DATA_SSL_INVALID_HOST_NAME: + case DATA_CANNOT_ESTABLISH_SSL_SESSION: + case DATA_IOE_ON_OPEN: + case DATA_GENERIC_IMAP_IOE: + status + .setDataChannelState( + VoicemailContract.Status.DATA_CHANNEL_STATE_COMMUNICATION_ERROR) + .apply(); + break; + case DATA_BAD_IMAP_CREDENTIAL: + case DATA_AUTH_UNKNOWN_USER: + case DATA_AUTH_UNKNOWN_DEVICE: + case DATA_AUTH_INVALID_PASSWORD: + case DATA_AUTH_MAILBOX_NOT_INITIALIZED: + case DATA_AUTH_SERVICE_NOT_PROVISIONED: + case DATA_AUTH_SERVICE_NOT_ACTIVATED: + case DATA_AUTH_USER_IS_BLOCKED: + status + .setDataChannelState( + VoicemailContract.Status.DATA_CHANNEL_STATE_BAD_CONFIGURATION) + .apply(); + break; + + case DATA_REJECTED_SERVER_RESPONSE: + case DATA_INVALID_INITIAL_SERVER_RESPONSE: + case DATA_MAILBOX_OPEN_FAILED: + case DATA_SSL_EXCEPTION: + case DATA_ALL_SOCKET_CONNECTION_FAILED: + status + .setDataChannelState( + VoicemailContract.Status.DATA_CHANNEL_STATE_SERVER_ERROR) + .apply(); + break; + + default: + VvmLog.wtf(TAG, "invalid data channel event " + event); + } + } + + private static void handleNotificationChannelEvent(Context context, + OmtpVvmCarrierConfigHelper config, VoicemailStatus.Editor status, OmtpEvents event) { + switch (event) { + case NOTIFICATION_IN_SERVICE: + status + .setNotificationChannelState(Status.NOTIFICATION_CHANNEL_STATE_OK) + // Clear the error state. A sync should follow signal return so any error + // will be reposted. + .setDataChannelState(Status.DATA_CHANNEL_STATE_OK) + .apply(); + break; + case NOTIFICATION_SERVICE_LOST: + status.setNotificationChannelState(Status.NOTIFICATION_CHANNEL_STATE_NO_CONNECTION); + if (config.isCellularDataRequired()) { + status.setDataChannelState( + Status.DATA_CHANNEL_STATE_NO_CONNECTION_CELLULAR_REQUIRED); + } + status.apply(); + break; + default: + VvmLog.wtf(TAG, "invalid notification channel event " + event); + } + } + + private static void handleOtherEvent(Context context, VoicemailStatus.Editor status, + OmtpEvents event) { + switch (event) { + case OTHER_SOURCE_REMOVED: + status + .setConfigurationState(Status.CONFIGURATION_STATE_NOT_CONFIGURED) + .setNotificationChannelState( + Status.NOTIFICATION_CHANNEL_STATE_NO_CONNECTION) + .setDataChannelState(Status.DATA_CHANNEL_STATE_NO_CONNECTION) + .apply(); + break; + default: + VvmLog.wtf(TAG, "invalid other event " + event); + } + } +} diff --git a/java/com/android/voicemailomtp/NeededForTesting.java b/java/com/android/voicemailomtp/NeededForTesting.java new file mode 100644 index 000000000..20517fed8 --- /dev/null +++ b/java/com/android/voicemailomtp/NeededForTesting.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2016 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.voicemailomtp; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.SOURCE) +public @interface NeededForTesting { + +} diff --git a/java/com/android/voicemailomtp/OmtpConstants.java b/java/com/android/voicemailomtp/OmtpConstants.java new file mode 100644 index 000000000..da2b998b6 --- /dev/null +++ b/java/com/android/voicemailomtp/OmtpConstants.java @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2015 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.voicemailomtp; + +import android.support.annotation.IntDef; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.HashMap; +import java.util.Map; + +/** + * Wrapper class to hold relevant OMTP constants as defined in the OMTP spec.

In essence this is + * a programmatic representation of the relevant portions of OMTP spec. + */ +public class OmtpConstants { + public static final String SMS_FIELD_SEPARATOR = ";"; + public static final String SMS_KEY_VALUE_SEPARATOR = "="; + public static final String SMS_PREFIX_SEPARATOR = ":"; + + public static final String SYNC_SMS_PREFIX = "SYNC"; + public static final String STATUS_SMS_PREFIX = "STATUS"; + + // This is the format designated by the OMTP spec. + public static final String DATE_TIME_FORMAT = "dd/MM/yyyy HH:mm Z"; + + /** OMTP protocol versions. */ + public static final String PROTOCOL_VERSION1_1 = "11"; + public static final String PROTOCOL_VERSION1_2 = "12"; + public static final String PROTOCOL_VERSION1_3 = "13"; + + ///////////////////////// Client/Mobile originated SMS ////////////////////// + + /** Mobile Originated requests */ + public static final String ACTIVATE_REQUEST = "Activate"; + public static final String DEACTIVATE_REQUEST = "Deactivate"; + public static final String STATUS_REQUEST = "Status"; + + /** fields that can be present in a Mobile Originated OMTP SMS */ + public static final String CLIENT_TYPE = "ct"; + public static final String APPLICATION_PORT = "pt"; + public static final String PROTOCOL_VERSION = "pv"; + + + //////////////////////////////// Sync SMS fields //////////////////////////// + + /** + * Sync SMS fields. + *

+ * Each string constant is the field's key in the SMS body which is used by the parser to + * identify the field's value, if present, in the SMS body. + */ + + /** + * The event that triggered this SYNC SMS. + * See {@link OmtpConstants#SYNC_TRIGGER_EVENT_VALUES} + */ + public static final String SYNC_TRIGGER_EVENT = "ev"; + public static final String MESSAGE_UID = "id"; + public static final String MESSAGE_LENGTH = "l"; + public static final String NUM_MESSAGE_COUNT = "c"; + /** See {@link OmtpConstants#CONTENT_TYPE_VALUES} */ + public static final String CONTENT_TYPE = "t"; + public static final String SENDER = "s"; + public static final String TIME = "dt"; + + /** + * SYNC message trigger events. + *

+ * These are the possible values of {@link OmtpConstants#SYNC_TRIGGER_EVENT}. + */ + public static final String NEW_MESSAGE = "NM"; + public static final String MAILBOX_UPDATE = "MBU"; + public static final String GREETINGS_UPDATE = "GU"; + + public static final String[] SYNC_TRIGGER_EVENT_VALUES = { + NEW_MESSAGE, + MAILBOX_UPDATE, + GREETINGS_UPDATE + }; + + /** + * Content types supported by OMTP VVM. + *

+ * These are the possible values of {@link OmtpConstants#CONTENT_TYPE}. + */ + public static final String VOICE = "v"; + public static final String VIDEO = "o"; + public static final String FAX = "f"; + /** Voice message deposited by an external application */ + public static final String INFOTAINMENT = "i"; + /** Empty Call Capture - i.e. voicemail with no voice message. */ + public static final String ECC = "e"; + + public static final String[] CONTENT_TYPE_VALUES = {VOICE, VIDEO, FAX, INFOTAINMENT, ECC}; + + ////////////////////////////// Status SMS fields //////////////////////////// + + /** + * Status SMS fields. + *

+ * Each string constant is the field's key in the SMS body which is used by the parser to + * identify the field's value, if present, in the SMS body. + */ + /** See {@link OmtpConstants#PROVISIONING_STATUS_VALUES} */ + public static final String PROVISIONING_STATUS = "st"; + /** See {@link OmtpConstants#RETURN_CODE_VALUES} */ + public static final String RETURN_CODE = "rc"; + /** URL to send users to for activation VVM */ + public static final String SUBSCRIPTION_URL = "rs"; + /** IMAP4/SMTP server IP address or fully qualified domain name */ + public static final String SERVER_ADDRESS = "srv"; + /** Phone number to access voicemails through Telephony User Interface */ + public static final String TUI_ACCESS_NUMBER = "tui"; + public static final String TUI_PASSWORD_LENGTH = "pw_len"; + /** Number to send client origination SMS */ + public static final String CLIENT_SMS_DESTINATION_NUMBER = "dn"; + public static final String IMAP_PORT = "ipt"; + public static final String IMAP_USER_NAME = "u"; + public static final String IMAP_PASSWORD = "pw"; + public static final String SMTP_PORT = "spt"; + public static final String SMTP_USER_NAME = "smtp_u"; + public static final String SMTP_PASSWORD = "smtp_pw"; + + /** + * User provisioning status values. + *

+ * Referred by {@link OmtpConstants#PROVISIONING_STATUS}. + */ + public static final String SUBSCRIBER_NEW = "N"; + public static final String SUBSCRIBER_READY = "R"; + public static final String SUBSCRIBER_PROVISIONED = "P"; + public static final String SUBSCRIBER_UNKNOWN = "U"; + public static final String SUBSCRIBER_BLOCKED = "B"; + + public static final String[] PROVISIONING_STATUS_VALUES = { + SUBSCRIBER_NEW, + SUBSCRIBER_READY, + SUBSCRIBER_PROVISIONED, + SUBSCRIBER_UNKNOWN, + SUBSCRIBER_BLOCKED + }; + + /** + * The return code included in a status message. + *

+ * These are the possible values of {@link OmtpConstants#RETURN_CODE}. + */ + public static final String SUCCESS = "0"; + public static final String SYSTEM_ERROR = "1"; + public static final String SUBSCRIBER_ERROR = "2"; + public static final String MAILBOX_UNKNOWN = "3"; + public static final String VVM_NOT_ACTIVATED = "4"; + public static final String VVM_NOT_PROVISIONED = "5"; + public static final String VVM_CLIENT_UKNOWN = "6"; + public static final String VVM_MAILBOX_NOT_INITIALIZED = "7"; + + public static final String[] RETURN_CODE_VALUES = { + SUCCESS, + SYSTEM_ERROR, + SUBSCRIBER_ERROR, + MAILBOX_UNKNOWN, + VVM_NOT_ACTIVATED, + VVM_NOT_PROVISIONED, + VVM_CLIENT_UKNOWN, + VVM_MAILBOX_NOT_INITIALIZED, + }; + + /** + * A map of all the field keys to the possible values they can have. + */ + public static final Map possibleValuesMap = new HashMap() {{ + put(SYNC_TRIGGER_EVENT, SYNC_TRIGGER_EVENT_VALUES); + put(CONTENT_TYPE, CONTENT_TYPE_VALUES); + put(PROVISIONING_STATUS, PROVISIONING_STATUS_VALUES); + put(RETURN_CODE, RETURN_CODE_VALUES); + }}; + + /** + * IMAP command extensions + */ + + /** + * OMTP spec v1.3 2.3.1 Change password request syntax + * + * This changes the PIN to access the Telephone User Interface, the traditional voicemail + * system. + */ + public static final String IMAP_CHANGE_TUI_PWD_FORMAT = "XCHANGE_TUI_PWD PWD=%1$s OLD_PWD=%2$s"; + + /** + * OMTP spec v1.3 2.4.1 Change languate request syntax + * + * This changes the language in the Telephone User Interface. + */ + public static final String IMAP_CHANGE_VM_LANG_FORMAT = "XCHANGE_VM_LANG LANG=%1$s"; + + /** + * OMTP spec v1.3 2.5.1 Close NUT Request syntax + * + * This disables the new user tutorial, the message played to new users calling in the Telephone + * User Interface. + */ + public static final String IMAP_CLOSE_NUT = "XCLOSE_NUT"; + + /** + * Possible NO responses for CHANGE_TUI_PWD + */ + + public static final String RESPONSE_CHANGE_PIN_TOO_SHORT = "password too short"; + public static final String RESPONSE_CHANGE_PIN_TOO_LONG = "password too long"; + public static final String RESPONSE_CHANGE_PIN_TOO_WEAK = "password too weak"; + public static final String RESPONSE_CHANGE_PIN_MISMATCH = "old password mismatch"; + public static final String RESPONSE_CHANGE_PIN_INVALID_CHARACTER = + "password contains invalid characters"; + + @Retention(RetentionPolicy.SOURCE) + @IntDef(value = {CHANGE_PIN_SUCCESS, CHANGE_PIN_TOO_SHORT, CHANGE_PIN_TOO_LONG, + CHANGE_PIN_TOO_WEAK, CHANGE_PIN_MISMATCH, CHANGE_PIN_INVALID_CHARACTER, + CHANGE_PIN_SYSTEM_ERROR}) + + public @interface ChangePinResult { + + } + + public static final int CHANGE_PIN_SUCCESS = 0; + public static final int CHANGE_PIN_TOO_SHORT = 1; + public static final int CHANGE_PIN_TOO_LONG = 2; + public static final int CHANGE_PIN_TOO_WEAK = 3; + public static final int CHANGE_PIN_MISMATCH = 4; + public static final int CHANGE_PIN_INVALID_CHARACTER = 5; + public static final int CHANGE_PIN_SYSTEM_ERROR = 6; + + /** Indicates the client is Google visual voicemail version 1.0. */ + public static final String CLIENT_TYPE_GOOGLE_10 = "google.vvm.10"; +} diff --git a/java/com/android/voicemailomtp/OmtpEvents.java b/java/com/android/voicemailomtp/OmtpEvents.java new file mode 100644 index 000000000..d5c2a8b03 --- /dev/null +++ b/java/com/android/voicemailomtp/OmtpEvents.java @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2016 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.voicemailomtp; + +import android.support.annotation.IntDef; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Events internal to the OMTP client. These should be translated into {@link + * android.provider.VoicemailContract.Status} error codes before writing into the voicemail status + * table. + */ +public enum OmtpEvents { + + // Configuration State + CONFIG_REQUEST_STATUS_SUCCESS(Type.CONFIGURATION, true), + + CONFIG_PIN_SET(Type.CONFIGURATION, true), + // The voicemail PIN is replaced with a generated PIN, user should change it. + CONFIG_DEFAULT_PIN_REPLACED(Type.CONFIGURATION, true), + CONFIG_ACTIVATING(Type.CONFIGURATION, true), + // There are already activation records, this is only a book-keeping activation. + CONFIG_ACTIVATING_SUBSEQUENT(Type.CONFIGURATION, true), + CONFIG_STATUS_SMS_TIME_OUT(Type.CONFIGURATION), + CONFIG_SERVICE_NOT_AVAILABLE(Type.CONFIGURATION), + + // Data channel State + + // A new sync has started, old errors in data channel should be cleared. + DATA_IMAP_OPERATION_STARTED(Type.DATA_CHANNEL, true), + // Successfully downloaded/uploaded data from the server, which means the data channel is clear. + DATA_IMAP_OPERATION_COMPLETED(Type.DATA_CHANNEL, true), + // The port provided in the STATUS SMS is invalid. + DATA_INVALID_PORT(Type.DATA_CHANNEL), + // No connection to the internet, and the carrier requires cellular data + DATA_NO_CONNECTION_CELLULAR_REQUIRED(Type.DATA_CHANNEL), + // No connection to the internet. + DATA_NO_CONNECTION(Type.DATA_CHANNEL), + // Address lookup for the server hostname failed. DNS error? + DATA_CANNOT_RESOLVE_HOST_ON_NETWORK(Type.DATA_CHANNEL), + // All destination address that resolves to the server hostname are rejected or timed out + DATA_ALL_SOCKET_CONNECTION_FAILED(Type.DATA_CHANNEL), + // Failed to establish SSL with the server, either with a direct SSL connection or by + // STARTTLS command + DATA_CANNOT_ESTABLISH_SSL_SESSION(Type.DATA_CHANNEL), + // Identity of the server cannot be verified. + DATA_SSL_INVALID_HOST_NAME(Type.DATA_CHANNEL), + // The server rejected our username/password + DATA_BAD_IMAP_CREDENTIAL(Type.DATA_CHANNEL), + + DATA_AUTH_UNKNOWN_USER(Type.DATA_CHANNEL), + DATA_AUTH_UNKNOWN_DEVICE(Type.DATA_CHANNEL), + DATA_AUTH_INVALID_PASSWORD(Type.DATA_CHANNEL), + DATA_AUTH_MAILBOX_NOT_INITIALIZED(Type.DATA_CHANNEL), + DATA_AUTH_SERVICE_NOT_PROVISIONED(Type.DATA_CHANNEL), + DATA_AUTH_SERVICE_NOT_ACTIVATED(Type.DATA_CHANNEL), + DATA_AUTH_USER_IS_BLOCKED(Type.DATA_CHANNEL), + + // A command to the server didn't result with an "OK" or continuation request + DATA_REJECTED_SERVER_RESPONSE(Type.DATA_CHANNEL), + // The server did not greet us with a "OK", possibly not a IMAP server. + DATA_INVALID_INITIAL_SERVER_RESPONSE(Type.DATA_CHANNEL), + // An IOException occurred while trying to open an ImapConnection + // TODO: reduce scope + DATA_IOE_ON_OPEN(Type.DATA_CHANNEL), + // The SELECT command on a mailbox is rejected + DATA_MAILBOX_OPEN_FAILED(Type.DATA_CHANNEL), + // An IOException has occurred + // TODO: reduce scope + DATA_GENERIC_IMAP_IOE(Type.DATA_CHANNEL), + // An SslException has occurred while opening an ImapConnection + // TODO: reduce scope + DATA_SSL_EXCEPTION(Type.DATA_CHANNEL), + + // Notification Channel + + // Cell signal restored, can received VVM SMSs + NOTIFICATION_IN_SERVICE(Type.NOTIFICATION_CHANNEL, true), + // Cell signal lost, cannot received VVM SMSs + NOTIFICATION_SERVICE_LOST(Type.NOTIFICATION_CHANNEL, false), + + + // Other + OTHER_SOURCE_REMOVED(Type.OTHER, false), + + // VVM3 + VVM3_NEW_USER_SETUP_FAILED, + // Table 4. client internal error handling + VVM3_VMG_DNS_FAILURE, + VVM3_SPG_DNS_FAILURE, + VVM3_VMG_CONNECTION_FAILED, + VVM3_SPG_CONNECTION_FAILED, + VVM3_VMG_TIMEOUT, + VVM3_STATUS_SMS_TIMEOUT, + + VVM3_SUBSCRIBER_PROVISIONED, + VVM3_SUBSCRIBER_BLOCKED, + VVM3_SUBSCRIBER_UNKNOWN; + + public static class Type { + + @Retention(RetentionPolicy.SOURCE) + @IntDef({CONFIGURATION, DATA_CHANNEL, NOTIFICATION_CHANNEL, OTHER}) + public @interface Values { + + } + + public static final int CONFIGURATION = 1; + public static final int DATA_CHANNEL = 2; + public static final int NOTIFICATION_CHANNEL = 3; + public static final int OTHER = 4; + } + + private final int mType; + private final boolean mIsSuccess; + + OmtpEvents(int type, boolean isSuccess) { + mType = type; + mIsSuccess = isSuccess; + } + + OmtpEvents(int type) { + mType = type; + mIsSuccess = false; + } + + OmtpEvents() { + mType = Type.OTHER; + mIsSuccess = false; + } + + @Type.Values + public int getType() { + return mType; + } + + public boolean isSuccess() { + return mIsSuccess; + } + +} diff --git a/java/com/android/voicemailomtp/OmtpService.java b/java/com/android/voicemailomtp/OmtpService.java new file mode 100644 index 000000000..261a7cb32 --- /dev/null +++ b/java/com/android/voicemailomtp/OmtpService.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2016 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.voicemailomtp; + +import android.content.Intent; +import android.telecom.PhoneAccountHandle; +import android.telephony.VisualVoicemailService; +import android.telephony.VisualVoicemailSms; + +import com.android.voicemailomtp.sync.OmtpVvmSourceManager; + +public class OmtpService extends VisualVoicemailService { + + private static String TAG = "VvmOmtpService"; + + public static final String ACTION_SMS_RECEIVED = "com.android.vociemailomtp.sms.sms_received"; + + public static final String EXTRA_VOICEMAIL_SMS = "extra_voicemail_sms"; + + @Override + public void onCellServiceConnected(VisualVoicemailTask task, + final PhoneAccountHandle phoneAccountHandle) { + VvmLog.i(TAG, "onCellServiceConnected"); + ActivationTask + .start(OmtpService.this, phoneAccountHandle, null); + task.finish(); + } + + @Override + public void onSmsReceived(VisualVoicemailTask task, final VisualVoicemailSms sms) { + VvmLog.i(TAG, "onSmsReceived"); + Intent intent = new Intent(ACTION_SMS_RECEIVED); + intent.setPackage(getPackageName()); + intent.putExtra(EXTRA_VOICEMAIL_SMS, sms); + sendBroadcast(intent); + task.finish(); + } + + @Override + public void onSimRemoved(final VisualVoicemailTask task, + final PhoneAccountHandle phoneAccountHandle) { + VvmLog.i(TAG, "onSimRemoved"); + OmtpVvmSourceManager.getInstance(OmtpService.this).removeSource(phoneAccountHandle); + task.finish(); + } + + @Override + public void onStopped(VisualVoicemailTask task) { + VvmLog.i(TAG, "onStopped"); + } +} diff --git a/java/com/android/voicemailomtp/OmtpVvmCarrierConfigHelper.java b/java/com/android/voicemailomtp/OmtpVvmCarrierConfigHelper.java new file mode 100644 index 000000000..b3e72d215 --- /dev/null +++ b/java/com/android/voicemailomtp/OmtpVvmCarrierConfigHelper.java @@ -0,0 +1,423 @@ +/* + * Copyright (C) 2015 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.voicemailomtp; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.pm.PackageManager.NameNotFoundException; +import android.os.Bundle; +import android.os.PersistableBundle; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.telecom.PhoneAccountHandle; +import android.telephony.CarrierConfigManager; +import android.telephony.TelephonyManager; +import android.telephony.VisualVoicemailService; +import android.telephony.VisualVoicemailSmsFilterSettings; +import android.text.TextUtils; +import android.util.ArraySet; + +import com.android.voicemailomtp.protocol.VisualVoicemailProtocol; +import com.android.voicemailomtp.protocol.VisualVoicemailProtocolFactory; +import com.android.voicemailomtp.sms.StatusMessage; + +import java.util.Arrays; +import java.util.Set; + +/** + * Manages carrier dependent visual voicemail configuration values. The primary source is the value + * retrieved from CarrierConfigManager. If CarrierConfigManager does not provide the config + * (KEY_VVM_TYPE_STRING is empty, or "hidden" configs), then the value hardcoded in telephony will + * be used (in res/xml/vvm_config.xml) + * + * Hidden configs are new configs that are planned for future APIs, or miscellaneous settings that + * may clutter CarrierConfigManager too much. + * + * The current hidden configs are: {@link #getSslPort()} {@link #getDisabledCapabilities()} + */ +public class OmtpVvmCarrierConfigHelper { + + private static final String TAG = "OmtpVvmCarrierCfgHlpr"; + + static final String KEY_VVM_TYPE_STRING = CarrierConfigManager.KEY_VVM_TYPE_STRING; + static final String KEY_VVM_DESTINATION_NUMBER_STRING = + CarrierConfigManager.KEY_VVM_DESTINATION_NUMBER_STRING; + static final String KEY_VVM_PORT_NUMBER_INT = + CarrierConfigManager.KEY_VVM_PORT_NUMBER_INT; + static final String KEY_CARRIER_VVM_PACKAGE_NAME_STRING = + CarrierConfigManager.KEY_CARRIER_VVM_PACKAGE_NAME_STRING; + static final String KEY_CARRIER_VVM_PACKAGE_NAME_STRING_ARRAY = + "carrier_vvm_package_name_string_array"; + static final String KEY_VVM_PREFETCH_BOOL = + CarrierConfigManager.KEY_VVM_PREFETCH_BOOL; + static final String KEY_VVM_CELLULAR_DATA_REQUIRED_BOOL = + CarrierConfigManager.KEY_VVM_CELLULAR_DATA_REQUIRED_BOOL; + + /** + * @see #getSslPort() + */ + static final String KEY_VVM_SSL_PORT_NUMBER_INT = + "vvm_ssl_port_number_int"; + + /** + * @see #isLegacyModeEnabled() + */ + static final String KEY_VVM_LEGACY_MODE_ENABLED_BOOL = + "vvm_legacy_mode_enabled_bool"; + + /** + * Ban a capability reported by the server from being used. The array of string should be a + * subset of the capabilities returned IMAP CAPABILITY command. + * + * @see #getDisabledCapabilities() + */ + static final String KEY_VVM_DISABLED_CAPABILITIES_STRING_ARRAY = + "vvm_disabled_capabilities_string_array"; + static final String KEY_VVM_CLIENT_PREFIX_STRING = + "vvm_client_prefix_string"; + + private final Context mContext; + private final PersistableBundle mCarrierConfig; + private final String mVvmType; + private final VisualVoicemailProtocol mProtocol; + private final PersistableBundle mTelephonyConfig; + + private PhoneAccountHandle mPhoneAccountHandle; + + public OmtpVvmCarrierConfigHelper(Context context, PhoneAccountHandle handle) { + mContext = context; + mPhoneAccountHandle = handle; + mCarrierConfig = getCarrierConfig(); + + TelephonyManager telephonyManager = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + mTelephonyConfig = new TelephonyVvmConfigManager(context.getResources()) + .getConfig(telephonyManager.createForPhoneAccountHandle(mPhoneAccountHandle) + .getSimOperator()); + + mVvmType = getVvmType(); + mProtocol = VisualVoicemailProtocolFactory.create(mContext.getResources(), mVvmType); + + } + + @VisibleForTesting + OmtpVvmCarrierConfigHelper(Context context, PersistableBundle carrierConfig, + PersistableBundle telephonyConfig) { + mContext = context; + mCarrierConfig = carrierConfig; + mTelephonyConfig = telephonyConfig; + mVvmType = getVvmType(); + mProtocol = VisualVoicemailProtocolFactory.create(mContext.getResources(), mVvmType); + } + + public Context getContext() { + return mContext; + } + + @Nullable + public PhoneAccountHandle getPhoneAccountHandle() { + return mPhoneAccountHandle; + } + + /** + * return whether the carrier's visual voicemail is supported, with KEY_VVM_TYPE_STRING set as a + * known protocol. + */ + public boolean isValid() { + return mProtocol != null; + } + + @Nullable + public String getVvmType() { + return (String) getValue(KEY_VVM_TYPE_STRING); + } + + @Nullable + public VisualVoicemailProtocol getProtocol() { + return mProtocol; + } + + /** + * @returns arbitrary String stored in the config file. Used for protocol specific values. + */ + @Nullable + public String getString(String key) { + return (String) getValue(key); + } + + @Nullable + public Set getCarrierVvmPackageNames() { + Set names = getCarrierVvmPackageNames(mCarrierConfig); + if (names != null) { + return names; + } + return getCarrierVvmPackageNames(mTelephonyConfig); + } + + private static Set getCarrierVvmPackageNames(@Nullable PersistableBundle bundle) { + if (bundle == null) { + return null; + } + Set names = new ArraySet<>(); + if (bundle.containsKey(KEY_CARRIER_VVM_PACKAGE_NAME_STRING)) { + names.add(bundle.getString(KEY_CARRIER_VVM_PACKAGE_NAME_STRING)); + } + if (bundle.containsKey(KEY_CARRIER_VVM_PACKAGE_NAME_STRING_ARRAY)) { + names.addAll(Arrays.asList( + bundle.getStringArray(KEY_CARRIER_VVM_PACKAGE_NAME_STRING_ARRAY))); + } + if (names.isEmpty()) { + return null; + } + return names; + } + + /** + * For checking upon sim insertion whether visual voicemail should be enabled. This method does + * so by checking if the carrier's voicemail app is installed. + */ + public boolean isEnabledByDefault() { + if (!isValid()) { + return false; + } + + Set carrierPackages = getCarrierVvmPackageNames(); + if (carrierPackages == null) { + return true; + } + for (String packageName : carrierPackages) { + try { + mContext.getPackageManager().getPackageInfo(packageName, 0); + return false; + } catch (NameNotFoundException e) { + // Do nothing. + } + } + return true; + } + + public boolean isCellularDataRequired() { + return (boolean) getValue(KEY_VVM_CELLULAR_DATA_REQUIRED_BOOL, false); + } + + public boolean isPrefetchEnabled() { + return (boolean) getValue(KEY_VVM_PREFETCH_BOOL, true); + } + + + public int getApplicationPort() { + return (int) getValue(KEY_VVM_PORT_NUMBER_INT, 0); + } + + @Nullable + public String getDestinationNumber() { + return (String) getValue(KEY_VVM_DESTINATION_NUMBER_STRING); + } + + /** + * Hidden config. + * + * @return Port to start a SSL IMAP connection directly. + * + * TODO: make config public and add to CarrierConfigManager + */ + public int getSslPort() { + return (int) getValue(KEY_VVM_SSL_PORT_NUMBER_INT, 0); + } + + /** + * Hidden Config. + * + *

Sometimes the server states it supports a certain feature but we found they have bug on + * the server side. For example, in b/28717550 the server reported AUTH=DIGEST-MD5 capability + * but using it to login will cause subsequent response to be erroneous. + * + * @return A set of capabilities that is reported by the IMAP CAPABILITY command, but determined + * to have issues and should not be used. + */ + @Nullable + public Set getDisabledCapabilities() { + Set disabledCapabilities = getDisabledCapabilities(mCarrierConfig); + if (disabledCapabilities != null) { + return disabledCapabilities; + } + return getDisabledCapabilities(mTelephonyConfig); + } + + @Nullable + private static Set getDisabledCapabilities(@Nullable PersistableBundle bundle) { + if (bundle == null) { + return null; + } + if (!bundle.containsKey(KEY_VVM_DISABLED_CAPABILITIES_STRING_ARRAY)) { + return null; + } + ArraySet result = new ArraySet(); + result.addAll( + Arrays.asList(bundle.getStringArray(KEY_VVM_DISABLED_CAPABILITIES_STRING_ARRAY))); + return result; + } + + public String getClientPrefix() { + String prefix = (String) getValue(KEY_VVM_CLIENT_PREFIX_STRING); + if (prefix != null) { + return prefix; + } + return "//VVM"; + } + + /** + * Should legacy mode be used when the OMTP VVM client is disabled? + * + *

Legacy mode is a mode that on the carrier side visual voicemail is still activated, but on + * the client side all network operations are disabled. SMSs are still monitored so a new + * message SYNC SMS will be translated to show a message waiting indicator, like traditional + * voicemails. + * + *

This is for carriers that does not support VVM deactivation so voicemail can continue to + * function without the data cost. + */ + public boolean isLegacyModeEnabled() { + return (boolean) getValue(KEY_VVM_LEGACY_MODE_ENABLED_BOOL, false); + } + + public void startActivation() { + PhoneAccountHandle phoneAccountHandle = getPhoneAccountHandle(); + if (phoneAccountHandle == null) { + // This should never happen + // Error logged in getPhoneAccountHandle(). + return; + } + + if (mVvmType == null || mVvmType.isEmpty()) { + // The VVM type is invalid; we should never have gotten here in the first place since + // this is loaded initially in the constructor, and callers should check isValid() + // before trying to start activation anyways. + VvmLog.e(TAG, "startActivation : vvmType is null or empty for account " + + phoneAccountHandle); + return; + } + + if (mProtocol != null) { + ActivationTask.start(mContext, mPhoneAccountHandle, null); + } + } + + public void activateSmsFilter() { + VisualVoicemailService.setSmsFilterSettings(mContext, getPhoneAccountHandle(), + new VisualVoicemailSmsFilterSettings.Builder() + .setClientPrefix(getClientPrefix()) + .build()); + } + + public void startDeactivation() { + if (!isLegacyModeEnabled()) { + // SMS should still be filtered in legacy mode + VisualVoicemailService.setSmsFilterSettings(mContext, getPhoneAccountHandle(), null); + } + if (mProtocol != null) { + mProtocol.startDeactivation(this); + } + } + + public boolean supportsProvisioning() { + if (mProtocol != null) { + return mProtocol.supportsProvisioning(); + } + return false; + } + + public void startProvisioning(ActivationTask task, PhoneAccountHandle phone, + VoicemailStatus.Editor status, StatusMessage message, Bundle data) { + if (mProtocol != null) { + mProtocol.startProvisioning(task, phone, this, status, message, data); + } + } + + public void requestStatus(@Nullable PendingIntent sentIntent) { + if (mProtocol != null) { + mProtocol.requestStatus(this, sentIntent); + } + } + + public void handleEvent(VoicemailStatus.Editor status, OmtpEvents event) { + VvmLog.i(TAG, "OmtpEvent:" + event); + if (mProtocol != null) { + mProtocol.handleEvent(mContext, this, status, event); + } + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("OmtpVvmCarrierConfigHelper ["); + builder.append("phoneAccountHandle: ").append(mPhoneAccountHandle) + .append(", carrierConfig: ").append(mCarrierConfig != null) + .append(", telephonyConfig: ").append(mTelephonyConfig != null) + .append(", type: ").append(getVvmType()) + .append(", destinationNumber: ").append(getDestinationNumber()) + .append(", applicationPort: ").append(getApplicationPort()) + .append(", sslPort: ").append(getSslPort()) + .append(", isEnabledByDefault: ").append(isEnabledByDefault()) + .append(", isCellularDataRequired: ").append(isCellularDataRequired()) + .append(", isPrefetchEnabled: ").append(isPrefetchEnabled()) + .append(", isLegacyModeEnabled: ").append(isLegacyModeEnabled()) + .append("]"); + return builder.toString(); + } + + @Nullable + private PersistableBundle getCarrierConfig() { + + CarrierConfigManager carrierConfigManager = (CarrierConfigManager) + mContext.getSystemService(Context.CARRIER_CONFIG_SERVICE); + if (carrierConfigManager == null) { + VvmLog.w(TAG, "No carrier config service found."); + return null; + } + + PersistableBundle config = TelephonyManagerStub + .getCarrirConfigForPhoneAccountHandle(getContext(), mPhoneAccountHandle); + + if (TextUtils.isEmpty(config.getString(CarrierConfigManager.KEY_VVM_TYPE_STRING))) { + return null; + } + return config; + } + + @Nullable + private Object getValue(String key) { + return getValue(key, null); + } + + @Nullable + private Object getValue(String key, Object defaultValue) { + Object result; + if (mCarrierConfig != null) { + result = mCarrierConfig.get(key); + if (result != null) { + return result; + } + } + if (mTelephonyConfig != null) { + result = mTelephonyConfig.get(key); + if (result != null) { + return result; + } + } + return defaultValue; + } + +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/SubscriptionInfoHelper.java b/java/com/android/voicemailomtp/SubscriptionInfoHelper.java new file mode 100644 index 000000000..b916247ad --- /dev/null +++ b/java/com/android/voicemailomtp/SubscriptionInfoHelper.java @@ -0,0 +1,75 @@ +/** + * 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.voicemailomtp; + +import android.app.ActionBar; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.text.TextUtils; + +/** + * Helper for manipulating intents or components with subscription-related information. + * + * In settings, subscription ids and labels are passed along to indicate that settings + * are being changed for particular subscriptions. This helper provides functions for + * helping extract this info and perform common operations using this info. + */ +public class SubscriptionInfoHelper { + public static final int NO_SUB_ID = -1; + + // Extra on intent containing the id of a subscription. + public static final String SUB_ID_EXTRA = + "com.android.voicemailomtp.settings.SubscriptionInfoHelper.SubscriptionId"; + // Extra on intent containing the label of a subscription. + private static final String SUB_LABEL_EXTRA = + "com.android.voicemailomtp.settings.SubscriptionInfoHelper.SubscriptionLabel"; + + private static Context mContext; + + private static int mSubId = NO_SUB_ID; + private static String mSubLabel; + + /** + * Instantiates the helper, by extracting the subscription id and label from the intent. + */ + public SubscriptionInfoHelper(Context context, Intent intent) { + mContext = context; + mSubId = intent.getIntExtra(SUB_ID_EXTRA, NO_SUB_ID); + mSubLabel = intent.getStringExtra(SUB_LABEL_EXTRA); + } + + /** + * Sets the action bar title to the string specified by the given resource id, formatting + * it with the subscription label. This assumes the resource string is formattable with a + * string-type specifier. + * + * If the subscription label does not exists, leave the existing title. + */ + public void setActionBarTitle(ActionBar actionBar, Resources res, int resId) { + if (actionBar == null || TextUtils.isEmpty(mSubLabel)) { + return; + } + + String title = String.format(res.getString(resId), mSubLabel); + actionBar.setTitle(title); + } + + public int getSubId() { + return mSubId; + } +} diff --git a/java/com/android/voicemailomtp/TelephonyManagerStub.java b/java/com/android/voicemailomtp/TelephonyManagerStub.java new file mode 100644 index 000000000..e2e5dacdb --- /dev/null +++ b/java/com/android/voicemailomtp/TelephonyManagerStub.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2017 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.voicemailomtp; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build.VERSION_CODES; +import android.os.PersistableBundle; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telephony.CarrierConfigManager; +import android.telephony.SubscriptionManager; +import android.telephony.TelephonyManager; +import java.lang.reflect.Method; + +/** + * Temporary stub for public APIs that should be added into telephony manager. + * + *

TODO(b/32637799) remove this. + */ +@TargetApi(VERSION_CODES.CUR_DEVELOPMENT) +public class TelephonyManagerStub { + + private static final String TAG = "TelephonyManagerStub"; + + public static void showVoicemailNotification(int voicemailCount) { + + } + + /** + * Dismisses the message waiting (voicemail) indicator. + * + * @param subId the subscription id we should dismiss the notification for. + */ + public static void clearMwiIndicator(int subId) { + + } + + public static void setShouldCheckVisualVoicemailConfigurationForMwi(int subId, + boolean enabled) { + + } + + public static int getSubIdForPhoneAccount(Context context, PhoneAccount phoneAccount) { + // Hidden + TelephonyManager telephonyManager = context.getSystemService(TelephonyManager.class); + try { + Method method = TelephonyManager.class + .getMethod("getSubIdForPhoneAccount", PhoneAccount.class); + return (int) method.invoke(telephonyManager, phoneAccount); + } catch (Exception e) { + VvmLog.e(TAG, "reflection call to getSubIdForPhoneAccount failed:", e); + } + return SubscriptionManager.INVALID_SUBSCRIPTION_ID; + } + + public static String getNetworkSpecifierForPhoneAccountHandle(Context context, + PhoneAccountHandle phoneAccountHandle) { + return String.valueOf(SubscriptionManager.getDefaultDataSubscriptionId()); + } + + public static PersistableBundle getCarrirConfigForPhoneAccountHandle(Context context, + PhoneAccountHandle phoneAccountHandle) { + return context.getSystemService(CarrierConfigManager.class).getConfig(); + } +} diff --git a/java/com/android/voicemailomtp/TelephonyVvmConfigManager.java b/java/com/android/voicemailomtp/TelephonyVvmConfigManager.java new file mode 100644 index 000000000..ab13d36ad --- /dev/null +++ b/java/com/android/voicemailomtp/TelephonyVvmConfigManager.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2016 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.voicemailomtp; + +import android.content.res.Resources; +import android.os.PersistableBundle; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.util.ArrayMap; +import com.android.voicemailomtp.utils.XmlUtils; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Map; +import java.util.Map.Entry; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +/** + * Load and caches telephony vvm config from res/xml/vvm_config.xml + */ +public class TelephonyVvmConfigManager { + + private static final String TAG = "TelephonyVvmCfgMgr"; + + private static final boolean USE_DEBUG_CONFIG = false; + + private static final String TAG_PERSISTABLEMAP = "pbundle_as_map"; + + static final String KEY_MCCMNC = "mccmnc"; + + private static Map sCachedConfigs; + + private final Map mConfigs; + + public TelephonyVvmConfigManager(Resources resources) { + if (sCachedConfigs == null) { + sCachedConfigs = loadConfigs(resources.getXml(R.xml.vvm_config)); + } + mConfigs = sCachedConfigs; + } + + @VisibleForTesting + TelephonyVvmConfigManager(XmlPullParser parser) { + mConfigs = loadConfigs(parser); + } + + @Nullable + public PersistableBundle getConfig(String mccMnc) { + if (USE_DEBUG_CONFIG) { + return mConfigs.get("TEST"); + } + return mConfigs.get(mccMnc); + } + + private static Map loadConfigs(XmlPullParser parser) { + Map configs = new ArrayMap<>(); + try { + ArrayList list = readBundleList(parser); + for (Object object : list) { + if (!(object instanceof PersistableBundle)) { + throw new IllegalArgumentException("PersistableBundle expected, got " + object); + } + PersistableBundle bundle = (PersistableBundle) object; + String[] mccMncs = bundle.getStringArray(KEY_MCCMNC); + if (mccMncs == null) { + throw new IllegalArgumentException("MCCMNC is null"); + } + for (String mccMnc : mccMncs) { + configs.put(mccMnc, bundle); + } + } + } catch (IOException | XmlPullParserException e) { + throw new RuntimeException(e); + } + return configs; + } + + @Nullable + public static ArrayList readBundleList(XmlPullParser in) throws IOException, + XmlPullParserException { + final int outerDepth = in.getDepth(); + int event; + while (((event = in.next()) != XmlPullParser.END_DOCUMENT) && + (event != XmlPullParser.END_TAG || in.getDepth() < outerDepth)) { + if (event == XmlPullParser.START_TAG) { + final String startTag = in.getName(); + final String[] tagName = new String[1]; + in.next(); + return XmlUtils.readThisListXml(in, startTag, tagName, + new MyReadMapCallback(), false); + } + } + return null; + } + + public static PersistableBundle restoreFromXml(XmlPullParser in) throws IOException, + XmlPullParserException { + final int outerDepth = in.getDepth(); + final String startTag = in.getName(); + final String[] tagName = new String[1]; + int event; + while (((event = in.next()) != XmlPullParser.END_DOCUMENT) && + (event != XmlPullParser.END_TAG || in.getDepth() < outerDepth)) { + if (event == XmlPullParser.START_TAG) { + ArrayMap map = + XmlUtils.readThisArrayMapXml(in, startTag, tagName, + new MyReadMapCallback()); + PersistableBundle result = new PersistableBundle(); + for (Entry entry : map.entrySet()) { + Object value = entry.getValue(); + if (value instanceof Integer) { + result.putInt(entry.getKey(), (int) value); + } else if (value instanceof Boolean) { + result.putBoolean(entry.getKey(), (boolean) value); + } else if (value instanceof String) { + result.putString(entry.getKey(), (String) value); + } else if (value instanceof String[]) { + result.putStringArray(entry.getKey(), (String[]) value); + } else if (value instanceof PersistableBundle) { + result.putPersistableBundle(entry.getKey(), (PersistableBundle) value); + } + } + return result; + } + } + return PersistableBundle.EMPTY; + } + + static class MyReadMapCallback implements XmlUtils.ReadMapCallback { + + @Override + public Object readThisUnknownObjectXml(XmlPullParser in, String tag) + throws XmlPullParserException, IOException { + if (TAG_PERSISTABLEMAP.equals(tag)) { + return restoreFromXml(in); + } + throw new XmlPullParserException("Unknown tag=" + tag); + } + } +} diff --git a/java/com/android/voicemailomtp/VisualVoicemailPreferences.java b/java/com/android/voicemailomtp/VisualVoicemailPreferences.java new file mode 100644 index 000000000..5bc2c6951 --- /dev/null +++ b/java/com/android/voicemailomtp/VisualVoicemailPreferences.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2016 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.voicemailomtp; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccountHandle; +import java.util.Set; + +/** + * Save visual voicemail values in shared preferences to be retrieved later. Because a voicemail + * source is tied 1:1 to a phone account, the phone account handle is used in the key for each + * voicemail source and the associated data. + */ +public class VisualVoicemailPreferences { + + private static final String VISUAL_VOICEMAIL_SHARED_PREFS_KEY_PREFIX = + "visual_voicemail_"; + + private final SharedPreferences mPreferences; + private final PhoneAccountHandle mPhoneAccountHandle; + + public VisualVoicemailPreferences(Context context, PhoneAccountHandle phoneAccountHandle) { + mPreferences = PreferenceManager.getDefaultSharedPreferences(context); + mPhoneAccountHandle = phoneAccountHandle; + } + + public class Editor { + + private final SharedPreferences.Editor mEditor; + + private Editor() { + mEditor = mPreferences.edit(); + } + + public void apply() { + mEditor.apply(); + } + + public Editor putBoolean(String key, boolean value) { + mEditor.putBoolean(getKey(key), value); + return this; + } + + @NeededForTesting + public Editor putFloat(String key, float value) { + mEditor.putFloat(getKey(key), value); + return this; + } + + public Editor putInt(String key, int value) { + mEditor.putInt(getKey(key), value); + return this; + } + + @NeededForTesting + public Editor putLong(String key, long value) { + mEditor.putLong(getKey(key), value); + return this; + } + + public Editor putString(String key, String value) { + mEditor.putString(getKey(key), value); + return this; + } + + @NeededForTesting + public Editor putStringSet(String key, Set value) { + mEditor.putStringSet(getKey(key), value); + return this; + } + } + + public Editor edit() { + return new Editor(); + } + + public boolean getBoolean(String key, boolean defValue) { + return getValue(key, defValue); + } + + @NeededForTesting + public float getFloat(String key, float defValue) { + return getValue(key, defValue); + } + + public int getInt(String key, int defValue) { + return getValue(key, defValue); + } + + @NeededForTesting + public long getLong(String key, long defValue) { + return getValue(key, defValue); + } + + public String getString(String key, String defValue) { + return getValue(key, defValue); + } + + @Nullable + public String getString(String key) { + return getValue(key, null); + } + + @NeededForTesting + public Set getStringSet(String key, Set defValue) { + return getValue(key, defValue); + } + + public boolean contains(String key) { + return mPreferences.contains(getKey(key)); + } + + private T getValue(String key, T defValue) { + if (!contains(key)) { + return defValue; + } + Object object = mPreferences.getAll().get(getKey(key)); + if (object == null) { + return defValue; + } + return (T) object; + } + + private String getKey(String key) { + return VISUAL_VOICEMAIL_SHARED_PREFS_KEY_PREFIX + key + "_" + mPhoneAccountHandle.getId(); + } +} diff --git a/java/com/android/voicemailomtp/Voicemail.java b/java/com/android/voicemailomtp/Voicemail.java new file mode 100644 index 000000000..9d8395142 --- /dev/null +++ b/java/com/android/voicemailomtp/Voicemail.java @@ -0,0 +1,330 @@ +/* + * Copyright (C) 2016 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.voicemailomtp; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.telecom.PhoneAccountHandle; +import android.text.TextUtils; + +/** + * Represents a single voicemail stored in the voicemail content provider. + */ +public class Voicemail implements Parcelable { + + private final Long mTimestamp; + private final String mNumber; + private final PhoneAccountHandle mPhoneAccount; + private final Long mId; + private final Long mDuration; + private final String mSource; + private final String mProviderData; + private final Uri mUri; + private final Boolean mIsRead; + private final Boolean mHasContent; + private final String mTranscription; + + private Voicemail(Long timestamp, String number, PhoneAccountHandle phoneAccountHandle, Long id, + Long duration, String source, String providerData, Uri uri, Boolean isRead, + Boolean hasContent, String transcription) { + mTimestamp = timestamp; + mNumber = number; + mPhoneAccount = phoneAccountHandle; + mId = id; + mDuration = duration; + mSource = source; + mProviderData = providerData; + mUri = uri; + mIsRead = isRead; + mHasContent = hasContent; + mTranscription = transcription; + } + + /** + * Create a {@link Builder} for a new {@link Voicemail} to be inserted.

The number and the + * timestamp are mandatory for insertion. + */ + public static Builder createForInsertion(long timestamp, String number) { + return new Builder().setNumber(number).setTimestamp(timestamp); + } + + /** + * Create a {@link Builder} for a {@link Voicemail} to be updated (or deleted).

The id and + * source data fields are mandatory for update - id is necessary for updating the database and + * source data is necessary for updating the server. + */ + public static Builder createForUpdate(long id, String sourceData) { + return new Builder().setId(id).setSourceData(sourceData); + } + + /** + * Builder pattern for creating a {@link Voicemail}. The builder must be created with the {@link + * #createForInsertion(long, String)} method.

This class is not thread safe + */ + public static class Builder { + + private Long mBuilderTimestamp; + private String mBuilderNumber; + private PhoneAccountHandle mBuilderPhoneAccount; + private Long mBuilderId; + private Long mBuilderDuration; + private String mBuilderSourcePackage; + private String mBuilderSourceData; + private Uri mBuilderUri; + private Boolean mBuilderIsRead; + private boolean mBuilderHasContent; + private String mBuilderTranscription; + + /** + * You should use the correct factory method to construct a builder. + */ + private Builder() { + } + + public Builder setNumber(String number) { + mBuilderNumber = number; + return this; + } + + public Builder setTimestamp(long timestamp) { + mBuilderTimestamp = timestamp; + return this; + } + + public Builder setPhoneAccount(PhoneAccountHandle phoneAccount) { + mBuilderPhoneAccount = phoneAccount; + return this; + } + + public Builder setId(long id) { + mBuilderId = id; + return this; + } + + public Builder setDuration(long duration) { + mBuilderDuration = duration; + return this; + } + + public Builder setSourcePackage(String sourcePackage) { + mBuilderSourcePackage = sourcePackage; + return this; + } + + public Builder setSourceData(String sourceData) { + mBuilderSourceData = sourceData; + return this; + } + + public Builder setUri(Uri uri) { + mBuilderUri = uri; + return this; + } + + public Builder setIsRead(boolean isRead) { + mBuilderIsRead = isRead; + return this; + } + + public Builder setHasContent(boolean hasContent) { + mBuilderHasContent = hasContent; + return this; + } + + public Builder setTranscription(String transcription) { + mBuilderTranscription = transcription; + return this; + } + + public Voicemail build() { + mBuilderId = mBuilderId == null ? -1 : mBuilderId; + mBuilderTimestamp = mBuilderTimestamp == null ? 0 : mBuilderTimestamp; + mBuilderDuration = mBuilderDuration == null ? 0 : mBuilderDuration; + mBuilderIsRead = mBuilderIsRead == null ? false : mBuilderIsRead; + return new Voicemail(mBuilderTimestamp, mBuilderNumber, mBuilderPhoneAccount, + mBuilderId, mBuilderDuration, mBuilderSourcePackage, mBuilderSourceData, + mBuilderUri, mBuilderIsRead, mBuilderHasContent, mBuilderTranscription); + } + } + + /** + * The identifier of the voicemail in the content provider.

This may be missing in the case + * of a new {@link Voicemail} that we plan to insert into the content provider, since until it + * has been inserted we don't know what id it should have. If none is specified, we return -1. + */ + public long getId() { + return mId; + } + + /** + * The number of the person leaving the voicemail, empty string if unknown, null if not set. + */ + public String getNumber() { + return mNumber; + } + + /** + * The phone account associated with the voicemail, null if not set. + */ + public PhoneAccountHandle getPhoneAccount() { + return mPhoneAccount; + } + + /** + * The timestamp the voicemail was received, in millis since the epoch, zero if not set. + */ + public long getTimestampMillis() { + return mTimestamp; + } + + /** + * Gets the duration of the voicemail in millis, or zero if the field is not set. + */ + public long getDuration() { + return mDuration; + } + + /** + * Returns the package name of the source that added this voicemail, or null if this field is + * not set. + */ + public String getSourcePackage() { + return mSource; + } + + /** + * Returns the application-specific data type stored with the voicemail, or null if this field + * is not set.

Source data is typically used as an identifier to uniquely identify the + * voicemail against the voicemail server. This is likely to be something like the IMAP UID, or + * some other server-generated identifying string. + */ + public String getSourceData() { + return mProviderData; + } + + /** + * Gets the Uri that can be used to refer to this voicemail, and to make it play.

Returns + * null if we don't know the Uri. + */ + public Uri getUri() { + return mUri; + } + + /** + * Tells us if the voicemail message has been marked as read.

Always returns false if this + * field has not been set, i.e. if hasRead() returns false. + */ + public boolean isRead() { + return mIsRead; + } + + /** + * Tells us if there is content stored at the Uri. + */ + public boolean hasContent() { + return mHasContent; + } + + /** + * Returns the text transcription of this voicemail, or null if this field is not set. + */ + public String getTranscription() { + return mTranscription; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(mTimestamp); + writeCharSequence(dest, mNumber); + if (mPhoneAccount == null) { + dest.writeInt(0); + } else { + dest.writeInt(1); + mPhoneAccount.writeToParcel(dest, flags); + } + dest.writeLong(mId); + dest.writeLong(mDuration); + writeCharSequence(dest, mSource); + writeCharSequence(dest, mProviderData); + if (mUri == null) { + dest.writeInt(0); + } else { + dest.writeInt(1); + mUri.writeToParcel(dest, flags); + } + if (mIsRead) { + dest.writeInt(1); + } else { + dest.writeInt(0); + } + if (mHasContent) { + dest.writeInt(1); + } else { + dest.writeInt(0); + } + writeCharSequence(dest, mTranscription); + } + + public static final Creator CREATOR + = new Creator() { + @Override + public Voicemail createFromParcel(Parcel in) { + return new Voicemail(in); + } + + @Override + public Voicemail[] newArray(int size) { + return new Voicemail[size]; + } + }; + + private Voicemail(Parcel in) { + mTimestamp = in.readLong(); + mNumber = (String) readCharSequence(in); + if (in.readInt() > 0) { + mPhoneAccount = PhoneAccountHandle.CREATOR.createFromParcel(in); + } else { + mPhoneAccount = null; + } + mId = in.readLong(); + mDuration = in.readLong(); + mSource = (String) readCharSequence(in); + mProviderData = (String) readCharSequence(in); + if (in.readInt() > 0) { + mUri = Uri.CREATOR.createFromParcel(in); + } else { + mUri = null; + } + mIsRead = in.readInt() > 0 ? true : false; + mHasContent = in.readInt() > 0 ? true : false; + mTranscription = (String) readCharSequence(in); + } + + private static CharSequence readCharSequence(Parcel in) { + return TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in); + } + + public static void writeCharSequence(Parcel dest, CharSequence val) { + TextUtils.writeToParcel(val, dest, 0); + } +} diff --git a/java/com/android/voicemailomtp/VoicemailStatus.java b/java/com/android/voicemailomtp/VoicemailStatus.java new file mode 100644 index 000000000..63007932e --- /dev/null +++ b/java/com/android/voicemailomtp/VoicemailStatus.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2016 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.voicemailomtp; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.provider.VoicemailContract; +import android.provider.VoicemailContract.Status; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccountHandle; + +public class VoicemailStatus { + + private static final String TAG = "VvmStatus"; + + public static class Editor { + + private final Context mContext; + @Nullable + private final PhoneAccountHandle mPhoneAccountHandle; + + private ContentValues mValues = new ContentValues(); + + private Editor(Context context, PhoneAccountHandle phoneAccountHandle) { + mContext = context; + mPhoneAccountHandle = phoneAccountHandle; + if (mPhoneAccountHandle == null) { + VvmLog.w(TAG, "VoicemailStatus.Editor created with null phone account, status will" + + " not be written"); + } + } + + @Nullable + public PhoneAccountHandle getPhoneAccountHandle() { + return mPhoneAccountHandle; + } + + public Editor setType(String type) { + mValues.put(Status.SOURCE_TYPE, type); + return this; + } + + public Editor setConfigurationState(int configurationState) { + mValues.put(Status.CONFIGURATION_STATE, configurationState); + return this; + } + + public Editor setDataChannelState(int dataChannelState) { + mValues.put(Status.DATA_CHANNEL_STATE, dataChannelState); + return this; + } + + public Editor setNotificationChannelState(int notificationChannelState) { + mValues.put(Status.NOTIFICATION_CHANNEL_STATE, notificationChannelState); + return this; + } + + public Editor setQuota(int occupied, int total) { + if (occupied == VoicemailContract.Status.QUOTA_UNAVAILABLE + && total == VoicemailContract.Status.QUOTA_UNAVAILABLE) { + return this; + } + + mValues.put(Status.QUOTA_OCCUPIED, occupied); + mValues.put(Status.QUOTA_TOTAL, total); + return this; + } + + /** + * Apply the changes to the {@link VoicemailStatus} {@link #Editor}. + * + * @return {@code true} if the changes were successfully applied, {@code false} otherwise. + */ + public boolean apply() { + if (mPhoneAccountHandle == null) { + return false; + } + mValues.put(Status.PHONE_ACCOUNT_COMPONENT_NAME, + mPhoneAccountHandle.getComponentName().flattenToString()); + mValues.put(Status.PHONE_ACCOUNT_ID, mPhoneAccountHandle.getId()); + ContentResolver contentResolver = mContext.getContentResolver(); + Uri statusUri = VoicemailContract.Status.buildSourceUri(mContext.getPackageName()); + try { + contentResolver.insert(statusUri, mValues); + } catch (IllegalArgumentException iae) { + VvmLog.e(TAG, "apply :: failed to insert content resolver ", iae); + mValues.clear(); + return false; + } + mValues.clear(); + return true; + } + + public ContentValues getValues() { + return mValues; + } + } + + /** + * A voicemail status editor that the decision of whether to actually write to the database can + * be deferred. This object will be passed around as a usual {@link Editor}, but {@link + * #apply()} doesn't do anything. If later the creator of this object decides any status changes + * written to it should be committed, {@link #deferredApply()} should be called. + */ + public static class DeferredEditor extends Editor { + + private DeferredEditor(Context context, PhoneAccountHandle phoneAccountHandle) { + super(context, phoneAccountHandle); + } + + @Override + public boolean apply() { + // Do nothing + return true; + } + + public void deferredApply() { + super.apply(); + } + } + + public static Editor edit(Context context, PhoneAccountHandle phoneAccountHandle) { + return new Editor(context, phoneAccountHandle); + } + + /** + * Reset the status to the "disabled" state, which the UI should not show anything for this + * phoneAccountHandle. + */ + public static void disable(Context context, PhoneAccountHandle phoneAccountHandle) { + edit(context, phoneAccountHandle) + .setConfigurationState(Status.CONFIGURATION_STATE_NOT_CONFIGURED) + .setDataChannelState(Status.DATA_CHANNEL_STATE_NO_CONNECTION) + .setNotificationChannelState(Status.NOTIFICATION_CHANNEL_STATE_NO_CONNECTION) + .apply(); + } + + public static DeferredEditor deferredEdit(Context context, + PhoneAccountHandle phoneAccountHandle) { + return new DeferredEditor(context, phoneAccountHandle); + } +} diff --git a/java/com/android/voicemailomtp/VvmLog.java b/java/com/android/voicemailomtp/VvmLog.java new file mode 100644 index 000000000..2add66a53 --- /dev/null +++ b/java/com/android/voicemailomtp/VvmLog.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2015 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.voicemailomtp; + +import android.util.Log; +import com.android.voicemailomtp.utils.IndentingPrintWriter; +import java.io.FileDescriptor; +import java.io.PrintWriter; +import java.util.ArrayDeque; +import java.util.Calendar; +import java.util.Deque; +import java.util.Iterator; + +/** + * Helper methods for adding to OMTP visual voicemail local logs. + */ +public class VvmLog { + + private static final int MAX_OMTP_VVM_LOGS = 100; + + private static final LocalLog sLocalLog = new LocalLog(MAX_OMTP_VVM_LOGS); + + public static void log(String tag, String log) { + sLocalLog.log(tag + ": " + log); + } + + public static void dump(FileDescriptor fd, PrintWriter printwriter, String[] args) { + IndentingPrintWriter indentingPrintWriter = new IndentingPrintWriter(printwriter, " "); + indentingPrintWriter.increaseIndent(); + sLocalLog.dump(fd, indentingPrintWriter, args); + indentingPrintWriter.decreaseIndent(); + } + + public static int e(String tag, String log) { + log(tag, log); + return Log.e(tag, log); + } + + public static int e(String tag, String log, Throwable e) { + log(tag, log + " " + e); + return Log.e(tag, log, e); + } + + public static int w(String tag, String log) { + log(tag, log); + return Log.w(tag, log); + } + + public static int w(String tag, String log, Throwable e) { + log(tag, log + " " + e); + return Log.w(tag, log, e); + } + + public static int i(String tag, String log) { + log(tag, log); + return Log.i(tag, log); + } + + public static int i(String tag, String log, Throwable e) { + log(tag, log + " " + e); + return Log.i(tag, log, e); + } + + public static int d(String tag, String log) { + log(tag, log); + return Log.d(tag, log); + } + + public static int d(String tag, String log, Throwable e) { + log(tag, log + " " + e); + return Log.d(tag, log, e); + } + + public static int v(String tag, String log) { + log(tag, log); + return Log.v(tag, log); + } + + public static int v(String tag, String log, Throwable e) { + log(tag, log + " " + e); + return Log.v(tag, log, e); + } + + public static int wtf(String tag, String log) { + log(tag, log); + return Log.wtf(tag, log); + } + + public static int wtf(String tag, String log, Throwable e) { + log(tag, log + " " + e); + return Log.wtf(tag, log, e); + } + + /** + * Redact personally identifiable information for production users. If we are running in verbose + * mode, return the original string, otherwise return a SHA-1 hash of the input string. + */ + public static String pii(Object pii) { + if (pii == null) { + return String.valueOf(pii); + } + return "[PII]"; + } + + public static class LocalLog { + + private final Deque mLog; + private final int mMaxLines; + + public LocalLog(int maxLines) { + mMaxLines = Math.max(0, maxLines); + mLog = new ArrayDeque<>(mMaxLines); + } + + public void log(String msg) { + if (mMaxLines <= 0) { + return; + } + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(System.currentTimeMillis()); + append(String.format("%tm-%td %tH:%tM:%tS.%tL - %s", c, c, c, c, c, c, msg)); + } + + private synchronized void append(String logLine) { + while (mLog.size() >= mMaxLines) { + mLog.remove(); + } + mLog.add(logLine); + } + + public synchronized void dump(FileDescriptor fd, PrintWriter pw, String[] args) { + Iterator itr = mLog.iterator(); + while (itr.hasNext()) { + pw.println(itr.next()); + } + } + + public synchronized void reverseDump(FileDescriptor fd, PrintWriter pw, String[] args) { + Iterator itr = mLog.descendingIterator(); + while (itr.hasNext()) { + pw.println(itr.next()); + } + } + + public static class ReadOnlyLocalLog { + + private final LocalLog mLog; + + ReadOnlyLocalLog(LocalLog log) { + mLog = log; + } + + public void dump(FileDescriptor fd, PrintWriter pw, String[] args) { + mLog.dump(fd, pw, args); + } + + public void reverseDump(FileDescriptor fd, PrintWriter pw, String[] args) { + mLog.reverseDump(fd, pw, args); + } + } + + public ReadOnlyLocalLog readOnlyLocalLog() { + return new ReadOnlyLocalLog(this); + } + } +} diff --git a/java/com/android/voicemailomtp/VvmPackageInstallReceiver.java b/java/com/android/voicemailomtp/VvmPackageInstallReceiver.java new file mode 100644 index 000000000..7d9eee9f8 --- /dev/null +++ b/java/com/android/voicemailomtp/VvmPackageInstallReceiver.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2015 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.voicemailomtp; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.telecom.PhoneAccountHandle; + +import com.android.voicemailomtp.settings.VisualVoicemailSettingsUtil; +import com.android.voicemailomtp.sync.OmtpVvmSourceManager; + +import java.util.Set; + +/** + * When a new package is installed, check if it matches any of the vvm carrier apps of the currently + * enabled dialer vvm sources. + */ +public class VvmPackageInstallReceiver extends BroadcastReceiver { + + private static final String TAG = "VvmPkgInstallReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getData() == null) { + return; + } + + String packageName = intent.getData().getSchemeSpecificPart(); + if (packageName == null) { + return; + } + + OmtpVvmSourceManager vvmSourceManager = OmtpVvmSourceManager.getInstance(context); + Set phoneAccounts = vvmSourceManager.getOmtpVvmSources(); + for (PhoneAccountHandle phoneAccount : phoneAccounts) { + if (VisualVoicemailSettingsUtil.isEnabledUserSet(context, phoneAccount)) { + // Skip the check if this voicemail source's setting is overridden by the user. + continue; + } + + OmtpVvmCarrierConfigHelper carrierConfigHelper = new OmtpVvmCarrierConfigHelper( + context, phoneAccount); + if (carrierConfigHelper.getCarrierVvmPackageNames() == null) { + continue; + } + if (carrierConfigHelper.getCarrierVvmPackageNames().contains(packageName)) { + // Force deactivate the client. The user can re-enable it in the settings. + // There are no need to update the settings for deactivation. At this point, if the + // default value is used it should be false because a carrier package is present. + VvmLog.i(TAG, "Carrier VVM package installed, disabling system VVM client"); + OmtpVvmSourceManager.getInstance(context).removeSource(phoneAccount); + carrierConfigHelper.startDeactivation(); + } + } + } +} diff --git a/java/com/android/voicemailomtp/VvmPhoneStateListener.java b/java/com/android/voicemailomtp/VvmPhoneStateListener.java new file mode 100644 index 000000000..1a3013d1f --- /dev/null +++ b/java/com/android/voicemailomtp/VvmPhoneStateListener.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2015 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.voicemailomtp; + +import android.content.Context; +import android.telecom.PhoneAccountHandle; +import android.telephony.PhoneStateListener; +import android.telephony.ServiceState; + +import com.android.voicemailomtp.sync.OmtpVvmSourceManager; +import com.android.voicemailomtp.sync.OmtpVvmSyncService; +import com.android.voicemailomtp.sync.SyncTask; +import com.android.voicemailomtp.sync.VoicemailStatusQueryHelper; + +/** + * Check if service is lost and indicate this in the voicemail status. + */ +public class VvmPhoneStateListener extends PhoneStateListener { + + private static final String TAG = "VvmPhoneStateListener"; + + private PhoneAccountHandle mPhoneAccount; + private Context mContext; + private int mPreviousState = -1; + + public VvmPhoneStateListener(Context context, PhoneAccountHandle accountHandle) { + // TODO: b/32637799 too much trouble to call super constructor through reflection, + // just use non-phoneAccountHandle version for now. + super(); + mContext = context; + mPhoneAccount = accountHandle; + } + + @Override + public void onServiceStateChanged(ServiceState serviceState) { + if (mPhoneAccount == null) { + VvmLog.e(TAG, "onServiceStateChanged on phoneAccount " + mPhoneAccount + + " with invalid phoneAccountHandle, ignoring"); + return; + } + + int state = serviceState.getState(); + if (state == mPreviousState || (state != ServiceState.STATE_IN_SERVICE + && mPreviousState != ServiceState.STATE_IN_SERVICE)) { + // Only interested in state changes or transitioning into or out of "in service". + // Otherwise just quit. + mPreviousState = state; + return; + } + + OmtpVvmCarrierConfigHelper helper = new OmtpVvmCarrierConfigHelper(mContext, mPhoneAccount); + + if (state == ServiceState.STATE_IN_SERVICE) { + VoicemailStatusQueryHelper voicemailStatusQueryHelper = + new VoicemailStatusQueryHelper(mContext); + if (voicemailStatusQueryHelper.isVoicemailSourceConfigured(mPhoneAccount)) { + if (!voicemailStatusQueryHelper.isNotificationsChannelActive(mPhoneAccount)) { + VvmLog + .v(TAG, "Notifications channel is active for " + mPhoneAccount); + helper.handleEvent(VoicemailStatus.edit(mContext, mPhoneAccount), + OmtpEvents.NOTIFICATION_IN_SERVICE); + } + } + + if (OmtpVvmSourceManager.getInstance(mContext).isVvmSourceRegistered(mPhoneAccount)) { + VvmLog + .v(TAG, "Signal returned: requesting resync for " + mPhoneAccount); + // If the source is already registered, run a full sync in case something was missed + // while signal was down. + SyncTask.start(mContext, mPhoneAccount, OmtpVvmSyncService.SYNC_FULL_SYNC); + } else { + VvmLog.v(TAG, + "Signal returned: reattempting activation for " + mPhoneAccount); + // Otherwise initiate an activation because this means that an OMTP source was + // recognized but either the activation text was not successfully sent or a response + // was not received. + helper.startActivation(); + } + } else { + VvmLog.v(TAG, "Notifications channel is inactive for " + mPhoneAccount); + + if (!OmtpVvmSourceManager.getInstance(mContext).isVvmSourceRegistered(mPhoneAccount)) { + return; + } + helper.handleEvent(VoicemailStatus.edit(mContext, mPhoneAccount), + OmtpEvents.NOTIFICATION_SERVICE_LOST); + } + mPreviousState = state; + } +} diff --git a/java/com/android/voicemailomtp/fetch/FetchVoicemailReceiver.java b/java/com/android/voicemailomtp/fetch/FetchVoicemailReceiver.java new file mode 100644 index 000000000..85fea80d7 --- /dev/null +++ b/java/com/android/voicemailomtp/fetch/FetchVoicemailReceiver.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.fetch; + +import android.annotation.TargetApi; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Network; +import android.net.Uri; +import android.os.Build.VERSION_CODES; +import android.provider.VoicemailContract; +import android.provider.VoicemailContract.Voicemails; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.os.BuildCompat; +import android.telecom.PhoneAccountHandle; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import com.android.voicemailomtp.VoicemailStatus; +import com.android.voicemailomtp.VvmLog; +import com.android.voicemailomtp.imap.ImapHelper; +import com.android.voicemailomtp.imap.ImapHelper.InitializingException; +import com.android.voicemailomtp.sync.OmtpVvmSourceManager; +import com.android.voicemailomtp.sync.VvmNetworkRequestCallback; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +@TargetApi(VERSION_CODES.CUR_DEVELOPMENT) +public class FetchVoicemailReceiver extends BroadcastReceiver { + + private static final String TAG = "FetchVoicemailReceiver"; + + final static String[] PROJECTION = new String[]{ + Voicemails.SOURCE_DATA, // 0 + Voicemails.PHONE_ACCOUNT_ID, // 1 + Voicemails.PHONE_ACCOUNT_COMPONENT_NAME, // 2 + }; + + public static final int SOURCE_DATA = 0; + public static final int PHONE_ACCOUNT_ID = 1; + public static final int PHONE_ACCOUNT_COMPONENT_NAME = 2; + + // Number of retries + private static final int NETWORK_RETRY_COUNT = 3; + + private ContentResolver mContentResolver; + private Uri mUri; + private VvmNetworkRequestCallback mNetworkCallback; + private Context mContext; + private String mUid; + private PhoneAccountHandle mPhoneAccount; + private int mRetryCount = NETWORK_RETRY_COUNT; + + @Override + public void onReceive(final Context context, Intent intent) { + if (VoicemailContract.ACTION_FETCH_VOICEMAIL.equals(intent.getAction())) { + VvmLog.i(TAG, "ACTION_FETCH_VOICEMAIL received"); + mContext = context; + mContentResolver = context.getContentResolver(); + mUri = intent.getData(); + + if (mUri == null) { + VvmLog.w(TAG, + VoicemailContract.ACTION_FETCH_VOICEMAIL + " intent sent with no data"); + return; + } + + if (!context.getPackageName().equals( + mUri.getQueryParameter(VoicemailContract.PARAM_KEY_SOURCE_PACKAGE))) { + // Ignore if the fetch request is for a voicemail not from this package. + VvmLog.e(TAG, + "ACTION_FETCH_VOICEMAIL from foreign pacakge " + context.getPackageName()); + return; + } + + Cursor cursor = mContentResolver.query(mUri, PROJECTION, null, null, null); + if (cursor == null) { + VvmLog.i(TAG, "ACTION_FETCH_VOICEMAIL query returned null"); + return; + } + try { + if (cursor.moveToFirst()) { + mUid = cursor.getString(SOURCE_DATA); + String accountId = cursor.getString(PHONE_ACCOUNT_ID); + if (TextUtils.isEmpty(accountId)) { + TelephonyManager telephonyManager = (TelephonyManager) + context.getSystemService(Context.TELEPHONY_SERVICE); + accountId = telephonyManager.getSimSerialNumber(); + + if (TextUtils.isEmpty(accountId)) { + VvmLog.e(TAG, "Account null and no default sim found."); + return; + } + } + + mPhoneAccount = new PhoneAccountHandle( + ComponentName.unflattenFromString( + cursor.getString(PHONE_ACCOUNT_COMPONENT_NAME)), + cursor.getString(PHONE_ACCOUNT_ID)); + if (!OmtpVvmSourceManager.getInstance(context) + .isVvmSourceRegistered(mPhoneAccount)) { + mPhoneAccount = getAccountFromMarshmallowAccount(context, mPhoneAccount); + if (mPhoneAccount == null) { + VvmLog.w(TAG, "Account not registered - cannot retrieve message."); + return; + } + VvmLog.i(TAG, "Fetching voicemail with Marshmallow PhoneAccountHandle"); + } + VvmLog.i(TAG, "Requesting network to fetch voicemail"); + mNetworkCallback = new fetchVoicemailNetworkRequestCallback(context, + mPhoneAccount); + mNetworkCallback.requestNetwork(); + } + } finally { + cursor.close(); + } + } + } + + /** + * In ag/930496 the format of PhoneAccountHandle has changed between Marshmallow and Nougat. + * This method attempts to search the account from the old database in registered sources using + * the old format. There's a chance of M phone account collisions on multi-SIM devices, but + * visual voicemail is not supported on M multi-SIM. + */ + @Nullable + private static PhoneAccountHandle getAccountFromMarshmallowAccount(Context context, + PhoneAccountHandle oldAccount) { + if (!BuildCompat.isAtLeastN()) { + return null; + } + for (PhoneAccountHandle handle : OmtpVvmSourceManager.getInstance(context) + .getOmtpVvmSources()) { + if (getIccSerialNumberFromFullIccSerialNumber(handle.getId()) + .equals(oldAccount.getId())) { + return handle; + } + } + return null; + } + + /** + * getIccSerialNumber() is used for ID before N, and getFullIccSerialNumber() after. + * getIccSerialNumber() stops at the first hex char. + */ + @NonNull + private static String getIccSerialNumberFromFullIccSerialNumber(@NonNull String id) { + for(int i =0;i 0) { + VvmLog.i(TAG, "fetching voicemail, retry count=" + mRetryCount); + try (ImapHelper imapHelper = new ImapHelper(mContext, mPhoneAccount, + network, status)) { + boolean success = imapHelper.fetchVoicemailPayload( + new VoicemailFetchedCallback(mContext, mUri, mPhoneAccount), + mUid); + if (!success && mRetryCount > 0) { + VvmLog.i(TAG, "fetch voicemail failed, retrying"); + mRetryCount--; + } else { + return; + } + } catch (InitializingException e) { + VvmLog.w(TAG, "Can't retrieve Imap credentials ", e); + return; + } + } + } finally { + if (mNetworkCallback != null) { + mNetworkCallback.releaseNetwork(); + } + } + } + }); + } +} diff --git a/java/com/android/voicemailomtp/fetch/VoicemailFetchedCallback.java b/java/com/android/voicemailomtp/fetch/VoicemailFetchedCallback.java new file mode 100644 index 000000000..7479c4c4e --- /dev/null +++ b/java/com/android/voicemailomtp/fetch/VoicemailFetchedCallback.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.fetch; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.provider.VoicemailContract.Voicemails; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; +import com.android.voicemailomtp.R; +import com.android.voicemailomtp.VvmLog; +import com.android.voicemailomtp.imap.VoicemailPayload; +import java.io.IOException; +import java.io.OutputStream; +import org.apache.commons.io.IOUtils; + +/** + * Callback for when a voicemail payload is fetched. It copies the returned stream to the data + * file corresponding to the voicemail. + */ +public class VoicemailFetchedCallback { + private static final String TAG = "VoicemailFetchedCallback"; + + private final Context mContext; + private final ContentResolver mContentResolver; + private final Uri mUri; + private final PhoneAccountHandle mPhoneAccountHandle; + + public VoicemailFetchedCallback(Context context, Uri uri, + PhoneAccountHandle phoneAccountHandle) { + mContext = context; + mContentResolver = context.getContentResolver(); + mUri = uri; + mPhoneAccountHandle = phoneAccountHandle; + } + + /** + * Saves the voicemail payload data into the voicemail provider then sets the "has_content" bit + * of the voicemail to "1". + * + * @param voicemailPayload The object containing the content data for the voicemail + */ + public void setVoicemailContent(@Nullable VoicemailPayload voicemailPayload) { + if (voicemailPayload == null) { + VvmLog.i(TAG, "Payload not found, message has unsupported format"); + ContentValues values = new ContentValues(); + values.put(Voicemails.TRANSCRIPTION, + mContext.getString(R.string.vvm_unsupported_message_format, + mContext.getSystemService(TelecomManager.class) + .getVoiceMailNumber(mPhoneAccountHandle))); + updateVoicemail(values); + return; + } + + VvmLog.d(TAG, String.format("Writing new voicemail content: %s", mUri)); + OutputStream outputStream = null; + + try { + outputStream = mContentResolver.openOutputStream(mUri); + byte[] inputBytes = voicemailPayload.getBytes(); + if (inputBytes != null) { + outputStream.write(inputBytes); + } + } catch (IOException e) { + VvmLog.w(TAG, String.format("File not found for %s", mUri)); + return; + } finally { + IOUtils.closeQuietly(outputStream); + } + + // Update mime_type & has_content after we are done with file update. + ContentValues values = new ContentValues(); + values.put(Voicemails.MIME_TYPE, voicemailPayload.getMimeType()); + values.put(Voicemails.HAS_CONTENT, true); + updateVoicemail(values); + } + + private void updateVoicemail(ContentValues values) { + int updatedCount = mContentResolver.update(mUri, values, null, null); + if (updatedCount != 1) { + VvmLog + .e(TAG, "Updating voicemail should have updated 1 row, was: " + updatedCount); + } + } +} diff --git a/java/com/android/voicemailomtp/imap/ImapHelper.java b/java/com/android/voicemailomtp/imap/ImapHelper.java new file mode 100644 index 000000000..b2a40fb64 --- /dev/null +++ b/java/com/android/voicemailomtp/imap/ImapHelper.java @@ -0,0 +1,711 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.imap; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkInfo; +import android.provider.VoicemailContract; +import android.telecom.PhoneAccountHandle; +import android.util.Base64; + +import com.android.voicemailomtp.OmtpConstants; +import com.android.voicemailomtp.OmtpConstants.ChangePinResult; +import com.android.voicemailomtp.OmtpEvents; +import com.android.voicemailomtp.OmtpVvmCarrierConfigHelper; +import com.android.voicemailomtp.VisualVoicemailPreferences; +import com.android.voicemailomtp.Voicemail; +import com.android.voicemailomtp.VoicemailStatus; +import com.android.voicemailomtp.VvmLog; +import com.android.voicemailomtp.fetch.VoicemailFetchedCallback; +import com.android.voicemailomtp.mail.Address; +import com.android.voicemailomtp.mail.Body; +import com.android.voicemailomtp.mail.BodyPart; +import com.android.voicemailomtp.mail.FetchProfile; +import com.android.voicemailomtp.mail.Flag; +import com.android.voicemailomtp.mail.Message; +import com.android.voicemailomtp.mail.MessagingException; +import com.android.voicemailomtp.mail.Multipart; +import com.android.voicemailomtp.mail.TempDirectory; +import com.android.voicemailomtp.mail.internet.MimeMessage; +import com.android.voicemailomtp.mail.store.ImapConnection; +import com.android.voicemailomtp.mail.store.ImapFolder; +import com.android.voicemailomtp.mail.store.ImapStore; +import com.android.voicemailomtp.mail.store.imap.ImapConstants; +import com.android.voicemailomtp.mail.store.imap.ImapResponse; +import com.android.voicemailomtp.mail.utils.LogUtils; +import com.android.voicemailomtp.sync.OmtpVvmSyncService.TranscriptionFetchedCallback; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import org.apache.commons.io.IOUtils; + +/** + * A helper interface to abstract commands sent across IMAP interface for a given account. + */ +public class ImapHelper implements Closeable { + + private static final String TAG = "ImapHelper"; + + private ImapFolder mFolder; + private ImapStore mImapStore; + + private final Context mContext; + private final PhoneAccountHandle mPhoneAccount; + private final Network mNetwork; + private final VoicemailStatus.Editor mStatus; + + VisualVoicemailPreferences mPrefs; + private static final String PREF_KEY_QUOTA_OCCUPIED = "quota_occupied_"; + private static final String PREF_KEY_QUOTA_TOTAL = "quota_total_"; + + private int mQuotaOccupied; + private int mQuotaTotal; + + private final OmtpVvmCarrierConfigHelper mConfig; + + public class InitializingException extends Exception { + + public InitializingException(String message) { + super(message); + } + } + + public ImapHelper(Context context, PhoneAccountHandle phoneAccount, Network network, + VoicemailStatus.Editor status) + throws InitializingException { + this(context, + new OmtpVvmCarrierConfigHelper( + context, + phoneAccount), + phoneAccount, + network, + status); + } + + public ImapHelper(Context context, OmtpVvmCarrierConfigHelper config, + PhoneAccountHandle phoneAccount, Network network, VoicemailStatus.Editor status) + throws InitializingException { + mContext = context; + mPhoneAccount = phoneAccount; + mNetwork = network; + mStatus = status; + mConfig = config; + mPrefs = new VisualVoicemailPreferences(context, + phoneAccount); + + try { + TempDirectory.setTempDirectory(context); + + String username = mPrefs.getString(OmtpConstants.IMAP_USER_NAME, null); + String password = mPrefs.getString(OmtpConstants.IMAP_PASSWORD, null); + String serverName = mPrefs.getString(OmtpConstants.SERVER_ADDRESS, null); + int port = Integer.parseInt( + mPrefs.getString(OmtpConstants.IMAP_PORT, null)); + int auth = ImapStore.FLAG_NONE; + + int sslPort = mConfig.getSslPort(); + if (sslPort != 0) { + port = sslPort; + auth = ImapStore.FLAG_SSL; + } + + mImapStore = new ImapStore( + context, this, username, password, port, serverName, auth, network); + } catch (NumberFormatException e) { + handleEvent(OmtpEvents.DATA_INVALID_PORT); + LogUtils.w(TAG, "Could not parse port number"); + throw new InitializingException("cannot initialize ImapHelper:" + e.toString()); + } + + mQuotaOccupied = mPrefs + .getInt(PREF_KEY_QUOTA_OCCUPIED, VoicemailContract.Status.QUOTA_UNAVAILABLE); + mQuotaTotal = mPrefs + .getInt(PREF_KEY_QUOTA_TOTAL, VoicemailContract.Status.QUOTA_UNAVAILABLE); + } + + @Override + public void close() { + mImapStore.closeConnection(); + } + + public boolean isRoaming() { + ConnectivityManager connectivityManager = (ConnectivityManager) mContext.getSystemService( + Context.CONNECTIVITY_SERVICE); + NetworkInfo info = connectivityManager.getNetworkInfo(mNetwork); + if (info == null) { + return false; + } + return info.isRoaming(); + } + + public OmtpVvmCarrierConfigHelper getConfig() { + return mConfig; + } + + public ImapConnection connect() { + return mImapStore.getConnection(); + } + + /** + * The caller thread will block until the method returns. + */ + public boolean markMessagesAsRead(List voicemails) { + return setFlags(voicemails, Flag.SEEN); + } + + /** + * The caller thread will block until the method returns. + */ + public boolean markMessagesAsDeleted(List voicemails) { + return setFlags(voicemails, Flag.DELETED); + } + + public void handleEvent(OmtpEvents event) { + mConfig.handleEvent(mStatus, event); + } + + /** + * Set flags on the server for a given set of voicemails. + * + * @param voicemails The voicemails to set flags for. + * @param flags The flags to set on the voicemails. + * @return {@code true} if the operation completes successfully, {@code false} otherwise. + */ + private boolean setFlags(List voicemails, String... flags) { + if (voicemails.size() == 0) { + return false; + } + try { + mFolder = openImapFolder(ImapFolder.MODE_READ_WRITE); + if (mFolder != null) { + mFolder.setFlags(convertToImapMessages(voicemails), flags, true); + return true; + } + return false; + } catch (MessagingException e) { + LogUtils.e(TAG, e, "Messaging exception"); + return false; + } finally { + closeImapFolder(); + } + } + + /** + * Fetch a list of voicemails from the server. + * + * @return A list of voicemail objects containing data about voicemails stored on the server. + */ + public List fetchAllVoicemails() { + List result = new ArrayList(); + Message[] messages; + try { + mFolder = openImapFolder(ImapFolder.MODE_READ_WRITE); + if (mFolder == null) { + // This means we were unable to successfully open the folder. + return null; + } + + // This method retrieves lightweight messages containing only the uid of the message. + messages = mFolder.getMessages(null); + + for (Message message : messages) { + // Get the voicemail details (message structure). + MessageStructureWrapper messageStructureWrapper = fetchMessageStructure(message); + if (messageStructureWrapper != null) { + result.add(getVoicemailFromMessageStructure(messageStructureWrapper)); + } + } + return result; + } catch (MessagingException e) { + LogUtils.e(TAG, e, "Messaging Exception"); + return null; + } finally { + closeImapFolder(); + } + } + + /** + * Extract voicemail details from the message structure. Also fetch transcription if a + * transcription exists. + */ + private Voicemail getVoicemailFromMessageStructure( + MessageStructureWrapper messageStructureWrapper) throws MessagingException { + Message messageDetails = messageStructureWrapper.messageStructure; + + TranscriptionFetchedListener listener = new TranscriptionFetchedListener(); + if (messageStructureWrapper.transcriptionBodyPart != null) { + FetchProfile fetchProfile = new FetchProfile(); + fetchProfile.add(messageStructureWrapper.transcriptionBodyPart); + + mFolder.fetch(new Message[]{messageDetails}, fetchProfile, listener); + } + + // Found an audio attachment, this is a valid voicemail. + long time = messageDetails.getSentDate().getTime(); + String number = getNumber(messageDetails.getFrom()); + boolean isRead = Arrays.asList(messageDetails.getFlags()).contains(Flag.SEEN); + return Voicemail.createForInsertion(time, number) + .setPhoneAccount(mPhoneAccount) + .setSourcePackage(mContext.getPackageName()) + .setSourceData(messageDetails.getUid()) + .setIsRead(isRead) + .setTranscription(listener.getVoicemailTranscription()) + .build(); + } + + /** + * The "from" field of a visual voicemail IMAP message is the number of the caller who left the + * message. Extract this number from the list of "from" addresses. + * + * @param fromAddresses A list of addresses that comprise the "from" line. + * @return The number of the voicemail sender. + */ + private String getNumber(Address[] fromAddresses) { + if (fromAddresses != null && fromAddresses.length > 0) { + if (fromAddresses.length != 1) { + LogUtils.w(TAG, "More than one from addresses found. Using the first one."); + } + String sender = fromAddresses[0].getAddress(); + int atPos = sender.indexOf('@'); + if (atPos != -1) { + // Strip domain part of the address. + sender = sender.substring(0, atPos); + } + return sender; + } + return null; + } + + /** + * Fetches the structure of the given message and returns a wrapper containing the message + * structure and the transcription structure (if applicable). + * + * @throws MessagingException if fetching the structure of the message fails + */ + private MessageStructureWrapper fetchMessageStructure(Message message) + throws MessagingException { + LogUtils.d(TAG, "Fetching message structure for " + message.getUid()); + + MessageStructureFetchedListener listener = new MessageStructureFetchedListener(); + + FetchProfile fetchProfile = new FetchProfile(); + fetchProfile.addAll(Arrays.asList(FetchProfile.Item.FLAGS, FetchProfile.Item.ENVELOPE, + FetchProfile.Item.STRUCTURE)); + + // The IMAP folder fetch method will call "messageRetrieved" on the listener when the + // message is successfully retrieved. + mFolder.fetch(new Message[]{message}, fetchProfile, listener); + return listener.getMessageStructure(); + } + + public boolean fetchVoicemailPayload(VoicemailFetchedCallback callback, final String uid) { + try { + mFolder = openImapFolder(ImapFolder.MODE_READ_WRITE); + if (mFolder == null) { + // This means we were unable to successfully open the folder. + return false; + } + Message message = mFolder.getMessage(uid); + if (message == null) { + return false; + } + VoicemailPayload voicemailPayload = fetchVoicemailPayload(message); + callback.setVoicemailContent(voicemailPayload); + return true; + } catch (MessagingException e) { + } finally { + closeImapFolder(); + } + return false; + } + + /** + * Fetches the body of the given message and returns the parsed voicemail payload. + * + * @throws MessagingException if fetching the body of the message fails + */ + private VoicemailPayload fetchVoicemailPayload(Message message) + throws MessagingException { + LogUtils.d(TAG, "Fetching message body for " + message.getUid()); + + MessageBodyFetchedListener listener = new MessageBodyFetchedListener(); + + FetchProfile fetchProfile = new FetchProfile(); + fetchProfile.add(FetchProfile.Item.BODY); + + mFolder.fetch(new Message[]{message}, fetchProfile, listener); + return listener.getVoicemailPayload(); + } + + public boolean fetchTranscription(TranscriptionFetchedCallback callback, String uid) { + try { + mFolder = openImapFolder(ImapFolder.MODE_READ_WRITE); + if (mFolder == null) { + // This means we were unable to successfully open the folder. + return false; + } + + Message message = mFolder.getMessage(uid); + if (message == null) { + return false; + } + + MessageStructureWrapper messageStructureWrapper = fetchMessageStructure(message); + if (messageStructureWrapper != null) { + TranscriptionFetchedListener listener = new TranscriptionFetchedListener(); + if (messageStructureWrapper.transcriptionBodyPart != null) { + FetchProfile fetchProfile = new FetchProfile(); + fetchProfile.add(messageStructureWrapper.transcriptionBodyPart); + + // This method is called synchronously so the transcription will be populated + // in the listener once the next method is called. + mFolder.fetch(new Message[]{message}, fetchProfile, listener); + callback.setVoicemailTranscription(listener.getVoicemailTranscription()); + } + } + return true; + } catch (MessagingException e) { + LogUtils.e(TAG, e, "Messaging Exception"); + return false; + } finally { + closeImapFolder(); + } + } + + + @ChangePinResult + public int changePin(String oldPin, String newPin) + throws MessagingException { + ImapConnection connection = mImapStore.getConnection(); + try { + String command = getConfig().getProtocol() + .getCommand(OmtpConstants.IMAP_CHANGE_TUI_PWD_FORMAT); + connection.sendCommand( + String.format(Locale.US, command, newPin, oldPin), true); + return getChangePinResultFromImapResponse(connection.readResponse()); + } catch (IOException ioe) { + VvmLog.e(TAG, "changePin: ", ioe); + return OmtpConstants.CHANGE_PIN_SYSTEM_ERROR; + } finally { + connection.destroyResponses(); + } + } + + public void changeVoicemailTuiLanguage(String languageCode) + throws MessagingException { + ImapConnection connection = mImapStore.getConnection(); + try { + String command = getConfig().getProtocol() + .getCommand(OmtpConstants.IMAP_CHANGE_VM_LANG_FORMAT); + connection.sendCommand( + String.format(Locale.US, command, languageCode), true); + } catch (IOException ioe) { + LogUtils.e(TAG, ioe.toString()); + } finally { + connection.destroyResponses(); + } + } + + public void closeNewUserTutorial() throws MessagingException { + ImapConnection connection = mImapStore.getConnection(); + try { + String command = getConfig().getProtocol() + .getCommand(OmtpConstants.IMAP_CLOSE_NUT); + connection.executeSimpleCommand(command, false); + } catch (IOException ioe) { + throw new MessagingException(MessagingException.SERVER_ERROR, ioe.toString()); + } finally { + connection.destroyResponses(); + } + } + + @ChangePinResult + private static int getChangePinResultFromImapResponse(ImapResponse response) + throws MessagingException { + if (!response.isTagged()) { + throw new MessagingException(MessagingException.SERVER_ERROR, + "tagged response expected"); + } + if (!response.isOk()) { + String message = response.getStringOrEmpty(1).getString(); + LogUtils.d(TAG, "change PIN failed: " + message); + if (OmtpConstants.RESPONSE_CHANGE_PIN_TOO_SHORT.equals(message)) { + return OmtpConstants.CHANGE_PIN_TOO_SHORT; + } + if (OmtpConstants.RESPONSE_CHANGE_PIN_TOO_LONG.equals(message)) { + return OmtpConstants.CHANGE_PIN_TOO_LONG; + } + if (OmtpConstants.RESPONSE_CHANGE_PIN_TOO_WEAK.equals(message)) { + return OmtpConstants.CHANGE_PIN_TOO_WEAK; + } + if (OmtpConstants.RESPONSE_CHANGE_PIN_MISMATCH.equals(message)) { + return OmtpConstants.CHANGE_PIN_MISMATCH; + } + if (OmtpConstants.RESPONSE_CHANGE_PIN_INVALID_CHARACTER.equals(message)) { + return OmtpConstants.CHANGE_PIN_INVALID_CHARACTER; + } + return OmtpConstants.CHANGE_PIN_SYSTEM_ERROR; + } + LogUtils.d(TAG, "change PIN succeeded"); + return OmtpConstants.CHANGE_PIN_SUCCESS; + } + + public void updateQuota() { + try { + mFolder = openImapFolder(ImapFolder.MODE_READ_WRITE); + if (mFolder == null) { + // This means we were unable to successfully open the folder. + return; + } + updateQuota(mFolder); + } catch (MessagingException e) { + LogUtils.e(TAG, e, "Messaging Exception"); + } finally { + closeImapFolder(); + } + } + + private void updateQuota(ImapFolder folder) throws MessagingException { + setQuota(folder.getQuota()); + } + + private void setQuota(ImapFolder.Quota quota) { + if (quota == null) { + return; + } + if (quota.occupied == mQuotaOccupied && quota.total == mQuotaTotal) { + VvmLog.v(TAG, "Quota hasn't changed"); + return; + } + mQuotaOccupied = quota.occupied; + mQuotaTotal = quota.total; + VoicemailStatus.edit(mContext, mPhoneAccount) + .setQuota(mQuotaOccupied, mQuotaTotal) + .apply(); + mPrefs.edit() + .putInt(PREF_KEY_QUOTA_OCCUPIED, mQuotaOccupied) + .putInt(PREF_KEY_QUOTA_TOTAL, mQuotaTotal) + .apply(); + VvmLog.v(TAG, "Quota changed to " + mQuotaOccupied + "/" + mQuotaTotal); + } + + /** + * A wrapper to hold a message with its header details and the structure for transcriptions (so + * they can be fetched in the future). + */ + public class MessageStructureWrapper { + + public Message messageStructure; + public BodyPart transcriptionBodyPart; + + public MessageStructureWrapper() { + } + } + + /** + * Listener for the message structure being fetched. + */ + private final class MessageStructureFetchedListener + implements ImapFolder.MessageRetrievalListener { + + private MessageStructureWrapper mMessageStructure; + + public MessageStructureFetchedListener() { + } + + public MessageStructureWrapper getMessageStructure() { + return mMessageStructure; + } + + @Override + public void messageRetrieved(Message message) { + LogUtils.d(TAG, "Fetched message structure for " + message.getUid()); + LogUtils.d(TAG, "Message retrieved: " + message); + try { + mMessageStructure = getMessageOrNull(message); + if (mMessageStructure == null) { + LogUtils.d(TAG, "This voicemail does not have an attachment..."); + return; + } + } catch (MessagingException e) { + LogUtils.e(TAG, e, "Messaging Exception"); + closeImapFolder(); + } + } + + /** + * Check if this IMAP message is a valid voicemail and whether it contains a transcription. + * + * @param message The IMAP message. + * @return The MessageStructureWrapper object corresponding to an IMAP message and + * transcription. + */ + private MessageStructureWrapper getMessageOrNull(Message message) + throws MessagingException { + if (!message.getMimeType().startsWith("multipart/")) { + LogUtils.w(TAG, "Ignored non multi-part message"); + return null; + } + + MessageStructureWrapper messageStructureWrapper = new MessageStructureWrapper(); + + Multipart multipart = (Multipart) message.getBody(); + for (int i = 0; i < multipart.getCount(); ++i) { + BodyPart bodyPart = multipart.getBodyPart(i); + String bodyPartMimeType = bodyPart.getMimeType().toLowerCase(); + LogUtils.d(TAG, "bodyPart mime type: " + bodyPartMimeType); + + if (bodyPartMimeType.startsWith("audio/")) { + messageStructureWrapper.messageStructure = message; + } else if (bodyPartMimeType.startsWith("text/")) { + messageStructureWrapper.transcriptionBodyPart = bodyPart; + } else { + VvmLog.v(TAG, "Unknown bodyPart MIME: " + bodyPartMimeType); + } + } + + if (messageStructureWrapper.messageStructure != null) { + return messageStructureWrapper; + } + + // No attachment found, this is not a voicemail. + return null; + } + } + + /** + * Listener for the message body being fetched. + */ + private final class MessageBodyFetchedListener implements ImapFolder.MessageRetrievalListener { + + private VoicemailPayload mVoicemailPayload; + + /** + * Returns the fetch voicemail payload. + */ + public VoicemailPayload getVoicemailPayload() { + return mVoicemailPayload; + } + + @Override + public void messageRetrieved(Message message) { + LogUtils.d(TAG, "Fetched message body for " + message.getUid()); + LogUtils.d(TAG, "Message retrieved: " + message); + try { + mVoicemailPayload = getVoicemailPayloadFromMessage(message); + } catch (MessagingException e) { + LogUtils.e(TAG, "Messaging Exception:", e); + } catch (IOException e) { + LogUtils.e(TAG, "IO Exception:", e); + } + } + + private VoicemailPayload getVoicemailPayloadFromMessage(Message message) + throws MessagingException, IOException { + Multipart multipart = (Multipart) message.getBody(); + List mimeTypes = new ArrayList<>(); + for (int i = 0; i < multipart.getCount(); ++i) { + BodyPart bodyPart = multipart.getBodyPart(i); + String bodyPartMimeType = bodyPart.getMimeType().toLowerCase(); + mimeTypes.add(bodyPartMimeType); + if (bodyPartMimeType.startsWith("audio/")) { + byte[] bytes = getDataFromBody(bodyPart.getBody()); + LogUtils.d(TAG, String.format("Fetched %s bytes of data", bytes.length)); + return new VoicemailPayload(bodyPartMimeType, bytes); + } + } + LogUtils.e(TAG, "No audio attachment found on this voicemail, mimeTypes:" + mimeTypes); + return null; + } + } + + /** + * Listener for the transcription being fetched. + */ + private final class TranscriptionFetchedListener implements + ImapFolder.MessageRetrievalListener { + + private String mVoicemailTranscription; + + /** + * Returns the fetched voicemail transcription. + */ + public String getVoicemailTranscription() { + return mVoicemailTranscription; + } + + @Override + public void messageRetrieved(Message message) { + LogUtils.d(TAG, "Fetched transcription for " + message.getUid()); + try { + mVoicemailTranscription = new String(getDataFromBody(message.getBody())); + } catch (MessagingException e) { + LogUtils.e(TAG, "Messaging Exception:", e); + } catch (IOException e) { + LogUtils.e(TAG, "IO Exception:", e); + } + } + } + + private ImapFolder openImapFolder(String modeReadWrite) { + try { + if (mImapStore == null) { + return null; + } + ImapFolder folder = new ImapFolder(mImapStore, ImapConstants.INBOX); + folder.open(modeReadWrite); + return folder; + } catch (MessagingException e) { + LogUtils.e(TAG, e, "Messaging Exception"); + } + return null; + } + + private Message[] convertToImapMessages(List voicemails) { + Message[] messages = new Message[voicemails.size()]; + for (int i = 0; i < voicemails.size(); ++i) { + messages[i] = new MimeMessage(); + messages[i].setUid(voicemails.get(i).getSourceData()); + } + return messages; + } + + private void closeImapFolder() { + if (mFolder != null) { + mFolder.close(true); + } + } + + private byte[] getDataFromBody(Body body) throws IOException, MessagingException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + BufferedOutputStream bufferedOut = new BufferedOutputStream(out); + try { + body.writeTo(bufferedOut); + return Base64.decode(out.toByteArray(), Base64.DEFAULT); + } finally { + IOUtils.closeQuietly(bufferedOut); + IOUtils.closeQuietly(out); + } + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/imap/VoicemailPayload.java b/java/com/android/voicemailomtp/imap/VoicemailPayload.java new file mode 100644 index 000000000..04c69dea5 --- /dev/null +++ b/java/com/android/voicemailomtp/imap/VoicemailPayload.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.imap; + +/** + * The payload for a voicemail, usually audio data. + */ +public class VoicemailPayload { + private final String mMimeType; + private final byte[] mBytes; + + public VoicemailPayload(String mimeType, byte[] bytes) { + mMimeType = mimeType; + mBytes = bytes; + } + + public byte[] getBytes() { + return mBytes; + } + + public String getMimeType() { + return mMimeType; + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/Address.java b/java/com/android/voicemailomtp/mail/Address.java new file mode 100644 index 000000000..ed3f44c03 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/Address.java @@ -0,0 +1,541 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.VisibleForTesting; +import android.text.Html; +import android.text.TextUtils; +import android.text.util.Rfc822Token; +import android.text.util.Rfc822Tokenizer; +import com.android.voicemailomtp.mail.utils.LogUtils; +import java.util.ArrayList; +import java.util.regex.Pattern; +import org.apache.james.mime4j.codec.EncoderUtil; +import org.apache.james.mime4j.decoder.DecoderUtil; + +/** + * This class represent email address. + * + * RFC822 email address may have following format. + * "name"

(comment) + * "name"
+ * name
+ * address + * Name and comment part should be MIME/base64 encoded in header if necessary. + * + */ +public class Address implements Parcelable { + public static final String ADDRESS_DELIMETER = ","; + /** + * Address part, in the form local_part@domain_part. No surrounding angle brackets. + */ + private String mAddress; + + /** + * Name part. No surrounding double quote, and no MIME/base64 encoding. + * This must be null if Address has no name part. + */ + private String mPersonal; + + /** + * When personal is set, it will return the first token of the personal + * string. Otherwise, it will return the e-mail address up to the '@' sign. + */ + private String mSimplifiedName; + + // Regex that matches address surrounded by '<>' optionally. '^]+)>?$' + private static final Pattern REMOVE_OPTIONAL_BRACKET = Pattern.compile("^]+)>?$"); + // Regex that matches personal name surrounded by '""' optionally. '^"?([^"]+)"?$' + private static final Pattern REMOVE_OPTIONAL_DQUOTE = Pattern.compile("^\"?([^\"]*)\"?$"); + // Regex that matches escaped character '\\([\\"])' + private static final Pattern UNQUOTE = Pattern.compile("\\\\([\\\\\"])"); + + // TODO: LOCAL_PART and DOMAIN_PART_PART are too permissive and can be improved. + // TODO: Fix this to better constrain comments. + /** Regex for the local part of an email address. */ + private static final String LOCAL_PART = "[^@]+"; + /** Regex for each part of the domain part, i.e. the thing between the dots. */ + private static final String DOMAIN_PART_PART = "[[\\w][\\d]\\-\\(\\)\\[\\]]+"; + /** Regex for the domain part, which is two or more {@link #DOMAIN_PART_PART} separated by . */ + private static final String DOMAIN_PART = + "(" + DOMAIN_PART_PART + "\\.)+" + DOMAIN_PART_PART; + + /** Pattern to check if an email address is valid. */ + private static final Pattern EMAIL_ADDRESS = + Pattern.compile("\\A" + LOCAL_PART + "@" + DOMAIN_PART + "\\z"); + + private static final Address[] EMPTY_ADDRESS_ARRAY = new Address[0]; + + // delimiters are chars that do not appear in an email address, used by fromHeader + private static final char LIST_DELIMITER_EMAIL = '\1'; + private static final char LIST_DELIMITER_PERSONAL = '\2'; + + private static final String LOG_TAG = "Email Address"; + + @VisibleForTesting + public Address(String address) { + setAddress(address); + } + + public Address(String address, String personal) { + setPersonal(personal); + setAddress(address); + } + + /** + * Returns a simplified string for this e-mail address. + * When a name is known, it will return the first token of that name. Otherwise, it will + * return the e-mail address up to the '@' sign. + */ + public String getSimplifiedName() { + if (mSimplifiedName == null) { + if (TextUtils.isEmpty(mPersonal) && !TextUtils.isEmpty(mAddress)) { + int atSign = mAddress.indexOf('@'); + mSimplifiedName = (atSign != -1) ? mAddress.substring(0, atSign) : ""; + } else if (!TextUtils.isEmpty(mPersonal)) { + + // TODO: use Contacts' NameSplitter for more reliable first-name extraction + + int end = mPersonal.indexOf(' '); + while (end > 0 && mPersonal.charAt(end - 1) == ',') { + end--; + } + mSimplifiedName = (end < 1) ? mPersonal : mPersonal.substring(0, end); + + } else { + LogUtils.w(LOG_TAG, "Unable to get a simplified name"); + mSimplifiedName = ""; + } + } + return mSimplifiedName; + } + + public static synchronized Address getEmailAddress(String rawAddress) { + if (TextUtils.isEmpty(rawAddress)) { + return null; + } + String name, address; + final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(rawAddress); + if (tokens.length > 0) { + final String tokenizedName = tokens[0].getName(); + name = tokenizedName != null ? Html.fromHtml(tokenizedName.trim()).toString() + : ""; + address = Html.fromHtml(tokens[0].getAddress()).toString(); + } else { + name = ""; + address = rawAddress == null ? + "" : Html.fromHtml(rawAddress).toString(); + } + return new Address(address, name); + } + + public String getAddress() { + return mAddress; + } + + public void setAddress(String address) { + mAddress = REMOVE_OPTIONAL_BRACKET.matcher(address).replaceAll("$1"); + } + + /** + * Get name part as UTF-16 string. No surrounding double quote, and no MIME/base64 encoding. + * + * @return Name part of email address. Returns null if it is omitted. + */ + public String getPersonal() { + return mPersonal; + } + + /** + * Set personal part from UTF-16 string. Optional surrounding double quote will be removed. + * It will be also unquoted and MIME/base64 decoded. + * + * @param personal name part of email address as UTF-16 string. Null is acceptable. + */ + public void setPersonal(String personal) { + mPersonal = decodeAddressPersonal(personal); + } + + /** + * Decodes name from UTF-16 string. Optional surrounding double quote will be removed. + * It will be also unquoted and MIME/base64 decoded. + * + * @param personal name part of email address as UTF-16 string. Null is acceptable. + */ + public static String decodeAddressPersonal(String personal) { + if (personal != null) { + personal = REMOVE_OPTIONAL_DQUOTE.matcher(personal).replaceAll("$1"); + personal = UNQUOTE.matcher(personal).replaceAll("$1"); + personal = DecoderUtil.decodeEncodedWords(personal); + if (personal.length() == 0) { + personal = null; + } + } + return personal; + } + + /** + * This method is used to check that all the addresses that the user + * entered in a list (e.g. To:) are valid, so that none is dropped. + */ + @VisibleForTesting + public static boolean isAllValid(String addressList) { + // This code mimics the parse() method below. + // I don't know how to better avoid the code-duplication. + if (addressList != null && addressList.length() > 0) { + Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressList); + for (int i = 0, length = tokens.length; i < length; ++i) { + Rfc822Token token = tokens[i]; + String address = token.getAddress(); + if (!TextUtils.isEmpty(address) && !isValidAddress(address)) { + return false; + } + } + } + return true; + } + + /** + * Parse a comma-delimited list of addresses in RFC822 format and return an + * array of Address objects. + * + * @param addressList Address list in comma-delimited string. + * @return An array of 0 or more Addresses. + */ + public static Address[] parse(String addressList) { + if (addressList == null || addressList.length() == 0) { + return EMPTY_ADDRESS_ARRAY; + } + Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressList); + ArrayList
addresses = new ArrayList
(); + for (int i = 0, length = tokens.length; i < length; ++i) { + Rfc822Token token = tokens[i]; + String address = token.getAddress(); + if (!TextUtils.isEmpty(address)) { + if (isValidAddress(address)) { + String name = token.getName(); + if (TextUtils.isEmpty(name)) { + name = null; + } + addresses.add(new Address(address, name)); + } + } + } + return addresses.toArray(new Address[addresses.size()]); + } + + /** + * Checks whether a string email address is valid. + * E.g. name@domain.com is valid. + */ + @VisibleForTesting + static boolean isValidAddress(final String address) { + return EMAIL_ADDRESS.matcher(address).find(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Address) { + // It seems that the spec says that the "user" part is case-sensitive, + // while the domain part in case-insesitive. + // So foo@yahoo.com and Foo@yahoo.com are different. + // This may seem non-intuitive from the user POV, so we + // may re-consider it if it creates UI trouble. + // A problem case is "replyAll" sending to both + // a@b.c and to A@b.c, which turn out to be the same on the server. + // Leave unchanged for now (i.e. case-sensitive). + return getAddress().equals(((Address) o).getAddress()); + } + return super.equals(o); + } + + @Override + public int hashCode() { + return getAddress().hashCode(); + } + + /** + * Get human readable address string. + * Do not use this for email header. + * + * @return Human readable address string. Not quoted and not encoded. + */ + @Override + public String toString() { + if (mPersonal != null && !mPersonal.equals(mAddress)) { + if (mPersonal.matches(".*[\\(\\)<>@,;:\\\\\".\\[\\]].*")) { + return ensureQuotedString(mPersonal) + " <" + mAddress + ">"; + } else { + return mPersonal + " <" + mAddress + ">"; + } + } else { + return mAddress; + } + } + + /** + * Ensures that the given string starts and ends with the double quote character. The string is + * not modified in any way except to add the double quote character to start and end if it's not + * already there. + * + * sample -> "sample" + * "sample" -> "sample" + * ""sample"" -> "sample" + * "sample"" -> "sample" + * sa"mp"le -> "sa"mp"le" + * "sa"mp"le" -> "sa"mp"le" + * (empty string) -> "" + * " -> "" + */ + private static String ensureQuotedString(String s) { + if (s == null) { + return null; + } + if (!s.matches("^\".*\"$")) { + return "\"" + s + "\""; + } else { + return s; + } + } + + /** + * Get human readable comma-delimited address string. + * + * @param addresses Address array + * @return Human readable comma-delimited address string. + */ + @VisibleForTesting + public static String toString(Address[] addresses) { + return toString(addresses, ADDRESS_DELIMETER); + } + + /** + * Get human readable address strings joined with the specified separator. + * + * @param addresses Address array + * @param separator Separator + * @return Human readable comma-delimited address string. + */ + public static String toString(Address[] addresses, String separator) { + if (addresses == null || addresses.length == 0) { + return null; + } + if (addresses.length == 1) { + return addresses[0].toString(); + } + StringBuilder sb = new StringBuilder(addresses[0].toString()); + for (int i = 1; i < addresses.length; i++) { + sb.append(separator); + // TODO: investigate why this .trim() is needed. + sb.append(addresses[i].toString().trim()); + } + return sb.toString(); + } + + /** + * Get RFC822/MIME compatible address string. + * + * @return RFC822/MIME compatible address string. + * It may be surrounded by double quote or quoted and MIME/base64 encoded if necessary. + */ + public String toHeader() { + if (mPersonal != null) { + return EncoderUtil.encodeAddressDisplayName(mPersonal) + " <" + mAddress + ">"; + } else { + return mAddress; + } + } + + /** + * Get RFC822/MIME compatible comma-delimited address string. + * + * @param addresses Address array + * @return RFC822/MIME compatible comma-delimited address string. + * it may be surrounded by double quoted or quoted and MIME/base64 encoded if necessary. + */ + public static String toHeader(Address[] addresses) { + if (addresses == null || addresses.length == 0) { + return null; + } + if (addresses.length == 1) { + return addresses[0].toHeader(); + } + StringBuilder sb = new StringBuilder(addresses[0].toHeader()); + for (int i = 1; i < addresses.length; i++) { + // We need space character to be able to fold line. + sb.append(", "); + sb.append(addresses[i].toHeader()); + } + return sb.toString(); + } + + /** + * Get Human friendly address string. + * + * @return the personal part of this Address, or the address part if the + * personal part is not available + */ + @VisibleForTesting + public String toFriendly() { + if (mPersonal != null && mPersonal.length() > 0) { + return mPersonal; + } else { + return mAddress; + } + } + + /** + * Creates a comma-delimited list of addresses in the "friendly" format (see toFriendly() for + * details on the per-address conversion). + * + * @param addresses Array of Address[] values + * @return A comma-delimited string listing all of the addresses supplied. Null if source + * was null or empty. + */ + @VisibleForTesting + public static String toFriendly(Address[] addresses) { + if (addresses == null || addresses.length == 0) { + return null; + } + if (addresses.length == 1) { + return addresses[0].toFriendly(); + } + StringBuilder sb = new StringBuilder(addresses[0].toFriendly()); + for (int i = 1; i < addresses.length; i++) { + sb.append(", "); + sb.append(addresses[i].toFriendly()); + } + return sb.toString(); + } + + /** + * Returns exactly the same result as Address.toString(Address.fromHeader(addressList)). + */ + @VisibleForTesting + public static String fromHeaderToString(String addressList) { + return toString(fromHeader(addressList)); + } + + /** + * Returns exactly the same result as Address.toHeader(Address.parse(addressList)). + */ + @VisibleForTesting + public static String parseToHeader(String addressList) { + return Address.toHeader(Address.parse(addressList)); + } + + /** + * Returns null if the addressList has 0 addresses, otherwise returns the first address. + * The same as Address.fromHeader(addressList)[0] for non-empty list. + * This is an utility method that offers some performance optimization opportunities. + */ + @VisibleForTesting + public static Address firstAddress(String addressList) { + Address[] array = fromHeader(addressList); + return array.length > 0 ? array[0] : null; + } + + /** + * This method exists to convert an address list formatted in a deprecated legacy format to the + * standard RFC822 header format. {@link #fromHeader(String)} is capable of reading the legacy + * format and the RFC822 format. {@link #toHeader()} always produces the RFC822 format. + * + * This implementation is brute-force, and could be replaced with a more efficient version + * if desired. + */ + public static String reformatToHeader(String addressList) { + return toHeader(fromHeader(addressList)); + } + + /** + * @param addressList a CSV of RFC822 addresses or the deprecated legacy string format + * @return array of addresses parsed from addressList + */ + @VisibleForTesting + public static Address[] fromHeader(String addressList) { + if (addressList == null || addressList.length() == 0) { + return EMPTY_ADDRESS_ARRAY; + } + // IF we're CSV, just parse + if ((addressList.indexOf(LIST_DELIMITER_PERSONAL) == -1) && + (addressList.indexOf(LIST_DELIMITER_EMAIL) == -1)) { + return Address.parse(addressList); + } + // Otherwise, do backward-compatible unpack + ArrayList
addresses = new ArrayList
(); + int length = addressList.length(); + int pairStartIndex = 0; + int pairEndIndex; + + /* addressEndIndex is only re-scanned (indexOf()) when a LIST_DELIMITER_PERSONAL + is used, not for every email address; i.e. not for every iteration of the while(). + This reduces the theoretical complexity from quadratic to linear, + and provides some speed-up in practice by removing redundant scans of the string. + */ + int addressEndIndex = addressList.indexOf(LIST_DELIMITER_PERSONAL); + + while (pairStartIndex < length) { + pairEndIndex = addressList.indexOf(LIST_DELIMITER_EMAIL, pairStartIndex); + if (pairEndIndex == -1) { + pairEndIndex = length; + } + Address address; + if (addressEndIndex == -1 || pairEndIndex <= addressEndIndex) { + // in this case the DELIMITER_PERSONAL is in a future pair, + // so don't use personal, and don't update addressEndIndex + address = new Address(addressList.substring(pairStartIndex, pairEndIndex), null); + } else { + address = new Address(addressList.substring(pairStartIndex, addressEndIndex), + addressList.substring(addressEndIndex + 1, pairEndIndex)); + // only update addressEndIndex when we use the LIST_DELIMITER_PERSONAL + addressEndIndex = addressList.indexOf(LIST_DELIMITER_PERSONAL, pairEndIndex + 1); + } + addresses.add(address); + pairStartIndex = pairEndIndex + 1; + } + return addresses.toArray(new Address[addresses.size()]); + } + + public static final Creator
CREATOR = new Creator
() { + @Override + public Address createFromParcel(Parcel parcel) { + return new Address(parcel); + } + + @Override + public Address[] newArray(int size) { + return new Address[size]; + } + }; + + public Address(Parcel in) { + setPersonal(in.readString()); + setAddress(in.readString()); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeString(mPersonal); + out.writeString(mAddress); + } +} diff --git a/java/com/android/voicemailomtp/mail/AuthenticationFailedException.java b/java/com/android/voicemailomtp/mail/AuthenticationFailedException.java new file mode 100644 index 000000000..995d5d348 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/AuthenticationFailedException.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +public class AuthenticationFailedException extends MessagingException { + public static final long serialVersionUID = -1; + + public AuthenticationFailedException(String message) { + super(MessagingException.AUTHENTICATION_FAILED, message); + } + + public AuthenticationFailedException(int exceptionType, String message) { + super(exceptionType, message); + } + + public AuthenticationFailedException(String message, Throwable throwable) { + super(MessagingException.AUTHENTICATION_FAILED, message, throwable); + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/Base64Body.java b/java/com/android/voicemailomtp/mail/Base64Body.java new file mode 100644 index 000000000..6e1deff44 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/Base64Body.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import android.util.Base64; +import android.util.Base64OutputStream; + +import org.apache.commons.io.IOUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class Base64Body implements Body { + private final InputStream mSource; + // Because we consume the input stream, we can only write out once + private boolean mAlreadyWritten; + + public Base64Body(InputStream source) { + mSource = source; + } + + @Override + public InputStream getInputStream() throws MessagingException { + return mSource; + } + + /** + * This method consumes the input stream, so can only be called once + * @param out Stream to write to + * @throws IllegalStateException If called more than once + * @throws IOException + * @throws MessagingException + */ + @Override + public void writeTo(OutputStream out) + throws IllegalStateException, IOException, MessagingException { + if (mAlreadyWritten) { + throw new IllegalStateException("Base64Body can only be written once"); + } + mAlreadyWritten = true; + try { + final Base64OutputStream b64out = new Base64OutputStream(out, Base64.DEFAULT); + IOUtils.copyLarge(mSource, b64out); + } finally { + mSource.close(); + } + } +} diff --git a/java/com/android/voicemailomtp/mail/Body.java b/java/com/android/voicemailomtp/mail/Body.java new file mode 100644 index 000000000..393e1823c --- /dev/null +++ b/java/com/android/voicemailomtp/mail/Body.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public interface Body { + public InputStream getInputStream() throws MessagingException; + public void writeTo(OutputStream out) throws IOException, MessagingException; +} diff --git a/java/com/android/voicemailomtp/mail/BodyPart.java b/java/com/android/voicemailomtp/mail/BodyPart.java new file mode 100644 index 000000000..62390a43e --- /dev/null +++ b/java/com/android/voicemailomtp/mail/BodyPart.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +public abstract class BodyPart implements Part { + protected Multipart mParent; + + public Multipart getParent() { + return mParent; + } +} diff --git a/java/com/android/voicemailomtp/mail/CertificateValidationException.java b/java/com/android/voicemailomtp/mail/CertificateValidationException.java new file mode 100644 index 000000000..8ebe5480b --- /dev/null +++ b/java/com/android/voicemailomtp/mail/CertificateValidationException.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +public class CertificateValidationException extends MessagingException { + public static final long serialVersionUID = -1; + + public CertificateValidationException(String message) { + super(MessagingException.CERTIFICATE_VALIDATION_ERROR, message); + } + + public CertificateValidationException(String message, Throwable throwable) { + super(MessagingException.CERTIFICATE_VALIDATION_ERROR, message, throwable); + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/FetchProfile.java b/java/com/android/voicemailomtp/mail/FetchProfile.java new file mode 100644 index 000000000..d050692cc --- /dev/null +++ b/java/com/android/voicemailomtp/mail/FetchProfile.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import java.util.ArrayList; + +/** + *
+ * A FetchProfile is a list of items that should be downloaded in bulk for a set of messages.
+ * FetchProfile can contain the following objects:
+ *      FetchProfile.Item:      Described below.
+ *      Message:                Indicates that the body of the entire message should be fetched.
+ *                              Synonymous with FetchProfile.Item.BODY.
+ *      Part:                   Indicates that the given Part should be fetched. The provider
+ *                              is expected have previously created the given BodyPart and stored
+ *                              any information it needs to download the content.
+ * 
+ */ +public class FetchProfile extends ArrayList { + /** + * Default items available for pre-fetching. It should be expected that any + * item fetched by using these items could potentially include all of the + * previous items. + */ + public enum Item implements Fetchable { + /** + * Download the flags of the message. + */ + FLAGS, + + /** + * Download the envelope of the message. This should include at minimum + * the size and the following headers: date, subject, from, content-type, to, cc + */ + ENVELOPE, + + /** + * Download the structure of the message. This maps directly to IMAP's BODYSTRUCTURE + * and may map to other providers. + * The provider should, if possible, fill in a properly formatted MIME structure in + * the message without actually downloading any message data. If the provider is not + * capable of this operation it should specifically set the body of the message to null + * so that upper levels can detect that a full body download is needed. + */ + STRUCTURE, + + /** + * A sane portion of the entire message, cut off at a provider determined limit. + * This should generally be around 50kB. + */ + BODY_SANE, + + /** + * The entire message. + */ + BODY, + } + + /** + * @return the first {@link Part} in this collection, or null if it doesn't contain + * {@link Part}. + */ + public Part getFirstPart() { + for (Fetchable o : this) { + if (o instanceof Part) { + return (Part) o; + } + } + return null; + } +} diff --git a/java/com/android/voicemailomtp/mail/Fetchable.java b/java/com/android/voicemailomtp/mail/Fetchable.java new file mode 100644 index 000000000..1d8d0005b --- /dev/null +++ b/java/com/android/voicemailomtp/mail/Fetchable.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +/** + * Interface for classes that can be added to {@link FetchProfile}. + * i.e. {@link Part} and its subclasses, and {@link FetchProfile.Item}. + */ +public interface Fetchable { +} diff --git a/java/com/android/voicemailomtp/mail/FixedLengthInputStream.java b/java/com/android/voicemailomtp/mail/FixedLengthInputStream.java new file mode 100644 index 000000000..65655efd5 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/FixedLengthInputStream.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import java.io.IOException; +import java.io.InputStream; + +/** + * A filtering InputStream that stops allowing reads after the given length has been read. This + * is used to allow a client to read directly from an underlying protocol stream without reading + * past where the protocol handler intended the client to read. + */ +public class FixedLengthInputStream extends InputStream { + private final InputStream mIn; + private final int mLength; + private int mCount; + + public FixedLengthInputStream(InputStream in, int length) { + this.mIn = in; + this.mLength = length; + } + + @Override + public int available() throws IOException { + return mLength - mCount; + } + + @Override + public int read() throws IOException { + if (mCount < mLength) { + mCount++; + return mIn.read(); + } else { + return -1; + } + } + + @Override + public int read(byte[] b, int offset, int length) throws IOException { + if (mCount < mLength) { + int d = mIn.read(b, offset, Math.min(mLength - mCount, length)); + if (d == -1) { + return -1; + } else { + mCount += d; + return d; + } + } else { + return -1; + } + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + public int getLength() { + return mLength; + } + + @Override + public String toString() { + return String.format("FixedLengthInputStream(in=%s, length=%d)", mIn.toString(), mLength); + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/Flag.java b/java/com/android/voicemailomtp/mail/Flag.java new file mode 100644 index 000000000..a9f927099 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/Flag.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +/** + * Flags that can be applied to Messages. + */ +public class Flag { + // If adding new flags: ALL FLAGS MUST BE UPPER CASE. + public static final String DELETED = "deleted"; + public static final String SEEN = "seen"; + public static final String ANSWERED = "answered"; + public static final String FLAGGED = "flagged"; + public static final String DRAFT = "draft"; + public static final String RECENT = "recent"; +} diff --git a/java/com/android/voicemailomtp/mail/MailTransport.java b/java/com/android/voicemailomtp/mail/MailTransport.java new file mode 100644 index 000000000..3bf851fd8 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/MailTransport.java @@ -0,0 +1,344 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import android.content.Context; +import android.net.Network; +import android.support.annotation.VisibleForTesting; +import com.android.voicemailomtp.OmtpEvents; +import com.android.voicemailomtp.imap.ImapHelper; +import com.android.voicemailomtp.mail.store.ImapStore; +import com.android.voicemailomtp.mail.utils.LogUtils; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; + +/** + * Make connection and perform operations on mail server by reading and writing lines. + */ +public class MailTransport { + private static final String TAG = "MailTransport"; + + // TODO protected eventually + /*protected*/ public static final int SOCKET_CONNECT_TIMEOUT = 10000; + /*protected*/ public static final int SOCKET_READ_TIMEOUT = 60000; + + private static final HostnameVerifier HOSTNAME_VERIFIER = + HttpsURLConnection.getDefaultHostnameVerifier(); + + private final Context mContext; + private final ImapHelper mImapHelper; + private final Network mNetwork; + private final String mHost; + private final int mPort; + private Socket mSocket; + private BufferedInputStream mIn; + private BufferedOutputStream mOut; + private final int mFlags; + private SocketCreator mSocketCreator; + private InetSocketAddress mAddress; + + public MailTransport(Context context, ImapHelper imapHelper, Network network, String address, + int port, int flags) { + mContext = context; + mImapHelper = imapHelper; + mNetwork = network; + mHost = address; + mPort = port; + mFlags = flags; + } + + /** + * Returns a new transport, using the current transport as a model. The new transport is + * configured identically, but not opened or connected in any way. + */ + @Override + public MailTransport clone() { + return new MailTransport(mContext, mImapHelper, mNetwork, mHost, mPort, mFlags); + } + + public boolean canTrySslSecurity() { + return (mFlags & ImapStore.FLAG_SSL) != 0; + } + + public boolean canTrustAllCertificates() { + return (mFlags & ImapStore.FLAG_TRUST_ALL) != 0; + } + + /** + * Attempts to open a connection using the Uri supplied for connection parameters. Will attempt + * an SSL connection if indicated. + */ + public void open() throws MessagingException { + LogUtils.d(TAG, "*** IMAP open " + mHost + ":" + String.valueOf(mPort)); + + List socketAddresses = new ArrayList(); + + if (mNetwork == null) { + socketAddresses.add(new InetSocketAddress(mHost, mPort)); + } else { + try { + InetAddress[] inetAddresses = mNetwork.getAllByName(mHost); + if (inetAddresses.length == 0) { + throw new MessagingException(MessagingException.IOERROR, + "Host name " + mHost + "cannot be resolved on designated network"); + } + for (int i = 0; i < inetAddresses.length; i++) { + socketAddresses.add(new InetSocketAddress(inetAddresses[i], mPort)); + } + } catch (IOException ioe) { + LogUtils.d(TAG, ioe.toString()); + mImapHelper.handleEvent(OmtpEvents.DATA_CANNOT_RESOLVE_HOST_ON_NETWORK); + throw new MessagingException(MessagingException.IOERROR, ioe.toString()); + } + } + + boolean success = false; + while (socketAddresses.size() > 0) { + mSocket = createSocket(); + try { + mAddress = socketAddresses.remove(0); + mSocket.connect(mAddress, SOCKET_CONNECT_TIMEOUT); + + if (canTrySslSecurity()) { + /* + SSLSocket cannot be created with a connection timeout, so instead of doing a + direct SSL connection, we connect with a normal connection and upgrade it into + SSL + */ + reopenTls(); + } else { + mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); + mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); + mSocket.setSoTimeout(SOCKET_READ_TIMEOUT); + } + success = true; + return; + } catch (IOException ioe) { + LogUtils.d(TAG, ioe.toString()); + if (socketAddresses.size() == 0) { + // Only throw an error when there are no more sockets to try. + mImapHelper.handleEvent(OmtpEvents.DATA_ALL_SOCKET_CONNECTION_FAILED); + throw new MessagingException(MessagingException.IOERROR, ioe.toString()); + } + } finally { + if (!success) { + try { + mSocket.close(); + mSocket = null; + } catch (IOException ioe) { + throw new MessagingException(MessagingException.IOERROR, ioe.toString()); + } + + } + } + } + } + + // For testing. We need something that can replace the behavior of "new Socket()" + @VisibleForTesting + interface SocketCreator { + + Socket createSocket() throws MessagingException; + } + + @VisibleForTesting + void setSocketCreator(SocketCreator creator) { + mSocketCreator = creator; + } + + protected Socket createSocket() throws MessagingException { + if (mSocketCreator != null) { + return mSocketCreator.createSocket(); + } + + if (mNetwork == null) { + LogUtils.v(TAG, "createSocket: network not specified"); + return new Socket(); + } + + try { + LogUtils.v(TAG, "createSocket: network specified"); + return mNetwork.getSocketFactory().createSocket(); + } catch (IOException ioe) { + LogUtils.d(TAG, ioe.toString()); + throw new MessagingException(MessagingException.IOERROR, ioe.toString()); + } + } + + /** + * Attempts to reopen a normal connection into a TLS connection. + */ + public void reopenTls() throws MessagingException { + try { + LogUtils.d(TAG, "open: converting to TLS socket"); + mSocket = HttpsURLConnection.getDefaultSSLSocketFactory() + .createSocket(mSocket, mAddress.getHostName(), mAddress.getPort(), true); + // After the socket connects to an SSL server, confirm that the hostname is as + // expected + if (!canTrustAllCertificates()) { + verifyHostname(mSocket, mHost); + } + mSocket.setSoTimeout(SOCKET_READ_TIMEOUT); + mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); + mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); + + } catch (SSLException e) { + LogUtils.d(TAG, e.toString()); + throw new CertificateValidationException(e.getMessage(), e); + } catch (IOException ioe) { + LogUtils.d(TAG, ioe.toString()); + throw new MessagingException(MessagingException.IOERROR, ioe.toString()); + } + } + + /** + * Lightweight version of SSLCertificateSocketFactory.verifyHostname, which provides this + * service but is not in the public API. + * + * Verify the hostname of the certificate used by the other end of a + * connected socket. It is harmless to call this method redundantly if the hostname has already + * been verified. + * + *

Wildcard certificates are allowed to verify any matching hostname, + * so "foo.bar.example.com" is verified if the peer has a certificate + * for "*.example.com". + * + * @param socket An SSL socket which has been connected to a server + * @param hostname The expected hostname of the remote server + * @throws IOException if something goes wrong handshaking with the server + * @throws SSLPeerUnverifiedException if the server cannot prove its identity + */ + private void verifyHostname(Socket socket, String hostname) throws IOException { + // The code at the start of OpenSSLSocketImpl.startHandshake() + // ensures that the call is idempotent, so we can safely call it. + SSLSocket ssl = (SSLSocket) socket; + ssl.startHandshake(); + + SSLSession session = ssl.getSession(); + if (session == null) { + mImapHelper.handleEvent(OmtpEvents.DATA_CANNOT_ESTABLISH_SSL_SESSION); + throw new SSLException("Cannot verify SSL socket without session"); + } + // TODO: Instead of reporting the name of the server we think we're connecting to, + // we should be reporting the bad name in the certificate. Unfortunately this is buried + // in the verifier code and is not available in the verifier API, and extracting the + // CN & alts is beyond the scope of this patch. + if (!HOSTNAME_VERIFIER.verify(hostname, session)) { + mImapHelper.handleEvent(OmtpEvents.DATA_SSL_INVALID_HOST_NAME); + throw new SSLPeerUnverifiedException("Certificate hostname not useable for server: " + + session.getPeerPrincipal()); + } + } + + public boolean isOpen() { + return (mIn != null && mOut != null && + mSocket != null && mSocket.isConnected() && !mSocket.isClosed()); + } + + /** + * Close the connection. MUST NOT return any exceptions - must be "best effort" and safe. + */ + public void close() { + try { + mIn.close(); + } catch (Exception e) { + // May fail if the connection is already closed. + } + try { + mOut.close(); + } catch (Exception e) { + // May fail if the connection is already closed. + } + try { + mSocket.close(); + } catch (Exception e) { + // May fail if the connection is already closed. + } + mIn = null; + mOut = null; + mSocket = null; + } + + public String getHost() { + return mHost; + } + + public InputStream getInputStream() { + return mIn; + } + + public OutputStream getOutputStream() { + return mOut; + } + + /** + * Writes a single line to the server using \r\n termination. + */ + public void writeLine(String s, String sensitiveReplacement) throws IOException { + if (sensitiveReplacement != null) { + LogUtils.d(TAG, ">>> " + sensitiveReplacement); + } else { + LogUtils.d(TAG, ">>> " + s); + } + + OutputStream out = getOutputStream(); + out.write(s.getBytes()); + out.write('\r'); + out.write('\n'); + out.flush(); + } + + /** + * Reads a single line from the server, using either \r\n or \n as the delimiter. The + * delimiter char(s) are not included in the result. + */ + public String readLine(boolean loggable) throws IOException { + StringBuffer sb = new StringBuffer(); + InputStream in = getInputStream(); + int d; + while ((d = in.read()) != -1) { + if (((char)d) == '\r') { + continue; + } else if (((char)d) == '\n') { + break; + } else { + sb.append((char)d); + } + } + if (d == -1) { + LogUtils.d(TAG, "End of stream reached while trying to read line."); + } + String ret = sb.toString(); + if (loggable) { + LogUtils.d(TAG, "<<< " + ret); + } + return ret; + } +} diff --git a/java/com/android/voicemailomtp/mail/MeetingInfo.java b/java/com/android/voicemailomtp/mail/MeetingInfo.java new file mode 100644 index 000000000..0505bbf2c --- /dev/null +++ b/java/com/android/voicemailomtp/mail/MeetingInfo.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +public class MeetingInfo { + // Predefined tags; others can be added + public static final String MEETING_DTSTAMP = "DTSTAMP"; + public static final String MEETING_UID = "UID"; + public static final String MEETING_ORGANIZER_EMAIL = "ORGMAIL"; + public static final String MEETING_DTSTART = "DTSTART"; + public static final String MEETING_DTEND = "DTEND"; + public static final String MEETING_TITLE = "TITLE"; + public static final String MEETING_LOCATION = "LOC"; + public static final String MEETING_RESPONSE_REQUESTED = "RESPONSE"; + public static final String MEETING_ALL_DAY = "ALLDAY"; +} diff --git a/java/com/android/voicemailomtp/mail/Message.java b/java/com/android/voicemailomtp/mail/Message.java new file mode 100644 index 000000000..41555690f --- /dev/null +++ b/java/com/android/voicemailomtp/mail/Message.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import android.support.annotation.VisibleForTesting; +import java.util.Date; +import java.util.HashSet; + +public abstract class Message implements Part, Body { + public static final Message[] EMPTY_ARRAY = new Message[0]; + + public static final String RECIPIENT_TYPE_TO = "to"; + public static final String RECIPIENT_TYPE_CC = "cc"; + public static final String RECIPIENT_TYPE_BCC = "bcc"; + public enum RecipientType { + TO, CC, BCC, + } + + protected String mUid; + + private HashSet mFlags = null; + + protected Date mInternalDate; + + public String getUid() { + return mUid; + } + + public void setUid(String uid) { + this.mUid = uid; + } + + public abstract String getSubject() throws MessagingException; + + public abstract void setSubject(String subject) throws MessagingException; + + public Date getInternalDate() { + return mInternalDate; + } + + public void setInternalDate(Date internalDate) { + this.mInternalDate = internalDate; + } + + public abstract Date getReceivedDate() throws MessagingException; + + public abstract Date getSentDate() throws MessagingException; + + public abstract void setSentDate(Date sentDate) throws MessagingException; + + public abstract Address[] getRecipients(String type) throws MessagingException; + + public abstract void setRecipients(String type, Address[] addresses) + throws MessagingException; + + public void setRecipient(String type, Address address) throws MessagingException { + setRecipients(type, new Address[] { + address + }); + } + + public abstract Address[] getFrom() throws MessagingException; + + public abstract void setFrom(Address from) throws MessagingException; + + public abstract Address[] getReplyTo() throws MessagingException; + + public abstract void setReplyTo(Address[] from) throws MessagingException; + + // Always use these instead of getHeader("Message-ID") or setHeader("Message-ID"); + public abstract void setMessageId(String messageId) throws MessagingException; + public abstract String getMessageId() throws MessagingException; + + @Override + public boolean isMimeType(String mimeType) throws MessagingException { + return getContentType().startsWith(mimeType); + } + + private HashSet getFlagSet() { + if (mFlags == null) { + mFlags = new HashSet(); + } + return mFlags; + } + + /* + * TODO Refactor Flags at some point to be able to store user defined flags. + */ + public String[] getFlags() { + return getFlagSet().toArray(new String[] {}); + } + + /** + * Set/clear a flag directly, without involving overrides of {@link #setFlag} in subclasses. + * Only used for testing. + */ + @VisibleForTesting + private final void setFlagDirectlyForTest(String flag, boolean set) throws MessagingException { + if (set) { + getFlagSet().add(flag); + } else { + getFlagSet().remove(flag); + } + } + + public void setFlag(String flag, boolean set) throws MessagingException { + setFlagDirectlyForTest(flag, set); + } + + /** + * This method calls setFlag(String, boolean) + * @param flags + * @param set + */ + public void setFlags(String[] flags, boolean set) throws MessagingException { + for (String flag : flags) { + setFlag(flag, set); + } + } + + public boolean isSet(String flag) { + return getFlagSet().contains(flag); + } + + public abstract void saveChanges() throws MessagingException; + + @Override + public String toString() { + return getClass().getSimpleName() + ':' + mUid; + } +} diff --git a/java/com/android/voicemailomtp/mail/MessageDateComparator.java b/java/com/android/voicemailomtp/mail/MessageDateComparator.java new file mode 100644 index 000000000..37071034a --- /dev/null +++ b/java/com/android/voicemailomtp/mail/MessageDateComparator.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import java.util.Comparator; + +public class MessageDateComparator implements Comparator { + @Override + public int compare(Message o1, Message o2) { + try { + if (o1.getSentDate() == null) { + return 1; + } else if (o2.getSentDate() == null) { + return -1; + } else + return o2.getSentDate().compareTo(o1.getSentDate()); + } catch (Exception e) { + return 0; + } + } +} diff --git a/java/com/android/voicemailomtp/mail/MessagingException.java b/java/com/android/voicemailomtp/mail/MessagingException.java new file mode 100644 index 000000000..28550527f --- /dev/null +++ b/java/com/android/voicemailomtp/mail/MessagingException.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +/** + * This exception is used for most types of failures that occur during server interactions. + * + * Data passed through this exception should be considered non-localized. Any strings should + * either be internal-only (for debugging) or server-generated. + * + * TO DO: Does it make sense to further collapse AuthenticationFailedException and + * CertificateValidationException and any others into this? + */ +public class MessagingException extends Exception { + public static final long serialVersionUID = -1; + + public static final int NO_ERROR = -1; + /** Any exception that does not specify a specific issue */ + public static final int UNSPECIFIED_EXCEPTION = 0; + /** Connection or IO errors */ + public static final int IOERROR = 1; + /** The configuration requested TLS but the server did not support it. */ + public static final int TLS_REQUIRED = 2; + /** Authentication is required but the server did not support it. */ + public static final int AUTH_REQUIRED = 3; + /** General security failures */ + public static final int GENERAL_SECURITY = 4; + /** Authentication failed */ + public static final int AUTHENTICATION_FAILED = 5; + /** Attempt to create duplicate account */ + public static final int DUPLICATE_ACCOUNT = 6; + /** Required security policies reported - advisory only */ + public static final int SECURITY_POLICIES_REQUIRED = 7; + /** Required security policies not supported */ + public static final int SECURITY_POLICIES_UNSUPPORTED = 8; + /** The protocol (or protocol version) isn't supported */ + public static final int PROTOCOL_VERSION_UNSUPPORTED = 9; + /** The server's SSL certificate couldn't be validated */ + public static final int CERTIFICATE_VALIDATION_ERROR = 10; + /** Authentication failed during autodiscover */ + public static final int AUTODISCOVER_AUTHENTICATION_FAILED = 11; + /** Autodiscover completed with a result (non-error) */ + public static final int AUTODISCOVER_AUTHENTICATION_RESULT = 12; + /** Ambiguous failure; server error or bad credentials */ + public static final int AUTHENTICATION_FAILED_OR_SERVER_ERROR = 13; + /** The server refused access */ + public static final int ACCESS_DENIED = 14; + /** The server refused access */ + public static final int ATTACHMENT_NOT_FOUND = 15; + /** A client SSL certificate is required for connections to the server */ + public static final int CLIENT_CERTIFICATE_REQUIRED = 16; + /** The client SSL certificate specified is invalid */ + public static final int CLIENT_CERTIFICATE_ERROR = 17; + /** The server indicates it does not support OAuth authentication */ + public static final int OAUTH_NOT_SUPPORTED = 18; + /** The server indicates it experienced an internal error */ + public static final int SERVER_ERROR = 19; + + protected int mExceptionType; + // Exception type-specific data + protected Object mExceptionData; + + public MessagingException(String message, Throwable throwable) { + this(UNSPECIFIED_EXCEPTION, message, throwable); + } + + public MessagingException(int exceptionType, String message, Throwable throwable) { + super(message, throwable); + mExceptionType = exceptionType; + mExceptionData = null; + } + + /** + * Constructs a MessagingException with an exceptionType and a null message. + * @param exceptionType The exception type to set for this exception. + */ + public MessagingException(int exceptionType) { + this(exceptionType, null, null); + } + + /** + * Constructs a MessagingException with a message. + * @param message the message for this exception + */ + public MessagingException(String message) { + this(UNSPECIFIED_EXCEPTION, message, null); + } + + /** + * Constructs a MessagingException with an exceptionType and a message. + * @param exceptionType The exception type to set for this exception. + */ + public MessagingException(int exceptionType, String message) { + this(exceptionType, message, null); + } + + /** + * Constructs a MessagingException with an exceptionType, a message, and data + * @param exceptionType The exception type to set for this exception. + * @param message the message for the exception (or null) + * @param data exception-type specific data for the exception (or null) + */ + public MessagingException(int exceptionType, String message, Object data) { + super(message); + mExceptionType = exceptionType; + mExceptionData = data; + } + + /** + * Return the exception type. Will be OTHER_EXCEPTION if not explicitly set. + * + * @return Returns the exception type. + */ + public int getExceptionType() { + return mExceptionType; + } + /** + * Return the exception data. Will be null if not explicitly set. + * + * @return Returns the exception data. + */ + public Object getExceptionData() { + return mExceptionData; + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/Multipart.java b/java/com/android/voicemailomtp/mail/Multipart.java new file mode 100644 index 000000000..b45ebab3d --- /dev/null +++ b/java/com/android/voicemailomtp/mail/Multipart.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import java.util.ArrayList; + +public abstract class Multipart implements Body { + protected Part mParent; + + protected ArrayList mParts = new ArrayList(); + + protected String mContentType; + + public void addBodyPart(BodyPart part) throws MessagingException { + mParts.add(part); + } + + public void addBodyPart(BodyPart part, int index) throws MessagingException { + mParts.add(index, part); + } + + public BodyPart getBodyPart(int index) throws MessagingException { + return mParts.get(index); + } + + public String getContentType() throws MessagingException { + return mContentType; + } + + public int getCount() throws MessagingException { + return mParts.size(); + } + + public boolean removeBodyPart(BodyPart part) throws MessagingException { + return mParts.remove(part); + } + + public void removeBodyPart(int index) throws MessagingException { + mParts.remove(index); + } + + public Part getParent() throws MessagingException { + return mParent; + } + + public void setParent(Part parent) throws MessagingException { + this.mParent = parent; + } +} diff --git a/java/com/android/voicemailomtp/mail/PackedString.java b/java/com/android/voicemailomtp/mail/PackedString.java new file mode 100644 index 000000000..585759611 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/PackedString.java @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import java.util.HashMap; +import java.util.Map; + +/** + * A utility class for creating and modifying Strings that are tagged and packed together. + * + * Uses non-printable (control chars) for internal delimiters; Intended for regular displayable + * strings only, so please use base64 or other encoding if you need to hide any binary data here. + * + * Binary compatible with Address.pack() format, which should migrate to use this code. + */ +public class PackedString { + + /** + * Packing format is: + * element : [ value ] or [ value TAG-DELIMITER tag ] + * packed-string : [ element ] [ ELEMENT-DELIMITER [ element ] ]* + */ + private static final char DELIMITER_ELEMENT = '\1'; + private static final char DELIMITER_TAG = '\2'; + + private String mString; + private HashMap mExploded; + private static final HashMap EMPTY_MAP = new HashMap(); + + /** + * Create a packed string using an already-packed string (e.g. from database) + * @param string packed string + */ + public PackedString(String string) { + mString = string; + mExploded = null; + } + + /** + * Get the value referred to by a given tag. If the tag does not exist, return null. + * @param tag identifier of string of interest + * @return returns value, or null if no string is found + */ + public String get(String tag) { + if (mExploded == null) { + mExploded = explode(mString); + } + return mExploded.get(tag); + } + + /** + * Return a map of all of the values referred to by a given tag. This is a shallow + * copy, don't edit the values. + * @return a map of the values in the packed string + */ + public Map unpack() { + if (mExploded == null) { + mExploded = explode(mString); + } + return new HashMap(mExploded); + } + + /** + * Read out all values into a map. + */ + private static HashMap explode(String packed) { + if (packed == null || packed.length() == 0) { + return EMPTY_MAP; + } + HashMap map = new HashMap(); + + int length = packed.length(); + int elementStartIndex = 0; + int elementEndIndex = 0; + int tagEndIndex = packed.indexOf(DELIMITER_TAG); + + while (elementStartIndex < length) { + elementEndIndex = packed.indexOf(DELIMITER_ELEMENT, elementStartIndex); + if (elementEndIndex == -1) { + elementEndIndex = length; + } + String tag; + String value; + if (tagEndIndex == -1 || elementEndIndex <= tagEndIndex) { + // in this case the DELIMITER_PERSONAL is in a future pair (or not found) + // so synthesize a positional tag for the value, and don't update tagEndIndex + value = packed.substring(elementStartIndex, elementEndIndex); + tag = Integer.toString(map.size()); + } else { + value = packed.substring(elementStartIndex, tagEndIndex); + tag = packed.substring(tagEndIndex + 1, elementEndIndex); + // scan forward for next tag, if any + tagEndIndex = packed.indexOf(DELIMITER_TAG, elementEndIndex + 1); + } + map.put(tag, value); + elementStartIndex = elementEndIndex + 1; + } + + return map; + } + + /** + * Builder class for creating PackedString values. Can also be used for editing existing + * PackedString representations. + */ + static public class Builder { + HashMap mMap; + + /** + * Create a builder that's empty (for filling) + */ + public Builder() { + mMap = new HashMap(); + } + + /** + * Create a builder using the values of an existing PackedString (for editing). + */ + public Builder(String packed) { + mMap = explode(packed); + } + + /** + * Add a tagged value + * @param tag identifier of string of interest + * @param value the value to record in this position. null to delete entry. + */ + public void put(String tag, String value) { + if (value == null) { + mMap.remove(tag); + } else { + mMap.put(tag, value); + } + } + + /** + * Get the value referred to by a given tag. If the tag does not exist, return null. + * @param tag identifier of string of interest + * @return returns value, or null if no string is found + */ + public String get(String tag) { + return mMap.get(tag); + } + + /** + * Pack the values and return a single, encoded string + */ + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : mMap.entrySet()) { + if (sb.length() > 0) { + sb.append(DELIMITER_ELEMENT); + } + sb.append(entry.getValue()); + sb.append(DELIMITER_TAG); + sb.append(entry.getKey()); + } + return sb.toString(); + } + } +} diff --git a/java/com/android/voicemailomtp/mail/Part.java b/java/com/android/voicemailomtp/mail/Part.java new file mode 100644 index 000000000..51f8a4c38 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/Part.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import java.io.IOException; +import java.io.OutputStream; + +public interface Part extends Fetchable { + public void addHeader(String name, String value) throws MessagingException; + + public void removeHeader(String name) throws MessagingException; + + public void setHeader(String name, String value) throws MessagingException; + + public Body getBody() throws MessagingException; + + public String getContentType() throws MessagingException; + + public String getDisposition() throws MessagingException; + + public String getContentId() throws MessagingException; + + public String[] getHeader(String name) throws MessagingException; + + public void setExtendedHeader(String name, String value) throws MessagingException; + + public String getExtendedHeader(String name) throws MessagingException; + + public int getSize() throws MessagingException; + + public boolean isMimeType(String mimeType) throws MessagingException; + + public String getMimeType() throws MessagingException; + + public void setBody(Body body) throws MessagingException; + + public void writeTo(OutputStream out) throws IOException, MessagingException; +} diff --git a/java/com/android/voicemailomtp/mail/PeekableInputStream.java b/java/com/android/voicemailomtp/mail/PeekableInputStream.java new file mode 100644 index 000000000..c1181d189 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/PeekableInputStream.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import java.io.IOException; +import java.io.InputStream; + +/** + * A filtering InputStream that allows single byte "peeks" without consuming the byte. The + * client of this stream can call peek() to see the next available byte in the stream + * and a subsequent read will still return the peeked byte. + */ +public class PeekableInputStream extends InputStream { + private final InputStream mIn; + private boolean mPeeked; + private int mPeekedByte; + + public PeekableInputStream(InputStream in) { + this.mIn = in; + } + + @Override + public int read() throws IOException { + if (!mPeeked) { + return mIn.read(); + } else { + mPeeked = false; + return mPeekedByte; + } + } + + public int peek() throws IOException { + if (!mPeeked) { + mPeekedByte = read(); + mPeeked = true; + } + return mPeekedByte; + } + + @Override + public int read(byte[] b, int offset, int length) throws IOException { + if (!mPeeked) { + return mIn.read(b, offset, length); + } else { + b[0] = (byte)mPeekedByte; + mPeeked = false; + int r = mIn.read(b, offset + 1, length - 1); + if (r == -1) { + return 1; + } else { + return r + 1; + } + } + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + @Override + public String toString() { + return String.format("PeekableInputStream(in=%s, peeked=%b, peekedByte=%d)", + mIn.toString(), mPeeked, mPeekedByte); + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/TempDirectory.java b/java/com/android/voicemailomtp/mail/TempDirectory.java new file mode 100644 index 000000000..dfae36026 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/TempDirectory.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail; + +import android.content.Context; + +import java.io.File; + +/** + * TempDirectory caches the directory used for caching file. It is set up during application + * initialization. + */ +public class TempDirectory { + private static File sTempDirectory = null; + + public static void setTempDirectory(Context context) { + sTempDirectory = context.getCacheDir(); + } + + public static File getTempDirectory() { + if (sTempDirectory == null) { + throw new RuntimeException( + "TempDirectory not set. " + + "If in a unit test, call Email.setTempDirectory(context) in setUp()."); + } + return sTempDirectory; + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/internet/BinaryTempFileBody.java b/java/com/android/voicemailomtp/mail/internet/BinaryTempFileBody.java new file mode 100644 index 000000000..52c43de16 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/internet/BinaryTempFileBody.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.internet; + +import com.android.voicemailomtp.mail.Body; +import com.android.voicemailomtp.mail.MessagingException; +import com.android.voicemailomtp.mail.TempDirectory; + +import org.apache.commons.io.IOUtils; + +import android.util.Base64; +import android.util.Base64OutputStream; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * A Body that is backed by a temp file. The Body exposes a getOutputStream method that allows + * the user to write to the temp file. After the write the body is available via getInputStream + * and writeTo one time. After writeTo is called, or the InputStream returned from + * getInputStream is closed the file is deleted and the Body should be considered disposed of. + */ +public class BinaryTempFileBody implements Body { + private File mFile; + + /** + * An alternate way to put data into a BinaryTempFileBody is to simply supply an already- + * created file. Note that this file will be deleted after it is read. + * @param filePath The file containing the data to be stored on disk temporarily + */ + public void setFile(String filePath) { + mFile = new File(filePath); + } + + public OutputStream getOutputStream() throws IOException { + mFile = File.createTempFile("body", null, TempDirectory.getTempDirectory()); + mFile.deleteOnExit(); + return new FileOutputStream(mFile); + } + + @Override + public InputStream getInputStream() throws MessagingException { + try { + return new BinaryTempFileBodyInputStream(new FileInputStream(mFile)); + } + catch (IOException ioe) { + throw new MessagingException("Unable to open body", ioe); + } + } + + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + InputStream in = getInputStream(); + Base64OutputStream base64Out = new Base64OutputStream( + out, Base64.CRLF | Base64.NO_CLOSE); + IOUtils.copy(in, base64Out); + base64Out.close(); + mFile.delete(); + in.close(); + } + + class BinaryTempFileBodyInputStream extends FilterInputStream { + public BinaryTempFileBodyInputStream(InputStream in) { + super(in); + } + + @Override + public void close() throws IOException { + super.close(); + mFile.delete(); + } + } +} diff --git a/java/com/android/voicemailomtp/mail/internet/MimeBodyPart.java b/java/com/android/voicemailomtp/mail/internet/MimeBodyPart.java new file mode 100644 index 000000000..8a9c45cf9 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/internet/MimeBodyPart.java @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.internet; + +import com.android.voicemailomtp.mail.Body; +import com.android.voicemailomtp.mail.BodyPart; +import com.android.voicemailomtp.mail.MessagingException; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.regex.Pattern; + +/** + * TODO this is a close approximation of Message, need to update along with + * Message. + */ +public class MimeBodyPart extends BodyPart { + protected MimeHeader mHeader = new MimeHeader(); + protected MimeHeader mExtendedHeader; + protected Body mBody; + protected int mSize; + + // regex that matches content id surrounded by "<>" optionally. + private static final Pattern REMOVE_OPTIONAL_BRACKETS = Pattern.compile("^]+)>?$"); + // regex that matches end of line. + private static final Pattern END_OF_LINE = Pattern.compile("\r?\n"); + + public MimeBodyPart() throws MessagingException { + this(null); + } + + public MimeBodyPart(Body body) throws MessagingException { + this(body, null); + } + + public MimeBodyPart(Body body, String mimeType) throws MessagingException { + if (mimeType != null) { + setHeader(MimeHeader.HEADER_CONTENT_TYPE, mimeType); + } + setBody(body); + } + + protected String getFirstHeader(String name) throws MessagingException { + return mHeader.getFirstHeader(name); + } + + @Override + public void addHeader(String name, String value) throws MessagingException { + mHeader.addHeader(name, value); + } + + @Override + public void setHeader(String name, String value) throws MessagingException { + mHeader.setHeader(name, value); + } + + @Override + public String[] getHeader(String name) throws MessagingException { + return mHeader.getHeader(name); + } + + @Override + public void removeHeader(String name) throws MessagingException { + mHeader.removeHeader(name); + } + + @Override + public Body getBody() throws MessagingException { + return mBody; + } + + @Override + public void setBody(Body body) throws MessagingException { + this.mBody = body; + if (body instanceof com.android.voicemailomtp.mail.Multipart) { + com.android.voicemailomtp.mail.Multipart multipart = + ((com.android.voicemailomtp.mail.Multipart)body); + multipart.setParent(this); + setHeader(MimeHeader.HEADER_CONTENT_TYPE, multipart.getContentType()); + } + else if (body instanceof TextBody) { + String contentType = String.format("%s;\n charset=utf-8", getMimeType()); + String name = MimeUtility.getHeaderParameter(getContentType(), "name"); + if (name != null) { + contentType += String.format(";\n name=\"%s\"", name); + } + setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType); + setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64"); + } + } + + @Override + public String getContentType() throws MessagingException { + String contentType = getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE); + if (contentType == null) { + return "text/plain"; + } else { + return contentType; + } + } + + @Override + public String getDisposition() throws MessagingException { + String contentDisposition = getFirstHeader(MimeHeader.HEADER_CONTENT_DISPOSITION); + if (contentDisposition == null) { + return null; + } else { + return contentDisposition; + } + } + + @Override + public String getContentId() throws MessagingException { + String contentId = getFirstHeader(MimeHeader.HEADER_CONTENT_ID); + if (contentId == null) { + return null; + } else { + // remove optionally surrounding brackets. + return REMOVE_OPTIONAL_BRACKETS.matcher(contentId).replaceAll("$1"); + } + } + + @Override + public String getMimeType() throws MessagingException { + return MimeUtility.getHeaderParameter(getContentType(), null); + } + + @Override + public boolean isMimeType(String mimeType) throws MessagingException { + return getMimeType().equals(mimeType); + } + + public void setSize(int size) { + this.mSize = size; + } + + @Override + public int getSize() throws MessagingException { + return mSize; + } + + /** + * Set extended header + * + * @param name Extended header name + * @param value header value - flattened by removing CR-NL if any + * remove header if value is null + * @throws MessagingException + */ + @Override + public void setExtendedHeader(String name, String value) throws MessagingException { + if (value == null) { + if (mExtendedHeader != null) { + mExtendedHeader.removeHeader(name); + } + return; + } + if (mExtendedHeader == null) { + mExtendedHeader = new MimeHeader(); + } + mExtendedHeader.setHeader(name, END_OF_LINE.matcher(value).replaceAll("")); + } + + /** + * Get extended header + * + * @param name Extended header name + * @return header value - null if header does not exist + * @throws MessagingException + */ + @Override + public String getExtendedHeader(String name) throws MessagingException { + if (mExtendedHeader == null) { + return null; + } + return mExtendedHeader.getFirstHeader(name); + } + + /** + * Write the MimeMessage out in MIME format. + */ + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); + mHeader.writeTo(out); + writer.write("\r\n"); + writer.flush(); + if (mBody != null) { + mBody.writeTo(out); + } + } +} diff --git a/java/com/android/voicemailomtp/mail/internet/MimeHeader.java b/java/com/android/voicemailomtp/mail/internet/MimeHeader.java new file mode 100644 index 000000000..4b0aea749 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/internet/MimeHeader.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.internet; + +import com.android.voicemailomtp.mail.MessagingException; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.ArrayList; + +public class MimeHeader { + /** + * Application specific header that contains Store specific information about an attachment. + * In IMAP this contains the IMAP BODYSTRUCTURE part id so that the ImapStore can later + * retrieve the attachment at will from the server. + * The info is recorded from this header on LocalStore.appendMessage and is put back + * into the MIME data by LocalStore.fetch. + */ + public static final String HEADER_ANDROID_ATTACHMENT_STORE_DATA = "X-Android-Attachment-StoreData"; + + public static final String HEADER_CONTENT_TYPE = "Content-Type"; + public static final String HEADER_CONTENT_TRANSFER_ENCODING = "Content-Transfer-Encoding"; + public static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition"; + public static final String HEADER_CONTENT_ID = "Content-ID"; + + /** + * Fields that should be omitted when writing the header using writeTo() + */ + private static final String[] WRITE_OMIT_FIELDS = { +// HEADER_ANDROID_ATTACHMENT_DOWNLOADED, +// HEADER_ANDROID_ATTACHMENT_ID, + HEADER_ANDROID_ATTACHMENT_STORE_DATA + }; + + protected final ArrayList mFields = new ArrayList(); + + public void clear() { + mFields.clear(); + } + + public String getFirstHeader(String name) throws MessagingException { + String[] header = getHeader(name); + if (header == null) { + return null; + } + return header[0]; + } + + public void addHeader(String name, String value) throws MessagingException { + mFields.add(new Field(name, value)); + } + + public void setHeader(String name, String value) throws MessagingException { + if (name == null || value == null) { + return; + } + removeHeader(name); + addHeader(name, value); + } + + public String[] getHeader(String name) throws MessagingException { + ArrayList values = new ArrayList(); + for (Field field : mFields) { + if (field.name.equalsIgnoreCase(name)) { + values.add(field.value); + } + } + if (values.size() == 0) { + return null; + } + return values.toArray(new String[] {}); + } + + public void removeHeader(String name) throws MessagingException { + ArrayList removeFields = new ArrayList(); + for (Field field : mFields) { + if (field.name.equalsIgnoreCase(name)) { + removeFields.add(field); + } + } + mFields.removeAll(removeFields); + } + + /** + * Write header into String + * + * @return CR-NL separated header string except the headers in writeOmitFields + * null if header is empty + */ + public String writeToString() { + if (mFields.size() == 0) { + return null; + } + StringBuilder builder = new StringBuilder(); + for (Field field : mFields) { + if (!arrayContains(WRITE_OMIT_FIELDS, field.name)) { + builder.append(field.name + ": " + field.value + "\r\n"); + } + } + return builder.toString(); + } + + public void writeTo(OutputStream out) throws IOException, MessagingException { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); + for (Field field : mFields) { + if (!arrayContains(WRITE_OMIT_FIELDS, field.name)) { + writer.write(field.name + ": " + field.value + "\r\n"); + } + } + writer.flush(); + } + + private static class Field { + final String name; + final String value; + + public Field(String name, String value) { + this.name = name; + this.value = value; + } + + @Override + public String toString() { + return name + "=" + value; + } + } + + @Override + public String toString() { + return (mFields == null) ? null : mFields.toString(); + } + + public final static boolean arrayContains(Object[] a, Object o) { + int index = arrayIndex(a, o); + return (index >= 0); + } + + public final static int arrayIndex(Object[] a, Object o) { + for (int i = 0, count = a.length; i < count; i++) { + if (a[i].equals(o)) { + return i; + } + } + return -1; + } +} diff --git a/java/com/android/voicemailomtp/mail/internet/MimeMessage.java b/java/com/android/voicemailomtp/mail/internet/MimeMessage.java new file mode 100644 index 000000000..a11cd6d83 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/internet/MimeMessage.java @@ -0,0 +1,675 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.internet; + +import com.android.voicemailomtp.mail.Address; +import com.android.voicemailomtp.mail.Body; +import com.android.voicemailomtp.mail.BodyPart; +import com.android.voicemailomtp.mail.Message; +import com.android.voicemailomtp.mail.MessagingException; +import com.android.voicemailomtp.mail.Multipart; +import com.android.voicemailomtp.mail.Part; +import com.android.voicemailomtp.mail.utils.LogUtils; + +import org.apache.james.mime4j.BodyDescriptor; +import org.apache.james.mime4j.ContentHandler; +import org.apache.james.mime4j.EOLConvertingInputStream; +import org.apache.james.mime4j.MimeStreamParser; +import org.apache.james.mime4j.field.DateTimeField; +import org.apache.james.mime4j.field.Field; + +import android.text.TextUtils; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.Stack; +import java.util.regex.Pattern; + +/** + * An implementation of Message that stores all of its metadata in RFC 822 and + * RFC 2045 style headers. + * + * NOTE: Automatic generation of a local message-id is becoming unwieldy and should be removed. + * It would be better to simply do it explicitly on local creation of new outgoing messages. + */ +public class MimeMessage extends Message { + private MimeHeader mHeader; + private MimeHeader mExtendedHeader; + + // NOTE: The fields here are transcribed out of headers, and values stored here will supersede + // the values found in the headers. Use caution to prevent any out-of-phase errors. In + // particular, any adds/changes/deletes here must be echoed by changes in the parse() function. + private Address[] mFrom; + private Address[] mTo; + private Address[] mCc; + private Address[] mBcc; + private Address[] mReplyTo; + private Date mSentDate; + private Body mBody; + protected int mSize; + private boolean mInhibitLocalMessageId = false; + private boolean mComplete = true; + + // Shared random source for generating local message-id values + private static final java.util.Random sRandom = new java.util.Random(); + + // In MIME, en_US-like date format should be used. In other words "MMM" should be encoded to + // "Jan", not the other localized format like "Ene" (meaning January in locale es). + // This conversion is used when generating outgoing MIME messages. Incoming MIME date + // headers are parsed by org.apache.james.mime4j.field.DateTimeField which does not have any + // localization code. + private static final SimpleDateFormat DATE_FORMAT = + new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US); + + // regex that matches content id surrounded by "<>" optionally. + private static final Pattern REMOVE_OPTIONAL_BRACKETS = Pattern.compile("^]+)>?$"); + // regex that matches end of line. + private static final Pattern END_OF_LINE = Pattern.compile("\r?\n"); + + public MimeMessage() { + mHeader = null; + } + + /** + * Generate a local message id. This is only used when none has been assigned, and is + * installed lazily. Any remote (typically server-assigned) message id takes precedence. + * @return a long, locally-generated message-ID value + */ + private static String generateMessageId() { + final StringBuilder sb = new StringBuilder(); + sb.append("<"); + for (int i = 0; i < 24; i++) { + // We'll use a 5-bit range (0..31) + final int value = sRandom.nextInt() & 31; + final char c = "0123456789abcdefghijklmnopqrstuv".charAt(value); + sb.append(c); + } + sb.append("."); + sb.append(Long.toString(System.currentTimeMillis())); + sb.append("@email.android.com>"); + return sb.toString(); + } + + /** + * Parse the given InputStream using Apache Mime4J to build a MimeMessage. + * + * @param in InputStream providing message content + * @throws IOException + * @throws MessagingException + */ + public MimeMessage(InputStream in) throws IOException, MessagingException { + parse(in); + } + + private MimeStreamParser init() { + // Before parsing the input stream, clear all local fields that may be superceded by + // the new incoming message. + getMimeHeaders().clear(); + mInhibitLocalMessageId = true; + mFrom = null; + mTo = null; + mCc = null; + mBcc = null; + mReplyTo = null; + mSentDate = null; + mBody = null; + + final MimeStreamParser parser = new MimeStreamParser(); + parser.setContentHandler(new MimeMessageBuilder()); + return parser; + } + + protected void parse(InputStream in) throws IOException, MessagingException { + final MimeStreamParser parser = init(); + parser.parse(new EOLConvertingInputStream(in)); + mComplete = !parser.getPrematureEof(); + } + + public void parse(InputStream in, EOLConvertingInputStream.Callback callback) + throws IOException, MessagingException { + final MimeStreamParser parser = init(); + parser.parse(new EOLConvertingInputStream(in, getSize(), callback)); + mComplete = !parser.getPrematureEof(); + } + + /** + * Return the internal mHeader value, with very lazy initialization. + * The goal is to save memory by not creating the headers until needed. + */ + private MimeHeader getMimeHeaders() { + if (mHeader == null) { + mHeader = new MimeHeader(); + } + return mHeader; + } + + @Override + public Date getReceivedDate() throws MessagingException { + return null; + } + + @Override + public Date getSentDate() throws MessagingException { + if (mSentDate == null) { + try { + DateTimeField field = (DateTimeField)Field.parse("Date: " + + MimeUtility.unfoldAndDecode(getFirstHeader("Date"))); + mSentDate = field.getDate(); + // TODO: We should make it more clear what exceptions can be thrown here, + // and whether they reflect a normal or error condition. + } catch (Exception e) { + LogUtils.v(LogUtils.TAG, "Message missing Date header"); + } + } + if (mSentDate == null) { + // If we still don't have a date, fall back to "Delivery-date" + try { + DateTimeField field = (DateTimeField)Field.parse("Date: " + + MimeUtility.unfoldAndDecode(getFirstHeader("Delivery-date"))); + mSentDate = field.getDate(); + // TODO: We should make it more clear what exceptions can be thrown here, + // and whether they reflect a normal or error condition. + } catch (Exception e) { + LogUtils.v(LogUtils.TAG, "Message also missing Delivery-Date header"); + } + } + return mSentDate; + } + + @Override + public void setSentDate(Date sentDate) throws MessagingException { + setHeader("Date", DATE_FORMAT.format(sentDate)); + this.mSentDate = sentDate; + } + + @Override + public String getContentType() throws MessagingException { + final String contentType = getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE); + if (contentType == null) { + return "text/plain"; + } else { + return contentType; + } + } + + @Override + public String getDisposition() throws MessagingException { + return getFirstHeader(MimeHeader.HEADER_CONTENT_DISPOSITION); + } + + @Override + public String getContentId() throws MessagingException { + final String contentId = getFirstHeader(MimeHeader.HEADER_CONTENT_ID); + if (contentId == null) { + return null; + } else { + // remove optionally surrounding brackets. + return REMOVE_OPTIONAL_BRACKETS.matcher(contentId).replaceAll("$1"); + } + } + + public boolean isComplete() { + return mComplete; + } + + @Override + public String getMimeType() throws MessagingException { + return MimeUtility.getHeaderParameter(getContentType(), null); + } + + @Override + public int getSize() throws MessagingException { + return mSize; + } + + /** + * Returns a list of the given recipient type from this message. If no addresses are + * found the method returns an empty array. + */ + @Override + public Address[] getRecipients(String type) throws MessagingException { + if (type == RECIPIENT_TYPE_TO) { + if (mTo == null) { + mTo = Address.parse(MimeUtility.unfold(getFirstHeader("To"))); + } + return mTo; + } else if (type == RECIPIENT_TYPE_CC) { + if (mCc == null) { + mCc = Address.parse(MimeUtility.unfold(getFirstHeader("CC"))); + } + return mCc; + } else if (type == RECIPIENT_TYPE_BCC) { + if (mBcc == null) { + mBcc = Address.parse(MimeUtility.unfold(getFirstHeader("BCC"))); + } + return mBcc; + } else { + throw new MessagingException("Unrecognized recipient type."); + } + } + + @Override + public void setRecipients(String type, Address[] addresses) throws MessagingException { + final int TO_LENGTH = 4; // "To: " + final int CC_LENGTH = 4; // "Cc: " + final int BCC_LENGTH = 5; // "Bcc: " + if (type == RECIPIENT_TYPE_TO) { + if (addresses == null || addresses.length == 0) { + removeHeader("To"); + this.mTo = null; + } else { + setHeader("To", MimeUtility.fold(Address.toHeader(addresses), TO_LENGTH)); + this.mTo = addresses; + } + } else if (type == RECIPIENT_TYPE_CC) { + if (addresses == null || addresses.length == 0) { + removeHeader("CC"); + this.mCc = null; + } else { + setHeader("CC", MimeUtility.fold(Address.toHeader(addresses), CC_LENGTH)); + this.mCc = addresses; + } + } else if (type == RECIPIENT_TYPE_BCC) { + if (addresses == null || addresses.length == 0) { + removeHeader("BCC"); + this.mBcc = null; + } else { + setHeader("BCC", MimeUtility.fold(Address.toHeader(addresses), BCC_LENGTH)); + this.mBcc = addresses; + } + } else { + throw new MessagingException("Unrecognized recipient type."); + } + } + + /** + * Returns the unfolded, decoded value of the Subject header. + */ + @Override + public String getSubject() throws MessagingException { + return MimeUtility.unfoldAndDecode(getFirstHeader("Subject")); + } + + @Override + public void setSubject(String subject) throws MessagingException { + final int HEADER_NAME_LENGTH = 9; // "Subject: " + setHeader("Subject", MimeUtility.foldAndEncode2(subject, HEADER_NAME_LENGTH)); + } + + @Override + public Address[] getFrom() throws MessagingException { + if (mFrom == null) { + String list = MimeUtility.unfold(getFirstHeader("From")); + if (list == null || list.length() == 0) { + list = MimeUtility.unfold(getFirstHeader("Sender")); + } + mFrom = Address.parse(list); + } + return mFrom; + } + + @Override + public void setFrom(Address from) throws MessagingException { + final int FROM_LENGTH = 6; // "From: " + if (from != null) { + setHeader("From", MimeUtility.fold(from.toHeader(), FROM_LENGTH)); + this.mFrom = new Address[] { + from + }; + } else { + this.mFrom = null; + } + } + + @Override + public Address[] getReplyTo() throws MessagingException { + if (mReplyTo == null) { + mReplyTo = Address.parse(MimeUtility.unfold(getFirstHeader("Reply-to"))); + } + return mReplyTo; + } + + @Override + public void setReplyTo(Address[] replyTo) throws MessagingException { + final int REPLY_TO_LENGTH = 10; // "Reply-to: " + if (replyTo == null || replyTo.length == 0) { + removeHeader("Reply-to"); + mReplyTo = null; + } else { + setHeader("Reply-to", MimeUtility.fold(Address.toHeader(replyTo), REPLY_TO_LENGTH)); + mReplyTo = replyTo; + } + } + + /** + * Set the mime "Message-ID" header + * @param messageId the new Message-ID value + * @throws MessagingException + */ + @Override + public void setMessageId(String messageId) throws MessagingException { + setHeader("Message-ID", messageId); + } + + /** + * Get the mime "Message-ID" header. This value will be preloaded with a locally-generated + * random ID, if the value has not previously been set. Local generation can be inhibited/ + * overridden by explicitly clearing the headers, removing the message-id header, etc. + * @return the Message-ID header string, or null if explicitly has been set to null + */ + @Override + public String getMessageId() throws MessagingException { + String messageId = getFirstHeader("Message-ID"); + if (messageId == null && !mInhibitLocalMessageId) { + messageId = generateMessageId(); + setMessageId(messageId); + } + return messageId; + } + + @Override + public void saveChanges() throws MessagingException { + throw new MessagingException("saveChanges not yet implemented"); + } + + @Override + public Body getBody() throws MessagingException { + return mBody; + } + + @Override + public void setBody(Body body) throws MessagingException { + this.mBody = body; + if (body instanceof Multipart) { + final Multipart multipart = ((Multipart)body); + multipart.setParent(this); + setHeader(MimeHeader.HEADER_CONTENT_TYPE, multipart.getContentType()); + setHeader("MIME-Version", "1.0"); + } + else if (body instanceof TextBody) { + setHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\n charset=utf-8", + getMimeType())); + setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64"); + } + } + + protected String getFirstHeader(String name) throws MessagingException { + return getMimeHeaders().getFirstHeader(name); + } + + @Override + public void addHeader(String name, String value) throws MessagingException { + getMimeHeaders().addHeader(name, value); + } + + @Override + public void setHeader(String name, String value) throws MessagingException { + getMimeHeaders().setHeader(name, value); + } + + @Override + public String[] getHeader(String name) throws MessagingException { + return getMimeHeaders().getHeader(name); + } + + @Override + public void removeHeader(String name) throws MessagingException { + getMimeHeaders().removeHeader(name); + if ("Message-ID".equalsIgnoreCase(name)) { + mInhibitLocalMessageId = true; + } + } + + /** + * Set extended header + * + * @param name Extended header name + * @param value header value - flattened by removing CR-NL if any + * remove header if value is null + * @throws MessagingException + */ + @Override + public void setExtendedHeader(String name, String value) throws MessagingException { + if (value == null) { + if (mExtendedHeader != null) { + mExtendedHeader.removeHeader(name); + } + return; + } + if (mExtendedHeader == null) { + mExtendedHeader = new MimeHeader(); + } + mExtendedHeader.setHeader(name, END_OF_LINE.matcher(value).replaceAll("")); + } + + /** + * Get extended header + * + * @param name Extended header name + * @return header value - null if header does not exist + * @throws MessagingException + */ + @Override + public String getExtendedHeader(String name) throws MessagingException { + if (mExtendedHeader == null) { + return null; + } + return mExtendedHeader.getFirstHeader(name); + } + + /** + * Set entire extended headers from String + * + * @param headers Extended header and its value - "CR-NL-separated pairs + * if null or empty, remove entire extended headers + * @throws MessagingException + */ + public void setExtendedHeaders(String headers) throws MessagingException { + if (TextUtils.isEmpty(headers)) { + mExtendedHeader = null; + } else { + mExtendedHeader = new MimeHeader(); + for (final String header : END_OF_LINE.split(headers)) { + final String[] tokens = header.split(":", 2); + if (tokens.length != 2) { + throw new MessagingException("Illegal extended headers: " + headers); + } + mExtendedHeader.setHeader(tokens[0].trim(), tokens[1].trim()); + } + } + } + + /** + * Get entire extended headers as String + * + * @return "CR-NL-separated extended headers - null if extended header does not exist + */ + public String getExtendedHeaders() { + if (mExtendedHeader != null) { + return mExtendedHeader.writeToString(); + } + return null; + } + + /** + * Write message header and body to output stream + * + * @param out Output steam to write message header and body. + */ + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + final BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); + // Force creation of local message-id + getMessageId(); + getMimeHeaders().writeTo(out); + // mExtendedHeader will not be write out to external output stream, + // because it is intended to internal use. + writer.write("\r\n"); + writer.flush(); + if (mBody != null) { + mBody.writeTo(out); + } + } + + @Override + public InputStream getInputStream() throws MessagingException { + return null; + } + + class MimeMessageBuilder implements ContentHandler { + private final Stack stack = new Stack(); + + public MimeMessageBuilder() { + } + + private void expect(Class c) { + if (!c.isInstance(stack.peek())) { + throw new IllegalStateException("Internal stack error: " + "Expected '" + + c.getName() + "' found '" + stack.peek().getClass().getName() + "'"); + } + } + + @Override + public void startMessage() { + if (stack.isEmpty()) { + stack.push(MimeMessage.this); + } else { + expect(Part.class); + try { + final MimeMessage m = new MimeMessage(); + ((Part)stack.peek()).setBody(m); + stack.push(m); + } catch (MessagingException me) { + throw new Error(me); + } + } + } + + @Override + public void endMessage() { + expect(MimeMessage.class); + stack.pop(); + } + + @Override + public void startHeader() { + expect(Part.class); + } + + @Override + public void field(String fieldData) { + expect(Part.class); + try { + final String[] tokens = fieldData.split(":", 2); + ((Part)stack.peek()).addHeader(tokens[0], tokens[1].trim()); + } catch (MessagingException me) { + throw new Error(me); + } + } + + @Override + public void endHeader() { + expect(Part.class); + } + + @Override + public void startMultipart(BodyDescriptor bd) { + expect(Part.class); + + final Part e = (Part)stack.peek(); + try { + final MimeMultipart multiPart = new MimeMultipart(e.getContentType()); + e.setBody(multiPart); + stack.push(multiPart); + } catch (MessagingException me) { + throw new Error(me); + } + } + + @Override + public void body(BodyDescriptor bd, InputStream in) throws IOException { + expect(Part.class); + final Body body = MimeUtility.decodeBody(in, bd.getTransferEncoding()); + try { + ((Part)stack.peek()).setBody(body); + } catch (MessagingException me) { + throw new Error(me); + } + } + + @Override + public void endMultipart() { + stack.pop(); + } + + @Override + public void startBodyPart() { + expect(MimeMultipart.class); + + try { + final MimeBodyPart bodyPart = new MimeBodyPart(); + ((MimeMultipart)stack.peek()).addBodyPart(bodyPart); + stack.push(bodyPart); + } catch (MessagingException me) { + throw new Error(me); + } + } + + @Override + public void endBodyPart() { + expect(BodyPart.class); + stack.pop(); + } + + @Override + public void epilogue(InputStream is) throws IOException { + expect(MimeMultipart.class); + final StringBuilder sb = new StringBuilder(); + int b; + while ((b = is.read()) != -1) { + sb.append((char)b); + } + // TODO: why is this commented out? + // ((Multipart) stack.peek()).setEpilogue(sb.toString()); + } + + @Override + public void preamble(InputStream is) throws IOException { + expect(MimeMultipart.class); + final StringBuilder sb = new StringBuilder(); + int b; + while ((b = is.read()) != -1) { + sb.append((char)b); + } + try { + ((MimeMultipart)stack.peek()).setPreamble(sb.toString()); + } catch (MessagingException me) { + throw new Error(me); + } + } + + @Override + public void raw(InputStream is) throws IOException { + throw new UnsupportedOperationException("Not supported"); + } + } +} diff --git a/java/com/android/voicemailomtp/mail/internet/MimeMultipart.java b/java/com/android/voicemailomtp/mail/internet/MimeMultipart.java new file mode 100644 index 000000000..111924336 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/internet/MimeMultipart.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.internet; + +import com.android.voicemailomtp.mail.BodyPart; +import com.android.voicemailomtp.mail.MessagingException; +import com.android.voicemailomtp.mail.Multipart; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; + +public class MimeMultipart extends Multipart { + protected String mPreamble; + + protected String mContentType; + + protected String mBoundary; + + protected String mSubType; + + public MimeMultipart() throws MessagingException { + mBoundary = generateBoundary(); + setSubType("mixed"); + } + + public MimeMultipart(String contentType) throws MessagingException { + this.mContentType = contentType; + try { + mSubType = MimeUtility.getHeaderParameter(contentType, null).split("/")[1]; + mBoundary = MimeUtility.getHeaderParameter(contentType, "boundary"); + if (mBoundary == null) { + throw new MessagingException("MultiPart does not contain boundary: " + contentType); + } + } catch (Exception e) { + throw new MessagingException( + "Invalid MultiPart Content-Type; must contain subtype and boundary. (" + + contentType + ")", e); + } + } + + public String generateBoundary() { + StringBuffer sb = new StringBuffer(); + sb.append("----"); + for (int i = 0; i < 30; i++) { + sb.append(Integer.toString((int)(Math.random() * 35), 36)); + } + return sb.toString().toUpperCase(); + } + + public String getPreamble() throws MessagingException { + return mPreamble; + } + + public void setPreamble(String preamble) throws MessagingException { + this.mPreamble = preamble; + } + + @Override + public String getContentType() throws MessagingException { + return mContentType; + } + + public void setSubType(String subType) throws MessagingException { + this.mSubType = subType; + mContentType = String.format("multipart/%s; boundary=\"%s\"", subType, mBoundary); + } + + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); + + if (mPreamble != null) { + writer.write(mPreamble + "\r\n"); + } + + for (int i = 0, count = mParts.size(); i < count; i++) { + BodyPart bodyPart = mParts.get(i); + writer.write("--" + mBoundary + "\r\n"); + writer.flush(); + bodyPart.writeTo(out); + writer.write("\r\n"); + } + + writer.write("--" + mBoundary + "--\r\n"); + writer.flush(); + } + + @Override + public InputStream getInputStream() throws MessagingException { + return null; + } + + public String getSubTypeForTest() { + return mSubType; + } +} diff --git a/java/com/android/voicemailomtp/mail/internet/MimeUtility.java b/java/com/android/voicemailomtp/mail/internet/MimeUtility.java new file mode 100644 index 000000000..4d310b0f5 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/internet/MimeUtility.java @@ -0,0 +1,416 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.internet; + +import android.text.TextUtils; +import android.util.Base64; +import android.util.Base64DataException; +import android.util.Base64InputStream; + +import com.android.voicemailomtp.mail.Body; +import com.android.voicemailomtp.mail.BodyPart; +import com.android.voicemailomtp.mail.Message; +import com.android.voicemailomtp.mail.MessagingException; +import com.android.voicemailomtp.mail.Multipart; +import com.android.voicemailomtp.mail.Part; +import com.android.voicemailomtp.VvmLog; + +import org.apache.commons.io.IOUtils; +import org.apache.james.mime4j.codec.EncoderUtil; +import org.apache.james.mime4j.decoder.DecoderUtil; +import org.apache.james.mime4j.decoder.QuotedPrintableInputStream; +import org.apache.james.mime4j.util.CharsetUtil; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class MimeUtility { + private static final String LOG_TAG = "Email"; + + public static final String MIME_TYPE_RFC822 = "message/rfc822"; + private final static Pattern PATTERN_CR_OR_LF = Pattern.compile("\r|\n"); + + /** + * Replace sequences of CRLF+WSP with WSP. Tries to preserve original string + * object whenever possible. + */ + public static String unfold(String s) { + if (s == null) { + return null; + } + Matcher patternMatcher = PATTERN_CR_OR_LF.matcher(s); + if (patternMatcher.find()) { + patternMatcher.reset(); + s = patternMatcher.replaceAll(""); + } + return s; + } + + public static String decode(String s) { + if (s == null) { + return null; + } + return DecoderUtil.decodeEncodedWords(s); + } + + public static String unfoldAndDecode(String s) { + return decode(unfold(s)); + } + + // TODO implement proper foldAndEncode + // NOTE: When this really works, we *must* remove all calls to foldAndEncode2() to prevent + // duplication of encoding. + public static String foldAndEncode(String s) { + return s; + } + + /** + * INTERIM version of foldAndEncode that will be used only by Subject: headers. + * This is safer than implementing foldAndEncode() (see above) and risking unknown damage + * to other headers. + * + * TODO: Copy this code to foldAndEncode(), get rid of this function, confirm all working OK. + * + * @param s original string to encode and fold + * @param usedCharacters number of characters already used up by header name + + * @return the String ready to be transmitted + */ + public static String foldAndEncode2(String s, int usedCharacters) { + // james.mime4j.codec.EncoderUtil.java + // encode: encodeIfNecessary(text, usage, numUsedInHeaderName) + // Usage.TEXT_TOKENlooks like the right thing for subjects + // use WORD_ENTITY for address/names + + String encoded = EncoderUtil.encodeIfNecessary(s, EncoderUtil.Usage.TEXT_TOKEN, + usedCharacters); + + return fold(encoded, usedCharacters); + } + + /** + * INTERIM: From newer version of org.apache.james (but we don't want to import + * the entire MimeUtil class). + * + * Splits the specified string into a multiple-line representation with + * lines no longer than 76 characters (because the line might contain + * encoded words; see RFC + * 2047 section 2). If the string contains non-whitespace sequences + * longer than 76 characters a line break is inserted at the whitespace + * character following the sequence resulting in a line longer than 76 + * characters. + * + * @param s + * string to split. + * @param usedCharacters + * number of characters already used up. Usually the number of + * characters for header field name plus colon and one space. + * @return a multiple-line representation of the given string. + */ + public static String fold(String s, int usedCharacters) { + final int maxCharacters = 76; + + final int length = s.length(); + if (usedCharacters + length <= maxCharacters) + return s; + + StringBuilder sb = new StringBuilder(); + + int lastLineBreak = -usedCharacters; + int wspIdx = indexOfWsp(s, 0); + while (true) { + if (wspIdx == length) { + sb.append(s.substring(Math.max(0, lastLineBreak))); + return sb.toString(); + } + + int nextWspIdx = indexOfWsp(s, wspIdx + 1); + + if (nextWspIdx - lastLineBreak > maxCharacters) { + sb.append(s.substring(Math.max(0, lastLineBreak), wspIdx)); + sb.append("\r\n"); + lastLineBreak = wspIdx; + } + + wspIdx = nextWspIdx; + } + } + + /** + * INTERIM: From newer version of org.apache.james (but we don't want to import + * the entire MimeUtil class). + * + * Search for whitespace. + */ + private static int indexOfWsp(String s, int fromIndex) { + final int len = s.length(); + for (int index = fromIndex; index < len; index++) { + char c = s.charAt(index); + if (c == ' ' || c == '\t') + return index; + } + return len; + } + + /** + * Returns the named parameter of a header field. If name is null the first + * parameter is returned, or if there are no additional parameters in the + * field the entire field is returned. Otherwise the named parameter is + * searched for in a case insensitive fashion and returned. If the parameter + * cannot be found the method returns null. + * + * TODO: quite inefficient with the inner trimming & splitting. + * TODO: Also has a latent bug: uses "startsWith" to match the name, which can false-positive. + * TODO: The doc says that for a null name you get the first param, but you get the header. + * Should probably just fix the doc, but if other code assumes that behavior, fix the code. + * TODO: Need to decode %-escaped strings, as in: filename="ab%22d". + * ('+' -> ' ' conversion too? check RFC) + * + * @param header + * @param name + * @return the entire header (if name=null), the found parameter, or null + */ + public static String getHeaderParameter(String header, String name) { + if (header == null) { + return null; + } + String[] parts = unfold(header).split(";"); + if (name == null) { + return parts[0].trim(); + } + String lowerCaseName = name.toLowerCase(); + for (String part : parts) { + if (part.trim().toLowerCase().startsWith(lowerCaseName)) { + String[] parameterParts = part.split("=", 2); + if (parameterParts.length < 2) { + return null; + } + String parameter = parameterParts[1].trim(); + if (parameter.startsWith("\"") && parameter.endsWith("\"")) { + return parameter.substring(1, parameter.length() - 1); + } else { + return parameter; + } + } + } + return null; + } + + /** + * Reads the Part's body and returns a String based on any charset conversion that needed + * to be done. + * @param part The part containing a body + * @return a String containing the converted text in the body, or null if there was no text + * or an error during conversion. + */ + public static String getTextFromPart(Part part) { + try { + if (part != null && part.getBody() != null) { + InputStream in = part.getBody().getInputStream(); + String mimeType = part.getMimeType(); + if (mimeType != null && MimeUtility.mimeTypeMatches(mimeType, "text/*")) { + /* + * Now we read the part into a buffer for further processing. Because + * the stream is now wrapped we'll remove any transfer encoding at this point. + */ + ByteArrayOutputStream out = new ByteArrayOutputStream(); + IOUtils.copy(in, out); + in.close(); + in = null; // we want all of our memory back, and close might not release + + /* + * We've got a text part, so let's see if it needs to be processed further. + */ + String charset = getHeaderParameter(part.getContentType(), "charset"); + if (charset != null) { + /* + * See if there is conversion from the MIME charset to the Java one. + */ + charset = CharsetUtil.toJavaCharset(charset); + } + /* + * No encoding, so use us-ascii, which is the standard. + */ + if (charset == null) { + charset = "ASCII"; + } + /* + * Convert and return as new String + */ + String result = out.toString(charset); + out.close(); + return result; + } + } + + } + catch (OutOfMemoryError oom) { + /* + * If we are not able to process the body there's nothing we can do about it. Return + * null and let the upper layers handle the missing content. + */ + VvmLog.e(LOG_TAG, "Unable to getTextFromPart " + oom.toString()); + } + catch (Exception e) { + /* + * If we are not able to process the body there's nothing we can do about it. Return + * null and let the upper layers handle the missing content. + */ + VvmLog.e(LOG_TAG, "Unable to getTextFromPart " + e.toString()); + } + return null; + } + + /** + * Returns true if the given mimeType matches the matchAgainst specification. The comparison + * ignores case and the matchAgainst string may include "*" for a wildcard (e.g. "image/*"). + * + * @param mimeType A MIME type to check. + * @param matchAgainst A MIME type to check against. May include wildcards. + * @return true if the mimeType matches + */ + public static boolean mimeTypeMatches(String mimeType, String matchAgainst) { + Pattern p = Pattern.compile(matchAgainst.replaceAll("\\*", "\\.\\*"), + Pattern.CASE_INSENSITIVE); + return p.matcher(mimeType).matches(); + } + + /** + * Returns true if the given mimeType matches any of the matchAgainst specifications. The + * comparison ignores case and the matchAgainst strings may include "*" for a wildcard + * (e.g. "image/*"). + * + * @param mimeType A MIME type to check. + * @param matchAgainst An array of MIME types to check against. May include wildcards. + * @return true if the mimeType matches any of the matchAgainst strings + */ + public static boolean mimeTypeMatches(String mimeType, String[] matchAgainst) { + for (String matchType : matchAgainst) { + if (mimeTypeMatches(mimeType, matchType)) { + return true; + } + } + return false; + } + + /** + * Given an input stream and a transfer encoding, return a wrapped input stream for that + * encoding (or the original if none is required) + * @param in the input stream + * @param contentTransferEncoding the content transfer encoding + * @return a properly wrapped stream + */ + public static InputStream getInputStreamForContentTransferEncoding(InputStream in, + String contentTransferEncoding) { + if (contentTransferEncoding != null) { + contentTransferEncoding = + MimeUtility.getHeaderParameter(contentTransferEncoding, null); + if ("quoted-printable".equalsIgnoreCase(contentTransferEncoding)) { + in = new QuotedPrintableInputStream(in); + } + else if ("base64".equalsIgnoreCase(contentTransferEncoding)) { + in = new Base64InputStream(in, Base64.DEFAULT); + } + } + return in; + } + + /** + * Removes any content transfer encoding from the stream and returns a Body. + */ + public static Body decodeBody(InputStream in, String contentTransferEncoding) + throws IOException { + /* + * We'll remove any transfer encoding by wrapping the stream. + */ + in = getInputStreamForContentTransferEncoding(in, contentTransferEncoding); + BinaryTempFileBody tempBody = new BinaryTempFileBody(); + OutputStream out = tempBody.getOutputStream(); + try { + IOUtils.copy(in, out); + } catch (Base64DataException bde) { + // TODO Need to fix this somehow + //String warning = "\n\n" + Email.getMessageDecodeErrorString(); + //out.write(warning.getBytes()); + } finally { + out.close(); + } + return tempBody; + } + + /** + * Recursively scan a Part (usually a Message) and sort out which of its children will be + * "viewable" and which will be attachments. + * + * @param part The part to be broken down + * @param viewables This arraylist will be populated with all parts that appear to be + * the "message" (e.g. text/plain & text/html) + * @param attachments This arraylist will be populated with all parts that appear to be + * attachments (including inlines) + * @throws MessagingException + */ + public static void collectParts(Part part, ArrayList viewables, + ArrayList attachments) throws MessagingException { + String disposition = part.getDisposition(); + String dispositionType = MimeUtility.getHeaderParameter(disposition, null); + // If a disposition is not specified, default to "inline" + boolean inline = + TextUtils.isEmpty(dispositionType) || "inline".equalsIgnoreCase(dispositionType); + // The lower-case mime type + String mimeType = part.getMimeType().toLowerCase(); + + if (part.getBody() instanceof Multipart) { + // If the part is Multipart but not alternative it's either mixed or + // something we don't know about, which means we treat it as mixed + // per the spec. We just process its pieces recursively. + MimeMultipart mp = (MimeMultipart)part.getBody(); + boolean foundHtml = false; + if (mp.getSubTypeForTest().equals("alternative")) { + for (int i = 0; i < mp.getCount(); i++) { + if (mp.getBodyPart(i).isMimeType("text/html")) { + foundHtml = true; + break; + } + } + } + for (int i = 0; i < mp.getCount(); i++) { + // See if we have text and html + BodyPart bp = mp.getBodyPart(i); + // If there's html, don't bother loading text + if (foundHtml && bp.isMimeType("text/plain")) { + continue; + } + collectParts(bp, viewables, attachments); + } + } else if (part.getBody() instanceof Message) { + // If the part is an embedded message we just continue to process + // it, pulling any viewables or attachments into the running list. + Message message = (Message)part.getBody(); + collectParts(message, viewables, attachments); + } else if (inline && (mimeType.startsWith("text") || (mimeType.startsWith("image")))) { + // We'll treat text and images as viewables + viewables.add(part); + } else { + // Everything else is an attachment. + attachments.add(part); + } + } +} diff --git a/java/com/android/voicemailomtp/mail/internet/TextBody.java b/java/com/android/voicemailomtp/mail/internet/TextBody.java new file mode 100644 index 000000000..578193eff --- /dev/null +++ b/java/com/android/voicemailomtp/mail/internet/TextBody.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.internet; + +import android.util.Base64; + +import com.android.voicemailomtp.mail.Body; +import com.android.voicemailomtp.mail.MessagingException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; + +public class TextBody implements Body { + String mBody; + + public TextBody(String body) { + this.mBody = body; + } + + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + byte[] bytes = mBody.getBytes("UTF-8"); + out.write(Base64.encode(bytes, Base64.CRLF)); + } + + /** + * Get the text of the body in it's unencoded format. + * @return + */ + public String getText() { + return mBody; + } + + /** + * Returns an InputStream that reads this body's text in UTF-8 format. + */ + @Override + public InputStream getInputStream() throws MessagingException { + try { + byte[] b = mBody.getBytes("UTF-8"); + return new ByteArrayInputStream(b); + } + catch (UnsupportedEncodingException usee) { + return null; + } + } +} diff --git a/java/com/android/voicemailomtp/mail/store/ImapConnection.java b/java/com/android/voicemailomtp/mail/store/ImapConnection.java new file mode 100644 index 000000000..61dcf1281 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/ImapConnection.java @@ -0,0 +1,413 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.store; + +import android.util.ArraySet; +import android.util.Base64; +import com.android.voicemailomtp.mail.AuthenticationFailedException; +import com.android.voicemailomtp.mail.CertificateValidationException; +import com.android.voicemailomtp.mail.MailTransport; +import com.android.voicemailomtp.mail.MessagingException; +import com.android.voicemailomtp.mail.store.ImapStore.ImapException; +import com.android.voicemailomtp.mail.store.imap.DigestMd5Utils; +import com.android.voicemailomtp.mail.store.imap.ImapConstants; +import com.android.voicemailomtp.mail.store.imap.ImapResponse; +import com.android.voicemailomtp.mail.store.imap.ImapResponseParser; +import com.android.voicemailomtp.mail.store.imap.ImapUtility; +import com.android.voicemailomtp.mail.utils.LogUtils; +import com.android.voicemailomtp.OmtpEvents; +import com.android.voicemailomtp.VvmLog; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import javax.net.ssl.SSLException; + +/** + * A cacheable class that stores the details for a single IMAP connection. + */ +public class ImapConnection { + private final String TAG = "ImapConnection"; + + private String mLoginPhrase; + private ImapStore mImapStore; + private MailTransport mTransport; + private ImapResponseParser mParser; + private Set mCapabilities = new ArraySet<>(); + + static final String IMAP_REDACTED_LOG = "[IMAP command redacted]"; + + /** + * Next tag to use. All connections associated to the same ImapStore instance share the same + * counter to make tests simpler. + * (Some of the tests involve multiple connections but only have a single counter to track the + * tag.) + */ + private final AtomicInteger mNextCommandTag = new AtomicInteger(0); + + ImapConnection(ImapStore store) { + setStore(store); + } + + void setStore(ImapStore store) { + // TODO: maybe we should throw an exception if the connection is not closed here, + // if it's not currently closed, then we won't reopen it, so if the credentials have + // changed, the connection will not be reestablished. + mImapStore = store; + mLoginPhrase = null; + } + + /** + * Generates and returns the phrase to be used for authentication. This will be a LOGIN with + * username and password. + * + * @return the login command string to sent to the IMAP server + */ + String getLoginPhrase() { + if (mLoginPhrase == null) { + if (mImapStore.getUsername() != null && mImapStore.getPassword() != null) { + // build the LOGIN string once (instead of over-and-over again.) + // apply the quoting here around the built-up password + mLoginPhrase = ImapConstants.LOGIN + " " + mImapStore.getUsername() + " " + + ImapUtility.imapQuoted(mImapStore.getPassword()); + } + } + return mLoginPhrase; + } + + public void open() throws IOException, MessagingException { + if (mTransport != null && mTransport.isOpen()) { + return; + } + + try { + // copy configuration into a clean transport, if necessary + if (mTransport == null) { + mTransport = mImapStore.cloneTransport(); + } + + mTransport.open(); + + createParser(); + + // The server should greet us with something like + // * OK IMAP4rev1 Server + // consume the response before doing anything else. + ImapResponse response = mParser.readResponse(false); + if (!response.isOk()) { + mImapStore.getImapHelper() + .handleEvent(OmtpEvents.DATA_INVALID_INITIAL_SERVER_RESPONSE); + throw new MessagingException( + MessagingException.AUTHENTICATION_FAILED_OR_SERVER_ERROR, + "Invalid server initial response"); + } + + queryCapability(); + + maybeDoStartTls(); + + // LOGIN + doLogin(); + } catch (SSLException e) { + LogUtils.d(TAG, "SSLException ", e); + mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_SSL_EXCEPTION); + throw new CertificateValidationException(e.getMessage(), e); + } catch (IOException ioe) { + LogUtils.d(TAG, "IOException", ioe); + mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_IOE_ON_OPEN); + throw ioe; + } finally { + destroyResponses(); + } + } + + void logout() { + try { + sendCommand(ImapConstants.LOGOUT, false); + if (!mParser.readResponse(true).is(0, ImapConstants.BYE)) { + VvmLog.e(TAG, "Server did not respond LOGOUT with BYE"); + } + if (!mParser.readResponse(false).isOk()) { + VvmLog.e(TAG, "Server did not respond OK after LOGOUT"); + } + } catch (IOException | MessagingException e) { + VvmLog.e(TAG, "Error while logging out:" + e); + } + } + + /** + * Closes the connection and releases all resources. This connection can not be used again + * until {@link #setStore(ImapStore)} is called. + */ + void close() { + if (mTransport != null) { + logout(); + mTransport.close(); + mTransport = null; + } + destroyResponses(); + mParser = null; + mImapStore = null; + } + + /** + * Attempts to convert the connection into secure connection. + */ + private void maybeDoStartTls() throws IOException, MessagingException { + // STARTTLS is required in the OMTP standard but not every implementation support it. + // Make sure the server does have this capability + if (hasCapability(ImapConstants.CAPABILITY_STARTTLS)) { + executeSimpleCommand(ImapConstants.STARTTLS); + mTransport.reopenTls(); + createParser(); + // The cached capabilities should be refreshed after TLS is established. + queryCapability(); + } + } + + /** + * Logs into the IMAP server + */ + private void doLogin() throws IOException, MessagingException, AuthenticationFailedException { + try { + if (mCapabilities.contains(ImapConstants.CAPABILITY_AUTH_DIGEST_MD5)) { + doDigestMd5Auth(); + } else { + executeSimpleCommand(getLoginPhrase(), true); + } + } catch (ImapException ie) { + LogUtils.d(TAG, "ImapException", ie); + String status = ie.getStatus(); + String statusMessage = ie.getStatusMessage(); + String alertText = ie.getAlertText(); + + if (ImapConstants.NO.equals(status)) { + switch (statusMessage) { + case ImapConstants.NO_UNKNOWN_USER: + mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_AUTH_UNKNOWN_USER); + break; + case ImapConstants.NO_UNKNOWN_CLIENT: + mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_AUTH_UNKNOWN_DEVICE); + break; + case ImapConstants.NO_INVALID_PASSWORD: + mImapStore.getImapHelper() + .handleEvent(OmtpEvents.DATA_AUTH_INVALID_PASSWORD); + break; + case ImapConstants.NO_MAILBOX_NOT_INITIALIZED: + mImapStore.getImapHelper() + .handleEvent(OmtpEvents.DATA_AUTH_MAILBOX_NOT_INITIALIZED); + break; + case ImapConstants.NO_SERVICE_IS_NOT_PROVISIONED: + mImapStore.getImapHelper() + .handleEvent(OmtpEvents.DATA_AUTH_SERVICE_NOT_PROVISIONED); + break; + case ImapConstants.NO_SERVICE_IS_NOT_ACTIVATED: + mImapStore.getImapHelper() + .handleEvent(OmtpEvents.DATA_AUTH_SERVICE_NOT_ACTIVATED); + break; + case ImapConstants.NO_USER_IS_BLOCKED: + mImapStore.getImapHelper() + .handleEvent(OmtpEvents.DATA_AUTH_USER_IS_BLOCKED); + break; + case ImapConstants.NO_APPLICATION_ERROR: + mImapStore.getImapHelper() + .handleEvent(OmtpEvents.DATA_REJECTED_SERVER_RESPONSE); + default: + mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_BAD_IMAP_CREDENTIAL); + } + throw new AuthenticationFailedException(alertText, ie); + } + + mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_REJECTED_SERVER_RESPONSE); + throw new MessagingException(alertText, ie); + } + } + + private void doDigestMd5Auth() throws IOException, MessagingException { + + // Initiate the authentication. + // The server will issue us a challenge, asking to run MD5 on the nonce with our password + // and other data, including the cnonce we randomly generated. + // + // C: a AUTHENTICATE DIGEST-MD5 + // S: (BASE64) realm="elwood.innosoft.com",nonce="OA6MG9tEQGm2hh",qop="auth", + // algorithm=md5-sess,charset=utf-8 + List responses = executeSimpleCommand( + ImapConstants.AUTHENTICATE + " " + ImapConstants.AUTH_DIGEST_MD5); + String decodedChallenge = decodeBase64(responses.get(0).getStringOrEmpty(0).getString()); + + Map challenge = DigestMd5Utils.parseDigestMessage(decodedChallenge); + DigestMd5Utils.Data data = new DigestMd5Utils.Data(mImapStore, mTransport, challenge); + + String response = data.createResponse(); + // Respond to the challenge. If the server accepts it, it will reply a response-auth which + // is the MD5 of our password and the cnonce we've provided, to prove the server does know + // the password. + // + // C: (BASE64) charset=utf-8,username="chris",realm="elwood.innosoft.com", + // nonce="OA6MG9tEQGm2hh",nc=00000001,cnonce="OA6MHXh6VqTrRk", + // digest-uri="imap/elwood.innosoft.com", + // response=d388dad90d4bbd760a152321f2143af7,qop=auth + // S: (BASE64) rspauth=ea40f60335c427b5527b84dbabcdfffd + + responses = executeContinuationResponse(encodeBase64(response), true); + + // Verify response-auth. + // If failed verifyResponseAuth() will throw a MessagingException, terminating the + // connection + String decodedResponseAuth = decodeBase64(responses.get(0).getStringOrEmpty(0).getString()); + data.verifyResponseAuth(decodedResponseAuth); + + // Send a empty response to indicate we've accepted the response-auth + // + // C: (empty) + // S: a OK User logged in + executeContinuationResponse("", false); + + } + + private static String decodeBase64(String string) { + return new String(Base64.decode(string, Base64.DEFAULT)); + } + + private static String encodeBase64(String string) { + return Base64.encodeToString(string.getBytes(), Base64.NO_WRAP); + } + + private void queryCapability() throws IOException, MessagingException { + List responses = executeSimpleCommand(ImapConstants.CAPABILITY); + mCapabilities.clear(); + Set disabledCapabilities = mImapStore.getImapHelper().getConfig() + .getDisabledCapabilities(); + for (ImapResponse response : responses) { + if (response.isTagged()) { + continue; + } + for (int i = 0; i < response.size(); i++) { + String capability = response.getStringOrEmpty(i).getString(); + if (disabledCapabilities != null) { + if (!disabledCapabilities.contains(capability)) { + mCapabilities.add(capability); + } + } else { + mCapabilities.add(capability); + } + } + } + + LogUtils.d(TAG, "Capabilities: " + mCapabilities.toString()); + } + + private boolean hasCapability(String capability) { + return mCapabilities.contains(capability); + } + /** + * Create an {@link ImapResponseParser} from {@code mTransport.getInputStream()} and + * set it to {@link #mParser}. + * + * If we already have an {@link ImapResponseParser}, we + * {@link #destroyResponses()} and throw it away. + */ + private void createParser() { + destroyResponses(); + mParser = new ImapResponseParser(mTransport.getInputStream()); + } + + + public void destroyResponses() { + if (mParser != null) { + mParser.destroyResponses(); + } + } + + public ImapResponse readResponse() throws IOException, MessagingException { + return mParser.readResponse(false); + } + + public List executeSimpleCommand(String command) + throws IOException, MessagingException{ + return executeSimpleCommand(command, false); + } + + /** + * Send a single command to the server. The command will be preceded by an IMAP command + * tag and followed by \r\n (caller need not supply them). + * Execute a simple command at the server, a simple command being one that is sent in a single + * line of text + * + * @param command the command to send to the server + * @param sensitive whether the command should be redacted in logs (used for login) + * @return a list of ImapResponses + * @throws IOException + * @throws MessagingException + */ + public List executeSimpleCommand(String command, boolean sensitive) + throws IOException, MessagingException { + // TODO: It may be nice to catch IOExceptions and close the connection here. + // Currently, we expect callers to do that, but if they fail to we'll be in a broken state. + sendCommand(command, sensitive); + return getCommandResponses(); + } + + public String sendCommand(String command, boolean sensitive) + throws IOException, MessagingException { + open(); + + if (mTransport == null) { + throw new IOException("Null transport"); + } + String tag = Integer.toString(mNextCommandTag.incrementAndGet()); + String commandToSend = tag + " " + command; + mTransport.writeLine(commandToSend, (sensitive ? IMAP_REDACTED_LOG : command)); + return tag; + } + + List executeContinuationResponse(String response, boolean sensitive) + throws IOException, MessagingException { + mTransport.writeLine(response, (sensitive ? IMAP_REDACTED_LOG : response)); + return getCommandResponses(); + } + + /** + * Read and return all of the responses from the most recent command sent to the server + * + * @return a list of ImapResponses + * @throws IOException + * @throws MessagingException + */ + List getCommandResponses() + throws IOException, MessagingException { + final List responses = new ArrayList(); + ImapResponse response; + do { + response = mParser.readResponse(false); + responses.add(response); + } while (!(response.isTagged() || response.isContinuationRequest())); + + if (!(response.isOk() || response.isContinuationRequest())) { + final String toString = response.toString(); + final String status = response.getStatusOrEmpty().getString(); + final String statusMessage = response.getStatusResponseTextOrEmpty().getString(); + final String alert = response.getAlertTextOrEmpty().getString(); + final String responseCode = response.getResponseCodeOrEmpty().getString(); + destroyResponses(); + throw new ImapException(toString, status, statusMessage, alert, responseCode); + } + return responses; + } +} diff --git a/java/com/android/voicemailomtp/mail/store/ImapFolder.java b/java/com/android/voicemailomtp/mail/store/ImapFolder.java new file mode 100644 index 000000000..eca349876 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/ImapFolder.java @@ -0,0 +1,784 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.store; + +import android.content.Context; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.util.Base64DataException; +import com.android.voicemailomtp.OmtpEvents; +import com.android.voicemailomtp.mail.AuthenticationFailedException; +import com.android.voicemailomtp.mail.Body; +import com.android.voicemailomtp.mail.FetchProfile; +import com.android.voicemailomtp.mail.Flag; +import com.android.voicemailomtp.mail.Message; +import com.android.voicemailomtp.mail.MessagingException; +import com.android.voicemailomtp.mail.Part; +import com.android.voicemailomtp.mail.internet.BinaryTempFileBody; +import com.android.voicemailomtp.mail.internet.MimeBodyPart; +import com.android.voicemailomtp.mail.internet.MimeHeader; +import com.android.voicemailomtp.mail.internet.MimeMultipart; +import com.android.voicemailomtp.mail.internet.MimeUtility; +import com.android.voicemailomtp.mail.store.ImapStore.ImapException; +import com.android.voicemailomtp.mail.store.ImapStore.ImapMessage; +import com.android.voicemailomtp.mail.store.imap.ImapConstants; +import com.android.voicemailomtp.mail.store.imap.ImapElement; +import com.android.voicemailomtp.mail.store.imap.ImapList; +import com.android.voicemailomtp.mail.store.imap.ImapResponse; +import com.android.voicemailomtp.mail.store.imap.ImapString; +import com.android.voicemailomtp.mail.utils.LogUtils; +import com.android.voicemailomtp.mail.utils.Utility; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; + +public class ImapFolder { + private static final String TAG = "ImapFolder"; + private final static String[] PERMANENT_FLAGS = + { Flag.DELETED, Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED }; + private static final int COPY_BUFFER_SIZE = 16*1024; + + private final ImapStore mStore; + private final String mName; + private int mMessageCount = -1; + private ImapConnection mConnection; + private String mMode; + private boolean mExists; + /** A set of hashes that can be used to track dirtiness */ + Object mHash[]; + + public static final String MODE_READ_ONLY = "mode_read_only"; + public static final String MODE_READ_WRITE = "mode_read_write"; + + public ImapFolder(ImapStore store, String name) { + mStore = store; + mName = name; + } + + /** + * Callback for each message retrieval. + */ + public interface MessageRetrievalListener { + public void messageRetrieved(Message message); + } + + private void destroyResponses() { + if (mConnection != null) { + mConnection.destroyResponses(); + } + } + + public void open(String mode) throws MessagingException { + try { + if (isOpen()) { + throw new AssertionError("Duplicated open on ImapFolder"); + } + synchronized (this) { + mConnection = mStore.getConnection(); + } + // * FLAGS (\Answered \Flagged \Deleted \Seen \Draft NonJunk + // $MDNSent) + // * OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft + // NonJunk $MDNSent \*)] Flags permitted. + // * 23 EXISTS + // * 0 RECENT + // * OK [UIDVALIDITY 1125022061] UIDs valid + // * OK [UIDNEXT 57576] Predicted next UID + // 2 OK [READ-WRITE] Select completed. + try { + doSelect(); + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + } catch (AuthenticationFailedException e) { + // Don't cache this connection, so we're forced to try connecting/login again + mConnection = null; + close(false); + throw e; + } catch (MessagingException e) { + mExists = false; + close(false); + throw e; + } + } + + public boolean isOpen() { + return mExists && mConnection != null; + } + + public String getMode() { + return mMode; + } + + public void close(boolean expunge) { + if (expunge) { + try { + expunge(); + } catch (MessagingException e) { + LogUtils.e(TAG, e, "Messaging Exception"); + } + } + mMessageCount = -1; + synchronized (this) { + mConnection = null; + } + } + + public int getMessageCount() { + return mMessageCount; + } + + String[] getSearchUids(List responses) { + // S: * SEARCH 2 3 6 + final ArrayList uids = new ArrayList(); + for (ImapResponse response : responses) { + if (!response.isDataResponse(0, ImapConstants.SEARCH)) { + continue; + } + // Found SEARCH response data + for (int i = 1; i < response.size(); i++) { + ImapString s = response.getStringOrEmpty(i); + if (s.isString()) { + uids.add(s.getString()); + } + } + } + return uids.toArray(Utility.EMPTY_STRINGS); + } + + @VisibleForTesting + String[] searchForUids(String searchCriteria) throws MessagingException { + checkOpen(); + try { + try { + final String command = ImapConstants.UID_SEARCH + " " + searchCriteria; + final String[] result = getSearchUids(mConnection.executeSimpleCommand(command)); + LogUtils.d(TAG, "searchForUids '" + searchCriteria + "' results: " + + result.length); + return result; + } catch (ImapException me) { + LogUtils.d(TAG, "ImapException in search: " + searchCriteria, me); + return Utility.EMPTY_STRINGS; // Not found + } catch (IOException ioe) { + LogUtils.d(TAG, "IOException in search: " + searchCriteria, ioe); + mStore.getImapHelper().handleEvent(OmtpEvents.DATA_GENERIC_IMAP_IOE); + throw ioExceptionHandler(mConnection, ioe); + } + } finally { + destroyResponses(); + } + } + + @Nullable + public Message getMessage(String uid) throws MessagingException { + checkOpen(); + + final String[] uids = searchForUids(ImapConstants.UID + " " + uid); + for (int i = 0; i < uids.length; i++) { + if (uids[i].equals(uid)) { + return new ImapMessage(uid, this); + } + } + LogUtils.e(TAG, "UID " + uid + " not found on server"); + return null; + } + + @VisibleForTesting + protected static boolean isAsciiString(String str) { + int len = str.length(); + for (int i = 0; i < len; i++) { + char c = str.charAt(i); + if (c >= 128) return false; + } + return true; + } + + public Message[] getMessages(String[] uids) throws MessagingException { + if (uids == null) { + uids = searchForUids("1:* NOT DELETED"); + } + return getMessagesInternal(uids); + } + + public Message[] getMessagesInternal(String[] uids) { + final ArrayList messages = new ArrayList(uids.length); + for (int i = 0; i < uids.length; i++) { + final String uid = uids[i]; + final ImapMessage message = new ImapMessage(uid, this); + messages.add(message); + } + return messages.toArray(Message.EMPTY_ARRAY); + } + + public void fetch(Message[] messages, FetchProfile fp, + MessageRetrievalListener listener) throws MessagingException { + try { + fetchInternal(messages, fp, listener); + } catch (RuntimeException e) { // Probably a parser error. + LogUtils.w(TAG, "Exception detected: " + e.getMessage()); + throw e; + } + } + + public void fetchInternal(Message[] messages, FetchProfile fp, + MessageRetrievalListener listener) throws MessagingException { + if (messages.length == 0) { + return; + } + checkOpen(); + HashMap messageMap = new HashMap(); + for (Message m : messages) { + messageMap.put(m.getUid(), m); + } + + /* + * Figure out what command we are going to run: + * FLAGS - UID FETCH (FLAGS) + * ENVELOPE - UID FETCH (INTERNALDATE UID RFC822.SIZE FLAGS BODY.PEEK[ + * HEADER.FIELDS (date subject from content-type to cc)]) + * STRUCTURE - UID FETCH (BODYSTRUCTURE) + * BODY_SANE - UID FETCH (BODY.PEEK[]<0.N>) where N = max bytes returned + * BODY - UID FETCH (BODY.PEEK[]) + * Part - UID FETCH (BODY.PEEK[ID]) where ID = mime part ID + */ + + final LinkedHashSet fetchFields = new LinkedHashSet(); + + fetchFields.add(ImapConstants.UID); + if (fp.contains(FetchProfile.Item.FLAGS)) { + fetchFields.add(ImapConstants.FLAGS); + } + if (fp.contains(FetchProfile.Item.ENVELOPE)) { + fetchFields.add(ImapConstants.INTERNALDATE); + fetchFields.add(ImapConstants.RFC822_SIZE); + fetchFields.add(ImapConstants.FETCH_FIELD_HEADERS); + } + if (fp.contains(FetchProfile.Item.STRUCTURE)) { + fetchFields.add(ImapConstants.BODYSTRUCTURE); + } + + if (fp.contains(FetchProfile.Item.BODY_SANE)) { + fetchFields.add(ImapConstants.FETCH_FIELD_BODY_PEEK_SANE); + } + if (fp.contains(FetchProfile.Item.BODY)) { + fetchFields.add(ImapConstants.FETCH_FIELD_BODY_PEEK); + } + + // TODO Why are we only fetching the first part given? + final Part fetchPart = fp.getFirstPart(); + if (fetchPart != null) { + final String[] partIds = + fetchPart.getHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA); + // TODO Why can a single part have more than one Id? And why should we only fetch + // the first id if there are more than one? + if (partIds != null) { + fetchFields.add(ImapConstants.FETCH_FIELD_BODY_PEEK_BARE + + "[" + partIds[0] + "]"); + } + } + + try { + mConnection.sendCommand(String.format(Locale.US, + ImapConstants.UID_FETCH + " %s (%s)", ImapStore.joinMessageUids(messages), + Utility.combine(fetchFields.toArray(new String[fetchFields.size()]), ' ') + ), false); + ImapResponse response; + do { + response = null; + try { + response = mConnection.readResponse(); + + if (!response.isDataResponse(1, ImapConstants.FETCH)) { + continue; // Ignore + } + final ImapList fetchList = response.getListOrEmpty(2); + final String uid = fetchList.getKeyedStringOrEmpty(ImapConstants.UID) + .getString(); + if (TextUtils.isEmpty(uid)) continue; + + ImapMessage message = (ImapMessage) messageMap.get(uid); + if (message == null) continue; + + if (fp.contains(FetchProfile.Item.FLAGS)) { + final ImapList flags = + fetchList.getKeyedListOrEmpty(ImapConstants.FLAGS); + for (int i = 0, count = flags.size(); i < count; i++) { + final ImapString flag = flags.getStringOrEmpty(i); + if (flag.is(ImapConstants.FLAG_DELETED)) { + message.setFlagInternal(Flag.DELETED, true); + } else if (flag.is(ImapConstants.FLAG_ANSWERED)) { + message.setFlagInternal(Flag.ANSWERED, true); + } else if (flag.is(ImapConstants.FLAG_SEEN)) { + message.setFlagInternal(Flag.SEEN, true); + } else if (flag.is(ImapConstants.FLAG_FLAGGED)) { + message.setFlagInternal(Flag.FLAGGED, true); + } + } + } + if (fp.contains(FetchProfile.Item.ENVELOPE)) { + final Date internalDate = fetchList.getKeyedStringOrEmpty( + ImapConstants.INTERNALDATE).getDateOrNull(); + final int size = fetchList.getKeyedStringOrEmpty( + ImapConstants.RFC822_SIZE).getNumberOrZero(); + final String header = fetchList.getKeyedStringOrEmpty( + ImapConstants.BODY_BRACKET_HEADER, true).getString(); + + message.setInternalDate(internalDate); + message.setSize(size); + message.parse(Utility.streamFromAsciiString(header)); + } + if (fp.contains(FetchProfile.Item.STRUCTURE)) { + ImapList bs = fetchList.getKeyedListOrEmpty( + ImapConstants.BODYSTRUCTURE); + if (!bs.isEmpty()) { + try { + parseBodyStructure(bs, message, ImapConstants.TEXT); + } catch (MessagingException e) { + LogUtils.v(TAG, e, "Error handling message"); + message.setBody(null); + } + } + } + if (fp.contains(FetchProfile.Item.BODY) + || fp.contains(FetchProfile.Item.BODY_SANE)) { + // Body is keyed by "BODY[]...". + // Previously used "BODY[..." but this can be confused with "BODY[HEADER..." + // TODO Should we accept "RFC822" as well?? + ImapString body = fetchList.getKeyedStringOrEmpty("BODY[]", true); + InputStream bodyStream = body.getAsStream(); + message.parse(bodyStream); + } + if (fetchPart != null) { + InputStream bodyStream = + fetchList.getKeyedStringOrEmpty("BODY[", true).getAsStream(); + String encodings[] = fetchPart.getHeader( + MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING); + + String contentTransferEncoding = null; + if (encodings != null && encodings.length > 0) { + contentTransferEncoding = encodings[0]; + } else { + // According to http://tools.ietf.org/html/rfc2045#section-6.1 + // "7bit" is the default. + contentTransferEncoding = "7bit"; + } + + try { + // TODO Don't create 2 temp files. + // decodeBody creates BinaryTempFileBody, but we could avoid this + // if we implement ImapStringBody. + // (We'll need to share a temp file. Protect it with a ref-count.) + message.setBody(decodeBody(mStore.getContext(), bodyStream, + contentTransferEncoding, fetchPart.getSize(), listener)); + } catch(Exception e) { + // TODO: Figure out what kinds of exceptions might actually be thrown + // from here. This blanket catch-all is because we're not sure what to + // do if we don't have a contentTransferEncoding, and we don't have + // time to figure out what exceptions might be thrown. + LogUtils.e(TAG, "Error fetching body %s", e); + } + } + + if (listener != null) { + listener.messageRetrieved(message); + } + } finally { + destroyResponses(); + } + } while (!response.isTagged()); + } catch (IOException ioe) { + mStore.getImapHelper().handleEvent(OmtpEvents.DATA_GENERIC_IMAP_IOE); + throw ioExceptionHandler(mConnection, ioe); + } + } + + /** + * Removes any content transfer encoding from the stream and returns a Body. + * This code is taken/condensed from MimeUtility.decodeBody + */ + private static Body decodeBody(Context context,InputStream in, String contentTransferEncoding, + int size, MessageRetrievalListener listener) throws IOException { + // Get a properly wrapped input stream + in = MimeUtility.getInputStreamForContentTransferEncoding(in, contentTransferEncoding); + BinaryTempFileBody tempBody = new BinaryTempFileBody(); + OutputStream out = tempBody.getOutputStream(); + try { + byte[] buffer = new byte[COPY_BUFFER_SIZE]; + int n = 0; + int count = 0; + while (-1 != (n = in.read(buffer))) { + out.write(buffer, 0, n); + count += n; + } + } catch (Base64DataException bde) { + String warning = "\n\nThere was an error while decoding the message."; + out.write(warning.getBytes()); + } finally { + out.close(); + } + return tempBody; + } + + public String[] getPermanentFlags() { + return PERMANENT_FLAGS; + } + + /** + * Handle any untagged responses that the caller doesn't care to handle themselves. + * @param responses + */ + private void handleUntaggedResponses(List responses) { + for (ImapResponse response : responses) { + handleUntaggedResponse(response); + } + } + + /** + * Handle an untagged response that the caller doesn't care to handle themselves. + * @param response + */ + private void handleUntaggedResponse(ImapResponse response) { + if (response.isDataResponse(1, ImapConstants.EXISTS)) { + mMessageCount = response.getStringOrEmpty(0).getNumberOrZero(); + } + } + + private static void parseBodyStructure(ImapList bs, Part part, String id) + throws MessagingException { + if (bs.getElementOrNone(0).isList()) { + /* + * This is a multipart/* + */ + MimeMultipart mp = new MimeMultipart(); + for (int i = 0, count = bs.size(); i < count; i++) { + ImapElement e = bs.getElementOrNone(i); + if (e.isList()) { + /* + * For each part in the message we're going to add a new BodyPart and parse + * into it. + */ + MimeBodyPart bp = new MimeBodyPart(); + if (id.equals(ImapConstants.TEXT)) { + parseBodyStructure(bs.getListOrEmpty(i), bp, Integer.toString(i + 1)); + + } else { + parseBodyStructure(bs.getListOrEmpty(i), bp, id + "." + (i + 1)); + } + mp.addBodyPart(bp); + + } else { + if (e.isString()) { + mp.setSubType(bs.getStringOrEmpty(i).getString().toLowerCase(Locale.US)); + } + break; // Ignore the rest of the list. + } + } + part.setBody(mp); + } else { + /* + * This is a body. We need to add as much information as we can find out about + * it to the Part. + */ + + /* + body type + body subtype + body parameter parenthesized list + body id + body description + body encoding + body size + */ + + final ImapString type = bs.getStringOrEmpty(0); + final ImapString subType = bs.getStringOrEmpty(1); + final String mimeType = + (type.getString() + "/" + subType.getString()).toLowerCase(Locale.US); + + final ImapList bodyParams = bs.getListOrEmpty(2); + final ImapString cid = bs.getStringOrEmpty(3); + final ImapString encoding = bs.getStringOrEmpty(5); + final int size = bs.getStringOrEmpty(6).getNumberOrZero(); + + if (MimeUtility.mimeTypeMatches(mimeType, MimeUtility.MIME_TYPE_RFC822)) { + // A body type of type MESSAGE and subtype RFC822 + // contains, immediately after the basic fields, the + // envelope structure, body structure, and size in + // text lines of the encapsulated message. + // [MESSAGE, RFC822, [NAME, filename.eml], NIL, NIL, 7BIT, 5974, NIL, + // [INLINE, [FILENAME*0, Fwd: Xxx..., FILENAME*1, filename.eml]], NIL] + /* + * This will be caught by fetch and handled appropriately. + */ + throw new MessagingException("BODYSTRUCTURE " + MimeUtility.MIME_TYPE_RFC822 + + " not yet supported."); + } + + /* + * Set the content type with as much information as we know right now. + */ + final StringBuilder contentType = new StringBuilder(mimeType); + + /* + * If there are body params we might be able to get some more information out + * of them. + */ + for (int i = 1, count = bodyParams.size(); i < count; i += 2) { + + // TODO We need to convert " into %22, but + // because MimeUtility.getHeaderParameter doesn't recognize it, + // we can't fix it for now. + contentType.append(String.format(";\n %s=\"%s\"", + bodyParams.getStringOrEmpty(i - 1).getString(), + bodyParams.getStringOrEmpty(i).getString())); + } + + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType.toString()); + + // Extension items + final ImapList bodyDisposition; + + if (type.is(ImapConstants.TEXT) && bs.getElementOrNone(9).isList()) { + // If media-type is TEXT, 9th element might be: [body-fld-lines] := number + // So, if it's not a list, use 10th element. + // (Couldn't find evidence in the RFC if it's ALWAYS 10th element.) + bodyDisposition = bs.getListOrEmpty(9); + } else { + bodyDisposition = bs.getListOrEmpty(8); + } + + final StringBuilder contentDisposition = new StringBuilder(); + + if (bodyDisposition.size() > 0) { + final String bodyDisposition0Str = + bodyDisposition.getStringOrEmpty(0).getString().toLowerCase(Locale.US); + if (!TextUtils.isEmpty(bodyDisposition0Str)) { + contentDisposition.append(bodyDisposition0Str); + } + + final ImapList bodyDispositionParams = bodyDisposition.getListOrEmpty(1); + if (!bodyDispositionParams.isEmpty()) { + /* + * If there is body disposition information we can pull some more + * information about the attachment out. + */ + for (int i = 1, count = bodyDispositionParams.size(); i < count; i += 2) { + + // TODO We need to convert " into %22. See above. + contentDisposition.append(String.format(Locale.US, ";\n %s=\"%s\"", + bodyDispositionParams.getStringOrEmpty(i - 1) + .getString().toLowerCase(Locale.US), + bodyDispositionParams.getStringOrEmpty(i).getString())); + } + } + } + + if ((size > 0) + && (MimeUtility.getHeaderParameter(contentDisposition.toString(), "size") + == null)) { + contentDisposition.append(String.format(Locale.US, ";\n size=%d", size)); + } + + if (contentDisposition.length() > 0) { + /* + * Set the content disposition containing at least the size. Attachment + * handling code will use this down the road. + */ + part.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, + contentDisposition.toString()); + } + + /* + * Set the Content-Transfer-Encoding header. Attachment code will use this + * to parse the body. + */ + if (!encoding.isEmpty()) { + part.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, + encoding.getString()); + } + + /* + * Set the Content-ID header. + */ + if (!cid.isEmpty()) { + part.setHeader(MimeHeader.HEADER_CONTENT_ID, cid.getString()); + } + + if (size > 0) { + if (part instanceof ImapMessage) { + ((ImapMessage) part).setSize(size); + } else if (part instanceof MimeBodyPart) { + ((MimeBodyPart) part).setSize(size); + } else { + throw new MessagingException("Unknown part type " + part.toString()); + } + } + part.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, id); + } + + } + + public Message[] expunge() throws MessagingException { + checkOpen(); + try { + handleUntaggedResponses(mConnection.executeSimpleCommand(ImapConstants.EXPUNGE)); + } catch (IOException ioe) { + mStore.getImapHelper().handleEvent(OmtpEvents.DATA_GENERIC_IMAP_IOE); + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + return null; + } + + public void setFlags(Message[] messages, String[] flags, boolean value) + throws MessagingException { + checkOpen(); + + String allFlags = ""; + if (flags.length > 0) { + StringBuilder flagList = new StringBuilder(); + for (int i = 0, count = flags.length; i < count; i++) { + String flag = flags[i]; + if (flag == Flag.SEEN) { + flagList.append(" " + ImapConstants.FLAG_SEEN); + } else if (flag == Flag.DELETED) { + flagList.append(" " + ImapConstants.FLAG_DELETED); + } else if (flag == Flag.FLAGGED) { + flagList.append(" " + ImapConstants.FLAG_FLAGGED); + } else if (flag == Flag.ANSWERED) { + flagList.append(" " + ImapConstants.FLAG_ANSWERED); + } + } + allFlags = flagList.substring(1); + } + try { + mConnection.executeSimpleCommand(String.format(Locale.US, + ImapConstants.UID_STORE + " %s %s" + ImapConstants.FLAGS_SILENT + " (%s)", + ImapStore.joinMessageUids(messages), + value ? "+" : "-", + allFlags)); + + } catch (IOException ioe) { + mStore.getImapHelper().handleEvent(OmtpEvents.DATA_GENERIC_IMAP_IOE); + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + } + + /** + * Selects the folder for use. Before performing any operations on this folder, it + * must be selected. + */ + private void doSelect() throws IOException, MessagingException { + final List responses = mConnection.executeSimpleCommand( + String.format(Locale.US, ImapConstants.SELECT + " \"%s\"", mName)); + + // Assume the folder is opened read-write; unless we are notified otherwise + mMode = MODE_READ_WRITE; + int messageCount = -1; + for (ImapResponse response : responses) { + if (response.isDataResponse(1, ImapConstants.EXISTS)) { + messageCount = response.getStringOrEmpty(0).getNumberOrZero(); + } else if (response.isOk()) { + final ImapString responseCode = response.getResponseCodeOrEmpty(); + if (responseCode.is(ImapConstants.READ_ONLY)) { + mMode = MODE_READ_ONLY; + } else if (responseCode.is(ImapConstants.READ_WRITE)) { + mMode = MODE_READ_WRITE; + } + } else if (response.isTagged()) { // Not OK + mStore.getImapHelper().handleEvent(OmtpEvents.DATA_MAILBOX_OPEN_FAILED); + throw new MessagingException("Can't open mailbox: " + + response.getStatusResponseTextOrEmpty()); + } + } + if (messageCount == -1) { + throw new MessagingException("Did not find message count during select"); + } + mMessageCount = messageCount; + mExists = true; + } + + public class Quota { + + public final int occupied; + public final int total; + + public Quota(int occupied, int total) { + this.occupied = occupied; + this.total = total; + } + } + + public Quota getQuota() throws MessagingException { + try { + final List responses = mConnection.executeSimpleCommand( + String.format(Locale.US, ImapConstants.GETQUOTAROOT + " \"%s\"", mName)); + + for (ImapResponse response : responses) { + if (!response.isDataResponse(0, ImapConstants.QUOTA)) { + continue; + } + ImapList list = response.getListOrEmpty(2); + for (int i = 0; i < list.size(); i += 3) { + if (!list.getStringOrEmpty(i).is("voice")) { + continue; + } + return new Quota( + list.getStringOrEmpty(i + 1).getNumber(-1), + list.getStringOrEmpty(i + 2).getNumber(-1)); + } + } + } catch (IOException ioe) { + mStore.getImapHelper().handleEvent(OmtpEvents.DATA_GENERIC_IMAP_IOE); + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + return null; + } + + private void checkOpen() throws MessagingException { + if (!isOpen()) { + throw new MessagingException("Folder " + mName + " is not open."); + } + } + + private MessagingException ioExceptionHandler(ImapConnection connection, IOException ioe) { + LogUtils.d(TAG, "IO Exception detected: ", ioe); + connection.close(); + if (connection == mConnection) { + mConnection = null; // To prevent close() from returning the connection to the pool. + close(false); + } + return new MessagingException(MessagingException.IOERROR, "IO Error", ioe); + } + + public Message createMessage(String uid) { + return new ImapMessage(uid, this); + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/store/ImapStore.java b/java/com/android/voicemailomtp/mail/store/ImapStore.java new file mode 100644 index 000000000..f3e0c098e --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/ImapStore.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.store; + +import android.content.Context; +import android.net.Network; + +import com.android.voicemailomtp.mail.MailTransport; +import com.android.voicemailomtp.mail.Message; +import com.android.voicemailomtp.mail.MessagingException; +import com.android.voicemailomtp.mail.internet.MimeMessage; +import com.android.voicemailomtp.imap.ImapHelper; + +import java.io.IOException; +import java.io.InputStream; + +public class ImapStore { + /** + * A global suggestion to Store implementors on how much of the body + * should be returned on FetchProfile.Item.BODY_SANE requests. We'll use 125k now. + */ + public static final int FETCH_BODY_SANE_SUGGESTED_SIZE = (125 * 1024); + private final Context mContext; + private final ImapHelper mHelper; + private final String mUsername; + private final String mPassword; + private final MailTransport mTransport; + private ImapConnection mConnection; + + public static final int FLAG_NONE = 0x00; // No flags + public static final int FLAG_SSL = 0x01; // Use SSL + public static final int FLAG_TLS = 0x02; // Use TLS + public static final int FLAG_AUTHENTICATE = 0x04; // Use name/password for authentication + public static final int FLAG_TRUST_ALL = 0x08; // Trust all certificates + public static final int FLAG_OAUTH = 0x10; // Use OAuth for authentication + + /** + * Contains all the information necessary to log into an imap server + */ + public ImapStore(Context context, ImapHelper helper, String username, String password, int port, + String serverName, int flags, Network network) { + mContext = context; + mHelper = helper; + mUsername = username; + mPassword = password; + mTransport = new MailTransport(context, this.getImapHelper(), + network, serverName, port, flags); + } + + public Context getContext() { + return mContext; + } + + public ImapHelper getImapHelper() { + return mHelper; + } + + public String getUsername() { + return mUsername; + } + + public String getPassword() { + return mPassword; + } + + /** Returns a clone of the transport associated with this store. */ + MailTransport cloneTransport() { + return mTransport.clone(); + } + + /** + * Returns UIDs of Messages joined with "," as the separator. + */ + static String joinMessageUids(Message[] messages) { + StringBuilder sb = new StringBuilder(); + boolean notFirst = false; + for (Message m : messages) { + if (notFirst) { + sb.append(','); + } + sb.append(m.getUid()); + notFirst = true; + } + return sb.toString(); + } + + static class ImapMessage extends MimeMessage { + private ImapFolder mFolder; + + ImapMessage(String uid, ImapFolder folder) { + mUid = uid; + mFolder = folder; + } + + public void setSize(int size) { + mSize = size; + } + + @Override + public void parse(InputStream in) throws IOException, MessagingException { + super.parse(in); + } + + public void setFlagInternal(String flag, boolean set) throws MessagingException { + super.setFlag(flag, set); + } + + @Override + public void setFlag(String flag, boolean set) throws MessagingException { + super.setFlag(flag, set); + mFolder.setFlags(new Message[] { this }, new String[] { flag }, set); + } + } + + static class ImapException extends MessagingException { + private static final long serialVersionUID = 1L; + + private final String mStatus; + private final String mStatusMessage; + private final String mAlertText; + private final String mResponseCode; + + public ImapException(String message, String status, String statusMessage, String alertText, + String responseCode) { + super(message); + mStatus = status; + mStatusMessage = statusMessage; + mAlertText = alertText; + mResponseCode = responseCode; + } + + public String getStatus() { + return mStatus; + } + + public String getStatusMessage() { + return mStatusMessage; + } + + public String getAlertText() { + return mAlertText; + } + + public String getResponseCode() { + return mResponseCode; + } + } + + public void closeConnection() { + if (mConnection != null) { + mConnection.close(); + mConnection = null; + } + } + + public ImapConnection getConnection() { + if (mConnection == null) { + mConnection = new ImapConnection(this); + } + return mConnection; + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/store/imap/DigestMd5Utils.java b/java/com/android/voicemailomtp/mail/store/imap/DigestMd5Utils.java new file mode 100644 index 000000000..b78f55293 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/imap/DigestMd5Utils.java @@ -0,0 +1,335 @@ +/* + * Copyright (C) 2016 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.voicemailomtp.mail.store.imap; + +import android.annotation.TargetApi; +import android.os.Build.VERSION_CODES; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.util.ArrayMap; +import android.util.Base64; +import com.android.voicemailomtp.VvmLog; +import com.android.voicemailomtp.mail.MailTransport; +import com.android.voicemailomtp.mail.MessagingException; +import com.android.voicemailomtp.mail.store.ImapStore; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Map; + +@SuppressWarnings("AndroidApiChecker") // Map.getOrDefault() is java8 +@TargetApi(VERSION_CODES.CUR_DEVELOPMENT) +public class DigestMd5Utils { + + private static final String TAG = "DigestMd5Utils"; + + private static final String DIGEST_CHARSET = "CHARSET"; + private static final String DIGEST_USERNAME = "username"; + private static final String DIGEST_REALM = "realm"; + private static final String DIGEST_NONCE = "nonce"; + private static final String DIGEST_NC = "nc"; + private static final String DIGEST_CNONCE = "cnonce"; + private static final String DIGEST_URI = "digest-uri"; + private static final String DIGEST_RESPONSE = "response"; + private static final String DIGEST_QOP = "qop"; + + private static final String RESPONSE_AUTH_HEADER = "rspauth="; + private static final String HEX_CHARS = "0123456789abcdef"; + + /** + * Represents the set of data we need to generate the DIGEST-MD5 response. + */ + public static class Data { + + private static final String CHARSET = "utf-8"; + + public String username; + public String password; + public String realm; + public String nonce; + public String nc; + public String cnonce; + public String digestUri; + public String qop; + + @VisibleForTesting + Data() { + // Do nothing + } + + public Data(ImapStore imapStore, MailTransport transport, Map challenge) { + username = imapStore.getUsername(); + password = imapStore.getPassword(); + realm = challenge.getOrDefault(DIGEST_REALM, ""); + nonce = challenge.get(DIGEST_NONCE); + cnonce = createCnonce(); + nc = "00000001"; // Subsequent Authentication not supported, nounce count always 1. + qop = "auth"; // Other config not supported + digestUri = "imap/" + transport.getHost(); + } + + private static String createCnonce() { + SecureRandom generator = new SecureRandom(); + + // At least 64 bits of entropy is required + byte[] rawBytes = new byte[8]; + generator.nextBytes(rawBytes); + + return Base64.encodeToString(rawBytes, Base64.NO_WRAP); + } + + /** + * Verify the response-auth returned by the server is correct. + */ + public void verifyResponseAuth(String response) + throws MessagingException { + if (!response.startsWith(RESPONSE_AUTH_HEADER)) { + throw new MessagingException("response-auth expected"); + } + if (!response.substring(RESPONSE_AUTH_HEADER.length()) + .equals(DigestMd5Utils.getResponse(this, true))) { + throw new MessagingException("invalid response-auth return from the server."); + } + } + + public String createResponse() { + String response = getResponse(this, false); + ResponseBuilder builder = new ResponseBuilder(); + builder + .append(DIGEST_CHARSET, CHARSET) + .appendQuoted(DIGEST_USERNAME, username) + .appendQuoted(DIGEST_REALM, realm) + .appendQuoted(DIGEST_NONCE, nonce) + .append(DIGEST_NC, nc) + .appendQuoted(DIGEST_CNONCE, cnonce) + .appendQuoted(DIGEST_URI, digestUri) + .append(DIGEST_RESPONSE, response) + .append(DIGEST_QOP, qop); + return builder.toString(); + } + + private static class ResponseBuilder { + + private StringBuilder mBuilder = new StringBuilder(); + + public ResponseBuilder appendQuoted(String key, String value) { + if (mBuilder.length() != 0) { + mBuilder.append(","); + } + mBuilder.append(key).append("=\"").append(value).append("\""); + return this; + } + + public ResponseBuilder append(String key, String value) { + if (mBuilder.length() != 0) { + mBuilder.append(","); + } + mBuilder.append(key).append("=").append(value); + return this; + } + + @Override + public String toString() { + return mBuilder.toString(); + } + } + } + + /* + response-value = + toHex( getKeyDigest ( toHex(getMd5(a1)), + { nonce-value, ":" nc-value, ":", + cnonce-value, ":", qop-value, ":", toHex(getMd5(a2)) })) + * @param isResponseAuth is the response the one the server is returning us. response-auth has + * different a2 format. + */ + @VisibleForTesting + static String getResponse(Data data, boolean isResponseAuth) { + StringBuilder a1 = new StringBuilder(); + a1.append(new String( + getMd5(data.username + ":" + data.realm + ":" + data.password), + StandardCharsets.ISO_8859_1)); + a1.append(":").append(data.nonce).append(":").append(data.cnonce); + + StringBuilder a2 = new StringBuilder(); + if (!isResponseAuth) { + a2.append("AUTHENTICATE"); + } + a2.append(":").append(data.digestUri); + + return toHex(getKeyDigest( + toHex(getMd5(a1.toString())), + data.nonce + ":" + data.nc + ":" + data.cnonce + ":" + data.qop + ":" + toHex( + getMd5(a2.toString())) + )); + } + + /** + * Let getMd5(s) be the 16 octet MD5 hash [RFC 1321] of the octet string s. + */ + private static byte[] getMd5(String s) { + try { + MessageDigest digester = MessageDigest.getInstance("MD5"); + digester.update(s.getBytes(StandardCharsets.ISO_8859_1)); + return digester.digest(); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + /** + * Let getKeyDigest(k, s) be getMd5({k, ":", s}), i.e., the 16 octet hash of the string k, a colon and the + * string s. + */ + private static byte[] getKeyDigest(String k, String s) { + StringBuilder builder = new StringBuilder(k).append(":").append(s); + return getMd5(builder.toString()); + } + + /** + * Let toHex(n) be the representation of the 16 octet MD5 hash n as a string of 32 hex digits + * (with alphabetic characters always in lower case, since MD5 is case sensitive). + */ + private static String toHex(byte[] n) { + StringBuilder result = new StringBuilder(); + for (byte b : n) { + int unsignedByte = b & 0xFF; + result.append(HEX_CHARS.charAt(unsignedByte / 16)) + .append(HEX_CHARS.charAt(unsignedByte % 16)); + } + return result.toString(); + } + + public static Map parseDigestMessage(String message) throws MessagingException { + Map result = new DigestMessageParser(message).parse(); + if (!result.containsKey(DIGEST_NONCE)) { + throw new MessagingException("nonce missing from server DIGEST-MD5 challenge"); + } + return result; + } + + /** + * Parse the key-value pair returned by the server. + */ + private static class DigestMessageParser { + + private final String mMessage; + private int mPosition = 0; + private Map mResult = new ArrayMap<>(); + + public DigestMessageParser(String message) { + mMessage = message; + } + + @Nullable + public Map parse() { + try { + while (mPosition < mMessage.length()) { + parsePair(); + if (mPosition != mMessage.length()) { + expect(','); + } + } + } catch (IndexOutOfBoundsException e) { + VvmLog.e(TAG, e.toString()); + return null; + } + return mResult; + } + + private void parsePair() { + String key = parseKey(); + expect('='); + String value = parseValue(); + mResult.put(key, value); + } + + private void expect(char c) { + if (pop() != c) { + throw new IllegalStateException( + "unexpected character " + mMessage.charAt(mPosition)); + } + } + + private char pop() { + char result = peek(); + mPosition++; + return result; + } + + private char peek() { + return mMessage.charAt(mPosition); + } + + private void goToNext(char c) { + while (peek() != c) { + mPosition++; + } + } + + private String parseKey() { + int start = mPosition; + goToNext('='); + return mMessage.substring(start, mPosition); + } + + private String parseValue() { + if (peek() == '"') { + return parseQuotedValue(); + } else { + return parseUnquotedValue(); + } + } + + private String parseQuotedValue() { + expect('"'); + StringBuilder result = new StringBuilder(); + while (true) { + char c = pop(); + if (c == '\\') { + result.append(pop()); + } else if (c == '"') { + break; + } else { + result.append(c); + } + } + return result.toString(); + } + + private String parseUnquotedValue() { + StringBuilder result = new StringBuilder(); + while (true) { + char c = pop(); + if (c == '\\') { + result.append(pop()); + } else if (c == ',') { + mPosition--; + break; + } else { + result.append(c); + } + + if (mPosition == mMessage.length()) { + break; + } + } + return result.toString(); + } + } +} diff --git a/java/com/android/voicemailomtp/mail/store/imap/ImapConstants.java b/java/com/android/voicemailomtp/mail/store/imap/ImapConstants.java new file mode 100644 index 000000000..d8e75752f --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/imap/ImapConstants.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.store.imap; + +import com.android.voicemailomtp.mail.store.ImapStore; + +import java.util.Locale; + +public final class ImapConstants { + private ImapConstants() {} + + public static final String FETCH_FIELD_BODY_PEEK_BARE = "BODY.PEEK"; + public static final String FETCH_FIELD_BODY_PEEK = FETCH_FIELD_BODY_PEEK_BARE + "[]"; + public static final String FETCH_FIELD_BODY_PEEK_SANE = String.format( + Locale.US, "BODY.PEEK[]<0.%d>", ImapStore.FETCH_BODY_SANE_SUGGESTED_SIZE); + public static final String FETCH_FIELD_HEADERS = + "BODY.PEEK[HEADER.FIELDS (date subject from content-type to cc message-id)]"; + + public static final String ALERT = "ALERT"; + public static final String APPEND = "APPEND"; + public static final String AUTHENTICATE = "AUTHENTICATE"; + public static final String BAD = "BAD"; + public static final String BADCHARSET = "BADCHARSET"; + public static final String BODY = "BODY"; + public static final String BODY_BRACKET_HEADER = "BODY[HEADER"; + public static final String BODYSTRUCTURE = "BODYSTRUCTURE"; + public static final String BYE = "BYE"; + public static final String CAPABILITY = "CAPABILITY"; + public static final String CHECK = "CHECK"; + public static final String CLOSE = "CLOSE"; + public static final String COPY = "COPY"; + public static final String COPYUID = "COPYUID"; + public static final String CREATE = "CREATE"; + public static final String DELETE = "DELETE"; + public static final String EXAMINE = "EXAMINE"; + public static final String EXISTS = "EXISTS"; + public static final String EXPUNGE = "EXPUNGE"; + public static final String FETCH = "FETCH"; + public static final String FLAG_ANSWERED = "\\ANSWERED"; + public static final String FLAG_DELETED = "\\DELETED"; + public static final String FLAG_FLAGGED = "\\FLAGGED"; + public static final String FLAG_NO_SELECT = "\\NOSELECT"; + public static final String FLAG_SEEN = "\\SEEN"; + public static final String FLAGS = "FLAGS"; + public static final String FLAGS_SILENT = "FLAGS.SILENT"; + public static final String ID = "ID"; + public static final String INBOX = "INBOX"; + public static final String INTERNALDATE = "INTERNALDATE"; + public static final String LIST = "LIST"; + public static final String LOGIN = "LOGIN"; + public static final String LOGOUT = "LOGOUT"; + public static final String LSUB = "LSUB"; + public static final String NAMESPACE = "NAMESPACE"; + public static final String NO = "NO"; + public static final String NOOP = "NOOP"; + public static final String OK = "OK"; + public static final String PARSE = "PARSE"; + public static final String PERMANENTFLAGS = "PERMANENTFLAGS"; + public static final String PREAUTH = "PREAUTH"; + public static final String READ_ONLY = "READ-ONLY"; + public static final String READ_WRITE = "READ-WRITE"; + public static final String RENAME = "RENAME"; + public static final String RFC822_SIZE = "RFC822.SIZE"; + public static final String SEARCH = "SEARCH"; + public static final String SELECT = "SELECT"; + public static final String STARTTLS = "STARTTLS"; + public static final String STATUS = "STATUS"; + public static final String STORE = "STORE"; + public static final String SUBSCRIBE = "SUBSCRIBE"; + public static final String TEXT = "TEXT"; + public static final String TRYCREATE = "TRYCREATE"; + public static final String UID = "UID"; + public static final String UID_COPY = "UID COPY"; + public static final String UID_FETCH = "UID FETCH"; + public static final String UID_SEARCH = "UID SEARCH"; + public static final String UID_STORE = "UID STORE"; + public static final String UIDNEXT = "UIDNEXT"; + public static final String UIDPLUS = "UIDPLUS"; + public static final String UIDVALIDITY = "UIDVALIDITY"; + public static final String UNSEEN = "UNSEEN"; + public static final String UNSUBSCRIBE = "UNSUBSCRIBE"; + public static final String XOAUTH2 = "XOAUTH2"; + public static final String APPENDUID = "APPENDUID"; + public static final String NIL = "NIL"; + + /** + * NO responses + */ + public static final String NO_COMMAND_NOT_ALLOWED = "command not allowed"; + public static final String NO_RESERVATION_FAILED = "reservation failed"; + public static final String NO_APPLICATION_ERROR = "application error"; + public static final String NO_INVALID_PARAMETER = "invalid parameter"; + public static final String NO_INVALID_COMMAND = "invalid command"; + public static final String NO_UNKNOWN_COMMAND = "unknown command"; + // AUTHENTICATE + // The subscriber can not be located in the system. + public static final String NO_UNKNOWN_USER = "unknown user"; + // The Client Type or Protocol Version is unknown. + public static final String NO_UNKNOWN_CLIENT = "unknown client"; + // The password received from the client does not match the password defined in the subscriber's profile. + public static final String NO_INVALID_PASSWORD = "invalid password"; + // The subscriber's mailbox has not yet been initialised via the TUI + public static final String NO_MAILBOX_NOT_INITIALIZED = "mailbox not initialized"; + // The subscriber has not been provisioned for the VVM service. + public static final String NO_SERVICE_IS_NOT_PROVISIONED = + "service is not provisioned"; + // The subscriber is provisioned for the VVM service but the VVM service is currently not active + public static final String NO_SERVICE_IS_NOT_ACTIVATED = "service is not activated"; + // The Voice Mail Blocked flag in the subscriber's profile is set to YES. + public static final String NO_USER_IS_BLOCKED = "user is blocked"; + + /** + * extensions + */ + public static final String GETQUOTA = "GETQUOTA"; + public static final String GETQUOTAROOT = "GETQUOTAROOT"; + public static final String QUOTAROOT = "QUOTAROOT"; + public static final String QUOTA = "QUOTA"; + + /** + * capabilities + */ + public static final String CAPABILITY_AUTH_DIGEST_MD5 = "AUTH=DIGEST-MD5"; + public static final String CAPABILITY_STARTTLS = "STARTTLS"; + + /** + * authentication + */ + public static final String AUTH_DIGEST_MD5 = "DIGEST-MD5"; +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/store/imap/ImapElement.java b/java/com/android/voicemailomtp/mail/store/imap/ImapElement.java new file mode 100644 index 000000000..9f272e31c --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/imap/ImapElement.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.store.imap; + +/** + * Class representing "element"s in IMAP responses. + * + *

Class hierarchy: + *

+ * ImapElement
+ *   |
+ *   |-- ImapElement.NONE (for 'index out of range')
+ *   |
+ *   |-- ImapList (isList() == true)
+ *   |   |
+ *   |   |-- ImapList.EMPTY
+ *   |   |
+ *   |   --- ImapResponse
+ *   |
+ *   --- ImapString (isString() == true)
+ *       |
+ *       |-- ImapString.EMPTY
+ *       |
+ *       |-- ImapSimpleString
+ *       |
+ *       |-- ImapMemoryLiteral
+ *       |
+ *       --- ImapTempFileLiteral
+ * 
+ */ +public abstract class ImapElement { + /** + * An element that is returned by {@link ImapList#getElementOrNone} to indicate an index + * is out of range. + */ + public static final ImapElement NONE = new ImapElement() { + @Override public void destroy() { + // Don't call super.destroy(). + // It's a shared object. We don't want the mDestroyed to be set on this. + } + + @Override public boolean isList() { + return false; + } + + @Override public boolean isString() { + return false; + } + + @Override public String toString() { + return "[NO ELEMENT]"; + } + + @Override + public boolean equalsForTest(ImapElement that) { + return super.equalsForTest(that); + } + }; + + private boolean mDestroyed = false; + + public abstract boolean isList(); + + public abstract boolean isString(); + + protected boolean isDestroyed() { + return mDestroyed; + } + + /** + * Clean up the resources used by the instance. + * It's for removing a temp file used by {@link ImapTempFileLiteral}. + */ + public void destroy() { + mDestroyed = true; + } + + /** + * Throws {@link RuntimeException} if it's already destroyed. + */ + protected final void checkNotDestroyed() { + if (mDestroyed) { + throw new RuntimeException("Already destroyed"); + } + } + + /** + * Return a string that represents this object; it's purely for the debug purpose. Don't + * mistake it for {@link ImapString#getString}. + * + * Abstract to force subclasses to implement it. + */ + @Override + public abstract String toString(); + + /** + * The equals implementation that is intended to be used only for unit testing. + * (Because it may be heavy and has a special sense of "equal" for testing.) + */ + public boolean equalsForTest(ImapElement that) { + if (that == null) { + return false; + } + return this.getClass() == that.getClass(); // Has to be the same class. + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/store/imap/ImapList.java b/java/com/android/voicemailomtp/mail/store/imap/ImapList.java new file mode 100644 index 000000000..970423cbd --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/imap/ImapList.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.store.imap; + +import java.util.ArrayList; + +/** + * Class represents an IMAP list. + */ +public class ImapList extends ImapElement { + /** + * {@link ImapList} representing an empty list. + */ + public static final ImapList EMPTY = new ImapList() { + @Override public void destroy() { + // Don't call super.destroy(). + // It's a shared object. We don't want the mDestroyed to be set on this. + } + + @Override void add(ImapElement e) { + throw new RuntimeException(); + } + }; + + private ArrayList mList = new ArrayList(); + + /* package */ void add(ImapElement e) { + if (e == null) { + throw new RuntimeException("Can't add null"); + } + mList.add(e); + } + + @Override + public final boolean isString() { + return false; + } + + @Override + public final boolean isList() { + return true; + } + + public final int size() { + return mList.size(); + } + + public final boolean isEmpty() { + return size() == 0; + } + + /** + * Return true if the element at {@code index} exists, is string, and equals to {@code s}. + * (case insensitive) + */ + public final boolean is(int index, String s) { + return is(index, s, false); + } + + /** + * Same as {@link #is(int, String)}, but does the prefix match if {@code prefixMatch}. + */ + public final boolean is(int index, String s, boolean prefixMatch) { + if (!prefixMatch) { + return getStringOrEmpty(index).is(s); + } else { + return getStringOrEmpty(index).startsWith(s); + } + } + + /** + * Return the element at {@code index}. + * If {@code index} is out of range, returns {@link ImapElement#NONE}. + */ + public final ImapElement getElementOrNone(int index) { + return (index >= mList.size()) ? ImapElement.NONE : mList.get(index); + } + + /** + * Return the element at {@code index} if it's a list. + * If {@code index} is out of range or not a list, returns {@link ImapList#EMPTY}. + */ + public final ImapList getListOrEmpty(int index) { + ImapElement el = getElementOrNone(index); + return el.isList() ? (ImapList) el : EMPTY; + } + + /** + * Return the element at {@code index} if it's a string. + * If {@code index} is out of range or not a string, returns {@link ImapString#EMPTY}. + */ + public final ImapString getStringOrEmpty(int index) { + ImapElement el = getElementOrNone(index); + return el.isString() ? (ImapString) el : ImapString.EMPTY; + } + + /** + * Return an element keyed by {@code key}. Return null if not found. {@code key} has to be + * at an even index. + */ + /* package */ final ImapElement getKeyedElementOrNull(String key, boolean prefixMatch) { + for (int i = 1; i < size(); i += 2) { + if (is(i-1, key, prefixMatch)) { + return mList.get(i); + } + } + return null; + } + + /** + * Return an {@link ImapList} keyed by {@code key}. + * Return {@link ImapList#EMPTY} if not found. + */ + public final ImapList getKeyedListOrEmpty(String key) { + return getKeyedListOrEmpty(key, false); + } + + /** + * Return an {@link ImapList} keyed by {@code key}. + * Return {@link ImapList#EMPTY} if not found. + */ + public final ImapList getKeyedListOrEmpty(String key, boolean prefixMatch) { + ImapElement e = getKeyedElementOrNull(key, prefixMatch); + return (e != null) ? ((ImapList) e) : ImapList.EMPTY; + } + + /** + * Return an {@link ImapString} keyed by {@code key}. + * Return {@link ImapString#EMPTY} if not found. + */ + public final ImapString getKeyedStringOrEmpty(String key) { + return getKeyedStringOrEmpty(key, false); + } + + /** + * Return an {@link ImapString} keyed by {@code key}. + * Return {@link ImapString#EMPTY} if not found. + */ + public final ImapString getKeyedStringOrEmpty(String key, boolean prefixMatch) { + ImapElement e = getKeyedElementOrNull(key, prefixMatch); + return (e != null) ? ((ImapString) e) : ImapString.EMPTY; + } + + /** + * Return true if it contains {@code s}. + */ + public final boolean contains(String s) { + for (int i = 0; i < size(); i++) { + if (getStringOrEmpty(i).is(s)) { + return true; + } + } + return false; + } + + @Override + public void destroy() { + if (mList != null) { + for (ImapElement e : mList) { + e.destroy(); + } + mList = null; + } + super.destroy(); + } + + @Override + public String toString() { + return mList.toString(); + } + + /** + * Return the text representations of the contents concatenated with ",". + */ + public final String flatten() { + return flatten(new StringBuilder()).toString(); + } + + /** + * Returns text representations (i.e. getString()) of contents joined together with + * "," as the separator. + * + * Only used for building the capability string passed to vendor policies. + * + * We can't use toString(), because it's for debugging (meaning the format may change any time), + * and it won't expand literals. + */ + private final StringBuilder flatten(StringBuilder sb) { + sb.append('['); + for (int i = 0; i < mList.size(); i++) { + if (i > 0) { + sb.append(','); + } + final ImapElement e = getElementOrNone(i); + if (e.isList()) { + getListOrEmpty(i).flatten(sb); + } else if (e.isString()) { + sb.append(getStringOrEmpty(i).getString()); + } + } + sb.append(']'); + return sb; + } + + @Override + public boolean equalsForTest(ImapElement that) { + if (!super.equalsForTest(that)) { + return false; + } + ImapList thatList = (ImapList) that; + if (size() != thatList.size()) { + return false; + } + for (int i = 0; i < size(); i++) { + if (!mList.get(i).equalsForTest(thatList.getElementOrNone(i))) { + return false; + } + } + return true; + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/store/imap/ImapMemoryLiteral.java b/java/com/android/voicemailomtp/mail/store/imap/ImapMemoryLiteral.java new file mode 100644 index 000000000..ad60ca7a4 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/imap/ImapMemoryLiteral.java @@ -0,0 +1,76 @@ +/* + * 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.voicemailomtp.mail.store.imap; + +import com.android.voicemailomtp.mail.FixedLengthInputStream; +import com.android.voicemailomtp.VvmLog; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; + +/** + * Subclass of {@link ImapString} used for literals backed by an in-memory byte array. + */ +public class ImapMemoryLiteral extends ImapString { + private final String TAG = "ImapMemoryLiteral"; + private byte[] mData; + + /* package */ ImapMemoryLiteral(FixedLengthInputStream in) throws IOException { + // We could use ByteArrayOutputStream and IOUtils.copy, but it'd perform an unnecessary + // copy.... + mData = new byte[in.getLength()]; + int pos = 0; + while (pos < mData.length) { + int read = in.read(mData, pos, mData.length - pos); + if (read < 0) { + break; + } + pos += read; + } + if (pos != mData.length) { + VvmLog.w(TAG, "length mismatch"); + } + } + + @Override + public void destroy() { + mData = null; + super.destroy(); + } + + @Override + public String getString() { + try { + return new String(mData, "US-ASCII"); + } catch (UnsupportedEncodingException e) { + VvmLog.e(TAG, "Unsupported encoding: ", e); + } + return null; + } + + @Override + public InputStream getAsStream() { + return new ByteArrayInputStream(mData); + } + + @Override + public String toString() { + return String.format("{%d byte literal(memory)}", mData.length); + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/store/imap/ImapResponse.java b/java/com/android/voicemailomtp/mail/store/imap/ImapResponse.java new file mode 100644 index 000000000..412f16d8a --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/imap/ImapResponse.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.store.imap; + +/** + * Class represents an IMAP response. + */ +public class ImapResponse extends ImapList { + private final String mTag; + private final boolean mIsContinuationRequest; + + /* package */ ImapResponse(String tag, boolean isContinuationRequest) { + mTag = tag; + mIsContinuationRequest = isContinuationRequest; + } + + /* package */ static boolean isStatusResponse(String symbol) { + return ImapConstants.OK.equalsIgnoreCase(symbol) + || ImapConstants.NO.equalsIgnoreCase(symbol) + || ImapConstants.BAD.equalsIgnoreCase(symbol) + || ImapConstants.PREAUTH.equalsIgnoreCase(symbol) + || ImapConstants.BYE.equalsIgnoreCase(symbol); + } + + /** + * @return whether it's a tagged response. + */ + public boolean isTagged() { + return mTag != null; + } + + /** + * @return whether it's a continuation request. + */ + public boolean isContinuationRequest() { + return mIsContinuationRequest; + } + + public boolean isStatusResponse() { + return isStatusResponse(getStringOrEmpty(0).getString()); + } + + /** + * @return whether it's an OK response. + */ + public boolean isOk() { + return is(0, ImapConstants.OK); + } + + /** + * @return whether it's an BAD response. + */ + public boolean isBad() { + return is(0, ImapConstants.BAD); + } + + /** + * @return whether it's an NO response. + */ + public boolean isNo() { + return is(0, ImapConstants.NO); + } + + /** + * @return whether it's an {@code responseType} data response. (i.e. not tagged). + * @param index where {@code responseType} should appear. e.g. 1 for "FETCH" + * @param responseType e.g. "FETCH" + */ + public final boolean isDataResponse(int index, String responseType) { + return !isTagged() && getStringOrEmpty(index).is(responseType); + } + + /** + * @return Response code (RFC 3501 7.1) if it's a status response. + * + * e.g. "ALERT" for "* OK [ALERT] System shutdown in 10 minutes" + */ + public ImapString getResponseCodeOrEmpty() { + if (!isStatusResponse()) { + return ImapString.EMPTY; // Not a status response. + } + return getListOrEmpty(1).getStringOrEmpty(0); + } + + /** + * @return Alert message it it has ALERT response code. + * + * e.g. "System shutdown in 10 minutes" for "* OK [ALERT] System shutdown in 10 minutes" + */ + public ImapString getAlertTextOrEmpty() { + if (!getResponseCodeOrEmpty().is(ImapConstants.ALERT)) { + return ImapString.EMPTY; // Not an ALERT + } + // The 3rd element contains all the rest of line. + return getStringOrEmpty(2); + } + + /** + * @return Response text in a status response. + */ + public ImapString getStatusResponseTextOrEmpty() { + if (!isStatusResponse()) { + return ImapString.EMPTY; + } + return getStringOrEmpty(getElementOrNone(1).isList() ? 2 : 1); + } + + public ImapString getStatusOrEmpty() { + if (!isStatusResponse()) { + return ImapString.EMPTY; + } + return getStringOrEmpty(0); + } + + @Override + public String toString() { + String tag = mTag; + if (isContinuationRequest()) { + tag = "+"; + } + return "#" + tag + "# " + super.toString(); + } + + @Override + public boolean equalsForTest(ImapElement that) { + if (!super.equalsForTest(that)) { + return false; + } + final ImapResponse thatResponse = (ImapResponse) that; + if (mTag == null) { + if (thatResponse.mTag != null) { + return false; + } + } else { + if (!mTag.equals(thatResponse.mTag)) { + return false; + } + } + if (mIsContinuationRequest != thatResponse.mIsContinuationRequest) { + return false; + } + return true; + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/store/imap/ImapResponseParser.java b/java/com/android/voicemailomtp/mail/store/imap/ImapResponseParser.java new file mode 100644 index 000000000..692596f14 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/imap/ImapResponseParser.java @@ -0,0 +1,432 @@ +/* + * 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.voicemailomtp.mail.store.imap; + +import android.text.TextUtils; +import android.util.Log; + +import com.android.voicemailomtp.mail.FixedLengthInputStream; +import com.android.voicemailomtp.mail.MessagingException; +import com.android.voicemailomtp.mail.PeekableInputStream; +import com.android.voicemailomtp.VvmLog; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; + +/** + * IMAP response parser. + */ +public class ImapResponseParser { + private static final String TAG = "ImapResponseParser"; + + /** + * Literal larger than this will be stored in temp file. + */ + public static final int LITERAL_KEEP_IN_MEMORY_THRESHOLD = 2 * 1024 * 1024; + + /** Input stream */ + private final PeekableInputStream mIn; + + private final int mLiteralKeepInMemoryThreshold; + + /** StringBuilder used by readUntil() */ + private final StringBuilder mBufferReadUntil = new StringBuilder(); + + /** StringBuilder used by parseBareString() */ + private final StringBuilder mParseBareString = new StringBuilder(); + + /** + * We store all {@link ImapResponse} in it. {@link #destroyResponses()} must be called from + * time to time to destroy them and clear it. + */ + private final ArrayList mResponsesToDestroy = new ArrayList(); + + /** + * Exception thrown when we receive BYE. It derives from IOException, so it'll be treated + * in the same way EOF does. + */ + public static class ByeException extends IOException { + public static final String MESSAGE = "Received BYE"; + public ByeException() { + super(MESSAGE); + } + } + + /** + * Public constructor for normal use. + */ + public ImapResponseParser(InputStream in) { + this(in, LITERAL_KEEP_IN_MEMORY_THRESHOLD); + } + + /** + * Constructor for testing to override the literal size threshold. + */ + /* package for test */ ImapResponseParser(InputStream in, int literalKeepInMemoryThreshold) { + mIn = new PeekableInputStream(in); + mLiteralKeepInMemoryThreshold = literalKeepInMemoryThreshold; + } + + private static IOException newEOSException() { + final String message = "End of stream reached"; + VvmLog.d(TAG, message); + return new IOException(message); + } + + /** + * Peek next one byte. + * + * Throws IOException() if reaches EOF. As long as logical response lines end with \r\n, + * we shouldn't see EOF during parsing. + */ + private int peek() throws IOException { + final int next = mIn.peek(); + if (next == -1) { + throw newEOSException(); + } + return next; + } + + /** + * Read and return one byte from {@link #mIn}, and put it in {@link #mDiscourseLogger}. + * + * Throws IOException() if reaches EOF. As long as logical response lines end with \r\n, + * we shouldn't see EOF during parsing. + */ + private int readByte() throws IOException { + int next = mIn.read(); + if (next == -1) { + throw newEOSException(); + } + return next; + } + + /** + * Destroy all the {@link ImapResponse}s stored in the internal storage and clear it. + * + * @see #readResponse() + */ + public void destroyResponses() { + for (ImapResponse r : mResponsesToDestroy) { + r.destroy(); + } + mResponsesToDestroy.clear(); + } + + /** + * Reads the next response available on the stream and returns an + * {@link ImapResponse} object that represents it. + * + *

When this method successfully returns an {@link ImapResponse}, the {@link ImapResponse} + * is stored in the internal storage. When the {@link ImapResponse} is no longer used + * {@link #destroyResponses} should be called to destroy all the responses in the array. + * + * @param byeExpected is a untagged BYE response expected? If not proper cleanup will be done + * and {@link ByeException} will be thrown. + * @return the parsed {@link ImapResponse} object. + * @exception ByeException when detects BYE and byeExpected is false. + */ + public ImapResponse readResponse(boolean byeExpected) throws IOException, MessagingException { + ImapResponse response = null; + try { + response = parseResponse(); + } catch (RuntimeException e) { + // Parser crash -- log network activities. + onParseError(e); + throw e; + } catch (IOException e) { + // Network error, or received an unexpected char. + onParseError(e); + throw e; + } + + // Handle this outside of try-catch. We don't have to dump protocol log when getting BYE. + if (!byeExpected && response.is(0, ImapConstants.BYE)) { + Log.w(TAG, ByeException.MESSAGE); + response.destroy(); + throw new ByeException(); + } + mResponsesToDestroy.add(response); + return response; + } + + private void onParseError(Exception e) { + // Read a few more bytes, so that the log will contain some more context, even if the parser + // crashes in the middle of a response. + // This also makes sure the byte in question will be logged, no matter where it crashes. + // e.g. when parseAtom() peeks and finds at an unexpected char, it throws an exception + // before actually reading it. + // However, we don't want to read too much, because then it may get into an email message. + try { + for (int i = 0; i < 4; i++) { + int b = readByte(); + if (b == -1 || b == '\n') { + break; + } + } + } catch (IOException ignore) { + } + VvmLog.w(TAG, "Exception detected: " + e.getMessage()); + } + + /** + * Read next byte from stream and throw it away. If the byte is different from {@code expected} + * throw {@link MessagingException}. + */ + /* package for test */ void expect(char expected) throws IOException { + final int next = readByte(); + if (expected != next) { + throw new IOException(String.format("Expected %04x (%c) but got %04x (%c)", + (int) expected, expected, next, (char) next)); + } + } + + /** + * Read bytes until we find {@code end}, and return all as string. + * The {@code end} will be read (rather than peeked) and won't be included in the result. + */ + /* package for test */ String readUntil(char end) throws IOException { + mBufferReadUntil.setLength(0); + for (;;) { + final int ch = readByte(); + if (ch != end) { + mBufferReadUntil.append((char) ch); + } else { + return mBufferReadUntil.toString(); + } + } + } + + /** + * Read all bytes until \r\n. + */ + /* package */ String readUntilEol() throws IOException { + String ret = readUntil('\r'); + expect('\n'); // TODO Should this really be error? + return ret; + } + + /** + * Parse and return the response line. + */ + private ImapResponse parseResponse() throws IOException, MessagingException { + // We need to destroy the response if we get an exception. + // So, we first store the response that's being built in responseToDestroy, until it's + // completely built, at which point we copy it into responseToReturn and null out + // responseToDestroyt. + // If responseToDestroy is not null in finally, we destroy it because that means + // we got an exception somewhere. + ImapResponse responseToDestroy = null; + final ImapResponse responseToReturn; + + try { + final int ch = peek(); + if (ch == '+') { // Continuation request + readByte(); // skip + + expect(' '); + responseToDestroy = new ImapResponse(null, true); + + // If it's continuation request, we don't really care what's in it. + responseToDestroy.add(new ImapSimpleString(readUntilEol())); + + // Response has successfully been built. Let's return it. + responseToReturn = responseToDestroy; + responseToDestroy = null; + } else { + // Status response or response data + final String tag; + if (ch == '*') { + tag = null; + readByte(); // skip * + expect(' '); + } else { + tag = readUntil(' '); + } + responseToDestroy = new ImapResponse(tag, false); + + final ImapString firstString = parseBareString(); + responseToDestroy.add(firstString); + + // parseBareString won't eat a space after the string, so we need to skip it, + // if exists. + // If the next char is not ' ', it should be EOL. + if (peek() == ' ') { + readByte(); // skip ' ' + + if (responseToDestroy.isStatusResponse()) { // It's a status response + + // Is there a response code? + final int next = peek(); + if (next == '[') { + responseToDestroy.add(parseList('[', ']')); + if (peek() == ' ') { // Skip following space + readByte(); + } + } + + String rest = readUntilEol(); + if (!TextUtils.isEmpty(rest)) { + // The rest is free-form text. + responseToDestroy.add(new ImapSimpleString(rest)); + } + } else { // It's a response data. + parseElements(responseToDestroy, '\0'); + } + } else { + expect('\r'); + expect('\n'); + } + + // Response has successfully been built. Let's return it. + responseToReturn = responseToDestroy; + responseToDestroy = null; + } + } finally { + if (responseToDestroy != null) { + // We get an exception. + responseToDestroy.destroy(); + } + } + + return responseToReturn; + } + + private ImapElement parseElement() throws IOException, MessagingException { + final int next = peek(); + switch (next) { + case '(': + return parseList('(', ')'); + case '[': + return parseList('[', ']'); + case '"': + readByte(); // Skip " + return new ImapSimpleString(readUntil('"')); + case '{': + return parseLiteral(); + case '\r': // CR + readByte(); // Consume \r + expect('\n'); // Should be followed by LF. + return null; + case '\n': // LF // There shouldn't be a bare LF, but just in case. + readByte(); // Consume \n + return null; + default: + return parseBareString(); + } + } + + /** + * Parses an atom. + * + * Special case: If an atom contains '[', everything until the next ']' will be considered + * a part of the atom. + * (e.g. "BODY[HEADER.FIELDS ("DATE" ...)]" will become a single ImapString) + * + * If the value is "NIL", returns an empty string. + */ + private ImapString parseBareString() throws IOException, MessagingException { + mParseBareString.setLength(0); + for (;;) { + final int ch = peek(); + + // TODO Can we clean this up? (This condition is from the old parser.) + if (ch == '(' || ch == ')' || ch == '{' || ch == ' ' || + // ']' is not part of atom (it's in resp-specials) + ch == ']' || + // docs claim that flags are \ atom but atom isn't supposed to + // contain + // * and some flags contain * + // ch == '%' || ch == '*' || + ch == '%' || + // TODO probably should not allow \ and should recognize + // it as a flag instead + // ch == '"' || ch == '\' || + ch == '"' || (0x00 <= ch && ch <= 0x1f) || ch == 0x7f) { + if (mParseBareString.length() == 0) { + throw new MessagingException("Expected string, none found."); + } + String s = mParseBareString.toString(); + + // NIL will be always converted into the empty string. + if (ImapConstants.NIL.equalsIgnoreCase(s)) { + return ImapString.EMPTY; + } + return new ImapSimpleString(s); + } else if (ch == '[') { + // Eat all until next ']' + mParseBareString.append((char) readByte()); + mParseBareString.append(readUntil(']')); + mParseBareString.append(']'); // readUntil won't include the end char. + } else { + mParseBareString.append((char) readByte()); + } + } + } + + private void parseElements(ImapList list, char end) + throws IOException, MessagingException { + for (;;) { + for (;;) { + final int next = peek(); + if (next == end) { + return; + } + if (next != ' ') { + break; + } + // Skip space + readByte(); + } + final ImapElement el = parseElement(); + if (el == null) { // EOL + return; + } + list.add(el); + } + } + + private ImapList parseList(char opening, char closing) + throws IOException, MessagingException { + expect(opening); + final ImapList list = new ImapList(); + parseElements(list, closing); + expect(closing); + return list; + } + + private ImapString parseLiteral() throws IOException, MessagingException { + expect('{'); + final int size; + try { + size = Integer.parseInt(readUntil('}')); + } catch (NumberFormatException nfe) { + throw new MessagingException("Invalid length in literal"); + } + if (size < 0) { + throw new MessagingException("Invalid negative length in literal"); + } + expect('\r'); + expect('\n'); + FixedLengthInputStream in = new FixedLengthInputStream(mIn, size); + if (size > mLiteralKeepInMemoryThreshold) { + return new ImapTempFileLiteral(in); + } else { + return new ImapMemoryLiteral(in); + } + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/store/imap/ImapSimpleString.java b/java/com/android/voicemailomtp/mail/store/imap/ImapSimpleString.java new file mode 100644 index 000000000..22d8141a0 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/imap/ImapSimpleString.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.store.imap; + +import com.android.voicemailomtp.VvmLog; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; + +/** + * Subclass of {@link ImapString} used for non literals. + */ +public class ImapSimpleString extends ImapString { + private final String TAG = "ImapSimpleString"; + private String mString; + + /* package */ ImapSimpleString(String string) { + mString = (string != null) ? string : ""; + } + + @Override + public void destroy() { + mString = null; + super.destroy(); + } + + @Override + public String getString() { + return mString; + } + + @Override + public InputStream getAsStream() { + try { + return new ByteArrayInputStream(mString.getBytes("US-ASCII")); + } catch (UnsupportedEncodingException e) { + VvmLog.e(TAG, "Unsupported encoding: ", e); + } + return null; + } + + @Override + public String toString() { + // Purposefully not return just mString, in order to prevent using it instead of getString. + return "\"" + mString + "\""; + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/store/imap/ImapString.java b/java/com/android/voicemailomtp/mail/store/imap/ImapString.java new file mode 100644 index 000000000..83efb6479 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/imap/ImapString.java @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.store.imap; + +import com.android.voicemailomtp.VvmLog; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * Class represents an IMAP "element" that is not a list. + * + * An atom, quoted string, literal, are all represented by this. Values like OK, STATUS are too. + * Also, this class class may contain more arbitrary value like "BODY[HEADER.FIELDS ("DATE")]". + * See {@link ImapResponseParser}. + */ +public abstract class ImapString extends ImapElement { + private static final byte[] EMPTY_BYTES = new byte[0]; + + public static final ImapString EMPTY = new ImapString() { + @Override public void destroy() { + // Don't call super.destroy(). + // It's a shared object. We don't want the mDestroyed to be set on this. + } + + @Override public String getString() { + return ""; + } + + @Override public InputStream getAsStream() { + return new ByteArrayInputStream(EMPTY_BYTES); + } + + @Override public String toString() { + return ""; + } + }; + + // This is used only for parsing IMAP's FETCH ENVELOPE command, in which + // en_US-like date format is used like "01-Jan-2009 11:20:39 -0800", so this should be + // handled by Locale.US + private final static SimpleDateFormat DATE_TIME_FORMAT = + new SimpleDateFormat("dd-MMM-yyyy HH:mm:ss Z", Locale.US); + + private boolean mIsInteger; + private int mParsedInteger; + private Date mParsedDate; + + @Override + public final boolean isList() { + return false; + } + + @Override + public final boolean isString() { + return true; + } + + /** + * @return true if and only if the length of the string is larger than 0. + * + * Note: IMAP NIL is considered an empty string. See {@link ImapResponseParser + * #parseBareString}. + * On the other hand, a quoted/literal string with value NIL (i.e. "NIL" and {3}\r\nNIL) is + * treated literally. + */ + public final boolean isEmpty() { + return getString().length() == 0; + } + + public abstract String getString(); + + public abstract InputStream getAsStream(); + + /** + * @return whether it can be parsed as a number. + */ + public final boolean isNumber() { + if (mIsInteger) { + return true; + } + try { + mParsedInteger = Integer.parseInt(getString()); + mIsInteger = true; + return true; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * @return value parsed as a number, or 0 if the string is not a number. + */ + public final int getNumberOrZero() { + return getNumber(0); + } + + /** + * @return value parsed as a number, or {@code defaultValue} if the string is not a number. + */ + public final int getNumber(int defaultValue) { + if (!isNumber()) { + return defaultValue; + } + return mParsedInteger; + } + + /** + * @return whether it can be parsed as a date using {@link #DATE_TIME_FORMAT}. + */ + public final boolean isDate() { + if (mParsedDate != null) { + return true; + } + if (isEmpty()) { + return false; + } + try { + mParsedDate = DATE_TIME_FORMAT.parse(getString()); + return true; + } catch (ParseException e) { + VvmLog.w("ImapString", getString() + " can't be parsed as a date."); + return false; + } + } + + /** + * @return value it can be parsed as a {@link Date}, or null otherwise. + */ + public final Date getDateOrNull() { + if (!isDate()) { + return null; + } + return mParsedDate; + } + + /** + * @return whether the value case-insensitively equals to {@code s}. + */ + public final boolean is(String s) { + if (s == null) { + return false; + } + return getString().equalsIgnoreCase(s); + } + + + /** + * @return whether the value case-insensitively starts with {@code s}. + */ + public final boolean startsWith(String prefix) { + if (prefix == null) { + return false; + } + final String me = this.getString(); + if (me.length() < prefix.length()) { + return false; + } + return me.substring(0, prefix.length()).equalsIgnoreCase(prefix); + } + + // To force subclasses to implement it. + @Override + public abstract String toString(); + + @Override + public final boolean equalsForTest(ImapElement that) { + if (!super.equalsForTest(that)) { + return false; + } + ImapString thatString = (ImapString) that; + return getString().equals(thatString.getString()); + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/store/imap/ImapTempFileLiteral.java b/java/com/android/voicemailomtp/mail/store/imap/ImapTempFileLiteral.java new file mode 100644 index 000000000..efe5c3848 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/imap/ImapTempFileLiteral.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.store.imap; + +import com.android.voicemailomtp.mail.FixedLengthInputStream; +import com.android.voicemailomtp.mail.TempDirectory; +import com.android.voicemailomtp.mail.utils.Utility; +import com.android.voicemailomtp.mail.utils.LogUtils; + +import org.apache.commons.io.IOUtils; + +import java.io.ByteArrayInputStream; +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; + +/** + * Subclass of {@link ImapString} used for literals backed by a temp file. + */ +public class ImapTempFileLiteral extends ImapString { + private final String TAG = "ImapTempFileLiteral"; + + /* package for test */ final File mFile; + + /** Size is purely for toString() */ + private final int mSize; + + /* package */ ImapTempFileLiteral(FixedLengthInputStream stream) throws IOException { + mSize = stream.getLength(); + mFile = File.createTempFile("imap", ".tmp", TempDirectory.getTempDirectory()); + + // Unfortunately, we can't really use deleteOnExit(), because temp filenames are random + // so it'd simply cause a memory leak. + // deleteOnExit() simply adds filenames to a static list and the list will never shrink. + // mFile.deleteOnExit(); + OutputStream out = new FileOutputStream(mFile); + IOUtils.copy(stream, out); + out.close(); + } + + /** + * Make sure we delete the temp file. + * + * We should always be calling {@link ImapResponse#destroy()}, but it's here as a last resort. + */ + @Override + protected void finalize() throws Throwable { + try { + destroy(); + } finally { + super.finalize(); + } + } + + @Override + public InputStream getAsStream() { + checkNotDestroyed(); + try { + return new FileInputStream(mFile); + } catch (FileNotFoundException e) { + // It's probably possible if we're low on storage and the system clears the cache dir. + LogUtils.w(TAG, "ImapTempFileLiteral: Temp file not found"); + + // Return 0 byte stream as a dummy... + return new ByteArrayInputStream(new byte[0]); + } + } + + @Override + public String getString() { + checkNotDestroyed(); + try { + byte[] bytes = IOUtils.toByteArray(getAsStream()); + // Prevent crash from OOM; we've seen this, but only rarely and not reproducibly + if (bytes.length > ImapResponseParser.LITERAL_KEEP_IN_MEMORY_THRESHOLD) { + throw new IOException(); + } + return Utility.fromAscii(bytes); + } catch (IOException e) { + LogUtils.w(TAG, "ImapTempFileLiteral: Error while reading temp file", e); + return ""; + } + } + + @Override + public void destroy() { + try { + if (!isDestroyed() && mFile.exists()) { + mFile.delete(); + } + } catch (RuntimeException re) { + // Just log and ignore. + LogUtils.w(TAG, "Failed to remove temp file: " + re.getMessage()); + } + super.destroy(); + } + + @Override + public String toString() { + return String.format("{%d byte literal(file)}", mSize); + } + + public boolean tempFileExistsForTest() { + return mFile.exists(); + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/store/imap/ImapUtility.java b/java/com/android/voicemailomtp/mail/store/imap/ImapUtility.java new file mode 100644 index 000000000..b045eb32f --- /dev/null +++ b/java/com/android/voicemailomtp/mail/store/imap/ImapUtility.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.store.imap; + +import com.android.voicemailomtp.mail.utils.LogUtils; + +import java.util.ArrayList; + +/** + * Utility methods for use with IMAP. + */ +public class ImapUtility { + public static final String TAG = "ImapUtility"; + /** + * Apply quoting rules per IMAP RFC, + * quoted = DQUOTE *QUOTED-CHAR DQUOTE + * QUOTED-CHAR = / "\" quoted-specials + * quoted-specials = DQUOTE / "\" + * + * This is used primarily for IMAP login, but might be useful elsewhere. + * + * NOTE: Not very efficient - you may wish to preflight this, or perhaps it should check + * for trouble chars before calling the replace functions. + * + * @param s The string to be quoted. + * @return A copy of the string, having undergone quoting as described above + */ + public static String imapQuoted(String s) { + + // First, quote any backslashes by replacing \ with \\ + // regex Pattern: \\ (Java string const = \\\\) + // Substitute: \\\\ (Java string const = \\\\\\\\) + String result = s.replaceAll("\\\\", "\\\\\\\\"); + + // Then, quote any double-quotes by replacing " with \" + // regex Pattern: " (Java string const = \") + // Substitute: \\" (Java string const = \\\\\") + result = result.replaceAll("\"", "\\\\\""); + + // return string with quotes around it + return "\"" + result + "\""; + } + + /** + * Gets all of the values in a sequence set per RFC 3501. Any ranges are expanded into a + * list of individual numbers. If the set is invalid, an empty array is returned. + *

+     * sequence-number = nz-number / "*"
+     * sequence-range  = sequence-number ":" sequence-number
+     * sequence-set    = (sequence-number / sequence-range) *("," sequence-set)
+     * 
+ */ + public static String[] getImapSequenceValues(String set) { + ArrayList list = new ArrayList(); + if (set != null) { + String[] setItems = set.split(","); + for (String item : setItems) { + if (item.indexOf(':') == -1) { + // simple item + try { + Integer.parseInt(item); // Don't need the value; just ensure it's valid + list.add(item); + } catch (NumberFormatException e) { + LogUtils.d(TAG, "Invalid UID value", e); + } + } else { + // range + for (String rangeItem : getImapRangeValues(item)) { + list.add(rangeItem); + } + } + } + } + String[] stringList = new String[list.size()]; + return list.toArray(stringList); + } + + /** + * Expand the given number range into a list of individual numbers. If the range is not valid, + * an empty array is returned. + *
+     * sequence-number = nz-number / "*"
+     * sequence-range  = sequence-number ":" sequence-number
+     * sequence-set    = (sequence-number / sequence-range) *("," sequence-set)
+     * 
+ */ + public static String[] getImapRangeValues(String range) { + ArrayList list = new ArrayList(); + try { + if (range != null) { + int colonPos = range.indexOf(':'); + if (colonPos > 0) { + int first = Integer.parseInt(range.substring(0, colonPos)); + int second = Integer.parseInt(range.substring(colonPos + 1)); + if (first < second) { + for (int i = first; i <= second; i++) { + list.add(Integer.toString(i)); + } + } else { + for (int i = first; i >= second; i--) { + list.add(Integer.toString(i)); + } + } + } + } + } catch (NumberFormatException e) { + LogUtils.d(TAG, "Invalid range value", e); + } + String[] stringList = new String[list.size()]; + return list.toArray(stringList); + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/utility/CountingOutputStream.java b/java/com/android/voicemailomtp/mail/utility/CountingOutputStream.java new file mode 100644 index 000000000..fdf81d44a --- /dev/null +++ b/java/com/android/voicemailomtp/mail/utility/CountingOutputStream.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.utility; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * A simple pass-thru OutputStream that also counts how many bytes are written to it and + * makes that count available to callers. + */ +public class CountingOutputStream extends OutputStream { + private long mCount; + private final OutputStream mOutputStream; + + public CountingOutputStream(OutputStream outputStream) { + mOutputStream = outputStream; + } + + public long getCount() { + return mCount; + } + + @Override + public void write(byte[] buffer, int offset, int count) throws IOException { + mOutputStream.write(buffer, offset, count); + mCount += count; + } + + @Override + public void write(int oneByte) throws IOException { + mOutputStream.write(oneByte); + mCount++; + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/utility/EOLConvertingOutputStream.java b/java/com/android/voicemailomtp/mail/utility/EOLConvertingOutputStream.java new file mode 100644 index 000000000..5b93a92ab --- /dev/null +++ b/java/com/android/voicemailomtp/mail/utility/EOLConvertingOutputStream.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2015 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.voicemailomtp.mail.utility; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +public class EOLConvertingOutputStream extends FilterOutputStream { + int lastChar; + + public EOLConvertingOutputStream(OutputStream out) { + super(out); + } + + @Override + public void write(int oneByte) throws IOException { + if (oneByte == '\n') { + if (lastChar != '\r') { + super.write('\r'); + } + } + super.write(oneByte); + lastChar = oneByte; + } + + @Override + public void flush() throws IOException { + if (lastChar == '\r') { + super.write('\n'); + lastChar = '\n'; + } + super.flush(); + } +} \ No newline at end of file diff --git a/java/com/android/voicemailomtp/mail/utils/LogUtils.java b/java/com/android/voicemailomtp/mail/utils/LogUtils.java new file mode 100644 index 000000000..a213a835e --- /dev/null +++ b/java/com/android/voicemailomtp/mail/utils/LogUtils.java @@ -0,0 +1,413 @@ +/** + * Copyright (c) 2015 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.voicemailomtp.mail.utils; + +import android.net.Uri; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.util.Log; +import com.android.voicemailomtp.VvmLog; +import java.util.List; +import java.util.regex.Pattern; + +public class LogUtils { + public static final String TAG = "Email Log"; + + // "GMT" + "+" or "-" + 4 digits + private static final Pattern DATE_CLEANUP_PATTERN_WRONG_TIMEZONE = + Pattern.compile("GMT([-+]\\d{4})$"); + + private static final String ACCOUNT_PREFIX = "account:"; + + /** + * Priority constant for the println method; use LogUtils.v. + */ + public static final int VERBOSE = Log.VERBOSE; + + /** + * Priority constant for the println method; use LogUtils.d. + */ + public static final int DEBUG = Log.DEBUG; + + /** + * Priority constant for the println method; use LogUtils.i. + */ + public static final int INFO = Log.INFO; + + /** + * Priority constant for the println method; use LogUtils.w. + */ + public static final int WARN = Log.WARN; + + /** + * Priority constant for the println method; use LogUtils.e. + */ + public static final int ERROR = Log.ERROR; + + /** + * Used to enable/disable logging that we don't want included in production releases. This should + * be set to DEBUG for production releases, and VERBOSE for internal builds. + */ + private static final int MAX_ENABLED_LOG_LEVEL = DEBUG; + + private static Boolean sDebugLoggingEnabledForTests = null; + + /** + * Enable debug logging for unit tests. + */ + @VisibleForTesting + public static void setDebugLoggingEnabledForTests(boolean enabled) { + setDebugLoggingEnabledForTestsInternal(enabled); + } + + protected static void setDebugLoggingEnabledForTestsInternal(boolean enabled) { + sDebugLoggingEnabledForTests = Boolean.valueOf(enabled); + } + + /** + * Returns true if the build configuration prevents debug logging. + */ + @VisibleForTesting + public static boolean buildPreventsDebugLogging() { + return MAX_ENABLED_LOG_LEVEL > VERBOSE; + } + + /** + * Returns a boolean indicating whether debug logging is enabled. + */ + protected static boolean isDebugLoggingEnabled(String tag) { + if (buildPreventsDebugLogging()) { + return false; + } + if (sDebugLoggingEnabledForTests != null) { + return sDebugLoggingEnabledForTests.booleanValue(); + } + return Log.isLoggable(tag, Log.DEBUG) || Log.isLoggable(TAG, Log.DEBUG); + } + + /** + * Returns a String for the specified content provider uri. This will do + * sanitation of the uri to remove PII if debug logging is not enabled. + */ + public static String contentUriToString(final Uri uri) { + return contentUriToString(TAG, uri); + } + + /** + * Returns a String for the specified content provider uri. This will do + * sanitation of the uri to remove PII if debug logging is not enabled. + */ + public static String contentUriToString(String tag, Uri uri) { + if (isDebugLoggingEnabled(tag)) { + // Debug logging has been enabled, so log the uri as is + return uri.toString(); + } else { + // Debug logging is not enabled, we want to remove the email address from the uri. + List pathSegments = uri.getPathSegments(); + + Uri.Builder builder = new Uri.Builder() + .scheme(uri.getScheme()) + .authority(uri.getAuthority()) + .query(uri.getQuery()) + .fragment(uri.getFragment()); + + // This assumes that the first path segment is the account + final String account = pathSegments.get(0); + + builder = builder.appendPath(sanitizeAccountName(account)); + for (int i = 1; i < pathSegments.size(); i++) { + builder.appendPath(pathSegments.get(i)); + } + return builder.toString(); + } + } + + /** + * Sanitizes an account name. If debug logging is not enabled, a sanitized name + * is returned. + */ + public static String sanitizeAccountName(String accountName) { + if (TextUtils.isEmpty(accountName)) { + return ""; + } + + return ACCOUNT_PREFIX + sanitizeName(TAG, accountName); + } + + public static String sanitizeName(final String tag, final String name) { + if (TextUtils.isEmpty(name)) { + return ""; + } + + if (isDebugLoggingEnabled(tag)) { + return name; + } + + return String.valueOf(name.hashCode()); + } + + /** + * Checks to see whether or not a log for the specified tag is loggable at the specified level. + */ + public static boolean isLoggable(String tag, int level) { + if (MAX_ENABLED_LOG_LEVEL > level) { + return false; + } + return Log.isLoggable(tag, level) || Log.isLoggable(TAG, level); + } + + /** + * Send a {@link #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 format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int v(String tag, String format, Object... args) { + if (isLoggable(tag, VERBOSE)) { + return VvmLog.v(tag, String.format(format, args)); + } + return 0; + } + + /** + * Send a {@link #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 tr An exception to log + * @param format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int v(String tag, Throwable tr, String format, Object... args) { + if (isLoggable(tag, VERBOSE)) { + return VvmLog.v(tag, String.format(format, args), tr); + } + return 0; + } + + /** + * Send a {@link #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 format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int d(String tag, String format, Object... args) { + if (isLoggable(tag, DEBUG)) { + return VvmLog.d(tag, String.format(format, args)); + } + return 0; + } + + /** + * Send a {@link #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 tr An exception to log + * @param format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int d(String tag, Throwable tr, String format, Object... args) { + if (isLoggable(tag, DEBUG)) { + return VvmLog.d(tag, String.format(format, args), tr); + } + return 0; + } + + /** + * Send a {@link #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 format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int i(String tag, String format, Object... args) { + if (isLoggable(tag, INFO)) { + return VvmLog.i(tag, String.format(format, args)); + } + return 0; + } + + /** + * Send a {@link #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 tr An exception to log + * @param format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int i(String tag, Throwable tr, String format, Object... args) { + if (isLoggable(tag, INFO)) { + return VvmLog.i(tag, String.format(format, args), tr); + } + return 0; + } + + /** + * Send a {@link #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 format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int w(String tag, String format, Object... args) { + if (isLoggable(tag, WARN)) { + return VvmLog.w(tag, String.format(format, args)); + } + return 0; + } + + /** + * Send a {@link #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 tr An exception to log + * @param format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int w(String tag, Throwable tr, String format, Object... args) { + if (isLoggable(tag, WARN)) { + return VvmLog.w(tag, String.format(format, args), tr); + } + return 0; + } + + /** + * Send a {@link #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 format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int e(String tag, String format, Object... args) { + if (isLoggable(tag, ERROR)) { + return VvmLog.e(tag, String.format(format, args)); + } + return 0; + } + + /** + * Send a {@link #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 tr An exception to log + * @param format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int e(String tag, Throwable tr, String format, Object... args) { + if (isLoggable(tag, ERROR)) { + return VvmLog.e(tag, String.format(format, args), tr); + } + return 0; + } + + /** + * What a Terrible Failure: Report a condition that should never happen. + * The error will always be logged at level ASSERT with the call stack. + * Depending on system configuration, a report may be added to the + * {@link android.os.DropBoxManager} and/or the process may be terminated + * immediately with an error dialog. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int wtf(String tag, String format, Object... args) { + return VvmLog.wtf(tag, String.format(format, args), new Error()); + } + + /** + * What a Terrible Failure: Report a condition that should never happen. + * The error will always be logged at level ASSERT with the call stack. + * Depending on system configuration, a report may be added to the + * {@link android.os.DropBoxManager} and/or the process may be terminated + * immediately with an error dialog. + * @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 + * @param format the format string (see {@link java.util.Formatter#format}) + * @param args + * the list of arguments passed to the formatter. If there are + * more arguments than required by {@code format}, + * additional arguments are ignored. + */ + public static int wtf(String tag, Throwable tr, String format, Object... args) { + return VvmLog.wtf(tag, String.format(format, args), tr); + } + + + /** + * Try to make a date MIME(RFC 2822/5322)-compliant. + * + * It fixes: + * - "Thu, 10 Dec 09 15:08:08 GMT-0700" to "Thu, 10 Dec 09 15:08:08 -0700" + * (4 digit zone value can't be preceded by "GMT") + * We got a report saying eBay sends a date in this format + */ + public static String cleanUpMimeDate(String date) { + if (TextUtils.isEmpty(date)) { + return date; + } + date = DATE_CLEANUP_PATTERN_WRONG_TIMEZONE.matcher(date).replaceFirst("$1"); + return date; + } + + + public static String byteToHex(int b) { + return byteToHex(new StringBuilder(), b).toString(); + } + + public static StringBuilder byteToHex(StringBuilder sb, int b) { + b &= 0xFF; + sb.append("0123456789ABCDEF".charAt(b >> 4)); + sb.append("0123456789ABCDEF".charAt(b & 0xF)); + return sb; + } + +} diff --git a/java/com/android/voicemailomtp/mail/utils/Utility.java b/java/com/android/voicemailomtp/mail/utils/Utility.java new file mode 100644 index 000000000..c7286fa64 --- /dev/null +++ b/java/com/android/voicemailomtp/mail/utils/Utility.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2015 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.voicemailomtp.mail.utils; + +import java.io.ByteArrayInputStream; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; + +/** + * Simple utility methods used in email functions. + */ +public class Utility { + public static final Charset ASCII = Charset.forName("US-ASCII"); + + public static final String[] EMPTY_STRINGS = new String[0]; + + /** + * Returns a concatenated string containing the output of every Object's + * toString() method, each separated by the given separator character. + */ + public static String combine(Object[] parts, char separator) { + if (parts == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + sb.append(parts[i].toString()); + if (i < parts.length - 1) { + sb.append(separator); + } + } + return sb.toString(); + } + + /** Converts a String to ASCII bytes */ + public static byte[] toAscii(String s) { + return encode(ASCII, s); + } + + /** Builds a String from ASCII bytes */ + public static String fromAscii(byte[] b) { + return decode(ASCII, b); + } + + private static byte[] encode(Charset charset, String s) { + if (s == null) { + return null; + } + final ByteBuffer buffer = charset.encode(CharBuffer.wrap(s)); + final byte[] bytes = new byte[buffer.limit()]; + buffer.get(bytes); + return bytes; + } + + private static String decode(Charset charset, byte[] b) { + if (b == null) { + return null; + } + final CharBuffer cb = charset.decode(ByteBuffer.wrap(b)); + return new String(cb.array(), 0, cb.length()); + } + + public static ByteArrayInputStream streamFromAsciiString(String ascii) { + return new ByteArrayInputStream(toAscii(ascii)); + } +} diff --git a/java/com/android/voicemailomtp/permissions.xml b/java/com/android/voicemailomtp/permissions.xml new file mode 100644 index 000000000..9326d803a --- /dev/null +++ b/java/com/android/voicemailomtp/permissions.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/java/com/android/voicemailomtp/protocol/CvvmProtocol.java b/java/com/android/voicemailomtp/protocol/CvvmProtocol.java new file mode 100644 index 000000000..48ed99709 --- /dev/null +++ b/java/com/android/voicemailomtp/protocol/CvvmProtocol.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2016 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.voicemailomtp.protocol; + +import android.content.Context; +import android.telecom.PhoneAccountHandle; +import android.telephony.SmsManager; + +import com.android.voicemailomtp.OmtpConstants; +import com.android.voicemailomtp.sms.OmtpCvvmMessageSender; +import com.android.voicemailomtp.sms.OmtpMessageSender; + +/** + * A flavor of OMTP protocol with a different mobile originated (MO) format + * + * Used by carriers such as T-Mobile + */ +public class CvvmProtocol extends VisualVoicemailProtocol { + + private static String IMAP_CHANGE_TUI_PWD_FORMAT = "CHANGE_TUI_PWD PWD=%1$s OLD_PWD=%2$s"; + private static String IMAP_CHANGE_VM_LANG_FORMAT = "CHANGE_VM_LANG Lang=%1$s"; + private static String IMAP_CLOSE_NUT = "CLOSE_NUT"; + + @Override + public OmtpMessageSender createMessageSender(Context context, + PhoneAccountHandle phoneAccountHandle, short applicationPort, + String destinationNumber) { + return new OmtpCvvmMessageSender(context, phoneAccountHandle, applicationPort, + destinationNumber); + } + + @Override + public String getCommand(String command) { + if (command == OmtpConstants.IMAP_CHANGE_TUI_PWD_FORMAT) { + return IMAP_CHANGE_TUI_PWD_FORMAT; + } + if (command == OmtpConstants.IMAP_CLOSE_NUT) { + return IMAP_CLOSE_NUT; + } + if (command == OmtpConstants.IMAP_CHANGE_VM_LANG_FORMAT) { + return IMAP_CHANGE_VM_LANG_FORMAT; + } + return super.getCommand(command); + } +} diff --git a/java/com/android/voicemailomtp/protocol/OmtpProtocol.java b/java/com/android/voicemailomtp/protocol/OmtpProtocol.java new file mode 100644 index 000000000..d88a23285 --- /dev/null +++ b/java/com/android/voicemailomtp/protocol/OmtpProtocol.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2016 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.voicemailomtp.protocol; + +import android.content.Context; +import android.telecom.PhoneAccountHandle; +import android.telephony.SmsManager; + +import com.android.voicemailomtp.OmtpConstants; +import com.android.voicemailomtp.sms.OmtpMessageSender; +import com.android.voicemailomtp.sms.OmtpStandardMessageSender; + +public class OmtpProtocol extends VisualVoicemailProtocol { + + @Override + public OmtpMessageSender createMessageSender(Context context, + PhoneAccountHandle phoneAccountHandle, short applicationPort, + String destinationNumber) { + return new OmtpStandardMessageSender(context, phoneAccountHandle, applicationPort, + destinationNumber, + null, OmtpConstants.PROTOCOL_VERSION1_1, null); + } +} diff --git a/java/com/android/voicemailomtp/protocol/ProtocolHelper.java b/java/com/android/voicemailomtp/protocol/ProtocolHelper.java new file mode 100644 index 000000000..4fca199bf --- /dev/null +++ b/java/com/android/voicemailomtp/protocol/ProtocolHelper.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2016 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.voicemailomtp.protocol; + +import android.telephony.SmsManager; +import android.text.TextUtils; + +import com.android.voicemailomtp.OmtpVvmCarrierConfigHelper; +import com.android.voicemailomtp.VvmLog; +import com.android.voicemailomtp.sms.OmtpMessageSender; + +public class ProtocolHelper { + + private static final String TAG = "ProtocolHelper"; + + public static OmtpMessageSender getMessageSender(VisualVoicemailProtocol protocol, + OmtpVvmCarrierConfigHelper config) { + + int applicationPort = config.getApplicationPort(); + String destinationNumber = config.getDestinationNumber(); + if (TextUtils.isEmpty(destinationNumber)) { + VvmLog.w(TAG, "No destination number for this carrier."); + return null; + } + + return protocol.createMessageSender(config.getContext(), config.getPhoneAccountHandle(), + (short) applicationPort, destinationNumber); + } +} diff --git a/java/com/android/voicemailomtp/protocol/VisualVoicemailProtocol.java b/java/com/android/voicemailomtp/protocol/VisualVoicemailProtocol.java new file mode 100644 index 000000000..9ff2ed167 --- /dev/null +++ b/java/com/android/voicemailomtp/protocol/VisualVoicemailProtocol.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2016 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.voicemailomtp.protocol; + +import android.app.PendingIntent; +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccountHandle; +import android.telephony.SmsManager; +import com.android.voicemailomtp.ActivationTask; +import com.android.voicemailomtp.DefaultOmtpEventHandler; +import com.android.voicemailomtp.OmtpEvents; +import com.android.voicemailomtp.OmtpVvmCarrierConfigHelper; +import com.android.voicemailomtp.VoicemailStatus; +import com.android.voicemailomtp.sms.OmtpMessageSender; +import com.android.voicemailomtp.sms.StatusMessage; + +public abstract class VisualVoicemailProtocol { + + /** + * Activation should cause the carrier to respond with a STATUS SMS. + */ + public void startActivation(OmtpVvmCarrierConfigHelper config, PendingIntent sentIntent) { + OmtpMessageSender messageSender = ProtocolHelper.getMessageSender(this, config); + if (messageSender != null) { + messageSender.requestVvmActivation(sentIntent); + } + } + + public void startDeactivation(OmtpVvmCarrierConfigHelper config) { + OmtpMessageSender messageSender = ProtocolHelper.getMessageSender(this, config); + if (messageSender != null) { + messageSender.requestVvmDeactivation(null); + } + } + + public boolean supportsProvisioning() { + return false; + } + + public void startProvisioning(ActivationTask task, PhoneAccountHandle handle, + OmtpVvmCarrierConfigHelper config, VoicemailStatus.Editor editor, StatusMessage message, + Bundle data) { + // Do nothing + } + + public void requestStatus(OmtpVvmCarrierConfigHelper config, + @Nullable PendingIntent sentIntent) { + OmtpMessageSender messageSender = ProtocolHelper.getMessageSender(this, config); + if (messageSender != null) { + messageSender.requestVvmStatus(sentIntent); + } + } + + public abstract OmtpMessageSender createMessageSender(Context context, + PhoneAccountHandle phoneAccountHandle, + short applicationPort, String destinationNumber); + + /** + * Translate an OMTP IMAP command to the protocol specific one. For example, changing the TUI + * password on OMTP is XCHANGE_TUI_PWD, but on CVVM and VVM3 it is CHANGE_TUI_PWD. + * + * @param command A String command in {@link com.android.voicemailomtp.OmtpConstants}, the exact + * instance should be used instead of its' value. + * @returns Translated command, or {@code null} if not available in this protocol + */ + public String getCommand(String command) { + return command; + } + + public void handleEvent(Context context, OmtpVvmCarrierConfigHelper config, + VoicemailStatus.Editor status, OmtpEvents event) { + DefaultOmtpEventHandler.handleEvent(context, config, status, event); + } + + /** + * Given an VVM SMS with an unknown {@code event}, let the protocol attempt to translate it into + * an equivalent STATUS SMS. Returns {@code null} if it cannot be translated. + */ + @Nullable + public Bundle translateStatusSmsBundle(OmtpVvmCarrierConfigHelper config, String event, + Bundle data) { + return null; + } +} diff --git a/java/com/android/voicemailomtp/protocol/VisualVoicemailProtocolFactory.java b/java/com/android/voicemailomtp/protocol/VisualVoicemailProtocolFactory.java new file mode 100644 index 000000000..b74f503c6 --- /dev/null +++ b/java/com/android/voicemailomtp/protocol/VisualVoicemailProtocolFactory.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2016 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.voicemailomtp.protocol; + +import android.content.res.Resources; +import android.support.annotation.Nullable; +import android.telephony.TelephonyManager; +import com.android.voicemailomtp.VvmLog; + +public class VisualVoicemailProtocolFactory { + + private static final String TAG = "VvmProtocolFactory"; + + private static final String VVM_TYPE_VVM3 = "vvm_type_vvm3"; + + @Nullable + public static VisualVoicemailProtocol create(Resources resources, String type) { + if (type == null) { + return null; + } + switch (type) { + case TelephonyManager.VVM_TYPE_OMTP: + return new OmtpProtocol(); + case TelephonyManager.VVM_TYPE_CVVM: + return new CvvmProtocol(); + case VVM_TYPE_VVM3: + return new Vvm3Protocol(); + default: + VvmLog.e(TAG, "Unexpected visual voicemail type: " + type); + } + return null; + } +} diff --git a/java/com/android/voicemailomtp/protocol/Vvm3EventHandler.java b/java/com/android/voicemailomtp/protocol/Vvm3EventHandler.java new file mode 100644 index 000000000..72646386c --- /dev/null +++ b/java/com/android/voicemailomtp/protocol/Vvm3EventHandler.java @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2016 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.voicemailomtp.protocol; + +import android.content.Context; +import android.support.annotation.IntDef; +import android.util.Log; +import com.android.voicemailomtp.DefaultOmtpEventHandler; +import com.android.voicemailomtp.OmtpEvents; +import com.android.voicemailomtp.OmtpEvents.Type; +import com.android.voicemailomtp.OmtpVvmCarrierConfigHelper; +import com.android.voicemailomtp.VoicemailStatus; +import com.android.voicemailomtp.settings.VoicemailChangePinActivity; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Handles {@link OmtpEvents} when {@link Vvm3Protocol} is being used. This handler writes custom + * error codes into the voicemail status table so support on the dialer side is required. + * + * TODO(b/29577838) disable VVM3 by default so support on system dialer can be ensured. + */ +public class Vvm3EventHandler { + + private static final String TAG = "Vvm3EventHandler"; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({VMS_DNS_FAILURE, VMG_DNS_FAILURE, SPG_DNS_FAILURE, VMS_NO_CELLULAR, VMG_NO_CELLULAR, + SPG_NO_CELLULAR, VMS_TIMEOUT, VMG_TIMEOUT, STATUS_SMS_TIMEOUT, SUBSCRIBER_BLOCKED, + UNKNOWN_USER, UNKNOWN_DEVICE, INVALID_PASSWORD, MAILBOX_NOT_INITIALIZED, + SERVICE_NOT_PROVISIONED, SERVICE_NOT_ACTIVATED, USER_BLOCKED, IMAP_GETQUOTA_ERROR, + IMAP_SELECT_ERROR, IMAP_ERROR, VMG_INTERNAL_ERROR, VMG_DB_ERROR, + VMG_COMMUNICATION_ERROR, SPG_URL_NOT_FOUND, VMG_UNKNOWN_ERROR, PIN_NOT_SET}) + public @interface ErrorCode { + + } + + public static final int VMS_DNS_FAILURE = -9001; + public static final int VMG_DNS_FAILURE = -9002; + public static final int SPG_DNS_FAILURE = -9003; + public static final int VMS_NO_CELLULAR = -9004; + public static final int VMG_NO_CELLULAR = -9005; + public static final int SPG_NO_CELLULAR = -9006; + public static final int VMS_TIMEOUT = -9007; + public static final int VMG_TIMEOUT = -9008; + public static final int STATUS_SMS_TIMEOUT = -9009; + + public static final int SUBSCRIBER_BLOCKED = -9990; + public static final int UNKNOWN_USER = -9991; + public static final int UNKNOWN_DEVICE = -9992; + public static final int INVALID_PASSWORD = -9993; + public static final int MAILBOX_NOT_INITIALIZED = -9994; + public static final int SERVICE_NOT_PROVISIONED = -9995; + public static final int SERVICE_NOT_ACTIVATED = -9996; + public static final int USER_BLOCKED = -9998; + public static final int IMAP_GETQUOTA_ERROR = -9997; + public static final int IMAP_SELECT_ERROR = -9989; + public static final int IMAP_ERROR = -9999; + + public static final int VMG_INTERNAL_ERROR = -101; + public static final int VMG_DB_ERROR = -102; + public static final int VMG_COMMUNICATION_ERROR = -103; + public static final int SPG_URL_NOT_FOUND = -301; + + // Non VVM3 codes: + public static final int VMG_UNKNOWN_ERROR = -1; + public static final int PIN_NOT_SET = -100; + // STATUS SMS returned st=U and rc!=2. The user cannot be provisioned and must contact customer + // support. + public static final int SUBSCRIBER_UNKNOWN = -99; + + + public static void handleEvent(Context context, OmtpVvmCarrierConfigHelper config, + VoicemailStatus.Editor status, OmtpEvents event) { + boolean handled = false; + switch (event.getType()) { + case Type.CONFIGURATION: + handled = handleConfigurationEvent(context, status, event); + break; + case Type.DATA_CHANNEL: + handled = handleDataChannelEvent(status, event); + break; + case Type.NOTIFICATION_CHANNEL: + handled = handleNotificationChannelEvent(status, event); + break; + case Type.OTHER: + handled = handleOtherEvent(status, event); + break; + default: + Log.wtf(TAG, "invalid event type " + event.getType() + " for " + event); + } + if (!handled) { + DefaultOmtpEventHandler.handleEvent(context, config, status, event); + } + } + + private static boolean handleConfigurationEvent(Context context, VoicemailStatus.Editor status, + OmtpEvents event) { + switch (event) { + case CONFIG_REQUEST_STATUS_SUCCESS: + if (status.getPhoneAccountHandle() == null) { + // This should never happen. + Log.e(TAG, "status editor has null phone account handle"); + return true; + } + + if (!VoicemailChangePinActivity + .isDefaultOldPinSet(context, status.getPhoneAccountHandle())) { + return false; + } else { + postError(status, PIN_NOT_SET); + } + break; + case CONFIG_DEFAULT_PIN_REPLACED: + postError(status, PIN_NOT_SET); + break; + case CONFIG_STATUS_SMS_TIME_OUT: + postError(status, STATUS_SMS_TIMEOUT); + break; + default: + return false; + } + return true; + } + + private static boolean handleDataChannelEvent(VoicemailStatus.Editor status, OmtpEvents event) { + switch (event) { + case DATA_NO_CONNECTION: + case DATA_NO_CONNECTION_CELLULAR_REQUIRED: + case DATA_ALL_SOCKET_CONNECTION_FAILED: + postError(status, VMS_NO_CELLULAR); + break; + case DATA_SSL_INVALID_HOST_NAME: + case DATA_CANNOT_ESTABLISH_SSL_SESSION: + case DATA_IOE_ON_OPEN: + postError(status, VMS_TIMEOUT); + break; + case DATA_CANNOT_RESOLVE_HOST_ON_NETWORK: + postError(status, VMS_DNS_FAILURE); + break; + case DATA_BAD_IMAP_CREDENTIAL: + postError(status, IMAP_ERROR); + break; + case DATA_AUTH_UNKNOWN_USER: + postError(status, UNKNOWN_USER); + break; + case DATA_AUTH_UNKNOWN_DEVICE: + postError(status, UNKNOWN_DEVICE); + break; + case DATA_AUTH_INVALID_PASSWORD: + postError(status, INVALID_PASSWORD); + break; + case DATA_AUTH_MAILBOX_NOT_INITIALIZED: + postError(status, MAILBOX_NOT_INITIALIZED); + break; + case DATA_AUTH_SERVICE_NOT_PROVISIONED: + postError(status, SERVICE_NOT_PROVISIONED); + break; + case DATA_AUTH_SERVICE_NOT_ACTIVATED: + postError(status, SERVICE_NOT_ACTIVATED); + break; + case DATA_AUTH_USER_IS_BLOCKED: + postError(status, USER_BLOCKED); + break; + case DATA_REJECTED_SERVER_RESPONSE: + case DATA_INVALID_INITIAL_SERVER_RESPONSE: + case DATA_SSL_EXCEPTION: + postError(status, IMAP_ERROR); + break; + default: + return false; + } + return true; + } + + private static boolean handleNotificationChannelEvent(VoicemailStatus.Editor status, + OmtpEvents event) { + return false; + } + + private static boolean handleOtherEvent(VoicemailStatus.Editor status, + OmtpEvents event) { + switch (event) { + case VVM3_NEW_USER_SETUP_FAILED: + postError(status, MAILBOX_NOT_INITIALIZED); + break; + case VVM3_VMG_DNS_FAILURE: + postError(status, VMG_DNS_FAILURE); + break; + case VVM3_SPG_DNS_FAILURE: + postError(status, SPG_DNS_FAILURE); + break; + case VVM3_VMG_CONNECTION_FAILED: + postError(status, VMG_NO_CELLULAR); + break; + case VVM3_SPG_CONNECTION_FAILED: + postError(status, SPG_NO_CELLULAR); + break; + case VVM3_VMG_TIMEOUT: + postError(status, VMG_TIMEOUT); + break; + case VVM3_SUBSCRIBER_PROVISIONED: + postError(status, SERVICE_NOT_ACTIVATED); + break; + case VVM3_SUBSCRIBER_BLOCKED: + postError(status, SUBSCRIBER_BLOCKED); + break; + case VVM3_SUBSCRIBER_UNKNOWN: + postError(status, SUBSCRIBER_UNKNOWN); + break; + default: + return false; + } + return true; + } + + private static void postError(VoicemailStatus.Editor editor, @ErrorCode int errorCode) { + switch (errorCode) { + case VMG_DNS_FAILURE: + case SPG_DNS_FAILURE: + case VMG_NO_CELLULAR: + case SPG_NO_CELLULAR: + case VMG_TIMEOUT: + case SUBSCRIBER_BLOCKED: + case UNKNOWN_USER: + case UNKNOWN_DEVICE: + case INVALID_PASSWORD: + case MAILBOX_NOT_INITIALIZED: + case SERVICE_NOT_PROVISIONED: + case SERVICE_NOT_ACTIVATED: + case USER_BLOCKED: + case VMG_UNKNOWN_ERROR: + case SPG_URL_NOT_FOUND: + case VMG_INTERNAL_ERROR: + case VMG_DB_ERROR: + case VMG_COMMUNICATION_ERROR: + case PIN_NOT_SET: + case SUBSCRIBER_UNKNOWN: + editor.setConfigurationState(errorCode); + break; + case VMS_NO_CELLULAR: + case VMS_DNS_FAILURE: + case VMS_TIMEOUT: + case IMAP_GETQUOTA_ERROR: + case IMAP_SELECT_ERROR: + case IMAP_ERROR: + editor.setDataChannelState(errorCode); + break; + case STATUS_SMS_TIMEOUT: + editor.setNotificationChannelState(errorCode); + break; + default: + Log.wtf(TAG, "unknown error code: " + errorCode); + } + editor.apply(); + } +} diff --git a/java/com/android/voicemailomtp/protocol/Vvm3Protocol.java b/java/com/android/voicemailomtp/protocol/Vvm3Protocol.java new file mode 100644 index 000000000..652d1010a --- /dev/null +++ b/java/com/android/voicemailomtp/protocol/Vvm3Protocol.java @@ -0,0 +1,301 @@ +/* + * Copyright (C) 2016 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.voicemailomtp.protocol; + +import android.annotation.TargetApi; +import android.app.PendingIntent; +import android.content.Context; +import android.net.Network; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccountHandle; +import android.text.TextUtils; +import com.android.voicemailomtp.ActivationTask; +import com.android.voicemailomtp.OmtpConstants; +import com.android.voicemailomtp.OmtpEvents; +import com.android.voicemailomtp.OmtpVvmCarrierConfigHelper; +import com.android.voicemailomtp.VisualVoicemailPreferences; +import com.android.voicemailomtp.VoicemailStatus; +import com.android.voicemailomtp.VvmLog; +import com.android.voicemailomtp.imap.ImapHelper; +import com.android.voicemailomtp.imap.ImapHelper.InitializingException; +import com.android.voicemailomtp.mail.MessagingException; +import com.android.voicemailomtp.settings.VisualVoicemailSettingsUtil; +import com.android.voicemailomtp.settings.VoicemailChangePinActivity; +import com.android.voicemailomtp.sms.OmtpMessageSender; +import com.android.voicemailomtp.sms.StatusMessage; +import com.android.voicemailomtp.sms.Vvm3MessageSender; +import com.android.voicemailomtp.sync.VvmNetworkRequest; +import com.android.voicemailomtp.sync.VvmNetworkRequest.NetworkWrapper; +import com.android.voicemailomtp.sync.VvmNetworkRequest.RequestFailedException; +import java.io.IOException; +import java.security.SecureRandom; +import java.util.Locale; + +/** + * A flavor of OMTP protocol with a different provisioning process + * + *

Used by carriers such as Verizon Wireless + */ +@TargetApi(VERSION_CODES.CUR_DEVELOPMENT) +public class Vvm3Protocol extends VisualVoicemailProtocol { + + private static final String TAG = "Vvm3Protocol"; + + private static final String SMS_EVENT_UNRECOGNIZED = "UNRECOGNIZED"; + private static final String SMS_EVENT_UNRECOGNIZED_CMD = "cmd"; + private static final String SMS_EVENT_UNRECOGNIZED_STATUS = "STATUS"; + private static final String DEFAULT_VMG_URL_KEY = "default_vmg_url"; + + private static final String IMAP_CHANGE_TUI_PWD_FORMAT = "CHANGE_TUI_PWD PWD=%1$s OLD_PWD=%2$s"; + private static final String IMAP_CHANGE_VM_LANG_FORMAT = "CHANGE_VM_LANG Lang=%1$s"; + private static final String IMAP_CLOSE_NUT = "CLOSE_NUT"; + + private static final String ISO639_Spanish = "es"; + + /** + * For VVM3, if the STATUS SMS returns {@link StatusMessage#getProvisioningStatus()} of {@link + * OmtpConstants#SUBSCRIBER_UNKNOWN} and {@link StatusMessage#getReturnCode()} of this value, + * the user can self-provision visual voicemail service. For other response codes, the user must + * contact customer support to resolve the issue. + */ + private static final String VVM3_UNKNOWN_SUBSCRIBER_CAN_SUBSCRIBE_RESPONSE_CODE = "2"; + + // Default prompt level when using the telephone user interface. + // Standard prompt when the user call into the voicemail, and no prompts when someone else is + // leaving a voicemail. + private static final String VVM3_VM_LANGUAGE_ENGLISH_STANDARD_NO_GUEST_PROMPTS = "5"; + private static final String VVM3_VM_LANGUAGE_SPANISH_STANDARD_NO_GUEST_PROMPTS = "6"; + + private static final int DEFAULT_PIN_LENGTH = 6; + + @Override + public void startActivation(OmtpVvmCarrierConfigHelper config, + @Nullable PendingIntent sentIntent) { + // VVM3 does not support activation SMS. + // Send a status request which will start the provisioning process if the user is not + // provisioned. + VvmLog.i(TAG, "Activating"); + config.requestStatus(sentIntent); + } + + @Override + public void startDeactivation(OmtpVvmCarrierConfigHelper config) { + // VVM3 does not support deactivation. + // do nothing. + } + + @Override + public boolean supportsProvisioning() { + return true; + } + + @Override + public void startProvisioning(ActivationTask task, PhoneAccountHandle phoneAccountHandle, + OmtpVvmCarrierConfigHelper config, VoicemailStatus.Editor status, StatusMessage message, + Bundle data) { + VvmLog.i(TAG, "start vvm3 provisioning"); + if (OmtpConstants.SUBSCRIBER_UNKNOWN.equals(message.getProvisioningStatus())) { + VvmLog.i(TAG, "Provisioning status: Unknown"); + if (VVM3_UNKNOWN_SUBSCRIBER_CAN_SUBSCRIBE_RESPONSE_CODE + .equals(message.getReturnCode())) { + VvmLog.i(TAG, "Self provisioning available, subscribing"); + new Vvm3Subscriber(task, phoneAccountHandle, config, status, data).subscribe(); + } else { + config.handleEvent(status, OmtpEvents.VVM3_SUBSCRIBER_UNKNOWN); + } + } else if (OmtpConstants.SUBSCRIBER_NEW.equals(message.getProvisioningStatus())) { + VvmLog.i(TAG, "setting up new user"); + // Save the IMAP credentials in preferences so they are persistent and can be retrieved. + VisualVoicemailPreferences prefs = + new VisualVoicemailPreferences(config.getContext(), phoneAccountHandle); + message.putStatus(prefs.edit()).apply(); + + startProvisionNewUser(task, phoneAccountHandle, config, status, message); + } else if (OmtpConstants.SUBSCRIBER_PROVISIONED.equals(message.getProvisioningStatus())) { + VvmLog.i(TAG, "User provisioned but not activated, disabling VVM"); + VisualVoicemailSettingsUtil + .setEnabled(config.getContext(), phoneAccountHandle, false); + } else if (OmtpConstants.SUBSCRIBER_BLOCKED.equals(message.getProvisioningStatus())) { + VvmLog.i(TAG, "User blocked"); + config.handleEvent(status, OmtpEvents.VVM3_SUBSCRIBER_BLOCKED); + } + } + + @Override + public OmtpMessageSender createMessageSender(Context context, + PhoneAccountHandle phoneAccountHandle, short applicationPort, + String destinationNumber) { + return new Vvm3MessageSender(context, phoneAccountHandle, applicationPort, + destinationNumber); + } + + @Override + public void handleEvent(Context context, OmtpVvmCarrierConfigHelper config, + VoicemailStatus.Editor status, OmtpEvents event) { + Vvm3EventHandler.handleEvent(context, config, status, event); + } + + @Override + public String getCommand(String command) { + if (command == OmtpConstants.IMAP_CHANGE_TUI_PWD_FORMAT) { + return IMAP_CHANGE_TUI_PWD_FORMAT; + } + if (command == OmtpConstants.IMAP_CLOSE_NUT) { + return IMAP_CLOSE_NUT; + } + if (command == OmtpConstants.IMAP_CHANGE_VM_LANG_FORMAT) { + return IMAP_CHANGE_VM_LANG_FORMAT; + } + return super.getCommand(command); + } + + @Override + public Bundle translateStatusSmsBundle(OmtpVvmCarrierConfigHelper config, String event, + Bundle data) { + // UNRECOGNIZED?cmd=STATUS is the response of a STATUS request when the user is provisioned + // with iPhone visual voicemail without VoLTE. Translate it into an unprovisioned status + // so provisioning can be done. + if (!SMS_EVENT_UNRECOGNIZED.equals(event)) { + return null; + } + if (!SMS_EVENT_UNRECOGNIZED_STATUS.equals(data.getString(SMS_EVENT_UNRECOGNIZED_CMD))) { + return null; + } + Bundle bundle = new Bundle(); + bundle.putString(OmtpConstants.PROVISIONING_STATUS, OmtpConstants.SUBSCRIBER_UNKNOWN); + bundle.putString(OmtpConstants.RETURN_CODE, + VVM3_UNKNOWN_SUBSCRIBER_CAN_SUBSCRIBE_RESPONSE_CODE); + String vmgUrl = config.getString(DEFAULT_VMG_URL_KEY); + if (TextUtils.isEmpty(vmgUrl)) { + VvmLog.e(TAG, "Unable to translate STATUS SMS: VMG URL is not set in config"); + return null; + } + bundle.putString(Vvm3Subscriber.VMG_URL_KEY, vmgUrl); + VvmLog.i(TAG, "UNRECOGNIZED?cmd=STATUS translated into unprovisioned STATUS SMS"); + return bundle; + } + + private void startProvisionNewUser(ActivationTask task, PhoneAccountHandle phoneAccountHandle, + OmtpVvmCarrierConfigHelper config, VoicemailStatus.Editor status, + StatusMessage message) { + try (NetworkWrapper wrapper = VvmNetworkRequest + .getNetwork(config, phoneAccountHandle, status)) { + Network network = wrapper.get(); + + VvmLog.i(TAG, "new user: network available"); + try (ImapHelper helper = new ImapHelper(config.getContext(), phoneAccountHandle, + network, status)) { + // VVM3 has inconsistent error language code to OMTP. Just issue a raw command + // here. + // TODO(b/29082671): use LocaleList + if (Locale.getDefault().getLanguage() + .equals(new Locale(ISO639_Spanish).getLanguage())) { + // Spanish + helper.changeVoicemailTuiLanguage( + VVM3_VM_LANGUAGE_SPANISH_STANDARD_NO_GUEST_PROMPTS); + } else { + // English + helper.changeVoicemailTuiLanguage( + VVM3_VM_LANGUAGE_ENGLISH_STANDARD_NO_GUEST_PROMPTS); + } + VvmLog.i(TAG, "new user: language set"); + + if (setPin(config.getContext(), phoneAccountHandle, helper, message)) { + // Only close new user tutorial if the PIN has been changed. + helper.closeNewUserTutorial(); + VvmLog.i(TAG, "new user: NUT closed"); + + config.requestStatus(null); + } + } catch (InitializingException | MessagingException | IOException e) { + config.handleEvent(status, OmtpEvents.VVM3_NEW_USER_SETUP_FAILED); + task.fail(); + VvmLog.e(TAG, e.toString()); + } + } catch (RequestFailedException e) { + config.handleEvent(status, OmtpEvents.DATA_NO_CONNECTION_CELLULAR_REQUIRED); + task.fail(); + } + + } + + + private static boolean setPin(Context context, PhoneAccountHandle phoneAccountHandle, + ImapHelper helper, StatusMessage message) + throws IOException, MessagingException { + String defaultPin = getDefaultPin(message); + if (defaultPin == null) { + VvmLog.i(TAG, "cannot generate default PIN"); + return false; + } + + if (VoicemailChangePinActivity.isDefaultOldPinSet(context, phoneAccountHandle)) { + // The pin was already set + VvmLog.i(TAG, "PIN already set"); + return true; + } + String newPin = generatePin(getMinimumPinLength(context, phoneAccountHandle)); + if (helper.changePin(defaultPin, newPin) == OmtpConstants.CHANGE_PIN_SUCCESS) { + VoicemailChangePinActivity.setDefaultOldPIN(context, phoneAccountHandle, newPin); + helper.handleEvent(OmtpEvents.CONFIG_DEFAULT_PIN_REPLACED); + } + VvmLog.i(TAG, "new user: PIN set"); + return true; + } + + @Nullable + private static String getDefaultPin(StatusMessage message) { + // The IMAP username is [phone number]@example.com + String username = message.getImapUserName(); + try { + String number = username.substring(0, username.indexOf('@')); + if (number.length() < 4) { + VvmLog.e(TAG, "unable to extract number from IMAP username"); + return null; + } + return "1" + number.substring(number.length() - 4); + } catch (StringIndexOutOfBoundsException e) { + VvmLog.e(TAG, "unable to extract number from IMAP username"); + return null; + } + + } + + private static int getMinimumPinLength(Context context, PhoneAccountHandle phoneAccountHandle) { + VisualVoicemailPreferences preferences = new VisualVoicemailPreferences(context, + phoneAccountHandle); + // The OMTP pin length format is {min}-{max} + String[] lengths = preferences.getString(OmtpConstants.TUI_PASSWORD_LENGTH, "").split("-"); + if (lengths.length == 2) { + try { + return Integer.parseInt(lengths[0]); + } catch (NumberFormatException e) { + return DEFAULT_PIN_LENGTH; + } + } + return DEFAULT_PIN_LENGTH; + } + + private static String generatePin(int length) { + SecureRandom random = new SecureRandom(); + return String.format(Locale.US, "%010d", Math.abs(random.nextLong())) + .substring(0, length); + + } +} diff --git a/java/com/android/voicemailomtp/protocol/Vvm3Subscriber.java b/java/com/android/voicemailomtp/protocol/Vvm3Subscriber.java new file mode 100644 index 000000000..0a4d792b2 --- /dev/null +++ b/java/com/android/voicemailomtp/protocol/Vvm3Subscriber.java @@ -0,0 +1,326 @@ +/* + * Copyright (C) 2016 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.voicemailomtp.protocol; + +import android.annotation.TargetApi; +import android.net.Network; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.support.annotation.WorkerThread; +import android.telecom.PhoneAccountHandle; +import android.telephony.TelephonyManager; +import android.text.Html; +import android.text.Spanned; +import android.text.style.URLSpan; +import android.util.ArrayMap; +import com.android.voicemailomtp.ActivationTask; +import com.android.voicemailomtp.Assert; +import com.android.voicemailomtp.OmtpEvents; +import com.android.voicemailomtp.OmtpVvmCarrierConfigHelper; +import com.android.voicemailomtp.VoicemailStatus; +import com.android.voicemailomtp.VvmLog; +import com.android.voicemailomtp.sync.VvmNetworkRequest; +import com.android.voicemailomtp.sync.VvmNetworkRequest.NetworkWrapper; +import com.android.voicemailomtp.sync.VvmNetworkRequest.RequestFailedException; +import com.android.volley.AuthFailureError; +import com.android.volley.Request; +import com.android.volley.RequestQueue; +import com.android.volley.toolbox.HurlStack; +import com.android.volley.toolbox.RequestFuture; +import com.android.volley.toolbox.StringRequest; +import com.android.volley.toolbox.Volley; +import java.io.IOException; +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Locale; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Class to subscribe to basic VVM3 visual voicemail, for example, Verizon. Subscription is required + * when the user is unprovisioned. This could happen when the user is on a legacy service, or + * switched over from devices that used other type of visual voicemail. + * + *

The STATUS SMS will come with a URL to the voicemail management gateway. From it we can find + * the self provisioning gateway URL that we can modify voicemail services. + * + *

A request to the self provisioning gateway to activate basic visual voicemail will return us + * with a web page. If the user hasn't subscribe to it yet it will contain a link to confirm the + * subscription. This link should be clicked through cellular network, and have cookies enabled. + * + *

After the process is completed, the carrier should send us another STATUS SMS with a new or + * ready user. + */ +@TargetApi(VERSION_CODES.CUR_DEVELOPMENT) +public class Vvm3Subscriber { + + private static final String TAG = "Vvm3Subscriber"; + + private static final String OPERATION_GET_SPG_URL = "retrieveSPGURL"; + private static final String SPG_URL_TAG = "spgurl"; + private static final String TRANSACTION_ID_TAG = "transactionid"; + //language=XML + private static final String VMG_XML_REQUEST_FORMAT = "" + + "" + + "" + + " " + + " %1$s" + + " " + + " " + + " %2$s" + + " %3$s" + + " Device" + + " %4$s" + + " " + + ""; + + static final String VMG_URL_KEY = "vmg_url"; + + // Self provisioning POST key/values. VVM3 API 2.1.0 12.3 + private static final String SPG_VZW_MDN_PARAM = "VZW_MDN"; + private static final String SPG_VZW_SERVICE_PARAM = "VZW_SERVICE"; + private static final String SPG_VZW_SERVICE_BASIC = "BVVM"; + private static final String SPG_DEVICE_MODEL_PARAM = "DEVICE_MODEL"; + // Value for all android device + private static final String SPG_DEVICE_MODEL_ANDROID = "DROID_4G"; + private static final String SPG_APP_TOKEN_PARAM = "APP_TOKEN"; + private static final String SPG_APP_TOKEN = "q8e3t5u2o1"; + private static final String SPG_LANGUAGE_PARAM = "SPG_LANGUAGE_PARAM"; + private static final String SPG_LANGUAGE_EN = "ENGLISH"; + + private static final String BASIC_SUBSCRIBE_LINK_TEXT = "Subscribe to Basic Visual Voice Mail"; + + private static final int REQUEST_TIMEOUT_SECONDS = 30; + + private final ActivationTask mTask; + private final PhoneAccountHandle mHandle; + private final OmtpVvmCarrierConfigHelper mHelper; + private final VoicemailStatus.Editor mStatus; + private final Bundle mData; + + private final String mNumber; + + private RequestQueue mRequestQueue; + + private static class ProvisioningException extends Exception { + + public ProvisioningException(String message) { + super(message); + } + } + + static { + // Set the default cookie handler to retain session data for the self provisioning gateway. + // Note; this is not ideal as it is application-wide, and can easily get clobbered. + // But it seems to be the preferred way to manage cookie for HttpURLConnection, and manually + // managing cookies will greatly increase complexity. + CookieManager cookieManager = new CookieManager(); + CookieHandler.setDefault(cookieManager); + } + + @WorkerThread + public Vvm3Subscriber(ActivationTask task, PhoneAccountHandle handle, + OmtpVvmCarrierConfigHelper helper, VoicemailStatus.Editor status, Bundle data) { + Assert.isNotMainThread(); + mTask = task; + mHandle = handle; + mHelper = helper; + mStatus = status; + mData = data; + + // Assuming getLine1Number() will work with VVM3. For unprovisioned users the IMAP username + // is not included in the status SMS, thus no other way to get the current phone number. + mNumber = mHelper.getContext().getSystemService(TelephonyManager.class) + .createForPhoneAccountHandle(mHandle).getLine1Number(); + } + + @WorkerThread + public void subscribe() { + Assert.isNotMainThread(); + // Cellular data is required to subscribe. + // processSubscription() is called after network is available. + VvmLog.i(TAG, "Subscribing"); + + try (NetworkWrapper wrapper = VvmNetworkRequest.getNetwork(mHelper, mHandle, mStatus)) { + Network network = wrapper.get(); + VvmLog.d(TAG, "provisioning: network available"); + mRequestQueue = Volley + .newRequestQueue(mHelper.getContext(), new NetworkSpecifiedHurlStack(network)); + processSubscription(); + } catch (RequestFailedException e) { + mHelper.handleEvent(mStatus, OmtpEvents.VVM3_VMG_CONNECTION_FAILED); + mTask.fail(); + } + } + + private void processSubscription() { + try { + String gatewayUrl = getSelfProvisioningGateway(); + String selfProvisionResponse = getSelfProvisionResponse(gatewayUrl); + String subscribeLink = findSubscribeLink(selfProvisionResponse); + clickSubscribeLink(subscribeLink); + } catch (ProvisioningException e) { + VvmLog.e(TAG, e.toString()); + mTask.fail(); + } + } + + /** + * Get the URL to perform self-provisioning from the voicemail management gateway. + */ + private String getSelfProvisioningGateway() throws ProvisioningException { + VvmLog.i(TAG, "retrieving SPG URL"); + String response = vvm3XmlRequest(OPERATION_GET_SPG_URL); + return extractText(response, SPG_URL_TAG); + } + + /** + * Sent a request to the self-provisioning gateway, which will return us with a webpage. The + * page might contain a "Subscribe to Basic Visual Voice Mail" link to complete the + * subscription. The cookie from this response and cellular data is required to click the link. + */ + private String getSelfProvisionResponse(String url) throws ProvisioningException { + VvmLog.i(TAG, "Retrieving self provisioning response"); + + RequestFuture future = RequestFuture.newFuture(); + + StringRequest stringRequest = new StringRequest(Request.Method.POST, url, future, future) { + @Override + protected Map getParams() { + Map params = new ArrayMap<>(); + params.put(SPG_VZW_MDN_PARAM, mNumber); + params.put(SPG_VZW_SERVICE_PARAM, SPG_VZW_SERVICE_BASIC); + params.put(SPG_DEVICE_MODEL_PARAM, SPG_DEVICE_MODEL_ANDROID); + params.put(SPG_APP_TOKEN_PARAM, SPG_APP_TOKEN); + // Language to display the subscription page. The page is never shown to the user + // so just use English. + params.put(SPG_LANGUAGE_PARAM, SPG_LANGUAGE_EN); + return params; + } + }; + + mRequestQueue.add(stringRequest); + try { + return future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + mHelper.handleEvent(mStatus, OmtpEvents.VVM3_SPG_CONNECTION_FAILED); + throw new ProvisioningException(e.toString()); + } + } + + private void clickSubscribeLink(String subscribeLink) throws ProvisioningException { + VvmLog.i(TAG, "Clicking subscribe link"); + RequestFuture future = RequestFuture.newFuture(); + + StringRequest stringRequest = new StringRequest(Request.Method.POST, + subscribeLink, future, future); + mRequestQueue.add(stringRequest); + try { + // A new STATUS SMS will be sent after this request. + future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException | ExecutionException | InterruptedException e) { + mHelper.handleEvent(mStatus, OmtpEvents.VVM3_SPG_CONNECTION_FAILED); + throw new ProvisioningException(e.toString()); + } + // It could take very long for the STATUS SMS to return. Waiting for it is unreliable. + // Just leave the CONFIG STATUS as CONFIGURING and end the task. The user can always + // manually retry if it took too long. + } + + private String vvm3XmlRequest(String operation) throws ProvisioningException { + VvmLog.d(TAG, "Sending vvm3XmlRequest for " + operation); + String voicemailManagementGateway = mData.getString(VMG_URL_KEY); + if (voicemailManagementGateway == null) { + VvmLog.e(TAG, "voicemailManagementGateway url unknown"); + return null; + } + String transactionId = createTransactionId(); + String body = String.format(Locale.US, VMG_XML_REQUEST_FORMAT, + transactionId, mNumber, operation, Build.MODEL); + + RequestFuture future = RequestFuture.newFuture(); + StringRequest stringRequest = new StringRequest(Request.Method.POST, + voicemailManagementGateway, future, future) { + @Override + public byte[] getBody() throws AuthFailureError { + return body.getBytes(); + } + }; + mRequestQueue.add(stringRequest); + + try { + String response = future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!transactionId.equals(extractText(response, TRANSACTION_ID_TAG))) { + throw new ProvisioningException("transactionId mismatch"); + } + return response; + } catch (InterruptedException | ExecutionException | TimeoutException e) { + mHelper.handleEvent(mStatus, OmtpEvents.VVM3_VMG_CONNECTION_FAILED); + throw new ProvisioningException(e.toString()); + } + } + + private String findSubscribeLink(String response) throws ProvisioningException { + Spanned doc = Html.fromHtml(response, Html.FROM_HTML_MODE_LEGACY); + URLSpan[] spans = doc.getSpans(0, doc.length(), URLSpan.class); + StringBuilder fulltext = new StringBuilder(); + for (URLSpan span : spans) { + String text = doc.subSequence(doc.getSpanStart(span), doc.getSpanEnd(span)).toString(); + if (BASIC_SUBSCRIBE_LINK_TEXT.equals(text)) { + return span.getURL(); + } + fulltext.append(text); + } + throw new ProvisioningException("Subscribe link not found: " + fulltext); + } + + private String createTransactionId() { + return String.valueOf(Math.abs(new Random().nextLong())); + } + + private String extractText(String xml, String tag) throws ProvisioningException { + Pattern pattern = Pattern.compile("<" + tag + ">(.*)<\\/" + tag + ">"); + Matcher matcher = pattern.matcher(xml); + if (matcher.find()) { + return matcher.group(1); + } + throw new ProvisioningException("Tag " + tag + " not found in xml response"); + } + + private static class NetworkSpecifiedHurlStack extends HurlStack { + + private final Network mNetwork; + + public NetworkSpecifiedHurlStack(Network network) { + mNetwork = network; + } + + @Override + protected HttpURLConnection createConnection(URL url) throws IOException { + return (HttpURLConnection) mNetwork.openConnection(url); + } + + } +} diff --git a/java/com/android/voicemailomtp/res/layout/voicemail_change_pin.xml b/java/com/android/voicemailomtp/res/layout/voicemail_change_pin.xml new file mode 100644 index 000000000..b0db64b12 --- /dev/null +++ b/java/com/android/voicemailomtp/res/layout/voicemail_change_pin.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + +