/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License */ package com.android.voicemailomtp.imap; import android.content.Context; import android.net.ConnectivityManager; import android.net.Network; import android.net.NetworkInfo; import android.provider.VoicemailContract; import android.telecom.PhoneAccountHandle; import android.util.Base64; import com.android.voicemailomtp.OmtpConstants; import com.android.voicemailomtp.OmtpConstants.ChangePinResult; import com.android.voicemailomtp.OmtpEvents; import com.android.voicemailomtp.OmtpVvmCarrierConfigHelper; import com.android.voicemailomtp.VisualVoicemailPreferences; import com.android.voicemailomtp.Voicemail; import com.android.voicemailomtp.VoicemailStatus; import com.android.voicemailomtp.VvmLog; import com.android.voicemailomtp.fetch.VoicemailFetchedCallback; import com.android.voicemailomtp.mail.Address; import com.android.voicemailomtp.mail.Body; import com.android.voicemailomtp.mail.BodyPart; import com.android.voicemailomtp.mail.FetchProfile; import com.android.voicemailomtp.mail.Flag; import com.android.voicemailomtp.mail.Message; import com.android.voicemailomtp.mail.MessagingException; import com.android.voicemailomtp.mail.Multipart; import com.android.voicemailomtp.mail.TempDirectory; import com.android.voicemailomtp.mail.internet.MimeMessage; import com.android.voicemailomtp.mail.store.ImapConnection; import com.android.voicemailomtp.mail.store.ImapFolder; import com.android.voicemailomtp.mail.store.ImapStore; import com.android.voicemailomtp.mail.store.imap.ImapConstants; import com.android.voicemailomtp.mail.store.imap.ImapResponse; import com.android.voicemailomtp.mail.utils.LogUtils; import com.android.voicemailomtp.sync.OmtpVvmSyncService.TranscriptionFetchedCallback; import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Locale; import org.apache.commons.io.IOUtils; /** * A helper interface to abstract commands sent across IMAP interface for a given account. */ public class ImapHelper implements Closeable { private static final String TAG = "ImapHelper"; private ImapFolder mFolder; private ImapStore mImapStore; private final Context mContext; private final PhoneAccountHandle mPhoneAccount; private final Network mNetwork; private final VoicemailStatus.Editor mStatus; VisualVoicemailPreferences mPrefs; private static final String PREF_KEY_QUOTA_OCCUPIED = "quota_occupied_"; private static final String PREF_KEY_QUOTA_TOTAL = "quota_total_"; private int mQuotaOccupied; private int mQuotaTotal; private final OmtpVvmCarrierConfigHelper mConfig; public class InitializingException extends Exception { public InitializingException(String message) { super(message); } } public ImapHelper(Context context, PhoneAccountHandle phoneAccount, Network network, VoicemailStatus.Editor status) throws InitializingException { this(context, new OmtpVvmCarrierConfigHelper( context, phoneAccount), phoneAccount, network, status); } public ImapHelper(Context context, OmtpVvmCarrierConfigHelper config, PhoneAccountHandle phoneAccount, Network network, VoicemailStatus.Editor status) throws InitializingException { mContext = context; mPhoneAccount = phoneAccount; mNetwork = network; mStatus = status; mConfig = config; mPrefs = new VisualVoicemailPreferences(context, phoneAccount); try { TempDirectory.setTempDirectory(context); String username = mPrefs.getString(OmtpConstants.IMAP_USER_NAME, null); String password = mPrefs.getString(OmtpConstants.IMAP_PASSWORD, null); String serverName = mPrefs.getString(OmtpConstants.SERVER_ADDRESS, null); int port = Integer.parseInt( mPrefs.getString(OmtpConstants.IMAP_PORT, null)); int auth = ImapStore.FLAG_NONE; int sslPort = mConfig.getSslPort(); if (sslPort != 0) { port = sslPort; auth = ImapStore.FLAG_SSL; } mImapStore = new ImapStore( context, this, username, password, port, serverName, auth, network); } catch (NumberFormatException e) { handleEvent(OmtpEvents.DATA_INVALID_PORT); LogUtils.w(TAG, "Could not parse port number"); throw new InitializingException("cannot initialize ImapHelper:" + e.toString()); } mQuotaOccupied = mPrefs .getInt(PREF_KEY_QUOTA_OCCUPIED, VoicemailContract.Status.QUOTA_UNAVAILABLE); mQuotaTotal = mPrefs .getInt(PREF_KEY_QUOTA_TOTAL, VoicemailContract.Status.QUOTA_UNAVAILABLE); } @Override public void close() { mImapStore.closeConnection(); } public boolean isRoaming() { ConnectivityManager connectivityManager = (ConnectivityManager) mContext.getSystemService( Context.CONNECTIVITY_SERVICE); NetworkInfo info = connectivityManager.getNetworkInfo(mNetwork); if (info == null) { return false; } return info.isRoaming(); } public OmtpVvmCarrierConfigHelper getConfig() { return mConfig; } public ImapConnection connect() { return mImapStore.getConnection(); } /** * The caller thread will block until the method returns. */ public boolean markMessagesAsRead(List voicemails) { return setFlags(voicemails, Flag.SEEN); } /** * The caller thread will block until the method returns. */ public boolean markMessagesAsDeleted(List voicemails) { return setFlags(voicemails, Flag.DELETED); } public void handleEvent(OmtpEvents event) { mConfig.handleEvent(mStatus, event); } /** * Set flags on the server for a given set of voicemails. * * @param voicemails The voicemails to set flags for. * @param flags The flags to set on the voicemails. * @return {@code true} if the operation completes successfully, {@code false} otherwise. */ private boolean setFlags(List voicemails, String... flags) { if (voicemails.size() == 0) { return false; } try { mFolder = openImapFolder(ImapFolder.MODE_READ_WRITE); if (mFolder != null) { mFolder.setFlags(convertToImapMessages(voicemails), flags, true); return true; } return false; } catch (MessagingException e) { LogUtils.e(TAG, e, "Messaging exception"); return false; } finally { closeImapFolder(); } } /** * Fetch a list of voicemails from the server. * * @return A list of voicemail objects containing data about voicemails stored on the server. */ public List fetchAllVoicemails() { List result = new ArrayList(); Message[] messages; try { mFolder = openImapFolder(ImapFolder.MODE_READ_WRITE); if (mFolder == null) { // This means we were unable to successfully open the folder. return null; } // This method retrieves lightweight messages containing only the uid of the message. messages = mFolder.getMessages(null); for (Message message : messages) { // Get the voicemail details (message structure). MessageStructureWrapper messageStructureWrapper = fetchMessageStructure(message); if (messageStructureWrapper != null) { result.add(getVoicemailFromMessageStructure(messageStructureWrapper)); } } return result; } catch (MessagingException e) { LogUtils.e(TAG, e, "Messaging Exception"); return null; } finally { closeImapFolder(); } } /** * Extract voicemail details from the message structure. Also fetch transcription if a * transcription exists. */ private Voicemail getVoicemailFromMessageStructure( MessageStructureWrapper messageStructureWrapper) throws MessagingException { Message messageDetails = messageStructureWrapper.messageStructure; TranscriptionFetchedListener listener = new TranscriptionFetchedListener(); if (messageStructureWrapper.transcriptionBodyPart != null) { FetchProfile fetchProfile = new FetchProfile(); fetchProfile.add(messageStructureWrapper.transcriptionBodyPart); mFolder.fetch(new Message[]{messageDetails}, fetchProfile, listener); } // Found an audio attachment, this is a valid voicemail. long time = messageDetails.getSentDate().getTime(); String number = getNumber(messageDetails.getFrom()); boolean isRead = Arrays.asList(messageDetails.getFlags()).contains(Flag.SEEN); return Voicemail.createForInsertion(time, number) .setPhoneAccount(mPhoneAccount) .setSourcePackage(mContext.getPackageName()) .setSourceData(messageDetails.getUid()) .setIsRead(isRead) .setTranscription(listener.getVoicemailTranscription()) .build(); } /** * The "from" field of a visual voicemail IMAP message is the number of the caller who left the * message. Extract this number from the list of "from" addresses. * * @param fromAddresses A list of addresses that comprise the "from" line. * @return The number of the voicemail sender. */ private String getNumber(Address[] fromAddresses) { if (fromAddresses != null && fromAddresses.length > 0) { if (fromAddresses.length != 1) { LogUtils.w(TAG, "More than one from addresses found. Using the first one."); } String sender = fromAddresses[0].getAddress(); int atPos = sender.indexOf('@'); if (atPos != -1) { // Strip domain part of the address. sender = sender.substring(0, atPos); } return sender; } return null; } /** * Fetches the structure of the given message and returns a wrapper containing the message * structure and the transcription structure (if applicable). * * @throws MessagingException if fetching the structure of the message fails */ private MessageStructureWrapper fetchMessageStructure(Message message) throws MessagingException { LogUtils.d(TAG, "Fetching message structure for " + message.getUid()); MessageStructureFetchedListener listener = new MessageStructureFetchedListener(); FetchProfile fetchProfile = new FetchProfile(); fetchProfile.addAll(Arrays.asList(FetchProfile.Item.FLAGS, FetchProfile.Item.ENVELOPE, FetchProfile.Item.STRUCTURE)); // The IMAP folder fetch method will call "messageRetrieved" on the listener when the // message is successfully retrieved. mFolder.fetch(new Message[]{message}, fetchProfile, listener); return listener.getMessageStructure(); } public boolean fetchVoicemailPayload(VoicemailFetchedCallback callback, final String uid) { try { mFolder = openImapFolder(ImapFolder.MODE_READ_WRITE); if (mFolder == null) { // This means we were unable to successfully open the folder. return false; } Message message = mFolder.getMessage(uid); if (message == null) { return false; } VoicemailPayload voicemailPayload = fetchVoicemailPayload(message); callback.setVoicemailContent(voicemailPayload); return true; } catch (MessagingException e) { } finally { closeImapFolder(); } return false; } /** * Fetches the body of the given message and returns the parsed voicemail payload. * * @throws MessagingException if fetching the body of the message fails */ private VoicemailPayload fetchVoicemailPayload(Message message) throws MessagingException { LogUtils.d(TAG, "Fetching message body for " + message.getUid()); MessageBodyFetchedListener listener = new MessageBodyFetchedListener(); FetchProfile fetchProfile = new FetchProfile(); fetchProfile.add(FetchProfile.Item.BODY); mFolder.fetch(new Message[]{message}, fetchProfile, listener); return listener.getVoicemailPayload(); } public boolean fetchTranscription(TranscriptionFetchedCallback callback, String uid) { try { mFolder = openImapFolder(ImapFolder.MODE_READ_WRITE); if (mFolder == null) { // This means we were unable to successfully open the folder. return false; } Message message = mFolder.getMessage(uid); if (message == null) { return false; } MessageStructureWrapper messageStructureWrapper = fetchMessageStructure(message); if (messageStructureWrapper != null) { TranscriptionFetchedListener listener = new TranscriptionFetchedListener(); if (messageStructureWrapper.transcriptionBodyPart != null) { FetchProfile fetchProfile = new FetchProfile(); fetchProfile.add(messageStructureWrapper.transcriptionBodyPart); // This method is called synchronously so the transcription will be populated // in the listener once the next method is called. mFolder.fetch(new Message[]{message}, fetchProfile, listener); callback.setVoicemailTranscription(listener.getVoicemailTranscription()); } } return true; } catch (MessagingException e) { LogUtils.e(TAG, e, "Messaging Exception"); return false; } finally { closeImapFolder(); } } @ChangePinResult public int changePin(String oldPin, String newPin) throws MessagingException { ImapConnection connection = mImapStore.getConnection(); try { String command = getConfig().getProtocol() .getCommand(OmtpConstants.IMAP_CHANGE_TUI_PWD_FORMAT); connection.sendCommand( String.format(Locale.US, command, newPin, oldPin), true); return getChangePinResultFromImapResponse(connection.readResponse()); } catch (IOException ioe) { VvmLog.e(TAG, "changePin: ", ioe); return OmtpConstants.CHANGE_PIN_SYSTEM_ERROR; } finally { connection.destroyResponses(); } } public void changeVoicemailTuiLanguage(String languageCode) throws MessagingException { ImapConnection connection = mImapStore.getConnection(); try { String command = getConfig().getProtocol() .getCommand(OmtpConstants.IMAP_CHANGE_VM_LANG_FORMAT); connection.sendCommand( String.format(Locale.US, command, languageCode), true); } catch (IOException ioe) { LogUtils.e(TAG, ioe.toString()); } finally { connection.destroyResponses(); } } public void closeNewUserTutorial() throws MessagingException { ImapConnection connection = mImapStore.getConnection(); try { String command = getConfig().getProtocol() .getCommand(OmtpConstants.IMAP_CLOSE_NUT); connection.executeSimpleCommand(command, false); } catch (IOException ioe) { throw new MessagingException(MessagingException.SERVER_ERROR, ioe.toString()); } finally { connection.destroyResponses(); } } @ChangePinResult private static int getChangePinResultFromImapResponse(ImapResponse response) throws MessagingException { if (!response.isTagged()) { throw new MessagingException(MessagingException.SERVER_ERROR, "tagged response expected"); } if (!response.isOk()) { String message = response.getStringOrEmpty(1).getString(); LogUtils.d(TAG, "change PIN failed: " + message); if (OmtpConstants.RESPONSE_CHANGE_PIN_TOO_SHORT.equals(message)) { return OmtpConstants.CHANGE_PIN_TOO_SHORT; } if (OmtpConstants.RESPONSE_CHANGE_PIN_TOO_LONG.equals(message)) { return OmtpConstants.CHANGE_PIN_TOO_LONG; } if (OmtpConstants.RESPONSE_CHANGE_PIN_TOO_WEAK.equals(message)) { return OmtpConstants.CHANGE_PIN_TOO_WEAK; } if (OmtpConstants.RESPONSE_CHANGE_PIN_MISMATCH.equals(message)) { return OmtpConstants.CHANGE_PIN_MISMATCH; } if (OmtpConstants.RESPONSE_CHANGE_PIN_INVALID_CHARACTER.equals(message)) { return OmtpConstants.CHANGE_PIN_INVALID_CHARACTER; } return OmtpConstants.CHANGE_PIN_SYSTEM_ERROR; } LogUtils.d(TAG, "change PIN succeeded"); return OmtpConstants.CHANGE_PIN_SUCCESS; } public void updateQuota() { try { mFolder = openImapFolder(ImapFolder.MODE_READ_WRITE); if (mFolder == null) { // This means we were unable to successfully open the folder. return; } updateQuota(mFolder); } catch (MessagingException e) { LogUtils.e(TAG, e, "Messaging Exception"); } finally { closeImapFolder(); } } private void updateQuota(ImapFolder folder) throws MessagingException { setQuota(folder.getQuota()); } private void setQuota(ImapFolder.Quota quota) { if (quota == null) { return; } if (quota.occupied == mQuotaOccupied && quota.total == mQuotaTotal) { VvmLog.v(TAG, "Quota hasn't changed"); return; } mQuotaOccupied = quota.occupied; mQuotaTotal = quota.total; VoicemailStatus.edit(mContext, mPhoneAccount) .setQuota(mQuotaOccupied, mQuotaTotal) .apply(); mPrefs.edit() .putInt(PREF_KEY_QUOTA_OCCUPIED, mQuotaOccupied) .putInt(PREF_KEY_QUOTA_TOTAL, mQuotaTotal) .apply(); VvmLog.v(TAG, "Quota changed to " + mQuotaOccupied + "/" + mQuotaTotal); } /** * A wrapper to hold a message with its header details and the structure for transcriptions (so * they can be fetched in the future). */ public class MessageStructureWrapper { public Message messageStructure; public BodyPart transcriptionBodyPart; public MessageStructureWrapper() { } } /** * Listener for the message structure being fetched. */ private final class MessageStructureFetchedListener implements ImapFolder.MessageRetrievalListener { private MessageStructureWrapper mMessageStructure; public MessageStructureFetchedListener() { } public MessageStructureWrapper getMessageStructure() { return mMessageStructure; } @Override public void messageRetrieved(Message message) { LogUtils.d(TAG, "Fetched message structure for " + message.getUid()); LogUtils.d(TAG, "Message retrieved: " + message); try { mMessageStructure = getMessageOrNull(message); if (mMessageStructure == null) { LogUtils.d(TAG, "This voicemail does not have an attachment..."); return; } } catch (MessagingException e) { LogUtils.e(TAG, e, "Messaging Exception"); closeImapFolder(); } } /** * Check if this IMAP message is a valid voicemail and whether it contains a transcription. * * @param message The IMAP message. * @return The MessageStructureWrapper object corresponding to an IMAP message and * transcription. */ private MessageStructureWrapper getMessageOrNull(Message message) throws MessagingException { if (!message.getMimeType().startsWith("multipart/")) { LogUtils.w(TAG, "Ignored non multi-part message"); return null; } MessageStructureWrapper messageStructureWrapper = new MessageStructureWrapper(); Multipart multipart = (Multipart) message.getBody(); for (int i = 0; i < multipart.getCount(); ++i) { BodyPart bodyPart = multipart.getBodyPart(i); String bodyPartMimeType = bodyPart.getMimeType().toLowerCase(); LogUtils.d(TAG, "bodyPart mime type: " + bodyPartMimeType); if (bodyPartMimeType.startsWith("audio/")) { messageStructureWrapper.messageStructure = message; } else if (bodyPartMimeType.startsWith("text/")) { messageStructureWrapper.transcriptionBodyPart = bodyPart; } else { VvmLog.v(TAG, "Unknown bodyPart MIME: " + bodyPartMimeType); } } if (messageStructureWrapper.messageStructure != null) { return messageStructureWrapper; } // No attachment found, this is not a voicemail. return null; } } /** * Listener for the message body being fetched. */ private final class MessageBodyFetchedListener implements ImapFolder.MessageRetrievalListener { private VoicemailPayload mVoicemailPayload; /** * Returns the fetch voicemail payload. */ public VoicemailPayload getVoicemailPayload() { return mVoicemailPayload; } @Override public void messageRetrieved(Message message) { LogUtils.d(TAG, "Fetched message body for " + message.getUid()); LogUtils.d(TAG, "Message retrieved: " + message); try { mVoicemailPayload = getVoicemailPayloadFromMessage(message); } catch (MessagingException e) { LogUtils.e(TAG, "Messaging Exception:", e); } catch (IOException e) { LogUtils.e(TAG, "IO Exception:", e); } } private VoicemailPayload getVoicemailPayloadFromMessage(Message message) throws MessagingException, IOException { Multipart multipart = (Multipart) message.getBody(); List mimeTypes = new ArrayList<>(); for (int i = 0; i < multipart.getCount(); ++i) { BodyPart bodyPart = multipart.getBodyPart(i); String bodyPartMimeType = bodyPart.getMimeType().toLowerCase(); mimeTypes.add(bodyPartMimeType); if (bodyPartMimeType.startsWith("audio/")) { byte[] bytes = getDataFromBody(bodyPart.getBody()); LogUtils.d(TAG, String.format("Fetched %s bytes of data", bytes.length)); return new VoicemailPayload(bodyPartMimeType, bytes); } } LogUtils.e(TAG, "No audio attachment found on this voicemail, mimeTypes:" + mimeTypes); return null; } } /** * Listener for the transcription being fetched. */ private final class TranscriptionFetchedListener implements ImapFolder.MessageRetrievalListener { private String mVoicemailTranscription; /** * Returns the fetched voicemail transcription. */ public String getVoicemailTranscription() { return mVoicemailTranscription; } @Override public void messageRetrieved(Message message) { LogUtils.d(TAG, "Fetched transcription for " + message.getUid()); try { mVoicemailTranscription = new String(getDataFromBody(message.getBody())); } catch (MessagingException e) { LogUtils.e(TAG, "Messaging Exception:", e); } catch (IOException e) { LogUtils.e(TAG, "IO Exception:", e); } } } private ImapFolder openImapFolder(String modeReadWrite) { try { if (mImapStore == null) { return null; } ImapFolder folder = new ImapFolder(mImapStore, ImapConstants.INBOX); folder.open(modeReadWrite); return folder; } catch (MessagingException e) { LogUtils.e(TAG, e, "Messaging Exception"); } return null; } private Message[] convertToImapMessages(List voicemails) { Message[] messages = new Message[voicemails.size()]; for (int i = 0; i < voicemails.size(); ++i) { messages[i] = new MimeMessage(); messages[i].setUid(voicemails.get(i).getSourceData()); } return messages; } private void closeImapFolder() { if (mFolder != null) { mFolder.close(true); } } private byte[] getDataFromBody(Body body) throws IOException, MessagingException { ByteArrayOutputStream out = new ByteArrayOutputStream(); BufferedOutputStream bufferedOut = new BufferedOutputStream(out); try { body.writeTo(bufferedOut); return Base64.decode(out.toByteArray(), Base64.DEFAULT); } finally { IOUtils.closeQuietly(bufferedOut); IOUtils.closeQuietly(out); } } }