From d5e47f6da5b08b13ecdfa7f1edc7e12aeb83fab9 Mon Sep 17 00:00:00 2001 From: Eric Erfanian Date: Wed, 15 Mar 2017 14:41:07 -0700 Subject: Update Dialer source from latest green build. * Refactor voicemail component * Add new enriched calling components Test: treehugger, manual aosp testing Change-Id: I521a0f86327d4b42e14d93927c7d613044ed5942 --- java/com/android/voicemail/VoicemailClient.java | 60 ++ java/com/android/voicemail/VoicemailComponent.java | 46 ++ .../com/android/voicemail/impl/ActivationTask.java | 298 ++++++++ .../com/android/voicemail/impl/AndroidManifest.xml | 103 +++ java/com/android/voicemail/impl/Assert.java | 57 ++ .../voicemail/impl/DefaultOmtpEventHandler.java | 193 +++++ .../android/voicemail/impl/NeededForTesting.java | 23 + java/com/android/voicemail/impl/OmtpConstants.java | 239 ++++++ java/com/android/voicemail/impl/OmtpEvents.java | 152 ++++ java/com/android/voicemail/impl/OmtpService.java | 63 ++ .../voicemail/impl/OmtpVvmCarrierConfigHelper.java | 444 ++++++++++++ .../voicemail/impl/SubscriptionInfoHelper.java | 70 ++ .../voicemail/impl/TelephonyManagerStub.java | 40 ++ .../voicemail/impl/TelephonyMangerCompat.java | 57 ++ .../voicemail/impl/TelephonyVvmConfigManager.java | 150 ++++ .../voicemail/impl/VisualVoicemailPreferences.java | 37 + java/com/android/voicemail/impl/Voicemail.java | 341 +++++++++ .../voicemail/impl/VoicemailClientImpl.java | 90 +++ .../voicemail/impl/VoicemailClientReceiver.java | 51 ++ .../android/voicemail/impl/VoicemailModule.java | 41 ++ .../android/voicemail/impl/VoicemailStatus.java | 160 +++++ java/com/android/voicemail/impl/VvmLog.java | 177 +++++ .../voicemail/impl/VvmPackageInstallReceiver.java | 65 ++ .../voicemail/impl/VvmPhoneStateListener.java | 104 +++ .../impl/fetch/FetchVoicemailReceiver.java | 218 ++++++ .../impl/fetch/VoicemailFetchedCallback.java | 102 +++ .../android/voicemail/impl/imap/ImapHelper.java | 693 ++++++++++++++++++ .../voicemail/impl/imap/VoicemailPayload.java | 36 + java/com/android/voicemail/impl/mail/Address.java | 520 ++++++++++++++ .../impl/mail/AuthenticationFailedException.java | 33 + .../android/voicemail/impl/mail/Base64Body.java | 61 ++ java/com/android/voicemail/impl/mail/Body.java | 26 + java/com/android/voicemail/impl/mail/BodyPart.java | 24 + .../impl/mail/CertificateValidationException.java | 29 + .../android/voicemail/impl/mail/FetchProfile.java | 79 ++ .../com/android/voicemail/impl/mail/Fetchable.java | 22 + .../impl/mail/FixedLengthInputStream.java | 79 ++ java/com/android/voicemail/impl/mail/Flag.java | 27 + .../android/voicemail/impl/mail/MailTransport.java | 343 +++++++++ .../android/voicemail/impl/mail/MeetingInfo.java | 29 + java/com/android/voicemail/impl/mail/Message.java | 146 ++++ .../voicemail/impl/mail/MessageDateComparator.java | 35 + .../voicemail/impl/mail/MessagingException.java | 143 ++++ .../com/android/voicemail/impl/mail/Multipart.java | 62 ++ .../android/voicemail/impl/mail/PackedString.java | 172 +++++ java/com/android/voicemail/impl/mail/Part.java | 51 ++ .../voicemail/impl/mail/PeekableInputStream.java | 81 +++ .../android/voicemail/impl/mail/TempDirectory.java | 40 ++ .../impl/mail/internet/BinaryTempFileBody.java | 87 +++ .../voicemail/impl/mail/internet/MimeBodyPart.java | 200 ++++++ .../voicemail/impl/mail/internet/MimeHeader.java | 158 ++++ .../voicemail/impl/mail/internet/MimeMessage.java | 676 +++++++++++++++++ .../impl/mail/internet/MimeMultipart.java | 113 +++ .../voicemail/impl/mail/internet/MimeUtility.java | 400 +++++++++++ .../voicemail/impl/mail/internet/TextBody.java | 59 ++ .../voicemail/impl/mail/store/ImapConnection.java | 400 +++++++++++ .../voicemail/impl/mail/store/ImapFolder.java | 797 +++++++++++++++++++++ .../voicemail/impl/mail/store/ImapStore.java | 181 +++++ .../impl/mail/store/imap/DigestMd5Utils.java | 335 +++++++++ .../impl/mail/store/imap/ImapConstants.java | 138 ++++ .../impl/mail/store/imap/ImapElement.java | 124 ++++ .../voicemail/impl/mail/store/imap/ImapList.java | 226 ++++++ .../impl/mail/store/imap/ImapMemoryLiteral.java | 73 ++ .../impl/mail/store/imap/ImapResponse.java | 142 ++++ .../impl/mail/store/imap/ImapResponseParser.java | 424 +++++++++++ .../impl/mail/store/imap/ImapSimpleString.java | 59 ++ .../voicemail/impl/mail/store/imap/ImapString.java | 179 +++++ .../impl/mail/store/imap/ImapTempFileLiteral.java | 119 +++ .../impl/mail/store/imap/ImapUtility.java | 122 ++++ .../impl/mail/utility/CountingOutputStream.java | 48 ++ .../mail/utility/EOLConvertingOutputStream.java | 48 ++ .../voicemail/impl/mail/utils/LogUtils.java | 345 +++++++++ .../android/voicemail/impl/mail/utils/Utility.java | 76 ++ .../voicemail/impl/protocol/CvvmProtocol.java | 59 ++ .../voicemail/impl/protocol/OmtpProtocol.java | 42 ++ .../voicemail/impl/protocol/ProtocolHelper.java | 44 ++ .../impl/protocol/VisualVoicemailProtocol.java | 106 +++ .../protocol/VisualVoicemailProtocolFactory.java | 47 ++ .../voicemail/impl/protocol/Vvm3EventHandler.java | 307 ++++++++ .../voicemail/impl/protocol/Vvm3Protocol.java | 305 ++++++++ .../voicemail/impl/protocol/Vvm3Subscriber.java | 334 +++++++++ .../impl/res/layout/voicemail_change_pin.xml | 97 +++ .../android/voicemail/impl/res/values/arrays.xml | 19 + .../android/voicemail/impl/res/values/attrs.xml | 20 + .../android/voicemail/impl/res/values/colors.xml | 19 + .../android/voicemail/impl/res/values/config.xml | 19 + .../android/voicemail/impl/res/values/dimens.xml | 19 + java/com/android/voicemail/impl/res/values/ids.xml | 20 + .../android/voicemail/impl/res/values/strings.xml | 114 +++ .../android/voicemail/impl/res/values/styles.xml | 19 + .../voicemail/impl/res/xml/voicemail_settings.xml | 47 ++ .../android/voicemail/impl/res/xml/vvm_config.xml | 148 ++++ .../voicemail/impl/scheduling/BaseTask.java | 202 ++++++ .../voicemail/impl/scheduling/BlockerTask.java | 51 ++ .../impl/scheduling/MinimalIntervalPolicy.java | 62 ++ .../android/voicemail/impl/scheduling/Policy.java | 36 + .../voicemail/impl/scheduling/PostponePolicy.java | 68 ++ .../voicemail/impl/scheduling/RetryPolicy.java | 111 +++ .../android/voicemail/impl/scheduling/Task.java | 128 ++++ .../impl/scheduling/TaskSchedulerService.java | 396 ++++++++++ .../impl/settings/VisualVoicemailSettingsUtil.java | 91 +++ .../impl/settings/VoicemailChangePinActivity.java | 624 ++++++++++++++++ .../impl/settings/VoicemailRingtonePreference.java | 110 +++ .../impl/settings/VoicemailSettingsFragment.java | 202 ++++++ .../voicemail/impl/sms/LegacyModeSmsHandler.java | 66 ++ .../voicemail/impl/sms/OmtpCvvmMessageSender.java | 55 ++ .../voicemail/impl/sms/OmtpMessageReceiver.java | 161 +++++ .../voicemail/impl/sms/OmtpMessageSender.java | 85 +++ .../impl/sms/OmtpStandardMessageSender.java | 120 ++++ .../android/voicemail/impl/sms/StatusMessage.java | 201 ++++++ .../voicemail/impl/sms/StatusSmsFetcher.java | 162 +++++ .../android/voicemail/impl/sms/SyncMessage.java | 161 +++++ .../voicemail/impl/sms/Vvm3MessageSender.java | 57 ++ .../voicemail/impl/sync/OmtpVvmSyncReceiver.java | 54 ++ .../voicemail/impl/sync/OmtpVvmSyncService.java | 340 +++++++++ .../android/voicemail/impl/sync/SyncOneTask.java | 78 ++ java/com/android/voicemail/impl/sync/SyncTask.java | 75 ++ .../android/voicemail/impl/sync/UploadTask.java | 69 ++ .../impl/sync/VoicemailProviderChangeReceiver.java | 40 ++ .../impl/sync/VoicemailStatusQueryHelper.java | 113 +++ .../voicemail/impl/sync/VoicemailsQueryHelper.java | 295 ++++++++ .../voicemail/impl/sync/VvmAccountManager.java | 79 ++ .../voicemail/impl/sync/VvmNetworkRequest.java | 120 ++++ .../impl/sync/VvmNetworkRequestCallback.java | 183 +++++ .../voicemail/impl/utils/IndentingPrintWriter.java | 155 ++++ .../impl/utils/VoicemailDatabaseUtil.java | 85 +++ .../voicemail/impl/utils/VvmDumpHandler.java | 43 ++ .../com/android/voicemail/impl/utils/XmlUtils.java | 238 ++++++ java/com/android/voicemail/permissions.xml | 21 + .../voicemail/stub/StubVoicemailClient.java | 49 ++ .../voicemail/stub/StubVoicemailModule.java | 33 + .../voicemail/testing/TestVoicemailModule.java | 38 + 132 files changed, 18683 insertions(+) create mode 100644 java/com/android/voicemail/VoicemailClient.java create mode 100644 java/com/android/voicemail/VoicemailComponent.java create mode 100644 java/com/android/voicemail/impl/ActivationTask.java create mode 100644 java/com/android/voicemail/impl/AndroidManifest.xml create mode 100644 java/com/android/voicemail/impl/Assert.java create mode 100644 java/com/android/voicemail/impl/DefaultOmtpEventHandler.java create mode 100644 java/com/android/voicemail/impl/NeededForTesting.java create mode 100644 java/com/android/voicemail/impl/OmtpConstants.java create mode 100644 java/com/android/voicemail/impl/OmtpEvents.java create mode 100644 java/com/android/voicemail/impl/OmtpService.java create mode 100644 java/com/android/voicemail/impl/OmtpVvmCarrierConfigHelper.java create mode 100644 java/com/android/voicemail/impl/SubscriptionInfoHelper.java create mode 100644 java/com/android/voicemail/impl/TelephonyManagerStub.java create mode 100644 java/com/android/voicemail/impl/TelephonyMangerCompat.java create mode 100644 java/com/android/voicemail/impl/TelephonyVvmConfigManager.java create mode 100644 java/com/android/voicemail/impl/VisualVoicemailPreferences.java create mode 100644 java/com/android/voicemail/impl/Voicemail.java create mode 100644 java/com/android/voicemail/impl/VoicemailClientImpl.java create mode 100644 java/com/android/voicemail/impl/VoicemailClientReceiver.java create mode 100644 java/com/android/voicemail/impl/VoicemailModule.java create mode 100644 java/com/android/voicemail/impl/VoicemailStatus.java create mode 100644 java/com/android/voicemail/impl/VvmLog.java create mode 100644 java/com/android/voicemail/impl/VvmPackageInstallReceiver.java create mode 100644 java/com/android/voicemail/impl/VvmPhoneStateListener.java create mode 100644 java/com/android/voicemail/impl/fetch/FetchVoicemailReceiver.java create mode 100644 java/com/android/voicemail/impl/fetch/VoicemailFetchedCallback.java create mode 100644 java/com/android/voicemail/impl/imap/ImapHelper.java create mode 100644 java/com/android/voicemail/impl/imap/VoicemailPayload.java create mode 100644 java/com/android/voicemail/impl/mail/Address.java create mode 100644 java/com/android/voicemail/impl/mail/AuthenticationFailedException.java create mode 100644 java/com/android/voicemail/impl/mail/Base64Body.java create mode 100644 java/com/android/voicemail/impl/mail/Body.java create mode 100644 java/com/android/voicemail/impl/mail/BodyPart.java create mode 100644 java/com/android/voicemail/impl/mail/CertificateValidationException.java create mode 100644 java/com/android/voicemail/impl/mail/FetchProfile.java create mode 100644 java/com/android/voicemail/impl/mail/Fetchable.java create mode 100644 java/com/android/voicemail/impl/mail/FixedLengthInputStream.java create mode 100644 java/com/android/voicemail/impl/mail/Flag.java create mode 100644 java/com/android/voicemail/impl/mail/MailTransport.java create mode 100644 java/com/android/voicemail/impl/mail/MeetingInfo.java create mode 100644 java/com/android/voicemail/impl/mail/Message.java create mode 100644 java/com/android/voicemail/impl/mail/MessageDateComparator.java create mode 100644 java/com/android/voicemail/impl/mail/MessagingException.java create mode 100644 java/com/android/voicemail/impl/mail/Multipart.java create mode 100644 java/com/android/voicemail/impl/mail/PackedString.java create mode 100644 java/com/android/voicemail/impl/mail/Part.java create mode 100644 java/com/android/voicemail/impl/mail/PeekableInputStream.java create mode 100644 java/com/android/voicemail/impl/mail/TempDirectory.java create mode 100644 java/com/android/voicemail/impl/mail/internet/BinaryTempFileBody.java create mode 100644 java/com/android/voicemail/impl/mail/internet/MimeBodyPart.java create mode 100644 java/com/android/voicemail/impl/mail/internet/MimeHeader.java create mode 100644 java/com/android/voicemail/impl/mail/internet/MimeMessage.java create mode 100644 java/com/android/voicemail/impl/mail/internet/MimeMultipart.java create mode 100644 java/com/android/voicemail/impl/mail/internet/MimeUtility.java create mode 100644 java/com/android/voicemail/impl/mail/internet/TextBody.java create mode 100644 java/com/android/voicemail/impl/mail/store/ImapConnection.java create mode 100644 java/com/android/voicemail/impl/mail/store/ImapFolder.java create mode 100644 java/com/android/voicemail/impl/mail/store/ImapStore.java create mode 100644 java/com/android/voicemail/impl/mail/store/imap/DigestMd5Utils.java create mode 100644 java/com/android/voicemail/impl/mail/store/imap/ImapConstants.java create mode 100644 java/com/android/voicemail/impl/mail/store/imap/ImapElement.java create mode 100644 java/com/android/voicemail/impl/mail/store/imap/ImapList.java create mode 100644 java/com/android/voicemail/impl/mail/store/imap/ImapMemoryLiteral.java create mode 100644 java/com/android/voicemail/impl/mail/store/imap/ImapResponse.java create mode 100644 java/com/android/voicemail/impl/mail/store/imap/ImapResponseParser.java create mode 100644 java/com/android/voicemail/impl/mail/store/imap/ImapSimpleString.java create mode 100644 java/com/android/voicemail/impl/mail/store/imap/ImapString.java create mode 100644 java/com/android/voicemail/impl/mail/store/imap/ImapTempFileLiteral.java create mode 100644 java/com/android/voicemail/impl/mail/store/imap/ImapUtility.java create mode 100644 java/com/android/voicemail/impl/mail/utility/CountingOutputStream.java create mode 100644 java/com/android/voicemail/impl/mail/utility/EOLConvertingOutputStream.java create mode 100644 java/com/android/voicemail/impl/mail/utils/LogUtils.java create mode 100644 java/com/android/voicemail/impl/mail/utils/Utility.java create mode 100644 java/com/android/voicemail/impl/protocol/CvvmProtocol.java create mode 100644 java/com/android/voicemail/impl/protocol/OmtpProtocol.java create mode 100644 java/com/android/voicemail/impl/protocol/ProtocolHelper.java create mode 100644 java/com/android/voicemail/impl/protocol/VisualVoicemailProtocol.java create mode 100644 java/com/android/voicemail/impl/protocol/VisualVoicemailProtocolFactory.java create mode 100644 java/com/android/voicemail/impl/protocol/Vvm3EventHandler.java create mode 100644 java/com/android/voicemail/impl/protocol/Vvm3Protocol.java create mode 100644 java/com/android/voicemail/impl/protocol/Vvm3Subscriber.java create mode 100644 java/com/android/voicemail/impl/res/layout/voicemail_change_pin.xml create mode 100644 java/com/android/voicemail/impl/res/values/arrays.xml create mode 100644 java/com/android/voicemail/impl/res/values/attrs.xml create mode 100644 java/com/android/voicemail/impl/res/values/colors.xml create mode 100644 java/com/android/voicemail/impl/res/values/config.xml create mode 100644 java/com/android/voicemail/impl/res/values/dimens.xml create mode 100644 java/com/android/voicemail/impl/res/values/ids.xml create mode 100644 java/com/android/voicemail/impl/res/values/strings.xml create mode 100644 java/com/android/voicemail/impl/res/values/styles.xml create mode 100644 java/com/android/voicemail/impl/res/xml/voicemail_settings.xml create mode 100644 java/com/android/voicemail/impl/res/xml/vvm_config.xml create mode 100644 java/com/android/voicemail/impl/scheduling/BaseTask.java create mode 100644 java/com/android/voicemail/impl/scheduling/BlockerTask.java create mode 100644 java/com/android/voicemail/impl/scheduling/MinimalIntervalPolicy.java create mode 100644 java/com/android/voicemail/impl/scheduling/Policy.java create mode 100644 java/com/android/voicemail/impl/scheduling/PostponePolicy.java create mode 100644 java/com/android/voicemail/impl/scheduling/RetryPolicy.java create mode 100644 java/com/android/voicemail/impl/scheduling/Task.java create mode 100644 java/com/android/voicemail/impl/scheduling/TaskSchedulerService.java create mode 100644 java/com/android/voicemail/impl/settings/VisualVoicemailSettingsUtil.java create mode 100644 java/com/android/voicemail/impl/settings/VoicemailChangePinActivity.java create mode 100644 java/com/android/voicemail/impl/settings/VoicemailRingtonePreference.java create mode 100644 java/com/android/voicemail/impl/settings/VoicemailSettingsFragment.java create mode 100644 java/com/android/voicemail/impl/sms/LegacyModeSmsHandler.java create mode 100644 java/com/android/voicemail/impl/sms/OmtpCvvmMessageSender.java create mode 100644 java/com/android/voicemail/impl/sms/OmtpMessageReceiver.java create mode 100644 java/com/android/voicemail/impl/sms/OmtpMessageSender.java create mode 100644 java/com/android/voicemail/impl/sms/OmtpStandardMessageSender.java create mode 100644 java/com/android/voicemail/impl/sms/StatusMessage.java create mode 100644 java/com/android/voicemail/impl/sms/StatusSmsFetcher.java create mode 100644 java/com/android/voicemail/impl/sms/SyncMessage.java create mode 100644 java/com/android/voicemail/impl/sms/Vvm3MessageSender.java create mode 100644 java/com/android/voicemail/impl/sync/OmtpVvmSyncReceiver.java create mode 100644 java/com/android/voicemail/impl/sync/OmtpVvmSyncService.java create mode 100644 java/com/android/voicemail/impl/sync/SyncOneTask.java create mode 100644 java/com/android/voicemail/impl/sync/SyncTask.java create mode 100644 java/com/android/voicemail/impl/sync/UploadTask.java create mode 100644 java/com/android/voicemail/impl/sync/VoicemailProviderChangeReceiver.java create mode 100644 java/com/android/voicemail/impl/sync/VoicemailStatusQueryHelper.java create mode 100644 java/com/android/voicemail/impl/sync/VoicemailsQueryHelper.java create mode 100644 java/com/android/voicemail/impl/sync/VvmAccountManager.java create mode 100644 java/com/android/voicemail/impl/sync/VvmNetworkRequest.java create mode 100644 java/com/android/voicemail/impl/sync/VvmNetworkRequestCallback.java create mode 100644 java/com/android/voicemail/impl/utils/IndentingPrintWriter.java create mode 100644 java/com/android/voicemail/impl/utils/VoicemailDatabaseUtil.java create mode 100644 java/com/android/voicemail/impl/utils/VvmDumpHandler.java create mode 100644 java/com/android/voicemail/impl/utils/XmlUtils.java create mode 100644 java/com/android/voicemail/permissions.xml create mode 100644 java/com/android/voicemail/stub/StubVoicemailClient.java create mode 100644 java/com/android/voicemail/stub/StubVoicemailModule.java create mode 100644 java/com/android/voicemail/testing/TestVoicemailModule.java (limited to 'java/com/android/voicemail') diff --git a/java/com/android/voicemail/VoicemailClient.java b/java/com/android/voicemail/VoicemailClient.java new file mode 100644 index 000000000..b237f65f6 --- /dev/null +++ b/java/com/android/voicemail/VoicemailClient.java @@ -0,0 +1,60 @@ +/* + * 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.voicemail; + +import android.content.Context; +import android.provider.VoicemailContract.Voicemails; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccountHandle; +import android.telephony.TelephonyManager; +import java.util.List; + +/** Public interface for the voicemail module */ +public interface VoicemailClient { + + /** + * Broadcast to tell the client to upload local database changes to the server. Since the dialer + * UI and the client are in the same package, the {@link + * android.content.Intent#ACTION_PROVIDER_CHANGED} will always be a self-change even if the UI is + * external to the client. + */ + String ACTION_UPLOAD = "com.android.voicemailomtp.VoicemailClient.ACTION_UPLOAD"; + + /** + * Appends the selection to ignore voicemails from non-active OMTP voicemail package. In OC there + * can be multiple packages handling OMTP voicemails which represents the same source of truth. + * These packages should mark their voicemails as {@link Voicemails#IS_OMTP_VOICEMAIL} and only + * the voicemails from {@link TelephonyManager#getVisualVoicemailPackageName()} should be shown. + * For example, the user synced voicemails with DialerA, and then switched to DialerB, voicemails + * from DialerA should be ignored as they are no longer current. Voicemails from {@link + * #OMTP_VOICEMAIL_BLACKLIST} will also be ignored as they are voicemail source only valid pre-OC. + */ + void appendOmtpVoicemailSelectionClause( + Context context, StringBuilder where, List selectionArgs); + /** + * @return the class name of the {@link android.preference.PreferenceFragment} for voicemail + * settings, or {@code null} if dialer cannot control voicemail settings. Always return {@code + * null} before OC. + */ + @Nullable + String getSettingsFragment(); + + boolean isVoicemailArchiveEnabled(Context context, PhoneAccountHandle phoneAccountHandle); + + void setVoicemailArchiveEnabled( + Context context, PhoneAccountHandle phoneAccountHandle, boolean value); +} diff --git a/java/com/android/voicemail/VoicemailComponent.java b/java/com/android/voicemail/VoicemailComponent.java new file mode 100644 index 000000000..6dd6f9d90 --- /dev/null +++ b/java/com/android/voicemail/VoicemailComponent.java @@ -0,0 +1,46 @@ +/* + * 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.voicemail; + +import android.content.Context; +import dagger.Subcomponent; +import com.android.voicemail.impl.VoicemailClientImpl; + +/** Subcomponent that can be used to access the voicemail implementation. */ +public class VoicemailComponent { + private static VoicemailComponent instance; + private VoicemailClientImpl voicemailClient; + + public VoicemailClient getVoicemailClient() { + if (voicemailClient == null) { + voicemailClient = new VoicemailClientImpl(); + } + return voicemailClient; + } + + public static VoicemailComponent get(Context context) { + if (instance == null) { + instance = new VoicemailComponent(); + } + return instance; + } + + /** Used to refer to the root application component. */ + public interface HasComponent { + VoicemailComponent voicemailComponent(); + } +} diff --git a/java/com/android/voicemail/impl/ActivationTask.java b/java/com/android/voicemail/impl/ActivationTask.java new file mode 100644 index 000000000..c4716116f --- /dev/null +++ b/java/com/android/voicemail/impl/ActivationTask.java @@ -0,0 +1,298 @@ +/* + * 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.voicemail.impl; + +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.voicemail.impl.protocol.VisualVoicemailProtocol; +import com.android.voicemail.impl.scheduling.BaseTask; +import com.android.voicemail.impl.scheduling.RetryPolicy; +import com.android.voicemail.impl.sms.StatusMessage; +import com.android.voicemail.impl.sms.StatusSmsFetcher; +import com.android.voicemail.impl.sync.OmtpVvmSyncService; +import com.android.voicemail.impl.sync.SyncTask; +import com.android.voicemail.impl.sync.VvmAccountManager; +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.O) +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); + } + + @Override + 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); + VvmAccountManager.removeAccount(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 (VvmAccountManager.isAccountActivated(getContext(), phoneAccountHandle)) { + VvmLog.i(TAG, "Account is already activated"); + return; + } + helper.handleEvent( + VoicemailStatus.edit(getContext(), phoneAccountHandle), OmtpEvents.CONFIG_ACTIVATING); + + 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) { + + 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. + VvmAccountManager.addAccount(context, phone, message); + + 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/voicemail/impl/AndroidManifest.xml b/java/com/android/voicemail/impl/AndroidManifest.xml new file mode 100644 index 000000000..0d90d5932 --- /dev/null +++ b/java/com/android/voicemail/impl/AndroidManifest.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/java/com/android/voicemail/impl/Assert.java b/java/com/android/voicemail/impl/Assert.java new file mode 100644 index 000000000..fe063727a --- /dev/null +++ b/java/com/android/voicemail/impl/Assert.java @@ -0,0 +1,57 @@ +/* + * 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.voicemail.impl; + +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/voicemail/impl/DefaultOmtpEventHandler.java b/java/com/android/voicemail/impl/DefaultOmtpEventHandler.java new file mode 100644 index 000000000..13aaf0588 --- /dev/null +++ b/java/com/android/voicemail/impl/DefaultOmtpEventHandler.java @@ -0,0 +1,193 @@ +/* + * 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.voicemail.impl; + +import android.content.Context; +import android.provider.VoicemailContract; +import android.provider.VoicemailContract.Status; +import com.android.voicemail.impl.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/voicemail/impl/NeededForTesting.java b/java/com/android/voicemail/impl/NeededForTesting.java new file mode 100644 index 000000000..70e738385 --- /dev/null +++ b/java/com/android/voicemail/impl/NeededForTesting.java @@ -0,0 +1,23 @@ +/* + * 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.voicemail.impl; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.SOURCE) +public @interface NeededForTesting {} diff --git a/java/com/android/voicemail/impl/OmtpConstants.java b/java/com/android/voicemail/impl/OmtpConstants.java new file mode 100644 index 000000000..599d0d5f0 --- /dev/null +++ b/java/com/android/voicemail/impl/OmtpConstants.java @@ -0,0 +1,239 @@ +/* + * 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.voicemail.impl; + +import android.support.annotation.IntDef; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * 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, + }; + + /** 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/voicemail/impl/OmtpEvents.java b/java/com/android/voicemail/impl/OmtpEvents.java new file mode 100644 index 000000000..6807edcf0 --- /dev/null +++ b/java/com/android/voicemail/impl/OmtpEvents.java @@ -0,0 +1,152 @@ +/* + * 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.voicemail.impl; + +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/voicemail/impl/OmtpService.java b/java/com/android/voicemail/impl/OmtpService.java new file mode 100644 index 000000000..dfbd4cf42 --- /dev/null +++ b/java/com/android/voicemail/impl/OmtpService.java @@ -0,0 +1,63 @@ +/* + * 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.voicemail.impl; + +import android.content.Intent; +import android.telecom.PhoneAccountHandle; +import android.telephony.VisualVoicemailService; +import android.telephony.VisualVoicemailSms; +import com.android.voicemail.impl.sync.VvmAccountManager; + +public class OmtpService extends VisualVoicemailService { + + private static final 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"); + VvmAccountManager.removeAccount(this, phoneAccountHandle); + task.finish(); + } + + @Override + public void onStopped(VisualVoicemailTask task) { + VvmLog.i(TAG, "onStopped"); + } +} diff --git a/java/com/android/voicemail/impl/OmtpVvmCarrierConfigHelper.java b/java/com/android/voicemail/impl/OmtpVvmCarrierConfigHelper.java new file mode 100644 index 000000000..0296d208d --- /dev/null +++ b/java/com/android/voicemail/impl/OmtpVvmCarrierConfigHelper.java @@ -0,0 +1,444 @@ +/* + * 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.voicemail.impl; + +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.NonNull; +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.dialer.common.Assert; +import com.android.voicemail.impl.protocol.VisualVoicemailProtocol; +import com.android.voicemail.impl.protocol.VisualVoicemailProtocolFactory; +import com.android.voicemail.impl.sms.StatusMessage; +import com.android.voicemail.impl.sync.VvmAccountManager; +import java.util.Collections; +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; + TelephonyManager telephonyManager = + context + .getSystemService(TelephonyManager.class) + .createForPhoneAccountHandle(mPhoneAccountHandle); + if (telephonyManager == null) { + VvmLog.e(TAG, "PhoneAccountHandle is invalid"); + mCarrierConfig = null; + mTelephonyConfig = null; + mVvmType = null; + mProtocol = null; + return; + } + + mCarrierConfig = getCarrierConfig(telephonyManager); + mTelephonyConfig = + new TelephonyVvmConfigManager(context.getResources()) + .getConfig(telephonyManager.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) { + Assert.checkArgument(isValid()); + return (String) getValue(key); + } + + @Nullable + public Set getCarrierVvmPackageNames() { + Assert.checkArgument(isValid()); + 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)) { + String[] vvmPackages = bundle.getStringArray(KEY_CARRIER_VVM_PACKAGE_NAME_STRING_ARRAY); + if (vvmPackages != null && vvmPackages.length > 0) { + Collections.addAll(names, vvmPackages); + } + } + 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() { + Assert.checkArgument(isValid()); + return (boolean) getValue(KEY_VVM_CELLULAR_DATA_REQUIRED_BOOL, false); + } + + public boolean isPrefetchEnabled() { + Assert.checkArgument(isValid()); + return (boolean) getValue(KEY_VVM_PREFETCH_BOOL, true); + } + + public int getApplicationPort() { + Assert.checkArgument(isValid()); + return (int) getValue(KEY_VVM_PORT_NUMBER_INT, 0); + } + + @Nullable + public String getDestinationNumber() { + Assert.checkArgument(isValid()); + return (String) getValue(KEY_VVM_DESTINATION_NUMBER_STRING); + } + + /** + * @return Port to start a SSL IMAP connection directly. + */ + public int getSslPort() { + Assert.checkArgument(isValid()); + 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() { + Assert.checkArgument(isValid()); + 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; + } + String[] disabledCapabilities = + bundle.getStringArray(KEY_VVM_DISABLED_CAPABILITIES_STRING_ARRAY); + if (disabledCapabilities != null && disabledCapabilities.length > 0) { + ArraySet result = new ArraySet<>(); + Collections.addAll(result, disabledCapabilities); + return result; + } + return null; + } + + public String getClientPrefix() { + Assert.checkArgument(isValid()); + 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() { + Assert.checkArgument(isValid()); + return (boolean) getValue(KEY_VVM_LEGACY_MODE_ENABLED_BOOL, false); + } + + public void startActivation() { + Assert.checkArgument(isValid()); + 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() { + Assert.checkArgument(isValid()); + VisualVoicemailService.setSmsFilterSettings( + mContext, + getPhoneAccountHandle(), + new VisualVoicemailSmsFilterSettings.Builder().setClientPrefix(getClientPrefix()).build()); + } + + public void startDeactivation() { + Assert.checkArgument(isValid()); + if (!isLegacyModeEnabled()) { + // SMS should still be filtered in legacy mode + VisualVoicemailService.setSmsFilterSettings(mContext, getPhoneAccountHandle(), null); + } + if (mProtocol != null) { + mProtocol.startDeactivation(this); + } + VvmAccountManager.removeAccount(mContext, getPhoneAccountHandle()); + } + + public boolean supportsProvisioning() { + Assert.checkArgument(isValid()); + return mProtocol.supportsProvisioning(); + } + + public void startProvisioning( + ActivationTask task, + PhoneAccountHandle phone, + VoicemailStatus.Editor status, + StatusMessage message, + Bundle data) { + Assert.checkArgument(isValid()); + mProtocol.startProvisioning(task, phone, this, status, message, data); + } + + public void requestStatus(@Nullable PendingIntent sentIntent) { + Assert.checkArgument(isValid()); + mProtocol.requestStatus(this, sentIntent); + } + + public void handleEvent(VoicemailStatus.Editor status, OmtpEvents event) { + Assert.checkArgument(isValid()); + VvmLog.i(TAG, "OmtpEvent:" + event); + 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(@NonNull TelephonyManager telephonyManager) { + CarrierConfigManager carrierConfigManager = + (CarrierConfigManager) mContext.getSystemService(Context.CARRIER_CONFIG_SERVICE); + if (carrierConfigManager == null) { + VvmLog.w(TAG, "No carrier config service found."); + return null; + } + + PersistableBundle config = telephonyManager.getCarrierConfig(); + + 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; + } +} diff --git a/java/com/android/voicemail/impl/SubscriptionInfoHelper.java b/java/com/android/voicemail/impl/SubscriptionInfoHelper.java new file mode 100644 index 000000000..d8a8423eb --- /dev/null +++ b/java/com/android/voicemail/impl/SubscriptionInfoHelper.java @@ -0,0 +1,70 @@ +/** + * 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.voicemail.impl; + +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/voicemail/impl/TelephonyManagerStub.java b/java/com/android/voicemail/impl/TelephonyManagerStub.java new file mode 100644 index 000000000..4762e9023 --- /dev/null +++ b/java/com/android/voicemail/impl/TelephonyManagerStub.java @@ -0,0 +1,40 @@ +/* + * 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.voicemail.impl; + +import android.annotation.TargetApi; +import android.os.Build.VERSION_CODES; + +/** + * Temporary stub for public APIs that should be added into telephony manager. + * + *

TODO(b/32637799) remove this. + */ +@TargetApi(VERSION_CODES.O) +public class 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) {} +} diff --git a/java/com/android/voicemail/impl/TelephonyMangerCompat.java b/java/com/android/voicemail/impl/TelephonyMangerCompat.java new file mode 100644 index 000000000..353cd69e3 --- /dev/null +++ b/java/com/android/voicemail/impl/TelephonyMangerCompat.java @@ -0,0 +1,57 @@ +/* + * 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.voicemail.impl; + +import android.telecom.PhoneAccountHandle; +import android.telephony.TelephonyManager; +import java.lang.reflect.Method; + +/** Handles {@link TelephonyManager} API changes in experimental SDK */ +public class TelephonyMangerCompat { + + private static final String GET_VISUAL_VOICEMAIL_PACKGE_NAME = "getVisualVoicemailPackageName"; + + /** + * Changed from getVisualVoicemailPackageName(PhoneAccountHandle) to + * getVisualVoicemailPackageName() + */ + public static String getVisualVoicemailPackageName(TelephonyManager telephonyManager) { + try { + Method method = TelephonyManager.class.getMethod(GET_VISUAL_VOICEMAIL_PACKGE_NAME); + try { + return (String) method.invoke(telephonyManager); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } catch (NoSuchMethodException e) { + // Do nothing, try the next version. + } + + try { + Method method = + TelephonyManager.class.getMethod( + GET_VISUAL_VOICEMAIL_PACKGE_NAME, PhoneAccountHandle.class); + try { + return (String) method.invoke(telephonyManager, (Object) null); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } +} diff --git a/java/com/android/voicemail/impl/TelephonyVvmConfigManager.java b/java/com/android/voicemail/impl/TelephonyVvmConfigManager.java new file mode 100644 index 000000000..04012c9c2 --- /dev/null +++ b/java/com/android/voicemail/impl/TelephonyVvmConfigManager.java @@ -0,0 +1,150 @@ +/* + * 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.voicemail.impl; + +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.voicemail.impl.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/voicemail/impl/VisualVoicemailPreferences.java b/java/com/android/voicemail/impl/VisualVoicemailPreferences.java new file mode 100644 index 000000000..72506eb93 --- /dev/null +++ b/java/com/android/voicemail/impl/VisualVoicemailPreferences.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.voicemail.impl; + +import android.content.Context; +import android.preference.PreferenceManager; +import android.telecom.PhoneAccountHandle; +import com.android.dialer.common.PerAccountSharedPreferences; + +/** + * 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 extends PerAccountSharedPreferences { + + public VisualVoicemailPreferences(Context context, PhoneAccountHandle phoneAccountHandle) { + super( + context, + phoneAccountHandle, + PreferenceManager.getDefaultSharedPreferences(context), + "visual_voicemail_"); + } +} diff --git a/java/com/android/voicemail/impl/Voicemail.java b/java/com/android/voicemail/impl/Voicemail.java new file mode 100644 index 000000000..f98d56f0a --- /dev/null +++ b/java/com/android/voicemail/impl/Voicemail.java @@ -0,0 +1,341 @@ +/* + * 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.voicemail.impl; + +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/voicemail/impl/VoicemailClientImpl.java b/java/com/android/voicemail/impl/VoicemailClientImpl.java new file mode 100644 index 000000000..1ad12aeab --- /dev/null +++ b/java/com/android/voicemail/impl/VoicemailClientImpl.java @@ -0,0 +1,90 @@ +/** + * 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.voicemail.impl; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build.VERSION_CODES; +import android.provider.VoicemailContract.Voicemails; +import android.support.annotation.Nullable; +import android.support.v4.os.BuildCompat; +import android.telecom.PhoneAccountHandle; +import android.telephony.TelephonyManager; +import com.android.dialer.common.Assert; +import com.android.voicemail.VoicemailClient; +import com.android.voicemail.impl.settings.VisualVoicemailSettingsUtil; +import com.android.voicemail.impl.settings.VoicemailSettingsFragment; +import java.util.List; +import javax.inject.Inject; + +/** + * {@link VoicemailClient} to be used when the voicemail module is activated. May only be used above + * O. + */ +public class VoicemailClientImpl implements VoicemailClient { + + /** + * List of legacy OMTP voicemail packages that should be ignored. It could never be the active VVM + * package anymore. For example, voicemails in OC will no longer be handled by telephony, but + * legacy voicemails might still exist in the database due to upgrading from NYC. Dialer will + * fetch these voicemails again so it should be ignored. + */ + private static final String[] OMTP_VOICEMAIL_BLACKLIST = {"com.android.phone"}; + + @Inject + public VoicemailClientImpl() { + Assert.checkArgument(BuildCompat.isAtLeastO()); + } + + @Nullable + @Override + public String getSettingsFragment() { + return VoicemailSettingsFragment.class.getName(); + } + + @Override + public boolean isVoicemailArchiveEnabled(Context context, PhoneAccountHandle phoneAccountHandle) { + return VisualVoicemailSettingsUtil.isArchiveEnabled(context, phoneAccountHandle); + } + + @Override + public void setVoicemailArchiveEnabled( + Context context, PhoneAccountHandle phoneAccountHandle, boolean value) { + VisualVoicemailSettingsUtil.setArchiveEnabled(context, phoneAccountHandle, value); + } + + @TargetApi(VERSION_CODES.O) + @Override + public void appendOmtpVoicemailSelectionClause( + Context context, StringBuilder where, List selectionArgs) { + TelephonyManager telephonyManager = context.getSystemService(TelephonyManager.class); + String omtpSource = TelephonyMangerCompat.getVisualVoicemailPackageName(telephonyManager); + where.append( + "AND (" + + "(" + + Voicemails.IS_OMTP_VOICEMAIL + + " != 1)" + + "OR " + + "(" + + Voicemails.SOURCE_PACKAGE + + " = ? )" + + ")"); + selectionArgs.add(omtpSource); + + for (String blacklistedPackage : OMTP_VOICEMAIL_BLACKLIST) { + where.append("AND (" + Voicemails.SOURCE_PACKAGE + "!= ?)"); + selectionArgs.add(blacklistedPackage); + } + } +} diff --git a/java/com/android/voicemail/impl/VoicemailClientReceiver.java b/java/com/android/voicemail/impl/VoicemailClientReceiver.java new file mode 100644 index 000000000..49a55a41b --- /dev/null +++ b/java/com/android/voicemail/impl/VoicemailClientReceiver.java @@ -0,0 +1,51 @@ +/* + * 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.voicemail.impl; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.telecom.PhoneAccountHandle; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.voicemail.VoicemailClient; +import com.android.voicemail.impl.sync.UploadTask; +import com.android.voicemail.impl.sync.VvmAccountManager; + +/** Receiver for broadcasts in {@link VoicemailClient#ACTION_UPLOAD} */ +public class VoicemailClientReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + switch (intent.getAction()) { + case VoicemailClient.ACTION_UPLOAD: + doUpload(context); + break; + default: + Assert.fail("Unexpected action " + intent.getAction()); + break; + } + } + + /** Upload local database changes to the server. */ + private static void doUpload(Context context) { + LogUtil.i("VoicemailClientReceiver.onReceive", "ACTION_UPLOAD received"); + for (PhoneAccountHandle phoneAccountHandle : VvmAccountManager.getActiveAccounts(context)) { + UploadTask.start(context, phoneAccountHandle); + } + } +} diff --git a/java/com/android/voicemail/impl/VoicemailModule.java b/java/com/android/voicemail/impl/VoicemailModule.java new file mode 100644 index 000000000..c3e5714d5 --- /dev/null +++ b/java/com/android/voicemail/impl/VoicemailModule.java @@ -0,0 +1,41 @@ +/* + * 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.voicemail.impl; + +import android.support.v4.os.BuildCompat; +import com.android.voicemail.VoicemailClient; +import com.android.voicemail.stub.StubVoicemailClient; +import dagger.Module; +import dagger.Provides; +import javax.inject.Singleton; + +/** This module provides an instance of the voicemail client. */ +@Module +public final class VoicemailModule { + + @Provides + @Singleton + static VoicemailClient provideVoicemailClient() { + if (BuildCompat.isAtLeastO()) { + return new VoicemailClientImpl(); + } else { + return new StubVoicemailClient(); + } + } + + private VoicemailModule() {} +} diff --git a/java/com/android/voicemail/impl/VoicemailStatus.java b/java/com/android/voicemail/impl/VoicemailStatus.java new file mode 100644 index 000000000..ec1ab4e70 --- /dev/null +++ b/java/com/android/voicemail/impl/VoicemailStatus.java @@ -0,0 +1,160 @@ +/* + * 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.voicemail.impl; + +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/voicemail/impl/VvmLog.java b/java/com/android/voicemail/impl/VvmLog.java new file mode 100644 index 000000000..595207f92 --- /dev/null +++ b/java/com/android/voicemail/impl/VvmLog.java @@ -0,0 +1,177 @@ +/* + * 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.voicemail.impl; + +import com.android.dialer.common.LogUtil; +import com.android.voicemail.impl.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 void e(String tag, String log) { + log(tag, log); + LogUtil.e(tag, log); + } + + public static void e(String tag, String log, Throwable e) { + log(tag, log + " " + e); + LogUtil.e(tag, log, e); + } + + public static void w(String tag, String log) { + log(tag, log); + LogUtil.w(tag, log); + } + + public static void w(String tag, String log, Throwable e) { + log(tag, log + " " + e); + LogUtil.w(tag, log, e); + } + + public static void i(String tag, String log) { + log(tag, log); + LogUtil.i(tag, log); + } + + public static void i(String tag, String log, Throwable e) { + log(tag, log + " " + e); + LogUtil.i(tag, log, e); + } + + public static void d(String tag, String log) { + log(tag, log); + LogUtil.d(tag, log); + } + + public static void d(String tag, String log, Throwable e) { + log(tag, log + " " + e); + LogUtil.d(tag, log, e); + } + + public static void v(String tag, String log) { + log(tag, log); + LogUtil.v(tag, log); + } + + public static void v(String tag, String log, Throwable e) { + log(tag, log + " " + e); + LogUtil.v(tag, log, e); + } + + public static void wtf(String tag, String log) { + log(tag, log); + LogUtil.e(tag, log); + } + + public static void wtf(String tag, String log, Throwable e) { + log(tag, log + " " + e); + LogUtil.e(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/voicemail/impl/VvmPackageInstallReceiver.java b/java/com/android/voicemail/impl/VvmPackageInstallReceiver.java new file mode 100644 index 000000000..c5650b3ee --- /dev/null +++ b/java/com/android/voicemail/impl/VvmPackageInstallReceiver.java @@ -0,0 +1,65 @@ +/* + * 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.voicemail.impl; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; +import com.android.voicemail.impl.settings.VisualVoicemailSettingsUtil; + +/** + * 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; + } + + for (PhoneAccountHandle phoneAccount : + context.getSystemService(TelecomManager.class).getCallCapablePhoneAccounts()) { + 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 is 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"); + VisualVoicemailSettingsUtil.setEnabled(context, phoneAccount, false); + } + } + } +} diff --git a/java/com/android/voicemail/impl/VvmPhoneStateListener.java b/java/com/android/voicemail/impl/VvmPhoneStateListener.java new file mode 100644 index 000000000..48b72042c --- /dev/null +++ b/java/com/android/voicemail/impl/VvmPhoneStateListener.java @@ -0,0 +1,104 @@ +/* + * 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.voicemail.impl; + +import android.content.Context; +import android.telecom.PhoneAccountHandle; +import android.telephony.PhoneStateListener; +import android.telephony.ServiceState; +import com.android.voicemail.impl.sync.OmtpVvmSyncService; +import com.android.voicemail.impl.sync.SyncTask; +import com.android.voicemail.impl.sync.VoicemailStatusQueryHelper; +import com.android.voicemail.impl.sync.VvmAccountManager; + +/** + * Check if service is lost and indicate this in the voicemail status. TODO(b/35125657): Not used + * for now, restore it. + */ +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 (VvmAccountManager.isAccountActivated(mContext, 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 (!VvmAccountManager.isAccountActivated(mContext, mPhoneAccount)) { + return; + } + helper.handleEvent( + VoicemailStatus.edit(mContext, mPhoneAccount), OmtpEvents.NOTIFICATION_SERVICE_LOST); + } + mPreviousState = state; + } +} diff --git a/java/com/android/voicemail/impl/fetch/FetchVoicemailReceiver.java b/java/com/android/voicemail/impl/fetch/FetchVoicemailReceiver.java new file mode 100644 index 000000000..07e800836 --- /dev/null +++ b/java/com/android/voicemail/impl/fetch/FetchVoicemailReceiver.java @@ -0,0 +1,218 @@ +/* + * 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.voicemail.impl.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.telecom.TelecomManager; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import com.android.voicemail.impl.VoicemailStatus; +import com.android.voicemail.impl.VvmLog; +import com.android.voicemail.impl.imap.ImapHelper; +import com.android.voicemail.impl.imap.ImapHelper.InitializingException; +import com.android.voicemail.impl.sync.VvmAccountManager; +import com.android.voicemail.impl.sync.VvmNetworkRequestCallback; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** handles {@link VoicemailContract#ACTION_FETCH_VOICEMAIL} */ +@TargetApi(VERSION_CODES.O) +public class FetchVoicemailReceiver extends BroadcastReceiver { + + private static final String TAG = "FetchVoicemailReceiver"; + + static final 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 (!VvmAccountManager.isAccountActivated(context, 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 : + context.getSystemService(TelecomManager.class).getCallCapablePhoneAccounts()) { + 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 < id.length(); i++) { + if (!Character.isDigit(id.charAt(i))) { + return id.substring(0, i); + } + } + return id; + } + + private class fetchVoicemailNetworkRequestCallback extends VvmNetworkRequestCallback { + + public fetchVoicemailNetworkRequestCallback(Context context, PhoneAccountHandle phoneAccount) { + super(context, phoneAccount, VoicemailStatus.edit(context, phoneAccount)); + } + + @Override + public void onAvailable(final Network network) { + super.onAvailable(network); + fetchVoicemail(network, getVoicemailStatusEditor()); + } + } + + private void fetchVoicemail(final Network network, final VoicemailStatus.Editor status) { + Executor executor = Executors.newCachedThreadPool(); + executor.execute( + new Runnable() { + @Override + public void run() { + try { + while (mRetryCount > 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/voicemail/impl/fetch/VoicemailFetchedCallback.java b/java/com/android/voicemail/impl/fetch/VoicemailFetchedCallback.java new file mode 100644 index 000000000..f386fce0e --- /dev/null +++ b/java/com/android/voicemail/impl/fetch/VoicemailFetchedCallback.java @@ -0,0 +1,102 @@ +/* + * 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.voicemail.impl.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.voicemail.impl.R; +import com.android.voicemail.impl.VvmLog; +import com.android.voicemail.impl.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/voicemail/impl/imap/ImapHelper.java b/java/com/android/voicemail/impl/imap/ImapHelper.java new file mode 100644 index 000000000..6aa415811 --- /dev/null +++ b/java/com/android/voicemail/impl/imap/ImapHelper.java @@ -0,0 +1,693 @@ +/* + * 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.voicemail.impl.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.voicemail.impl.OmtpConstants; +import com.android.voicemail.impl.OmtpConstants.ChangePinResult; +import com.android.voicemail.impl.OmtpEvents; +import com.android.voicemail.impl.OmtpVvmCarrierConfigHelper; +import com.android.voicemail.impl.VisualVoicemailPreferences; +import com.android.voicemail.impl.Voicemail; +import com.android.voicemail.impl.VoicemailStatus; +import com.android.voicemail.impl.VoicemailStatus.Editor; +import com.android.voicemail.impl.VvmLog; +import com.android.voicemail.impl.fetch.VoicemailFetchedCallback; +import com.android.voicemail.impl.mail.Address; +import com.android.voicemail.impl.mail.Body; +import com.android.voicemail.impl.mail.BodyPart; +import com.android.voicemail.impl.mail.FetchProfile; +import com.android.voicemail.impl.mail.Flag; +import com.android.voicemail.impl.mail.Message; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.mail.Multipart; +import com.android.voicemail.impl.mail.TempDirectory; +import com.android.voicemail.impl.mail.internet.MimeMessage; +import com.android.voicemail.impl.mail.store.ImapConnection; +import com.android.voicemail.impl.mail.store.ImapFolder; +import com.android.voicemail.impl.mail.store.ImapStore; +import com.android.voicemail.impl.mail.store.imap.ImapConstants; +import com.android.voicemail.impl.mail.store.imap.ImapResponse; +import com.android.voicemail.impl.mail.utils.LogUtils; +import com.android.voicemail.impl.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 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; + + /** InitializingException */ + public static class InitializingException extends Exception { + + public InitializingException(String message) { + super(message); + } + } + + public ImapHelper( + Context context, + PhoneAccountHandle phoneAccount, + Network network, + Editor status) + throws InitializingException { + this( + context, + new OmtpVvmCarrierConfigHelper(context, phoneAccount), + phoneAccount, + network, + status); + } + + public ImapHelper( + Context context, + OmtpVvmCarrierConfigHelper config, + PhoneAccountHandle phoneAccount, + Network network, + 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(); + } + } + + public int getOccuupiedQuota() { + return mQuotaOccupied; + } + + public int getTotalQuota() { + return mQuotaTotal; + } + + 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 static 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); + } + } +} diff --git a/java/com/android/voicemail/impl/imap/VoicemailPayload.java b/java/com/android/voicemail/impl/imap/VoicemailPayload.java new file mode 100644 index 000000000..69befb42f --- /dev/null +++ b/java/com/android/voicemail/impl/imap/VoicemailPayload.java @@ -0,0 +1,36 @@ +/* + * 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.voicemail.impl.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; + } +} diff --git a/java/com/android/voicemail/impl/mail/Address.java b/java/com/android/voicemail/impl/mail/Address.java new file mode 100644 index 000000000..3a7a86607 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/Address.java @@ -0,0 +1,520 @@ +/* + * 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.voicemail.impl.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.voicemail.impl.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/voicemail/impl/mail/AuthenticationFailedException.java b/java/com/android/voicemail/impl/mail/AuthenticationFailedException.java new file mode 100644 index 000000000..c9fa08750 --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.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); + } +} diff --git a/java/com/android/voicemail/impl/mail/Base64Body.java b/java/com/android/voicemail/impl/mail/Base64Body.java new file mode 100644 index 000000000..def94dbb5 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/Base64Body.java @@ -0,0 +1,61 @@ +/* + * 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.voicemail.impl.mail; + +import android.util.Base64; +import android.util.Base64OutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import org.apache.commons.io.IOUtils; + +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/voicemail/impl/mail/Body.java b/java/com/android/voicemail/impl/mail/Body.java new file mode 100644 index 000000000..3ad81bcc8 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/Body.java @@ -0,0 +1,26 @@ +/* + * 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.voicemail.impl.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/voicemail/impl/mail/BodyPart.java b/java/com/android/voicemail/impl/mail/BodyPart.java new file mode 100644 index 000000000..3d15d4bad --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.mail; + +public abstract class BodyPart implements Part { + protected Multipart mParent; + + public Multipart getParent() { + return mParent; + } +} diff --git a/java/com/android/voicemail/impl/mail/CertificateValidationException.java b/java/com/android/voicemail/impl/mail/CertificateValidationException.java new file mode 100644 index 000000000..6f3bb2ff4 --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.mail; + +public class CertificateValidationException extends MessagingException { + public static final long serialVersionUID = -1; + + public CertificateValidationException(String message) { + super(CERTIFICATE_VALIDATION_ERROR, message); + } + + public CertificateValidationException(String message, Throwable throwable) { + super(CERTIFICATE_VALIDATION_ERROR, message, throwable); + } +} diff --git a/java/com/android/voicemail/impl/mail/FetchProfile.java b/java/com/android/voicemail/impl/mail/FetchProfile.java new file mode 100644 index 000000000..28a7080e6 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/FetchProfile.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.voicemail.impl.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/voicemail/impl/mail/Fetchable.java b/java/com/android/voicemail/impl/mail/Fetchable.java new file mode 100644 index 000000000..237ef6950 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/Fetchable.java @@ -0,0 +1,22 @@ +/* + * 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.voicemail.impl.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/voicemail/impl/mail/FixedLengthInputStream.java b/java/com/android/voicemail/impl/mail/FixedLengthInputStream.java new file mode 100644 index 000000000..bd3c16401 --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.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); + } +} diff --git a/java/com/android/voicemail/impl/mail/Flag.java b/java/com/android/voicemail/impl/mail/Flag.java new file mode 100644 index 000000000..72b5c1fa5 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/Flag.java @@ -0,0 +1,27 @@ +/* + * 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.voicemail.impl.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/voicemail/impl/mail/MailTransport.java b/java/com/android/voicemail/impl/mail/MailTransport.java new file mode 100644 index 000000000..3df36d544 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/MailTransport.java @@ -0,0 +1,343 @@ +/* + * 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.voicemail.impl.mail; + +import android.content.Context; +import android.net.Network; +import android.support.annotation.VisibleForTesting; +import com.android.voicemail.impl.OmtpEvents; +import com.android.voicemail.impl.imap.ImapHelper; +import com.android.voicemail.impl.mail.store.ImapStore; +import com.android.voicemail.impl.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/voicemail/impl/mail/MeetingInfo.java b/java/com/android/voicemail/impl/mail/MeetingInfo.java new file mode 100644 index 000000000..9fe953d5d --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.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/voicemail/impl/mail/Message.java b/java/com/android/voicemail/impl/mail/Message.java new file mode 100644 index 000000000..aea5d3ead --- /dev/null +++ b/java/com/android/voicemail/impl/mail/Message.java @@ -0,0 +1,146 @@ +/* + * 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.voicemail.impl.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/voicemail/impl/mail/MessageDateComparator.java b/java/com/android/voicemail/impl/mail/MessageDateComparator.java new file mode 100644 index 000000000..89231f6c2 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/MessageDateComparator.java @@ -0,0 +1,35 @@ +/* + * 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.voicemail.impl.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/voicemail/impl/mail/MessagingException.java b/java/com/android/voicemail/impl/mail/MessagingException.java new file mode 100644 index 000000000..c1e3051df --- /dev/null +++ b/java/com/android/voicemail/impl/mail/MessagingException.java @@ -0,0 +1,143 @@ +/* + * 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.voicemail.impl.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; + } +} diff --git a/java/com/android/voicemail/impl/mail/Multipart.java b/java/com/android/voicemail/impl/mail/Multipart.java new file mode 100644 index 000000000..e8d5046d5 --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.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/voicemail/impl/mail/PackedString.java b/java/com/android/voicemail/impl/mail/PackedString.java new file mode 100644 index 000000000..701dab62b --- /dev/null +++ b/java/com/android/voicemail/impl/mail/PackedString.java @@ -0,0 +1,172 @@ +/* + * 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.voicemail.impl.mail; + +import android.util.ArrayMap; +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 ArrayMap mExploded; + private static final ArrayMap EMPTY_MAP = new ArrayMap(); + + /** + * 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 ArrayMap(mExploded); + } + + /** Read out all values into a map. */ + private static ArrayMap explode(String packed) { + if (packed == null || packed.length() == 0) { + return EMPTY_MAP; + } + ArrayMap map = new ArrayMap(); + + 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. + */ + public static class Builder { + ArrayMap mMap; + + /** Create a builder that's empty (for filling) */ + public Builder() { + mMap = new ArrayMap(); + } + + /** 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/voicemail/impl/mail/Part.java b/java/com/android/voicemail/impl/mail/Part.java new file mode 100644 index 000000000..3be5c57b9 --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.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/voicemail/impl/mail/PeekableInputStream.java b/java/com/android/voicemail/impl/mail/PeekableInputStream.java new file mode 100644 index 000000000..08f867f82 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/PeekableInputStream.java @@ -0,0 +1,81 @@ +/* + * 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.voicemail.impl.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); + } +} diff --git a/java/com/android/voicemail/impl/mail/TempDirectory.java b/java/com/android/voicemail/impl/mail/TempDirectory.java new file mode 100644 index 000000000..42adbeb1f --- /dev/null +++ b/java/com/android/voicemail/impl/mail/TempDirectory.java @@ -0,0 +1,40 @@ +/* + * 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.voicemail.impl.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; + } +} diff --git a/java/com/android/voicemail/impl/mail/internet/BinaryTempFileBody.java b/java/com/android/voicemail/impl/mail/internet/BinaryTempFileBody.java new file mode 100644 index 000000000..753b70f23 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/internet/BinaryTempFileBody.java @@ -0,0 +1,87 @@ +/* + * 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.voicemail.impl.mail.internet; + +import android.util.Base64; +import android.util.Base64OutputStream; +import com.android.voicemail.impl.mail.Body; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.mail.TempDirectory; +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; +import org.apache.commons.io.IOUtils; + +/** + * 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/voicemail/impl/mail/internet/MimeBodyPart.java b/java/com/android/voicemail/impl/mail/internet/MimeBodyPart.java new file mode 100644 index 000000000..2add76c72 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/internet/MimeBodyPart.java @@ -0,0 +1,200 @@ +/* + * 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.voicemail.impl.mail.internet; + +import com.android.voicemail.impl.mail.Body; +import com.android.voicemail.impl.mail.BodyPart; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.mail.Multipart; +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 Multipart) { + Multipart multipart = + ((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/voicemail/impl/mail/internet/MimeHeader.java b/java/com/android/voicemail/impl/mail/internet/MimeHeader.java new file mode 100644 index 000000000..d41cdb3e4 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/internet/MimeHeader.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.voicemail.impl.mail.internet; + +import com.android.voicemail.impl.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 static final boolean arrayContains(Object[] a, Object o) { + int index = arrayIndex(a, o); + return (index >= 0); + } + + public static final 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/voicemail/impl/mail/internet/MimeMessage.java b/java/com/android/voicemail/impl/mail/internet/MimeMessage.java new file mode 100644 index 000000000..dfb7d7c25 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/internet/MimeMessage.java @@ -0,0 +1,676 @@ +/* + * 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.voicemail.impl.mail.internet; + +import android.text.TextUtils; +import com.android.voicemail.impl.mail.Address; +import com.android.voicemail.impl.mail.Body; +import com.android.voicemail.impl.mail.BodyPart; +import com.android.voicemail.impl.mail.Message; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.mail.Multipart; +import com.android.voicemail.impl.mail.Part; +import com.android.voicemail.impl.mail.utils.LogUtils; +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; +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; + +/** + * 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 toLength = 4; // "To: " + final int ccLength = 4; // "Cc: " + final int bccLength = 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), toLength)); + 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), ccLength)); + 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), bccLength)); + 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 headerNameLength = 9; // "Subject: " + setHeader("Subject", MimeUtility.foldAndEncode2(subject, headerNameLength)); + } + + @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 fromLength = 6; // "From: " + if (from != null) { + setHeader("From", MimeUtility.fold(from.toHeader(), fromLength)); + 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 replyToLength = 10; // "Reply-to: " + if (replyTo == null || replyTo.length == 0) { + removeHeader("Reply-to"); + mReplyTo = null; + } else { + setHeader("Reply-to", MimeUtility.fold(Address.toHeader(replyTo), replyToLength)); + 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/voicemail/impl/mail/internet/MimeMultipart.java b/java/com/android/voicemail/impl/mail/internet/MimeMultipart.java new file mode 100644 index 000000000..87b88b52a --- /dev/null +++ b/java/com/android/voicemail/impl/mail/internet/MimeMultipart.java @@ -0,0 +1,113 @@ +/* + * 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.voicemail.impl.mail.internet; + +import com.android.voicemail.impl.mail.BodyPart; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.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/voicemail/impl/mail/internet/MimeUtility.java b/java/com/android/voicemail/impl/mail/internet/MimeUtility.java new file mode 100644 index 000000000..99846027b --- /dev/null +++ b/java/com/android/voicemail/impl/mail/internet/MimeUtility.java @@ -0,0 +1,400 @@ +/* + * 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.voicemail.impl.mail.internet; + +import android.text.TextUtils; +import android.util.Base64; +import android.util.Base64DataException; +import android.util.Base64InputStream; +import com.android.voicemail.impl.VvmLog; +import com.android.voicemail.impl.mail.Body; +import com.android.voicemail.impl.mail.BodyPart; +import com.android.voicemail.impl.mail.Message; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.mail.Multipart; +import com.android.voicemail.impl.mail.Part; +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; +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; + +public class MimeUtility { + private static final String LOG_TAG = "Email"; + + public static final String MIME_TYPE_RFC822 = "message/rfc822"; + private static final 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/voicemail/impl/mail/internet/TextBody.java b/java/com/android/voicemail/impl/mail/internet/TextBody.java new file mode 100644 index 000000000..dae562508 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/internet/TextBody.java @@ -0,0 +1,59 @@ +/* + * 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.voicemail.impl.mail.internet; + +import android.util.Base64; +import com.android.voicemail.impl.mail.Body; +import com.android.voicemail.impl.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/voicemail/impl/mail/store/ImapConnection.java b/java/com/android/voicemail/impl/mail/store/ImapConnection.java new file mode 100644 index 000000000..0a48dfc69 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/ImapConnection.java @@ -0,0 +1,400 @@ +/* + * 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.voicemail.impl.mail.store; + +import android.util.ArraySet; +import android.util.Base64; +import com.android.voicemail.impl.OmtpEvents; +import com.android.voicemail.impl.VvmLog; +import com.android.voicemail.impl.mail.AuthenticationFailedException; +import com.android.voicemail.impl.mail.CertificateValidationException; +import com.android.voicemail.impl.mail.MailTransport; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.mail.store.ImapStore.ImapException; +import com.android.voicemail.impl.mail.store.imap.DigestMd5Utils; +import com.android.voicemail.impl.mail.store.imap.ImapConstants; +import com.android.voicemail.impl.mail.store.imap.ImapResponse; +import com.android.voicemail.impl.mail.store.imap.ImapResponseParser; +import com.android.voicemail.impl.mail.store.imap.ImapUtility; +import com.android.voicemail.impl.mail.utils.LogUtils; +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); + break; + 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/voicemail/impl/mail/store/ImapFolder.java b/java/com/android/voicemail/impl/mail/store/ImapFolder.java new file mode 100644 index 000000000..1d9b01120 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/ImapFolder.java @@ -0,0 +1,797 @@ +/* + * 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.voicemail.impl.mail.store; + +import android.content.Context; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.util.ArrayMap; +import android.util.Base64DataException; +import com.android.voicemail.impl.OmtpEvents; +import com.android.voicemail.impl.mail.AuthenticationFailedException; +import com.android.voicemail.impl.mail.Body; +import com.android.voicemail.impl.mail.FetchProfile; +import com.android.voicemail.impl.mail.Flag; +import com.android.voicemail.impl.mail.Message; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.mail.Part; +import com.android.voicemail.impl.mail.internet.BinaryTempFileBody; +import com.android.voicemail.impl.mail.internet.MimeBodyPart; +import com.android.voicemail.impl.mail.internet.MimeHeader; +import com.android.voicemail.impl.mail.internet.MimeMultipart; +import com.android.voicemail.impl.mail.internet.MimeUtility; +import com.android.voicemail.impl.mail.store.ImapStore.ImapException; +import com.android.voicemail.impl.mail.store.ImapStore.ImapMessage; +import com.android.voicemail.impl.mail.store.imap.ImapConstants; +import com.android.voicemail.impl.mail.store.imap.ImapElement; +import com.android.voicemail.impl.mail.store.imap.ImapList; +import com.android.voicemail.impl.mail.store.imap.ImapResponse; +import com.android.voicemail.impl.mail.store.imap.ImapString; +import com.android.voicemail.impl.mail.utils.LogUtils; +import com.android.voicemail.impl.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.LinkedHashSet; +import java.util.List; +import java.util.Locale; + +public class ImapFolder { + private static final String TAG = "ImapFolder"; + private static final 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(); + ArrayMap messageMap = new ArrayMap(); + 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); + } +} diff --git a/java/com/android/voicemail/impl/mail/store/ImapStore.java b/java/com/android/voicemail/impl/mail/store/ImapStore.java new file mode 100644 index 000000000..cadbe593f --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/ImapStore.java @@ -0,0 +1,181 @@ +/* + * 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.voicemail.impl.mail.store; + +import android.content.Context; +import android.net.Network; +import com.android.voicemail.impl.imap.ImapHelper; +import com.android.voicemail.impl.mail.MailTransport; +import com.android.voicemail.impl.mail.Message; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.mail.internet.MimeMessage; +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; + } +} diff --git a/java/com/android/voicemail/impl/mail/store/imap/DigestMd5Utils.java b/java/com/android/voicemail/impl/mail/store/imap/DigestMd5Utils.java new file mode 100644 index 000000000..f156f67c1 --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.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.voicemail.impl.VvmLog; +import com.android.voicemail.impl.mail.MailTransport; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.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.O) +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/voicemail/impl/mail/store/imap/ImapConstants.java b/java/com/android/voicemail/impl/mail/store/imap/ImapConstants.java new file mode 100644 index 000000000..88ec0ed90 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/imap/ImapConstants.java @@ -0,0 +1,138 @@ +/* + * 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.voicemail.impl.mail.store.imap; + +import com.android.voicemail.impl.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"; +} diff --git a/java/com/android/voicemail/impl/mail/store/imap/ImapElement.java b/java/com/android/voicemail/impl/mail/store/imap/ImapElement.java new file mode 100644 index 000000000..ee255d1eb --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/imap/ImapElement.java @@ -0,0 +1,124 @@ +/* + * 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.voicemail.impl.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. + } +} diff --git a/java/com/android/voicemail/impl/mail/store/imap/ImapList.java b/java/com/android/voicemail/impl/mail/store/imap/ImapList.java new file mode 100644 index 000000000..e4a6ec0ac --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/imap/ImapList.java @@ -0,0 +1,226 @@ +/* + * 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.voicemail.impl.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; + } +} diff --git a/java/com/android/voicemail/impl/mail/store/imap/ImapMemoryLiteral.java b/java/com/android/voicemail/impl/mail/store/imap/ImapMemoryLiteral.java new file mode 100644 index 000000000..96a8c4ae5 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/imap/ImapMemoryLiteral.java @@ -0,0 +1,73 @@ +/* + * 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.voicemail.impl.mail.store.imap; + +import com.android.voicemail.impl.VvmLog; +import com.android.voicemail.impl.mail.FixedLengthInputStream; +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); + } +} diff --git a/java/com/android/voicemail/impl/mail/store/imap/ImapResponse.java b/java/com/android/voicemail/impl/mail/store/imap/ImapResponse.java new file mode 100644 index 000000000..d53d458da --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/imap/ImapResponse.java @@ -0,0 +1,142 @@ +/* + * 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.voicemail.impl.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; + } +} diff --git a/java/com/android/voicemail/impl/mail/store/imap/ImapResponseParser.java b/java/com/android/voicemail/impl/mail/store/imap/ImapResponseParser.java new file mode 100644 index 000000000..e37106a69 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/imap/ImapResponseParser.java @@ -0,0 +1,424 @@ +/* + * 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.voicemail.impl.mail.store.imap; + +import android.text.TextUtils; +import com.android.voicemail.impl.VvmLog; +import com.android.voicemail.impl.mail.FixedLengthInputStream; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.mail.PeekableInputStream; +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)) { + VvmLog.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); + } + } +} diff --git a/java/com/android/voicemail/impl/mail/store/imap/ImapSimpleString.java b/java/com/android/voicemail/impl/mail/store/imap/ImapSimpleString.java new file mode 100644 index 000000000..7cc866b74 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/imap/ImapSimpleString.java @@ -0,0 +1,59 @@ +/* + * 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.voicemail.impl.mail.store.imap; + +import com.android.voicemail.impl.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 + "\""; + } +} diff --git a/java/com/android/voicemail/impl/mail/store/imap/ImapString.java b/java/com/android/voicemail/impl/mail/store/imap/ImapString.java new file mode 100644 index 000000000..d5c555126 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/imap/ImapString.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.voicemail.impl.mail.store.imap; + +import com.android.voicemail.impl.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 static final 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()); + } +} diff --git a/java/com/android/voicemail/impl/mail/store/imap/ImapTempFileLiteral.java b/java/com/android/voicemail/impl/mail/store/imap/ImapTempFileLiteral.java new file mode 100644 index 000000000..ab64d8537 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/imap/ImapTempFileLiteral.java @@ -0,0 +1,119 @@ +/* + * 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.voicemail.impl.mail.store.imap; + +import com.android.voicemail.impl.mail.FixedLengthInputStream; +import com.android.voicemail.impl.mail.TempDirectory; +import com.android.voicemail.impl.mail.utils.LogUtils; +import com.android.voicemail.impl.mail.utils.Utility; +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; +import org.apache.commons.io.IOUtils; + +/** 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(); + } +} diff --git a/java/com/android/voicemail/impl/mail/store/imap/ImapUtility.java b/java/com/android/voicemail/impl/mail/store/imap/ImapUtility.java new file mode 100644 index 000000000..a325cc295 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/store/imap/ImapUtility.java @@ -0,0 +1,122 @@ +/* + * 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.voicemail.impl.mail.store.imap; + +import com.android.voicemail.impl.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); + } +} diff --git a/java/com/android/voicemail/impl/mail/utility/CountingOutputStream.java b/java/com/android/voicemail/impl/mail/utility/CountingOutputStream.java new file mode 100644 index 000000000..c3586105f --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.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++; + } +} diff --git a/java/com/android/voicemail/impl/mail/utility/EOLConvertingOutputStream.java b/java/com/android/voicemail/impl/mail/utility/EOLConvertingOutputStream.java new file mode 100644 index 000000000..72649ac4d --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.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(); + } +} diff --git a/java/com/android/voicemail/impl/mail/utils/LogUtils.java b/java/com/android/voicemail/impl/mail/utils/LogUtils.java new file mode 100644 index 000000000..f6c3c6ba3 --- /dev/null +++ b/java/com/android/voicemail/impl/mail/utils/LogUtils.java @@ -0,0 +1,345 @@ +/** + * 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.voicemail.impl.mail.utils; + +import android.net.Uri; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.util.Log; +import com.android.voicemail.impl.VvmLog; +import java.util.List; + +public class LogUtils { + public static final String TAG = "Email Log"; + + 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 void v(String tag, String format, Object... args) { + if (isLoggable(tag, VERBOSE)) { + VvmLog.v(tag, String.format(format, args)); + } + } + + /** + * 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 void v(String tag, Throwable tr, String format, Object... args) { + if (isLoggable(tag, VERBOSE)) { + VvmLog.v(tag, String.format(format, args), tr); + } + } + + /** + * 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 void d(String tag, String format, Object... args) { + if (isLoggable(tag, DEBUG)) { + VvmLog.d(tag, String.format(format, args)); + } + } + + /** + * 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 void d(String tag, Throwable tr, String format, Object... args) { + if (isLoggable(tag, DEBUG)) { + VvmLog.d(tag, String.format(format, args), tr); + } + } + + /** + * 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 void i(String tag, String format, Object... args) { + if (isLoggable(tag, INFO)) { + VvmLog.i(tag, String.format(format, args)); + } + } + + /** + * 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 void i(String tag, Throwable tr, String format, Object... args) { + if (isLoggable(tag, INFO)) { + VvmLog.i(tag, String.format(format, args), tr); + } + } + + /** + * 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 void w(String tag, String format, Object... args) { + if (isLoggable(tag, WARN)) { + VvmLog.w(tag, String.format(format, args)); + } + } + + /** + * 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 void w(String tag, Throwable tr, String format, Object... args) { + if (isLoggable(tag, WARN)) { + VvmLog.w(tag, String.format(format, args), tr); + } + } + + /** + * 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 void e(String tag, String format, Object... args) { + if (isLoggable(tag, ERROR)) { + VvmLog.e(tag, String.format(format, args)); + } + } + + /** + * 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 void e(String tag, Throwable tr, String format, Object... args) { + if (isLoggable(tag, ERROR)) { + VvmLog.e(tag, String.format(format, args), tr); + } + } + + /** + * 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 void wtf(String tag, String format, Object... args) { + 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 void wtf(String tag, Throwable tr, String format, Object... args) { + VvmLog.wtf(tag, String.format(format, args), tr); + } + + 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/voicemail/impl/mail/utils/Utility.java b/java/com/android/voicemail/impl/mail/utils/Utility.java new file mode 100644 index 000000000..4db1681fb --- /dev/null +++ b/java/com/android/voicemail/impl/mail/utils/Utility.java @@ -0,0 +1,76 @@ +/** + * 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.voicemail.impl.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/voicemail/impl/protocol/CvvmProtocol.java b/java/com/android/voicemail/impl/protocol/CvvmProtocol.java new file mode 100644 index 000000000..a4b54f68c --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.protocol; + +import android.content.Context; +import android.telecom.PhoneAccountHandle; +import com.android.voicemail.impl.OmtpConstants; +import com.android.voicemail.impl.sms.OmtpCvvmMessageSender; +import com.android.voicemail.impl.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/voicemail/impl/protocol/OmtpProtocol.java b/java/com/android/voicemail/impl/protocol/OmtpProtocol.java new file mode 100644 index 000000000..27aab8a7c --- /dev/null +++ b/java/com/android/voicemail/impl/protocol/OmtpProtocol.java @@ -0,0 +1,42 @@ +/* + * 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.voicemail.impl.protocol; + +import android.content.Context; +import android.telecom.PhoneAccountHandle; +import com.android.voicemail.impl.OmtpConstants; +import com.android.voicemail.impl.sms.OmtpMessageSender; +import com.android.voicemail.impl.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, + OmtpConstants.CLIENT_TYPE_GOOGLE_10, + OmtpConstants.PROTOCOL_VERSION1_1, + null /*clientPrefix*/); + } +} diff --git a/java/com/android/voicemail/impl/protocol/ProtocolHelper.java b/java/com/android/voicemail/impl/protocol/ProtocolHelper.java new file mode 100644 index 000000000..4d2e7cce4 --- /dev/null +++ b/java/com/android/voicemail/impl/protocol/ProtocolHelper.java @@ -0,0 +1,44 @@ +/* + * 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.voicemail.impl.protocol; + +import android.text.TextUtils; +import com.android.voicemail.impl.OmtpVvmCarrierConfigHelper; +import com.android.voicemail.impl.VvmLog; +import com.android.voicemail.impl.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/voicemail/impl/protocol/VisualVoicemailProtocol.java b/java/com/android/voicemail/impl/protocol/VisualVoicemailProtocol.java new file mode 100644 index 000000000..6cf82f1b8 --- /dev/null +++ b/java/com/android/voicemail/impl/protocol/VisualVoicemailProtocol.java @@ -0,0 +1,106 @@ +/* + * 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.voicemail.impl.protocol; + +import android.app.PendingIntent; +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccountHandle; +import com.android.voicemail.impl.ActivationTask; +import com.android.voicemail.impl.DefaultOmtpEventHandler; +import com.android.voicemail.impl.OmtpConstants; +import com.android.voicemail.impl.OmtpEvents; +import com.android.voicemail.impl.OmtpVvmCarrierConfigHelper; +import com.android.voicemail.impl.VoicemailStatus; +import com.android.voicemail.impl.sms.OmtpMessageSender; +import com.android.voicemail.impl.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 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/voicemail/impl/protocol/VisualVoicemailProtocolFactory.java b/java/com/android/voicemail/impl/protocol/VisualVoicemailProtocolFactory.java new file mode 100644 index 000000000..056fb2eaf --- /dev/null +++ b/java/com/android/voicemail/impl/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.voicemail.impl.protocol; + +import android.content.res.Resources; +import android.support.annotation.Nullable; +import android.telephony.TelephonyManager; +import com.android.voicemail.impl.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/voicemail/impl/protocol/Vvm3EventHandler.java b/java/com/android/voicemail/impl/protocol/Vvm3EventHandler.java new file mode 100644 index 000000000..8bc3cc21c --- /dev/null +++ b/java/com/android/voicemail/impl/protocol/Vvm3EventHandler.java @@ -0,0 +1,307 @@ +/* + * 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.voicemail.impl.protocol; + +import android.content.Context; +import android.provider.VoicemailContract.Status; +import android.support.annotation.IntDef; +import android.telecom.PhoneAccountHandle; +import com.android.voicemail.impl.DefaultOmtpEventHandler; +import com.android.voicemail.impl.OmtpEvents; +import com.android.voicemail.impl.OmtpEvents.Type; +import com.android.voicemail.impl.OmtpVvmCarrierConfigHelper; +import com.android.voicemail.impl.VoicemailStatus; +import com.android.voicemail.impl.VvmLog; +import com.android.voicemail.impl.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: + VvmLog.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 (!isPinRandomized(context, status.getPhoneAccountHandle())) { + return false; + } else { + postError(status, PIN_NOT_SET); + } + break; + case CONFIG_ACTIVATING_SUBSEQUENT: + if (isPinRandomized(context, status.getPhoneAccountHandle())) { + status.setConfigurationState(PIN_NOT_SET); + } else { + status.setConfigurationState(Status.CONFIGURATION_STATE_OK); + } + status + .setNotificationChannelState(Status.NOTIFICATION_CHANNEL_STATE_OK) + .setDataChannelState(Status.DATA_CHANNEL_STATE_OK) + .apply(); + 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 unusedStatus, OmtpEvents unusedEvent) { + 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: + VvmLog.wtf(TAG, "unknown error code: " + errorCode); + } + editor.apply(); + } + + private static boolean isPinRandomized(Context context, PhoneAccountHandle phoneAccountHandle) { + if (phoneAccountHandle == null) { + // This should never happen. + VvmLog.e(TAG, "status editor has null phone account handle"); + return false; + } + return VoicemailChangePinActivity.isDefaultOldPinSet(context, phoneAccountHandle); + } +} diff --git a/java/com/android/voicemail/impl/protocol/Vvm3Protocol.java b/java/com/android/voicemail/impl/protocol/Vvm3Protocol.java new file mode 100644 index 000000000..f293a4cdb --- /dev/null +++ b/java/com/android/voicemail/impl/protocol/Vvm3Protocol.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.voicemail.impl.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.voicemail.impl.ActivationTask; +import com.android.voicemail.impl.OmtpConstants; +import com.android.voicemail.impl.OmtpEvents; +import com.android.voicemail.impl.OmtpVvmCarrierConfigHelper; +import com.android.voicemail.impl.VisualVoicemailPreferences; +import com.android.voicemail.impl.VoicemailStatus; +import com.android.voicemail.impl.VvmLog; +import com.android.voicemail.impl.imap.ImapHelper; +import com.android.voicemail.impl.imap.ImapHelper.InitializingException; +import com.android.voicemail.impl.mail.MessagingException; +import com.android.voicemail.impl.settings.VisualVoicemailSettingsUtil; +import com.android.voicemail.impl.settings.VoicemailChangePinActivity; +import com.android.voicemail.impl.sms.OmtpMessageSender; +import com.android.voicemail.impl.sms.StatusMessage; +import com.android.voicemail.impl.sms.Vvm3MessageSender; +import com.android.voicemail.impl.sync.VvmNetworkRequest; +import com.android.voicemail.impl.sync.VvmNetworkRequest.NetworkWrapper; +import com.android.voicemail.impl.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.O) +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) { + switch (command) { + case OmtpConstants.IMAP_CHANGE_TUI_PWD_FORMAT: + return IMAP_CHANGE_TUI_PWD_FORMAT; + case OmtpConstants.IMAP_CLOSE_NUT: + return IMAP_CLOSE_NUT; + case OmtpConstants.IMAP_CHANGE_VM_LANG_FORMAT: + return IMAP_CHANGE_VM_LANG_FORMAT; + default: + 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/voicemail/impl/protocol/Vvm3Subscriber.java b/java/com/android/voicemail/impl/protocol/Vvm3Subscriber.java new file mode 100644 index 000000000..c8a74c8d5 --- /dev/null +++ b/java/com/android/voicemail/impl/protocol/Vvm3Subscriber.java @@ -0,0 +1,334 @@ +/* + * 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.voicemail.impl.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.voicemail.impl.ActivationTask; +import com.android.voicemail.impl.Assert; +import com.android.voicemail.impl.OmtpEvents; +import com.android.voicemail.impl.OmtpVvmCarrierConfigHelper; +import com.android.voicemail.impl.VoicemailStatus; +import com.android.voicemail.impl.VvmLog; +import com.android.voicemail.impl.sync.VvmNetworkRequest; +import com.android.voicemail.impl.sync.VvmNetworkRequest.NetworkWrapper; +import com.android.voicemail.impl.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.O) +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/voicemail/impl/res/layout/voicemail_change_pin.xml b/java/com/android/voicemail/impl/res/layout/voicemail_change_pin.xml new file mode 100644 index 000000000..50c92777e --- /dev/null +++ b/java/com/android/voicemail/impl/res/layout/voicemail_change_pin.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + +