summaryrefslogtreecommitdiffstats
path: root/src/com
diff options
context:
space:
mode:
authorMike Dodd <mdodd@google.com>2015-08-11 11:16:59 -0700
committerMike Dodd <mdodd@google.com>2015-08-12 08:58:28 -0700
commit461a34b466cb4b13dbbc2ec6330b31e217b2ac4e (patch)
treebc4b489af52d0e2521e21167d2ad76a47256f348 /src/com
parent8b3e2b9c1b0a09423a7ba5d1091b9192106502f8 (diff)
downloadandroid_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.tar.gz
android_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.tar.bz2
android_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.zip
Initial checkin of AOSP Messaging app.
b/23110861 Change-Id: I9aa980d7569247d6b2ca78f5dcb4502e1eaadb8a
Diffstat (limited to 'src/com')
-rw-r--r--src/com/android/messaging/BugleApplication.java262
-rw-r--r--src/com/android/messaging/Factory.java75
-rw-r--r--src/com/android/messaging/FactoryImpl.java245
-rw-r--r--src/com/android/messaging/annotation/VisibleForAnimation.java23
-rw-r--r--src/com/android/messaging/datamodel/BitmapPool.java364
-rw-r--r--src/com/android/messaging/datamodel/BoundCursorLoader.java46
-rw-r--r--src/com/android/messaging/datamodel/BugleDatabaseOperations.java1919
-rw-r--r--src/com/android/messaging/datamodel/BugleNotifications.java1221
-rw-r--r--src/com/android/messaging/datamodel/BugleRecipientEntry.java64
-rw-r--r--src/com/android/messaging/datamodel/ConversationImagePartsView.java120
-rw-r--r--src/com/android/messaging/datamodel/CursorQueryData.java82
-rw-r--r--src/com/android/messaging/datamodel/DataModel.java158
-rw-r--r--src/com/android/messaging/datamodel/DataModelException.java101
-rw-r--r--src/com/android/messaging/datamodel/DataModelImpl.java236
-rw-r--r--src/com/android/messaging/datamodel/DatabaseHelper.java813
-rw-r--r--src/com/android/messaging/datamodel/DatabaseUpgradeHelper.java42
-rw-r--r--src/com/android/messaging/datamodel/DatabaseWrapper.java482
-rw-r--r--src/com/android/messaging/datamodel/FileProvider.java151
-rw-r--r--src/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java179
-rw-r--r--src/com/android/messaging/datamodel/FrequentContactsCursorQueryData.java114
-rw-r--r--src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java49
-rw-r--r--src/com/android/messaging/datamodel/MediaScratchFileProvider.java132
-rw-r--r--src/com/android/messaging/datamodel/MemoryCacheManager.java75
-rw-r--r--src/com/android/messaging/datamodel/MessageNotificationState.java1342
-rw-r--r--src/com/android/messaging/datamodel/MessageTextStats.java92
-rw-r--r--src/com/android/messaging/datamodel/MessagingContentProvider.java476
-rw-r--r--src/com/android/messaging/datamodel/MmsFileProvider.java69
-rw-r--r--src/com/android/messaging/datamodel/NoConfirmationSmsSendService.java148
-rw-r--r--src/com/android/messaging/datamodel/NotificationState.java149
-rw-r--r--src/com/android/messaging/datamodel/ParticipantRefresh.java738
-rw-r--r--src/com/android/messaging/datamodel/SyncManager.java478
-rw-r--r--src/com/android/messaging/datamodel/action/Action.java295
-rw-r--r--src/com/android/messaging/datamodel/action/ActionMonitor.java477
-rw-r--r--src/com/android/messaging/datamodel/action/ActionService.java63
-rw-r--r--src/com/android/messaging/datamodel/action/ActionServiceImpl.java341
-rw-r--r--src/com/android/messaging/datamodel/action/BackgroundWorker.java32
-rw-r--r--src/com/android/messaging/datamodel/action/BackgroundWorkerService.java168
-rw-r--r--src/com/android/messaging/datamodel/action/BugleActionToasts.java172
-rw-r--r--src/com/android/messaging/datamodel/action/DeleteConversationAction.java205
-rw-r--r--src/com/android/messaging/datamodel/action/DeleteMessageAction.java135
-rw-r--r--src/com/android/messaging/datamodel/action/DownloadMmsAction.java340
-rw-r--r--src/com/android/messaging/datamodel/action/DumpDatabaseAction.java124
-rw-r--r--src/com/android/messaging/datamodel/action/FixupMessageStatusOnStartupAction.java114
-rw-r--r--src/com/android/messaging/datamodel/action/GetOrCreateConversationAction.java173
-rw-r--r--src/com/android/messaging/datamodel/action/HandleLowStorageAction.java94
-rw-r--r--src/com/android/messaging/datamodel/action/InsertNewMessageAction.java480
-rw-r--r--src/com/android/messaging/datamodel/action/LogTelephonyDatabaseAction.java153
-rw-r--r--src/com/android/messaging/datamodel/action/MarkAsReadAction.java113
-rw-r--r--src/com/android/messaging/datamodel/action/MarkAsSeenAction.java126
-rw-r--r--src/com/android/messaging/datamodel/action/ProcessDeliveryReportAction.java122
-rw-r--r--src/com/android/messaging/datamodel/action/ProcessDownloadedMmsAction.java573
-rw-r--r--src/com/android/messaging/datamodel/action/ProcessPendingMessagesAction.java470
-rw-r--r--src/com/android/messaging/datamodel/action/ProcessSentMessageAction.java310
-rw-r--r--src/com/android/messaging/datamodel/action/ReadDraftDataAction.java166
-rw-r--r--src/com/android/messaging/datamodel/action/ReceiveMmsMessageAction.java197
-rw-r--r--src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java198
-rw-r--r--src/com/android/messaging/datamodel/action/RedownloadMmsAction.java128
-rw-r--r--src/com/android/messaging/datamodel/action/ResendMessageAction.java128
-rw-r--r--src/com/android/messaging/datamodel/action/SendMessageAction.java447
-rw-r--r--src/com/android/messaging/datamodel/action/SyncCursorPair.java712
-rw-r--r--src/com/android/messaging/datamodel/action/SyncMessageBatch.java383
-rw-r--r--src/com/android/messaging/datamodel/action/SyncMessagesAction.java637
-rw-r--r--src/com/android/messaging/datamodel/action/UpdateConversationArchiveStatusAction.java93
-rw-r--r--src/com/android/messaging/datamodel/action/UpdateConversationOptionsAction.java156
-rw-r--r--src/com/android/messaging/datamodel/action/UpdateDestinationBlockedAction.java148
-rw-r--r--src/com/android/messaging/datamodel/action/UpdateMessageNotificationAction.java63
-rw-r--r--src/com/android/messaging/datamodel/action/UpdateMessagePartSizeAction.java103
-rw-r--r--src/com/android/messaging/datamodel/action/WriteDraftMessageAction.java104
-rw-r--r--src/com/android/messaging/datamodel/binding/BindableData.java72
-rw-r--r--src/com/android/messaging/datamodel/binding/BindableOnceData.java42
-rw-r--r--src/com/android/messaging/datamodel/binding/Binding.java94
-rw-r--r--src/com/android/messaging/datamodel/binding/BindingBase.java84
-rw-r--r--src/com/android/messaging/datamodel/binding/DetachableBinding.java56
-rw-r--r--src/com/android/messaging/datamodel/binding/ImmutableBindingRef.java83
-rw-r--r--src/com/android/messaging/datamodel/data/BlockedParticipantsData.java103
-rw-r--r--src/com/android/messaging/datamodel/data/ContactListItemData.java160
-rw-r--r--src/com/android/messaging/datamodel/data/ContactPickerData.java194
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationData.java849
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationListData.java211
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationListItemData.java510
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationMessageBubbleData.java37
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationMessageData.java917
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationParticipantsData.java125
-rw-r--r--src/com/android/messaging/datamodel/data/DraftMessageData.java855
-rw-r--r--src/com/android/messaging/datamodel/data/GalleryGridItemData.java128
-rw-r--r--src/com/android/messaging/datamodel/data/LaunchConversationData.java90
-rw-r--r--src/com/android/messaging/datamodel/data/MediaPickerData.java175
-rw-r--r--src/com/android/messaging/datamodel/data/MediaPickerMessagePartData.java64
-rw-r--r--src/com/android/messaging/datamodel/data/MessageData.java922
-rw-r--r--src/com/android/messaging/datamodel/data/MessagePartData.java534
-rw-r--r--src/com/android/messaging/datamodel/data/ParticipantData.java569
-rw-r--r--src/com/android/messaging/datamodel/data/ParticipantListItemData.java95
-rw-r--r--src/com/android/messaging/datamodel/data/PendingAttachmentData.java176
-rw-r--r--src/com/android/messaging/datamodel/data/PeopleAndOptionsData.java210
-rw-r--r--src/com/android/messaging/datamodel/data/PeopleOptionsItemData.java155
-rw-r--r--src/com/android/messaging/datamodel/data/PersonItemData.java67
-rw-r--r--src/com/android/messaging/datamodel/data/SelfParticipantsData.java107
-rw-r--r--src/com/android/messaging/datamodel/data/SettingsData.java223
-rw-r--r--src/com/android/messaging/datamodel/data/SubscriptionListData.java128
-rw-r--r--src/com/android/messaging/datamodel/data/VCardContactItemData.java185
-rw-r--r--src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java74
-rw-r--r--src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java159
-rw-r--r--src/com/android/messaging/datamodel/media/AvatarRequest.java189
-rw-r--r--src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java60
-rw-r--r--src/com/android/messaging/datamodel/media/BindableMediaRequest.java63
-rw-r--r--src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java54
-rw-r--r--src/com/android/messaging/datamodel/media/CompositeImageRequest.java109
-rw-r--r--src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java61
-rw-r--r--src/com/android/messaging/datamodel/media/CustomVCardEntry.java48
-rw-r--r--src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java133
-rw-r--r--src/com/android/messaging/datamodel/media/DecodedImageResource.java254
-rw-r--r--src/com/android/messaging/datamodel/media/EncodedImageResource.java162
-rw-r--r--src/com/android/messaging/datamodel/media/FileImageRequest.java108
-rw-r--r--src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java68
-rw-r--r--src/com/android/messaging/datamodel/media/GifImageResource.java110
-rw-r--r--src/com/android/messaging/datamodel/media/ImageRequest.java258
-rw-r--r--src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java113
-rw-r--r--src/com/android/messaging/datamodel/media/ImageResource.java63
-rw-r--r--src/com/android/messaging/datamodel/media/MediaBytes.java42
-rw-r--r--src/com/android/messaging/datamodel/media/MediaCache.java113
-rw-r--r--src/com/android/messaging/datamodel/media/MediaCacheManager.java69
-rw-r--r--src/com/android/messaging/datamodel/media/MediaRequest.java70
-rw-r--r--src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java38
-rw-r--r--src/com/android/messaging/datamodel/media/MediaResourceManager.java325
-rw-r--r--src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java64
-rw-r--r--src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java38
-rw-r--r--src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java120
-rw-r--r--src/com/android/messaging/datamodel/media/PoolableImageCache.java419
-rw-r--r--src/com/android/messaging/datamodel/media/RefCountedMediaResource.java164
-rw-r--r--src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java117
-rw-r--r--src/com/android/messaging/datamodel/media/UriImageRequest.java58
-rw-r--r--src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java95
-rw-r--r--src/com/android/messaging/datamodel/media/VCardRequest.java328
-rw-r--r--src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java35
-rw-r--r--src/com/android/messaging/datamodel/media/VCardResource.java52
-rw-r--r--src/com/android/messaging/datamodel/media/VCardResourceEntry.java389
-rw-r--r--src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java78
-rw-r--r--src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java44
-rw-r--r--src/com/android/messaging/mmslib/Downloads.java807
-rw-r--r--src/com/android/messaging/mmslib/InvalidHeaderValueException.java41
-rw-r--r--src/com/android/messaging/mmslib/MmsException.java60
-rw-r--r--src/com/android/messaging/mmslib/SqliteWrapper.java88
-rw-r--r--src/com/android/messaging/mmslib/pdu/AcknowledgeInd.java89
-rw-r--r--src/com/android/messaging/mmslib/pdu/Base64.java167
-rw-r--r--src/com/android/messaging/mmslib/pdu/CharacterSets.java452
-rw-r--r--src/com/android/messaging/mmslib/pdu/DeliveryInd.java138
-rw-r--r--src/com/android/messaging/mmslib/pdu/EncodedStringValue.java298
-rw-r--r--src/com/android/messaging/mmslib/pdu/GenericPdu.java113
-rw-r--r--src/com/android/messaging/mmslib/pdu/MultimediaMessagePdu.java159
-rw-r--r--src/com/android/messaging/mmslib/pdu/NotificationInd.java285
-rw-r--r--src/com/android/messaging/mmslib/pdu/NotifyRespInd.java114
-rw-r--r--src/com/android/messaging/mmslib/pdu/PduBody.java87
-rw-r--r--src/com/android/messaging/mmslib/pdu/PduComposer.java1260
-rw-r--r--src/com/android/messaging/mmslib/pdu/PduContentTypes.java110
-rw-r--r--src/com/android/messaging/mmslib/pdu/PduHeaders.java743
-rwxr-xr-xsrc/com/android/messaging/mmslib/pdu/PduParser.java2044
-rw-r--r--src/com/android/messaging/mmslib/pdu/PduPart.java389
-rw-r--r--src/com/android/messaging/mmslib/pdu/PduPersister.java1683
-rw-r--r--src/com/android/messaging/mmslib/pdu/QuotedPrintable.java68
-rw-r--r--src/com/android/messaging/mmslib/pdu/ReadOrigInd.java153
-rw-r--r--src/com/android/messaging/mmslib/pdu/ReadRecInd.java144
-rw-r--r--src/com/android/messaging/mmslib/pdu/RetrieveConf.java305
-rw-r--r--src/com/android/messaging/mmslib/pdu/SendConf.java117
-rw-r--r--src/com/android/messaging/mmslib/pdu/SendReq.java346
-rw-r--r--src/com/android/messaging/mmslib/util/AbstractCache.java112
-rw-r--r--src/com/android/messaging/mmslib/util/DownloadDrmHelper.java111
-rw-r--r--src/com/android/messaging/mmslib/util/DrmConvertSession.java174
-rw-r--r--src/com/android/messaging/mmslib/util/PduCache.java262
-rw-r--r--src/com/android/messaging/mmslib/util/PduCacheEntry.java44
-rw-r--r--src/com/android/messaging/receiver/AbortMmsWapPushReceiver.java43
-rw-r--r--src/com/android/messaging/receiver/AbortSmsReceiver.java41
-rw-r--r--src/com/android/messaging/receiver/BootAndPackageReplacedReceiver.java49
-rw-r--r--src/com/android/messaging/receiver/DefaultSmsSubscriptionChangeReceiver.java32
-rw-r--r--src/com/android/messaging/receiver/MmsWapPushDeliverReceiver.java43
-rw-r--r--src/com/android/messaging/receiver/MmsWapPushReceiver.java58
-rw-r--r--src/com/android/messaging/receiver/NotificationReceiver.java57
-rw-r--r--src/com/android/messaging/receiver/SendStatusReceiver.java96
-rw-r--r--src/com/android/messaging/receiver/SmsDeliverReceiver.java31
-rw-r--r--src/com/android/messaging/receiver/SmsReceiver.java375
-rw-r--r--src/com/android/messaging/receiver/StorageStatusReceiver.java38
-rw-r--r--src/com/android/messaging/sms/ApnDatabase.java374
-rw-r--r--src/com/android/messaging/sms/ApnsXmlProcessor.java329
-rw-r--r--src/com/android/messaging/sms/BugleApnSettingsLoader.java646
-rw-r--r--src/com/android/messaging/sms/BugleCarrierConfigValuesLoader.java201
-rw-r--r--src/com/android/messaging/sms/BugleUserAgentInfoLoader.java96
-rw-r--r--src/com/android/messaging/sms/DatabaseMessages.java1006
-rwxr-xr-xsrc/com/android/messaging/sms/MmsConfig.java309
-rw-r--r--src/com/android/messaging/sms/MmsFailureException.java102
-rw-r--r--src/com/android/messaging/sms/MmsSender.java312
-rw-r--r--src/com/android/messaging/sms/MmsSmsUtils.java204
-rw-r--r--src/com/android/messaging/sms/MmsUtils.java2747
-rw-r--r--src/com/android/messaging/sms/SmsException.java59
-rw-r--r--src/com/android/messaging/sms/SmsReleaseStorage.java166
-rw-r--r--src/com/android/messaging/sms/SmsSender.java315
-rw-r--r--src/com/android/messaging/sms/SmsStorageStatusManager.java102
-rw-r--r--src/com/android/messaging/sms/SystemProperties.java54
-rw-r--r--src/com/android/messaging/ui/AsyncImageView.java457
-rw-r--r--src/com/android/messaging/ui/AttachmentPreview.java331
-rw-r--r--src/com/android/messaging/ui/AttachmentPreviewFactory.java299
-rw-r--r--src/com/android/messaging/ui/AudioAttachmentPlayPauseButton.java59
-rw-r--r--src/com/android/messaging/ui/AudioAttachmentView.java329
-rw-r--r--src/com/android/messaging/ui/AudioPlaybackProgressBar.java121
-rw-r--r--src/com/android/messaging/ui/BaseBugleActivity.java51
-rw-r--r--src/com/android/messaging/ui/BaseBugleFragmentActivity.java43
-rw-r--r--src/com/android/messaging/ui/BasePagerViewHolder.java106
-rw-r--r--src/com/android/messaging/ui/BlockedParticipantListItemView.java64
-rw-r--r--src/com/android/messaging/ui/BlockedParticipantsActivity.java57
-rw-r--r--src/com/android/messaging/ui/BlockedParticipantsFragment.java102
-rw-r--r--src/com/android/messaging/ui/BugleActionBarActivity.java356
-rw-r--r--src/com/android/messaging/ui/BugleAnimationTags.java38
-rw-r--r--src/com/android/messaging/ui/ClassZeroActivity.java205
-rw-r--r--src/com/android/messaging/ui/CompositeAdapter.java288
-rw-r--r--src/com/android/messaging/ui/ContactIconView.java152
-rw-r--r--src/com/android/messaging/ui/ConversationDrawables.java177
-rw-r--r--src/com/android/messaging/ui/CursorRecyclerAdapter.java333
-rw-r--r--src/com/android/messaging/ui/CustomHeaderPagerListViewHolder.java136
-rw-r--r--src/com/android/messaging/ui/CustomHeaderPagerViewHolder.java26
-rw-r--r--src/com/android/messaging/ui/CustomHeaderViewPager.java91
-rw-r--r--src/com/android/messaging/ui/CustomHeaderViewPagerAdapter.java33
-rw-r--r--src/com/android/messaging/ui/FixedViewPagerAdapter.java132
-rw-r--r--src/com/android/messaging/ui/ImeDetectFrameLayout.java46
-rw-r--r--src/com/android/messaging/ui/LicenseActivity.java35
-rw-r--r--src/com/android/messaging/ui/LineWrapLayout.java232
-rw-r--r--src/com/android/messaging/ui/ListEmptyView.java72
-rw-r--r--src/com/android/messaging/ui/MaxHeightScrollView.java50
-rw-r--r--src/com/android/messaging/ui/MultiAttachmentLayout.java424
-rw-r--r--src/com/android/messaging/ui/OrientedBitmapDrawable.java104
-rw-r--r--src/com/android/messaging/ui/PagerViewHolder.java34
-rw-r--r--src/com/android/messaging/ui/PagingAwareViewPager.java95
-rw-r--r--src/com/android/messaging/ui/PermissionCheckActivity.java141
-rw-r--r--src/com/android/messaging/ui/PersistentInstanceState.java39
-rw-r--r--src/com/android/messaging/ui/PersonItemView.java242
-rw-r--r--src/com/android/messaging/ui/PlaceholderInsetDrawable.java72
-rw-r--r--src/com/android/messaging/ui/PlainTextEditText.java85
-rw-r--r--src/com/android/messaging/ui/PlaybackStateView.java43
-rw-r--r--src/com/android/messaging/ui/RemoteInputEntrypointActivity.java58
-rw-r--r--src/com/android/messaging/ui/SmsStorageLowWarningActivity.java36
-rw-r--r--src/com/android/messaging/ui/SmsStorageLowWarningFragment.java267
-rw-r--r--src/com/android/messaging/ui/SnackBar.java314
-rw-r--r--src/com/android/messaging/ui/SnackBarInteraction.java67
-rw-r--r--src/com/android/messaging/ui/SnackBarManager.java365
-rw-r--r--src/com/android/messaging/ui/TestActivity.java88
-rw-r--r--src/com/android/messaging/ui/UIIntents.java378
-rw-r--r--src/com/android/messaging/ui/UIIntentsImpl.java577
-rw-r--r--src/com/android/messaging/ui/VCardDetailActivity.java60
-rw-r--r--src/com/android/messaging/ui/VCardDetailAdapter.java120
-rw-r--r--src/com/android/messaging/ui/VCardDetailFragment.java197
-rw-r--r--src/com/android/messaging/ui/VideoThumbnailView.java343
-rw-r--r--src/com/android/messaging/ui/ViewPagerTabStrip.java102
-rw-r--r--src/com/android/messaging/ui/ViewPagerTabs.java236
-rw-r--r--src/com/android/messaging/ui/WidgetPickConversationActivity.java114
-rw-r--r--src/com/android/messaging/ui/animation/PopupTransitionAnimation.java302
-rw-r--r--src/com/android/messaging/ui/animation/RectEvaluatorCompat.java45
-rw-r--r--src/com/android/messaging/ui/animation/ViewGroupItemVerticalExplodeAnimation.java208
-rw-r--r--src/com/android/messaging/ui/appsettings/ApnEditorActivity.java463
-rw-r--r--src/com/android/messaging/ui/appsettings/ApnPreference.java151
-rw-r--r--src/com/android/messaging/ui/appsettings/ApnSettingsActivity.java406
-rw-r--r--src/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java262
-rw-r--r--src/com/android/messaging/ui/appsettings/GroupMmsSettingDialog.java92
-rw-r--r--src/com/android/messaging/ui/appsettings/PerSubscriptionSettingsActivity.java246
-rw-r--r--src/com/android/messaging/ui/appsettings/PhoneNumberPreference.java116
-rw-r--r--src/com/android/messaging/ui/appsettings/SettingsActivity.java178
-rw-r--r--src/com/android/messaging/ui/attachmentchooser/AttachmentChooserActivity.java56
-rw-r--r--src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragment.java183
-rw-r--r--src/com/android/messaging/ui/attachmentchooser/AttachmentGridItemView.java119
-rw-r--r--src/com/android/messaging/ui/attachmentchooser/AttachmentGridView.java172
-rw-r--r--src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java85
-rw-r--r--src/com/android/messaging/ui/contact/AllContactsListViewHolder.java62
-rw-r--r--src/com/android/messaging/ui/contact/ContactDropdownLayouter.java138
-rw-r--r--src/com/android/messaging/ui/contact/ContactListAdapter.java86
-rw-r--r--src/com/android/messaging/ui/contact/ContactListItemView.java177
-rw-r--r--src/com/android/messaging/ui/contact/ContactPickerFragment.java607
-rw-r--r--src/com/android/messaging/ui/contact/ContactRecipientAdapter.java286
-rw-r--r--src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java289
-rw-r--r--src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java96
-rw-r--r--src/com/android/messaging/ui/contact/ContactSectionIndexer.java169
-rw-r--r--src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java63
-rw-r--r--src/com/android/messaging/ui/conversation/ComposeMessageView.java962
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationActivity.java379
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationActivityUiState.java306
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationFastScroller.java489
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationFragment.java1662
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationInput.java103
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationInputManager.java550
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java117
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java132
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationMessageView.java1206
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationSimSelector.java128
-rw-r--r--src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java92
-rw-r--r--src/com/android/messaging/ui/conversation/LaunchConversationActivity.java134
-rw-r--r--src/com/android/messaging/ui/conversation/MessageBubbleBackground.java47
-rw-r--r--src/com/android/messaging/ui/conversation/MessageDetailsDialog.java381
-rw-r--r--src/com/android/messaging/ui/conversation/SimIconView.java51
-rw-r--r--src/com/android/messaging/ui/conversation/SimSelectorItemView.java90
-rw-r--r--src/com/android/messaging/ui/conversation/SimSelectorView.java169
-rw-r--r--src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java339
-rw-r--r--src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java96
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListActivity.java144
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java77
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListFragment.java446
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListItemView.java643
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java462
-rw-r--r--src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java81
-rw-r--r--src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java219
-rw-r--r--src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java177
-rw-r--r--src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java138
-rw-r--r--src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java163
-rw-r--r--src/com/android/messaging/ui/conversationsettings/CopyContactDetailDialog.java66
-rw-r--r--src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java66
-rw-r--r--src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java329
-rw-r--r--src/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java99
-rw-r--r--src/com/android/messaging/ui/debug/DebugMmsConfigActivity.java34
-rw-r--r--src/com/android/messaging/ui/debug/DebugMmsConfigFragment.java147
-rw-r--r--src/com/android/messaging/ui/debug/DebugMmsConfigItemView.java134
-rw-r--r--src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java171
-rw-r--r--src/com/android/messaging/ui/mediapicker/AudioLevelSource.java73
-rw-r--r--src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java130
-rw-r--r--src/com/android/messaging/ui/mediapicker/AudioRecordView.java351
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraManager.java1200
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java481
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java102
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraPreview.java152
-rw-r--r--src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java128
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java62
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java159
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryGridView.java315
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java230
-rw-r--r--src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java118
-rw-r--r--src/com/android/messaging/ui/mediapicker/ImagePersistTask.java172
-rw-r--r--src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java223
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaChooser.java216
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaPicker.java736
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java44
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java563
-rw-r--r--src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java127
-rw-r--r--src/com/android/messaging/ui/mediapicker/PausableChronometer.java75
-rw-r--r--src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java114
-rw-r--r--src/com/android/messaging/ui/mediapicker/SoundLevels.java212
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java24
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java589
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java95
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java202
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java825
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/README.txt3
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java178
-rw-r--r--src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java192
-rw-r--r--src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java38
-rw-r--r--src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java31
-rw-r--r--src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java179
-rw-r--r--src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java89
-rw-r--r--src/com/android/messaging/util/AccessibilityUtil.java170
-rw-r--r--src/com/android/messaging/util/Assert.java214
-rw-r--r--src/com/android/messaging/util/AvatarUriUtil.java320
-rw-r--r--src/com/android/messaging/util/BugleActivityUtil.java88
-rw-r--r--src/com/android/messaging/util/BugleApplicationPrefs.java45
-rw-r--r--src/com/android/messaging/util/BugleGservices.java72
-rw-r--r--src/com/android/messaging/util/BugleGservicesImpl.java68
-rw-r--r--src/com/android/messaging/util/BugleGservicesKeys.java298
-rw-r--r--src/com/android/messaging/util/BuglePrefs.java140
-rw-r--r--src/com/android/messaging/util/BuglePrefsImpl.java135
-rw-r--r--src/com/android/messaging/util/BuglePrefsKeys.java71
-rw-r--r--src/com/android/messaging/util/BugleSubscriptionPrefs.java95
-rw-r--r--src/com/android/messaging/util/BugleWidgetPrefs.java41
-rw-r--r--src/com/android/messaging/util/ChangeDefaultSmsAppHelper.java157
-rw-r--r--src/com/android/messaging/util/CircularArray.java111
-rw-r--r--src/com/android/messaging/util/ConnectivityUtil.java247
-rw-r--r--src/com/android/messaging/util/ContactRecipientEntryUtils.java115
-rw-r--r--src/com/android/messaging/util/ContactUtil.java525
-rw-r--r--src/com/android/messaging/util/ContentType.java185
-rw-r--r--src/com/android/messaging/util/ConversationIdSet.java69
-rw-r--r--src/com/android/messaging/util/CubicBezierInterpolator.java120
-rw-r--r--src/com/android/messaging/util/Dates.java280
-rw-r--r--src/com/android/messaging/util/DebugUtils.java425
-rw-r--r--src/com/android/messaging/util/EmailAddress.java272
-rw-r--r--src/com/android/messaging/util/FallbackStrategies.java91
-rw-r--r--src/com/android/messaging/util/FileUtil.java140
-rw-r--r--src/com/android/messaging/util/GifTranscoder.java94
-rw-r--r--src/com/android/messaging/util/ImageUtils.java908
-rw-r--r--src/com/android/messaging/util/ImeUtil.java88
-rw-r--r--src/com/android/messaging/util/LogSaver.java293
-rw-r--r--src/com/android/messaging/util/LogUtil.java274
-rw-r--r--src/com/android/messaging/util/LoggingTimer.java70
-rw-r--r--src/com/android/messaging/util/LongSparseSet.java59
-rw-r--r--src/com/android/messaging/util/MaterialPalette.java27
-rw-r--r--src/com/android/messaging/util/MediaMetadataRetrieverWrapper.java81
-rw-r--r--src/com/android/messaging/util/MediaUtil.java36
-rw-r--r--src/com/android/messaging/util/MediaUtilImpl.java66
-rw-r--r--src/com/android/messaging/util/NotificationPlayer.java363
-rw-r--r--src/com/android/messaging/util/OsUtil.java269
-rw-r--r--src/com/android/messaging/util/PendingIntentConstants.java40
-rw-r--r--src/com/android/messaging/util/PhoneUtils.java1011
-rw-r--r--src/com/android/messaging/util/RingtoneUtil.java53
-rw-r--r--src/com/android/messaging/util/SafeAsyncTask.java176
-rw-r--r--src/com/android/messaging/util/SwitchCompatUtils.java129
-rw-r--r--src/com/android/messaging/util/TextUtil.java73
-rw-r--r--src/com/android/messaging/util/ThreadUtil.java28
-rw-r--r--src/com/android/messaging/util/TintDrawableWrapper.java70
-rw-r--r--src/com/android/messaging/util/Trace.java115
-rw-r--r--src/com/android/messaging/util/Typefaces.java45
-rw-r--r--src/com/android/messaging/util/UiUtils.java438
-rw-r--r--src/com/android/messaging/util/UriUtil.java393
-rw-r--r--src/com/android/messaging/util/VersionUtil.java67
-rw-r--r--src/com/android/messaging/util/WakeLockHelper.java121
-rw-r--r--src/com/android/messaging/util/YouTubeUtil.java98
-rw-r--r--src/com/android/messaging/util/exif/ByteBufferInputStream.java48
-rw-r--r--src/com/android/messaging/util/exif/CountedDataInputStream.java140
-rw-r--r--src/com/android/messaging/util/exif/ExifData.java349
-rw-r--r--src/com/android/messaging/util/exif/ExifInterface.java2448
-rw-r--r--src/com/android/messaging/util/exif/ExifInvalidFormatException.java23
-rw-r--r--src/com/android/messaging/util/exif/ExifModifier.java196
-rw-r--r--src/com/android/messaging/util/exif/ExifOutputStream.java522
-rw-r--r--src/com/android/messaging/util/exif/ExifParser.java918
-rw-r--r--src/com/android/messaging/util/exif/ExifReader.java93
-rw-r--r--src/com/android/messaging/util/exif/ExifTag.java1008
-rw-r--r--src/com/android/messaging/util/exif/IfdData.java152
-rw-r--r--src/com/android/messaging/util/exif/IfdId.java31
-rw-r--r--src/com/android/messaging/util/exif/JpegHeader.java39
-rw-r--r--src/com/android/messaging/util/exif/OrderedDataOutputStream.java56
-rw-r--r--src/com/android/messaging/util/exif/Rational.java88
-rw-r--r--src/com/android/messaging/widget/BaseWidgetFactory.java232
-rw-r--r--src/com/android/messaging/widget/BaseWidgetProvider.java184
-rw-r--r--src/com/android/messaging/widget/BugleWidgetProvider.java114
-rw-r--r--src/com/android/messaging/widget/WidgetConversationListService.java281
-rw-r--r--src/com/android/messaging/widget/WidgetConversationProvider.java316
-rw-r--r--src/com/android/messaging/widget/WidgetConversationService.java521
425 files changed, 102274 insertions, 0 deletions
diff --git a/src/com/android/messaging/BugleApplication.java b/src/com/android/messaging/BugleApplication.java
new file mode 100644
index 0000000..a5aea9f
--- /dev/null
+++ b/src/com/android/messaging/BugleApplication.java
@@ -0,0 +1,262 @@
+/*
+ * 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.messaging;
+
+import android.app.Application;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.res.Configuration;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.v7.mms.CarrierConfigValuesLoader;
+import android.support.v7.mms.MmsManager;
+import android.telephony.CarrierConfigManager;
+
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.receiver.SmsReceiver;
+import com.android.messaging.sms.ApnDatabase;
+import com.android.messaging.sms.BugleApnSettingsLoader;
+import com.android.messaging.sms.BugleUserAgentInfoLoader;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.ui.ConversationDrawables;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.BuglePrefsKeys;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.Trace;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+import java.lang.Thread.UncaughtExceptionHandler;
+
+/**
+ * The application object
+ */
+public class BugleApplication extends Application implements UncaughtExceptionHandler {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private UncaughtExceptionHandler sSystemUncaughtExceptionHandler;
+ private static boolean sRunningTests = false;
+
+ @VisibleForTesting
+ protected static void setTestsRunning() {
+ sRunningTests = true;
+ }
+
+ /**
+ * @return true if we're running unit tests.
+ */
+ public static boolean isRunningTests() {
+ return sRunningTests;
+ }
+
+ @Override
+ public void onCreate() {
+ Trace.beginSection("app.onCreate");
+ super.onCreate();
+
+ // Note onCreate is called in both test and real application environments
+ if (!sRunningTests) {
+ // Only create the factory if not running tests
+ FactoryImpl.register(getApplicationContext(), this);
+ } else {
+ LogUtil.e(TAG, "BugleApplication.onCreate: FactoryImpl.register skipped for test run");
+ }
+
+ sSystemUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
+ Thread.setDefaultUncaughtExceptionHandler(this);
+ Trace.endSection();
+ }
+
+ @Override
+ public void onConfigurationChanged(final Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ // Update conversation drawables when changing writing systems
+ // (Right-To-Left / Left-To-Right)
+ ConversationDrawables.get().updateDrawables();
+ }
+
+ // Called by the "real" factory from FactoryImpl.register() (i.e. not run in tests)
+ public void initializeSync(final Factory factory) {
+ Trace.beginSection("app.initializeSync");
+ final Context context = factory.getApplicationContext();
+ final BugleGservices bugleGservices = factory.getBugleGservices();
+ final BuglePrefs buglePrefs = factory.getApplicationPrefs();
+ final DataModel dataModel = factory.getDataModel();
+ final CarrierConfigValuesLoader carrierConfigValuesLoader =
+ factory.getCarrierConfigValuesLoader();
+
+ maybeStartProfiling();
+
+ BugleApplication.updateAppConfig(context);
+
+ // Initialize MMS lib
+ initMmsLib(context, bugleGservices, carrierConfigValuesLoader);
+ // Initialize APN database
+ ApnDatabase.initializeAppContext(context);
+ // Fixup messages in flight if we crashed and send any pending
+ dataModel.onApplicationCreated();
+ // Register carrier config change receiver
+ if (OsUtil.isAtLeastM()) {
+ registerCarrierConfigChangeReceiver(context);
+ }
+
+ Trace.endSection();
+ }
+
+ private static void registerCarrierConfigChangeReceiver(final Context context) {
+ context.registerReceiver(new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ LogUtil.i(TAG, "Carrier config changed. Reloading MMS config.");
+ MmsConfig.loadAsync();
+ }
+ }, new IntentFilter(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED));
+ }
+
+ private static void initMmsLib(final Context context, final BugleGservices bugleGservices,
+ final CarrierConfigValuesLoader carrierConfigValuesLoader) {
+ MmsManager.setApnSettingsLoader(new BugleApnSettingsLoader(context));
+ MmsManager.setCarrierConfigValuesLoader(carrierConfigValuesLoader);
+ MmsManager.setUserAgentInfoLoader(new BugleUserAgentInfoLoader(context));
+ MmsManager.setUseWakeLock(true);
+ // If Gservices is configured not to use mms api, force MmsManager to always use
+ // legacy mms sending logic
+ MmsManager.setForceLegacyMms(!bugleGservices.getBoolean(
+ BugleGservicesKeys.USE_MMS_API_IF_PRESENT,
+ BugleGservicesKeys.USE_MMS_API_IF_PRESENT_DEFAULT));
+ bugleGservices.registerForChanges(new Runnable() {
+ @Override
+ public void run() {
+ MmsManager.setForceLegacyMms(!bugleGservices.getBoolean(
+ BugleGservicesKeys.USE_MMS_API_IF_PRESENT,
+ BugleGservicesKeys.USE_MMS_API_IF_PRESENT_DEFAULT));
+ }
+ });
+ }
+
+ public static void updateAppConfig(final Context context) {
+ // Make sure we set the correct state for the SMS/MMS receivers
+ SmsReceiver.updateSmsReceiveHandler(context);
+ }
+
+ // Called from thread started in FactoryImpl.register() (i.e. not run in tests)
+ public void initializeAsync(final Factory factory) {
+ // Handle shared prefs upgrade & Load MMS Configuration
+ Trace.beginSection("app.initializeAsync");
+ maybeHandleSharedPrefsUpgrade(factory);
+ MmsConfig.load();
+ Trace.endSection();
+ }
+
+ @Override
+ public void onLowMemory() {
+ super.onLowMemory();
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "BugleApplication.onLowMemory");
+ }
+ Factory.get().reclaimMemory();
+ }
+
+ @Override
+ public void uncaughtException(final Thread thread, final Throwable ex) {
+ final boolean background = getMainLooper().getThread() != thread;
+ if (background) {
+ LogUtil.e(TAG, "Uncaught exception in background thread " + thread, ex);
+
+ final Handler handler = new Handler(getMainLooper());
+ handler.post(new Runnable() {
+
+ @Override
+ public void run() {
+ sSystemUncaughtExceptionHandler.uncaughtException(thread, ex);
+ }
+ });
+ } else {
+ sSystemUncaughtExceptionHandler.uncaughtException(thread, ex);
+ }
+ }
+
+ private void maybeStartProfiling() {
+ // App startup profiling support. To use it:
+ // adb shell setprop log.tag.BugleProfile DEBUG
+ // # Start the app, wait for a 30s, download trace file:
+ // adb pull /data/data/com.android.messaging/cache/startup.trace /tmp
+ // # Open trace file (using adt/tools/traceview)
+ if (android.util.Log.isLoggable(LogUtil.PROFILE_TAG, android.util.Log.DEBUG)) {
+ // Start method tracing with a big enough buffer and let it run for 30s.
+ // Note we use a logging tag as we don't want to wait for gservices to start up.
+ final File file = DebugUtils.getDebugFile("startup.trace", true);
+ if (file != null) {
+ android.os.Debug.startMethodTracing(file.getAbsolutePath(), 160 * 1024 * 1024);
+ new Handler(Looper.getMainLooper()).postDelayed(
+ new Runnable() {
+ @Override
+ public void run() {
+ android.os.Debug.stopMethodTracing();
+ // Allow world to see trace file
+ DebugUtils.ensureReadable(file);
+ LogUtil.d(LogUtil.PROFILE_TAG, "Tracing complete - "
+ + file.getAbsolutePath());
+ }
+ }, 30000);
+ }
+ }
+ }
+
+ private void maybeHandleSharedPrefsUpgrade(final Factory factory) {
+ final int existingVersion = factory.getApplicationPrefs().getInt(
+ BuglePrefsKeys.SHARED_PREFERENCES_VERSION,
+ BuglePrefsKeys.SHARED_PREFERENCES_VERSION_DEFAULT);
+ final int targetVersion = Integer.parseInt(getString(R.string.pref_version));
+ if (targetVersion > existingVersion) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "Upgrading shared prefs from " + existingVersion +
+ " to " + targetVersion);
+ try {
+ // Perform upgrade on application-wide prefs.
+ factory.getApplicationPrefs().onUpgrade(existingVersion, targetVersion);
+ // Perform upgrade on each subscription's prefs.
+ PhoneUtils.forEachActiveSubscription(new PhoneUtils.SubscriptionRunnable() {
+ @Override
+ public void runForSubscription(final int subId) {
+ factory.getSubscriptionPrefs(subId)
+ .onUpgrade(existingVersion, targetVersion);
+ }
+ });
+ factory.getApplicationPrefs().putInt(BuglePrefsKeys.SHARED_PREFERENCES_VERSION,
+ targetVersion);
+ } catch (final Exception ex) {
+ // Upgrade failed. Don't crash the app because we can always fall back to the
+ // default settings.
+ LogUtil.e(LogUtil.BUGLE_TAG, "Failed to upgrade shared prefs", ex);
+ }
+ } else if (targetVersion < existingVersion) {
+ // We don't care about downgrade since real user shouldn't encounter this, so log it
+ // and ignore any prefs migration.
+ LogUtil.e(LogUtil.BUGLE_TAG, "Shared prefs downgrade requested and ignored. " +
+ "oldVersion = " + existingVersion + ", newVersion = " + targetVersion);
+ }
+ }
+}
diff --git a/src/com/android/messaging/Factory.java b/src/com/android/messaging/Factory.java
new file mode 100644
index 0000000..c542a54
--- /dev/null
+++ b/src/com/android/messaging/Factory.java
@@ -0,0 +1,75 @@
+/*
+ * 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.messaging;
+
+import android.content.Context;
+
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.MemoryCacheManager;
+import com.android.messaging.datamodel.ParticipantRefresh.ContactContentObserver;
+import com.android.messaging.datamodel.media.MediaCacheManager;
+import com.android.messaging.datamodel.media.MediaResourceManager;
+import com.android.messaging.sms.BugleCarrierConfigValuesLoader;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.MediaUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.google.common.annotations.VisibleForTesting;
+
+public abstract class Factory {
+
+ // Making this volatile because on the unit tests, setInstance is called from a unit test
+ // thread, and then it's read on the UI thread.
+ private static volatile Factory sInstance;
+ @VisibleForTesting
+ protected static boolean sRegistered;
+ @VisibleForTesting
+ protected static boolean sInitialized;
+
+ public static Factory get() {
+ return sInstance;
+ }
+
+ protected static void setInstance(final Factory factory) {
+ // Not allowed to call this after real application initialization is complete
+ Assert.isTrue(!sRegistered);
+ Assert.isTrue(!sInitialized);
+ sInstance = factory;
+ }
+ public abstract void onRequiredPermissionsAcquired();
+
+ public abstract Context getApplicationContext();
+ public abstract DataModel getDataModel();
+ public abstract BugleGservices getBugleGservices();
+ public abstract BuglePrefs getApplicationPrefs();
+ public abstract BuglePrefs getSubscriptionPrefs(int subId);
+ public abstract BuglePrefs getWidgetPrefs();
+ public abstract UIIntents getUIIntents();
+ public abstract MemoryCacheManager getMemoryCacheManager();
+ public abstract MediaResourceManager getMediaResourceManager();
+ public abstract MediaCacheManager getMediaCacheManager();
+ public abstract ContactContentObserver getContactContentObserver();
+ public abstract PhoneUtils getPhoneUtils(int subId);
+ public abstract MediaUtil getMediaUtil();
+ public abstract BugleCarrierConfigValuesLoader getCarrierConfigValuesLoader();
+ // Note this needs to run from any thread
+ public abstract void reclaimMemory();
+
+ public abstract void onActivityResume();
+}
diff --git a/src/com/android/messaging/FactoryImpl.java b/src/com/android/messaging/FactoryImpl.java
new file mode 100644
index 0000000..a862308
--- /dev/null
+++ b/src/com/android/messaging/FactoryImpl.java
@@ -0,0 +1,245 @@
+/*
+ * 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.messaging;
+
+import android.content.Context;
+import android.os.Process;
+import android.telephony.SmsManager;
+import android.util.SparseArray;
+
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DataModelImpl;
+import com.android.messaging.datamodel.MemoryCacheManager;
+import com.android.messaging.datamodel.ParticipantRefresh.ContactContentObserver;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.media.BugleMediaCacheManager;
+import com.android.messaging.datamodel.media.MediaCacheManager;
+import com.android.messaging.datamodel.media.MediaResourceManager;
+import com.android.messaging.sms.BugleCarrierConfigValuesLoader;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.UIIntentsImpl;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BugleApplicationPrefs;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesImpl;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.BugleSubscriptionPrefs;
+import com.android.messaging.util.BugleWidgetPrefs;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.MediaUtil;
+import com.android.messaging.util.MediaUtilImpl;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+class FactoryImpl extends Factory {
+ private BugleApplication mApplication;
+ private DataModel mDataModel;
+ private BugleGservices mBugleGservices;
+ private BugleApplicationPrefs mBugleApplicationPrefs;
+ private BugleWidgetPrefs mBugleWidgetPrefs;
+ private Context mApplicationContext;
+ private UIIntents mUIIntents;
+ private MemoryCacheManager mMemoryCacheManager;
+ private MediaResourceManager mMediaResourceManager;
+ private MediaCacheManager mMediaCacheManager;
+ private ContactContentObserver mContactContentObserver;
+ private PhoneUtils mPhoneUtils;
+ private MediaUtil mMediaUtil;
+ private SparseArray<BugleSubscriptionPrefs> mSubscriptionPrefs;
+ private BugleCarrierConfigValuesLoader mCarrierConfigValuesLoader;
+
+ // Cached instance for Pre-L_MR1
+ private static final Object PHONEUTILS_INSTANCE_LOCK = new Object();
+ private static PhoneUtils sPhoneUtilsInstancePreLMR1 = null;
+ // Cached subId->instance for L_MR1 and beyond
+ private static final ConcurrentHashMap<Integer, PhoneUtils> sPhoneUtilsInstanceCacheLMR1 =
+ new ConcurrentHashMap<>();
+
+ private FactoryImpl() {
+ }
+
+ public static Factory register(final Context applicationContext,
+ final BugleApplication application) {
+ // This only gets called once (from BugleApplication.onCreate), but its not called in tests.
+ Assert.isTrue(!sRegistered);
+ Assert.isNull(Factory.get());
+
+ final FactoryImpl factory = new FactoryImpl();
+ Factory.setInstance(factory);
+ sRegistered = true;
+
+ // At this point Factory is published. Services can now get initialized and depend on
+ // Factory.get().
+ factory.mApplication = application;
+ factory.mApplicationContext = applicationContext;
+ factory.mMemoryCacheManager = new MemoryCacheManager();
+ factory.mMediaCacheManager = new BugleMediaCacheManager();
+ factory.mMediaResourceManager = new MediaResourceManager();
+ factory.mBugleGservices = new BugleGservicesImpl(applicationContext);
+ factory.mBugleApplicationPrefs = new BugleApplicationPrefs(applicationContext);
+ factory.mDataModel = new DataModelImpl(applicationContext);
+ factory.mBugleWidgetPrefs = new BugleWidgetPrefs(applicationContext);
+ factory.mUIIntents = new UIIntentsImpl();
+ factory.mContactContentObserver = new ContactContentObserver();
+ factory.mMediaUtil = new MediaUtilImpl();
+ factory.mSubscriptionPrefs = new SparseArray<BugleSubscriptionPrefs>();
+ factory.mCarrierConfigValuesLoader = new BugleCarrierConfigValuesLoader(applicationContext);
+
+ Assert.initializeGservices(factory.mBugleGservices);
+ LogUtil.initializeGservices(factory.mBugleGservices);
+
+ if (OsUtil.hasRequiredPermissions()) {
+ factory.onRequiredPermissionsAcquired();
+ }
+
+ return factory;
+ }
+
+ @Override
+ public void onRequiredPermissionsAcquired() {
+ if (sInitialized) {
+ return;
+ }
+ sInitialized = true;
+
+ mApplication.initializeSync(this);
+
+ final Thread asyncInitialization = new Thread() {
+ @Override
+ public void run() {
+ Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
+ mApplication.initializeAsync(FactoryImpl.this);
+ }
+ };
+ asyncInitialization.start();
+ }
+
+ @Override
+ public Context getApplicationContext() {
+ return mApplicationContext;
+ }
+
+ @Override
+ public DataModel getDataModel() {
+ return mDataModel;
+ }
+
+ @Override
+ public BugleGservices getBugleGservices() {
+ return mBugleGservices;
+ }
+
+ @Override
+ public BuglePrefs getApplicationPrefs() {
+ return mBugleApplicationPrefs;
+ }
+
+ @Override
+ public BuglePrefs getWidgetPrefs() {
+ return mBugleWidgetPrefs;
+ }
+
+ @Override
+ public BuglePrefs getSubscriptionPrefs(int subId) {
+ subId = PhoneUtils.getDefault().getEffectiveSubId(subId);
+ BugleSubscriptionPrefs pref = mSubscriptionPrefs.get(subId);
+ if (pref == null) {
+ synchronized (this) {
+ if ((pref = mSubscriptionPrefs.get(subId)) == null) {
+ pref = new BugleSubscriptionPrefs(getApplicationContext(), subId);
+ mSubscriptionPrefs.put(subId, pref);
+ }
+ }
+ }
+ return pref;
+ }
+
+ @Override
+ public UIIntents getUIIntents() {
+ return mUIIntents;
+ }
+
+ @Override
+ public MemoryCacheManager getMemoryCacheManager() {
+ return mMemoryCacheManager;
+ }
+
+ @Override
+ public MediaResourceManager getMediaResourceManager() {
+ return mMediaResourceManager;
+ }
+
+ @Override
+ public MediaCacheManager getMediaCacheManager() {
+ return mMediaCacheManager;
+ }
+
+ @Override
+ public ContactContentObserver getContactContentObserver() {
+ return mContactContentObserver;
+ }
+
+ @Override
+ public PhoneUtils getPhoneUtils(int subId) {
+ if (OsUtil.isAtLeastL_MR1()) {
+ if (subId == ParticipantData.DEFAULT_SELF_SUB_ID) {
+ subId = SmsManager.getDefaultSmsSubscriptionId();
+ }
+ if (subId < 0) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "PhoneUtils.getForLMR1(): invalid subId = " + subId);
+ subId = ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+ PhoneUtils instance = sPhoneUtilsInstanceCacheLMR1.get(subId);
+ if (instance == null) {
+ instance = new PhoneUtils.PhoneUtilsLMR1(subId);
+ sPhoneUtilsInstanceCacheLMR1.putIfAbsent(subId, instance);
+ }
+ return instance;
+ } else {
+ Assert.isTrue(subId == ParticipantData.DEFAULT_SELF_SUB_ID);
+ if (sPhoneUtilsInstancePreLMR1 == null) {
+ synchronized (PHONEUTILS_INSTANCE_LOCK) {
+ if (sPhoneUtilsInstancePreLMR1 == null) {
+ sPhoneUtilsInstancePreLMR1 = new PhoneUtils.PhoneUtilsPreLMR1();
+ }
+ }
+ }
+ return sPhoneUtilsInstancePreLMR1;
+ }
+ }
+
+ @Override
+ public void reclaimMemory() {
+ mMemoryCacheManager.reclaimMemory();
+ }
+
+ @Override
+ public void onActivityResume() {
+ }
+
+ @Override
+ public MediaUtil getMediaUtil() {
+ return mMediaUtil;
+ }
+
+ @Override
+ public BugleCarrierConfigValuesLoader getCarrierConfigValuesLoader() {
+ return mCarrierConfigValuesLoader;
+ }
+}
diff --git a/src/com/android/messaging/annotation/VisibleForAnimation.java b/src/com/android/messaging/annotation/VisibleForAnimation.java
new file mode 100644
index 0000000..f97d827
--- /dev/null
+++ b/src/com/android/messaging/annotation/VisibleForAnimation.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.annotation;
+
+/**
+ * An annotation for class members that are made visible for Android's ObjectAnimator to work
+ * properly through reflection.
+ */
+public @interface VisibleForAnimation {
+}
diff --git a/src/com/android/messaging/datamodel/BitmapPool.java b/src/com/android/messaging/datamodel/BitmapPool.java
new file mode 100644
index 0000000..1ec4f76
--- /dev/null
+++ b/src/com/android/messaging/datamodel/BitmapPool.java
@@ -0,0 +1,364 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+import android.util.SparseArray;
+
+import com.android.messaging.datamodel.MemoryCacheManager.MemoryCache;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+
+import java.io.InputStream;
+
+/**
+ * Class for creating / loading / reusing bitmaps. This class allow the user to create a new bitmap,
+ * reuse an bitmap from the pool and to return a bitmap for future reuse. The pool of bitmaps
+ * allows for faster decode and more efficient memory usage.
+ * Note: consumers should not create BitmapPool directly, but instead get the pool they want from
+ * the BitmapPoolManager.
+ */
+public class BitmapPool implements MemoryCache {
+ public static final int MAX_SUPPORTED_IMAGE_DIMENSION = 0xFFFF;
+
+ protected static final boolean VERBOSE = false;
+
+ /**
+ * Number of reuse failures to skip before reporting.
+ */
+ private static final int FAILED_REPORTING_FREQUENCY = 100;
+
+ /**
+ * Count of reuse failures which have occurred.
+ */
+ private static volatile int sFailedBitmapReuseCount = 0;
+
+ /**
+ * Overall pool data structure which currently only supports rectangular bitmaps. The size of
+ * one of the sides is used to index into the SparseArray.
+ */
+ private final SparseArray<SingleSizePool> mPool;
+ private final Object mPoolLock = new Object();
+ private final String mPoolName;
+ private final int mMaxSize;
+
+ /**
+ * Inner structure which holds a pool of bitmaps all the same size (i.e. all have the same
+ * width as each other and height as each other, but not necessarily the same).
+ */
+ private class SingleSizePool {
+ int mNumItems;
+ final Bitmap[] mBitmaps;
+
+ SingleSizePool(final int maxPoolSize) {
+ mNumItems = 0;
+ mBitmaps = new Bitmap[maxPoolSize];
+ }
+ }
+
+ /**
+ * Creates a pool of reused bitmaps with helper decode methods which will attempt to use the
+ * reclaimed bitmaps. This will help speed up the creation of bitmaps by using already allocated
+ * bitmaps.
+ * @param maxSize The overall max size of the pool. When the pool exceeds this size, all calls
+ * to reclaimBitmap(Bitmap) will result in recycling the bitmap.
+ * @param name Name of the bitmap pool and only used for logging. Can not be null.
+ */
+ BitmapPool(final int maxSize, @NonNull final String name) {
+ Assert.isTrue(maxSize > 0);
+ Assert.isTrue(!TextUtils.isEmpty(name));
+ mPoolName = name;
+ mMaxSize = maxSize;
+ mPool = new SparseArray<SingleSizePool>();
+ }
+
+ @Override
+ public void reclaim() {
+ synchronized (mPoolLock) {
+ for (int p = 0; p < mPool.size(); p++) {
+ final SingleSizePool singleSizePool = mPool.valueAt(p);
+ for (int i = 0; i < singleSizePool.mNumItems; i++) {
+ singleSizePool.mBitmaps[i].recycle();
+ singleSizePool.mBitmaps[i] = null;
+ }
+ singleSizePool.mNumItems = 0;
+ }
+ mPool.clear();
+ }
+ }
+
+ /**
+ * Creates a new BitmapFactory.Options.
+ */
+ public static BitmapFactory.Options getBitmapOptionsForPool(final boolean scaled,
+ final int inputDensity, final int targetDensity) {
+ final BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inScaled = scaled;
+ options.inDensity = inputDensity;
+ options.inTargetDensity = targetDensity;
+ options.inSampleSize = 1;
+ options.inJustDecodeBounds = false;
+ options.inMutable = true;
+ return options;
+ }
+
+ /**
+ * @return The pool key for the provided image dimensions or 0 if either width or height is
+ * greater than the max supported image dimension.
+ */
+ private int getPoolKey(final int width, final int height) {
+ if (width > MAX_SUPPORTED_IMAGE_DIMENSION || height > MAX_SUPPORTED_IMAGE_DIMENSION) {
+ return 0;
+ }
+ return (width << 16) | height;
+ }
+
+ /**
+ *
+ * @return A bitmap in the pool with the specified dimensions or null if no bitmap with the
+ * specified dimension is available.
+ */
+ private Bitmap findPoolBitmap(final int width, final int height) {
+ final int poolKey = getPoolKey(width, height);
+ if (poolKey != 0) {
+ synchronized (mPoolLock) {
+ // Take a bitmap from the pool if one is available
+ final SingleSizePool singlePool = mPool.get(poolKey);
+ if (singlePool != null && singlePool.mNumItems > 0) {
+ singlePool.mNumItems--;
+ final Bitmap foundBitmap = singlePool.mBitmaps[singlePool.mNumItems];
+ singlePool.mBitmaps[singlePool.mNumItems] = null;
+ return foundBitmap;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Internal function to try and find a bitmap in the pool which matches the desired width and
+ * height and then set that in the bitmap options properly.
+ *
+ * TODO: Why do we take a width/height? Shouldn't this already be in the
+ * BitmapFactory.Options instance? Can we assert that they match?
+ * @param optionsTmp The BitmapFactory.Options to update with the bitmap for the system to try
+ * to reuse.
+ * @param width The width of the reusable bitmap.
+ * @param height The height of the reusable bitmap.
+ */
+ private void assignPoolBitmap(final BitmapFactory.Options optionsTmp, final int width,
+ final int height) {
+ if (optionsTmp.inJustDecodeBounds) {
+ return;
+ }
+ optionsTmp.inBitmap = findPoolBitmap(width, height);
+ }
+
+ /**
+ * Load a resource into a bitmap. Uses a bitmap from the pool if possible to reduce memory
+ * turnover.
+ * @param resourceId Resource id to load.
+ * @param resources Application resources. Cannot be null.
+ * @param optionsTmp Should be the same options returned from getBitmapOptionsForPool(). Cannot
+ * be null.
+ * @param width The width of the bitmap.
+ * @param height The height of the bitmap.
+ * @return The decoded Bitmap with the resource drawn in it.
+ */
+ public Bitmap decodeSampledBitmapFromResource(final int resourceId,
+ @NonNull final Resources resources, @NonNull final BitmapFactory.Options optionsTmp,
+ final int width, final int height) {
+ Assert.notNull(resources);
+ Assert.notNull(optionsTmp);
+ Assert.isTrue(width > 0);
+ Assert.isTrue(height > 0);
+ assignPoolBitmap(optionsTmp, width, height);
+ Bitmap b = null;
+ try {
+ b = BitmapFactory.decodeResource(resources, resourceId, optionsTmp);
+ } catch (final IllegalArgumentException e) {
+ // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap.
+ if (optionsTmp.inBitmap != null) {
+ optionsTmp.inBitmap = null;
+ b = BitmapFactory.decodeResource(resources, resourceId, optionsTmp);
+ sFailedBitmapReuseCount++;
+ if (sFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) {
+ LogUtil.w(LogUtil.BUGLE_TAG,
+ "Pooled bitmap consistently not being reused count = " +
+ sFailedBitmapReuseCount);
+ }
+ }
+ } catch (final OutOfMemoryError e) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Oom decoding resource " + resourceId);
+ reclaim();
+ }
+ return b;
+ }
+
+ /**
+ * Load an input stream into a bitmap. Uses a bitmap from the pool if possible to reduce memory
+ * turnover.
+ * @param inputStream InputStream load. Cannot be null.
+ * @param optionsTmp Should be the same options returned from getBitmapOptionsForPool(). Cannot
+ * be null.
+ * @param width The width of the bitmap.
+ * @param height The height of the bitmap.
+ * @return The decoded Bitmap with the resource drawn in it.
+ */
+ public Bitmap decodeSampledBitmapFromInputStream(@NonNull final InputStream inputStream,
+ @NonNull final BitmapFactory.Options optionsTmp,
+ final int width, final int height) {
+ Assert.notNull(inputStream);
+ Assert.isTrue(width > 0);
+ Assert.isTrue(height > 0);
+ assignPoolBitmap(optionsTmp, width, height);
+ Bitmap b = null;
+ try {
+ b = BitmapFactory.decodeStream(inputStream, null, optionsTmp);
+ } catch (final IllegalArgumentException e) {
+ // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap.
+ if (optionsTmp.inBitmap != null) {
+ optionsTmp.inBitmap = null;
+ b = BitmapFactory.decodeStream(inputStream, null, optionsTmp);
+ sFailedBitmapReuseCount++;
+ if (sFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) {
+ LogUtil.w(LogUtil.BUGLE_TAG,
+ "Pooled bitmap consistently not being reused count = " +
+ sFailedBitmapReuseCount);
+ }
+ }
+ } catch (final OutOfMemoryError e) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Oom decoding inputStream");
+ reclaim();
+ }
+ return b;
+ }
+
+ /**
+ * Turn encoded bytes into a bitmap. Uses a bitmap from the pool if possible to reduce memory
+ * turnover.
+ * @param bytes Encoded bytes to draw on the bitmap. Cannot be null.
+ * @param optionsTmp The bitmap will set here and the input should be generated from
+ * getBitmapOptionsForPool(). Cannot be null.
+ * @param width The width of the bitmap.
+ * @param height The height of the bitmap.
+ * @return A Bitmap with the encoded bytes drawn in it.
+ */
+ public Bitmap decodeByteArray(@NonNull final byte[] bytes,
+ @NonNull final BitmapFactory.Options optionsTmp, final int width,
+ final int height) throws OutOfMemoryError {
+ Assert.notNull(bytes);
+ Assert.notNull(optionsTmp);
+ Assert.isTrue(width > 0);
+ Assert.isTrue(height > 0);
+ assignPoolBitmap(optionsTmp, width, height);
+ Bitmap b = null;
+ try {
+ b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp);
+ } catch (final IllegalArgumentException e) {
+ if (VERBOSE) {
+ LogUtil.v(LogUtil.BUGLE_TAG, "BitmapPool(" + mPoolName +
+ ") Unable to use pool bitmap");
+ }
+ // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap.
+ // (i.e. without the bitmap from the pool)
+ if (optionsTmp.inBitmap != null) {
+ optionsTmp.inBitmap = null;
+ b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp);
+ sFailedBitmapReuseCount++;
+ if (sFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) {
+ LogUtil.w(LogUtil.BUGLE_TAG,
+ "Pooled bitmap consistently not being reused count = " +
+ sFailedBitmapReuseCount);
+ }
+ }
+ }
+ return b;
+ }
+
+ /**
+ * Creates a bitmap with the given size, this will reuse a bitmap in the pool, if one is
+ * available, otherwise this will create a new one.
+ * @param width The desired width of the bitmap.
+ * @param height The desired height of the bitmap.
+ * @return A bitmap with the desired width and height, this maybe a reused bitmap from the pool.
+ */
+ public Bitmap createOrReuseBitmap(final int width, final int height) {
+ Bitmap b = findPoolBitmap(width, height);
+ if (b == null) {
+ b = createBitmap(width, height);
+ }
+ return b;
+ }
+
+ /**
+ * This will create a new bitmap regardless of pool state.
+ * @param width The desired width of the bitmap.
+ * @param height The desired height of the bitmap.
+ * @return A bitmap with the desired width and height.
+ */
+ private Bitmap createBitmap(final int width, final int height) {
+ return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ }
+
+ /**
+ * Called when a bitmap is finished being used so that it can be used for another bitmap in the
+ * future or recycled. Any bitmaps returned should not be used by the caller again.
+ * @param b The bitmap to return to the pool for future usage or recycled. This cannot be null.
+ */
+ public void reclaimBitmap(@NonNull final Bitmap b) {
+ Assert.notNull(b);
+ final int poolKey = getPoolKey(b.getWidth(), b.getHeight());
+ if (poolKey == 0 || !b.isMutable()) {
+ // Unsupported image dimensions or a immutable bitmap.
+ b.recycle();
+ return;
+ }
+ synchronized (mPoolLock) {
+ SingleSizePool singleSizePool = mPool.get(poolKey);
+ if (singleSizePool == null) {
+ singleSizePool = new SingleSizePool(mMaxSize);
+ mPool.append(poolKey, singleSizePool);
+ }
+ if (singleSizePool.mNumItems < singleSizePool.mBitmaps.length) {
+ singleSizePool.mBitmaps[singleSizePool.mNumItems] = b;
+ singleSizePool.mNumItems++;
+ } else {
+ b.recycle();
+ }
+ }
+ }
+
+ /**
+ * @return whether the pool is full for a given width and height.
+ */
+ public boolean isFull(final int width, final int height) {
+ final int poolKey = getPoolKey(width, height);
+ synchronized (mPoolLock) {
+ final SingleSizePool singleSizePool = mPool.get(poolKey);
+ if (singleSizePool != null &&
+ singleSizePool.mNumItems >= singleSizePool.mBitmaps.length) {
+ return true;
+ }
+ return false;
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/BoundCursorLoader.java b/src/com/android/messaging/datamodel/BoundCursorLoader.java
new file mode 100644
index 0000000..84d38e6
--- /dev/null
+++ b/src/com/android/messaging/datamodel/BoundCursorLoader.java
@@ -0,0 +1,46 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.Context;
+import android.content.CursorLoader;
+import android.net.Uri;
+
+/**
+ * Extension to basic cursor loader that has an attached binding id
+ */
+public class BoundCursorLoader extends CursorLoader {
+ private final String mBindingId;
+
+ /**
+ * Create cursor loader for associated binding id
+ */
+ public BoundCursorLoader(final String bindingId, final Context context, final Uri uri,
+ final String[] projection, final String selection, final String[] selectionArgs,
+ final String sortOrder) {
+ super(context, uri, projection, selection, selectionArgs, sortOrder);
+ mBindingId = bindingId;
+ }
+
+ /**
+ * Binding id associated with this loader - consume can check to verify data still valid
+ * @return
+ */
+ public String getBindingId() {
+ return mBindingId;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/BugleDatabaseOperations.java b/src/com/android/messaging/datamodel/BugleDatabaseOperations.java
new file mode 100644
index 0000000..8c40177
--- /dev/null
+++ b/src/com/android/messaging/datamodel/BugleDatabaseOperations.java
@@ -0,0 +1,1919 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDoneException;
+import android.database.sqlite.SQLiteStatement;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.support.v4.util.ArrayMap;
+import android.support.v4.util.SimpleArrayMap;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
+import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseHelper.PartColumns;
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
+import com.android.messaging.datamodel.ParticipantRefresh.ConversationParticipantsQuery;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.UriUtil;
+import com.android.messaging.widget.WidgetConversationProvider;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import javax.annotation.Nullable;
+
+
+/**
+ * This class manages updating our local database
+ */
+public class BugleDatabaseOperations {
+
+ private static final String TAG = LogUtil.BUGLE_DATABASE_TAG;
+
+ // Global cache of phone numbers -> participant id mapping since this call is expensive.
+ private static final ArrayMap<String, String> sNormalizedPhoneNumberToParticipantIdCache =
+ new ArrayMap<String, String>();
+
+ /**
+ * Convert list of recipient strings (email/phone number) into list of ConversationParticipants
+ *
+ * @param recipients The recipient list
+ * @param refSubId The subId used to normalize phone numbers in the recipients
+ */
+ static ArrayList<ParticipantData> getConversationParticipantsFromRecipients(
+ final List<String> recipients, final int refSubId) {
+ // Generate a list of partially formed participants
+ final ArrayList<ParticipantData> participants = new
+ ArrayList<ParticipantData>();
+
+ if (recipients != null) {
+ for (final String recipient : recipients) {
+ participants.add(ParticipantData.getFromRawPhoneBySimLocale(recipient, refSubId));
+ }
+ }
+ return participants;
+ }
+
+ /**
+ * Sanitize a given list of conversation participants by de-duping and stripping out self
+ * phone number in group conversation.
+ */
+ @DoesNotRunOnMainThread
+ public static void sanitizeConversationParticipants(final List<ParticipantData> participants) {
+ Assert.isNotMainThread();
+ if (participants.size() > 0) {
+ // First remove redundant phone numbers
+ final HashSet<String> recipients = new HashSet<String>();
+ for (int i = participants.size() - 1; i >= 0; i--) {
+ final String recipient = participants.get(i).getNormalizedDestination();
+ if (!recipients.contains(recipient)) {
+ recipients.add(recipient);
+ } else {
+ participants.remove(i);
+ }
+ }
+ if (participants.size() > 1) {
+ // Remove self phone number from group conversation.
+ final HashSet<String> selfNumbers =
+ PhoneUtils.getDefault().getNormalizedSelfNumbers();
+ int removed = 0;
+ // Do this two-pass scan to avoid unnecessary memory allocation.
+ // Prescan to count the self numbers in the list
+ for (final ParticipantData p : participants) {
+ if (selfNumbers.contains(p.getNormalizedDestination())) {
+ removed++;
+ }
+ }
+ // If all are self numbers, maybe that's what the user wants, just leave
+ // the participants as is. Otherwise, do another scan to remove self numbers.
+ if (removed < participants.size()) {
+ for (int i = participants.size() - 1; i >= 0; i--) {
+ final String recipient = participants.get(i).getNormalizedDestination();
+ if (selfNumbers.contains(recipient)) {
+ participants.remove(i);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Convert list of ConversationParticipants into recipient strings (email/phone number)
+ */
+ @DoesNotRunOnMainThread
+ public static ArrayList<String> getRecipientsFromConversationParticipants(
+ final List<ParticipantData> participants) {
+ Assert.isNotMainThread();
+ // First find the thread id for this list of participants.
+ final ArrayList<String> recipients = new ArrayList<String>();
+
+ for (final ParticipantData participant : participants) {
+ recipients.add(participant.getSendDestination());
+ }
+ return recipients;
+ }
+
+ /**
+ * Get or create a conversation based on the message's thread id
+ *
+ * NOTE: There are phones on which you can't get the recipients from the thread id for SMS
+ * until you have a message, so use getOrCreateConversationFromRecipient instead.
+ *
+ * TODO: Should this be in MMS/SMS code?
+ *
+ * @param db the database
+ * @param threadId The message's thread
+ * @param senderBlocked Flag whether sender of message is in blocked people list
+ * @param refSubId The reference subId for canonicalize phone numbers
+ * @return conversationId
+ */
+ @DoesNotRunOnMainThread
+ public static String getOrCreateConversationFromThreadId(final DatabaseWrapper db,
+ final long threadId, final boolean senderBlocked, final int refSubId) {
+ Assert.isNotMainThread();
+ final List<String> recipients = MmsUtils.getRecipientsByThread(threadId);
+ final ArrayList<ParticipantData> participants =
+ getConversationParticipantsFromRecipients(recipients, refSubId);
+
+ return getOrCreateConversation(db, threadId, senderBlocked, participants, false, false,
+ null);
+ }
+
+ /**
+ * Get or create a conversation based on provided recipient
+ *
+ * @param db the database
+ * @param threadId The message's thread
+ * @param senderBlocked Flag whether sender of message is in blocked people list
+ * @param recipient recipient for thread
+ * @return conversationId
+ */
+ @DoesNotRunOnMainThread
+ public static String getOrCreateConversationFromRecipient(final DatabaseWrapper db,
+ final long threadId, final boolean senderBlocked, final ParticipantData recipient) {
+ Assert.isNotMainThread();
+ final ArrayList<ParticipantData> recipients = new ArrayList<>(1);
+ recipients.add(recipient);
+ return getOrCreateConversation(db, threadId, senderBlocked, recipients, false, false, null);
+ }
+
+ /**
+ * Get or create a conversation based on provided participants
+ *
+ * @param db the database
+ * @param threadId The message's thread
+ * @param archived Flag whether the conversation should be created archived
+ * @param participants list of conversation participants
+ * @param noNotification If notification should be disabled
+ * @param noVibrate If vibrate on notification should be disabled
+ * @param soundUri If there is custom sound URI
+ * @return a conversation id
+ */
+ @DoesNotRunOnMainThread
+ public static String getOrCreateConversation(final DatabaseWrapper db, final long threadId,
+ final boolean archived, final ArrayList<ParticipantData> participants,
+ boolean noNotification, boolean noVibrate, String soundUri) {
+ Assert.isNotMainThread();
+
+ // Check to see if this conversation is already in out local db cache
+ String conversationId = BugleDatabaseOperations.getExistingConversation(db, threadId,
+ false);
+
+ if (conversationId == null) {
+ final String conversationName = ConversationListItemData.generateConversationName(
+ participants);
+
+ // Create the conversation with the default self participant which always maps to
+ // the system default subscription.
+ final ParticipantData self = ParticipantData.getSelfParticipant(
+ ParticipantData.DEFAULT_SELF_SUB_ID);
+
+ db.beginTransaction();
+ try {
+ // Look up the "self" participantId (creating if necessary)
+ final String selfId =
+ BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
+ // Create a new conversation
+ conversationId = BugleDatabaseOperations.createConversationInTransaction(
+ db, threadId, conversationName, selfId, participants, archived,
+ noNotification, noVibrate, soundUri);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ return conversationId;
+ }
+
+ /**
+ * Get a conversation from the local DB based on the message's thread id.
+ *
+ * @param dbWrapper The database
+ * @param threadId The message's thread in the SMS database
+ * @param senderBlocked Flag whether sender of message is in blocked people list
+ * @return The existing conversation id or null
+ */
+ @VisibleForTesting
+ @DoesNotRunOnMainThread
+ public static String getExistingConversation(final DatabaseWrapper dbWrapper,
+ final long threadId, final boolean senderBlocked) {
+ Assert.isNotMainThread();
+ String conversationId = null;
+
+ Cursor cursor = null;
+ try {
+ // Look for an existing conversation in the db with this thread id
+ cursor = dbWrapper.rawQuery("SELECT " + ConversationColumns._ID
+ + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE
+ + " WHERE " + ConversationColumns.SMS_THREAD_ID + "=" + threadId,
+ null);
+
+ if (cursor.moveToFirst()) {
+ Assert.isTrue(cursor.getCount() == 1);
+ conversationId = cursor.getString(0);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ return conversationId;
+ }
+
+ /**
+ * Get the thread id for an existing conversation from the local DB.
+ *
+ * @param dbWrapper The database
+ * @param conversationId The conversation to look up thread for
+ * @return The thread id. Returns -1 if the conversation was not found or if it was found
+ * but the thread column was NULL.
+ */
+ @DoesNotRunOnMainThread
+ public static long getThreadId(final DatabaseWrapper dbWrapper, final String conversationId) {
+ Assert.isNotMainThread();
+ long threadId = -1;
+
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE,
+ new String[] { ConversationColumns.SMS_THREAD_ID },
+ ConversationColumns._ID + " =?",
+ new String[] { conversationId },
+ null, null, null);
+
+ if (cursor.moveToFirst()) {
+ Assert.isTrue(cursor.getCount() == 1);
+ if (!cursor.isNull(0)) {
+ threadId = cursor.getLong(0);
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ return threadId;
+ }
+
+ @DoesNotRunOnMainThread
+ public static boolean isBlockedDestination(final DatabaseWrapper db, final String destination) {
+ Assert.isNotMainThread();
+ return isBlockedParticipant(db, destination, ParticipantColumns.NORMALIZED_DESTINATION);
+ }
+
+ static boolean isBlockedParticipant(final DatabaseWrapper db, final String participantId) {
+ return isBlockedParticipant(db, participantId, ParticipantColumns._ID);
+ }
+
+ static boolean isBlockedParticipant(final DatabaseWrapper db, final String value,
+ final String column) {
+ Cursor cursor = null;
+ try {
+ cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE,
+ new String[] { ParticipantColumns.BLOCKED },
+ column + "=? AND " + ParticipantColumns.SUB_ID + "=?",
+ new String[] { value,
+ Integer.toString(ParticipantData.OTHER_THAN_SELF_SUB_ID) },
+ null, null, null);
+
+ Assert.inRange(cursor.getCount(), 0, 1);
+ if (cursor.moveToFirst()) {
+ return cursor.getInt(0) == 1;
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return false; // if there's no row, it's not blocked :-)
+ }
+
+ /**
+ * Create a conversation in the local DB based on the message's thread id.
+ *
+ * It's up to the caller to make sure that this is all inside a transaction. It will return
+ * null if it's not in the local DB.
+ *
+ * @param dbWrapper The database
+ * @param threadId The message's thread
+ * @param selfId The selfId to make default for this conversation
+ * @param archived Flag whether the conversation should be created archived
+ * @param noNotification If notification should be disabled
+ * @param noVibrate If vibrate on notification should be disabled
+ * @param soundUri The customized sound
+ * @return The existing conversation id or new conversation id
+ */
+ static String createConversationInTransaction(final DatabaseWrapper dbWrapper,
+ final long threadId, final String conversationName, final String selfId,
+ final List<ParticipantData> participants, final boolean archived,
+ boolean noNotification, boolean noVibrate, String soundUri) {
+ // We want conversation and participant creation to be atomic
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+ boolean hasEmailAddress = false;
+ for (final ParticipantData participant : participants) {
+ Assert.isTrue(!participant.isSelf());
+ if (participant.isEmail()) {
+ hasEmailAddress = true;
+ }
+ }
+
+ // TODO : Conversations state - normal vs. archived
+
+ // Insert a new local conversation for this thread id
+ final ContentValues values = new ContentValues();
+ values.put(ConversationColumns.SMS_THREAD_ID, threadId);
+ // Start with conversation hidden - sending a message or saving a draft will change that
+ values.put(ConversationColumns.SORT_TIMESTAMP, 0L);
+ values.put(ConversationColumns.CURRENT_SELF_ID, selfId);
+ values.put(ConversationColumns.PARTICIPANT_COUNT, participants.size());
+ values.put(ConversationColumns.INCLUDE_EMAIL_ADDRESS, (hasEmailAddress ? 1 : 0));
+ if (archived) {
+ values.put(ConversationColumns.ARCHIVE_STATUS, 1);
+ }
+ if (noNotification) {
+ values.put(ConversationColumns.NOTIFICATION_ENABLED, 0);
+ }
+ if (noVibrate) {
+ values.put(ConversationColumns.NOTIFICATION_VIBRATION, 0);
+ }
+ if (!TextUtils.isEmpty(soundUri)) {
+ values.put(ConversationColumns.NOTIFICATION_SOUND_URI, soundUri);
+ }
+
+ fillParticipantData(values, participants);
+
+ final long conversationRowId = dbWrapper.insert(DatabaseHelper.CONVERSATIONS_TABLE, null,
+ values);
+
+ Assert.isTrue(conversationRowId != -1);
+ if (conversationRowId == -1) {
+ LogUtil.e(TAG, "BugleDatabaseOperations : failed to insert conversation into table");
+ return null;
+ }
+
+ final String conversationId = Long.toString(conversationRowId);
+
+ // Make sure that participants are added for this conversation
+ for (final ParticipantData participant : participants) {
+ // TODO: Use blocking information
+ addParticipantToConversation(dbWrapper, participant, conversationId);
+ }
+
+ // Now fully resolved participants available can update conversation name / avatar.
+ // b/16437575: We cannot use the participants directly, but instead have to call
+ // getParticipantsForConversation() to retrieve the actual participants. This is needed
+ // because the call to addParticipantToConversation() won't fill up the ParticipantData
+ // if the participant already exists in the participant table. For example, say you have
+ // an existing conversation with John. Now if you create a new group conversation with
+ // Jeff & John with only their phone numbers, then when we try to add John's number to the
+ // group conversation, we see that he's already in the participant table, therefore we
+ // short-circuit any steps to actually fill out the ParticipantData for John other than
+ // just returning his participant id. Eventually, the ParticipantData we have is still the
+ // raw data with just the phone number. getParticipantsForConversation(), on the other
+ // hand, will fill out all the info for each participant from the participants table.
+ updateConversationNameAndAvatarInTransaction(dbWrapper, conversationId,
+ getParticipantsForConversation(dbWrapper, conversationId));
+
+ return conversationId;
+ }
+
+ private static void fillParticipantData(final ContentValues values,
+ final List<ParticipantData> participants) {
+ if (participants != null && !participants.isEmpty()) {
+ final Uri avatarUri = AvatarUriUtil.createAvatarUri(participants);
+ values.put(ConversationColumns.ICON, avatarUri.toString());
+
+ long contactId;
+ String lookupKey;
+ String destination;
+ if (participants.size() == 1) {
+ final ParticipantData firstParticipant = participants.get(0);
+ contactId = firstParticipant.getContactId();
+ lookupKey = firstParticipant.getLookupKey();
+ destination = firstParticipant.getNormalizedDestination();
+ } else {
+ contactId = 0;
+ lookupKey = null;
+ destination = null;
+ }
+
+ values.put(ConversationColumns.PARTICIPANT_CONTACT_ID, contactId);
+ values.put(ConversationColumns.PARTICIPANT_LOOKUP_KEY, lookupKey);
+ values.put(ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION, destination);
+ }
+ }
+
+ /**
+ * Delete conversation and associated messages/parts
+ */
+ @DoesNotRunOnMainThread
+ public static boolean deleteConversation(final DatabaseWrapper dbWrapper,
+ final String conversationId, final long cutoffTimestamp) {
+ Assert.isNotMainThread();
+ dbWrapper.beginTransaction();
+ boolean conversationDeleted = false;
+ boolean conversationMessagesDeleted = false;
+ try {
+ // Delete existing messages
+ if (cutoffTimestamp == Long.MAX_VALUE) {
+ // Delete parts and messages
+ dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE,
+ MessageColumns.CONVERSATION_ID + "=?", new String[] { conversationId });
+ conversationMessagesDeleted = true;
+ } else {
+ // Delete all messages prior to the cutoff
+ dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE,
+ MessageColumns.CONVERSATION_ID + "=? AND "
+ + MessageColumns.RECEIVED_TIMESTAMP + "<=?",
+ new String[] { conversationId, Long.toString(cutoffTimestamp) });
+
+ // Delete any draft message. The delete above may not always include the draft,
+ // because under certain scenarios (e.g. sending messages in progress), the draft
+ // timestamp can be larger than the cutoff time, which is generally the conversation
+ // sort timestamp. Because of how the sms/mms provider works on some newer
+ // devices, it's important that we never delete all the messages in a conversation
+ // without also deleting the conversation itself (see b/20262204 for details).
+ dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE,
+ MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?",
+ new String[] {
+ Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT),
+ conversationId
+ });
+
+ // Check to see if there are any messages left in the conversation
+ final long count = dbWrapper.queryNumEntries(DatabaseHelper.MESSAGES_TABLE,
+ MessageColumns.CONVERSATION_ID + "=?", new String[] { conversationId });
+ conversationMessagesDeleted = (count == 0);
+
+ // Log detail information if there are still messages left in the conversation
+ if (!conversationMessagesDeleted) {
+ final long maxTimestamp =
+ getConversationMaxTimestamp(dbWrapper, conversationId);
+ LogUtil.w(TAG, "BugleDatabaseOperations:"
+ + " cannot delete all messages in a conversation"
+ + ", after deletion: count=" + count
+ + ", max timestamp=" + maxTimestamp
+ + ", cutoff timestamp=" + cutoffTimestamp);
+ }
+ }
+
+ if (conversationMessagesDeleted) {
+ // Delete conversation row
+ final int count = dbWrapper.delete(DatabaseHelper.CONVERSATIONS_TABLE,
+ ConversationColumns._ID + "=?", new String[] { conversationId });
+ conversationDeleted = (count > 0);
+ }
+ dbWrapper.setTransactionSuccessful();
+ } finally {
+ dbWrapper.endTransaction();
+ }
+ return conversationDeleted;
+ }
+
+ private static final String MAX_RECEIVED_TIMESTAMP =
+ "MAX(" + MessageColumns.RECEIVED_TIMESTAMP + ")";
+ /**
+ * Get the max received timestamp of a conversation's messages
+ */
+ private static long getConversationMaxTimestamp(final DatabaseWrapper dbWrapper,
+ final String conversationId) {
+ final Cursor cursor = dbWrapper.query(
+ DatabaseHelper.MESSAGES_TABLE,
+ new String[]{ MAX_RECEIVED_TIMESTAMP },
+ MessageColumns.CONVERSATION_ID + "=?",
+ new String[]{ conversationId },
+ null, null, null);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ return cursor.getLong(0);
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ return 0;
+ }
+
+ @DoesNotRunOnMainThread
+ public static void updateConversationMetadataInTransaction(final DatabaseWrapper dbWrapper,
+ final String conversationId, final String messageId, final long latestTimestamp,
+ final boolean keepArchived, final String smsServiceCenter,
+ final boolean shouldAutoSwitchSelfId) {
+ Assert.isNotMainThread();
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+
+ final ContentValues values = new ContentValues();
+ values.put(ConversationColumns.LATEST_MESSAGE_ID, messageId);
+ values.put(ConversationColumns.SORT_TIMESTAMP, latestTimestamp);
+ if (!TextUtils.isEmpty(smsServiceCenter)) {
+ values.put(ConversationColumns.SMS_SERVICE_CENTER, smsServiceCenter);
+ }
+
+ // When the conversation gets updated with new messages, unarchive the conversation unless
+ // the sender is blocked, or we have been told to keep it archived.
+ if (!keepArchived) {
+ values.put(ConversationColumns.ARCHIVE_STATUS, 0);
+ }
+
+ final MessageData message = readMessage(dbWrapper, messageId);
+ addSnippetTextAndPreviewToContentValues(message, false /* showDraft */, values);
+
+ if (shouldAutoSwitchSelfId) {
+ addSelfIdAutoSwitchInfoToContentValues(dbWrapper, message, conversationId, values);
+ }
+
+ // Conversation always exists as this method is called from ActionService only after
+ // reading and if necessary creating the conversation.
+ updateConversationRow(dbWrapper, conversationId, values);
+
+ if (shouldAutoSwitchSelfId && OsUtil.isAtLeastL_MR1()) {
+ // Normally, the draft message compose UI trusts its UI state for providing up-to-date
+ // conversation self id. Therefore, notify UI through local broadcast receiver about
+ // this external change so the change can be properly reflected.
+ UIIntents.get().broadcastConversationSelfIdChange(dbWrapper.getContext(),
+ conversationId, getConversationSelfId(dbWrapper, conversationId));
+ }
+ }
+
+ @DoesNotRunOnMainThread
+ public static void updateConversationMetadataInTransaction(final DatabaseWrapper db,
+ final String conversationId, final String messageId, final long latestTimestamp,
+ final boolean keepArchived, final boolean shouldAutoSwitchSelfId) {
+ Assert.isNotMainThread();
+ updateConversationMetadataInTransaction(
+ db, conversationId, messageId, latestTimestamp, keepArchived, null,
+ shouldAutoSwitchSelfId);
+ }
+
+ @DoesNotRunOnMainThread
+ public static void updateConversationArchiveStatusInTransaction(final DatabaseWrapper dbWrapper,
+ final String conversationId, final boolean isArchived) {
+ Assert.isNotMainThread();
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+ final ContentValues values = new ContentValues();
+ values.put(ConversationColumns.ARCHIVE_STATUS, isArchived ? 1 : 0);
+ updateConversationRowIfExists(dbWrapper, conversationId, values);
+ }
+
+ static void addSnippetTextAndPreviewToContentValues(final MessageData message,
+ final boolean showDraft, final ContentValues values) {
+ values.put(ConversationColumns.SHOW_DRAFT, showDraft ? 1 : 0);
+ values.put(ConversationColumns.SNIPPET_TEXT, message.getMessageText());
+ values.put(ConversationColumns.SUBJECT_TEXT, message.getMmsSubject());
+
+ String type = null;
+ String uriString = null;
+ for (final MessagePartData part : message.getParts()) {
+ if (part.isAttachment() &&
+ ContentType.isConversationListPreviewableType(part.getContentType())) {
+ uriString = part.getContentUri().toString();
+ type = part.getContentType();
+ break;
+ }
+ }
+ values.put(ConversationColumns.PREVIEW_CONTENT_TYPE, type);
+ values.put(ConversationColumns.PREVIEW_URI, uriString);
+ }
+
+ /**
+ * Adds self-id auto switch info for a conversation if the last message has a different
+ * subscription than the conversation's.
+ * @return true if self id will need to be changed, false otherwise.
+ */
+ static boolean addSelfIdAutoSwitchInfoToContentValues(final DatabaseWrapper dbWrapper,
+ final MessageData message, final String conversationId, final ContentValues values) {
+ // Only auto switch conversation self for incoming messages.
+ if (!OsUtil.isAtLeastL_MR1() || !message.getIsIncoming()) {
+ return false;
+ }
+
+ final String conversationSelfId = getConversationSelfId(dbWrapper, conversationId);
+ final String messageSelfId = message.getSelfId();
+
+ if (conversationSelfId == null || messageSelfId == null) {
+ return false;
+ }
+
+ // Get the sub IDs in effect for both the message and the conversation and compare them:
+ // 1. If message is unbound (using default sub id), then the message was sent with
+ // pre-MSIM support. Don't auto-switch because we don't know the subscription for the
+ // message.
+ // 2. If message is bound,
+ // i. If conversation is unbound, use the system default sub id as its effective sub.
+ // ii. If conversation is bound, use its subscription directly.
+ // Compare the message sub id with the conversation's effective sub id. If they are
+ // different, auto-switch the conversation to the message's sub.
+ final ParticipantData conversationSelf = getExistingParticipant(dbWrapper,
+ conversationSelfId);
+ final ParticipantData messageSelf = getExistingParticipant(dbWrapper, messageSelfId);
+ if (!messageSelf.isActiveSubscription()) {
+ // Don't switch if the message subscription is no longer active.
+ return false;
+ }
+ final int messageSubId = messageSelf.getSubId();
+ if (messageSubId == ParticipantData.DEFAULT_SELF_SUB_ID) {
+ return false;
+ }
+
+ final int conversationEffectiveSubId =
+ PhoneUtils.getDefault().getEffectiveSubId(conversationSelf.getSubId());
+
+ if (conversationEffectiveSubId != messageSubId) {
+ return addConversationSelfIdToContentValues(dbWrapper, messageSelf.getId(), values);
+ }
+ return false;
+ }
+
+ /**
+ * Adds conversation self id updates to ContentValues given. This performs check on the selfId
+ * to ensure it's valid and active.
+ * @return true if self id will need to be changed, false otherwise.
+ */
+ static boolean addConversationSelfIdToContentValues(final DatabaseWrapper dbWrapper,
+ final String selfId, final ContentValues values) {
+ // Make sure the selfId passed in is valid and active.
+ final String selection = ParticipantColumns._ID + "=? AND " +
+ ParticipantColumns.SIM_SLOT_ID + "<>?";
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE,
+ new String[] { ParticipantColumns._ID }, selection,
+ new String[] { selfId, String.valueOf(ParticipantData.INVALID_SLOT_ID) },
+ null, null, null);
+
+ if (cursor != null && cursor.getCount() > 0) {
+ values.put(ConversationColumns.CURRENT_SELF_ID, selfId);
+ return true;
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return false;
+ }
+
+ private static void updateConversationDraftSnippetAndPreviewInTransaction(
+ final DatabaseWrapper dbWrapper, final String conversationId,
+ final MessageData draftMessage) {
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+
+ long sortTimestamp = 0L;
+ Cursor cursor = null;
+ try {
+ // Check to find the latest message in the conversation
+ cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE,
+ REFRESH_CONVERSATION_MESSAGE_PROJECTION,
+ MessageColumns.CONVERSATION_ID + "=?",
+ new String[]{conversationId}, null, null,
+ MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */);
+
+ if (cursor.moveToFirst()) {
+ sortTimestamp = cursor.getLong(1);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+
+ final ContentValues values = new ContentValues();
+ if (draftMessage == null || !draftMessage.hasContent()) {
+ values.put(ConversationColumns.SHOW_DRAFT, 0);
+ values.put(ConversationColumns.DRAFT_SNIPPET_TEXT, "");
+ values.put(ConversationColumns.DRAFT_SUBJECT_TEXT, "");
+ values.put(ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE, "");
+ values.put(ConversationColumns.DRAFT_PREVIEW_URI, "");
+ } else {
+ sortTimestamp = Math.max(sortTimestamp, draftMessage.getReceivedTimeStamp());
+ values.put(ConversationColumns.SHOW_DRAFT, 1);
+ values.put(ConversationColumns.DRAFT_SNIPPET_TEXT, draftMessage.getMessageText());
+ values.put(ConversationColumns.DRAFT_SUBJECT_TEXT, draftMessage.getMmsSubject());
+ String type = null;
+ String uriString = null;
+ for (final MessagePartData part : draftMessage.getParts()) {
+ if (part.isAttachment() &&
+ ContentType.isConversationListPreviewableType(part.getContentType())) {
+ uriString = part.getContentUri().toString();
+ type = part.getContentType();
+ break;
+ }
+ }
+ values.put(ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE, type);
+ values.put(ConversationColumns.DRAFT_PREVIEW_URI, uriString);
+ }
+ values.put(ConversationColumns.SORT_TIMESTAMP, sortTimestamp);
+ // Called in transaction after reading conversation row
+ updateConversationRow(dbWrapper, conversationId, values);
+ }
+
+ @DoesNotRunOnMainThread
+ public static boolean updateConversationRowIfExists(final DatabaseWrapper dbWrapper,
+ final String conversationId, final ContentValues values) {
+ Assert.isNotMainThread();
+ return updateRowIfExists(dbWrapper, DatabaseHelper.CONVERSATIONS_TABLE,
+ ConversationColumns._ID, conversationId, values);
+ }
+
+ @DoesNotRunOnMainThread
+ public static void updateConversationRow(final DatabaseWrapper dbWrapper,
+ final String conversationId, final ContentValues values) {
+ Assert.isNotMainThread();
+ final boolean exists = updateConversationRowIfExists(dbWrapper, conversationId, values);
+ Assert.isTrue(exists);
+ }
+
+ @DoesNotRunOnMainThread
+ public static boolean updateMessageRowIfExists(final DatabaseWrapper dbWrapper,
+ final String messageId, final ContentValues values) {
+ Assert.isNotMainThread();
+ return updateRowIfExists(dbWrapper, DatabaseHelper.MESSAGES_TABLE, MessageColumns._ID,
+ messageId, values);
+ }
+
+ @DoesNotRunOnMainThread
+ public static void updateMessageRow(final DatabaseWrapper dbWrapper,
+ final String messageId, final ContentValues values) {
+ Assert.isNotMainThread();
+ final boolean exists = updateMessageRowIfExists(dbWrapper, messageId, values);
+ Assert.isTrue(exists);
+ }
+
+ @DoesNotRunOnMainThread
+ public static boolean updatePartRowIfExists(final DatabaseWrapper dbWrapper,
+ final String partId, final ContentValues values) {
+ Assert.isNotMainThread();
+ return updateRowIfExists(dbWrapper, DatabaseHelper.PARTS_TABLE, PartColumns._ID,
+ partId, values);
+ }
+
+ /**
+ * Returns the default conversation name based on its participants.
+ */
+ private static String getDefaultConversationName(final List<ParticipantData> participants) {
+ return ConversationListItemData.generateConversationName(participants);
+ }
+
+ /**
+ * Updates a given conversation's name based on its participants.
+ */
+ @DoesNotRunOnMainThread
+ public static void updateConversationNameAndAvatarInTransaction(
+ final DatabaseWrapper dbWrapper, final String conversationId) {
+ Assert.isNotMainThread();
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+
+ final ArrayList<ParticipantData> participants =
+ getParticipantsForConversation(dbWrapper, conversationId);
+ updateConversationNameAndAvatarInTransaction(dbWrapper, conversationId, participants);
+ }
+
+ /**
+ * Updates a given conversation's name based on its participants.
+ */
+ private static void updateConversationNameAndAvatarInTransaction(
+ final DatabaseWrapper dbWrapper, final String conversationId,
+ final List<ParticipantData> participants) {
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+
+ final ContentValues values = new ContentValues();
+ values.put(ConversationColumns.NAME,
+ getDefaultConversationName(participants));
+
+ fillParticipantData(values, participants);
+
+ // Used by background thread when refreshing conversation so conversation could be deleted.
+ updateConversationRowIfExists(dbWrapper, conversationId, values);
+
+ WidgetConversationProvider.notifyConversationRenamed(Factory.get().getApplicationContext(),
+ conversationId);
+ }
+
+ /**
+ * Updates a given conversation's self id.
+ */
+ @DoesNotRunOnMainThread
+ public static void updateConversationSelfIdInTransaction(
+ final DatabaseWrapper dbWrapper, final String conversationId, final String selfId) {
+ Assert.isNotMainThread();
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+ final ContentValues values = new ContentValues();
+ if (addConversationSelfIdToContentValues(dbWrapper, selfId, values)) {
+ updateConversationRowIfExists(dbWrapper, conversationId, values);
+ }
+ }
+
+ @DoesNotRunOnMainThread
+ public static String getConversationSelfId(final DatabaseWrapper dbWrapper,
+ final String conversationId) {
+ Assert.isNotMainThread();
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE,
+ new String[] { ConversationColumns.CURRENT_SELF_ID },
+ ConversationColumns._ID + "=?",
+ new String[] { conversationId },
+ null, null, null);
+ Assert.inRange(cursor.getCount(), 0, 1);
+ if (cursor.moveToFirst()) {
+ return cursor.getString(0);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Frees up memory associated with phone number to participant id matching.
+ */
+ @DoesNotRunOnMainThread
+ public static void clearParticipantIdCache() {
+ Assert.isNotMainThread();
+ synchronized (sNormalizedPhoneNumberToParticipantIdCache) {
+ sNormalizedPhoneNumberToParticipantIdCache.clear();
+ }
+ }
+
+ @DoesNotRunOnMainThread
+ public static ArrayList<String> getRecipientsForConversation(final DatabaseWrapper dbWrapper,
+ final String conversationId) {
+ Assert.isNotMainThread();
+ final ArrayList<ParticipantData> participants =
+ getParticipantsForConversation(dbWrapper, conversationId);
+
+ final ArrayList<String> recipients = new ArrayList<String>();
+ for (final ParticipantData participant : participants) {
+ recipients.add(participant.getSendDestination());
+ }
+
+ return recipients;
+ }
+
+ @DoesNotRunOnMainThread
+ public static String getSmsServiceCenterForConversation(final DatabaseWrapper dbWrapper,
+ final String conversationId) {
+ Assert.isNotMainThread();
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE,
+ new String[] { ConversationColumns.SMS_SERVICE_CENTER },
+ ConversationColumns._ID + "=?",
+ new String[] { conversationId },
+ null, null, null);
+ Assert.inRange(cursor.getCount(), 0, 1);
+ if (cursor.moveToFirst()) {
+ return cursor.getString(0);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+
+ @DoesNotRunOnMainThread
+ public static ParticipantData getExistingParticipant(final DatabaseWrapper dbWrapper,
+ final String participantId) {
+ Assert.isNotMainThread();
+ ParticipantData participant = null;
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE,
+ ParticipantData.ParticipantsQuery.PROJECTION,
+ ParticipantColumns._ID + " =?",
+ new String[] { participantId }, null, null, null);
+ Assert.inRange(cursor.getCount(), 0, 1);
+ if (cursor.moveToFirst()) {
+ participant = ParticipantData.getFromCursor(cursor);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ return participant;
+ }
+
+ static int getSelfSubscriptionId(final DatabaseWrapper dbWrapper,
+ final String selfParticipantId) {
+ final ParticipantData selfParticipant = BugleDatabaseOperations.getExistingParticipant(
+ dbWrapper, selfParticipantId);
+ if (selfParticipant != null) {
+ Assert.isTrue(selfParticipant.isSelf());
+ return selfParticipant.getSubId();
+ }
+ return ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+
+ @VisibleForTesting
+ @DoesNotRunOnMainThread
+ public static ArrayList<ParticipantData> getParticipantsForConversation(
+ final DatabaseWrapper dbWrapper, final String conversationId) {
+ Assert.isNotMainThread();
+ final ArrayList<ParticipantData> participants =
+ new ArrayList<ParticipantData>();
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE,
+ ParticipantData.ParticipantsQuery.PROJECTION,
+ ParticipantColumns._ID + " IN ( " + "SELECT "
+ + ConversationParticipantsColumns.PARTICIPANT_ID + " AS "
+ + ParticipantColumns._ID
+ + " FROM " + DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE
+ + " WHERE " + ConversationParticipantsColumns.CONVERSATION_ID + " =? )",
+ new String[] { conversationId }, null, null, null);
+
+ while (cursor.moveToNext()) {
+ participants.add(ParticipantData.getFromCursor(cursor));
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ return participants;
+ }
+
+ @DoesNotRunOnMainThread
+ public static MessageData readMessage(final DatabaseWrapper dbWrapper, final String messageId) {
+ Assert.isNotMainThread();
+ final MessageData message = readMessageData(dbWrapper, messageId);
+ if (message != null) {
+ readMessagePartsData(dbWrapper, message, false);
+ }
+ return message;
+ }
+
+ @VisibleForTesting
+ static MessagePartData readMessagePartData(final DatabaseWrapper dbWrapper,
+ final String partId) {
+ MessagePartData messagePartData = null;
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.PARTS_TABLE,
+ MessagePartData.getProjection(), PartColumns._ID + "=?",
+ new String[] { partId }, null, null, null);
+ Assert.inRange(cursor.getCount(), 0, 1);
+ if (cursor.moveToFirst()) {
+ messagePartData = MessagePartData.createFromCursor(cursor);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return messagePartData;
+ }
+
+ @DoesNotRunOnMainThread
+ public static MessageData readMessageData(final DatabaseWrapper dbWrapper,
+ final Uri smsMessageUri) {
+ Assert.isNotMainThread();
+ MessageData message = null;
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE,
+ MessageData.getProjection(), MessageColumns.SMS_MESSAGE_URI + "=?",
+ new String[] { smsMessageUri.toString() }, null, null, null);
+ Assert.inRange(cursor.getCount(), 0, 1);
+ if (cursor.moveToFirst()) {
+ message = new MessageData();
+ message.bind(cursor);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return message;
+ }
+
+ @DoesNotRunOnMainThread
+ public static MessageData readMessageData(final DatabaseWrapper dbWrapper,
+ final String messageId) {
+ Assert.isNotMainThread();
+ MessageData message = null;
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE,
+ MessageData.getProjection(), MessageColumns._ID + "=?",
+ new String[] { messageId }, null, null, null);
+ Assert.inRange(cursor.getCount(), 0, 1);
+ if (cursor.moveToFirst()) {
+ message = new MessageData();
+ message.bind(cursor);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return message;
+ }
+
+ /**
+ * Read all the parts for a message
+ * @param dbWrapper database
+ * @param message read parts for this message
+ * @param checkAttachmentFilesExist check each attachment file and only include if file exists
+ */
+ private static void readMessagePartsData(final DatabaseWrapper dbWrapper,
+ final MessageData message, final boolean checkAttachmentFilesExist) {
+ final ContentResolver contentResolver =
+ Factory.get().getApplicationContext().getContentResolver();
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.PARTS_TABLE,
+ MessagePartData.getProjection(), PartColumns.MESSAGE_ID + "=?",
+ new String[] { message.getMessageId() }, null, null, null);
+ while (cursor.moveToNext()) {
+ final MessagePartData messagePartData = MessagePartData.createFromCursor(cursor);
+ if (checkAttachmentFilesExist && messagePartData.isAttachment() &&
+ !UriUtil.isBugleAppResource(messagePartData.getContentUri())) {
+ try {
+ // Test that the file exists before adding the attachment to the draft
+ final ParcelFileDescriptor fileDescriptor =
+ contentResolver.openFileDescriptor(
+ messagePartData.getContentUri(), "r");
+ if (fileDescriptor != null) {
+ fileDescriptor.close();
+ message.addPart(messagePartData);
+ }
+ } catch (final IOException e) {
+ // The attachment's temp storage no longer exists, just ignore the file
+ } catch (final SecurityException e) {
+ // Likely thrown by openFileDescriptor due to an expired access grant.
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
+ LogUtil.d(LogUtil.BUGLE_TAG, "uri: " + messagePartData.getContentUri());
+ }
+ }
+ } else {
+ message.addPart(messagePartData);
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+
+ /**
+ * Write a message part to our local database
+ *
+ * @param dbWrapper The database
+ * @param messagePart The message part to insert
+ * @return The row id of the newly inserted part
+ */
+ static String insertNewMessagePartInTransaction(final DatabaseWrapper dbWrapper,
+ final MessagePartData messagePart, final String conversationId) {
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+ Assert.isTrue(!TextUtils.isEmpty(messagePart.getMessageId()));
+
+ // Insert a new part row
+ final SQLiteStatement insert = messagePart.getInsertStatement(dbWrapper, conversationId);
+ final long rowNumber = insert.executeInsert();
+
+ Assert.inRange(rowNumber, 0, Long.MAX_VALUE);
+ final String partId = Long.toString(rowNumber);
+
+ // Update the part id
+ messagePart.updatePartId(partId);
+
+ return partId;
+ }
+
+ /**
+ * Insert a message and its parts into the table
+ */
+ @DoesNotRunOnMainThread
+ public static void insertNewMessageInTransaction(final DatabaseWrapper dbWrapper,
+ final MessageData message) {
+ Assert.isNotMainThread();
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+
+ // Insert message row
+ final SQLiteStatement insert = message.getInsertStatement(dbWrapper);
+ final long rowNumber = insert.executeInsert();
+
+ Assert.inRange(rowNumber, 0, Long.MAX_VALUE);
+ final String messageId = Long.toString(rowNumber);
+ message.updateMessageId(messageId);
+ // Insert new parts
+ for (final MessagePartData messagePart : message.getParts()) {
+ messagePart.updateMessageId(messageId);
+ insertNewMessagePartInTransaction(dbWrapper, messagePart, message.getConversationId());
+ }
+ }
+
+ /**
+ * Update a message and add its parts into the table
+ */
+ @DoesNotRunOnMainThread
+ public static void updateMessageInTransaction(final DatabaseWrapper dbWrapper,
+ final MessageData message) {
+ Assert.isNotMainThread();
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+ final String messageId = message.getMessageId();
+ // Check message still exists (sms sync or delete might have purged it)
+ final MessageData current = BugleDatabaseOperations.readMessage(dbWrapper, messageId);
+ if (current != null) {
+ // Delete existing message parts)
+ deletePartsForMessage(dbWrapper, message.getMessageId());
+ // Insert new parts
+ for (final MessagePartData messagePart : message.getParts()) {
+ messagePart.updatePartId(null);
+ messagePart.updateMessageId(message.getMessageId());
+ insertNewMessagePartInTransaction(dbWrapper, messagePart,
+ message.getConversationId());
+ }
+ // Update message row
+ final ContentValues values = new ContentValues();
+ message.populate(values);
+ updateMessageRowIfExists(dbWrapper, message.getMessageId(), values);
+ }
+ }
+
+ @DoesNotRunOnMainThread
+ public static void updateMessageAndPartsInTransaction(final DatabaseWrapper dbWrapper,
+ final MessageData message, final List<MessagePartData> partsToUpdate) {
+ Assert.isNotMainThread();
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+ final ContentValues values = new ContentValues();
+ for (final MessagePartData messagePart : partsToUpdate) {
+ values.clear();
+ messagePart.populate(values);
+ updatePartRowIfExists(dbWrapper, messagePart.getPartId(), values);
+ }
+ values.clear();
+ message.populate(values);
+ updateMessageRowIfExists(dbWrapper, message.getMessageId(), values);
+ }
+
+ /**
+ * Delete all parts for a message
+ */
+ static void deletePartsForMessage(final DatabaseWrapper dbWrapper,
+ final String messageId) {
+ final int cnt = dbWrapper.delete(DatabaseHelper.PARTS_TABLE,
+ PartColumns.MESSAGE_ID + " =?",
+ new String[] { messageId });
+ Assert.inRange(cnt, 0, Integer.MAX_VALUE);
+ }
+
+ /**
+ * Delete one message and update the conversation (if necessary).
+ *
+ * @return number of rows deleted (should be 1 or 0).
+ */
+ @DoesNotRunOnMainThread
+ public static int deleteMessage(final DatabaseWrapper dbWrapper, final String messageId) {
+ Assert.isNotMainThread();
+ dbWrapper.beginTransaction();
+ try {
+ // Read message to find out which conversation it is in
+ final MessageData message = BugleDatabaseOperations.readMessage(dbWrapper, messageId);
+
+ int count = 0;
+ if (message != null) {
+ final String conversationId = message.getConversationId();
+ // Delete message
+ count = dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE,
+ MessageColumns._ID + "=?", new String[] { messageId });
+
+ if (!deleteConversationIfEmptyInTransaction(dbWrapper, conversationId)) {
+ // TODO: Should we leave the conversation sort timestamp alone?
+ refreshConversationMetadataInTransaction(dbWrapper, conversationId,
+ false/* shouldAutoSwitchSelfId */, false/*archived*/);
+ }
+ }
+ dbWrapper.setTransactionSuccessful();
+ return count;
+ } finally {
+ dbWrapper.endTransaction();
+ }
+ }
+
+ /**
+ * Deletes the conversation if there are zero non-draft messages left.
+ * <p>
+ * This is necessary because the telephony database has a trigger that deletes threads after
+ * their last message is deleted. We need to ensure that if a thread goes away, we also delete
+ * the conversation in Bugle. We don't store draft messages in telephony, so we ignore those
+ * when querying for the # of messages in the conversation.
+ *
+ * @return true if the conversation was deleted
+ */
+ @DoesNotRunOnMainThread
+ public static boolean deleteConversationIfEmptyInTransaction(final DatabaseWrapper dbWrapper,
+ final String conversationId) {
+ Assert.isNotMainThread();
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+ Cursor cursor = null;
+ try {
+ // TODO: The refreshConversationMetadataInTransaction method below uses this
+ // same query; maybe they should share this logic?
+
+ // Check to see if there are any (non-draft) messages in the conversation
+ cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE,
+ REFRESH_CONVERSATION_MESSAGE_PROJECTION,
+ MessageColumns.CONVERSATION_ID + "=? AND " +
+ MessageColumns.STATUS + "!=" + MessageData.BUGLE_STATUS_OUTGOING_DRAFT,
+ new String[] { conversationId }, null, null,
+ MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */);
+ if (cursor.getCount() == 0) {
+ dbWrapper.delete(DatabaseHelper.CONVERSATIONS_TABLE,
+ ConversationColumns._ID + "=?", new String[] { conversationId });
+ LogUtil.i(TAG,
+ "BugleDatabaseOperations: Deleted empty conversation " + conversationId);
+ return true;
+ } else {
+ return false;
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+
+ private static final String[] REFRESH_CONVERSATION_MESSAGE_PROJECTION = new String[] {
+ MessageColumns._ID,
+ MessageColumns.RECEIVED_TIMESTAMP,
+ MessageColumns.SENDER_PARTICIPANT_ID
+ };
+
+ /**
+ * Update conversation snippet, timestamp and optionally self id to match latest message in
+ * conversation.
+ */
+ @DoesNotRunOnMainThread
+ public static void refreshConversationMetadataInTransaction(final DatabaseWrapper dbWrapper,
+ final String conversationId, final boolean shouldAutoSwitchSelfId,
+ boolean keepArchived) {
+ Assert.isNotMainThread();
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+ Cursor cursor = null;
+ try {
+ // Check to see if there are any (non-draft) messages in the conversation
+ cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE,
+ REFRESH_CONVERSATION_MESSAGE_PROJECTION,
+ MessageColumns.CONVERSATION_ID + "=? AND " +
+ MessageColumns.STATUS + "!=" + MessageData.BUGLE_STATUS_OUTGOING_DRAFT,
+ new String[] { conversationId }, null, null,
+ MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */);
+
+ if (cursor.moveToFirst()) {
+ // Refresh latest message in conversation
+ final String latestMessageId = cursor.getString(0);
+ final long latestMessageTimestamp = cursor.getLong(1);
+ final String senderParticipantId = cursor.getString(2);
+ final boolean senderBlocked = isBlockedParticipant(dbWrapper, senderParticipantId);
+ updateConversationMetadataInTransaction(dbWrapper, conversationId,
+ latestMessageId, latestMessageTimestamp, senderBlocked || keepArchived,
+ shouldAutoSwitchSelfId);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+
+ /**
+ * When moving/removing an existing message update conversation metadata if necessary
+ * @param dbWrapper db wrapper
+ * @param conversationId conversation to modify
+ * @param messageId message that is leaving the conversation
+ * @param shouldAutoSwitchSelfId should we try to auto-switch the conversation's self-id as a
+ * result of this call when we see a new latest message?
+ * @param keepArchived should we keep the conversation archived despite refresh
+ */
+ @DoesNotRunOnMainThread
+ public static void maybeRefreshConversationMetadataInTransaction(
+ final DatabaseWrapper dbWrapper, final String conversationId, final String messageId,
+ final boolean shouldAutoSwitchSelfId, final boolean keepArchived) {
+ Assert.isNotMainThread();
+ boolean refresh = true;
+ if (!TextUtils.isEmpty(messageId)) {
+ refresh = false;
+ // Look for an existing conversation in the db with this conversation id
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE,
+ new String[] { ConversationColumns.LATEST_MESSAGE_ID },
+ ConversationColumns._ID + "=?",
+ new String[] { conversationId },
+ null, null, null);
+ Assert.inRange(cursor.getCount(), 0, 1);
+ if (cursor.moveToFirst()) {
+ refresh = TextUtils.equals(cursor.getString(0), messageId);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+ if (refresh) {
+ // TODO: I think it is okay to delete the conversation if it is empty...
+ refreshConversationMetadataInTransaction(dbWrapper, conversationId,
+ shouldAutoSwitchSelfId, keepArchived);
+ }
+ }
+
+
+
+ // SQL statement to query latest message if for particular conversation
+ private static final String QUERY_CONVERSATIONS_LATEST_MESSAGE_SQL = "SELECT "
+ + ConversationColumns.LATEST_MESSAGE_ID + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE
+ + " WHERE " + ConversationColumns._ID + "=? LIMIT 1";
+
+ /**
+ * Note this is not thread safe so callers need to make sure they own the wrapper + statements
+ * while they call this and use the returned value.
+ */
+ @DoesNotRunOnMainThread
+ public static SQLiteStatement getQueryConversationsLatestMessageStatement(
+ final DatabaseWrapper db, final String conversationId) {
+ Assert.isNotMainThread();
+ final SQLiteStatement query = db.getStatementInTransaction(
+ DatabaseWrapper.INDEX_QUERY_CONVERSATIONS_LATEST_MESSAGE,
+ QUERY_CONVERSATIONS_LATEST_MESSAGE_SQL);
+ query.clearBindings();
+ query.bindString(1, conversationId);
+ return query;
+ }
+
+ // SQL statement to query latest message if for particular conversation
+ private static final String QUERY_MESSAGES_LATEST_MESSAGE_SQL = "SELECT "
+ + MessageColumns._ID + " FROM " + DatabaseHelper.MESSAGES_TABLE
+ + " WHERE " + MessageColumns.CONVERSATION_ID + "=? ORDER BY "
+ + MessageColumns.RECEIVED_TIMESTAMP + " DESC LIMIT 1";
+
+ /**
+ * Note this is not thread safe so callers need to make sure they own the wrapper + statements
+ * while they call this and use the returned value.
+ */
+ @DoesNotRunOnMainThread
+ public static SQLiteStatement getQueryMessagesLatestMessageStatement(
+ final DatabaseWrapper db, final String conversationId) {
+ Assert.isNotMainThread();
+ final SQLiteStatement query = db.getStatementInTransaction(
+ DatabaseWrapper.INDEX_QUERY_MESSAGES_LATEST_MESSAGE,
+ QUERY_MESSAGES_LATEST_MESSAGE_SQL);
+ query.clearBindings();
+ query.bindString(1, conversationId);
+ return query;
+ }
+
+ /**
+ * Update conversation metadata if necessary
+ * @param dbWrapper db wrapper
+ * @param conversationId conversation to modify
+ * @param shouldAutoSwitchSelfId should we try to auto-switch the conversation's self-id as a
+ * result of this call when we see a new latest message?
+ * @param keepArchived if the conversation should be kept archived
+ */
+ @DoesNotRunOnMainThread
+ public static void maybeRefreshConversationMetadataInTransaction(
+ final DatabaseWrapper dbWrapper, final String conversationId,
+ final boolean shouldAutoSwitchSelfId, boolean keepArchived) {
+ Assert.isNotMainThread();
+ String currentLatestMessageId = null;
+ String latestMessageId = null;
+ try {
+ final SQLiteStatement currentLatestMessageIdSql =
+ getQueryConversationsLatestMessageStatement(dbWrapper, conversationId);
+ currentLatestMessageId = currentLatestMessageIdSql.simpleQueryForString();
+
+ final SQLiteStatement latestMessageIdSql =
+ getQueryMessagesLatestMessageStatement(dbWrapper, conversationId);
+ latestMessageId = latestMessageIdSql.simpleQueryForString();
+ } catch (final SQLiteDoneException e) {
+ LogUtil.e(TAG, "BugleDatabaseOperations: Query for latest message failed", e);
+ }
+
+ if (TextUtils.isEmpty(currentLatestMessageId) ||
+ !TextUtils.equals(currentLatestMessageId, latestMessageId)) {
+ refreshConversationMetadataInTransaction(dbWrapper, conversationId,
+ shouldAutoSwitchSelfId, keepArchived);
+ }
+ }
+
+ static boolean getConversationExists(final DatabaseWrapper dbWrapper,
+ final String conversationId) {
+ // Look for an existing conversation in the db with this conversation id
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE,
+ new String[] { /* No projection */},
+ ConversationColumns._ID + "=?",
+ new String[] { conversationId },
+ null, null, null);
+ return cursor.getCount() == 1;
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+
+ /** Preserve parts in message but clear the stored draft */
+ public static final int UPDATE_MODE_CLEAR_DRAFT = 1;
+ /** Add the message as a draft */
+ public static final int UPDATE_MODE_ADD_DRAFT = 2;
+
+ /**
+ * Update draft message for specified conversation
+ * @param dbWrapper local database (wrapped)
+ * @param conversationId conversation to update
+ * @param message Optional message to preserve attachments for (either as draft or for
+ * sending)
+ * @param updateMode either {@link #UPDATE_MODE_CLEAR_DRAFT} or
+ * {@link #UPDATE_MODE_ADD_DRAFT}
+ * @return message id of newly written draft (else null)
+ */
+ @DoesNotRunOnMainThread
+ public static String updateDraftMessageData(final DatabaseWrapper dbWrapper,
+ final String conversationId, @Nullable final MessageData message,
+ final int updateMode) {
+ Assert.isNotMainThread();
+ Assert.notNull(conversationId);
+ Assert.inRange(updateMode, UPDATE_MODE_CLEAR_DRAFT, UPDATE_MODE_ADD_DRAFT);
+ String messageId = null;
+ Cursor cursor = null;
+ dbWrapper.beginTransaction();
+ try {
+ // Find all draft parts for the current conversation
+ final SimpleArrayMap<Uri, MessagePartData> currentDraftParts = new SimpleArrayMap<>();
+ cursor = dbWrapper.query(DatabaseHelper.DRAFT_PARTS_VIEW,
+ MessagePartData.getProjection(),
+ MessageColumns.CONVERSATION_ID + " =?",
+ new String[] { conversationId }, null, null, null);
+ while (cursor.moveToNext()) {
+ final MessagePartData part = MessagePartData.createFromCursor(cursor);
+ if (part.isAttachment()) {
+ currentDraftParts.put(part.getContentUri(), part);
+ }
+ }
+ // Optionally, preserve attachments for "message"
+ final boolean conversationExists = getConversationExists(dbWrapper, conversationId);
+ if (message != null && conversationExists) {
+ for (final MessagePartData part : message.getParts()) {
+ if (part.isAttachment()) {
+ currentDraftParts.remove(part.getContentUri());
+ }
+ }
+ }
+
+ // Delete orphan content
+ for (int index = 0; index < currentDraftParts.size(); index++) {
+ final MessagePartData part = currentDraftParts.valueAt(index);
+ part.destroySync();
+ }
+
+ // Delete existing draft (cascade deletes parts)
+ dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE,
+ MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?",
+ new String[] {
+ Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT),
+ conversationId
+ });
+
+ // Write new draft
+ if (updateMode == UPDATE_MODE_ADD_DRAFT && message != null
+ && message.hasContent() && conversationExists) {
+ Assert.equals(MessageData.BUGLE_STATUS_OUTGOING_DRAFT,
+ message.getStatus());
+
+ // Now add draft to message table
+ insertNewMessageInTransaction(dbWrapper, message);
+ messageId = message.getMessageId();
+ }
+
+ if (conversationExists) {
+ updateConversationDraftSnippetAndPreviewInTransaction(
+ dbWrapper, conversationId, message);
+
+ if (message != null && message.getSelfId() != null) {
+ updateConversationSelfIdInTransaction(dbWrapper, conversationId,
+ message.getSelfId());
+ }
+ }
+
+ dbWrapper.setTransactionSuccessful();
+ } finally {
+ dbWrapper.endTransaction();
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG,
+ "Updated draft message " + messageId + " for conversation " + conversationId);
+ }
+ return messageId;
+ }
+
+ /**
+ * Read the first draft message associated with this conversation.
+ * If none present create an empty (sms) draft message.
+ */
+ @DoesNotRunOnMainThread
+ public static MessageData readDraftMessageData(final DatabaseWrapper dbWrapper,
+ final String conversationId, final String conversationSelfId) {
+ Assert.isNotMainThread();
+ MessageData message = null;
+ Cursor cursor = null;
+ dbWrapper.beginTransaction();
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE,
+ MessageData.getProjection(),
+ MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?",
+ new String[] {
+ Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT),
+ conversationId
+ }, null, null, null);
+ Assert.inRange(cursor.getCount(), 0, 1);
+ if (cursor.moveToFirst()) {
+ message = new MessageData();
+ message.bindDraft(cursor, conversationSelfId);
+ readMessagePartsData(dbWrapper, message, true);
+ // Disconnect draft parts from DB
+ for (final MessagePartData part : message.getParts()) {
+ part.updatePartId(null);
+ part.updateMessageId(null);
+ }
+ message.updateMessageId(null);
+ }
+ dbWrapper.setTransactionSuccessful();
+ } finally {
+ dbWrapper.endTransaction();
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return message;
+ }
+
+ // Internal
+ private static void addParticipantToConversation(final DatabaseWrapper dbWrapper,
+ final ParticipantData participant, final String conversationId) {
+ final String participantId = getOrCreateParticipantInTransaction(dbWrapper, participant);
+ Assert.notNull(participantId);
+
+ // Add the participant to the conversation participants table
+ final ContentValues values = new ContentValues();
+ values.put(ConversationParticipantsColumns.CONVERSATION_ID, conversationId);
+ values.put(ConversationParticipantsColumns.PARTICIPANT_ID, participantId);
+ dbWrapper.insert(DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE, null, values);
+ }
+
+ /**
+ * Get string used as canonical recipient for participant cache for sub id
+ */
+ private static String getCanonicalRecipientFromSubId(final int subId) {
+ return "SELF(" + subId + ")";
+ }
+
+ /**
+ * Maps from a sub id or phone number to a participant id if there is one.
+ *
+ * @return If the participant is available in our cache, or the DB, this returns the
+ * participant id for the given subid/phone number. Otherwise it returns null.
+ */
+ @VisibleForTesting
+ private static String getParticipantId(final DatabaseWrapper dbWrapper,
+ final int subId, final String canonicalRecipient) {
+ // First check our memory cache for the participant Id
+ String participantId;
+ synchronized (sNormalizedPhoneNumberToParticipantIdCache) {
+ participantId = sNormalizedPhoneNumberToParticipantIdCache.get(canonicalRecipient);
+ }
+
+ if (participantId != null) {
+ return participantId;
+ }
+
+ // This code will only be executed for incremental additions.
+ Cursor cursor = null;
+ try {
+ if (subId != ParticipantData.OTHER_THAN_SELF_SUB_ID) {
+ // Now look for an existing participant in the db with this sub id.
+ cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE,
+ new String[] {ParticipantColumns._ID},
+ ParticipantColumns.SUB_ID + "=?",
+ new String[] { Integer.toString(subId) }, null, null, null);
+ } else {
+ // Look for existing participant with this normalized phone number and no subId.
+ cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE,
+ new String[] {ParticipantColumns._ID},
+ ParticipantColumns.NORMALIZED_DESTINATION + "=? AND "
+ + ParticipantColumns.SUB_ID + "=?",
+ new String[] {canonicalRecipient, Integer.toString(subId)},
+ null, null, null);
+ }
+
+ if (cursor.moveToFirst()) {
+ // TODO Is this assert correct for multi-sim where a new sim was put in?
+ Assert.isTrue(cursor.getCount() == 1);
+
+ // We found an existing participant in the database
+ participantId = cursor.getString(0);
+
+ synchronized (sNormalizedPhoneNumberToParticipantIdCache) {
+ // Add it to the cache for next time
+ sNormalizedPhoneNumberToParticipantIdCache.put(canonicalRecipient,
+ participantId);
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return participantId;
+ }
+
+ @DoesNotRunOnMainThread
+ public static ParticipantData getOrCreateSelf(final DatabaseWrapper dbWrapper,
+ final int subId) {
+ Assert.isNotMainThread();
+ ParticipantData participant = null;
+ dbWrapper.beginTransaction();
+ try {
+ final ParticipantData shell = ParticipantData.getSelfParticipant(subId);
+ final String participantId = getOrCreateParticipantInTransaction(dbWrapper, shell);
+ participant = getExistingParticipant(dbWrapper, participantId);
+ dbWrapper.setTransactionSuccessful();
+ } finally {
+ dbWrapper.endTransaction();
+ }
+ return participant;
+ }
+
+ /**
+ * Lookup and if necessary create a new participant
+ * @param dbWrapper Database wrapper
+ * @param participant Participant to find/create
+ * @return participantId ParticipantId for existing or newly created participant
+ */
+ @DoesNotRunOnMainThread
+ public static String getOrCreateParticipantInTransaction(final DatabaseWrapper dbWrapper,
+ final ParticipantData participant) {
+ Assert.isNotMainThread();
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+ int subId = ParticipantData.OTHER_THAN_SELF_SUB_ID;
+ String participantId = null;
+ String canonicalRecipient = null;
+ if (participant.isSelf()) {
+ subId = participant.getSubId();
+ canonicalRecipient = getCanonicalRecipientFromSubId(subId);
+ } else {
+ canonicalRecipient = participant.getNormalizedDestination();
+ }
+ Assert.notNull(canonicalRecipient);
+ participantId = getParticipantId(dbWrapper, subId, canonicalRecipient);
+
+ if (participantId != null) {
+ return participantId;
+ }
+
+ if (!participant.isContactIdResolved()) {
+ // Refresh participant's name and avatar with matching contact in CP2.
+ ParticipantRefresh.refreshParticipant(dbWrapper, participant);
+ }
+
+ // Insert the participant into the participants table
+ final ContentValues values = participant.toContentValues();
+ final long participantRow = dbWrapper.insert(DatabaseHelper.PARTICIPANTS_TABLE, null,
+ values);
+ participantId = Long.toString(participantRow);
+ Assert.notNull(canonicalRecipient);
+
+ synchronized (sNormalizedPhoneNumberToParticipantIdCache) {
+ // Now that we've inserted it, add it to our cache
+ sNormalizedPhoneNumberToParticipantIdCache.put(canonicalRecipient, participantId);
+ }
+
+ return participantId;
+ }
+
+ @DoesNotRunOnMainThread
+ public static void updateDestination(final DatabaseWrapper dbWrapper,
+ final String destination, final boolean blocked) {
+ Assert.isNotMainThread();
+ final ContentValues values = new ContentValues();
+ values.put(ParticipantColumns.BLOCKED, blocked ? 1 : 0);
+ dbWrapper.update(DatabaseHelper.PARTICIPANTS_TABLE, values,
+ ParticipantColumns.NORMALIZED_DESTINATION + "=? AND " +
+ ParticipantColumns.SUB_ID + "=?",
+ new String[] { destination, Integer.toString(
+ ParticipantData.OTHER_THAN_SELF_SUB_ID) });
+ }
+
+ @DoesNotRunOnMainThread
+ public static String getConversationFromOtherParticipantDestination(
+ final DatabaseWrapper db, final String otherDestination) {
+ Assert.isNotMainThread();
+ Cursor cursor = null;
+ try {
+ cursor = db.query(DatabaseHelper.CONVERSATIONS_TABLE,
+ new String[] { ConversationColumns._ID },
+ ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + "=?",
+ new String[] { otherDestination }, null, null, null);
+ Assert.inRange(cursor.getCount(), 0, 1);
+ if (cursor.moveToFirst()) {
+ return cursor.getString(0);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+
+
+ /**
+ * Get a list of conversations that contain any of participants specified.
+ */
+ private static HashSet<String> getConversationsForParticipants(
+ final ArrayList<String> participantIds) {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final HashSet<String> conversationIds = new HashSet<String>();
+
+ final String selection = ConversationParticipantsColumns.PARTICIPANT_ID + "=?";
+ for (final String participantId : participantIds) {
+ final String[] selectionArgs = new String[] { participantId };
+ final Cursor cursor = db.query(DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE,
+ ConversationParticipantsQuery.PROJECTION,
+ selection, selectionArgs, null, null, null);
+
+ if (cursor != null) {
+ try {
+ while (cursor.moveToNext()) {
+ final String conversationId = cursor.getString(
+ ConversationParticipantsQuery.INDEX_CONVERSATION_ID);
+ conversationIds.add(conversationId);
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+
+ return conversationIds;
+ }
+
+ /**
+ * Refresh conversation names/avatars based on a list of participants that are changed.
+ */
+ @DoesNotRunOnMainThread
+ public static void refreshConversationsForParticipants(final ArrayList<String> participants) {
+ Assert.isNotMainThread();
+ final HashSet<String> conversationIds = getConversationsForParticipants(participants);
+ if (conversationIds.size() > 0) {
+ for (final String conversationId : conversationIds) {
+ refreshConversation(conversationId);
+ }
+
+ MessagingContentProvider.notifyConversationListChanged();
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Number of conversations refreshed:" + conversationIds.size());
+ }
+ }
+ }
+
+ /**
+ * Refresh conversation names/avatars based on a changed participant.
+ */
+ @DoesNotRunOnMainThread
+ public static void refreshConversationsForParticipant(final String participantId) {
+ Assert.isNotMainThread();
+ final ArrayList<String> participantList = new ArrayList<String>(1);
+ participantList.add(participantId);
+ refreshConversationsForParticipants(participantList);
+ }
+
+ /**
+ * Refresh one conversation.
+ */
+ private static void refreshConversation(final String conversationId) {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ db.beginTransaction();
+ try {
+ BugleDatabaseOperations.updateConversationNameAndAvatarInTransaction(db,
+ conversationId);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ MessagingContentProvider.notifyParticipantsChanged(conversationId);
+ MessagingContentProvider.notifyMessagesChanged(conversationId);
+ MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
+ }
+
+ @DoesNotRunOnMainThread
+ public static boolean updateRowIfExists(final DatabaseWrapper db, final String table,
+ final String rowKey, final String rowId, final ContentValues values) {
+ Assert.isNotMainThread();
+ final StringBuilder sb = new StringBuilder();
+ final ArrayList<String> whereValues = new ArrayList<String>(values.size() + 1);
+ whereValues.add(rowId);
+
+ for (final String key : values.keySet()) {
+ if (sb.length() > 0) {
+ sb.append(" OR ");
+ }
+ final Object value = values.get(key);
+ sb.append(key);
+ if (value != null) {
+ sb.append(" IS NOT ?");
+ whereValues.add(value.toString());
+ } else {
+ sb.append(" IS NOT NULL");
+ }
+ }
+
+ final String whereClause = rowKey + "=?" + " AND (" + sb.toString() + ")";
+ final String [] whereValuesArray = whereValues.toArray(new String[whereValues.size()]);
+ final int count = db.update(table, values, whereClause, whereValuesArray);
+ if (count > 1) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Updated more than 1 row " + count + "; " + table +
+ " for " + rowKey + " = " + rowId + " (deleted?)");
+ }
+ Assert.inRange(count, 0, 1);
+ return (count >= 0);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/BugleNotifications.java b/src/com/android/messaging/datamodel/BugleNotifications.java
new file mode 100644
index 0000000..b796e73
--- /dev/null
+++ b/src/com/android/messaging/datamodel/BugleNotifications.java
@@ -0,0 +1,1221 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.BitmapFactory;
+import android.graphics.Typeface;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationCompat.WearableExtender;
+import android.support.v4.app.NotificationManagerCompat;
+import android.support.v4.app.RemoteInput;
+import android.support.v4.util.SimpleArrayMap;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.TextUtils;
+import android.text.style.StyleSpan;
+import android.text.style.TextAppearanceSpan;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.MessageNotificationState.BundledMessageNotificationState;
+import com.android.messaging.datamodel.MessageNotificationState.ConversationLineInfo;
+import com.android.messaging.datamodel.MessageNotificationState.MultiConversationNotificationState;
+import com.android.messaging.datamodel.MessageNotificationState.MultiMessageNotificationState;
+import com.android.messaging.datamodel.action.MarkAsReadAction;
+import com.android.messaging.datamodel.action.MarkAsSeenAction;
+import com.android.messaging.datamodel.action.RedownloadMmsAction;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.media.AvatarRequestDescriptor;
+import com.android.messaging.datamodel.media.ImageResource;
+import com.android.messaging.datamodel.media.MediaRequest;
+import com.android.messaging.datamodel.media.MediaResourceManager;
+import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor;
+import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
+import com.android.messaging.datamodel.media.VideoThumbnailRequest;
+import com.android.messaging.sms.MmsSmsUtils;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.BuglePrefsKeys;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.ConversationIdSet;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.NotificationPlayer;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PendingIntentConstants;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.RingtoneUtil;
+import com.android.messaging.util.ThreadUtil;
+import com.android.messaging.util.UriUtil;
+
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Handle posting, updating and removing all conversation notifications.
+ *
+ * There are currently two main classes of notification and their rules: <p>
+ * 1) Messages - {@link MessageNotificationState}. Only one message notification.
+ * Unread messages across senders and conversations are coalesced.<p>
+ * 2) Failed Messages - {@link MessageNotificationState#checkFailedMesages } Only one failed
+ * message. Multiple failures are coalesced.<p>
+ *
+ * To add a new class of notifications, subclass the NotificationState and add commands which
+ * create one and pass into general creation function.
+ *
+ */
+public class BugleNotifications {
+ // Logging
+ public static final String TAG = LogUtil.BUGLE_NOTIFICATIONS_TAG;
+
+ // Constants to use for update.
+ public static final int UPDATE_NONE = 0;
+ public static final int UPDATE_MESSAGES = 1;
+ public static final int UPDATE_ERRORS = 2;
+ public static final int UPDATE_ALL = UPDATE_MESSAGES + UPDATE_ERRORS;
+
+ // Constants for notification type used for audio and vibration settings.
+ public static final int LOCAL_SMS_NOTIFICATION = 0;
+
+ private static final String SMS_NOTIFICATION_TAG = ":sms:";
+ private static final String SMS_ERROR_NOTIFICATION_TAG = ":error:";
+
+ private static final String WEARABLE_COMPANION_APP_PACKAGE = "com.google.android.wearable.app";
+
+ private static final Set<NotificationState> sPendingNotifications =
+ new HashSet<NotificationState>();
+
+ private static int sWearableImageWidth;
+ private static int sWearableImageHeight;
+ private static int sIconWidth;
+ private static int sIconHeight;
+
+ private static boolean sInitialized = false;
+
+ private static final Object mLock = new Object();
+
+ // sLastMessageDingTime is a map between a conversation id and a time. It's used to keep track
+ // of the time we last dinged a message for this conversation. When messages are coming in
+ // at flurry, we don't want to over-ding the user.
+ private static final SimpleArrayMap<String, Long> sLastMessageDingTime =
+ new SimpleArrayMap<String, Long>();
+ private static int sTimeBetweenDingsMs;
+
+ /**
+ * This is the volume at which to play the observable-conversation notification sound,
+ * expressed as a fraction of the system notification volume.
+ */
+ private static final float OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME = 0.25f;
+
+ /**
+ * Entry point for posting notifications.
+ * Don't call this on the UI thread.
+ * @param silent If true, no ring will be played. If false, checks global settings before
+ * playing a ringtone
+ * @param coverage Indicates which notification types should be checked. Valid values are
+ * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL
+ */
+ public static void update(final boolean silent, final int coverage) {
+ update(silent, null /* conversationId */, coverage);
+ }
+
+ /**
+ * Entry point for posting notifications.
+ * Don't call this on the UI thread.
+ * @param silent If true, no ring will be played. If false, checks global settings before
+ * playing a ringtone
+ * @param conversationId Conversation ID where a new message was received
+ * @param coverage Indicates which notification types should be checked. Valid values are
+ * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL
+ */
+ public static void update(final boolean silent, final String conversationId,
+ final int coverage) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Update: silent = " + silent
+ + " conversationId = " + conversationId
+ + " coverage = " + coverage);
+ }
+ Assert.isNotMainThread();
+ checkInitialized();
+
+ if (!shouldNotify()) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Notifications disabled");
+ }
+ cancel(PendingIntentConstants.SMS_NOTIFICATION_ID);
+ return;
+ } else {
+ if ((coverage & UPDATE_MESSAGES) != 0) {
+ createMessageNotification(silent, conversationId);
+ }
+ }
+ if ((coverage & UPDATE_ERRORS) != 0) {
+ MessageNotificationState.checkFailedMessages();
+ }
+ }
+
+ /**
+ * Cancel all notifications of a certain type.
+ *
+ * @param type Message or error notifications from Constants.
+ */
+ private static synchronized void cancel(final int type) {
+ cancel(type, null, false);
+ }
+
+ /**
+ * Cancel all notifications of a certain type.
+ *
+ * @param type Message or error notifications from Constants.
+ * @param conversationId If set, cancel the notification for this
+ * conversation only. For message notifications, this only works
+ * if the notifications are bundled (group children).
+ * @param isBundledNotification True if this notification is part of a
+ * notification bundle. This only applies to message notifications,
+ * which are bundled together with other message notifications.
+ */
+ private static synchronized void cancel(final int type, final String conversationId,
+ final boolean isBundledNotification) {
+ final String notificationTag = buildNotificationTag(type, conversationId,
+ isBundledNotification);
+ final NotificationManagerCompat notificationManager =
+ NotificationManagerCompat.from(Factory.get().getApplicationContext());
+
+ // Find all pending notifications and cancel them.
+ synchronized (sPendingNotifications) {
+ final Iterator<NotificationState> iter = sPendingNotifications.iterator();
+ while (iter.hasNext()) {
+ final NotificationState notifState = iter.next();
+ if (notifState.mType == type) {
+ notifState.mCanceled = true;
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Canceling pending notification");
+ }
+ iter.remove();
+ }
+ }
+ }
+ notificationManager.cancel(notificationTag, type);
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "Canceled notifications of type " + type);
+ }
+
+ // Message notifications for multiple conversations can be grouped together (see comment in
+ // createMessageNotification). We need to do bookkeeping to track the current set of
+ // notification group children, including removing them when we cancel notifications).
+ if (type == PendingIntentConstants.SMS_NOTIFICATION_ID) {
+ final Context context = Factory.get().getApplicationContext();
+ final ConversationIdSet groupChildIds = getGroupChildIds(context);
+
+ if (groupChildIds != null && groupChildIds.size() > 0) {
+ // If a conversation is specified, remove just that notification. Otherwise,
+ // we're removing the group summary so clear all children.
+ if (conversationId != null) {
+ groupChildIds.remove(conversationId);
+ writeGroupChildIds(context, groupChildIds);
+ } else {
+ cancelStaleGroupChildren(groupChildIds, null);
+ // We'll update the group children preference as we cancel each child,
+ // so we don't need to do it here.
+ }
+ }
+ }
+ }
+
+ /**
+ * Cancels stale notifications from the currently active group of
+ * notifications. If the {@code state} parameter is an instance of
+ * {@link MultiConversationNotificationState} it represents a new
+ * notification group. This method will cancel any notifications that were
+ * in the old group, but not the new one. If the new notification is not a
+ * group, then all existing grouped notifications are cancelled.
+ *
+ * @param previousGroupChildren Conversation ids for the active notification
+ * group
+ * @param state New notification state
+ */
+ private static void cancelStaleGroupChildren(final ConversationIdSet previousGroupChildren,
+ final NotificationState state) {
+ final ConversationIdSet newChildren = new ConversationIdSet();
+ if (state instanceof MultiConversationNotificationState) {
+ for (final NotificationState child :
+ ((MultiConversationNotificationState) state).mChildren) {
+ if (child.mConversationIds != null) {
+ newChildren.add(child.mConversationIds.first());
+ }
+ }
+ }
+ for (final String childConversationId : previousGroupChildren) {
+ if (!newChildren.contains(childConversationId)) {
+ cancel(PendingIntentConstants.SMS_NOTIFICATION_ID, childConversationId, true);
+ }
+ }
+ }
+
+ /**
+ * Returns {@code true} if incoming notifications should display a
+ * notification, {@code false} otherwise.
+ *
+ * @return true if the notification should occur
+ */
+ private static boolean shouldNotify() {
+ // If we're not the default sms app, don't put up any notifications.
+ if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
+ return false;
+ }
+
+ // Now check prefs (i.e. settings) to see if the user turned off notifications.
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ final Context context = Factory.get().getApplicationContext();
+ final String prefKey = context.getString(R.string.notifications_enabled_pref_key);
+ final boolean defaultValue = context.getResources().getBoolean(
+ R.bool.notifications_enabled_pref_default);
+ return prefs.getBoolean(prefKey, defaultValue);
+ }
+
+ /**
+ * Returns {@code true} if incoming notifications for the given {@link NotificationState}
+ * should vibrate the device, {@code false} otherwise.
+ *
+ * @return true if vibration should be used
+ */
+ public static boolean shouldVibrate(final NotificationState state) {
+ // The notification should vibrate if the global setting is turned on AND
+ // the per-conversation setting is turned on (default).
+ if (!state.getNotificationVibrate()) {
+ return false;
+ } else {
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ final Context context = Factory.get().getApplicationContext();
+ final String prefKey = context.getString(R.string.notification_vibration_pref_key);
+ final boolean defaultValue = context.getResources().getBoolean(
+ R.bool.notification_vibration_pref_default);
+ return prefs.getBoolean(prefKey, defaultValue);
+ }
+ }
+
+ private static Uri getNotificationRingtoneUriForConversationId(final String conversationId) {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final ConversationListItemData convData =
+ ConversationListItemData.getExistingConversation(db, conversationId);
+ return RingtoneUtil.getNotificationRingtoneUri(
+ convData != null ? convData.getNotificationSoundUri() : null);
+ }
+
+ /**
+ * Returns a unique tag to identify a notification.
+ *
+ * @param name The tag name (in practice, the type)
+ * @param conversationId The conversation id (optional)
+ */
+ private static String buildNotificationTag(final String name,
+ final String conversationId) {
+ final Context context = Factory.get().getApplicationContext();
+ if (conversationId != null) {
+ return context.getPackageName() + name + ":" + conversationId;
+ } else {
+ return context.getPackageName() + name;
+ }
+ }
+
+ /**
+ * Returns a unique tag to identify a notification.
+ * <p>
+ * This delegates to
+ * {@link #buildNotificationTag(int, String, boolean)} and can be
+ * used when the notification is never bundled (e.g. error notifications).
+ */
+ static String buildNotificationTag(final int type, final String conversationId) {
+ return buildNotificationTag(type, conversationId, false /* bundledNotification */);
+ }
+
+ /**
+ * Returns a unique tag to identify a notification.
+ *
+ * @param type One of the constants in {@link PendingIntentConstants}
+ * @param conversationId The conversation id (where applicable)
+ * @param bundledNotification Set to true if this notification will be
+ * bundled together with other notifications (e.g. on a wearable
+ * device).
+ */
+ static String buildNotificationTag(final int type, final String conversationId,
+ final boolean bundledNotification) {
+ String tag = null;
+ switch(type) {
+ case PendingIntentConstants.SMS_NOTIFICATION_ID:
+ if (bundledNotification) {
+ tag = buildNotificationTag(SMS_NOTIFICATION_TAG, conversationId);
+ } else {
+ tag = buildNotificationTag(SMS_NOTIFICATION_TAG, null);
+ }
+ break;
+ case PendingIntentConstants.MSG_SEND_ERROR:
+ tag = buildNotificationTag(SMS_ERROR_NOTIFICATION_TAG, null);
+ break;
+ }
+ return tag;
+ }
+
+ private static void checkInitialized() {
+ if (!sInitialized) {
+ final Resources resources = Factory.get().getApplicationContext().getResources();
+ sWearableImageWidth = resources.getDimensionPixelSize(
+ R.dimen.notification_wearable_image_width);
+ sWearableImageHeight = resources.getDimensionPixelSize(
+ R.dimen.notification_wearable_image_height);
+ sIconHeight = (int) resources.getDimension(
+ android.R.dimen.notification_large_icon_height);
+ sIconWidth =
+ (int) resources.getDimension(android.R.dimen.notification_large_icon_width);
+
+ sInitialized = true;
+ }
+ }
+
+ private static void processAndSend(final NotificationState state, final boolean silent,
+ final boolean softSound) {
+ final Context context = Factory.get().getApplicationContext();
+ final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context);
+ notifBuilder.setCategory(Notification.CATEGORY_MESSAGE);
+ // TODO: Need to fix this for multi conversation notifications to rate limit dings.
+ final String conversationId = state.mConversationIds.first();
+
+
+ final Uri ringtoneUri = RingtoneUtil.getNotificationRingtoneUri(state.getRingtoneUri());
+ // If the notification's conversation is currently observable (focused or in the
+ // conversation list), then play a notification beep at a low volume and don't display an
+ // actual notification.
+ if (softSound) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "processAndSend: fromConversationId == " +
+ "sCurrentlyDisplayedConversationId so NOT showing notification," +
+ " but playing soft sound. conversationId: " + conversationId);
+ }
+ playObservableConversationNotificationSound(ringtoneUri);
+ return;
+ }
+ state.mBaseRequestCode = state.mType;
+
+ // Set the delete intent (except for bundled wearable notifications, which are dismissed
+ // as a group, either from the wearable or when the summary notification is dismissed from
+ // the host device).
+ if (!(state instanceof BundledMessageNotificationState)) {
+ final PendingIntent clearIntent = state.getClearIntent();
+ notifBuilder.setDeleteIntent(clearIntent);
+ }
+
+ updateBuilderAudioVibrate(state, notifBuilder, silent, ringtoneUri, conversationId);
+
+ // Set the content intent
+ PendingIntent destinationIntent;
+ if (state.mConversationIds.size() > 1) {
+ // We have notifications for multiple conversation, go to the conversation list.
+ destinationIntent = UIIntents.get()
+ .getPendingIntentForConversationListActivity(context);
+ } else {
+ // We have a single conversation, go directly to that conversation.
+ destinationIntent = UIIntents.get()
+ .getPendingIntentForConversationActivity(context,
+ state.mConversationIds.first(),
+ null /*draft*/);
+ }
+ notifBuilder.setContentIntent(destinationIntent);
+
+ // TODO: set based on contact coming from a favorite.
+ notifBuilder.setPriority(state.getPriority());
+
+ // Save the state of the notification in-progress so when the avatar is loaded,
+ // we can continue building the notification.
+ final NotificationCompat.Style notifStyle = state.build(notifBuilder);
+ state.mNotificationBuilder = notifBuilder;
+ state.mNotificationStyle = notifStyle;
+ if (!state.mPeople.isEmpty()) {
+ final Bundle people = new Bundle();
+ people.putStringArray(NotificationCompat.EXTRA_PEOPLE,
+ state.mPeople.toArray(new String[state.mPeople.size()]));
+ notifBuilder.addExtras(people);
+ }
+
+ if (state.mParticipantAvatarsUris != null) {
+ final Uri avatarUri = state.mParticipantAvatarsUris.get(0);
+ final AvatarRequestDescriptor descriptor = new AvatarRequestDescriptor(avatarUri,
+ sIconWidth, sIconHeight, OsUtil.isAtLeastL());
+ final MediaRequest<ImageResource> imageRequest = descriptor.buildSyncMediaRequest(
+ context);
+
+ synchronized (sPendingNotifications) {
+ sPendingNotifications.add(state);
+ }
+
+ // Synchronously load the avatar.
+ final ImageResource avatarImage =
+ MediaResourceManager.get().requestMediaResourceSync(imageRequest);
+ if (avatarImage != null) {
+ ImageResource avatarHiRes = null;
+ try {
+ if (isWearCompanionAppInstalled()) {
+ // For Wear users, we need to request a high-res avatar image to use as the
+ // notification card background. If the sender has a contact photo, we'll
+ // request the display photo from the Contacts provider. Otherwise, we ask
+ // the local content provider for a hi-res version of the generic avatar
+ // (e.g. letter with colored background).
+ avatarHiRes = requestContactDisplayPhoto(context,
+ getDisplayPhotoUri(avatarUri));
+ if (avatarHiRes == null) {
+ final AvatarRequestDescriptor hiResDesc =
+ new AvatarRequestDescriptor(avatarUri,
+ sWearableImageWidth,
+ sWearableImageHeight,
+ false /* cropToCircle */,
+ true /* isWearBackground */);
+ avatarHiRes = MediaResourceManager.get().requestMediaResourceSync(
+ hiResDesc.buildSyncMediaRequest(context));
+ }
+ }
+
+ // We have to make copies of the bitmaps to hand to the NotificationManager
+ // because the bitmap in the ImageResource is managed and will automatically
+ // get released.
+ Bitmap avatarBitmap = Bitmap.createBitmap(avatarImage.getBitmap());
+ Bitmap avatarHiResBitmap = (avatarHiRes != null) ?
+ Bitmap.createBitmap(avatarHiRes.getBitmap()) : null;
+ sendNotification(state, avatarBitmap, avatarHiResBitmap);
+ return;
+ } finally {
+ avatarImage.release();
+ if (avatarHiRes != null) {
+ avatarHiRes.release();
+ }
+ }
+ }
+ }
+ // We have no avatar. Post the notification anyway.
+ sendNotification(state, null, null);
+ }
+
+ /**
+ * Returns the thumbnailUri from the avatar URI, or null if avatar URI does not have thumbnail.
+ */
+ private static Uri getThumbnailUri(final Uri avatarUri) {
+ Uri localUri = null;
+ final String avatarType = AvatarUriUtil.getAvatarType(avatarUri);
+ if (TextUtils.equals(avatarType, AvatarUriUtil.TYPE_LOCAL_RESOURCE_URI)) {
+ localUri = AvatarUriUtil.getPrimaryUri(avatarUri);
+ } else if (UriUtil.isLocalResourceUri(avatarUri)) {
+ localUri = avatarUri;
+ }
+ if (localUri != null && localUri.getAuthority().equals(ContactsContract.AUTHORITY)) {
+ // Contact photos are of the form: content://com.android.contacts/contacts/123/photo
+ final List<String> pathParts = localUri.getPathSegments();
+ if (pathParts.size() == 3 &&
+ pathParts.get(2).equals(Contacts.Photo.CONTENT_DIRECTORY)) {
+ return localUri;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the displayPhotoUri from the avatar URI, or null if avatar URI
+ * does not have a displayPhotoUri.
+ */
+ private static Uri getDisplayPhotoUri(final Uri avatarUri) {
+ final Uri thumbnailUri = getThumbnailUri(avatarUri);
+ if (thumbnailUri == null) {
+ return null;
+ }
+ final List<String> originalPaths = thumbnailUri.getPathSegments();
+ final int originalPathsSize = originalPaths.size();
+ final StringBuilder newPathBuilder = new StringBuilder();
+ // Change content://com.android.contacts/contacts("_corp")/123/photo to
+ // content://com.android.contacts/contacts("_corp")/123/display_photo
+ for (int i = 0; i < originalPathsSize; i++) {
+ newPathBuilder.append('/');
+ if (i == 2) {
+ newPathBuilder.append(ContactsContract.Contacts.Photo.DISPLAY_PHOTO);
+ } else {
+ newPathBuilder.append(originalPaths.get(i));
+ }
+ }
+ return thumbnailUri.buildUpon().path(newPathBuilder.toString()).build();
+ }
+
+ private static ImageResource requestContactDisplayPhoto(final Context context,
+ final Uri displayPhotoUri) {
+ final UriImageRequestDescriptor bgDescriptor =
+ new UriImageRequestDescriptor(displayPhotoUri,
+ sWearableImageWidth,
+ sWearableImageHeight,
+ false, /* allowCompression */
+ true, /* isStatic */
+ false /* cropToCircle */,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ return MediaResourceManager.get().requestMediaResourceSync(
+ bgDescriptor.buildSyncMediaRequest(context));
+ }
+
+ private static void createMessageNotification(final boolean silent,
+ final String conversationId) {
+ final NotificationState state = MessageNotificationState.getNotificationState();
+ final boolean softSound = DataModel.get().isNewMessageObservable(conversationId);
+ if (state == null) {
+ cancel(PendingIntentConstants.SMS_NOTIFICATION_ID);
+ if (softSound && !TextUtils.isEmpty(conversationId)) {
+ final Uri ringtoneUri = getNotificationRingtoneUriForConversationId(conversationId);
+ playObservableConversationNotificationSound(ringtoneUri);
+ }
+ return;
+ }
+ processAndSend(state, silent, softSound);
+
+ // The rest of the logic here is for supporting Android Wear devices, specifically for when
+ // we are notifying about multiple conversations. In that case, the Inbox-style summary
+ // notification (which we already processed above) appears on the phone (as it always has),
+ // but wearables show per-conversation notifications, bundled together in a group.
+
+ // It is valid to replace a notification group with another group with fewer conversations,
+ // or even with one notification for a single conversation. In either case, we need to
+ // explicitly cancel any children from the old group which are not being notified about now.
+ final Context context = Factory.get().getApplicationContext();
+ final ConversationIdSet oldGroupChildIds = getGroupChildIds(context);
+ if (oldGroupChildIds != null && oldGroupChildIds.size() > 0) {
+ cancelStaleGroupChildren(oldGroupChildIds, state);
+ }
+
+ // Send per-conversation notifications (if there are multiple conversations).
+ final ConversationIdSet groupChildIds = new ConversationIdSet();
+ if (state instanceof MultiConversationNotificationState) {
+ for (final NotificationState child :
+ ((MultiConversationNotificationState) state).mChildren) {
+ processAndSend(child, true /* silent */, softSound);
+ if (child.mConversationIds != null) {
+ groupChildIds.add(child.mConversationIds.first());
+ }
+ }
+ }
+
+ // Record the new set of group children.
+ writeGroupChildIds(context, groupChildIds);
+ }
+
+ private static void updateBuilderAudioVibrate(final NotificationState state,
+ final NotificationCompat.Builder notifBuilder, final boolean silent,
+ final Uri ringtoneUri, final String conversationId) {
+ int defaults = Notification.DEFAULT_LIGHTS;
+ if (!silent) {
+ final BuglePrefs prefs = Factory.get().getApplicationPrefs();
+ final long latestNotificationTimestamp = prefs.getLong(
+ BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP, Long.MIN_VALUE);
+ final long latestReceivedTimestamp = state.getLatestReceivedTimestamp();
+ prefs.putLong(
+ BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP,
+ Math.max(latestNotificationTimestamp, latestReceivedTimestamp));
+ if (latestReceivedTimestamp > latestNotificationTimestamp) {
+ synchronized (mLock) {
+ // Find out the last time we dinged for this conversation
+ Long lastTime = sLastMessageDingTime.get(conversationId);
+ if (sTimeBetweenDingsMs == 0) {
+ sTimeBetweenDingsMs = BugleGservices.get().getInt(
+ BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS,
+ BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS_DEFAULT) *
+ 1000;
+ }
+ if (lastTime == null
+ || SystemClock.elapsedRealtime() - lastTime > sTimeBetweenDingsMs) {
+ sLastMessageDingTime.put(conversationId, SystemClock.elapsedRealtime());
+ notifBuilder.setSound(ringtoneUri);
+ if (shouldVibrate(state)) {
+ defaults |= Notification.DEFAULT_VIBRATE;
+ }
+ }
+ }
+ }
+ }
+ notifBuilder.setDefaults(defaults);
+ }
+
+ // TODO: this doesn't seem to be defined in NotificationCompat yet. Temporarily
+ // define it here until it makes its way from Notification -> NotificationCompat.
+ /**
+ * Notification category: incoming direct message (SMS, instant message, etc.).
+ */
+ private static final String CATEGORY_MESSAGE = "msg";
+
+ private static void sendNotification(final NotificationState notificationState,
+ final Bitmap avatarIcon, final Bitmap avatarHiRes) {
+ final Context context = Factory.get().getApplicationContext();
+ if (notificationState.mCanceled) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "sendNotification: Notification already cancelled; dropping it");
+ }
+ return;
+ }
+
+ synchronized (sPendingNotifications) {
+ if (sPendingNotifications.contains(notificationState)) {
+ sPendingNotifications.remove(notificationState);
+ }
+ }
+
+ notificationState.mNotificationBuilder
+ .setSmallIcon(notificationState.getIcon())
+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
+ .setColor(context.getResources().getColor(R.color.notification_accent_color))
+// .setPublicVersion(null) // TODO: when/if we ever support different
+ // text on the lockscreen, instead of "contents hidden"
+ .setCategory(CATEGORY_MESSAGE);
+
+ if (avatarIcon != null) {
+ notificationState.mNotificationBuilder.setLargeIcon(avatarIcon);
+ }
+
+ if (notificationState.mParticipantContactUris != null &&
+ notificationState.mParticipantContactUris.size() > 0) {
+ for (final Uri contactUri : notificationState.mParticipantContactUris) {
+ notificationState.mNotificationBuilder.addPerson(contactUri.toString());
+ }
+ }
+
+ final Uri attachmentUri = notificationState.getAttachmentUri();
+ final String attachmentType = notificationState.getAttachmentType();
+ Bitmap attachmentBitmap = null;
+
+ // For messages with photo/video attachment, request an image to show in the notification.
+ if (attachmentUri != null && notificationState.mNotificationStyle != null &&
+ (notificationState.mNotificationStyle instanceof
+ NotificationCompat.BigPictureStyle) &&
+ (ContentType.isImageType(attachmentType) ||
+ ContentType.isVideoType(attachmentType))) {
+ final boolean isVideo = ContentType.isVideoType(attachmentType);
+
+ MediaRequest<ImageResource> imageRequest;
+ if (isVideo) {
+ Assert.isTrue(VideoThumbnailRequest.shouldShowIncomingVideoThumbnails());
+ final MessagePartVideoThumbnailRequestDescriptor videoDescriptor =
+ new MessagePartVideoThumbnailRequestDescriptor(attachmentUri);
+ imageRequest = videoDescriptor.buildSyncMediaRequest(context);
+ } else {
+ final UriImageRequestDescriptor imageDescriptor =
+ new UriImageRequestDescriptor(attachmentUri,
+ sWearableImageWidth,
+ sWearableImageHeight,
+ false /* allowCompression */,
+ true /* isStatic */,
+ false /* cropToCircle */,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ imageRequest = imageDescriptor.buildSyncMediaRequest(context);
+ }
+ final ImageResource imageResource =
+ MediaResourceManager.get().requestMediaResourceSync(imageRequest);
+ if (imageResource != null) {
+ try {
+ // Copy the bitmap, because the one in the ImageResource is managed by
+ // MediaResourceManager.
+ Bitmap imageResourceBitmap = imageResource.getBitmap();
+ Config config = imageResourceBitmap.getConfig();
+
+ // Make sure our bitmap has a valid format.
+ if (config == null) {
+ config = Bitmap.Config.ARGB_8888;
+ }
+ attachmentBitmap = imageResourceBitmap.copy(config, true);
+ } finally {
+ imageResource.release();
+ }
+ }
+ }
+
+ fireOffNotification(notificationState, attachmentBitmap, avatarIcon, avatarHiRes);
+ }
+
+ private static void fireOffNotification(final NotificationState notificationState,
+ final Bitmap attachmentBitmap, final Bitmap avatarBitmap, Bitmap avatarHiResBitmap) {
+ if (notificationState.mCanceled) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Firing off notification, but notification already canceled");
+ }
+ return;
+ }
+
+ final Context context = Factory.get().getApplicationContext();
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "MMS picture loaded, bitmap: " + attachmentBitmap);
+ }
+
+ final NotificationCompat.Builder notifBuilder = notificationState.mNotificationBuilder;
+ notifBuilder.setStyle(notificationState.mNotificationStyle);
+ notifBuilder.setColor(context.getResources().getColor(R.color.notification_accent_color));
+
+ final WearableExtender wearableExtender = new WearableExtender();
+ setWearableGroupOptions(notifBuilder, notificationState);
+
+ if (avatarHiResBitmap != null) {
+ wearableExtender.setBackground(avatarHiResBitmap);
+ } else if (avatarBitmap != null) {
+ // Nothing to do here; we already set avatarBitmap as the notification icon
+ } else {
+ final Bitmap defaultBackground = BitmapFactory.decodeResource(
+ context.getResources(), R.drawable.bg_sms);
+ wearableExtender.setBackground(defaultBackground);
+ }
+
+ if (notificationState instanceof MultiMessageNotificationState) {
+ if (attachmentBitmap != null) {
+ // When we've got a picture attachment, we do some switcheroo trickery. When
+ // the notification is expanded, we show the picture as a bigPicture. The small
+ // icon shows the sender's avatar. When that same notification is collapsed, the
+ // picture is shown in the location where the avatar is normally shown. The lines
+ // below make all that happen.
+
+ // Here we're taking the picture attachment and making a small, scaled, center
+ // cropped version of the picture we can stuff into the place where the avatar
+ // goes when the notification is collapsed.
+ final Bitmap smallBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap, sIconWidth,
+ sIconHeight);
+ ((NotificationCompat.BigPictureStyle) notificationState.mNotificationStyle)
+ .bigPicture(attachmentBitmap)
+ .bigLargeIcon(avatarBitmap);
+ notificationState.mNotificationBuilder.setLargeIcon(smallBitmap);
+
+ // Add a wearable page with no visible card so you can more easily see the photo.
+ final NotificationCompat.Builder photoPageNotifBuilder =
+ new NotificationCompat.Builder(Factory.get().getApplicationContext());
+ final WearableExtender photoPageWearableExtender = new WearableExtender();
+ photoPageWearableExtender.setHintShowBackgroundOnly(true);
+ if (attachmentBitmap != null) {
+ final Bitmap wearBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap,
+ sWearableImageWidth, sWearableImageHeight);
+ photoPageWearableExtender.setBackground(wearBitmap);
+ }
+ photoPageNotifBuilder.extend(photoPageWearableExtender);
+ wearableExtender.addPage(photoPageNotifBuilder.build());
+ }
+
+ maybeAddWearableConversationLog(wearableExtender,
+ (MultiMessageNotificationState) notificationState);
+ addDownloadMmsAction(notifBuilder, wearableExtender, notificationState);
+ addWearableVoiceReplyAction(wearableExtender, notificationState);
+ }
+
+ // Apply the wearable options and build & post the notification
+ notifBuilder.extend(wearableExtender);
+ doNotify(notifBuilder.build(), notificationState);
+ }
+
+ private static void setWearableGroupOptions(final NotificationCompat.Builder notifBuilder,
+ final NotificationState notificationState) {
+ final String groupKey = "groupkey";
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Group key (for wearables)=" + groupKey);
+ }
+ if (notificationState instanceof MultiConversationNotificationState) {
+ notifBuilder.setGroup(groupKey).setGroupSummary(true);
+ } else if (notificationState instanceof BundledMessageNotificationState) {
+ final int order = ((BundledMessageNotificationState) notificationState).mGroupOrder;
+ // Convert the order to a zero-padded string ("00", "01", "02", etc).
+ // The Wear library orders notifications within a bundle lexicographically
+ // by the sort key, hence the need for zeroes to preserve the ordering.
+ final String sortKey = String.format(Locale.US, "%02d", order);
+ notifBuilder.setGroup(groupKey).setSortKey(sortKey);
+ }
+ }
+
+ private static void maybeAddWearableConversationLog(
+ final WearableExtender wearableExtender,
+ final MultiMessageNotificationState notificationState) {
+ if (!isWearCompanionAppInstalled()) {
+ return;
+ }
+ final String convId = notificationState.mConversationIds.first();
+ ConversationLineInfo convInfo = notificationState.mConvList.mConvInfos.get(0);
+ final Notification page = MessageNotificationState.buildConversationPageForWearable(
+ convId,
+ convInfo.mParticipantCount);
+ if (page != null) {
+ wearableExtender.addPage(page);
+ }
+ }
+
+ private static void addWearableVoiceReplyAction(
+ final WearableExtender wearableExtender, final NotificationState notificationState) {
+ if (!(notificationState instanceof MultiMessageNotificationState)) {
+ return;
+ }
+ final MultiMessageNotificationState multiMessageNotificationState =
+ (MultiMessageNotificationState) notificationState;
+ final Context context = Factory.get().getApplicationContext();
+
+ final String conversationId = notificationState.mConversationIds.first();
+ final ConversationLineInfo convInfo =
+ multiMessageNotificationState.mConvList.mConvInfos.get(0);
+ final String selfId = convInfo.mSelfParticipantId;
+
+ final boolean requiresMms =
+ MmsSmsUtils.getRequireMmsForEmailAddress(
+ convInfo.mIncludeEmailAddress, convInfo.mSubId) ||
+ (convInfo.mIsGroup && MmsUtils.groupMmsEnabled(convInfo.mSubId));
+
+ final int requestCode = multiMessageNotificationState.getReplyIntentRequestCode();
+ final PendingIntent replyPendingIntent = UIIntents.get()
+ .getPendingIntentForSendingMessageToConversation(context,
+ conversationId, selfId, requiresMms, requestCode);
+
+ final int replyLabelRes = requiresMms ? R.string.notification_reply_via_mms :
+ R.string.notification_reply_via_sms;
+
+ final NotificationCompat.Action.Builder actionBuilder =
+ new NotificationCompat.Action.Builder(R.drawable.ic_wear_reply,
+ context.getString(replyLabelRes), replyPendingIntent);
+ final String[] choices = context.getResources().getStringArray(
+ R.array.notification_reply_choices);
+ final RemoteInput remoteInput = new RemoteInput.Builder(Intent.EXTRA_TEXT).setLabel(
+ context.getString(R.string.notification_reply_prompt)).
+ setChoices(choices)
+ .build();
+ actionBuilder.addRemoteInput(remoteInput);
+ wearableExtender.addAction(actionBuilder.build());
+ }
+
+ private static void addDownloadMmsAction(final NotificationCompat.Builder notifBuilder,
+ final WearableExtender wearableExtender, final NotificationState notificationState) {
+ if (!(notificationState instanceof MultiMessageNotificationState)) {
+ return;
+ }
+ final MultiMessageNotificationState multiMessageNotificationState =
+ (MultiMessageNotificationState) notificationState;
+ final ConversationLineInfo convInfo =
+ multiMessageNotificationState.mConvList.mConvInfos.get(0);
+ if (!convInfo.getDoesLatestMessageNeedDownload()) {
+ return;
+ }
+ final String messageId = convInfo.getLatestMessageId();
+ if (messageId == null) {
+ // No message Id, no download for you
+ return;
+ }
+ final Context context = Factory.get().getApplicationContext();
+ final PendingIntent downloadPendingIntent =
+ RedownloadMmsAction.getPendingIntentForRedownloadMms(context, messageId);
+
+ final NotificationCompat.Action.Builder actionBuilder =
+ new NotificationCompat.Action.Builder(R.drawable.ic_file_download_light,
+ context.getString(R.string.notification_download_mms),
+ downloadPendingIntent);
+ final NotificationCompat.Action downloadAction = actionBuilder.build();
+ notifBuilder.addAction(downloadAction);
+
+ // Support the action on a wearable device as well
+ wearableExtender.addAction(downloadAction);
+ }
+
+ private static synchronized void doNotify(final Notification notification,
+ final NotificationState notificationState) {
+ if (notification == null) {
+ return;
+ }
+ final int type = notificationState.mType;
+ final ConversationIdSet conversationIds = notificationState.mConversationIds;
+ final boolean isBundledNotification =
+ (notificationState instanceof BundledMessageNotificationState);
+
+ // Mark the notification as finished
+ notificationState.mCanceled = true;
+
+ final NotificationManagerCompat notificationManager =
+ NotificationManagerCompat.from(Factory.get().getApplicationContext());
+ // Only need conversationId for tags with a single conversation.
+ String conversationId = null;
+ if (conversationIds != null && conversationIds.size() == 1) {
+ conversationId = conversationIds.first();
+ }
+ final String notificationTag = buildNotificationTag(type,
+ conversationId, isBundledNotification);
+
+ notification.flags |= Notification.FLAG_AUTO_CANCEL;
+ notification.defaults |= Notification.DEFAULT_LIGHTS;
+
+ notificationManager.notify(notificationTag, type, notification);
+
+ LogUtil.i(TAG, "Notifying for conversation " + conversationId + "; "
+ + "tag = " + notificationTag + ", type = " + type);
+ }
+
+ // This is the message string used in each line of an inboxStyle notification.
+ // TODO: add attachment type
+ static CharSequence formatInboxMessage(final String sender,
+ final CharSequence message, final Uri attachmentUri, final String attachmentType) {
+ final Context context = Factory.get().getApplicationContext();
+ final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan(
+ context, R.style.NotificationSenderText);
+
+ final TextAppearanceSpan notificationTertiaryText = new TextAppearanceSpan(
+ context, R.style.NotificationTertiaryText);
+
+ final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();
+ if (!TextUtils.isEmpty(sender)) {
+ spannableStringBuilder.append(sender);
+ spannableStringBuilder.setSpan(notificationSenderSpan, 0, sender.length(), 0);
+ }
+ final String separator = context.getString(R.string.notification_separator);
+
+ if (!TextUtils.isEmpty(message)) {
+ if (spannableStringBuilder.length() > 0) {
+ spannableStringBuilder.append(separator);
+ }
+ final int start = spannableStringBuilder.length();
+ spannableStringBuilder.append(message);
+ spannableStringBuilder.setSpan(notificationTertiaryText, start,
+ start + message.length(), 0);
+ }
+ if (attachmentUri != null) {
+ if (spannableStringBuilder.length() > 0) {
+ spannableStringBuilder.append(separator);
+ }
+ spannableStringBuilder.append(formatAttachmentTag(null, attachmentType));
+ }
+ return spannableStringBuilder;
+ }
+
+ protected static CharSequence buildColonSeparatedMessage(
+ final String title, final CharSequence content, final Uri attachmentUri,
+ final String attachmentType) {
+ return buildBoldedMessage(title, content, attachmentUri, attachmentType,
+ R.string.notification_ticker_separator);
+ }
+
+ protected static CharSequence buildSpaceSeparatedMessage(
+ final String title, final CharSequence content, final Uri attachmentUri,
+ final String attachmentType) {
+ return buildBoldedMessage(title, content, attachmentUri, attachmentType,
+ R.string.notification_space_separator);
+ }
+
+ /**
+ * buildBoldedMessage - build a formatted message where the title is bold, there's a
+ * separator, then the message.
+ */
+ private static CharSequence buildBoldedMessage(
+ final String title, final CharSequence message, final Uri attachmentUri,
+ final String attachmentType,
+ final int separatorId) {
+ final Context context = Factory.get().getApplicationContext();
+ final SpannableStringBuilder spanBuilder = new SpannableStringBuilder();
+
+ // Boldify the title (which is the sender's name)
+ if (!TextUtils.isEmpty(title)) {
+ spanBuilder.append(title);
+ spanBuilder.setSpan(new StyleSpan(Typeface.BOLD), 0, title.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (!TextUtils.isEmpty(message)) {
+ if (spanBuilder.length() > 0) {
+ spanBuilder.append(context.getString(separatorId));
+ }
+ spanBuilder.append(message);
+ }
+ if (attachmentUri != null) {
+ if (spanBuilder.length() > 0) {
+ final String separator = context.getString(R.string.notification_separator);
+ spanBuilder.append(separator);
+ }
+ spanBuilder.append(formatAttachmentTag(null, attachmentType));
+ }
+ return spanBuilder;
+ }
+
+ static CharSequence formatAttachmentTag(final String author, final String attachmentType) {
+ final Context context = Factory.get().getApplicationContext();
+ final TextAppearanceSpan notificationSecondaryText = new TextAppearanceSpan(
+ context, R.style.NotificationSecondaryText);
+ final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();
+ if (!TextUtils.isEmpty(author)) {
+ final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan(
+ context, R.style.NotificationSenderText);
+ spannableStringBuilder.append(author);
+ spannableStringBuilder.setSpan(notificationSenderSpan, 0, author.length(), 0);
+ final String separator = context.getString(R.string.notification_separator);
+ spannableStringBuilder.append(separator);
+ }
+ final int start = spannableStringBuilder.length();
+ // The default attachment type is an image, since that's what was originally
+ // supported. When there's no content type, assume it's an image.
+ int message = R.string.notification_picture;
+ if (ContentType.isAudioType(attachmentType)) {
+ message = R.string.notification_audio;
+ } else if (ContentType.isVideoType(attachmentType)) {
+ message = R.string.notification_video;
+ } else if (ContentType.isVCardType(attachmentType)) {
+ message = R.string.notification_vcard;
+ }
+ spannableStringBuilder.append(context.getText(message));
+ spannableStringBuilder.setSpan(notificationSecondaryText, start,
+ spannableStringBuilder.length(), 0);
+ return spannableStringBuilder;
+ }
+
+ /**
+ * Play the observable conversation notification sound (it's the regular notification sound, but
+ * played at half-volume)
+ */
+ private static void playObservableConversationNotificationSound(final Uri ringtoneUri) {
+ final Context context = Factory.get().getApplicationContext();
+ final AudioManager audioManager = (AudioManager) context
+ .getSystemService(Context.AUDIO_SERVICE);
+ final boolean silenced =
+ audioManager.getRingerMode() != AudioManager.RINGER_MODE_NORMAL;
+ if (silenced) {
+ return;
+ }
+
+ final NotificationPlayer player = new NotificationPlayer(LogUtil.BUGLE_TAG);
+ player.play(ringtoneUri, false,
+ AudioManager.STREAM_NOTIFICATION,
+ OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME);
+
+ // Stop the sound after five seconds to handle continuous ringtones
+ ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ player.stop();
+ }
+ }, 5000);
+ }
+
+ public static boolean isWearCompanionAppInstalled() {
+ boolean found = false;
+ try {
+ Factory.get().getApplicationContext().getPackageManager()
+ .getPackageInfo(WEARABLE_COMPANION_APP_PACKAGE, 0);
+ found = true;
+ } catch (final NameNotFoundException e) {
+ // Ignore; found is already false
+ }
+ return found;
+ }
+
+ /**
+ * When we go to the conversation list, call this to mark all messages as seen. That means
+ * we won't show a notification again for the same message.
+ */
+ public static void markAllMessagesAsSeen() {
+ MarkAsSeenAction.markAllAsSeen();
+ resetLastMessageDing(null); // reset the ding timeout for all conversations
+ }
+
+ /**
+ * When we open a particular conversation, call this to mark all messages as read.
+ */
+ public static void markMessagesAsRead(final String conversationId) {
+ MarkAsReadAction.markAsRead(conversationId);
+ resetLastMessageDing(conversationId);
+ }
+
+ /**
+ * Returns the conversation ids of all active, grouped notifications, or
+ * {code null} if no notifications are currently active and grouped.
+ */
+ private static ConversationIdSet getGroupChildIds(final Context context) {
+ final String prefKey = context.getString(R.string.notifications_group_children_key);
+ final String groupChildIdsText = BuglePrefs.getApplicationPrefs().getString(prefKey, "");
+ if (!TextUtils.isEmpty(groupChildIdsText)) {
+ return ConversationIdSet.createSet(groupChildIdsText);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Records the conversation ids of the currently active grouped notifications.
+ */
+ private static void writeGroupChildIds(final Context context,
+ final ConversationIdSet childIds) {
+ final ConversationIdSet oldChildIds = getGroupChildIds(context);
+ if (childIds.equals(oldChildIds)) {
+ return;
+ }
+ final String prefKey = context.getString(R.string.notifications_group_children_key);
+ BuglePrefs.getApplicationPrefs().putString(prefKey, childIds.getDelimitedString());
+ }
+
+ /**
+ * Reset the timer for a notification ding on a particular conversation or all conversations.
+ */
+ public static void resetLastMessageDing(final String conversationId) {
+ synchronized (mLock) {
+ if (TextUtils.isEmpty(conversationId)) {
+ // reset all conversation dings
+ sLastMessageDingTime.clear();
+ } else {
+ sLastMessageDingTime.remove(conversationId);
+ }
+ }
+ }
+
+ public static void notifyEmergencySmsFailed(final String emergencyNumber,
+ final String conversationId) {
+ final Context context = Factory.get().getApplicationContext();
+
+ final CharSequence line1 = MessageNotificationState.applyWarningTextColor(context,
+ context.getString(R.string.notification_emergency_send_failure_line1,
+ emergencyNumber));
+ final String line2 = context.getString(R.string.notification_emergency_send_failure_line2,
+ emergencyNumber);
+ final PendingIntent destinationIntent = UIIntents.get()
+ .getPendingIntentForConversationActivity(context, conversationId, null /* draft */);
+
+ final NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
+ builder.setTicker(line1)
+ .setContentTitle(line1)
+ .setContentText(line2)
+ .setStyle(new NotificationCompat.BigTextStyle(builder).bigText(line2))
+ .setSmallIcon(R.drawable.ic_failed_light)
+ .setContentIntent(destinationIntent)
+ .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure));
+
+ final String tag = context.getPackageName() + ":emergency_sms_error";
+ NotificationManagerCompat.from(context).notify(
+ tag,
+ PendingIntentConstants.MSG_SEND_ERROR,
+ builder.build());
+ }
+}
+
diff --git a/src/com/android/messaging/datamodel/BugleRecipientEntry.java b/src/com/android/messaging/datamodel/BugleRecipientEntry.java
new file mode 100644
index 0000000..2a9e5ff
--- /dev/null
+++ b/src/com/android/messaging/datamodel/BugleRecipientEntry.java
@@ -0,0 +1,64 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.net.Uri;
+import android.text.TextUtils;
+
+import com.android.ex.chips.RecipientEntry;
+
+/**
+ * An extension of RecipientEntry for Bugle's use since Bugle uses phone numbers to identify
+ * participants / recipients instead of contact ids. This allows the user to send to multiple
+ * phone numbers of the same contact.
+ */
+public class BugleRecipientEntry extends RecipientEntry {
+
+ protected BugleRecipientEntry(final int entryType, final String displayName,
+ final String destination, final int destinationType, final String destinationLabel,
+ final long contactId, final Long directoryId, final long dataId,
+ final Uri photoThumbnailUri, final boolean isFirstLevel, final boolean isValid,
+ final String lookupKey) {
+ super(entryType, displayName, destination, destinationType, destinationLabel, contactId,
+ directoryId, dataId, photoThumbnailUri, isFirstLevel, isValid, lookupKey);
+ }
+
+ public static BugleRecipientEntry constructTopLevelEntry(final String displayName,
+ final int displayNameSource, final String destination, final int destinationType,
+ final String destinationLabel, final long contactId, final Long directoryId,
+ final long dataId, final String thumbnailUriAsString, final boolean isValid,
+ final String lookupKey) {
+ return new BugleRecipientEntry(ENTRY_TYPE_PERSON, displayName, destination, destinationType,
+ destinationLabel, contactId, directoryId, dataId, (thumbnailUriAsString != null
+ ? Uri.parse(thumbnailUriAsString) : null), true, isValid, lookupKey);
+ }
+
+ public static BugleRecipientEntry constructSecondLevelEntry(final String displayName,
+ final int displayNameSource, final String destination, final int destinationType,
+ final String destinationLabel, final long contactId, final Long directoryId,
+ final long dataId, final String thumbnailUriAsString, final boolean isValid,
+ final String lookupKey) {
+ return new BugleRecipientEntry(ENTRY_TYPE_PERSON, displayName, destination, destinationType,
+ destinationLabel, contactId, directoryId, dataId, (thumbnailUriAsString != null
+ ? Uri.parse(thumbnailUriAsString) : null), false, isValid, lookupKey);
+ }
+
+ @Override
+ public boolean isSamePerson(final RecipientEntry entry) {
+ return getDestination() != null && entry.getDestination() != null &&
+ TextUtils.equals(getDestination(), entry.getDestination());
+ }
+}
diff --git a/src/com/android/messaging/datamodel/ConversationImagePartsView.java b/src/com/android/messaging/datamodel/ConversationImagePartsView.java
new file mode 100644
index 0000000..70ba381
--- /dev/null
+++ b/src/com/android/messaging/datamodel/ConversationImagePartsView.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.datamodel;
+
+import android.provider.BaseColumns;
+
+import com.android.ex.photo.provider.PhotoContract.PhotoViewColumns;
+
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseHelper.PartColumns;
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
+import com.android.messaging.util.ContentType;
+
+/**
+ * View for the image parts for the conversation. It is used to provide the photoviewer with a
+ * a data source for all the photos in a conversation, so that the photoviewer can support paging
+ * through all the photos of the conversation. The columns of the view are a superset of
+ * {@link com.android.ex.photo.provider.PhotoContract.PhotoViewColumns}.
+ */
+public class ConversationImagePartsView {
+ private static final String VIEW_NAME = "conversation_image_parts_view";
+
+ private static final String CREATE_SQL = "CREATE VIEW " +
+ VIEW_NAME + " AS SELECT "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID
+ + " as " + Columns.CONVERSATION_ID + ", "
+ + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_URI
+ + " as " + Columns.URI + ", "
+ + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FULL_NAME
+ + " as " + Columns.SENDER_FULL_NAME + ", "
+ + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_URI
+ + " as " + Columns.CONTENT_URI + ", "
+ // Use NULL as the thumbnail uri
+ + " NULL as " + Columns.THUMBNAIL_URI + ", "
+ + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_TYPE
+ + " as " + Columns.CONTENT_TYPE + ", "
+ //
+ // Columns in addition to those specified by PhotoContract
+ //
+ + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION
+ + " as " + Columns.DISPLAY_DESTINATION + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP
+ + " as " + Columns.RECEIVED_TIMESTAMP + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS
+ + " as " + Columns.STATUS + " "
+
+ + " FROM " + DatabaseHelper.MESSAGES_TABLE + " LEFT JOIN " + DatabaseHelper.PARTS_TABLE
+ + " ON (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns._ID
+ + "=" + DatabaseHelper.PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ") "
+ + " LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE + " ON ("
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID
+ + '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns._ID + ")"
+
+ // "content_type like 'image/%'"
+ + " WHERE " + DatabaseHelper.PARTS_TABLE + "." + PartColumns.CONTENT_TYPE
+ + " like '" + ContentType.IMAGE_PREFIX + "%'"
+
+ + " ORDER BY "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " ASC, "
+ + DatabaseHelper.PARTS_TABLE + '.' + PartColumns._ID + " ASC";
+
+ static class Columns implements BaseColumns {
+ static final String CONVERSATION_ID = MessageColumns.CONVERSATION_ID;
+ static final String URI = PhotoViewColumns.URI;
+ static final String SENDER_FULL_NAME = PhotoViewColumns.NAME;
+ static final String CONTENT_URI = PhotoViewColumns.CONTENT_URI;
+ static final String THUMBNAIL_URI = PhotoViewColumns.THUMBNAIL_URI;
+ static final String CONTENT_TYPE = PhotoViewColumns.CONTENT_TYPE;
+ // Columns in addition to those specified by PhotoContract
+ static final String DISPLAY_DESTINATION = ParticipantColumns.DISPLAY_DESTINATION;
+ static final String RECEIVED_TIMESTAMP = MessageColumns.RECEIVED_TIMESTAMP;
+ static final String STATUS = MessageColumns.STATUS;
+ }
+
+ public interface PhotoViewQuery {
+ public final String[] PROJECTION = {
+ PhotoViewColumns.URI,
+ PhotoViewColumns.NAME,
+ PhotoViewColumns.CONTENT_URI,
+ PhotoViewColumns.THUMBNAIL_URI,
+ PhotoViewColumns.CONTENT_TYPE,
+ // Columns in addition to those specified by PhotoContract
+ Columns.DISPLAY_DESTINATION,
+ Columns.RECEIVED_TIMESTAMP,
+ Columns.STATUS,
+ };
+
+ public final int INDEX_URI = 0;
+ public final int INDEX_SENDER_FULL_NAME = 1;
+ public final int INDEX_CONTENT_URI = 2;
+ public final int INDEX_THUMBNAIL_URI = 3;
+ public final int INDEX_CONTENT_TYPE = 4;
+ // Columns in addition to those specified by PhotoContract
+ public final int INDEX_DISPLAY_DESTINATION = 5;
+ public final int INDEX_RECEIVED_TIMESTAMP = 6;
+ public final int INDEX_STATUS = 7;
+ }
+
+ static final String getViewName() {
+ return VIEW_NAME;
+ }
+
+ static final String getCreateSql() {
+ return CREATE_SQL;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/CursorQueryData.java b/src/com/android/messaging/datamodel/CursorQueryData.java
new file mode 100644
index 0000000..3e6a656
--- /dev/null
+++ b/src/com/android/messaging/datamodel/CursorQueryData.java
@@ -0,0 +1,82 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+
+import com.android.messaging.util.Assert;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Holds parameters and data (such as content URI) for performing queries on the content provider.
+ * This class could then be used to perform a query using either a BoundCursorLoader or querying
+ * on the content resolver directly.
+ *
+ * This class is used for cases where the way to load a cursor is not fixed. For example,
+ * when using ContactUtil to query for phone numbers, the ContactPickerFragment wants to use
+ * a CursorLoader to asynchronously load the data and tie in nicely with its data binding
+ * paradigm, whereas ContactRecipientAdapter wants to synchronously perform the query on the
+ * worker thread.
+ */
+public class CursorQueryData {
+ protected final Uri mUri;
+ protected final String[] mProjection;
+ protected final String mSelection;
+ protected final String[] mSelectionArgs;
+ protected final String mSortOrder;
+ protected final Context mContext;
+
+ public CursorQueryData(final Context context, final Uri uri, final String[] projection,
+ final String selection, final String[] selectionArgs, final String sortOrder) {
+ mContext = context;
+ mUri = uri;
+ mProjection = projection;
+ mSelection = selection;
+ mSelectionArgs = selectionArgs;
+ mSortOrder = sortOrder;
+ }
+
+ public BoundCursorLoader createBoundCursorLoader(final String bindingId) {
+ return new BoundCursorLoader(bindingId, mContext, mUri, mProjection, mSelection,
+ mSelectionArgs, mSortOrder);
+ }
+
+ public Cursor performSynchronousQuery() {
+ Assert.isNotMainThread();
+ if (mUri == null) {
+ // See {@link #getEmptyQueryData}
+ return null;
+ } else {
+ return mContext.getContentResolver().query(mUri, mProjection, mSelection,
+ mSelectionArgs, mSortOrder);
+ }
+ }
+
+ @VisibleForTesting
+ public Uri getUri() {
+ return mUri;
+ }
+
+ /**
+ * Representation of an invalid query. {@link #performSynchronousQuery} will return
+ * a null Cursor.
+ */
+ public static CursorQueryData getEmptyQueryData() {
+ return new CursorQueryData(null, null, null, null, null, null);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/DataModel.java b/src/com/android/messaging/datamodel/DataModel.java
new file mode 100644
index 0000000..936b51c
--- /dev/null
+++ b/src/com/android/messaging/datamodel/DataModel.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.messaging.datamodel;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.action.Action;
+import com.android.messaging.datamodel.action.ActionService;
+import com.android.messaging.datamodel.action.BackgroundWorker;
+import com.android.messaging.datamodel.data.BlockedParticipantsData;
+import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener;
+import com.android.messaging.datamodel.data.ContactListItemData;
+import com.android.messaging.datamodel.data.ContactPickerData;
+import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener;
+import com.android.messaging.datamodel.data.ConversationData;
+import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
+import com.android.messaging.datamodel.data.ConversationListData;
+import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.GalleryGridItemData;
+import com.android.messaging.datamodel.data.LaunchConversationData;
+import com.android.messaging.datamodel.data.LaunchConversationData.LaunchConversationDataListener;
+import com.android.messaging.datamodel.data.MediaPickerData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.data.ParticipantListItemData;
+import com.android.messaging.datamodel.data.PeopleAndOptionsData;
+import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener;
+import com.android.messaging.datamodel.data.PeopleOptionsItemData;
+import com.android.messaging.datamodel.data.SettingsData;
+import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
+import com.android.messaging.datamodel.data.SubscriptionListData;
+import com.android.messaging.datamodel.data.VCardContactItemData;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.ConnectivityUtil;
+
+public abstract class DataModel {
+ private String mFocusedConversation;
+ private boolean mConversationListScrolledToNewestConversation;
+
+ public static DataModel get() {
+ return Factory.get().getDataModel();
+ }
+
+ public static final void startActionService(final Action action) {
+ get().getActionService().startAction(action);
+ }
+
+ public static final void scheduleAction(final Action action,
+ final int code, final long delayMs) {
+ get().getActionService().scheduleAction(action, code, delayMs);
+ }
+
+ public abstract ConversationListData createConversationListData(final Context context,
+ final ConversationListDataListener listener, final boolean archivedMode);
+
+ public abstract ConversationData createConversationData(final Context context,
+ final ConversationDataListener listener, final String conversationId);
+
+ public abstract ContactListItemData createContactListItemData();
+
+ public abstract ContactPickerData createContactPickerData(final Context context,
+ final ContactPickerDataListener listener);
+
+ public abstract MediaPickerData createMediaPickerData(final Context context);
+
+ public abstract GalleryGridItemData createGalleryGridItemData();
+
+ public abstract LaunchConversationData createLaunchConversationData(
+ LaunchConversationDataListener listener);
+
+ public abstract PeopleOptionsItemData createPeopleOptionsItemData(final Context context);
+
+ public abstract PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId,
+ final Context context, final PeopleAndOptionsDataListener listener);
+
+ public abstract VCardContactItemData createVCardContactItemData(final Context context,
+ final MessagePartData data);
+
+ public abstract VCardContactItemData createVCardContactItemData(final Context context,
+ final Uri vCardUri);
+
+ public abstract ParticipantListItemData createParticipantListItemData(
+ final ParticipantData participant);
+
+ public abstract BlockedParticipantsData createBlockedParticipantsData(Context context,
+ BlockedParticipantsDataListener listener);
+
+ public abstract SubscriptionListData createSubscriptonListData(Context context);
+
+ public abstract SettingsData createSettingsData(Context context, SettingsDataListener listener);
+
+ public abstract DraftMessageData createDraftMessageData(String conversationId);
+
+ public abstract ActionService getActionService();
+
+ public abstract BackgroundWorker getBackgroundWorkerForActionService();
+
+ @DoesNotRunOnMainThread
+ public abstract DatabaseWrapper getDatabase();
+
+ // Allow DataModel to coordinate with activity lifetime events.
+ public abstract void onActivityResume();
+
+ abstract void onCreateTables(final SQLiteDatabase db);
+
+ public void setFocusedConversation(final String conversationId) {
+ mFocusedConversation = conversationId;
+ }
+
+ public boolean isFocusedConversation(final String conversationId) {
+ return !TextUtils.isEmpty(mFocusedConversation)
+ && TextUtils.equals(mFocusedConversation, conversationId);
+ }
+
+ public void setConversationListScrolledToNewestConversation(
+ final boolean scrolledToNewestConversation) {
+ mConversationListScrolledToNewestConversation = scrolledToNewestConversation;
+ }
+
+ public boolean isConversationListScrolledToNewestConversation() {
+ return mConversationListScrolledToNewestConversation;
+ }
+
+ /**
+ * If a new message is received in the specified conversation, will the user be able to
+ * observe it in some UI within the app?
+ * @param conversationId conversation with the new incoming message
+ */
+ public boolean isNewMessageObservable(final String conversationId) {
+ return isConversationListScrolledToNewestConversation()
+ || isFocusedConversation(conversationId);
+ }
+
+ public abstract void onApplicationCreated();
+
+ public abstract ConnectivityUtil getConnectivityUtil();
+
+ public abstract SyncManager getSyncManager();
+}
diff --git a/src/com/android/messaging/datamodel/DataModelException.java b/src/com/android/messaging/datamodel/DataModelException.java
new file mode 100644
index 0000000..7084438
--- /dev/null
+++ b/src/com/android/messaging/datamodel/DataModelException.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.datamodel;
+
+public class DataModelException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ private static final int FIRST = 100;
+
+ // ERRORS GENERATED INTERNALLY BY DATA MODEL.
+
+ // ERRORS RELATED WITH SMS.
+ public static final int ERROR_SMS_TEMPORARY_FAILURE = 116;
+ public static final int ERROR_SMS_PERMANENT_FAILURE = 117;
+ public static final int ERROR_MMS_TEMPORARY_FAILURE = 118;
+ public static final int ERROR_MMS_PERMANENT_UNKNOWN_FAILURE = 119;
+
+ // Request expired.
+ public static final int ERROR_EXPIRED = 120;
+ // Request canceled by user.
+ public static final int ERROR_CANCELED = 121;
+
+ public static final int ERROR_MOBILE_DATA_DISABLED = 123;
+ public static final int ERROR_MMS_SERVICE_BLOCKED = 124;
+ public static final int ERROR_MMS_INVALID_ADDRESS = 125;
+ public static final int ERROR_MMS_NETWORK_PROBLEM = 126;
+ public static final int ERROR_MMS_MESSAGE_NOT_FOUND = 127;
+ public static final int ERROR_MMS_MESSAGE_FORMAT_CORRUPT = 128;
+ public static final int ERROR_MMS_CONTENT_NOT_ACCEPTED = 129;
+ public static final int ERROR_MMS_MESSAGE_NOT_SUPPORTED = 130;
+ public static final int ERROR_MMS_REPLY_CHARGING_ERROR = 131;
+ public static final int ERROR_MMS_ADDRESS_HIDING_NOT_SUPPORTED = 132;
+ public static final int ERROR_MMS_LACK_OF_PREPAID = 133;
+ public static final int ERROR_MMS_CAN_NOT_PERSIST = 134;
+ public static final int ERROR_MMS_NO_AVAILABLE_APN = 135;
+ public static final int ERROR_MMS_INVALID_MESSAGE_TO_SEND = 136;
+ public static final int ERROR_MMS_INVALID_MESSAGE_RECEIVED = 137;
+ public static final int ERROR_MMS_NO_CONFIGURATION = 138;
+
+ private static final int LAST = 138;
+
+ private final boolean mIsInjection;
+ private final int mErrorCode;
+ private final String mMessage;
+ private final long mBackoff;
+
+ public DataModelException(final int errorCode, final Exception innerException,
+ final long backoff, final boolean injection, final String message) {
+ // Since some of the exceptions passed in may not be serializable, only record message
+ // instead of setting inner exception for Exception class. Otherwise, we will get
+ // serialization issues when we pass ServerRequestException as intent extra later.
+ if (errorCode < FIRST || errorCode > LAST) {
+ throw new IllegalArgumentException("error code out of range: " + errorCode);
+ }
+ mIsInjection = injection;
+ mErrorCode = errorCode;
+ if (innerException != null) {
+ mMessage = innerException.getMessage() + " -- " +
+ (mIsInjection ? "[INJECTED] -- " : "") + message;
+ } else {
+ mMessage = (mIsInjection ? "[INJECTED] -- " : "") + message;
+ }
+
+ mBackoff = backoff;
+ }
+
+ public DataModelException(final int errorCode) {
+ this(errorCode, null, 0, false, null);
+ }
+
+ public DataModelException(final int errorCode, final Exception innerException) {
+ this(errorCode, innerException, 0, false, null);
+ }
+
+ public DataModelException(final int errorCode, final String message) {
+ this(errorCode, null, 0, false, message);
+ }
+
+ @Override
+ public String getMessage() {
+ return mMessage;
+ }
+
+ public int getErrorCode() {
+ return mErrorCode;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/DataModelImpl.java b/src/com/android/messaging/datamodel/DataModelImpl.java
new file mode 100644
index 0000000..6ab3f00
--- /dev/null
+++ b/src/com/android/messaging/datamodel/DataModelImpl.java
@@ -0,0 +1,236 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.telephony.SubscriptionManager;
+
+import com.android.messaging.datamodel.action.ActionService;
+import com.android.messaging.datamodel.action.BackgroundWorker;
+import com.android.messaging.datamodel.action.FixupMessageStatusOnStartupAction;
+import com.android.messaging.datamodel.action.ProcessPendingMessagesAction;
+import com.android.messaging.datamodel.data.BlockedParticipantsData;
+import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener;
+import com.android.messaging.datamodel.data.ContactListItemData;
+import com.android.messaging.datamodel.data.ContactPickerData;
+import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener;
+import com.android.messaging.datamodel.data.ConversationData;
+import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
+import com.android.messaging.datamodel.data.ConversationListData;
+import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.GalleryGridItemData;
+import com.android.messaging.datamodel.data.LaunchConversationData;
+import com.android.messaging.datamodel.data.LaunchConversationData.LaunchConversationDataListener;
+import com.android.messaging.datamodel.data.MediaPickerData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.data.ParticipantListItemData;
+import com.android.messaging.datamodel.data.PeopleAndOptionsData;
+import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener;
+import com.android.messaging.datamodel.data.PeopleOptionsItemData;
+import com.android.messaging.datamodel.data.SettingsData;
+import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
+import com.android.messaging.datamodel.data.SubscriptionListData;
+import com.android.messaging.datamodel.data.VCardContactItemData;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.ConnectivityUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+public class DataModelImpl extends DataModel {
+ private final Context mContext;
+ private final ActionService mActionService;
+ private final BackgroundWorker mDataModelWorker;
+ private final DatabaseHelper mDatabaseHelper;
+ private final ConnectivityUtil mConnectivityUtil;
+ private final SyncManager mSyncManager;
+
+ public DataModelImpl(final Context context) {
+ super();
+ mContext = context;
+ mActionService = new ActionService();
+ mDataModelWorker = new BackgroundWorker();
+ mDatabaseHelper = DatabaseHelper.getInstance(context);
+ mConnectivityUtil = new ConnectivityUtil(context);
+ mSyncManager = new SyncManager();
+ }
+
+ @Override
+ public ConversationListData createConversationListData(final Context context,
+ final ConversationListDataListener listener, final boolean archivedMode) {
+ return new ConversationListData(context, listener, archivedMode);
+ }
+
+ @Override
+ public ConversationData createConversationData(final Context context,
+ final ConversationDataListener listener, final String conversationId) {
+ return new ConversationData(context, listener, conversationId);
+ }
+
+ @Override
+ public ContactListItemData createContactListItemData() {
+ return new ContactListItemData();
+ }
+
+ @Override
+ public ContactPickerData createContactPickerData(final Context context,
+ final ContactPickerDataListener listener) {
+ return new ContactPickerData(context, listener);
+ }
+
+ @Override
+ public BlockedParticipantsData createBlockedParticipantsData(
+ final Context context, final BlockedParticipantsDataListener listener) {
+ return new BlockedParticipantsData(context, listener);
+ }
+
+ @Override
+ public MediaPickerData createMediaPickerData(final Context context) {
+ return new MediaPickerData(context);
+ }
+
+ @Override
+ public GalleryGridItemData createGalleryGridItemData() {
+ return new GalleryGridItemData();
+ }
+
+ @Override
+ public LaunchConversationData createLaunchConversationData(
+ final LaunchConversationDataListener listener) {
+ return new LaunchConversationData(listener);
+ }
+
+ @Override
+ public PeopleOptionsItemData createPeopleOptionsItemData(final Context context) {
+ return new PeopleOptionsItemData(context);
+ }
+
+ @Override
+ public PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId,
+ final Context context, final PeopleAndOptionsDataListener listener) {
+ return new PeopleAndOptionsData(conversationId, context, listener);
+ }
+
+ @Override
+ public VCardContactItemData createVCardContactItemData(final Context context,
+ final MessagePartData data) {
+ return new VCardContactItemData(context, data);
+ }
+
+ @Override
+ public VCardContactItemData createVCardContactItemData(final Context context,
+ final Uri vCardUri) {
+ return new VCardContactItemData(context, vCardUri);
+ }
+
+ @Override
+ public ParticipantListItemData createParticipantListItemData(
+ final ParticipantData participant) {
+ return new ParticipantListItemData(participant);
+ }
+
+ @Override
+ public SubscriptionListData createSubscriptonListData(Context context) {
+ return new SubscriptionListData(context);
+ }
+
+ @Override
+ public SettingsData createSettingsData(Context context, SettingsDataListener listener) {
+ return new SettingsData(context, listener);
+ }
+
+ @Override
+ public DraftMessageData createDraftMessageData(String conversationId) {
+ return new DraftMessageData(conversationId);
+ }
+
+ @Override
+ public ActionService getActionService() {
+ // We need to allow access to this on the UI thread since it's used to start actions.
+ return mActionService;
+ }
+
+ @Override
+ public BackgroundWorker getBackgroundWorkerForActionService() {
+ return mDataModelWorker;
+ }
+
+ @Override
+ @DoesNotRunOnMainThread
+ public DatabaseWrapper getDatabase() {
+ // We prevent the main UI thread from accessing the database since we have to allow
+ // public access to this class to enable sub-packages to access data.
+ Assert.isNotMainThread();
+ return mDatabaseHelper.getDatabase();
+ }
+
+ @Override
+ public ConnectivityUtil getConnectivityUtil() {
+ return mConnectivityUtil;
+ }
+
+ @Override
+ public SyncManager getSyncManager() {
+ return mSyncManager;
+ }
+
+ @Override
+ void onCreateTables(final SQLiteDatabase db) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Rebuilt databases: reseting related state");
+ // Clear other things that implicitly reference the DB
+ SyncManager.resetLastSyncTimestamps();
+ }
+
+ @Override
+ public void onActivityResume() {
+ // Perform an incremental sync and register for changes if necessary
+ mSyncManager.updateSyncObserver(mContext);
+
+ // Trigger a participant refresh if needed, we should only need to refresh if there is
+ // contact change while the activity was paused.
+ ParticipantRefresh.refreshParticipantsIfNeeded();
+ }
+
+ @Override
+ public void onApplicationCreated() {
+ FixupMessageStatusOnStartupAction.fixupMessageStatus();
+ ProcessPendingMessagesAction.processFirstPendingMessage();
+ SyncManager.immediateSync();
+
+ if (OsUtil.isAtLeastL_MR1()) {
+ // Start listening for subscription change events for refreshing self participants.
+ PhoneUtils.getDefault().toLMr1().registerOnSubscriptionsChangedListener(
+ new SubscriptionManager.OnSubscriptionsChangedListener() {
+ @Override
+ public void onSubscriptionsChanged() {
+ // TODO: This dynamically changes the mms config that app is
+ // currently using. It may cause inconsistency in some cases. We need
+ // to check the usage of mms config and handle the dynamic change
+ // gracefully
+ MmsConfig.loadAsync();
+ ParticipantRefresh.refreshSelfParticipants();
+ }
+ });
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/DatabaseHelper.java b/src/com/android/messaging/datamodel/DatabaseHelper.java
new file mode 100644
index 0000000..f16bb3c
--- /dev/null
+++ b/src/com/android/messaging/datamodel/DatabaseHelper.java
@@ -0,0 +1,813 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.provider.BaseColumns;
+
+import com.android.messaging.BugleApplication;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.LogUtil;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * TODO: Open Issues:
+ * - Should we be storing the draft messages in the regular messages table or should we have a
+ * separate table for drafts to keep the normal messages query as simple as possible?
+ */
+
+/**
+ * Allows access to the SQL database. This is package private.
+ */
+public class DatabaseHelper extends SQLiteOpenHelper {
+ public static final String DATABASE_NAME = "bugle_db";
+
+ private static final int getDatabaseVersion(final Context context) {
+ return Integer.parseInt(context.getResources().getString(R.string.database_version));
+ }
+
+ /** Table containing names of all other tables and views */
+ private static final String MASTER_TABLE = "sqlite_master";
+ /** Column containing the name of the tables and views */
+ private static final String[] MASTER_COLUMNS = new String[] { "name", };
+
+ // Table names
+ public static final String CONVERSATIONS_TABLE = "conversations";
+ public static final String MESSAGES_TABLE = "messages";
+ public static final String PARTS_TABLE = "parts";
+ public static final String PARTICIPANTS_TABLE = "participants";
+ public static final String CONVERSATION_PARTICIPANTS_TABLE = "conversation_participants";
+
+ // Views
+ static final String DRAFT_PARTS_VIEW = "draft_parts_view";
+
+ // Conversations table schema
+ public static class ConversationColumns implements BaseColumns {
+ /* SMS/MMS Thread ID from the system provider */
+ public static final String SMS_THREAD_ID = "sms_thread_id";
+
+ /* Display name for the conversation */
+ public static final String NAME = "name";
+
+ /* Latest Message ID for the read status to display in conversation list */
+ public static final String LATEST_MESSAGE_ID = "latest_message_id";
+
+ /* Latest text snippet for display in conversation list */
+ public static final String SNIPPET_TEXT = "snippet_text";
+
+ /* Latest text subject for display in conversation list, empty string if none exists */
+ public static final String SUBJECT_TEXT = "subject_text";
+
+ /* Preview Uri */
+ public static final String PREVIEW_URI = "preview_uri";
+
+ /* The preview uri's content type */
+ public static final String PREVIEW_CONTENT_TYPE = "preview_content_type";
+
+ /* If we should display the current draft snippet/preview pair or snippet/preview pair */
+ public static final String SHOW_DRAFT = "show_draft";
+
+ /* Latest draft text subject for display in conversation list, empty string if none exists*/
+ public static final String DRAFT_SUBJECT_TEXT = "draft_subject_text";
+
+ /* Latest draft text snippet for display, empty string if none exists */
+ public static final String DRAFT_SNIPPET_TEXT = "draft_snippet_text";
+
+ /* Draft Preview Uri, empty string if none exists */
+ public static final String DRAFT_PREVIEW_URI = "draft_preview_uri";
+
+ /* The preview uri's content type */
+ public static final String DRAFT_PREVIEW_CONTENT_TYPE = "draft_preview_content_type";
+
+ /* If this conversation is archived */
+ public static final String ARCHIVE_STATUS = "archive_status";
+
+ /* Timestamp for sorting purposes */
+ public static final String SORT_TIMESTAMP = "sort_timestamp";
+
+ /* Last read message timestamp */
+ public static final String LAST_READ_TIMESTAMP = "last_read_timestamp";
+
+ /* Avatar for the conversation. Could be for group of individual */
+ public static final String ICON = "icon";
+
+ /* Participant contact ID if this conversation has a single participant. -1 otherwise */
+ public static final String PARTICIPANT_CONTACT_ID = "participant_contact_id";
+
+ /* Participant lookup key if this conversation has a single participant. null otherwise */
+ public static final String PARTICIPANT_LOOKUP_KEY = "participant_lookup_key";
+
+ /*
+ * Participant's normalized destination if this conversation has a single participant.
+ * null otherwise.
+ */
+ public static final String OTHER_PARTICIPANT_NORMALIZED_DESTINATION =
+ "participant_normalized_destination";
+
+ /* Default self participant for the conversation */
+ public static final String CURRENT_SELF_ID = "current_self_id";
+
+ /* Participant count not including self (so will be 1 for 1:1 or bigger for group) */
+ public static final String PARTICIPANT_COUNT = "participant_count";
+
+ /* Should notifications be enabled for this conversation? */
+ public static final String NOTIFICATION_ENABLED = "notification_enabled";
+
+ /* Notification sound used for the conversation */
+ public static final String NOTIFICATION_SOUND_URI = "notification_sound_uri";
+
+ /* Should vibrations be enabled for the conversation's notification? */
+ public static final String NOTIFICATION_VIBRATION = "notification_vibration";
+
+ /* Conversation recipients include email address */
+ public static final String INCLUDE_EMAIL_ADDRESS = "include_email_addr";
+
+ // Record the last received sms's service center info if it indicates that the reply path
+ // is present (TP-Reply-Path), so that we could use it for the subsequent message to send.
+ // Refer to TS 23.040 D.6 and SmsMessageSender.java in Android Messaging app.
+ public static final String SMS_SERVICE_CENTER = "sms_service_center";
+ }
+
+ // Conversation table SQL
+ private static final String CREATE_CONVERSATIONS_TABLE_SQL =
+ "CREATE TABLE " + CONVERSATIONS_TABLE + "("
+ + ConversationColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
+ // TODO : Int? Required not default?
+ + ConversationColumns.SMS_THREAD_ID + " INT DEFAULT(0), "
+ + ConversationColumns.NAME + " TEXT, "
+ + ConversationColumns.LATEST_MESSAGE_ID + " INT, "
+ + ConversationColumns.SNIPPET_TEXT + " TEXT, "
+ + ConversationColumns.SUBJECT_TEXT + " TEXT, "
+ + ConversationColumns.PREVIEW_URI + " TEXT, "
+ + ConversationColumns.PREVIEW_CONTENT_TYPE + " TEXT, "
+ + ConversationColumns.SHOW_DRAFT + " INT DEFAULT(0), "
+ + ConversationColumns.DRAFT_SNIPPET_TEXT + " TEXT, "
+ + ConversationColumns.DRAFT_SUBJECT_TEXT + " TEXT, "
+ + ConversationColumns.DRAFT_PREVIEW_URI + " TEXT, "
+ + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE + " TEXT, "
+ + ConversationColumns.ARCHIVE_STATUS + " INT DEFAULT(0), "
+ + ConversationColumns.SORT_TIMESTAMP + " INT DEFAULT(0), "
+ + ConversationColumns.LAST_READ_TIMESTAMP + " INT DEFAULT(0), "
+ + ConversationColumns.ICON + " TEXT, "
+ + ConversationColumns.PARTICIPANT_CONTACT_ID + " INT DEFAULT ( "
+ + ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + "), "
+ + ConversationColumns.PARTICIPANT_LOOKUP_KEY + " TEXT, "
+ + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + " TEXT, "
+ + ConversationColumns.CURRENT_SELF_ID + " TEXT, "
+ + ConversationColumns.PARTICIPANT_COUNT + " INT DEFAULT(0), "
+ + ConversationColumns.NOTIFICATION_ENABLED + " INT DEFAULT(1), "
+ + ConversationColumns.NOTIFICATION_SOUND_URI + " TEXT, "
+ + ConversationColumns.NOTIFICATION_VIBRATION + " INT DEFAULT(1), "
+ + ConversationColumns.INCLUDE_EMAIL_ADDRESS + " INT DEFAULT(0), "
+ + ConversationColumns.SMS_SERVICE_CENTER + " TEXT "
+ + ");";
+
+ private static final String CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL =
+ "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SMS_THREAD_ID
+ + " ON " + CONVERSATIONS_TABLE
+ + "(" + ConversationColumns.SMS_THREAD_ID + ")";
+
+ private static final String CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL =
+ "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.ARCHIVE_STATUS
+ + " ON " + CONVERSATIONS_TABLE
+ + "(" + ConversationColumns.ARCHIVE_STATUS + ")";
+
+ private static final String CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL =
+ "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SORT_TIMESTAMP
+ + " ON " + CONVERSATIONS_TABLE
+ + "(" + ConversationColumns.SORT_TIMESTAMP + ")";
+
+ // Messages table schema
+ public static class MessageColumns implements BaseColumns {
+ /* conversation id that this message belongs to */
+ public static final String CONVERSATION_ID = "conversation_id";
+
+ /* participant which send this message */
+ public static final String SENDER_PARTICIPANT_ID = "sender_id";
+
+ /* This is bugle's internal status for the message */
+ public static final String STATUS = "message_status";
+
+ /* Type of message: SMS, MMS or MMS notification */
+ public static final String PROTOCOL = "message_protocol";
+
+ /* This is the time that the sender sent the message */
+ public static final String SENT_TIMESTAMP = "sent_timestamp";
+
+ /* Time that we received the message on this device */
+ public static final String RECEIVED_TIMESTAMP = "received_timestamp";
+
+ /* When the message has been seen by a user in a notification */
+ public static final String SEEN = "seen";
+
+ /* When the message has been read by a user */
+ public static final String READ = "read";
+
+ /* participant representing the sim which processed this message */
+ public static final String SELF_PARTICIPANT_ID = "self_id";
+
+ /*
+ * Time when a retry is initiated. This is used to compute the retry window
+ * when we retry sending/downloading a message.
+ */
+ public static final String RETRY_START_TIMESTAMP = "retry_start_timestamp";
+
+ // Columns which map to the SMS provider
+
+ /* Message ID from the platform provider */
+ public static final String SMS_MESSAGE_URI = "sms_message_uri";
+
+ /* The message priority for MMS message */
+ public static final String SMS_PRIORITY = "sms_priority";
+
+ /* The message size for MMS message */
+ public static final String SMS_MESSAGE_SIZE = "sms_message_size";
+
+ /* The subject for MMS message */
+ public static final String MMS_SUBJECT = "mms_subject";
+
+ /* Transaction id for MMS notificaiton */
+ public static final String MMS_TRANSACTION_ID = "mms_transaction_id";
+
+ /* Content location for MMS notificaiton */
+ public static final String MMS_CONTENT_LOCATION = "mms_content_location";
+
+ /* The expiry time (ms) for MMS message */
+ public static final String MMS_EXPIRY = "mms_expiry";
+
+ /* The detailed status (RESPONSE_STATUS or RETRIEVE_STATUS) for MMS message */
+ public static final String RAW_TELEPHONY_STATUS = "raw_status";
+ }
+
+ // Messages table SQL
+ private static final String CREATE_MESSAGES_TABLE_SQL =
+ "CREATE TABLE " + MESSAGES_TABLE + " ("
+ + MessageColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
+ + MessageColumns.CONVERSATION_ID + " INT, "
+ + MessageColumns.SENDER_PARTICIPANT_ID + " INT, "
+ + MessageColumns.SENT_TIMESTAMP + " INT DEFAULT(0), "
+ + MessageColumns.RECEIVED_TIMESTAMP + " INT DEFAULT(0), "
+ + MessageColumns.PROTOCOL + " INT DEFAULT(0), "
+ + MessageColumns.STATUS + " INT DEFAULT(0), "
+ + MessageColumns.SEEN + " INT DEFAULT(0), "
+ + MessageColumns.READ + " INT DEFAULT(0), "
+ + MessageColumns.SMS_MESSAGE_URI + " TEXT, "
+ + MessageColumns.SMS_PRIORITY + " INT DEFAULT(0), "
+ + MessageColumns.SMS_MESSAGE_SIZE + " INT DEFAULT(0), "
+ + MessageColumns.MMS_SUBJECT + " TEXT, "
+ + MessageColumns.MMS_TRANSACTION_ID + " TEXT, "
+ + MessageColumns.MMS_CONTENT_LOCATION + " TEXT, "
+ + MessageColumns.MMS_EXPIRY + " INT DEFAULT(0), "
+ + MessageColumns.RAW_TELEPHONY_STATUS + " INT DEFAULT(0), "
+ + MessageColumns.SELF_PARTICIPANT_ID + " INT, "
+ + MessageColumns.RETRY_START_TIMESTAMP + " INT DEFAULT(0), "
+ + "FOREIGN KEY (" + MessageColumns.CONVERSATION_ID + ") REFERENCES "
+ + CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ") ON DELETE CASCADE "
+ + "FOREIGN KEY (" + MessageColumns.SENDER_PARTICIPANT_ID + ") REFERENCES "
+ + PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + ") ON DELETE SET NULL "
+ + "FOREIGN KEY (" + MessageColumns.SELF_PARTICIPANT_ID + ") REFERENCES "
+ + PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + ") ON DELETE SET NULL "
+ + ");";
+
+ // Primary sort index for messages table : by conversation id, status, received timestamp.
+ private static final String MESSAGES_TABLE_SORT_INDEX_SQL =
+ "CREATE INDEX index_" + MESSAGES_TABLE + "_sort ON " + MESSAGES_TABLE + "("
+ + MessageColumns.CONVERSATION_ID + ", "
+ + MessageColumns.STATUS + ", "
+ + MessageColumns.RECEIVED_TIMESTAMP + ")";
+
+ private static final String MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL =
+ "CREATE INDEX index_" + MESSAGES_TABLE + "_status_seen ON " + MESSAGES_TABLE + "("
+ + MessageColumns.STATUS + ", "
+ + MessageColumns.SEEN + ")";
+
+ // Parts table schema
+ // A part may contain text or a media url, but not both.
+ public static class PartColumns implements BaseColumns {
+ /* message id that this part belongs to */
+ public static final String MESSAGE_ID = "message_id";
+
+ /* conversation id that this part belongs to */
+ public static final String CONVERSATION_ID = "conversation_id";
+
+ /* text for this part */
+ public static final String TEXT = "text";
+
+ /* content uri for this part */
+ public static final String CONTENT_URI = "uri";
+
+ /* content type for this part */
+ public static final String CONTENT_TYPE = "content_type";
+
+ /* cached width for this part (for layout while loading) */
+ public static final String WIDTH = "width";
+
+ /* cached height for this part (for layout while loading) */
+ public static final String HEIGHT = "height";
+
+ /* de-normalized copy of timestamp from the messages table. This is populated
+ * via an insert trigger on the parts table.
+ */
+ public static final String TIMESTAMP = "timestamp";
+ }
+
+ // Message part table SQL
+ private static final String CREATE_PARTS_TABLE_SQL =
+ "CREATE TABLE " + PARTS_TABLE + "("
+ + PartColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ + PartColumns.MESSAGE_ID + " INT,"
+ + PartColumns.TEXT + " TEXT,"
+ + PartColumns.CONTENT_URI + " TEXT,"
+ + PartColumns.CONTENT_TYPE + " TEXT,"
+ + PartColumns.WIDTH + " INT DEFAULT("
+ + MessagingContentProvider.UNSPECIFIED_SIZE + "),"
+ + PartColumns.HEIGHT + " INT DEFAULT("
+ + MessagingContentProvider.UNSPECIFIED_SIZE + "),"
+ + PartColumns.TIMESTAMP + " INT, "
+ + PartColumns.CONVERSATION_ID + " INT NOT NULL,"
+ + "FOREIGN KEY (" + PartColumns.MESSAGE_ID + ") REFERENCES "
+ + MESSAGES_TABLE + "(" + MessageColumns._ID + ") ON DELETE CASCADE "
+ + "FOREIGN KEY (" + PartColumns.CONVERSATION_ID + ") REFERENCES "
+ + CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ") ON DELETE CASCADE "
+ + ");";
+
+ public static final String CREATE_PARTS_TRIGGER_SQL =
+ "CREATE TRIGGER " + PARTS_TABLE + "_TRIGGER" + " AFTER INSERT ON " + PARTS_TABLE
+ + " FOR EACH ROW "
+ + " BEGIN UPDATE " + PARTS_TABLE
+ + " SET " + PartColumns.TIMESTAMP + "="
+ + " (SELECT received_timestamp FROM " + MESSAGES_TABLE + " WHERE " + MESSAGES_TABLE
+ + "." + MessageColumns._ID + "=" + "NEW." + PartColumns.MESSAGE_ID + ")"
+ + " WHERE " + PARTS_TABLE + "." + PartColumns._ID + "=" + "NEW." + PartColumns._ID
+ + "; END";
+
+ public static final String CREATE_MESSAGES_TRIGGER_SQL =
+ "CREATE TRIGGER " + MESSAGES_TABLE + "_TRIGGER" + " AFTER UPDATE OF "
+ + MessageColumns.RECEIVED_TIMESTAMP + " ON " + MESSAGES_TABLE
+ + " FOR EACH ROW BEGIN UPDATE " + PARTS_TABLE + " SET " + PartColumns.TIMESTAMP
+ + " = NEW." + MessageColumns.RECEIVED_TIMESTAMP + " WHERE " + PARTS_TABLE + "."
+ + PartColumns.MESSAGE_ID + " = NEW." + MessageColumns._ID
+ + "; END;";
+
+ // Primary sort index for parts table : by message_id
+ private static final String PARTS_TABLE_MESSAGE_INDEX_SQL =
+ "CREATE INDEX index_" + PARTS_TABLE + "_message_id ON " + PARTS_TABLE + "("
+ + PartColumns.MESSAGE_ID + ")";
+
+ // Participants table schema
+ public static class ParticipantColumns implements BaseColumns {
+ /* The subscription id for the sim associated with this self participant.
+ * Introduced in L. For earlier versions will always be default_sub_id (-1).
+ * For multi sim devices (or cases where the sim was changed) single device
+ * may have several different sub_id values */
+ public static final String SUB_ID = "sub_id";
+
+ /* The slot of the active SIM (inserted in the device) for this self-participant. If the
+ * self-participant doesn't correspond to any active SIM, this will be
+ * {@link android.telephony.SubscriptionManager#INVALID_SLOT_ID}.
+ * The column is ignored for all non-self participants.
+ */
+ public static final String SIM_SLOT_ID = "sim_slot_id";
+
+ /* The phone number stored in a standard E164 format if possible. This is unique for a
+ * given participant. We can't handle multiple participants with the same phone number
+ * since we don't know which of them a message comes from. This can also be an email
+ * address, in which case this is the same as the displayed address */
+ public static final String NORMALIZED_DESTINATION = "normalized_destination";
+
+ /* The phone number as originally supplied and used for dialing. Not necessarily in E164
+ * format or unique */
+ public static final String SEND_DESTINATION = "send_destination";
+
+ /* The user-friendly formatting of the phone number according to the region setting of
+ * the device when the row was added. */
+ public static final String DISPLAY_DESTINATION = "display_destination";
+
+ /* A string with this participant's full name or a pretty printed phone number */
+ public static final String FULL_NAME = "full_name";
+
+ /* A string with just this participant's first name */
+ public static final String FIRST_NAME = "first_name";
+
+ /* A local URI to an asset for the icon for this participant */
+ public static final String PROFILE_PHOTO_URI = "profile_photo_uri";
+
+ /* Contact id for matching local contact for this participant */
+ public static final String CONTACT_ID = "contact_id";
+
+ /* String that contains hints on how to find contact information in a contact lookup */
+ public static final String LOOKUP_KEY = "lookup_key";
+
+ /* If this participant is blocked */
+ public static final String BLOCKED = "blocked";
+
+ /* The color of the subscription (FOR SELF PARTICIPANTS ONLY) */
+ public static final String SUBSCRIPTION_COLOR = "subscription_color";
+
+ /* The name of the subscription (FOR SELF PARTICIPANTS ONLY) */
+ public static final String SUBSCRIPTION_NAME = "subscription_name";
+
+ /* The exact destination stored in Contacts for this participant */
+ public static final String CONTACT_DESTINATION = "contact_destination";
+ }
+
+ // Participants table SQL
+ private static final String CREATE_PARTICIPANTS_TABLE_SQL =
+ "CREATE TABLE " + PARTICIPANTS_TABLE + "("
+ + ParticipantColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ + ParticipantColumns.SUB_ID + " INT DEFAULT("
+ + ParticipantData.OTHER_THAN_SELF_SUB_ID + "),"
+ + ParticipantColumns.SIM_SLOT_ID + " INT DEFAULT("
+ + ParticipantData.INVALID_SLOT_ID + "),"
+ + ParticipantColumns.NORMALIZED_DESTINATION + " TEXT,"
+ + ParticipantColumns.SEND_DESTINATION + " TEXT,"
+ + ParticipantColumns.DISPLAY_DESTINATION + " TEXT,"
+ + ParticipantColumns.FULL_NAME + " TEXT,"
+ + ParticipantColumns.FIRST_NAME + " TEXT,"
+ + ParticipantColumns.PROFILE_PHOTO_URI + " TEXT, "
+ + ParticipantColumns.CONTACT_ID + " INT DEFAULT( "
+ + ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + "), "
+ + ParticipantColumns.LOOKUP_KEY + " STRING, "
+ + ParticipantColumns.BLOCKED + " INT DEFAULT(0), "
+ + ParticipantColumns.SUBSCRIPTION_NAME + " TEXT, "
+ + ParticipantColumns.SUBSCRIPTION_COLOR + " INT DEFAULT(0), "
+ + ParticipantColumns.CONTACT_DESTINATION + " TEXT, "
+ + "UNIQUE (" + ParticipantColumns.NORMALIZED_DESTINATION + ", "
+ + ParticipantColumns.SUB_ID + ") ON CONFLICT FAIL" + ");";
+
+ private static final String CREATE_SELF_PARTICIPANT_SQL =
+ "INSERT INTO " + PARTICIPANTS_TABLE
+ + " ( " + ParticipantColumns.SUB_ID + " ) VALUES ( %s )";
+
+ static String getCreateSelfParticipantSql(int subId) {
+ return String.format(CREATE_SELF_PARTICIPANT_SQL, subId);
+ }
+
+ // Conversation Participants table schema - contains a list of participants excluding the user
+ // in a given conversation.
+ public static class ConversationParticipantsColumns implements BaseColumns {
+ /* participant id of someone in this conversation */
+ public static final String PARTICIPANT_ID = "participant_id";
+
+ /* conversation id that this participant belongs to */
+ public static final String CONVERSATION_ID = "conversation_id";
+ }
+
+ // Conversation Participants table SQL
+ private static final String CREATE_CONVERSATION_PARTICIPANTS_TABLE_SQL =
+ "CREATE TABLE " + CONVERSATION_PARTICIPANTS_TABLE + "("
+ + ConversationParticipantsColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ + ConversationParticipantsColumns.CONVERSATION_ID + " INT,"
+ + ConversationParticipantsColumns.PARTICIPANT_ID + " INT,"
+ + "UNIQUE (" + ConversationParticipantsColumns.CONVERSATION_ID + ","
+ + ConversationParticipantsColumns.PARTICIPANT_ID + ") ON CONFLICT FAIL, "
+ + "FOREIGN KEY (" + ConversationParticipantsColumns.CONVERSATION_ID + ") "
+ + "REFERENCES " + CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ")"
+ + " ON DELETE CASCADE "
+ + "FOREIGN KEY (" + ConversationParticipantsColumns.PARTICIPANT_ID + ")"
+ + " REFERENCES " + PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + "));";
+
+ // Primary access pattern for conversation participants is to look them up for a specific
+ // conversation.
+ private static final String CONVERSATION_PARTICIPANTS_TABLE_CONVERSATION_ID_INDEX_SQL =
+ "CREATE INDEX index_" + CONVERSATION_PARTICIPANTS_TABLE + "_"
+ + ConversationParticipantsColumns.CONVERSATION_ID
+ + " ON " + CONVERSATION_PARTICIPANTS_TABLE
+ + "(" + ConversationParticipantsColumns.CONVERSATION_ID + ")";
+
+ // View for getting parts which are for draft messages.
+ static final String DRAFT_PARTS_VIEW_SQL = "CREATE VIEW " +
+ DRAFT_PARTS_VIEW + " AS SELECT "
+ + PARTS_TABLE + '.' + PartColumns._ID
+ + " as " + PartColumns._ID + ", "
+ + PARTS_TABLE + '.' + PartColumns.MESSAGE_ID
+ + " as " + PartColumns.MESSAGE_ID + ", "
+ + PARTS_TABLE + '.' + PartColumns.TEXT
+ + " as " + PartColumns.TEXT + ", "
+ + PARTS_TABLE + '.' + PartColumns.CONTENT_URI
+ + " as " + PartColumns.CONTENT_URI + ", "
+ + PARTS_TABLE + '.' + PartColumns.CONTENT_TYPE
+ + " as " + PartColumns.CONTENT_TYPE + ", "
+ + PARTS_TABLE + '.' + PartColumns.WIDTH
+ + " as " + PartColumns.WIDTH + ", "
+ + PARTS_TABLE + '.' + PartColumns.HEIGHT
+ + " as " + PartColumns.HEIGHT + ", "
+ + MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID
+ + " as " + MessageColumns.CONVERSATION_ID + " "
+ + " FROM " + MESSAGES_TABLE + " LEFT JOIN " + PARTS_TABLE + " ON ("
+ + MESSAGES_TABLE + "." + MessageColumns._ID
+ + "=" + PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ")"
+ // Exclude draft messages from main view
+ + " WHERE " + MESSAGES_TABLE + "." + MessageColumns.STATUS
+ + " = " + MessageData.BUGLE_STATUS_OUTGOING_DRAFT;
+
+ // List of all our SQL tables
+ private static final String[] CREATE_TABLE_SQLS = new String[] {
+ CREATE_CONVERSATIONS_TABLE_SQL,
+ CREATE_MESSAGES_TABLE_SQL,
+ CREATE_PARTS_TABLE_SQL,
+ CREATE_PARTICIPANTS_TABLE_SQL,
+ CREATE_CONVERSATION_PARTICIPANTS_TABLE_SQL,
+ };
+
+ // List of all our indices
+ private static final String[] CREATE_INDEX_SQLS = new String[] {
+ CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL,
+ CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL,
+ CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL,
+ MESSAGES_TABLE_SORT_INDEX_SQL,
+ MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL,
+ PARTS_TABLE_MESSAGE_INDEX_SQL,
+ CONVERSATION_PARTICIPANTS_TABLE_CONVERSATION_ID_INDEX_SQL,
+ };
+
+ // List of all our SQL triggers
+ private static final String[] CREATE_TRIGGER_SQLS = new String[] {
+ CREATE_PARTS_TRIGGER_SQL,
+ CREATE_MESSAGES_TRIGGER_SQL,
+ };
+
+ // List of all our views
+ private static final String[] CREATE_VIEW_SQLS = new String[] {
+ ConversationListItemData.getConversationListViewSql(),
+ ConversationImagePartsView.getCreateSql(),
+ DRAFT_PARTS_VIEW_SQL,
+ };
+
+ private static final Object sLock = new Object();
+ private final Context mApplicationContext;
+ private static DatabaseHelper sHelperInstance; // Protected by sLock.
+
+ private final Object mDatabaseWrapperLock = new Object();
+ private DatabaseWrapper mDatabaseWrapper; // Protected by mDatabaseWrapperLock.
+ private final DatabaseUpgradeHelper mUpgradeHelper = new DatabaseUpgradeHelper();
+
+ /**
+ * Get a (singleton) instance of {@link DatabaseHelper}, creating one if there isn't one yet.
+ * This is the only public method for getting a new instance of the class.
+ * @param context Should be the application context (or something that will live for the
+ * lifetime of the application).
+ * @return The current (or a new) DatabaseHelper instance.
+ */
+ public static DatabaseHelper getInstance(final Context context) {
+ synchronized (sLock) {
+ if (sHelperInstance == null) {
+ sHelperInstance = new DatabaseHelper(context);
+ }
+ return sHelperInstance;
+ }
+ }
+
+ /**
+ * Private constructor, used from {@link #getInstance()}.
+ * @param context Should be the application context (or something that will live for the
+ * lifetime of the application).
+ */
+ private DatabaseHelper(final Context context) {
+ super(context, DATABASE_NAME, null, getDatabaseVersion(context), null);
+ mApplicationContext = context;
+ }
+
+ /**
+ * Test method that always instantiates a new DatabaseHelper instance. This should
+ * be used ONLY by the tests and never by the real application.
+ * @param context Test context.
+ * @return Brand new DatabaseHelper instance.
+ */
+ @VisibleForTesting
+ static DatabaseHelper getNewInstanceForTest(final Context context) {
+ Assert.isEngBuild();
+ Assert.isTrue(BugleApplication.isRunningTests());
+ return new DatabaseHelper(context);
+ }
+
+ /**
+ * Get the (singleton) instance of @{link DatabaseWrapper}.
+ * <p>The database is always opened as a writeable database.
+ * @return The current (or a new) DatabaseWrapper instance.
+ */
+ @DoesNotRunOnMainThread
+ DatabaseWrapper getDatabase() {
+ // We prevent the main UI thread from accessing the database here since we have to allow
+ // public access to this class to enable sub-packages to access data.
+ Assert.isNotMainThread();
+
+ synchronized (mDatabaseWrapperLock) {
+ if (mDatabaseWrapper == null) {
+ mDatabaseWrapper = new DatabaseWrapper(mApplicationContext, getWritableDatabase());
+ }
+ return mDatabaseWrapper;
+ }
+ }
+
+ @Override
+ public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
+ mUpgradeHelper.onDowngrade(db, oldVersion, newVersion);
+ }
+
+ /**
+ * Drops and recreates all tables.
+ */
+ public static void rebuildTables(final SQLiteDatabase db) {
+ // Drop tables first, then views, and indices.
+ dropAllTables(db);
+ dropAllViews(db);
+ dropAllIndexes(db);
+ dropAllTriggers(db);
+
+ // Recreate the whole database.
+ createDatabase(db);
+ }
+
+ /**
+ * Drop and rebuild a given view.
+ */
+ static void rebuildView(final SQLiteDatabase db, final String viewName,
+ final String createViewSql) {
+ dropView(db, viewName, true /* throwOnFailure */);
+ db.execSQL(createViewSql);
+ }
+
+ private static void dropView(final SQLiteDatabase db, final String viewName,
+ final boolean throwOnFailure) {
+ final String dropPrefix = "DROP VIEW IF EXISTS ";
+ try {
+ db.execSQL(dropPrefix + viewName);
+ } catch (final SQLException ex) {
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
+ LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop view " + viewName + " "
+ + ex);
+ }
+
+ if (throwOnFailure) {
+ throw ex;
+ }
+ }
+ }
+
+ /**
+ * Drops all user-defined tables from the given database.
+ */
+ private static void dropAllTables(final SQLiteDatabase db) {
+ final Cursor tableCursor =
+ db.query(MASTER_TABLE, MASTER_COLUMNS, "type='table'", null, null, null, null);
+ if (tableCursor != null) {
+ try {
+ final String dropPrefix = "DROP TABLE IF EXISTS ";
+ while (tableCursor.moveToNext()) {
+ final String tableName = tableCursor.getString(0);
+
+ // Skip special tables
+ if (tableName.startsWith("android_") || tableName.startsWith("sqlite_")) {
+ continue;
+ }
+ try {
+ db.execSQL(dropPrefix + tableName);
+ } catch (final SQLException ex) {
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
+ LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop table " + tableName + " "
+ + ex);
+ }
+ }
+ }
+ } finally {
+ tableCursor.close();
+ }
+ }
+ }
+
+ /**
+ * Drops all user-defined triggers from the given database.
+ */
+ private static void dropAllTriggers(final SQLiteDatabase db) {
+ final Cursor triggerCursor =
+ db.query(MASTER_TABLE, MASTER_COLUMNS, "type='trigger'", null, null, null, null);
+ if (triggerCursor != null) {
+ try {
+ final String dropPrefix = "DROP TRIGGER IF EXISTS ";
+ while (triggerCursor.moveToNext()) {
+ final String triggerName = triggerCursor.getString(0);
+
+ // Skip special tables
+ if (triggerName.startsWith("android_") || triggerName.startsWith("sqlite_")) {
+ continue;
+ }
+ try {
+ db.execSQL(dropPrefix + triggerName);
+ } catch (final SQLException ex) {
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
+ LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop trigger " + triggerName +
+ " " + ex);
+ }
+ }
+ }
+ } finally {
+ triggerCursor.close();
+ }
+ }
+ }
+
+ /**
+ * Drops all user-defined views from the given database.
+ */
+ private static void dropAllViews(final SQLiteDatabase db) {
+ final Cursor viewCursor =
+ db.query(MASTER_TABLE, MASTER_COLUMNS, "type='view'", null, null, null, null);
+ if (viewCursor != null) {
+ try {
+ while (viewCursor.moveToNext()) {
+ final String viewName = viewCursor.getString(0);
+ dropView(db, viewName, false /* throwOnFailure */);
+ }
+ } finally {
+ viewCursor.close();
+ }
+ }
+ }
+
+ /**
+ * Drops all user-defined views from the given database.
+ */
+ private static void dropAllIndexes(final SQLiteDatabase db) {
+ final Cursor indexCursor =
+ db.query(MASTER_TABLE, MASTER_COLUMNS, "type='index'", null, null, null, null);
+ if (indexCursor != null) {
+ try {
+ final String dropPrefix = "DROP INDEX IF EXISTS ";
+ while (indexCursor.moveToNext()) {
+ final String indexName = indexCursor.getString(0);
+ try {
+ db.execSQL(dropPrefix + indexName);
+ } catch (final SQLException ex) {
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
+ LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop index " + indexName + " "
+ + ex);
+ }
+ }
+ }
+ } finally {
+ indexCursor.close();
+ }
+ }
+ }
+
+ private static void createDatabase(final SQLiteDatabase db) {
+ for (final String sql : CREATE_TABLE_SQLS) {
+ db.execSQL(sql);
+ }
+
+ for (final String sql : CREATE_INDEX_SQLS) {
+ db.execSQL(sql);
+ }
+
+ for (final String sql : CREATE_VIEW_SQLS) {
+ db.execSQL(sql);
+ }
+
+ for (final String sql : CREATE_TRIGGER_SQLS) {
+ db.execSQL(sql);
+ }
+
+ // Enable foreign key constraints
+ db.execSQL("PRAGMA foreign_keys=ON;");
+
+ // Add the default self participant. The default self will be assigned a proper slot id
+ // during participant refresh.
+ db.execSQL(getCreateSelfParticipantSql(ParticipantData.DEFAULT_SELF_SUB_ID));
+
+ DataModel.get().onCreateTables(db);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ createDatabase(db);
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ mUpgradeHelper.doOnUpgrade(db, oldVersion, newVersion);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/DatabaseUpgradeHelper.java b/src/com/android/messaging/datamodel/DatabaseUpgradeHelper.java
new file mode 100644
index 0000000..d112533
--- /dev/null
+++ b/src/com/android/messaging/datamodel/DatabaseUpgradeHelper.java
@@ -0,0 +1,42 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+
+public class DatabaseUpgradeHelper {
+ private static final String TAG = LogUtil.BUGLE_DATABASE_TAG;
+
+ public void doOnUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
+ Assert.isTrue(newVersion >= oldVersion);
+ if (oldVersion == newVersion) {
+ return;
+ }
+
+ LogUtil.i(TAG, "Database upgrade started from version " + oldVersion + " to " + newVersion);
+
+ // Add future upgrade code here
+ }
+
+ public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
+ DatabaseHelper.rebuildTables(db);
+ LogUtil.e(TAG, "Database downgrade requested for version " +
+ oldVersion + " version " + newVersion + ", forcing db rebuild!");
+ }
+}
diff --git a/src/com/android/messaging/datamodel/DatabaseWrapper.java b/src/com/android/messaging/datamodel/DatabaseWrapper.java
new file mode 100644
index 0000000..ca7a331
--- /dev/null
+++ b/src/com/android/messaging/datamodel/DatabaseWrapper.java
@@ -0,0 +1,482 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteFullException;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.database.sqlite.SQLiteStatement;
+import android.util.SparseArray;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.UiUtils;
+
+import java.util.Locale;
+import java.util.Stack;
+import java.util.regex.Pattern;
+
+public class DatabaseWrapper {
+ private static final String TAG = LogUtil.BUGLE_DATABASE_TAG;
+
+ private final SQLiteDatabase mDatabase;
+ private final Context mContext;
+ private final boolean mLog;
+ /**
+ * Set mExplainQueryPlanRegexp (via {@link BugleGservicesKeys#EXPLAIN_QUERY_PLAN_REGEXP}
+ * to regex matching queries to see query plans. For example, ".*" to show all query plans.
+ */
+ // See
+ private final String mExplainQueryPlanRegexp;
+ private static final int sTimingThreshold = 50; // in milliseconds
+
+ public static final int INDEX_INSERT_MESSAGE_PART = 0;
+ public static final int INDEX_INSERT_MESSAGE = 1;
+ public static final int INDEX_QUERY_CONVERSATIONS_LATEST_MESSAGE = 2;
+ public static final int INDEX_QUERY_MESSAGES_LATEST_MESSAGE = 3;
+
+ private final SparseArray<SQLiteStatement> mCompiledStatements;
+
+ static class TransactionData {
+ long time;
+ boolean transactionSuccessful;
+ }
+
+ // track transaction on a per thread basis
+ private static ThreadLocal<Stack<TransactionData>> sTransactionDepth =
+ new ThreadLocal<Stack<TransactionData>>() {
+ @Override
+ public Stack<TransactionData> initialValue() {
+ return new Stack<TransactionData>();
+ }
+ };
+
+ private static String[] sFormatStrings = new String[] {
+ "took %d ms to %s",
+ " took %d ms to %s",
+ " took %d ms to %s",
+ };
+
+ DatabaseWrapper(final Context context, final SQLiteDatabase db) {
+ mLog = LogUtil.isLoggable(LogUtil.BUGLE_DATABASE_PERF_TAG, LogUtil.VERBOSE);
+ mExplainQueryPlanRegexp = Factory.get().getBugleGservices().getString(
+ BugleGservicesKeys.EXPLAIN_QUERY_PLAN_REGEXP, null);
+ mDatabase = db;
+ mContext = context;
+ mCompiledStatements = new SparseArray<SQLiteStatement>();
+ }
+
+ public SQLiteStatement getStatementInTransaction(final int index, final String statement) {
+ // Use transaction to serialize access to statements
+ Assert.isTrue(mDatabase.inTransaction());
+ SQLiteStatement compiled = mCompiledStatements.get(index);
+ if (compiled == null) {
+ compiled = mDatabase.compileStatement(statement);
+ Assert.isTrue(compiled.toString().contains(statement.trim()));
+ mCompiledStatements.put(index, compiled);
+ }
+ return compiled;
+ }
+
+ private void maybePlayDebugNoise() {
+ DebugUtils.maybePlayDebugNoise(mContext, DebugUtils.DEBUG_SOUND_DB_OP);
+ }
+
+ private static void printTiming(final long t1, final String msg) {
+ final int transactionDepth = sTransactionDepth.get().size();
+ final long t2 = System.currentTimeMillis();
+ final long delta = t2 - t1;
+ if (delta > sTimingThreshold) {
+ LogUtil.v(LogUtil.BUGLE_DATABASE_PERF_TAG, String.format(Locale.US,
+ sFormatStrings[Math.min(sFormatStrings.length - 1, transactionDepth)],
+ delta,
+ msg));
+ }
+ }
+
+ public Context getContext() {
+ return mContext;
+ }
+
+ public void beginTransaction() {
+ final long t1 = System.currentTimeMillis();
+
+ // push the current time onto the transaction stack
+ final TransactionData f = new TransactionData();
+ f.time = t1;
+ sTransactionDepth.get().push(f);
+
+ mDatabase.beginTransaction();
+ }
+
+ public void setTransactionSuccessful() {
+ final TransactionData f = sTransactionDepth.get().peek();
+ f.transactionSuccessful = true;
+ mDatabase.setTransactionSuccessful();
+ }
+
+ public void endTransaction() {
+ long t1 = 0;
+ long transactionStartTime = 0;
+ final TransactionData f = sTransactionDepth.get().pop();
+ if (f.transactionSuccessful == false) {
+ LogUtil.w(TAG, "endTransaction without setting successful");
+ for (final StackTraceElement st : (new Exception()).getStackTrace()) {
+ LogUtil.w(TAG, " " + st.toString());
+ }
+ }
+ if (mLog) {
+ transactionStartTime = f.time;
+ t1 = System.currentTimeMillis();
+ }
+ try {
+ mDatabase.endTransaction();
+ } catch (SQLiteFullException ex) {
+ LogUtil.e(TAG, "Database full, unable to endTransaction", ex);
+ UiUtils.showToastAtBottom(R.string.db_full);
+ }
+ if (mLog) {
+ printTiming(t1, String.format(Locale.US,
+ ">>> endTransaction (total for this transaction: %d)",
+ (System.currentTimeMillis() - transactionStartTime)));
+ }
+ }
+
+ public void yieldTransaction() {
+ long yieldStartTime = 0;
+ if (mLog) {
+ yieldStartTime = System.currentTimeMillis();
+ }
+ final boolean wasYielded = mDatabase.yieldIfContendedSafely();
+ if (wasYielded && mLog) {
+ printTiming(yieldStartTime, "yieldTransaction");
+ }
+ }
+
+ public void insertWithOnConflict(final String searchTable, final String nullColumnHack,
+ final ContentValues initialValues, final int conflictAlgorithm) {
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ try {
+ mDatabase.insertWithOnConflict(searchTable, nullColumnHack, initialValues,
+ conflictAlgorithm);
+ } catch (SQLiteFullException ex) {
+ LogUtil.e(TAG, "Database full, unable to insertWithOnConflict", ex);
+ UiUtils.showToastAtBottom(R.string.db_full);
+ }
+ if (mLog) {
+ printTiming(t1, String.format(Locale.US,
+ "insertWithOnConflict with ", searchTable));
+ }
+ }
+
+ private void explainQueryPlan(final SQLiteQueryBuilder qb, final SQLiteDatabase db,
+ final String[] projection, final String selection,
+ @SuppressWarnings("unused")
+ final String[] queryArgs,
+ final String groupBy,
+ @SuppressWarnings("unused")
+ final String having,
+ final String sortOrder, final String limit) {
+ final String queryString = qb.buildQuery(
+ projection,
+ selection,
+ groupBy,
+ null/*having*/,
+ sortOrder,
+ limit);
+ explainQueryPlan(db, queryString, queryArgs);
+ }
+
+ private void explainQueryPlan(final SQLiteDatabase db, final String sql,
+ final String[] queryArgs) {
+ if (!Pattern.matches(mExplainQueryPlanRegexp, sql)) {
+ return;
+ }
+ final Cursor planCursor = db.rawQuery("explain query plan " + sql, queryArgs);
+ try {
+ if (planCursor != null && planCursor.moveToFirst()) {
+ final int detailColumn = planCursor.getColumnIndex("detail");
+ final StringBuilder sb = new StringBuilder();
+ do {
+ sb.append(planCursor.getString(detailColumn));
+ sb.append("\n");
+ } while (planCursor.moveToNext());
+ if (sb.length() > 0) {
+ sb.setLength(sb.length() - 1);
+ }
+ LogUtil.v(TAG, "for query " + sql + "\nplan is: "
+ + sb.toString());
+ }
+ } catch (final Exception e) {
+ LogUtil.w(TAG, "Query plan failed ", e);
+ } finally {
+ if (planCursor != null) {
+ planCursor.close();
+ }
+ }
+ }
+
+ public Cursor query(final String searchTable, final String[] projection,
+ final String selection, final String[] selectionArgs, final String groupBy,
+ final String having, final String orderBy, final String limit) {
+ if (mExplainQueryPlanRegexp != null) {
+ final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+ qb.setTables(searchTable);
+ explainQueryPlan(qb, mDatabase, projection, selection, selectionArgs,
+ groupBy, having, orderBy, limit);
+ }
+
+ maybePlayDebugNoise();
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ final Cursor cursor = mDatabase.query(searchTable, projection, selection, selectionArgs,
+ groupBy, having, orderBy, limit);
+ if (mLog) {
+ printTiming(
+ t1,
+ String.format(Locale.US, "query %s with %s ==> %d",
+ searchTable, selection, cursor.getCount()));
+ }
+ return cursor;
+ }
+
+ public Cursor query(final String searchTable, final String[] columns,
+ final String selection, final String[] selectionArgs, final String groupBy,
+ final String having, final String orderBy) {
+ return query(
+ searchTable, columns, selection, selectionArgs,
+ groupBy, having, orderBy, null);
+ }
+
+ public Cursor query(final SQLiteQueryBuilder qb,
+ final String[] projection, final String selection, final String[] queryArgs,
+ final String groupBy, final String having, final String sortOrder, final String limit) {
+ if (mExplainQueryPlanRegexp != null) {
+ explainQueryPlan(qb, mDatabase, projection, selection, queryArgs,
+ groupBy, having, sortOrder, limit);
+ }
+ maybePlayDebugNoise();
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ final Cursor cursor = qb.query(mDatabase, projection, selection, queryArgs, groupBy,
+ having, sortOrder, limit);
+ if (mLog) {
+ printTiming(
+ t1,
+ String.format(Locale.US, "query %s with %s ==> %d",
+ qb.getTables(), selection, cursor.getCount()));
+ }
+ return cursor;
+ }
+
+ public long queryNumEntries(final String table, final String selection,
+ final String[] selectionArgs) {
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ maybePlayDebugNoise();
+ final long retval =
+ DatabaseUtils.queryNumEntries(mDatabase, table, selection, selectionArgs);
+ if (mLog){
+ printTiming(
+ t1,
+ String.format(Locale.US, "queryNumEntries %s with %s ==> %d", table,
+ selection, retval));
+ }
+ return retval;
+ }
+
+ public Cursor rawQuery(final String sql, final String[] args) {
+ if (mExplainQueryPlanRegexp != null) {
+ explainQueryPlan(mDatabase, sql, args);
+ }
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ maybePlayDebugNoise();
+ final Cursor cursor = mDatabase.rawQuery(sql, args);
+ if (mLog) {
+ printTiming(
+ t1,
+ String.format(Locale.US, "rawQuery %s ==> %d", sql, cursor.getCount()));
+ }
+ return cursor;
+ }
+
+ public int update(final String table, final ContentValues values,
+ final String selection, final String[] selectionArgs) {
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ maybePlayDebugNoise();
+ int count = 0;
+ try {
+ count = mDatabase.update(table, values, selection, selectionArgs);
+ } catch (SQLiteFullException ex) {
+ LogUtil.e(TAG, "Database full, unable to update", ex);
+ UiUtils.showToastAtBottom(R.string.db_full);
+ }
+ if (mLog) {
+ printTiming(t1, String.format(Locale.US, "update %s with %s ==> %d",
+ table, selection, count));
+ }
+ return count;
+ }
+
+ public int delete(final String table, final String whereClause, final String[] whereArgs) {
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ maybePlayDebugNoise();
+ int count = 0;
+ try {
+ count = mDatabase.delete(table, whereClause, whereArgs);
+ } catch (SQLiteFullException ex) {
+ LogUtil.e(TAG, "Database full, unable to delete", ex);
+ UiUtils.showToastAtBottom(R.string.db_full);
+ }
+ if (mLog) {
+ printTiming(t1,
+ String.format(Locale.US, "delete from %s with %s ==> %d", table,
+ whereClause, count));
+ }
+ return count;
+ }
+
+ public long insert(final String table, final String nullColumnHack,
+ final ContentValues values) {
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ maybePlayDebugNoise();
+ long rowId = -1;
+ try {
+ rowId = mDatabase.insert(table, nullColumnHack, values);
+ } catch (SQLiteFullException ex) {
+ LogUtil.e(TAG, "Database full, unable to insert", ex);
+ UiUtils.showToastAtBottom(R.string.db_full);
+ }
+ if (mLog) {
+ printTiming(t1, String.format(Locale.US, "insert to %s", table));
+ }
+ return rowId;
+ }
+
+ public long replace(final String table, final String nullColumnHack,
+ final ContentValues values) {
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ maybePlayDebugNoise();
+ long rowId = -1;
+ try {
+ rowId = mDatabase.replace(table, nullColumnHack, values);
+ } catch (SQLiteFullException ex) {
+ LogUtil.e(TAG, "Database full, unable to replace", ex);
+ UiUtils.showToastAtBottom(R.string.db_full);
+ }
+ if (mLog) {
+ printTiming(t1, String.format(Locale.US, "replace to %s", table));
+ }
+ return rowId;
+ }
+
+ public void setLocale(final Locale locale) {
+ mDatabase.setLocale(locale);
+ }
+
+ public void execSQL(final String sql, final String[] bindArgs) {
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ maybePlayDebugNoise();
+ try {
+ mDatabase.execSQL(sql, bindArgs);
+ } catch (SQLiteFullException ex) {
+ LogUtil.e(TAG, "Database full, unable to execSQL", ex);
+ UiUtils.showToastAtBottom(R.string.db_full);
+ }
+
+ if (mLog) {
+ printTiming(t1, String.format(Locale.US, "execSQL %s", sql));
+ }
+ }
+
+ public void execSQL(final String sql) {
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ maybePlayDebugNoise();
+ try {
+ mDatabase.execSQL(sql);
+ } catch (SQLiteFullException ex) {
+ LogUtil.e(TAG, "Database full, unable to execSQL", ex);
+ UiUtils.showToastAtBottom(R.string.db_full);
+ }
+
+ if (mLog) {
+ printTiming(t1, String.format(Locale.US, "execSQL %s", sql));
+ }
+ }
+
+ public int execSQLUpdateDelete(final String sql) {
+ long t1 = 0;
+ if (mLog) {
+ t1 = System.currentTimeMillis();
+ }
+ maybePlayDebugNoise();
+ final SQLiteStatement statement = mDatabase.compileStatement(sql);
+ int rowsUpdated = 0;
+ try {
+ rowsUpdated = statement.executeUpdateDelete();
+ } catch (SQLiteFullException ex) {
+ LogUtil.e(TAG, "Database full, unable to execSQLUpdateDelete", ex);
+ UiUtils.showToastAtBottom(R.string.db_full);
+ }
+ if (mLog) {
+ printTiming(t1, String.format(Locale.US, "execSQLUpdateDelete %s", sql));
+ }
+ return rowsUpdated;
+ }
+
+ public SQLiteDatabase getDatabase() {
+ return mDatabase;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/FileProvider.java b/src/com/android/messaging/datamodel/FileProvider.java
new file mode 100644
index 0000000..ee332cd
--- /dev/null
+++ b/src/com/android/messaging/datamodel/FileProvider.java
@@ -0,0 +1,151 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.text.TextUtils;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.Random;
+
+/**
+ * A very simple content provider that can serve files.
+ */
+public abstract class FileProvider extends ContentProvider {
+ // Object to generate random id for temp images.
+ private static final Random RANDOM_ID = new Random();
+
+ abstract File getFile(final String path, final String extension);
+
+ private static final String FILE_EXTENSION_PARAM_KEY = "ext";
+
+ /**
+ * Check if filename conforms to requirement for our provider
+ * @param fileId filename (optionally starting with path character
+ * @return true if filename consists only of digits
+ */
+ protected static boolean isValidFileId(final String fileId) {
+ // Ignore initial "/"
+ for (int index = (fileId.startsWith("/") ? 1 : 0); index < fileId.length(); index++) {
+ final Character c = fileId.charAt(index);
+ if (!Character.isDigit(c)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Create a temp file (to allow writing to that one particular file)
+ * @param file the file to create
+ * @return true if file successfully created
+ */
+ protected static boolean ensureFileExists(final File file) {
+ try {
+ final File parentDir = file.getParentFile();
+ if (parentDir.exists() || parentDir.mkdirs()) {
+ return file.createNewFile();
+ }
+ } catch (final IOException e) {
+ // fail on exceptions creating the file
+ }
+ return false;
+ }
+
+ /**
+ * Build uri for a new temporary file (creating file)
+ * @param authority authority with which to populate uri
+ * @param extension optional file extension
+ * @return unique uri that can be used to write temporary files
+ */
+ protected static Uri buildFileUri(final String authority, final String extension) {
+ final long fileId = Math.abs(RANDOM_ID.nextLong());
+ final Uri.Builder builder = (new Uri.Builder()).authority(authority).scheme(
+ ContentResolver.SCHEME_CONTENT);
+ builder.appendPath(String.valueOf(fileId));
+ if (!TextUtils.isEmpty(extension)) {
+ builder.appendQueryParameter(FILE_EXTENSION_PARAM_KEY, extension);
+ }
+ return builder.build();
+ }
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ @Override
+ public int delete(final Uri uri, final String selection, final String[] selectionArgs) {
+ final String fileId = uri.getPath();
+ if (isValidFileId(fileId)) {
+ final File file = getFile(fileId, getExtensionFromUri(uri));
+ return file.delete() ? 1 : 0;
+ }
+ return 0;
+ }
+
+ @Override
+ public ParcelFileDescriptor openFile(final Uri uri, final String fileMode)
+ throws FileNotFoundException {
+ final String fileId = uri.getPath();
+ if (isValidFileId(fileId)) {
+ final File file = getFile(fileId, getExtensionFromUri(uri));
+ final int mode =
+ (TextUtils.equals(fileMode, "r") ? ParcelFileDescriptor.MODE_READ_ONLY :
+ ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_TRUNCATE);
+ return ParcelFileDescriptor.open(file, mode);
+ }
+ return null;
+ }
+
+ protected static String getExtensionFromUri(final Uri uri) {
+ return uri.getQueryParameter(FILE_EXTENSION_PARAM_KEY);
+ }
+
+ @Override
+ public Cursor query(final Uri uri, final String[] projection, final String selection,
+ final String[] selectionArgs, final String sortOrder) {
+ // Don't support queries.
+ return null;
+ }
+
+ @Override
+ public Uri insert(final Uri uri, final ContentValues values) {
+ // Don't support inserts.
+ return null;
+ }
+
+ @Override
+ public int update(final Uri uri, final ContentValues values, final String selection,
+ final String[] selectionArgs) {
+ // Don't support updates.
+ return 0;
+ }
+
+ @Override
+ public String getType(final Uri uri) {
+ // No need for mime types.
+ return null;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java b/src/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java
new file mode 100644
index 0000000..62483a0
--- /dev/null
+++ b/src/com/android/messaging/datamodel/FrequentContactsCursorBuilder.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.messaging.datamodel;
+
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.support.v4.util.SimpleArrayMap;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContactUtil;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+
+/**
+ * A cursor builder that takes the frequent contacts cursor and aggregate it with the all contacts
+ * cursor to fill in contact details such as phone numbers and strip away invalid contacts.
+ *
+ * Because the frequent contact list depends on the loading of two cursors, it needs to temporarily
+ * store the cursor that it receives with setFrequents() and setAllContacts() calls. Because it
+ * doesn't know which one will be finished first, it always checks whether both cursors are ready
+ * to pull data from and construct the aggregate cursor when it's ready to do so. Note that
+ * this cursor builder doesn't assume ownership of the cursors passed in - it merely references
+ * them and always does a isClosed() check before consuming them. The ownership still belongs to
+ * the loader framework and the cursor may be closed when the UI is torn down.
+ */
+public class FrequentContactsCursorBuilder {
+ private Cursor mAllContactsCursor;
+ private Cursor mFrequentContactsCursor;
+
+ /**
+ * Sets the frequent contacts cursor as soon as it is loaded, or null if it's reset.
+ * @return this builder instance for chained operations
+ */
+ public FrequentContactsCursorBuilder setFrequents(final Cursor frequentContactsCursor) {
+ mFrequentContactsCursor = frequentContactsCursor;
+ return this;
+ }
+
+ /**
+ * Sets the all contacts cursor as soon as it is loaded, or null if it's reset.
+ * @return this builder instance for chained operations
+ */
+ public FrequentContactsCursorBuilder setAllContacts(final Cursor allContactsCursor) {
+ mAllContactsCursor = allContactsCursor;
+ return this;
+ }
+
+ /**
+ * Reset this builder. Must be called when the consumer resets its data.
+ */
+ public void resetBuilder() {
+ mAllContactsCursor = null;
+ mFrequentContactsCursor = null;
+ }
+
+ /**
+ * Attempt to build the cursor records from the frequent and all contacts cursor if they
+ * are both ready to be consumed.
+ * @return the frequent contact cursor if built successfully, or null if it can't be built yet.
+ */
+ public Cursor build() {
+ if (mFrequentContactsCursor != null && mAllContactsCursor != null) {
+ Assert.isTrue(!mFrequentContactsCursor.isClosed());
+ Assert.isTrue(!mAllContactsCursor.isClosed());
+
+ // Frequent contacts cursor has one record per contact, plus it doesn't contain info
+ // such as phone number and type. In order for the records to be usable by Bugle, we
+ // would like to populate it with information from the all contacts cursor.
+ final MatrixCursor retCursor = new MatrixCursor(ContactUtil.PhoneQuery.PROJECTION);
+
+ // First, go through the frequents cursor and take note of all lookup keys and their
+ // corresponding rank in the frequents list.
+ final SimpleArrayMap<String, Integer> lookupKeyToRankMap =
+ new SimpleArrayMap<String, Integer>();
+ int oldPosition = mFrequentContactsCursor.getPosition();
+ int rank = 0;
+ mFrequentContactsCursor.moveToPosition(-1);
+ while (mFrequentContactsCursor.moveToNext()) {
+ final String lookupKey = mFrequentContactsCursor.getString(
+ ContactUtil.INDEX_LOOKUP_KEY_FREQUENT);
+ lookupKeyToRankMap.put(lookupKey, rank++);
+ }
+ mFrequentContactsCursor.moveToPosition(oldPosition);
+
+ // Second, go through the all contacts cursor once and retrieve all information
+ // (multiple phone numbers etc.) and store that in an array list. Since the all
+ // contacts list only contains phone contacts, this step will ensure that we filter
+ // out any invalid/email contacts in the frequents list.
+ final ArrayList<Object[]> rows =
+ new ArrayList<Object[]>(mFrequentContactsCursor.getCount());
+ oldPosition = mAllContactsCursor.getPosition();
+ mAllContactsCursor.moveToPosition(-1);
+ while (mAllContactsCursor.moveToNext()) {
+ final String lookupKey = mAllContactsCursor.getString(ContactUtil.INDEX_LOOKUP_KEY);
+ if (lookupKeyToRankMap.containsKey(lookupKey)) {
+ final Object[] row = new Object[ContactUtil.PhoneQuery.PROJECTION.length];
+ row[ContactUtil.INDEX_DATA_ID] =
+ mAllContactsCursor.getLong(ContactUtil.INDEX_DATA_ID);
+ row[ContactUtil.INDEX_CONTACT_ID] =
+ mAllContactsCursor.getLong(ContactUtil.INDEX_CONTACT_ID);
+ row[ContactUtil.INDEX_LOOKUP_KEY] =
+ mAllContactsCursor.getString(ContactUtil.INDEX_LOOKUP_KEY);
+ row[ContactUtil.INDEX_DISPLAY_NAME] =
+ mAllContactsCursor.getString(ContactUtil.INDEX_DISPLAY_NAME);
+ row[ContactUtil.INDEX_PHOTO_URI] =
+ mAllContactsCursor.getString(ContactUtil.INDEX_PHOTO_URI);
+ row[ContactUtil.INDEX_PHONE_EMAIL] =
+ mAllContactsCursor.getString(ContactUtil.INDEX_PHONE_EMAIL);
+ row[ContactUtil.INDEX_PHONE_EMAIL_TYPE] =
+ mAllContactsCursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE);
+ row[ContactUtil.INDEX_PHONE_EMAIL_LABEL] =
+ mAllContactsCursor.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL);
+ rows.add(row);
+ }
+ }
+ mAllContactsCursor.moveToPosition(oldPosition);
+
+ // Now we have a list of rows containing frequent contacts in alphabetical order.
+ // Therefore, sort all the rows according to their actual ranks in the frequents list.
+ Collections.sort(rows, new Comparator<Object[]>() {
+ @Override
+ public int compare(final Object[] lhs, final Object[] rhs) {
+ final String lookupKeyLhs = (String) lhs[ContactUtil.INDEX_LOOKUP_KEY];
+ final String lookupKeyRhs = (String) rhs[ContactUtil.INDEX_LOOKUP_KEY];
+ Assert.isTrue(lookupKeyToRankMap.containsKey(lookupKeyLhs) &&
+ lookupKeyToRankMap.containsKey(lookupKeyRhs));
+ final int rankLhs = lookupKeyToRankMap.get(lookupKeyLhs);
+ final int rankRhs = lookupKeyToRankMap.get(lookupKeyRhs);
+ if (rankLhs < rankRhs) {
+ return -1;
+ } else if (rankLhs > rankRhs) {
+ return 1;
+ } else {
+ // Same rank, so it's two contact records for the same contact.
+ // Perform secondary sorting on the phone type. Always place
+ // mobile before everything else.
+ final int phoneTypeLhs = (int) lhs[ContactUtil.INDEX_PHONE_EMAIL_TYPE];
+ final int phoneTypeRhs = (int) rhs[ContactUtil.INDEX_PHONE_EMAIL_TYPE];
+ if (phoneTypeLhs == Phone.TYPE_MOBILE &&
+ phoneTypeRhs == Phone.TYPE_MOBILE) {
+ return 0;
+ } else if (phoneTypeLhs == Phone.TYPE_MOBILE) {
+ return -1;
+ } else if (phoneTypeRhs == Phone.TYPE_MOBILE) {
+ return 1;
+ } else {
+ // Use the default sort order, i.e. sort by phoneType value.
+ return phoneTypeLhs < phoneTypeRhs ? -1 :
+ (phoneTypeLhs == phoneTypeRhs ? 0 : 1);
+ }
+ }
+ }
+ });
+
+ // Finally, add all the rows to this cursor.
+ for (final Object[] row : rows) {
+ retCursor.addRow(row);
+ }
+ return retCursor;
+ }
+ return null;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/FrequentContactsCursorQueryData.java b/src/com/android/messaging/datamodel/FrequentContactsCursorQueryData.java
new file mode 100644
index 0000000..d1759ad
--- /dev/null
+++ b/src/com/android/messaging/datamodel/FrequentContactsCursorQueryData.java
@@ -0,0 +1,114 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+
+import com.android.messaging.util.FallbackStrategies;
+import com.android.messaging.util.FallbackStrategies.Strategy;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+
+/**
+ * Helper for querying frequent (and/or starred) contacts.
+ */
+public class FrequentContactsCursorQueryData extends CursorQueryData {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private static class FrequentContactsCursorLoader extends BoundCursorLoader {
+ private final Uri mOriginalUri;
+
+ FrequentContactsCursorLoader(String bindingId, Context context, Uri uri,
+ String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ super(bindingId, context, uri, projection, selection, selectionArgs, sortOrder);
+ mOriginalUri = uri;
+ }
+
+ @Override
+ public Cursor loadInBackground() {
+ return FallbackStrategies
+ .startWith(new PrimaryStrequentContactsQueryStrategy())
+ .thenTry(new FrequentOnlyContactsQueryStrategy())
+ .thenTry(new PhoneOnlyStrequentContactsQueryStrategy())
+ .execute(null);
+ }
+
+ private abstract class StrequentContactsQueryStrategy implements Strategy<Void, Cursor> {
+ @Override
+ public Cursor execute(Void params) throws Exception {
+ final Uri uri = getUri();
+ if (uri != null) {
+ setUri(uri);
+ }
+ return FrequentContactsCursorLoader.super.loadInBackground();
+ }
+ protected abstract Uri getUri();
+ }
+
+ private class PrimaryStrequentContactsQueryStrategy extends StrequentContactsQueryStrategy {
+ @Override
+ protected Uri getUri() {
+ // Use the original URI requested.
+ return mOriginalUri;
+ }
+ }
+
+ private class FrequentOnlyContactsQueryStrategy extends StrequentContactsQueryStrategy {
+ @Override
+ protected Uri getUri() {
+ // Some phones have a buggy implementation of the Contacts provider which crashes
+ // when we query for strequent (starred+frequent) contacts (b/17991485).
+ // If this happens, switch to just querying for frequent contacts.
+ return Contacts.CONTENT_FREQUENT_URI;
+ }
+ }
+
+ private class PhoneOnlyStrequentContactsQueryStrategy extends
+ StrequentContactsQueryStrategy {
+ @Override
+ protected Uri getUri() {
+ // Some 3rd party ROMs have content provider
+ // implementation where invalid SQL queries are returned for regular strequent
+ // queries. Using strequent_phone_only query as a fallback to display only phone
+ // contacts. This is the last-ditch effort; if this fails, we will display an
+ // empty frequent list (b/18354836).
+ final String strequentQueryParam = OsUtil.isAtLeastL() ?
+ ContactsContract.STREQUENT_PHONE_ONLY : "strequent_phone_only";
+ // TODO: Handle enterprise contacts post M once contacts provider supports it
+ return Contacts.CONTENT_STREQUENT_URI.buildUpon()
+ .appendQueryParameter(strequentQueryParam, "true").build();
+ }
+ }
+ }
+
+ public FrequentContactsCursorQueryData(Context context, String[] projection,
+ String selection, String[] selectionArgs, String sortOrder) {
+ // TODO: Handle enterprise contacts post M once contacts provider supports it
+ super(context, Contacts.CONTENT_STREQUENT_URI, projection, selection, selectionArgs,
+ sortOrder);
+ }
+
+ @Override
+ public BoundCursorLoader createBoundCursorLoader(String bindingId) {
+ return new FrequentContactsCursorLoader(bindingId, mContext, mUri, mProjection, mSelection,
+ mSelectionArgs, mSortOrder);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java b/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java
new file mode 100644
index 0000000..28ec303
--- /dev/null
+++ b/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java
@@ -0,0 +1,49 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.Context;
+import android.net.Uri;
+import android.provider.MediaStore.Files;
+import android.provider.MediaStore.Files.FileColumns;
+import android.provider.MediaStore.Images.Media;
+
+import com.android.messaging.datamodel.data.GalleryGridItemData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.google.common.base.Joiner;
+
+/**
+ * A BoundCursorLoader that reads local media on the device.
+ */
+public class GalleryBoundCursorLoader extends BoundCursorLoader {
+ public static final String MEDIA_SCANNER_VOLUME_EXTERNAL = "external";
+ private static final Uri STORAGE_URI = Files.getContentUri(MEDIA_SCANNER_VOLUME_EXTERNAL);
+ private static final String SORT_ORDER = Media.DATE_MODIFIED + " DESC";
+ private static final String IMAGE_SELECTION = createSelection(
+ MessagePartData.ACCEPTABLE_IMAGE_TYPES,
+ new Integer[] { FileColumns.MEDIA_TYPE_IMAGE });
+
+ public GalleryBoundCursorLoader(final String bindingId, final Context context) {
+ super(bindingId, context, STORAGE_URI, GalleryGridItemData.IMAGE_PROJECTION,
+ IMAGE_SELECTION, null, SORT_ORDER);
+ }
+
+ private static String createSelection(final String[] mimeTypes, Integer[] mediaTypes) {
+ return Media.MIME_TYPE + " IN ('" + Joiner.on("','").join(mimeTypes) + "') AND "
+ + FileColumns.MEDIA_TYPE + " IN (" + Joiner.on(',').join(mediaTypes) + ")";
+ }
+}
diff --git a/src/com/android/messaging/datamodel/MediaScratchFileProvider.java b/src/com/android/messaging/datamodel/MediaScratchFileProvider.java
new file mode 100644
index 0000000..29ae4f4
--- /dev/null
+++ b/src/com/android/messaging/datamodel/MediaScratchFileProvider.java
@@ -0,0 +1,132 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MatrixCursor.RowBuilder;
+import android.net.Uri;
+import android.provider.OpenableColumns;
+import android.support.v4.util.SimpleArrayMap;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+import java.util.List;
+
+/**
+ * A very simple content provider that can serve media files from our cache directory.
+ */
+public class MediaScratchFileProvider extends FileProvider {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private static final SimpleArrayMap<Uri, String> sUriToDisplayNameMap =
+ new SimpleArrayMap<Uri, String>();
+
+ @VisibleForTesting
+ public static final String AUTHORITY =
+ "com.android.messaging.datamodel.MediaScratchFileProvider";
+ private static final String MEDIA_SCRATCH_SPACE_DIR = "mediascratchspace";
+
+ public static boolean isMediaScratchSpaceUri(final Uri uri) {
+ if (uri == null) {
+ return false;
+ }
+
+ final List<String> segments = uri.getPathSegments();
+ return (TextUtils.equals(uri.getScheme(), ContentResolver.SCHEME_CONTENT) &&
+ TextUtils.equals(uri.getAuthority(), AUTHORITY) &&
+ segments.size() == 1 && FileProvider.isValidFileId(segments.get(0)));
+ }
+
+ /**
+ * Returns a uri that can be used to access a raw mms file.
+ *
+ * @return the URI for an raw mms file
+ */
+ public static Uri buildMediaScratchSpaceUri(final String extension) {
+ final Uri uri = FileProvider.buildFileUri(AUTHORITY, extension);
+ final File file = getFileWithExtension(uri.getPath(), extension);
+ if (!ensureFileExists(file)) {
+ LogUtil.e(TAG, "Failed to create temp file " + file.getAbsolutePath());
+ }
+ return uri;
+ }
+
+ public static File getFileFromUri(final Uri uri) {
+ Assert.equals(AUTHORITY, uri.getAuthority());
+ return getFileWithExtension(uri.getPath(), getExtensionFromUri(uri));
+ }
+
+ public static Uri.Builder getUriBuilder() {
+ return (new Uri.Builder()).authority(AUTHORITY).scheme(ContentResolver.SCHEME_CONTENT);
+ }
+
+ @Override
+ File getFile(final String path, final String extension) {
+ return getFileWithExtension(path, extension);
+ }
+
+ private static File getFileWithExtension(final String path, final String extension) {
+ final Context context = Factory.get().getApplicationContext();
+ return new File(getDirectory(context),
+ TextUtils.isEmpty(extension) ? path : path + "." + extension);
+ }
+
+ private static File getDirectory(final Context context) {
+ return new File(context.getCacheDir(), MEDIA_SCRATCH_SPACE_DIR);
+ }
+
+ @Override
+ public Cursor query(final Uri uri, final String[] projection, final String selection,
+ final String[] selectionArgs, final String sortOrder) {
+ if (projection != null && projection.length > 0 &&
+ TextUtils.equals(projection[0], OpenableColumns.DISPLAY_NAME) &&
+ isMediaScratchSpaceUri(uri)) {
+ // Retrieve the display name associated with a temp file. This is used by the Contacts
+ // ImportVCardActivity to retrieve the name of the contact(s) being imported.
+ String displayName;
+ synchronized (sUriToDisplayNameMap) {
+ displayName = sUriToDisplayNameMap.get(uri);
+ }
+ if (!TextUtils.isEmpty(displayName)) {
+ MatrixCursor cursor =
+ new MatrixCursor(new String[] { OpenableColumns.DISPLAY_NAME });
+ RowBuilder row = cursor.newRow();
+ row.add(displayName);
+ return cursor;
+ }
+ }
+ return null;
+ }
+
+ public static void addUriToDisplayNameEntry(final Uri scratchFileUri,
+ final String displayName) {
+ if (TextUtils.isEmpty(displayName)) {
+ return;
+ }
+ synchronized (sUriToDisplayNameMap) {
+ sUriToDisplayNameMap.put(scratchFileUri, displayName);
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/MemoryCacheManager.java b/src/com/android/messaging/datamodel/MemoryCacheManager.java
new file mode 100644
index 0000000..0968cff
--- /dev/null
+++ b/src/com/android/messaging/datamodel/MemoryCacheManager.java
@@ -0,0 +1,75 @@
+/*
+ * 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.messaging.datamodel;
+
+import com.android.messaging.Factory;
+
+import java.util.HashSet;
+
+/**
+ * Utility abstraction which allows MemoryCaches in an application to register and then when there
+ * is memory pressure provide a callback to reclaim the memory in the caches.
+ */
+public class MemoryCacheManager {
+ private final HashSet<MemoryCache> mMemoryCaches = new HashSet<MemoryCache>();
+ private final Object mMemoryCacheLock = new Object();
+
+ public static MemoryCacheManager get() {
+ return Factory.get().getMemoryCacheManager();
+ }
+
+ /**
+ * Extend this interface to provide a reclaim method on a memory cache.
+ */
+ public interface MemoryCache {
+ void reclaim();
+ }
+
+ /**
+ * Register the memory cache with the application.
+ */
+ public void registerMemoryCache(final MemoryCache cache) {
+ synchronized (mMemoryCacheLock) {
+ mMemoryCaches.add(cache);
+ }
+ }
+
+ /**
+ * Unregister the memory cache with the application.
+ */
+ public void unregisterMemoryCache(final MemoryCache cache) {
+ synchronized (mMemoryCacheLock) {
+ mMemoryCaches.remove(cache);
+ }
+ }
+
+ /**
+ * Reclaim memory in all the memory caches in the application.
+ */
+ @SuppressWarnings("unchecked")
+ public void reclaimMemory() {
+ // We're creating a cache copy in the lock to ensure we're not working on a concurrently
+ // modified set, then reclaim outside of the lock to minimize the time within the lock.
+ final HashSet<MemoryCache> shallowCopy;
+ synchronized (mMemoryCacheLock) {
+ shallowCopy = (HashSet<MemoryCache>) mMemoryCaches.clone();
+ }
+ for (final MemoryCache cache : shallowCopy) {
+ cache.reclaim();
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/MessageNotificationState.java b/src/com/android/messaging/datamodel/MessageNotificationState.java
new file mode 100644
index 0000000..0bd4aaa
--- /dev/null
+++ b/src/com/android/messaging/datamodel/MessageNotificationState.java
@@ -0,0 +1,1342 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Typeface;
+import android.net.Uri;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationCompat.Builder;
+import android.support.v4.app.NotificationCompat.WearableExtender;
+import android.support.v4.app.NotificationManagerCompat;
+import android.text.Html;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StyleSpan;
+import android.text.style.TextAppearanceSpan;
+import android.text.style.URLSpan;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.datamodel.data.ConversationParticipantsData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.media.VideoThumbnailRequest;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.ConversationIdSet;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.PendingIntentConstants;
+import com.android.messaging.util.UriUtil;
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Notification building class for conversation messages.
+ *
+ * Message Notifications are built in several stages with several utility classes.
+ * 1) Perform a database query and fill a data structure with information on messages and
+ * conversations which need to be notified.
+ * 2) Based on the data structure choose an appropriate NotificationState subclass to
+ * represent all the notifications.
+ * -- For one or more messages in one conversation: MultiMessageNotificationState.
+ * -- For multiple messages in multiple conversations: MultiConversationNotificationState
+ *
+ * A three level structure is used to coalesce the data from the database. From bottom to top:
+ * 1) NotificationLineInfo - A single message that needs to be notified.
+ * 2) ConversationLineInfo - A list of NotificationLineInfo in a single conversation.
+ * 3) ConversationInfoList - A list of ConversationLineInfo and the total number of messages.
+ *
+ * The createConversationInfoList function performs the query and creates the data structure.
+ */
+public abstract class MessageNotificationState extends NotificationState {
+ // Logging
+ static final String TAG = LogUtil.BUGLE_NOTIFICATIONS_TAG;
+ private static final int MAX_MESSAGES_IN_WEARABLE_PAGE = 20;
+
+ private static final int MAX_CHARACTERS_IN_GROUP_NAME = 30;
+
+ private static final int REPLY_INTENT_REQUEST_CODE_OFFSET = 0;
+ private static final int NUM_EXTRA_REQUEST_CODES_NEEDED = 1;
+ protected String mTickerSender = null;
+ protected CharSequence mTickerText = null;
+ protected String mTitle = null;
+ protected CharSequence mContent = null;
+ protected Uri mAttachmentUri = null;
+ protected String mAttachmentType = null;
+ protected boolean mTickerNoContent;
+
+ @Override
+ protected Uri getAttachmentUri() {
+ return mAttachmentUri;
+ }
+
+ @Override
+ protected String getAttachmentType() {
+ return mAttachmentType;
+ }
+
+ @Override
+ public int getIcon() {
+ return R.drawable.ic_sms_light;
+ }
+
+ @Override
+ public int getPriority() {
+ // Returning PRIORITY_HIGH causes L to put up a HUD notification. Without it, the ticker
+ // isn't displayed.
+ return Notification.PRIORITY_HIGH;
+ }
+
+ /**
+ * Base class for single notification events for messages. Multiple of these
+ * may be grouped into a single conversation.
+ */
+ static class NotificationLineInfo {
+
+ final int mNotificationType;
+
+ NotificationLineInfo() {
+ mNotificationType = BugleNotifications.LOCAL_SMS_NOTIFICATION;
+ }
+
+ NotificationLineInfo(final int notificationType) {
+ mNotificationType = notificationType;
+ }
+ }
+
+ /**
+ * Information on a single chat message which should be shown in a notification.
+ */
+ static class MessageLineInfo extends NotificationLineInfo {
+ final CharSequence mText;
+ Uri mAttachmentUri;
+ String mAttachmentType;
+ final String mAuthorFullName;
+ final String mAuthorFirstName;
+ boolean mIsManualDownloadNeeded;
+ final String mMessageId;
+
+ MessageLineInfo(final boolean isGroup, final String authorFullName,
+ final String authorFirstName, final CharSequence text, final Uri attachmentUrl,
+ final String attachmentType, final boolean isManualDownloadNeeded,
+ final String messageId) {
+ super(BugleNotifications.LOCAL_SMS_NOTIFICATION);
+ mAuthorFullName = authorFullName;
+ mAuthorFirstName = authorFirstName;
+ mText = text;
+ mAttachmentUri = attachmentUrl;
+ mAttachmentType = attachmentType;
+ mIsManualDownloadNeeded = isManualDownloadNeeded;
+ mMessageId = messageId;
+ }
+ }
+
+ /**
+ * Information on all the notification messages within a single conversation.
+ */
+ static class ConversationLineInfo {
+ // Conversation id of the latest message in the notification for this merged conversation.
+ final String mConversationId;
+
+ // True if this represents a group conversation.
+ final boolean mIsGroup;
+
+ // Name of the group conversation if available.
+ final String mGroupConversationName;
+
+ // True if this conversation's recipients includes one or more email address(es)
+ // (see ConversationColumns.INCLUDE_EMAIL_ADDRESS)
+ final boolean mIncludeEmailAddress;
+
+ // Timestamp of the latest message
+ final long mReceivedTimestamp;
+
+ // Self participant id.
+ final String mSelfParticipantId;
+
+ // List of individual line notifications to be parsed later.
+ final List<NotificationLineInfo> mLineInfos;
+
+ // Total number of messages. Might be different that mLineInfos.size() as the number of
+ // line infos is capped.
+ int mTotalMessageCount;
+
+ // Custom ringtone if set
+ final String mRingtoneUri;
+
+ // Should notification be enabled for this conversation?
+ final boolean mNotificationEnabled;
+
+ // Should notifications vibrate for this conversation?
+ final boolean mNotificationVibrate;
+
+ // Avatar uri of sender
+ final Uri mAvatarUri;
+
+ // Contact uri of sender
+ final Uri mContactUri;
+
+ // Subscription id.
+ final int mSubId;
+
+ // Number of participants
+ final int mParticipantCount;
+
+ public ConversationLineInfo(final String conversationId,
+ final boolean isGroup,
+ final String groupConversationName,
+ final boolean includeEmailAddress,
+ final long receivedTimestamp,
+ final String selfParticipantId,
+ final String ringtoneUri,
+ final boolean notificationEnabled,
+ final boolean notificationVibrate,
+ final Uri avatarUri,
+ final Uri contactUri,
+ final int subId,
+ final int participantCount) {
+ mConversationId = conversationId;
+ mIsGroup = isGroup;
+ mGroupConversationName = groupConversationName;
+ mIncludeEmailAddress = includeEmailAddress;
+ mReceivedTimestamp = receivedTimestamp;
+ mSelfParticipantId = selfParticipantId;
+ mLineInfos = new ArrayList<NotificationLineInfo>();
+ mTotalMessageCount = 0;
+ mRingtoneUri = ringtoneUri;
+ mAvatarUri = avatarUri;
+ mContactUri = contactUri;
+ mNotificationEnabled = notificationEnabled;
+ mNotificationVibrate = notificationVibrate;
+ mSubId = subId;
+ mParticipantCount = participantCount;
+ }
+
+ public int getLatestMessageNotificationType() {
+ final MessageLineInfo messageLineInfo = getLatestMessageLineInfo();
+ if (messageLineInfo == null) {
+ return BugleNotifications.LOCAL_SMS_NOTIFICATION;
+ }
+ return messageLineInfo.mNotificationType;
+ }
+
+ public String getLatestMessageId() {
+ final MessageLineInfo messageLineInfo = getLatestMessageLineInfo();
+ if (messageLineInfo == null) {
+ return null;
+ }
+ return messageLineInfo.mMessageId;
+ }
+
+ public boolean getDoesLatestMessageNeedDownload() {
+ final MessageLineInfo messageLineInfo = getLatestMessageLineInfo();
+ if (messageLineInfo == null) {
+ return false;
+ }
+ return messageLineInfo.mIsManualDownloadNeeded;
+ }
+
+ private MessageLineInfo getLatestMessageLineInfo() {
+ // The latest message is stored at index zero of the message line infos.
+ if (mLineInfos.size() > 0 && mLineInfos.get(0) instanceof MessageLineInfo) {
+ return (MessageLineInfo) mLineInfos.get(0);
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Information on all the notification messages across all conversations.
+ */
+ public static class ConversationInfoList {
+ final int mMessageCount;
+ final List<ConversationLineInfo> mConvInfos;
+ public ConversationInfoList(final int count, final List<ConversationLineInfo> infos) {
+ mMessageCount = count;
+ mConvInfos = infos;
+ }
+ }
+
+ final ConversationInfoList mConvList;
+ private long mLatestReceivedTimestamp;
+
+ private static ConversationIdSet makeConversationIdSet(final ConversationInfoList convList) {
+ ConversationIdSet set = null;
+ if (convList != null && convList.mConvInfos != null && convList.mConvInfos.size() > 0) {
+ set = new ConversationIdSet();
+ for (final ConversationLineInfo info : convList.mConvInfos) {
+ set.add(info.mConversationId);
+ }
+ }
+ return set;
+ }
+
+ protected MessageNotificationState(final ConversationInfoList convList) {
+ super(makeConversationIdSet(convList));
+ mConvList = convList;
+ mType = PendingIntentConstants.SMS_NOTIFICATION_ID;
+ mLatestReceivedTimestamp = Long.MIN_VALUE;
+ if (convList != null) {
+ for (final ConversationLineInfo info : convList.mConvInfos) {
+ mLatestReceivedTimestamp = Math.max(mLatestReceivedTimestamp,
+ info.mReceivedTimestamp);
+ }
+ }
+ }
+
+ @Override
+ public long getLatestReceivedTimestamp() {
+ return mLatestReceivedTimestamp;
+ }
+
+ @Override
+ public int getNumRequestCodesNeeded() {
+ // Get additional request codes for the Reply PendingIntent (wearables only)
+ // and the DND PendingIntent.
+ return super.getNumRequestCodesNeeded() + NUM_EXTRA_REQUEST_CODES_NEEDED;
+ }
+
+ private int getBaseExtraRequestCode() {
+ return mBaseRequestCode + super.getNumRequestCodesNeeded();
+ }
+
+ public int getReplyIntentRequestCode() {
+ return getBaseExtraRequestCode() + REPLY_INTENT_REQUEST_CODE_OFFSET;
+ }
+
+ @Override
+ public PendingIntent getClearIntent() {
+ return UIIntents.get().getPendingIntentForClearingNotifications(
+ Factory.get().getApplicationContext(),
+ BugleNotifications.UPDATE_MESSAGES,
+ mConversationIds,
+ getClearIntentRequestCode());
+ }
+
+ /**
+ * Notification for multiple messages in at least 2 different conversations.
+ */
+ public static class MultiConversationNotificationState extends MessageNotificationState {
+
+ public final List<MessageNotificationState>
+ mChildren = new ArrayList<MessageNotificationState>();
+
+ public MultiConversationNotificationState(
+ final ConversationInfoList convList, final MessageNotificationState state) {
+ super(convList);
+ mAttachmentUri = null;
+ mAttachmentType = null;
+
+ // Pull the ticker title/text from the single notification
+ mTickerSender = state.getTitle();
+ mTitle = Factory.get().getApplicationContext().getResources().getQuantityString(
+ R.plurals.notification_new_messages,
+ convList.mMessageCount, convList.mMessageCount);
+ mTickerText = state.mContent;
+
+ // Create child notifications for each conversation,
+ // which will be displayed (only) on a wearable device.
+ for (int i = 0; i < convList.mConvInfos.size(); i++) {
+ final ConversationLineInfo convInfo = convList.mConvInfos.get(i);
+ if (!(convInfo.mLineInfos.get(0) instanceof MessageLineInfo)) {
+ continue;
+ }
+ setPeopleForConversation(convInfo.mConversationId);
+ final ConversationInfoList list = new ConversationInfoList(
+ convInfo.mTotalMessageCount, Lists.newArrayList(convInfo));
+ mChildren.add(new BundledMessageNotificationState(list, i));
+ }
+ }
+
+ @Override
+ public int getIcon() {
+ return R.drawable.ic_sms_multi_light;
+ }
+
+ @Override
+ protected NotificationCompat.Style build(final Builder builder) {
+ builder.setContentTitle(mTitle);
+ NotificationCompat.InboxStyle inboxStyle = null;
+ inboxStyle = new NotificationCompat.InboxStyle(builder);
+
+ final Context context = Factory.get().getApplicationContext();
+ // enumeration_comma is defined as ", "
+ final String separator = context.getString(R.string.enumeration_comma);
+ final StringBuilder senders = new StringBuilder();
+ long when = 0;
+ for (int i = 0; i < mConvList.mConvInfos.size(); i++) {
+ final ConversationLineInfo convInfo = mConvList.mConvInfos.get(i);
+ if (convInfo.mReceivedTimestamp > when) {
+ when = convInfo.mReceivedTimestamp;
+ }
+ String sender;
+ CharSequence text;
+ final NotificationLineInfo lineInfo = convInfo.mLineInfos.get(0);
+ final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfo;
+ if (convInfo.mIsGroup) {
+ sender = (convInfo.mGroupConversationName.length() >
+ MAX_CHARACTERS_IN_GROUP_NAME) ?
+ truncateGroupMessageName(convInfo.mGroupConversationName)
+ : convInfo.mGroupConversationName;
+ } else {
+ sender = messageLineInfo.mAuthorFullName;
+ }
+ text = messageLineInfo.mText;
+ mAttachmentUri = messageLineInfo.mAttachmentUri;
+ mAttachmentType = messageLineInfo.mAttachmentType;
+
+ inboxStyle.addLine(BugleNotifications.formatInboxMessage(
+ sender, text, mAttachmentUri, mAttachmentType));
+ if (sender != null) {
+ if (senders.length() > 0) {
+ senders.append(separator);
+ }
+ senders.append(sender);
+ }
+ }
+ // for collapsed state
+ mContent = senders;
+ builder.setContentText(senders)
+ .setTicker(getTicker())
+ .setWhen(when);
+
+ return inboxStyle;
+ }
+ }
+
+ /**
+ * Truncate group conversation name to be displayed in the notifications. This either truncates
+ * the entire group name or finds the last comma in the available length and truncates the name
+ * at that point
+ */
+ private static String truncateGroupMessageName(final String conversationName) {
+ int endIndex = MAX_CHARACTERS_IN_GROUP_NAME;
+ for (int i = MAX_CHARACTERS_IN_GROUP_NAME; i >= 0; i--) {
+ // The dividing marker should stay consistent with ConversationListItemData.DIVIDER_TEXT
+ if (conversationName.charAt(i) == ',') {
+ endIndex = i;
+ break;
+ }
+ }
+ return conversationName.substring(0, endIndex) + '\u2026';
+ }
+
+ /**
+ * Notification for multiple messages in a single conversation. Also used if there is a single
+ * message in a single conversation.
+ */
+ public static class MultiMessageNotificationState extends MessageNotificationState {
+
+ public MultiMessageNotificationState(final ConversationInfoList convList) {
+ super(convList);
+ // This conversation has been accepted.
+ final ConversationLineInfo convInfo = convList.mConvInfos.get(0);
+ setAvatarUrlsForConversation(convInfo.mConversationId);
+ setPeopleForConversation(convInfo.mConversationId);
+
+ final Context context = Factory.get().getApplicationContext();
+ MessageLineInfo messageInfo = (MessageLineInfo) convInfo.mLineInfos.get(0);
+ // attached photo
+ mAttachmentUri = messageInfo.mAttachmentUri;
+ mAttachmentType = messageInfo.mAttachmentType;
+ mContent = messageInfo.mText;
+
+ if (mAttachmentUri != null) {
+ // The default attachment type is an image, since that's what was originally
+ // supported. When there's no content type, assume it's an image.
+ int message = R.string.notification_picture;
+ if (ContentType.isAudioType(mAttachmentType)) {
+ message = R.string.notification_audio;
+ } else if (ContentType.isVideoType(mAttachmentType)) {
+ message = R.string.notification_video;
+ } else if (ContentType.isVCardType(mAttachmentType)) {
+ message = R.string.notification_vcard;
+ }
+ final String attachment = context.getString(message);
+ final SpannableStringBuilder spanBuilder = new SpannableStringBuilder();
+ if (!TextUtils.isEmpty(mContent)) {
+ spanBuilder.append(mContent).append(System.getProperty("line.separator"));
+ }
+ final int start = spanBuilder.length();
+ spanBuilder.append(attachment);
+ spanBuilder.setSpan(new StyleSpan(Typeface.ITALIC), start, spanBuilder.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ mContent = spanBuilder;
+ }
+ if (convInfo.mIsGroup) {
+ // When the message is part of a group, the sender's first name
+ // is prepended to the message, but not for the ticker message.
+ mTickerText = mContent;
+ mTickerSender = messageInfo.mAuthorFullName;
+ // append the bold name to the front of the message
+ mContent = BugleNotifications.buildSpaceSeparatedMessage(
+ messageInfo.mAuthorFullName, mContent, mAttachmentUri,
+ mAttachmentType);
+ mTitle = convInfo.mGroupConversationName;
+ } else {
+ // No matter how many messages there are, since this is a 1:1, just
+ // get the author full name from the first one.
+ messageInfo = (MessageLineInfo) convInfo.mLineInfos.get(0);
+ mTitle = messageInfo.mAuthorFullName;
+ }
+ }
+
+ @Override
+ protected NotificationCompat.Style build(final Builder builder) {
+ builder.setContentTitle(mTitle)
+ .setTicker(getTicker());
+
+ NotificationCompat.Style notifStyle = null;
+ final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0);
+ final List<NotificationLineInfo> lineInfos = convInfo.mLineInfos;
+ final int messageCount = lineInfos.size();
+ // At this point, all the messages come from the same conversation. We need to load
+ // the sender's avatar and then finish building the notification on a callback.
+
+ builder.setContentText(mContent); // for collapsed state
+
+ if (messageCount == 1) {
+ final boolean shouldShowImage = ContentType.isImageType(mAttachmentType)
+ || (ContentType.isVideoType(mAttachmentType)
+ && VideoThumbnailRequest.shouldShowIncomingVideoThumbnails());
+ if (mAttachmentUri != null && shouldShowImage) {
+ // Show "Picture" as the content
+ final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfos.get(0);
+ String authorFirstName = messageLineInfo.mAuthorFirstName;
+
+ // For the collapsed state, just show "picture" unless this is a
+ // group conversation. If it's a group, show the sender name and
+ // "picture".
+ final CharSequence tickerTag =
+ BugleNotifications.formatAttachmentTag(authorFirstName,
+ mAttachmentType);
+ // For 1:1 notifications don't show first name in the notification, but
+ // do show it in the ticker text
+ CharSequence pictureTag = tickerTag;
+ if (!convInfo.mIsGroup) {
+ authorFirstName = null;
+ pictureTag = BugleNotifications.formatAttachmentTag(authorFirstName,
+ mAttachmentType);
+ }
+ builder.setContentText(pictureTag);
+ builder.setTicker(tickerTag);
+
+ notifStyle = new NotificationCompat.BigPictureStyle(builder)
+ .setSummaryText(BugleNotifications.formatInboxMessage(
+ authorFirstName,
+ null, null,
+ null)); // expanded state, just show sender
+ } else {
+ notifStyle = new NotificationCompat.BigTextStyle(builder)
+ .bigText(mContent);
+ }
+ } else {
+ // We've got multiple messages for the same sender.
+ // Starting with the oldest new message, display the full text of each message.
+ // Begin a line for each subsequent message.
+ final SpannableStringBuilder buf = new SpannableStringBuilder();
+
+ for (int i = lineInfos.size() - 1; i >= 0; --i) {
+ final NotificationLineInfo info = lineInfos.get(i);
+ final MessageLineInfo messageLineInfo = (MessageLineInfo) info;
+ mAttachmentUri = messageLineInfo.mAttachmentUri;
+ mAttachmentType = messageLineInfo.mAttachmentType;
+ CharSequence text = messageLineInfo.mText;
+ if (!TextUtils.isEmpty(text) || mAttachmentUri != null) {
+ if (convInfo.mIsGroup) {
+ // append the bold name to the front of the message
+ text = BugleNotifications.buildSpaceSeparatedMessage(
+ messageLineInfo.mAuthorFullName, text, mAttachmentUri,
+ mAttachmentType);
+ } else {
+ text = BugleNotifications.buildSpaceSeparatedMessage(
+ null, text, mAttachmentUri, mAttachmentType);
+ }
+ buf.append(text);
+ if (i > 0) {
+ buf.append('\n');
+ }
+ }
+ }
+
+ // Show a single notification -- big style with the text of all the messages
+ notifStyle = new NotificationCompat.BigTextStyle(builder).bigText(buf);
+ }
+ builder.setWhen(convInfo.mReceivedTimestamp);
+ return notifStyle;
+ }
+
+ }
+
+ private static boolean firstNameUsedMoreThanOnce(
+ final HashMap<String, Integer> map, final String firstName) {
+ if (map == null) {
+ return false;
+ }
+ if (firstName == null) {
+ return false;
+ }
+ final Integer count = map.get(firstName);
+ if (count != null) {
+ return count > 1;
+ } else {
+ return false;
+ }
+ }
+
+ private static HashMap<String, Integer> scanFirstNames(final String conversationId) {
+ final Context context = Factory.get().getApplicationContext();
+ final Uri uri =
+ MessagingContentProvider.buildConversationParticipantsUri(conversationId);
+ final Cursor participantsCursor = context.getContentResolver().query(
+ uri, ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
+ final ConversationParticipantsData participantsData = new ConversationParticipantsData();
+ participantsData.bind(participantsCursor);
+ final Iterator<ParticipantData> iter = participantsData.iterator();
+
+ final HashMap<String, Integer> firstNames = new HashMap<String, Integer>();
+ boolean seenSelf = false;
+ while (iter.hasNext()) {
+ final ParticipantData participant = iter.next();
+ // Make sure we only add the self participant once
+ if (participant.isSelf()) {
+ if (seenSelf) {
+ continue;
+ } else {
+ seenSelf = true;
+ }
+ }
+
+ final String firstName = participant.getFirstName();
+ if (firstName == null) {
+ continue;
+ }
+
+ final int currentCount = firstNames.containsKey(firstName)
+ ? firstNames.get(firstName)
+ : 0;
+ firstNames.put(firstName, currentCount + 1);
+ }
+ return firstNames;
+ }
+
+ // Essentially, we're building a list of the past 20 messages for this conversation to display
+ // on the wearable.
+ public static Notification buildConversationPageForWearable(final String conversationId,
+ int participantCount) {
+ final Context context = Factory.get().getApplicationContext();
+
+ // Limit the number of messages to show. We just want enough to provide context for the
+ // notification. Fetch one more than we need, so we can tell if there are more messages
+ // before the one we're showing.
+ // TODO: in the query, a multipart message will contain a row for each part.
+ // We might need a smarter GROUP_BY. On the other hand, we might want to show each of the
+ // parts as separate messages on the wearable.
+ final int limit = MAX_MESSAGES_IN_WEARABLE_PAGE + 1;
+
+ final List<CharSequence> messages = Lists.newArrayList();
+ boolean hasSeenMessagesBeforeNotification = false;
+ Cursor convMessageCursor = null;
+ try {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ final String[] queryArgs = { conversationId };
+ final String convPageSql = ConversationMessageData.getWearableQuerySql() + " LIMIT " +
+ limit;
+ convMessageCursor = db.rawQuery(
+ convPageSql,
+ queryArgs);
+
+ if (convMessageCursor == null || !convMessageCursor.moveToFirst()) {
+ return null;
+ }
+ final ConversationMessageData convMessageData =
+ new ConversationMessageData();
+
+ final HashMap<String, Integer> firstNames = scanFirstNames(conversationId);
+ do {
+ convMessageData.bind(convMessageCursor);
+
+ final String authorFullName = convMessageData.getSenderFullName();
+ final String authorFirstName = convMessageData.getSenderFirstName();
+ String text = convMessageData.getText();
+
+ final boolean isSmsPushNotification = convMessageData.getIsMmsNotification();
+
+ // if auto-download was off to show a message to tap to download the message. We
+ // might need to get that working again.
+ if (isSmsPushNotification && text != null) {
+ text = convertHtmlAndStripUrls(text).toString();
+ }
+ // Skip messages without any content
+ if (TextUtils.isEmpty(text) && !convMessageData.hasAttachments()) {
+ continue;
+ }
+ // Track whether there are messages prior to the one(s) shown in the notification.
+ if (convMessageData.getIsSeen()) {
+ hasSeenMessagesBeforeNotification = true;
+ }
+
+ final boolean usedMoreThanOnce = firstNameUsedMoreThanOnce(
+ firstNames, authorFirstName);
+ String displayName = usedMoreThanOnce ? authorFullName : authorFirstName;
+ if (TextUtils.isEmpty(displayName)) {
+ if (convMessageData.getIsIncoming()) {
+ displayName = convMessageData.getSenderDisplayDestination();
+ if (TextUtils.isEmpty(displayName)) {
+ displayName = context.getString(R.string.unknown_sender);
+ }
+ } else {
+ displayName = context.getString(R.string.unknown_self_participant);
+ }
+ }
+
+ Uri attachmentUri = null;
+ String attachmentType = null;
+ final List<MessagePartData> attachments = convMessageData.getAttachments();
+ for (final MessagePartData messagePartData : attachments) {
+ // Look for the first attachment that's not the text piece.
+ if (!messagePartData.isText()) {
+ attachmentUri = messagePartData.getContentUri();
+ attachmentType = messagePartData.getContentType();
+ break;
+ }
+ }
+
+ final CharSequence message = BugleNotifications.buildSpaceSeparatedMessage(
+ displayName, text, attachmentUri, attachmentType);
+ messages.add(message);
+
+ } while (convMessageCursor.moveToNext());
+ } finally {
+ if (convMessageCursor != null) {
+ convMessageCursor.close();
+ }
+ }
+
+ // If there is no conversation history prior to what is already visible in the main
+ // notification, there's no need to include the conversation log, too.
+ final int maxMessagesInNotification = getMaxMessagesInConversationNotification();
+ if (!hasSeenMessagesBeforeNotification && messages.size() <= maxMessagesInNotification) {
+ return null;
+ }
+
+ final SpannableStringBuilder bigText = new SpannableStringBuilder();
+ // There is at least 1 message prior to the first one that we're going to show.
+ // Indicate this by inserting an ellipsis at the beginning of the conversation log.
+ if (convMessageCursor.getCount() == limit) {
+ bigText.append(context.getString(R.string.ellipsis) + "\n\n");
+ if (messages.size() > MAX_MESSAGES_IN_WEARABLE_PAGE) {
+ messages.remove(messages.size() - 1);
+ }
+ }
+ // Messages are sorted in descending timestamp order, so iterate backwards
+ // to get them back in ascending order for display purposes.
+ for (int i = messages.size() - 1; i >= 0; --i) {
+ bigText.append(messages.get(i));
+ if (i > 0) {
+ bigText.append("\n\n");
+ }
+ }
+ ++participantCount; // Add in myself
+
+ if (participantCount > 2) {
+ final SpannableString statusText = new SpannableString(
+ context.getResources().getQuantityString(R.plurals.wearable_participant_count,
+ participantCount, participantCount));
+ statusText.setSpan(new ForegroundColorSpan(context.getResources().getColor(
+ R.color.wearable_notification_participants_count)), 0, statusText.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ bigText.append("\n\n").append(statusText);
+ }
+
+ final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context);
+ final NotificationCompat.Style notifStyle =
+ new NotificationCompat.BigTextStyle(notifBuilder).bigText(bigText);
+ notifBuilder.setStyle(notifStyle);
+
+ final WearableExtender wearableExtender = new WearableExtender();
+ wearableExtender.setStartScrollBottom(true);
+ notifBuilder.extend(wearableExtender);
+
+ return notifBuilder.build();
+ }
+
+ /**
+ * Notification for one or more messages in a single conversation, which is bundled together
+ * with notifications for other conversations on a wearable device.
+ */
+ public static class BundledMessageNotificationState extends MultiMessageNotificationState {
+ public int mGroupOrder;
+ public BundledMessageNotificationState(final ConversationInfoList convList,
+ final int groupOrder) {
+ super(convList);
+ mGroupOrder = groupOrder;
+ }
+ }
+
+ /**
+ * Performs a query on the database.
+ */
+ private static ConversationInfoList createConversationInfoList() {
+ // Map key is conversation id. We use LinkedHashMap to ensure that entries are iterated in
+ // the same order they were originally added. We scan unseen messages from newest to oldest,
+ // so the corresponding conversations are added in that order, too.
+ final Map<String, ConversationLineInfo> convLineInfos = new LinkedHashMap<>();
+ int messageCount = 0;
+
+ Cursor convMessageCursor = null;
+ try {
+ final Context context = Factory.get().getApplicationContext();
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ convMessageCursor = db.rawQuery(
+ ConversationMessageData.getNotificationQuerySql(),
+ null);
+
+ if (convMessageCursor != null && convMessageCursor.moveToFirst()) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "MessageNotificationState: Found unseen message notifications.");
+ }
+ final ConversationMessageData convMessageData =
+ new ConversationMessageData();
+
+ HashMap<String, Integer> firstNames = null;
+ String conversationIdForFirstNames = null;
+ String groupConversationName = null;
+ final int maxMessages = getMaxMessagesInConversationNotification();
+
+ do {
+ convMessageData.bind(convMessageCursor);
+
+ // First figure out if this is a valid message.
+ String authorFullName = convMessageData.getSenderFullName();
+ String authorFirstName = convMessageData.getSenderFirstName();
+ final String messageText = convMessageData.getText();
+
+ final String convId = convMessageData.getConversationId();
+ final String messageId = convMessageData.getMessageId();
+
+ CharSequence text = messageText;
+ final boolean isManualDownloadNeeded = convMessageData.getIsMmsNotification();
+ if (isManualDownloadNeeded) {
+ // Don't try and convert the text from html if it's sms and not a sms push
+ // notification.
+ Assert.equals(MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD,
+ convMessageData.getStatus());
+ text = context.getResources().getString(
+ R.string.message_title_manual_download);
+ }
+ ConversationLineInfo currConvInfo = convLineInfos.get(convId);
+ if (currConvInfo == null) {
+ final ConversationListItemData convData =
+ ConversationListItemData.getExistingConversation(db, convId);
+ if (!convData.getNotificationEnabled()) {
+ // Skip conversations that have notifications disabled.
+ continue;
+ }
+ final int subId = BugleDatabaseOperations.getSelfSubscriptionId(db,
+ convData.getSelfId());
+ groupConversationName = convData.getName();
+ final Uri avatarUri = AvatarUriUtil.createAvatarUri(
+ convMessageData.getSenderProfilePhotoUri(),
+ convMessageData.getSenderFullName(),
+ convMessageData.getSenderNormalizedDestination(),
+ convMessageData.getSenderContactLookupKey());
+ currConvInfo = new ConversationLineInfo(convId,
+ convData.getIsGroup(),
+ groupConversationName,
+ convData.getIncludeEmailAddress(),
+ convMessageData.getReceivedTimeStamp(),
+ convData.getSelfId(),
+ convData.getNotificationSoundUri(),
+ convData.getNotificationEnabled(),
+ convData.getNotifiationVibrate(),
+ avatarUri,
+ convMessageData.getSenderContactLookupUri(),
+ subId,
+ convData.getParticipantCount());
+ convLineInfos.put(convId, currConvInfo);
+ }
+ // Prepare the message line
+ if (currConvInfo.mTotalMessageCount < maxMessages) {
+ if (currConvInfo.mIsGroup) {
+ if (authorFirstName == null) {
+ // authorFullName might be null as well. In that case, we won't
+ // show an author. That is better than showing all the group
+ // names again on the 2nd line.
+ authorFirstName = authorFullName;
+ }
+ } else {
+ // don't recompute this if we don't need to
+ if (!TextUtils.equals(conversationIdForFirstNames, convId)) {
+ firstNames = scanFirstNames(convId);
+ conversationIdForFirstNames = convId;
+ }
+ if (firstNames != null) {
+ final Integer count = firstNames.get(authorFirstName);
+ if (count != null && count > 1) {
+ authorFirstName = authorFullName;
+ }
+ }
+
+ if (authorFullName == null) {
+ authorFullName = groupConversationName;
+ }
+ if (authorFirstName == null) {
+ authorFirstName = groupConversationName;
+ }
+ }
+ final String subjectText = MmsUtils.cleanseMmsSubject(
+ context.getResources(),
+ convMessageData.getMmsSubject());
+ if (!TextUtils.isEmpty(subjectText)) {
+ final String subjectLabel =
+ context.getString(R.string.subject_label);
+ final SpannableStringBuilder spanBuilder =
+ new SpannableStringBuilder();
+
+ spanBuilder.append(context.getString(R.string.notification_subject,
+ subjectLabel, subjectText));
+ spanBuilder.setSpan(new TextAppearanceSpan(
+ context, R.style.NotificationSubjectText), 0,
+ subjectLabel.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ if (!TextUtils.isEmpty(text)) {
+ // Now add the actual message text below the subject header.
+ spanBuilder.append(System.getProperty("line.separator") + text);
+ }
+ text = spanBuilder;
+ }
+ // If we've got attachments, find the best one. If one of the messages is
+ // a photo, save the url so we'll display a big picture notification.
+ // Otherwise, show the first one we find.
+ Uri attachmentUri = null;
+ String attachmentType = null;
+ final MessagePartData messagePartData =
+ getMostInterestingAttachment(convMessageData);
+ if (messagePartData != null) {
+ attachmentUri = messagePartData.getContentUri();
+ attachmentType = messagePartData.getContentType();
+ }
+ currConvInfo.mLineInfos.add(new MessageLineInfo(currConvInfo.mIsGroup,
+ authorFullName, authorFirstName, text,
+ attachmentUri, attachmentType, isManualDownloadNeeded, messageId));
+ }
+ messageCount++;
+ currConvInfo.mTotalMessageCount++;
+ } while (convMessageCursor.moveToNext());
+ }
+ } finally {
+ if (convMessageCursor != null) {
+ convMessageCursor.close();
+ }
+ }
+ if (convLineInfos.isEmpty()) {
+ return null;
+ } else {
+ return new ConversationInfoList(messageCount,
+ Lists.newLinkedList(convLineInfos.values()));
+ }
+ }
+
+ /**
+ * Scans all the attachments for a message and returns the most interesting one that we'll
+ * show in a notification. By order of importance, in case there are multiple attachments:
+ * 1- an image (because we can show the image as a BigPictureNotification)
+ * 2- a video (because we can show a video frame as a BigPictureNotification)
+ * 3- a vcard
+ * 4- an audio attachment
+ * @return MessagePartData for the most interesting part. Can be null.
+ */
+ private static MessagePartData getMostInterestingAttachment(
+ final ConversationMessageData convMessageData) {
+ final List<MessagePartData> attachments = convMessageData.getAttachments();
+
+ MessagePartData imagePart = null;
+ MessagePartData audioPart = null;
+ MessagePartData vcardPart = null;
+ MessagePartData videoPart = null;
+
+ // 99.99% of the time there will be 0 or 1 part, since receiving slideshows is so
+ // uncommon.
+
+ // Remember the first of each type of part.
+ for (final MessagePartData messagePartData : attachments) {
+ if (messagePartData.isImage() && imagePart == null) {
+ imagePart = messagePartData;
+ }
+ if (messagePartData.isVideo() && videoPart == null) {
+ videoPart = messagePartData;
+ }
+ if (messagePartData.isVCard() && vcardPart == null) {
+ vcardPart = messagePartData;
+ }
+ if (messagePartData.isAudio() && audioPart == null) {
+ audioPart = messagePartData;
+ }
+ }
+ if (imagePart != null) {
+ return imagePart;
+ } else if (videoPart != null) {
+ return videoPart;
+ } else if (audioPart != null) {
+ return audioPart;
+ } else if (vcardPart != null) {
+ return vcardPart;
+ }
+ return null;
+ }
+
+ private static int getMaxMessagesInConversationNotification() {
+ if (!BugleNotifications.isWearCompanionAppInstalled()) {
+ return BugleGservices.get().getInt(
+ BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION,
+ BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_DEFAULT);
+ }
+ return BugleGservices.get().getInt(
+ BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE,
+ BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE_DEFAULT);
+ }
+
+ /**
+ * Scans the database for messages that need to go into notifications. Creates the appropriate
+ * MessageNotificationState depending on if there are multiple senders, or
+ * messages from one sender.
+ * @return NotificationState for the notification created.
+ */
+ public static NotificationState getNotificationState() {
+ MessageNotificationState state = null;
+ final ConversationInfoList convList = createConversationInfoList();
+
+ if (convList == null || convList.mConvInfos.size() == 0) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "MessageNotificationState: No unseen notifications");
+ }
+ } else {
+ final ConversationLineInfo convInfo = convList.mConvInfos.get(0);
+ state = new MultiMessageNotificationState(convList);
+
+ if (convList.mConvInfos.size() > 1) {
+ // We've got notifications across multiple conversations. Pass in the notification
+ // we just built of the most recent notification so we can use that to show the
+ // user the new message in the ticker.
+ state = new MultiConversationNotificationState(convList, state);
+ } else {
+ // For now, only show avatars for notifications for a single conversation.
+ if (convInfo.mAvatarUri != null) {
+ if (state.mParticipantAvatarsUris == null) {
+ state.mParticipantAvatarsUris = new ArrayList<Uri>(1);
+ }
+ state.mParticipantAvatarsUris.add(convInfo.mAvatarUri);
+ }
+ if (convInfo.mContactUri != null) {
+ if (state.mParticipantContactUris == null) {
+ state.mParticipantContactUris = new ArrayList<Uri>(1);
+ }
+ state.mParticipantContactUris.add(convInfo.mContactUri);
+ }
+ }
+ }
+ if (state != null && LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "MessageNotificationState: Notification state created"
+ + ", title = " + LogUtil.sanitizePII(state.mTitle)
+ + ", content = " + LogUtil.sanitizePII(state.mContent.toString()));
+ }
+ return state;
+ }
+
+ protected String getTitle() {
+ return mTitle;
+ }
+
+ @Override
+ public int getLatestMessageNotificationType() {
+ // This function is called to determine whether the most recent notification applies
+ // to an sms conversation or a hangout conversation. We have different ringtone/vibrate
+ // settings for both types of conversations.
+ if (mConvList.mConvInfos.size() > 0) {
+ final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0);
+ return convInfo.getLatestMessageNotificationType();
+ }
+ return BugleNotifications.LOCAL_SMS_NOTIFICATION;
+ }
+
+ @Override
+ public String getRingtoneUri() {
+ if (mConvList.mConvInfos.size() > 0) {
+ return mConvList.mConvInfos.get(0).mRingtoneUri;
+ }
+ return null;
+ }
+
+ @Override
+ public boolean getNotificationVibrate() {
+ if (mConvList.mConvInfos.size() > 0) {
+ return mConvList.mConvInfos.get(0).mNotificationVibrate;
+ }
+ return false;
+ }
+
+ protected CharSequence getTicker() {
+ return BugleNotifications.buildColonSeparatedMessage(
+ mTickerSender != null ? mTickerSender : mTitle,
+ mTickerText != null ? mTickerText : (mTickerNoContent ? null : mContent), null,
+ null);
+ }
+
+ private static CharSequence convertHtmlAndStripUrls(final String s) {
+ final Spanned text = Html.fromHtml(s);
+ if (text instanceof Spannable) {
+ stripUrls((Spannable) text);
+ }
+ return text;
+ }
+
+ // Since we don't want to show URLs in notifications, a function
+ // to remove them in place.
+ private static void stripUrls(final Spannable text) {
+ final URLSpan[] spans = text.getSpans(0, text.length(), URLSpan.class);
+ for (final URLSpan span : spans) {
+ text.removeSpan(span);
+ }
+ }
+
+ /*
+ private static void updateAlertStatusMessages(final long thresholdDeltaMs) {
+ // TODO may need this when supporting error notifications
+ final EsDatabaseHelper helper = EsDatabaseHelper.getDatabaseHelper();
+ final ContentValues values = new ContentValues();
+ final long nowMicros = System.currentTimeMillis() * 1000;
+ values.put(MessageColumns.ALERT_STATUS, "1");
+ final String selection =
+ MessageColumns.ALERT_STATUS + "=0 AND (" +
+ MessageColumns.STATUS + "=" + EsProvider.MESSAGE_STATUS_FAILED_TO_SEND + " OR (" +
+ MessageColumns.STATUS + "!=" + EsProvider.MESSAGE_STATUS_ON_SERVER + " AND " +
+ MessageColumns.TIMESTAMP + "+" + thresholdDeltaMs*1000 + "<" + nowMicros + ")) ";
+
+ final int updateCount = helper.getWritableDatabaseWrapper().update(
+ EsProvider.MESSAGES_TABLE,
+ values,
+ selection,
+ null);
+ if (updateCount > 0) {
+ EsConversationsData.notifyConversationsChanged();
+ }
+ }*/
+
+ static CharSequence applyWarningTextColor(final Context context,
+ final CharSequence text) {
+ if (text == null) {
+ return null;
+ }
+ final SpannableStringBuilder spanBuilder = new SpannableStringBuilder();
+ spanBuilder.append(text);
+ spanBuilder.setSpan(new ForegroundColorSpan(context.getResources().getColor(
+ R.color.notification_warning_color)), 0, text.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ return spanBuilder;
+ }
+
+ /**
+ * Check for failed messages and post notifications as needed.
+ * TODO: Rewrite this as a NotificationState.
+ */
+ public static void checkFailedMessages() {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ final Cursor messageDataCursor = db.query(DatabaseHelper.MESSAGES_TABLE,
+ MessageData.getProjection(),
+ FailedMessageQuery.FAILED_MESSAGES_WHERE_CLAUSE,
+ null /*selectionArgs*/,
+ null /*groupBy*/,
+ null /*having*/,
+ FailedMessageQuery.FAILED_ORDER_BY);
+
+ try {
+ final Context context = Factory.get().getApplicationContext();
+ final Resources resources = context.getResources();
+ final NotificationManagerCompat notificationManager =
+ NotificationManagerCompat.from(context);
+ if (messageDataCursor != null) {
+ final MessageData messageData = new MessageData();
+
+ final HashSet<String> conversationsWithFailedMessages = new HashSet<String>();
+
+ // track row ids in case we want to display something that requires this
+ // information
+ final ArrayList<Integer> failedMessages = new ArrayList<Integer>();
+
+ int cursorPosition = -1;
+ final long when = 0;
+
+ messageDataCursor.moveToPosition(-1);
+ while (messageDataCursor.moveToNext()) {
+ messageData.bind(messageDataCursor);
+
+ final String conversationId = messageData.getConversationId();
+ if (DataModel.get().isNewMessageObservable(conversationId)) {
+ // Don't post a system notification for an observable conversation
+ // because we already show an angry red annotation in the conversation
+ // itself or in the conversation preview snippet.
+ continue;
+ }
+
+ cursorPosition = messageDataCursor.getPosition();
+ failedMessages.add(cursorPosition);
+ conversationsWithFailedMessages.add(conversationId);
+ }
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "Found " + failedMessages.size() + " failed messages");
+ }
+ if (failedMessages.size() > 0) {
+ final NotificationCompat.Builder builder =
+ new NotificationCompat.Builder(context);
+
+ CharSequence line1;
+ CharSequence line2;
+ final boolean isRichContent = false;
+ ConversationIdSet conversationIds = null;
+ PendingIntent destinationIntent;
+ if (failedMessages.size() == 1) {
+ messageDataCursor.moveToPosition(cursorPosition);
+ messageData.bind(messageDataCursor);
+ final String conversationId = messageData.getConversationId();
+
+ // We have a single conversation, go directly to that conversation.
+ destinationIntent = UIIntents.get()
+ .getPendingIntentForConversationActivity(context,
+ conversationId,
+ null /*draft*/);
+
+ conversationIds = ConversationIdSet.createSet(conversationId);
+
+ final String failedMessgeSnippet = messageData.getMessageText();
+ int failureStringId;
+ if (messageData.getStatus() ==
+ MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) {
+ failureStringId =
+ R.string.notification_download_failures_line1_singular;
+ } else {
+ failureStringId = R.string.notification_send_failures_line1_singular;
+ }
+ line1 = resources.getString(failureStringId);
+ line2 = failedMessgeSnippet;
+ // Set rich text for non-SMS messages or MMS push notification messages
+ // which we generate locally with rich text
+ // TODO- fix this
+// if (messageData.isMmsInd()) {
+// isRichContent = true;
+// }
+ } else {
+ // We have notifications for multiple conversation, go to the conversation
+ // list.
+ destinationIntent = UIIntents.get()
+ .getPendingIntentForConversationListActivity(context);
+
+ int line1StringId;
+ int line2PluralsId;
+ if (messageData.getStatus() ==
+ MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) {
+ line1StringId =
+ R.string.notification_download_failures_line1_plural;
+ line2PluralsId = R.plurals.notification_download_failures;
+ } else {
+ line1StringId = R.string.notification_send_failures_line1_plural;
+ line2PluralsId = R.plurals.notification_send_failures;
+ }
+ line1 = resources.getString(line1StringId);
+ line2 = resources.getQuantityString(
+ line2PluralsId,
+ conversationsWithFailedMessages.size(),
+ failedMessages.size(),
+ conversationsWithFailedMessages.size());
+ }
+ line1 = applyWarningTextColor(context, line1);
+ line2 = applyWarningTextColor(context, line2);
+
+ final PendingIntent pendingIntentForDelete =
+ UIIntents.get().getPendingIntentForClearingNotifications(
+ context,
+ BugleNotifications.UPDATE_ERRORS,
+ conversationIds,
+ 0);
+
+ builder
+ .setContentTitle(line1)
+ .setTicker(line1)
+ .setWhen(when > 0 ? when : System.currentTimeMillis())
+ .setSmallIcon(R.drawable.ic_failed_light)
+ .setDeleteIntent(pendingIntentForDelete)
+ .setContentIntent(destinationIntent)
+ .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure));
+ if (isRichContent && !TextUtils.isEmpty(line2)) {
+ final NotificationCompat.InboxStyle inboxStyle =
+ new NotificationCompat.InboxStyle(builder);
+ if (line2 != null) {
+ inboxStyle.addLine(Html.fromHtml(line2.toString()));
+ }
+ builder.setStyle(inboxStyle);
+ } else {
+ builder.setContentText(line2);
+ }
+
+ if (builder != null) {
+ notificationManager.notify(
+ BugleNotifications.buildNotificationTag(
+ PendingIntentConstants.MSG_SEND_ERROR, null),
+ PendingIntentConstants.MSG_SEND_ERROR,
+ builder.build());
+ }
+ } else {
+ notificationManager.cancel(
+ BugleNotifications.buildNotificationTag(
+ PendingIntentConstants.MSG_SEND_ERROR, null),
+ PendingIntentConstants.MSG_SEND_ERROR);
+ }
+ }
+ } finally {
+ if (messageDataCursor != null) {
+ messageDataCursor.close();
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/MessageTextStats.java b/src/com/android/messaging/datamodel/MessageTextStats.java
new file mode 100644
index 0000000..2bd24ff
--- /dev/null
+++ b/src/com/android/messaging/datamodel/MessageTextStats.java
@@ -0,0 +1,92 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.telephony.SmsMessage;
+
+import com.android.messaging.sms.MmsConfig;
+
+public class MessageTextStats {
+ private boolean mMessageLengthRequiresMms;
+ private int mMessageCount;
+ private int mCodePointsRemainingInCurrentMessage;
+
+ public MessageTextStats() {
+ mCodePointsRemainingInCurrentMessage = Integer.MAX_VALUE;
+ }
+
+ public int getNumMessagesToBeSent() {
+ return mMessageCount;
+ }
+
+ public int getCodePointsRemainingInCurrentMessage() {
+ return mCodePointsRemainingInCurrentMessage;
+ }
+
+ public boolean getMessageLengthRequiresMms() {
+ return mMessageLengthRequiresMms;
+ }
+
+ public void updateMessageTextStats(final int selfSubId, final String messageText) {
+ final int[] params = SmsMessage.calculateLength(messageText, false);
+ /* SmsMessage.calculateLength returns an int[4] with:
+ * int[0] being the number of SMS's required,
+ * int[1] the number of code points used,
+ * int[2] is the number of code points remaining until the next message.
+ * int[3] is the encoding type that should be used for the message.
+ */
+ mMessageCount = params[0];
+ mCodePointsRemainingInCurrentMessage = params[2];
+
+ final MmsConfig mmsConfig = MmsConfig.get(selfSubId);
+ if (!mmsConfig.getMultipartSmsEnabled() &&
+ !mmsConfig.getSendMultipartSmsAsSeparateMessages()) {
+ // The provider doesn't support multi-part sms's and we should use MMS to
+ // send multi-part sms, so as soon as the user types
+ // an sms longer than one segment, we have to turn the message into an mms.
+ mMessageLengthRequiresMms = mMessageCount > 1;
+ } else {
+ final int threshold = mmsConfig.getSmsToMmsTextThreshold();
+ mMessageLengthRequiresMms = threshold > 0 && mMessageCount > threshold;
+ }
+ // Some carriers require any SMS message longer than 80 to be sent as MMS
+ // see b/12122333
+ int smsToMmsLengthThreshold = mmsConfig.getSmsToMmsTextLengthThreshold();
+ if (smsToMmsLengthThreshold > 0) {
+ final int usedInCurrentMessage = params[1];
+ /*
+ * A little hacky way to find out if we should count characters in double bytes.
+ * SmsMessage.calculateLength counts message code units based on the characters
+ * in input. If all of them are ascii, the max length is
+ * SmsMessage.MAX_USER_DATA_SEPTETS (160). If any of them are double-byte, like
+ * Korean or Chinese, the max length is SmsMessage.MAX_USER_DATA_BYTES (140) bytes
+ * (70 code units).
+ * Here we check if the total code units we can use is smaller than 140. If so,
+ * we know we should count threshold in double-byte, so divide the threshold by 2.
+ * In this way, we will count Korean text correctly with regard to the length threshold.
+ */
+ if (usedInCurrentMessage + mCodePointsRemainingInCurrentMessage
+ < SmsMessage.MAX_USER_DATA_BYTES) {
+ smsToMmsLengthThreshold /= 2;
+ }
+ if (usedInCurrentMessage > smsToMmsLengthThreshold) {
+ mMessageLengthRequiresMms = true;
+ }
+ }
+ }
+
+}
diff --git a/src/com/android/messaging/datamodel/MessagingContentProvider.java b/src/com/android/messaging/datamodel/MessagingContentProvider.java
new file mode 100644
index 0000000..7688abd
--- /dev/null
+++ b/src/com/android/messaging/datamodel/MessagingContentProvider.java
@@ -0,0 +1,476 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.text.TextUtils;
+
+import com.android.messaging.BugleApplication;
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
+import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns;
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.widget.BugleWidgetProvider;
+import com.android.messaging.widget.WidgetConversationProvider;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.FileDescriptor;
+import java.io.FileNotFoundException;
+import java.io.PrintWriter;
+
+/**
+ * A centralized provider for Uris exposed by Bugle.
+ * */
+public class MessagingContentProvider extends ContentProvider {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ @VisibleForTesting
+ public static final String AUTHORITY =
+ "com.android.messaging.datamodel.MessagingContentProvider";
+ private static final String CONTENT_AUTHORITY = "content://" + AUTHORITY + '/';
+
+ // Conversations query
+ private static final String CONVERSATIONS_QUERY = "conversations";
+
+ public static final Uri CONVERSATIONS_URI = Uri.parse(CONTENT_AUTHORITY + CONVERSATIONS_QUERY);
+ static final Uri PARTS_URI = Uri.parse(CONTENT_AUTHORITY + DatabaseHelper.PARTS_TABLE);
+
+ // Messages query
+ private static final String MESSAGES_QUERY = "messages";
+
+ static final Uri MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY + MESSAGES_QUERY);
+
+ public static final Uri CONVERSATION_MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY +
+ MESSAGES_QUERY + "/conversation");
+
+ // Conversation participants query
+ private static final String PARTICIPANTS_QUERY = "participants";
+
+ static class ConversationParticipantsQueryColumns extends ParticipantColumns {
+ static final String CONVERSATION_ID = ConversationParticipantsColumns.CONVERSATION_ID;
+ }
+
+ static final Uri CONVERSATION_PARTICIPANTS_URI = Uri.parse(CONTENT_AUTHORITY +
+ PARTICIPANTS_QUERY + "/conversation");
+
+ public static final Uri PARTICIPANTS_URI = Uri.parse(CONTENT_AUTHORITY + PARTICIPANTS_QUERY);
+
+ // Conversation images query
+ private static final String CONVERSATION_IMAGES_QUERY = "conversation_images";
+
+ public static final Uri CONVERSATION_IMAGES_URI = Uri.parse(CONTENT_AUTHORITY +
+ CONVERSATION_IMAGES_QUERY);
+
+ private static final String DRAFT_IMAGES_QUERY = "draft_images";
+
+ public static final Uri DRAFT_IMAGES_URI = Uri.parse(CONTENT_AUTHORITY +
+ DRAFT_IMAGES_QUERY);
+
+ /**
+ * Notifies that <i>all</i> data exposed by the provider needs to be refreshed.
+ * <p>
+ * <b>IMPORTANT!</b> You probably shouldn't be calling this. Prefer to notify more specific
+ * uri's instead. Currently only sync uses this, because sync can potentially update many
+ * different tables at once.
+ */
+ public static void notifyEverythingChanged() {
+ final Uri uri = Uri.parse(CONTENT_AUTHORITY);
+ final Context context = Factory.get().getApplicationContext();
+ final ContentResolver cr = context.getContentResolver();
+ cr.notifyChange(uri, null);
+
+ // Notify any conversations widgets the conversation list has changed.
+ BugleWidgetProvider.notifyConversationListChanged(context);
+
+ // Notify all conversation widgets to update.
+ WidgetConversationProvider.notifyMessagesChanged(context, null /*conversationId*/);
+ }
+
+ /**
+ * Build a participant uri from the conversation id.
+ */
+ public static Uri buildConversationParticipantsUri(final String conversationId) {
+ final Uri.Builder builder = CONVERSATION_PARTICIPANTS_URI.buildUpon();
+ builder.appendPath(conversationId);
+ return builder.build();
+ }
+
+ public static void notifyParticipantsChanged(final String conversationId) {
+ final Uri uri = buildConversationParticipantsUri(conversationId);
+ final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
+ cr.notifyChange(uri, null);
+ }
+
+ public static void notifyAllMessagesChanged() {
+ final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
+ cr.notifyChange(CONVERSATION_MESSAGES_URI, null);
+ }
+
+ public static void notifyAllParticipantsChanged() {
+ final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
+ cr.notifyChange(CONVERSATION_PARTICIPANTS_URI, null);
+ }
+
+ // Default value for unknown dimension of image
+ public static final int UNSPECIFIED_SIZE = -1;
+
+ // Internal
+ private static final int CONVERSATIONS_QUERY_CODE = 10;
+
+ private static final int CONVERSATION_QUERY_CODE = 20;
+ private static final int CONVERSATION_MESSAGES_QUERY_CODE = 30;
+ private static final int CONVERSATION_PARTICIPANTS_QUERY_CODE = 40;
+ private static final int CONVERSATION_IMAGES_QUERY_CODE = 50;
+ private static final int DRAFT_IMAGES_QUERY_CODE = 60;
+ private static final int PARTICIPANTS_QUERY_CODE = 70;
+
+ // TODO: Move to a better structured URI namespace.
+ private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+ static {
+ sURIMatcher.addURI(AUTHORITY, CONVERSATIONS_QUERY, CONVERSATIONS_QUERY_CODE);
+ sURIMatcher.addURI(AUTHORITY, CONVERSATIONS_QUERY + "/*", CONVERSATION_QUERY_CODE);
+ sURIMatcher.addURI(AUTHORITY, MESSAGES_QUERY + "/conversation/*",
+ CONVERSATION_MESSAGES_QUERY_CODE);
+ sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY + "/conversation/*",
+ CONVERSATION_PARTICIPANTS_QUERY_CODE);
+ sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY, PARTICIPANTS_QUERY_CODE);
+ sURIMatcher.addURI(AUTHORITY, CONVERSATION_IMAGES_QUERY + "/*",
+ CONVERSATION_IMAGES_QUERY_CODE);
+ sURIMatcher.addURI(AUTHORITY, DRAFT_IMAGES_QUERY + "/*",
+ DRAFT_IMAGES_QUERY_CODE);
+ }
+
+ /**
+ * Build a messages uri from the conversation id.
+ */
+ public static Uri buildConversationMessagesUri(final String conversationId) {
+ final Uri.Builder builder = CONVERSATION_MESSAGES_URI.buildUpon();
+ builder.appendPath(conversationId);
+ return builder.build();
+ }
+
+ public static void notifyMessagesChanged(final String conversationId) {
+ final Uri uri = buildConversationMessagesUri(conversationId);
+ final Context context = Factory.get().getApplicationContext();
+ final ContentResolver cr = context.getContentResolver();
+ cr.notifyChange(uri, null);
+ notifyConversationListChanged();
+
+ // Notify the widget the messages changed
+ WidgetConversationProvider.notifyMessagesChanged(context, conversationId);
+ }
+
+ /**
+ * Build a conversation metadata uri from a conversation id.
+ */
+ public static Uri buildConversationMetadataUri(final String conversationId) {
+ final Uri.Builder builder = CONVERSATIONS_URI.buildUpon();
+ builder.appendPath(conversationId);
+ return builder.build();
+ }
+
+ public static void notifyConversationMetadataChanged(final String conversationId) {
+ final Uri uri = buildConversationMetadataUri(conversationId);
+ final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
+ cr.notifyChange(uri, null);
+ notifyConversationListChanged();
+ }
+
+ public static void notifyPartsChanged() {
+ final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
+ cr.notifyChange(PARTS_URI, null);
+ }
+
+ public static void notifyConversationListChanged() {
+ final Context context = Factory.get().getApplicationContext();
+ final ContentResolver cr = context.getContentResolver();
+ cr.notifyChange(CONVERSATIONS_URI, null);
+
+ // Notify the widget the conversation list changed
+ BugleWidgetProvider.notifyConversationListChanged(context);
+ }
+
+ /**
+ * Build a conversation images uri from a conversation id.
+ */
+ public static Uri buildConversationImagesUri(final String conversationId) {
+ final Uri.Builder builder = CONVERSATION_IMAGES_URI.buildUpon();
+ builder.appendPath(conversationId);
+ return builder.build();
+ }
+
+ /**
+ * Build a draft images uri from a conversation id.
+ */
+ public static Uri buildDraftImagesUri(final String conversationId) {
+ final Uri.Builder builder = DRAFT_IMAGES_URI.buildUpon();
+ builder.appendPath(conversationId);
+ return builder.build();
+ }
+
+ private DatabaseHelper mDatabaseHelper;
+ private DatabaseWrapper mDatabaseWrapper;
+
+ public MessagingContentProvider() {
+ super();
+ }
+
+ @VisibleForTesting
+ public void setDatabaseForTest(final DatabaseWrapper db) {
+ Assert.isTrue(BugleApplication.isRunningTests());
+ mDatabaseWrapper = db;
+ }
+
+ private DatabaseWrapper getDatabaseWrapper() {
+ if (mDatabaseWrapper == null) {
+ mDatabaseWrapper = mDatabaseHelper.getDatabase();
+ }
+ return mDatabaseWrapper;
+ }
+
+ @Override
+ public Cursor query(final Uri uri, final String[] projection, String selection,
+ final String[] selectionArgs, String sortOrder) {
+
+ // Processes other than self are allowed to temporarily access the media
+ // scratch space; we grant uri read access on a case-by-case basis. Dialer app and
+ // contacts app would doQuery() on the vCard uri before trying to open the inputStream.
+ // There's nothing that we need to return for this uri so just No-Op.
+ //if (isMediaScratchSpaceUri(uri)) {
+ // return null;
+ //}
+
+ final SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
+
+ String[] queryArgs = selectionArgs;
+ final int match = sURIMatcher.match(uri);
+ String groupBy = null;
+ String limit = null;
+ switch (match) {
+ case CONVERSATIONS_QUERY_CODE:
+ queryBuilder.setTables(ConversationListItemData.getConversationListView());
+ // Hide empty conversations (ones with 0 sort_timestamp)
+ queryBuilder.appendWhere(ConversationColumns.SORT_TIMESTAMP + " > 0 ");
+ break;
+ case CONVERSATION_QUERY_CODE:
+ queryBuilder.setTables(ConversationListItemData.getConversationListView());
+ if (uri.getPathSegments().size() == 2) {
+ queryBuilder.appendWhere(ConversationColumns._ID + "=?");
+ // Get the conversation id from the uri
+ queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1));
+ } else {
+ throw new IllegalArgumentException("Malformed URI " + uri);
+ }
+ break;
+ case CONVERSATION_PARTICIPANTS_QUERY_CODE:
+ queryBuilder.setTables(DatabaseHelper.PARTICIPANTS_TABLE);
+ if (uri.getPathSegments().size() == 3 &&
+ TextUtils.equals(uri.getPathSegments().get(1), "conversation")) {
+ queryBuilder.appendWhere(ParticipantColumns._ID + " IN ( " + "SELECT "
+ + ConversationParticipantsColumns.PARTICIPANT_ID + " AS "
+ + ParticipantColumns._ID
+ + " FROM " + DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE
+ + " WHERE " + ConversationParticipantsColumns.CONVERSATION_ID
+ + " =? UNION SELECT " + ParticipantColumns._ID + " FROM "
+ + DatabaseHelper.PARTICIPANTS_TABLE + " WHERE "
+ + ParticipantColumns.SUB_ID + " != "
+ + ParticipantData.OTHER_THAN_SELF_SUB_ID + " )");
+ // Get the conversation id from the uri
+ queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(2));
+ } else {
+ throw new IllegalArgumentException("Malformed URI " + uri);
+ }
+ break;
+ case PARTICIPANTS_QUERY_CODE:
+ queryBuilder.setTables(DatabaseHelper.PARTICIPANTS_TABLE);
+ if (uri.getPathSegments().size() != 1) {
+ throw new IllegalArgumentException("Malformed URI " + uri);
+ }
+ break;
+ case CONVERSATION_MESSAGES_QUERY_CODE:
+ if (uri.getPathSegments().size() == 3 &&
+ TextUtils.equals(uri.getPathSegments().get(1), "conversation")) {
+ // Get the conversation id from the uri
+ final String conversationId = uri.getPathSegments().get(2);
+
+ // We need to handle this query differently, instead of falling through to the
+ // generic query call at the bottom. For performance reasons, the conversation
+ // messages query is executed as a raw query. It is invalid to specify
+ // selection/sorting for this query.
+
+ if (selection == null && selectionArgs == null && sortOrder == null) {
+ return queryConversationMessages(conversationId, uri);
+ } else {
+ throw new IllegalArgumentException(
+ "Cannot set selection or sort order with this query");
+ }
+ } else {
+ throw new IllegalArgumentException("Malformed URI " + uri);
+ }
+ case CONVERSATION_IMAGES_QUERY_CODE:
+ queryBuilder.setTables(ConversationImagePartsView.getViewName());
+ if (uri.getPathSegments().size() == 2) {
+ // Exclude draft.
+ queryBuilder.appendWhere(
+ ConversationImagePartsView.Columns.CONVERSATION_ID + " =? AND " +
+ ConversationImagePartsView.Columns.STATUS + "<>" +
+ MessageData.BUGLE_STATUS_OUTGOING_DRAFT);
+ // Get the conversation id from the uri
+ queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1));
+ } else {
+ throw new IllegalArgumentException("Malformed URI " + uri);
+ }
+ break;
+ case DRAFT_IMAGES_QUERY_CODE:
+ queryBuilder.setTables(ConversationImagePartsView.getViewName());
+ if (uri.getPathSegments().size() == 2) {
+ // Draft only.
+ queryBuilder.appendWhere(
+ ConversationImagePartsView.Columns.CONVERSATION_ID + " =? AND " +
+ ConversationImagePartsView.Columns.STATUS + "=" +
+ MessageData.BUGLE_STATUS_OUTGOING_DRAFT);
+ // Get the conversation id from the uri
+ queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1));
+ } else {
+ throw new IllegalArgumentException("Malformed URI " + uri);
+ }
+ break;
+ default: {
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+ }
+
+ final Cursor cursor = getDatabaseWrapper().query(queryBuilder, projection, selection,
+ queryArgs, groupBy, null, sortOrder, limit);
+ cursor.setNotificationUri(getContext().getContentResolver(), uri);
+ return cursor;
+ }
+
+ private Cursor queryConversationMessages(final String conversationId, final Uri notifyUri) {
+ final String[] queryArgs = { conversationId };
+ final Cursor cursor = getDatabaseWrapper().rawQuery(
+ ConversationMessageData.getConversationMessagesQuerySql(), queryArgs);
+ cursor.setNotificationUri(getContext().getContentResolver(), notifyUri);
+ return cursor;
+ }
+
+ @Override
+ public String getType(final Uri uri) {
+ final StringBuilder sb = new
+ StringBuilder("vnd.android.cursor.dir/vnd.android.messaging.");
+
+ switch (sURIMatcher.match(uri)) {
+ case CONVERSATIONS_QUERY_CODE: {
+ sb.append(CONVERSATIONS_QUERY);
+ break;
+ }
+ default: {
+ throw new IllegalArgumentException("Unknown URI: " + uri);
+ }
+ }
+ return sb.toString();
+ }
+
+ protected DatabaseHelper getDatabase() {
+ return DatabaseHelper.getInstance(getContext());
+ }
+
+ @Override
+ public ParcelFileDescriptor openFile(final Uri uri, final String fileMode)
+ throws FileNotFoundException {
+ throw new IllegalArgumentException("openFile not supported: " + uri);
+ }
+
+ @Override
+ public Uri insert(final Uri uri, final ContentValues values) {
+ throw new IllegalStateException("Insert not supported " + uri);
+ }
+
+ @Override
+ public int delete(final Uri uri, final String selection, final String[] selectionArgs) {
+ throw new IllegalArgumentException("Delete not supported: " + uri);
+ }
+
+ @Override
+ public int update(final Uri uri, final ContentValues values, final String selection,
+ final String[] selectionArgs) {
+ throw new IllegalArgumentException("Update not supported: " + uri);
+ }
+
+ /**
+ * Prepends new arguments to the existing argument list.
+ *
+ * @param oldArgList The current list of arguments. May be {@code null}
+ * @param args The new arguments to prepend
+ * @return A new argument list with the given arguments prepended
+ */
+ private String[] prependArgs(final String[] oldArgList, final String... args) {
+ if (args == null || args.length == 0) {
+ return oldArgList;
+ }
+ final int oldArgCount = (oldArgList == null ? 0 : oldArgList.length);
+ final int newArgCount = args.length;
+
+ final String[] newArgs = new String[oldArgCount + newArgCount];
+ System.arraycopy(args, 0, newArgs, 0, newArgCount);
+ if (oldArgCount > 0) {
+ System.arraycopy(oldArgList, 0, newArgs, newArgCount, oldArgCount);
+ }
+ return newArgs;
+ }
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void dump(final FileDescriptor fd, final PrintWriter writer, final String[] args) {
+ // First dump out the default SMS app package name
+ String defaultSmsApp = PhoneUtils.getDefault().getDefaultSmsApp();
+ if (TextUtils.isEmpty(defaultSmsApp)) {
+ if (OsUtil.isAtLeastKLP()) {
+ defaultSmsApp = "None";
+ } else {
+ defaultSmsApp = "None (pre-Kitkat)";
+ }
+ }
+ writer.println("Default SMS app: " + defaultSmsApp);
+ // Now dump logs
+ LogUtil.dump(writer);
+ }
+
+ @Override
+ public boolean onCreate() {
+ // This is going to wind up calling into createDatabase() below.
+ mDatabaseHelper = (DatabaseHelper) getDatabase();
+ // We cannot initialize mDatabaseWrapper yet as the Factory may not be initialized
+ return true;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/MmsFileProvider.java b/src/com/android/messaging/datamodel/MmsFileProvider.java
new file mode 100644
index 0000000..0022630
--- /dev/null
+++ b/src/com/android/messaging/datamodel/MmsFileProvider.java
@@ -0,0 +1,69 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.Context;
+import android.net.Uri;
+
+import com.android.messaging.Factory;
+import com.android.messaging.util.LogUtil;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+
+/**
+ * A very simple content provider that can serve mms files from our cache directory.
+ */
+public class MmsFileProvider extends FileProvider {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ @VisibleForTesting
+ static final String AUTHORITY = "com.android.messaging.datamodel.MmsFileProvider";
+ private static final String RAW_MMS_DIR = "rawmms";
+
+ /**
+ * Returns a uri that can be used to access a raw mms file.
+ *
+ * @return the URI for an raw mms file
+ */
+ public static Uri buildRawMmsUri() {
+ final Uri uri = FileProvider.buildFileUri(AUTHORITY, null);
+ final File file = getFile(uri.getPath());
+ if (!ensureFileExists(file)) {
+ LogUtil.e(TAG, "Failed to create temp file " + file.getAbsolutePath());
+ }
+ return uri;
+ }
+
+ @Override
+ File getFile(final String path, final String extension) {
+ return getFile(path);
+ }
+
+ public static File getFile(final Uri uri) {
+ return getFile(uri.getPath());
+ }
+
+ private static File getFile(final String path) {
+ final Context context = Factory.get().getApplicationContext();
+ return new File(getDirectory(context), path + ".dat");
+ }
+
+ private static File getDirectory(final Context context) {
+ return new File(context.getCacheDir(), RAW_MMS_DIR);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/NoConfirmationSmsSendService.java b/src/com/android/messaging/datamodel/NoConfirmationSmsSendService.java
new file mode 100644
index 0000000..791ff34
--- /dev/null
+++ b/src/com/android/messaging/datamodel/NoConfirmationSmsSendService.java
@@ -0,0 +1,148 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.app.IntentService;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v4.app.RemoteInput;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.action.InsertNewMessageAction;
+import com.android.messaging.datamodel.action.UpdateMessageNotificationAction;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.conversationlist.ConversationListActivity;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Respond to a special intent and send an SMS message without the user's intervention, unless
+ * the intent extra "showUI" is true.
+ */
+public class NoConfirmationSmsSendService extends IntentService {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private static final String EXTRA_SUBSCRIPTION = "subscription";
+ public static final String EXTRA_SELF_ID = "self_id";
+
+ public NoConfirmationSmsSendService() {
+ // Class name will be the thread name.
+ super(NoConfirmationSmsSendService.class.getName());
+
+ // Intent should be redelivered if the process gets killed before completing the job.
+ setIntentRedelivery(true);
+ }
+
+ @Override
+ protected void onHandleIntent(final Intent intent) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "NoConfirmationSmsSendService onHandleIntent");
+ }
+
+ final String action = intent.getAction();
+ if (!TelephonyManager.ACTION_RESPOND_VIA_MESSAGE.equals(action)) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "NoConfirmationSmsSendService onHandleIntent wrong action: " +
+ action);
+ }
+ return;
+ }
+ final Bundle extras = intent.getExtras();
+ if (extras == null) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Called to send SMS but no extras");
+ }
+ return;
+ }
+
+ // Get all possible extras from intent
+ final String conversationId =
+ intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
+ final String selfId = intent.getStringExtra(EXTRA_SELF_ID);
+ final boolean requiresMms = intent.getBooleanExtra(UIIntents.UI_INTENT_EXTRA_REQUIRES_MMS,
+ false);
+ final String message = getText(intent, Intent.EXTRA_TEXT);
+ final String subject = getText(intent, Intent.EXTRA_SUBJECT);
+ final int subId = extras.getInt(EXTRA_SUBSCRIPTION, ParticipantData.DEFAULT_SELF_SUB_ID);
+
+ final Uri intentUri = intent.getData();
+ final String recipients = intentUri != null ? MmsUtils.getSmsRecipients(intentUri) : null;
+
+ if (TextUtils.isEmpty(recipients) && TextUtils.isEmpty(conversationId)) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Both conversationId and recipient(s) cannot be empty");
+ }
+ return;
+ }
+
+ if (extras.getBoolean("showUI", false)) {
+ startActivity(new Intent(this, ConversationListActivity.class));
+ } else {
+ if (TextUtils.isEmpty(message)) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Message cannot be empty");
+ }
+ return;
+ }
+
+ // TODO: it's possible that a long message would require sending it via mms,
+ // but we're not testing for that here and we're sending the message as an sms.
+
+ if (TextUtils.isEmpty(conversationId)) {
+ InsertNewMessageAction.insertNewMessage(subId, recipients, message, subject);
+ } else {
+ MessageData messageData = null;
+ if (requiresMms) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Auto-sending MMS message in conversation: " +
+ conversationId);
+ }
+ messageData = MessageData.createDraftMmsMessage(conversationId, selfId, message,
+ subject);
+ } else {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Auto-sending SMS message in conversation: " +
+ conversationId);
+ }
+ messageData = MessageData.createDraftSmsMessage(conversationId, selfId,
+ message);
+ }
+ InsertNewMessageAction.insertNewMessage(messageData);
+ }
+ UpdateMessageNotificationAction.updateMessageNotification();
+ }
+ }
+
+ private String getText(final Intent intent, final String textType) {
+ final String message = intent.getStringExtra(textType);
+ if (message == null) {
+ final Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
+ if (remoteInput != null) {
+ final CharSequence extra = remoteInput.getCharSequence(textType);
+ if (extra != null) {
+ return extra.toString();
+ }
+ }
+ }
+ return message;
+ }
+
+}
diff --git a/src/com/android/messaging/datamodel/NotificationState.java b/src/com/android/messaging/datamodel/NotificationState.java
new file mode 100644
index 0000000..d589874
--- /dev/null
+++ b/src/com/android/messaging/datamodel/NotificationState.java
@@ -0,0 +1,149 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.app.PendingIntent;
+import android.net.Uri;
+import android.support.v4.app.NotificationCompat;
+
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.util.ConversationIdSet;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+
+/**
+ * Base class for representing notifications. The main reason for this class is that in order to
+ * show pictures or avatars they might need to be loaded in the background. This class and
+ * subclasses can do the main work to get the notification ready and then wait until any images
+ * that are needed are ready before posting.
+ *
+ * The creation of a notification is split into two parts. The NotificationState ctor should
+ * setup the basic information including the mContentIntent. A Notification Builder is created in
+ * RealTimeChatNotifications and passed to the build() method of each notification where the
+ * Notification is fully specified.
+ *
+ * TODO: There is still some duplication and inconsistency in the utility functions and
+ * placement of different building blocks across notification types (e.g. summary text for accounts)
+ */
+public abstract class NotificationState {
+ private static final int CONTENT_INTENT_REQUEST_CODE_OFFSET = 0;
+ private static final int CLEAR_INTENT_REQUEST_CODE_OFFSET = 1;
+ private static final int NUM_REQUEST_CODES_NEEDED = 2;
+
+ public interface FailedMessageQuery {
+ static final String FAILED_MESSAGES_WHERE_CLAUSE =
+ "((" + MessageColumns.STATUS + " = " +
+ MessageData.BUGLE_STATUS_OUTGOING_FAILED + " OR " +
+ MessageColumns.STATUS + " = " +
+ MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED + ") AND " +
+ DatabaseHelper.MessageColumns.SEEN + " = 0)";
+
+ static final String FAILED_ORDER_BY = DatabaseHelper.MessageColumns.CONVERSATION_ID + ", " +
+ DatabaseHelper.MessageColumns.SENT_TIMESTAMP + " asc";
+ }
+
+ public final ConversationIdSet mConversationIds;
+ public final HashSet<String> mPeople;
+
+ public NotificationCompat.Style mNotificationStyle;
+ public NotificationCompat.Builder mNotificationBuilder;
+ public boolean mCanceled;
+ public int mType;
+ public int mBaseRequestCode;
+ public ArrayList<Uri> mParticipantAvatarsUris = null;
+ public ArrayList<Uri> mParticipantContactUris = null;
+
+ NotificationState(final ConversationIdSet conversationIds) {
+ mConversationIds = conversationIds;
+ mPeople = new HashSet<String>();
+ }
+
+ /**
+ * The intent to be triggered when the notification is dismissed.
+ */
+ public abstract PendingIntent getClearIntent();
+
+ protected Uri getAttachmentUri() {
+ return null;
+ }
+
+ // Returns the mime type of the attachment (See ContentType class for definitions)
+ protected String getAttachmentType() {
+ return null;
+ }
+
+ /**
+ * Build the notification using the given builder.
+ * @param builder
+ * @return The style of the notification.
+ */
+ protected abstract NotificationCompat.Style build(NotificationCompat.Builder builder);
+
+ protected void setAvatarUrlsForConversation(final String conversationId) {
+ }
+
+ protected void setPeopleForConversation(final String conversationId) {
+ }
+
+ /**
+ * Reserves request codes for this notification type. By default 2 codes are reserved, one for
+ * the main intent and another for the cancel intent. Override this function to reserve more.
+ */
+ public int getNumRequestCodesNeeded() {
+ return NUM_REQUEST_CODES_NEEDED;
+ }
+
+ public int getContentIntentRequestCode() {
+ return mBaseRequestCode + CONTENT_INTENT_REQUEST_CODE_OFFSET;
+ }
+
+ public int getClearIntentRequestCode() {
+ return mBaseRequestCode + CLEAR_INTENT_REQUEST_CODE_OFFSET;
+ }
+
+ /**
+ * Gets the appropriate icon needed for notifications.
+ */
+ public abstract int getIcon();
+
+ /**
+ * @return the type of notification that should be used from {@link RealTimeChatNotifications}
+ * so that the proper ringtone and vibrate settings can be used.
+ */
+ public int getLatestMessageNotificationType() {
+ return BugleNotifications.LOCAL_SMS_NOTIFICATION;
+ }
+
+ /**
+ * @return the notification priority level for this notification.
+ */
+ public abstract int getPriority();
+
+ /** @return custom ringtone URI or null if not set */
+ public String getRingtoneUri() {
+ return null;
+ }
+
+ public boolean getNotificationVibrate() {
+ return false;
+ }
+
+ public long getLatestReceivedTimestamp() {
+ return Long.MIN_VALUE;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/ParticipantRefresh.java b/src/com/android/messaging/datamodel/ParticipantRefresh.java
new file mode 100644
index 0000000..5324496
--- /dev/null
+++ b/src/com/android/messaging/datamodel/ParticipantRefresh.java
@@ -0,0 +1,738 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.ContentValues;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.graphics.Color;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.support.v4.util.ArrayMap;
+import android.telephony.SubscriptionInfo;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
+import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns;
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.data.ParticipantData.ParticipantsQuery;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContactUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.SafeAsyncTask;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Utility class for refreshing participant information based on matching contact. This updates
+ * 1. name, photo_uri, matching contact_id of participants.
+ * 2. generated_name of conversations.
+ *
+ * There are two kinds of participant refreshes,
+ * 1. Full refresh, this is triggered at application start or activity resumes after contact
+ * change is detected.
+ * 2. Partial refresh, this is triggered when a participant is added to a conversation. This
+ * normally happens during SMS sync.
+ */
+@VisibleForTesting
+public class ParticipantRefresh {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ /**
+ * Refresh all participants including ones that were resolved before.
+ */
+ public static final int REFRESH_MODE_FULL = 0;
+
+ /**
+ * Refresh all unresolved participants.
+ */
+ public static final int REFRESH_MODE_INCREMENTAL = 1;
+
+ /**
+ * Force refresh all self participants.
+ */
+ public static final int REFRESH_MODE_SELF_ONLY = 2;
+
+ public static class ConversationParticipantsQuery {
+ public static final String[] PROJECTION = new String[] {
+ ConversationParticipantsColumns._ID,
+ ConversationParticipantsColumns.CONVERSATION_ID,
+ ConversationParticipantsColumns.PARTICIPANT_ID
+ };
+
+ public static final int INDEX_ID = 0;
+ public static final int INDEX_CONVERSATION_ID = 1;
+ public static final int INDEX_PARTICIPANT_ID = 2;
+ }
+
+ // Track whether observer is initialized or not.
+ private static volatile boolean sObserverInitialized = false;
+ private static final Object sLock = new Object();
+ private static final AtomicBoolean sFullRefreshScheduled = new AtomicBoolean(false);
+ private static final Runnable sFullRefreshRunnable = new Runnable() {
+ @Override
+ public void run() {
+ final boolean oldScheduled = sFullRefreshScheduled.getAndSet(false);
+ Assert.isTrue(oldScheduled);
+ refreshParticipants(REFRESH_MODE_FULL);
+ }
+ };
+ private static final Runnable sSelfOnlyRefreshRunnable = new Runnable() {
+ @Override
+ public void run() {
+ refreshParticipants(REFRESH_MODE_SELF_ONLY);
+ }
+ };
+
+ /**
+ * A customized content resolver to track contact changes.
+ */
+ public static class ContactContentObserver extends ContentObserver {
+ private volatile boolean mContactChanged = false;
+
+ public ContactContentObserver() {
+ super(null);
+ }
+
+ @Override
+ public void onChange(final boolean selfChange) {
+ super.onChange(selfChange);
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Contacts changed");
+ }
+ mContactChanged = true;
+ }
+
+ public boolean getContactChanged() {
+ return mContactChanged;
+ }
+
+ public void resetContactChanged() {
+ mContactChanged = false;
+ }
+
+ public void initialize() {
+ // TODO: Handle enterprise contacts post M once contacts provider supports it
+ Factory.get().getApplicationContext().getContentResolver().registerContentObserver(
+ Phone.CONTENT_URI, true, this);
+ mContactChanged = true; // Force a full refresh on initialization.
+ }
+ }
+
+ /**
+ * Refresh participants only if needed, i.e., application start or contact changed.
+ */
+ public static void refreshParticipantsIfNeeded() {
+ if (ParticipantRefresh.getNeedFullRefresh() &&
+ sFullRefreshScheduled.compareAndSet(false, true)) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Started full participant refresh");
+ }
+ SafeAsyncTask.executeOnThreadPool(sFullRefreshRunnable);
+ } else if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Skipped full participant refresh");
+ }
+ }
+
+ /**
+ * Refresh self participants on subscription or settings change.
+ */
+ public static void refreshSelfParticipants() {
+ SafeAsyncTask.executeOnThreadPool(sSelfOnlyRefreshRunnable);
+ }
+
+ private static boolean getNeedFullRefresh() {
+ final ContactContentObserver observer = Factory.get().getContactContentObserver();
+ if (observer == null) {
+ // If there is no observer (for unittest cases), we don't need to refresh participants.
+ return false;
+ }
+
+ if (!sObserverInitialized) {
+ synchronized (sLock) {
+ if (!sObserverInitialized) {
+ observer.initialize();
+ sObserverInitialized = true;
+ }
+ }
+ }
+
+ return observer.getContactChanged();
+ }
+
+ private static void resetNeedFullRefresh() {
+ final ContactContentObserver observer = Factory.get().getContactContentObserver();
+ if (observer != null) {
+ observer.resetContactChanged();
+ }
+ }
+
+ /**
+ * This class is totally static. Make constructor to be private so that an instance
+ * of this class would not be created by by mistake.
+ */
+ private ParticipantRefresh() {
+ }
+
+ /**
+ * Refresh participants in Bugle.
+ *
+ * @param refreshMode the refresh mode desired. See {@link #REFRESH_MODE_FULL},
+ * {@link #REFRESH_MODE_INCREMENTAL}, and {@link #REFRESH_MODE_SELF_ONLY}
+ */
+ @VisibleForTesting
+ static void refreshParticipants(final int refreshMode) {
+ Assert.inRange(refreshMode, REFRESH_MODE_FULL, REFRESH_MODE_SELF_ONLY);
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ switch (refreshMode) {
+ case REFRESH_MODE_FULL:
+ LogUtil.v(TAG, "Start full participant refresh");
+ break;
+ case REFRESH_MODE_INCREMENTAL:
+ LogUtil.v(TAG, "Start partial participant refresh");
+ break;
+ case REFRESH_MODE_SELF_ONLY:
+ LogUtil.v(TAG, "Start self participant refresh");
+ break;
+ }
+ }
+
+ if (!ContactUtil.hasReadContactsPermission() || !OsUtil.hasPhonePermission()) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Skipping participant referesh because of permissions");
+ }
+ return;
+ }
+
+ if (refreshMode == REFRESH_MODE_FULL) {
+ // resetNeedFullRefresh right away so that we will skip duplicated full refresh
+ // requests.
+ resetNeedFullRefresh();
+ }
+
+ if (refreshMode == REFRESH_MODE_FULL || refreshMode == REFRESH_MODE_SELF_ONLY) {
+ refreshSelfParticipantList();
+ }
+
+ final ArrayList<String> changedParticipants = new ArrayList<String>();
+
+ String selection = null;
+ String[] selectionArgs = null;
+
+ if (refreshMode == REFRESH_MODE_INCREMENTAL) {
+ // In case of incremental refresh, filter out participants that are already resolved.
+ selection = ParticipantColumns.CONTACT_ID + "=?";
+ selectionArgs = new String[] {
+ String.valueOf(ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED) };
+ } else if (refreshMode == REFRESH_MODE_SELF_ONLY) {
+ // In case of self-only refresh, filter out non-self participants.
+ selection = SELF_PARTICIPANTS_CLAUSE;
+ selectionArgs = null;
+ }
+
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ Cursor cursor = null;
+ boolean selfUpdated = false;
+ try {
+ cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE,
+ ParticipantsQuery.PROJECTION, selection, selectionArgs, null, null, null);
+
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ try {
+ final ParticipantData participantData =
+ ParticipantData.getFromCursor(cursor);
+ if (refreshParticipant(db, participantData)) {
+ if (participantData.isSelf()) {
+ selfUpdated = true;
+ }
+ updateParticipant(db, participantData);
+ final String id = participantData.getId();
+ changedParticipants.add(id);
+ }
+ } catch (final Exception exception) {
+ // Failure to update one participant shouldn't cancel the entire refresh.
+ // Log the failure so we know what's going on and resume the loop.
+ LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "ParticipantRefresh: Failed to " +
+ "update participant", exception);
+ }
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Number of participants refreshed:" + changedParticipants.size());
+ }
+
+ // Refresh conversations for participants that are changed.
+ if (changedParticipants.size() > 0) {
+ BugleDatabaseOperations.refreshConversationsForParticipants(changedParticipants);
+ }
+ if (selfUpdated) {
+ // Boom
+ MessagingContentProvider.notifyAllParticipantsChanged();
+ MessagingContentProvider.notifyAllMessagesChanged();
+ }
+ }
+
+ private static final String SELF_PARTICIPANTS_CLAUSE = ParticipantColumns.SUB_ID
+ + " NOT IN ( "
+ + ParticipantData.OTHER_THAN_SELF_SUB_ID
+ + " )";
+
+ private static final Set<Integer> getExistingSubIds() {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final HashSet<Integer> existingSubIds = new HashSet<Integer>();
+
+ Cursor cursor = null;
+ try {
+ cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE,
+ ParticipantsQuery.PROJECTION,
+ SELF_PARTICIPANTS_CLAUSE, null, null, null, null);
+
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ final int subId = cursor.getInt(ParticipantsQuery.INDEX_SUB_ID);
+ existingSubIds.add(subId);
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return existingSubIds;
+ }
+
+ private static final String UPDATE_SELF_PARTICIPANT_SUBSCRIPTION_SQL =
+ "UPDATE " + DatabaseHelper.PARTICIPANTS_TABLE + " SET "
+ + ParticipantColumns.SIM_SLOT_ID + " = %d, "
+ + ParticipantColumns.SUBSCRIPTION_COLOR + " = %d, "
+ + ParticipantColumns.SUBSCRIPTION_NAME + " = %s "
+ + " WHERE %s";
+
+ static String getUpdateSelfParticipantSubscriptionInfoSql(final int slotId,
+ final int subscriptionColor, final String subscriptionName, final String where) {
+ return String.format((Locale) null /* construct SQL string without localization */,
+ UPDATE_SELF_PARTICIPANT_SUBSCRIPTION_SQL,
+ slotId, subscriptionColor, subscriptionName, where);
+ }
+
+ /**
+ * Ensure that there is a self participant corresponding to every active SIM. Also, ensure
+ * that any other older SIM self participants are marked as inactive.
+ */
+ private static void refreshSelfParticipantList() {
+ if (!OsUtil.isAtLeastL_MR1()) {
+ return;
+ }
+
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ final List<SubscriptionInfo> subInfoRecords =
+ PhoneUtils.getDefault().toLMr1().getActiveSubscriptionInfoList();
+ final ArrayMap<Integer, SubscriptionInfo> activeSubscriptionIdToRecordMap =
+ new ArrayMap<Integer, SubscriptionInfo>();
+ db.beginTransaction();
+ final Set<Integer> existingSubIds = getExistingSubIds();
+
+ try {
+ if (subInfoRecords != null) {
+ for (final SubscriptionInfo subInfoRecord : subInfoRecords) {
+ final int subId = subInfoRecord.getSubscriptionId();
+ // If its a new subscription, add it to the database.
+ if (!existingSubIds.contains(subId)) {
+ db.execSQL(DatabaseHelper.getCreateSelfParticipantSql(subId));
+ // Add it to the local set to guard against duplicated entries returned
+ // by subscription manager.
+ existingSubIds.add(subId);
+ }
+ activeSubscriptionIdToRecordMap.put(subId, subInfoRecord);
+
+ if (subId == PhoneUtils.getDefault().getDefaultSmsSubscriptionId()) {
+ // This is the system default subscription, so update the default self.
+ activeSubscriptionIdToRecordMap.put(ParticipantData.DEFAULT_SELF_SUB_ID,
+ subInfoRecord);
+ }
+ }
+ }
+
+ // For subscriptions already in the database, refresh ParticipantColumns.SIM_SLOT_ID.
+ for (final Integer subId : activeSubscriptionIdToRecordMap.keySet()) {
+ final SubscriptionInfo record = activeSubscriptionIdToRecordMap.get(subId);
+ final String displayName =
+ DatabaseUtils.sqlEscapeString(record.getDisplayName().toString());
+ db.execSQL(getUpdateSelfParticipantSubscriptionInfoSql(record.getSimSlotIndex(),
+ record.getIconTint(), displayName,
+ ParticipantColumns.SUB_ID + " = " + subId));
+ }
+ db.execSQL(getUpdateSelfParticipantSubscriptionInfoSql(
+ ParticipantData.INVALID_SLOT_ID, Color.TRANSPARENT, "''",
+ ParticipantColumns.SUB_ID + " NOT IN (" +
+ Joiner.on(", ").join(activeSubscriptionIdToRecordMap.keySet()) + ")"));
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ // Fix up conversation self ids by reverting to default self for conversations whose self
+ // ids are no longer active.
+ refreshConversationSelfIds();
+ }
+
+ /**
+ * Refresh one participant.
+ * @return true if the ParticipantData was changed
+ */
+ public static boolean refreshParticipant(final DatabaseWrapper db,
+ final ParticipantData participantData) {
+ boolean updated = false;
+
+ if (participantData.isSelf()) {
+ final int selfChange = refreshFromSelfProfile(db, participantData);
+
+ if (selfChange == SELF_PROFILE_EXISTS) {
+ // If a self-profile exists, it takes precedence over Contacts data. So we are done.
+ return true;
+ }
+
+ updated = (selfChange == SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED);
+
+ // Fall-through and try to update based on Contacts data
+ }
+
+ updated |= refreshFromContacts(db, participantData);
+ return updated;
+ }
+
+ private static final int SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED = 1;
+ private static final int SELF_PROFILE_EXISTS = 2;
+
+ private static int refreshFromSelfProfile(final DatabaseWrapper db,
+ final ParticipantData participantData) {
+ int changed = 0;
+ // Refresh the phone number based on information from telephony
+ if (participantData.updatePhoneNumberForSelfIfChanged()) {
+ changed = SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED;
+ }
+
+ if (OsUtil.isAtLeastL_MR1()) {
+ // Refresh the subscription info based on information from SubscriptionManager.
+ final SubscriptionInfo subscriptionInfo =
+ PhoneUtils.get(participantData.getSubId()).toLMr1().getActiveSubscriptionInfo();
+ if (participantData.updateSubscriptionInfoForSelfIfChanged(subscriptionInfo)) {
+ changed = SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED;
+ }
+ }
+
+ // For self participant, try getting name/avatar from self profile in CP2 first.
+ // TODO: in case of multi-sim, profile would not be able to be used for
+ // different numbers. Need to figure out that.
+ Cursor selfCursor = null;
+ try {
+ selfCursor = ContactUtil.getSelf(db.getContext()).performSynchronousQuery();
+ if (selfCursor != null && selfCursor.getCount() > 0) {
+ selfCursor.moveToNext();
+ final long selfContactId = selfCursor.getLong(ContactUtil.INDEX_CONTACT_ID);
+ participantData.setContactId(selfContactId);
+ participantData.setFullName(selfCursor.getString(
+ ContactUtil.INDEX_DISPLAY_NAME));
+ participantData.setFirstName(
+ ContactUtil.lookupFirstName(db.getContext(), selfContactId));
+ participantData.setProfilePhotoUri(selfCursor.getString(
+ ContactUtil.INDEX_PHOTO_URI));
+ participantData.setLookupKey(selfCursor.getString(
+ ContactUtil.INDEX_SELF_QUERY_LOOKUP_KEY));
+ return SELF_PROFILE_EXISTS;
+ }
+ } catch (final Exception exception) {
+ // It's possible for contact query to fail and we don't want that to crash our app.
+ // However, we need to at least log the exception so we know something was wrong.
+ LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Participant refresh: failed to refresh " +
+ "participant. exception=" + exception);
+ } finally {
+ if (selfCursor != null) {
+ selfCursor.close();
+ }
+ }
+ return changed;
+ }
+
+ private static boolean refreshFromContacts(final DatabaseWrapper db,
+ final ParticipantData participantData) {
+ final String normalizedDestination = participantData.getNormalizedDestination();
+ final long currentContactId = participantData.getContactId();
+ final String currentDisplayName = participantData.getFullName();
+ final String currentFirstName = participantData.getFirstName();
+ final String currentPhotoUri = participantData.getProfilePhotoUri();
+ final String currentContactDestination = participantData.getContactDestination();
+
+ Cursor matchingContactCursor = null;
+ long matchingContactId = -1;
+ String matchingDisplayName = null;
+ String matchingFirstName = null;
+ String matchingPhotoUri = null;
+ String matchingLookupKey = null;
+ String matchingDestination = null;
+ boolean updated = false;
+
+ if (TextUtils.isEmpty(normalizedDestination)) {
+ // The normalized destination can be "" for the self id if we can't get it from the
+ // SIM. Some contact providers throw an IllegalArgumentException if you lookup "",
+ // so we early out.
+ return false;
+ }
+
+ try {
+ matchingContactCursor = ContactUtil.lookupDestination(db.getContext(),
+ normalizedDestination).performSynchronousQuery();
+ if (matchingContactCursor == null || matchingContactCursor.getCount() == 0) {
+ // If there is no match, mark the participant as contact not found.
+ if (currentContactId != ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND) {
+ participantData.setContactId(ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND);
+ participantData.setFullName(null);
+ participantData.setFirstName(null);
+ participantData.setProfilePhotoUri(null);
+ participantData.setLookupKey(null);
+ updated = true;
+ }
+ return updated;
+ }
+
+ while (matchingContactCursor.moveToNext()) {
+ final long contactId = matchingContactCursor.getLong(ContactUtil.INDEX_CONTACT_ID);
+ // Pick either the first contact or the contact with same id as previous matched
+ // contact id.
+ if (matchingContactId == -1 || currentContactId == contactId) {
+ matchingContactId = contactId;
+ matchingDisplayName = matchingContactCursor.getString(
+ ContactUtil.INDEX_DISPLAY_NAME);
+ matchingFirstName = ContactUtil.lookupFirstName(db.getContext(), contactId);
+ matchingPhotoUri = matchingContactCursor.getString(
+ ContactUtil.INDEX_PHOTO_URI);
+ matchingLookupKey = matchingContactCursor.getString(
+ ContactUtil.INDEX_LOOKUP_KEY);
+ matchingDestination = matchingContactCursor.getString(
+ ContactUtil.INDEX_PHONE_EMAIL);
+ }
+
+ // There is no need to try other contacts if the current contactId was not filled...
+ if (currentContactId < 0
+ // or we found the matching contact id
+ || currentContactId == contactId) {
+ break;
+ }
+ }
+ } catch (final Exception exception) {
+ // It's possible for contact query to fail and we don't want that to crash our app.
+ // However, we need to at least log the exception so we know something was wrong.
+ LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Participant refresh: failed to refresh " +
+ "participant. exception=" + exception);
+ return false;
+ } finally {
+ if (matchingContactCursor != null) {
+ matchingContactCursor.close();
+ }
+ }
+
+ // Update participant only if something changed.
+ final boolean isContactIdChanged = (matchingContactId != currentContactId);
+ final boolean isDisplayNameChanged =
+ !TextUtils.equals(matchingDisplayName, currentDisplayName);
+ final boolean isFirstNameChanged = !TextUtils.equals(matchingFirstName, currentFirstName);
+ final boolean isPhotoUrlChanged = !TextUtils.equals(matchingPhotoUri, currentPhotoUri);
+ final boolean isDestinationChanged = !TextUtils.equals(matchingDestination,
+ currentContactDestination);
+
+ if (isContactIdChanged || isDisplayNameChanged || isFirstNameChanged || isPhotoUrlChanged
+ || isDestinationChanged) {
+ participantData.setContactId(matchingContactId);
+ participantData.setFullName(matchingDisplayName);
+ participantData.setFirstName(matchingFirstName);
+ participantData.setProfilePhotoUri(matchingPhotoUri);
+ participantData.setLookupKey(matchingLookupKey);
+ participantData.setContactDestination(matchingDestination);
+ if (isDestinationChanged) {
+ // Update the send destination to the new one entered by user in Contacts.
+ participantData.setSendDestination(matchingDestination);
+ }
+ updated = true;
+ }
+
+ return updated;
+ }
+
+ /**
+ * Update participant with matching contact's contactId, displayName and photoUri.
+ */
+ private static void updateParticipant(final DatabaseWrapper db,
+ final ParticipantData participantData) {
+ final ContentValues values = new ContentValues();
+ if (participantData.isSelf()) {
+ // Self participants can refresh their normalized phone numbers
+ values.put(ParticipantColumns.NORMALIZED_DESTINATION,
+ participantData.getNormalizedDestination());
+ values.put(ParticipantColumns.DISPLAY_DESTINATION,
+ participantData.getDisplayDestination());
+ }
+ values.put(ParticipantColumns.CONTACT_ID, participantData.getContactId());
+ values.put(ParticipantColumns.LOOKUP_KEY, participantData.getLookupKey());
+ values.put(ParticipantColumns.FULL_NAME, participantData.getFullName());
+ values.put(ParticipantColumns.FIRST_NAME, participantData.getFirstName());
+ values.put(ParticipantColumns.PROFILE_PHOTO_URI, participantData.getProfilePhotoUri());
+ values.put(ParticipantColumns.CONTACT_DESTINATION, participantData.getContactDestination());
+ values.put(ParticipantColumns.SEND_DESTINATION, participantData.getSendDestination());
+
+ db.beginTransaction();
+ try {
+ db.update(DatabaseHelper.PARTICIPANTS_TABLE, values, ParticipantColumns._ID + "=?",
+ new String[] { participantData.getId() });
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ /**
+ * Get a list of inactive self ids in the participants table.
+ */
+ private static List<String> getInactiveSelfParticipantIds() {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final List<String> inactiveSelf = new ArrayList<String>();
+
+ final String selection = ParticipantColumns.SIM_SLOT_ID + "=? AND " +
+ SELF_PARTICIPANTS_CLAUSE;
+ Cursor cursor = null;
+ try {
+ cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE,
+ new String[] { ParticipantColumns._ID },
+ selection, new String[] { String.valueOf(ParticipantData.INVALID_SLOT_ID) },
+ null, null, null);
+
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ final String participantId = cursor.getString(0);
+ inactiveSelf.add(participantId);
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ return inactiveSelf;
+ }
+
+ /**
+ * Gets a list of conversations with the given self ids.
+ */
+ private static List<String> getConversationsWithSelfParticipantIds(final List<String> selfIds) {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final List<String> conversationIds = new ArrayList<String>();
+
+ Cursor cursor = null;
+ try {
+ final StringBuilder selectionList = new StringBuilder();
+ for (int i = 0; i < selfIds.size(); i++) {
+ selectionList.append('?');
+ if (i < selfIds.size() - 1) {
+ selectionList.append(',');
+ }
+ }
+ final String selection =
+ ConversationColumns.CURRENT_SELF_ID + " IN (" + selectionList + ")";
+ cursor = db.query(DatabaseHelper.CONVERSATIONS_TABLE,
+ new String[] { ConversationColumns._ID },
+ selection, selfIds.toArray(new String[0]),
+ null, null, null);
+
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ final String conversationId = cursor.getString(0);
+ conversationIds.add(conversationId);
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return conversationIds;
+ }
+
+ /**
+ * Refresh one conversation's self id.
+ */
+ private static void updateConversationSelfId(final String conversationId,
+ final String selfId) {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ db.beginTransaction();
+ try {
+ BugleDatabaseOperations.updateConversationSelfIdInTransaction(db, conversationId,
+ selfId);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ MessagingContentProvider.notifyMessagesChanged(conversationId);
+ MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
+ UIIntents.get().broadcastConversationSelfIdChange(db.getContext(), conversationId, selfId);
+ }
+
+ /**
+ * After refreshing the self participant list, find all conversations with inactive self ids,
+ * and switch them back to system default.
+ */
+ private static void refreshConversationSelfIds() {
+ final List<String> inactiveSelfs = getInactiveSelfParticipantIds();
+ if (inactiveSelfs.size() == 0) {
+ return;
+ }
+ final List<String> conversationsToRefresh =
+ getConversationsWithSelfParticipantIds(inactiveSelfs);
+ if (conversationsToRefresh.size() == 0) {
+ return;
+ }
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final ParticipantData defaultSelf =
+ BugleDatabaseOperations.getOrCreateSelf(db, ParticipantData.DEFAULT_SELF_SUB_ID);
+
+ if (defaultSelf != null) {
+ for (final String conversationId : conversationsToRefresh) {
+ updateConversationSelfId(conversationId, defaultSelf.getId());
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/SyncManager.java b/src/com/android/messaging/datamodel/SyncManager.java
new file mode 100644
index 0000000..b3571bf
--- /dev/null
+++ b/src/com/android/messaging/datamodel/SyncManager.java
@@ -0,0 +1,478 @@
+/*
+ * 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.messaging.datamodel;
+
+import android.content.Context;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.provider.Telephony;
+import android.support.v4.util.LongSparseArray;
+
+import com.android.messaging.datamodel.action.SyncMessagesAction;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.BuglePrefsKeys;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * This class manages message sync with the Telephony SmsProvider/MmsProvider.
+ */
+public class SyncManager {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ /**
+ * Record of any user customization to conversation settings
+ */
+ public static class ConversationCustomization {
+ private final boolean mArchived;
+ private final boolean mMuted;
+ private final boolean mNoVibrate;
+ private final String mNotificationSoundUri;
+
+ public ConversationCustomization(final boolean archived, final boolean muted,
+ final boolean noVibrate, final String notificationSoundUri) {
+ mArchived = archived;
+ mMuted = muted;
+ mNoVibrate = noVibrate;
+ mNotificationSoundUri = notificationSoundUri;
+ }
+
+ public boolean isArchived() {
+ return mArchived;
+ }
+
+ public boolean isMuted() {
+ return mMuted;
+ }
+
+ public boolean noVibrate() {
+ return mNoVibrate;
+ }
+
+ public String getNotificationSoundUri() {
+ return mNotificationSoundUri;
+ }
+ }
+
+ SyncManager() {
+ }
+
+ /**
+ * Timestamp of in progress sync - used to keep track of whether sync is running
+ */
+ private long mSyncInProgressTimestamp = -1;
+
+ /**
+ * Timestamp of current sync batch upper bound - used to determine if message makes batch dirty
+ */
+ private long mCurrentUpperBoundTimestamp = -1;
+
+ /**
+ * Timestamp of messages inserted since sync batch started - used to determine if batch dirty
+ */
+ private long mMaxRecentChangeTimestamp = -1L;
+
+ private final ThreadInfoCache mThreadInfoCache = new ThreadInfoCache();
+
+ /**
+ * User customization to conversations. If this is set, we need to recover them after
+ * a full sync.
+ */
+ private LongSparseArray<ConversationCustomization> mCustomization = null;
+
+ /**
+ * Start an incremental sync (backed off a few seconds)
+ */
+ public static void sync() {
+ SyncMessagesAction.sync();
+ }
+
+ /**
+ * Start an incremental sync (with no backoff)
+ */
+ public static void immediateSync() {
+ SyncMessagesAction.immediateSync();
+ }
+
+ /**
+ * Start a full sync (for debugging)
+ */
+ public static void forceSync() {
+ SyncMessagesAction.fullSync();
+ }
+
+ /**
+ * Called from data model thread when starting a sync batch
+ * @param upperBoundTimestamp upper bound timestamp for sync batch
+ */
+ public synchronized void startSyncBatch(final long upperBoundTimestamp) {
+ Assert.isTrue(mCurrentUpperBoundTimestamp < 0);
+ mCurrentUpperBoundTimestamp = upperBoundTimestamp;
+ mMaxRecentChangeTimestamp = -1L;
+ }
+
+ /**
+ * Called from data model thread at end of batch to determine if any messages added in window
+ * @param lowerBoundTimestamp lower bound timestamp for sync batch
+ * @return true if message added within window from lower to upper bound timestamp of batch
+ */
+ public synchronized boolean isBatchDirty(final long lowerBoundTimestamp) {
+ Assert.isTrue(mCurrentUpperBoundTimestamp >= 0);
+ final long max = mMaxRecentChangeTimestamp;
+
+ final boolean dirty = (max >= 0 && max >= lowerBoundTimestamp);
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncManager: Sync batch of messages from " + lowerBoundTimestamp
+ + " to " + mCurrentUpperBoundTimestamp + " is "
+ + (dirty ? "DIRTY" : "clean") + "; max change timestamp = "
+ + mMaxRecentChangeTimestamp);
+ }
+
+ mCurrentUpperBoundTimestamp = -1L;
+ mMaxRecentChangeTimestamp = -1L;
+
+ return dirty;
+ }
+
+ /**
+ * Called from data model or background worker thread to indicate start of message add process
+ * (add must complete on that thread before action transitions to new thread/stage)
+ * @param timestamp timestamp of message being added
+ */
+ public synchronized void onNewMessageInserted(final long timestamp) {
+ if (mCurrentUpperBoundTimestamp >= 0 && timestamp <= mCurrentUpperBoundTimestamp) {
+ // Message insert in current sync window
+ mMaxRecentChangeTimestamp = Math.max(mCurrentUpperBoundTimestamp, timestamp);
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " before upper bound of "
+ + "current sync batch " + mCurrentUpperBoundTimestamp);
+ }
+ } else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " after upper bound of "
+ + "current sync batch " + mCurrentUpperBoundTimestamp);
+ }
+ }
+
+ /**
+ * Synchronously checks whether sync is allowed and starts sync if allowed
+ * @param full - true indicates a full (not incremental) sync operation
+ * @param startTimestamp - starttimestamp for this sync (if allowed)
+ * @return - true if sync should start
+ */
+ public synchronized boolean shouldSync(final boolean full, final long startTimestamp) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SyncManager: Checking shouldSync " + (full ? "full " : "")
+ + "at " + startTimestamp);
+ }
+
+ if (full) {
+ final long delayUntilFullSync = delayUntilFullSync(startTimestamp);
+ if (delayUntilFullSync > 0) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncManager: Full sync requested for " + startTimestamp
+ + " delayed for " + delayUntilFullSync + " ms");
+ }
+ return false;
+ }
+ }
+
+ if (isSyncing()) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncManager: Not allowed to " + (full ? "full " : "")
+ + "sync yet; still running sync started at " + mSyncInProgressTimestamp);
+ }
+ return false;
+ }
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncManager: Starting " + (full ? "full " : "") + "sync at "
+ + startTimestamp);
+ }
+
+ mSyncInProgressTimestamp = startTimestamp;
+
+ return true;
+ }
+
+ /**
+ * Return delay (in ms) until allowed to run a full sync (0 meaning can run immediately)
+ * @param startTimestamp Timestamp used to start the sync
+ * @return 0 if allowed to run now, else delay in ms
+ */
+ public long delayUntilFullSync(final long startTimestamp) {
+ final BugleGservices bugleGservices = BugleGservices.get();
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+
+ final long lastFullSyncTime = prefs.getLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, -1L);
+ final long smsFullSyncBackoffTimeMillis = bugleGservices.getLong(
+ BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS,
+ BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS_DEFAULT);
+ final long noFullSyncBefore = (lastFullSyncTime < 0 ? startTimestamp :
+ lastFullSyncTime + smsFullSyncBackoffTimeMillis);
+
+ final long delayUntilFullSync = noFullSyncBefore - startTimestamp;
+ if (delayUntilFullSync > 0) {
+ return delayUntilFullSync;
+ }
+ return 0;
+ }
+
+ /**
+ * Check if sync currently in progress (public for asserts/logging).
+ */
+ public synchronized boolean isSyncing() {
+ return (mSyncInProgressTimestamp >= 0);
+ }
+
+ /**
+ * Check if sync batch should be in progress - compares upperBound with in memory value
+ * @param upperBoundTimestamp - upperbound timestamp for sync batch
+ * @return - true if timestamps match (otherwise batch is orphan from older process)
+ */
+ public synchronized boolean isSyncing(final long upperBoundTimestamp) {
+ Assert.isTrue(upperBoundTimestamp >= 0);
+ return (upperBoundTimestamp == mCurrentUpperBoundTimestamp);
+ }
+
+ /**
+ * Check if sync has completed for the first time.
+ */
+ public boolean getHasFirstSyncCompleted() {
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ return prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME,
+ BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT) !=
+ BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT;
+ }
+
+ /**
+ * Called once sync is complete
+ */
+ public synchronized void complete() {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncManager: Sync started at " + mSyncInProgressTimestamp
+ + " marked as complete");
+ }
+ mSyncInProgressTimestamp = -1L;
+ // Conversation customization only used once
+ mCustomization = null;
+ }
+
+ private final ContentObserver mMmsSmsObserver = new TelephonyMessagesObserver();
+ private boolean mSyncOnChanges = false;
+ private boolean mNotifyOnChanges = false;
+
+ /**
+ * Register content observer when necessary and kick off a catch up sync
+ */
+ public void updateSyncObserver(final Context context) {
+ registerObserver(context);
+ // Trigger an sms sync in case we missed and messages before registering this observer or
+ // becoming the SMS provider.
+ immediateSync();
+ }
+
+ private void registerObserver(final Context context) {
+ if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
+ // Not default SMS app - need to actively monitor telephony but not notify
+ mNotifyOnChanges = false;
+ mSyncOnChanges = true;
+ } else if (OsUtil.isSecondaryUser()){
+ // Secondary users default SMS app - need to actively monitor telephony and notify
+ mNotifyOnChanges = true;
+ mSyncOnChanges = true;
+ } else {
+ // Primary users default SMS app - don't monitor telephony (most changes from this app)
+ mNotifyOnChanges = false;
+ mSyncOnChanges = false;
+ }
+ if (mNotifyOnChanges || mSyncOnChanges) {
+ context.getContentResolver().registerContentObserver(Telephony.MmsSms.CONTENT_URI,
+ true, mMmsSmsObserver);
+ } else {
+ context.getContentResolver().unregisterContentObserver(mMmsSmsObserver);
+ }
+ }
+
+ public synchronized void setCustomization(
+ final LongSparseArray<ConversationCustomization> customization) {
+ this.mCustomization = customization;
+ }
+
+ public synchronized ConversationCustomization getCustomizationForThread(final long threadId) {
+ if (mCustomization != null) {
+ return mCustomization.get(threadId);
+ }
+ return null;
+ }
+
+ public static void resetLastSyncTimestamps() {
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME,
+ BuglePrefsKeys.LAST_FULL_SYNC_TIME_DEFAULT);
+ prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT);
+ }
+
+ private class TelephonyMessagesObserver extends ContentObserver {
+ public TelephonyMessagesObserver() {
+ // Just run on default thread
+ super(null);
+ }
+
+ // Implement the onChange(boolean) method to delegate the change notification to
+ // the onChange(boolean, Uri) method to ensure correct operation on older versions
+ // of the framework that did not have the onChange(boolean, Uri) method.
+ @Override
+ public void onChange(final boolean selfChange) {
+ onChange(selfChange, null);
+ }
+
+ // Implement the onChange(boolean, Uri) method to take advantage of the new Uri argument.
+ @Override
+ public void onChange(final boolean selfChange, final Uri uri) {
+ // Handle change.
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SyncManager: Sms/Mms DB changed @" + System.currentTimeMillis()
+ + " for " + (uri == null ? "<unk>" : uri.toString()) + " "
+ + mSyncOnChanges + "/" + mNotifyOnChanges);
+ }
+
+ if (mSyncOnChanges) {
+ // If sync is already running this will do nothing - but at end of each sync
+ // action there is a check for recent messages that should catch new changes.
+ SyncManager.immediateSync();
+ }
+ if (mNotifyOnChanges) {
+ // TODO: Secondary users are not going to get notifications
+ }
+ }
+ }
+
+ public ThreadInfoCache getThreadInfoCache() {
+ return mThreadInfoCache;
+ }
+
+ public static class ThreadInfoCache {
+ // Cache of thread->conversationId map
+ private final LongSparseArray<String> mThreadToConversationId =
+ new LongSparseArray<String>();
+
+ // Cache of thread->recipients map
+ private final LongSparseArray<List<String>> mThreadToRecipients =
+ new LongSparseArray<List<String>>();
+
+ // Remember the conversation ids that need to be archived
+ private final HashSet<String> mArchivedConversations = new HashSet<>();
+
+ public synchronized void clear() {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncManager: Cleared ThreadInfoCache");
+ }
+ mThreadToConversationId.clear();
+ mThreadToRecipients.clear();
+ mArchivedConversations.clear();
+ }
+
+ public synchronized boolean isArchived(final String conversationId) {
+ return mArchivedConversations.contains(conversationId);
+ }
+
+ /**
+ * Get or create a conversation based on the message's thread id
+ *
+ * @param threadId The message's thread
+ * @param refSubId The subId used for normalizing phone numbers in the thread
+ * @param customization The user setting customization to the conversation if any
+ * @return The existing conversation id or new conversation id
+ */
+ public synchronized String getOrCreateConversation(final DatabaseWrapper db,
+ final long threadId, int refSubId, final ConversationCustomization customization) {
+ // This function has several components which need to be atomic.
+ Assert.isTrue(db.getDatabase().inTransaction());
+
+ // If we already have this conversation ID in our local map, just return it
+ String conversationId = mThreadToConversationId.get(threadId);
+ if (conversationId != null) {
+ return conversationId;
+ }
+
+ final List<String> recipients = getThreadRecipients(threadId);
+ final ArrayList<ParticipantData> participants =
+ BugleDatabaseOperations.getConversationParticipantsFromRecipients(recipients,
+ refSubId);
+
+ if (customization != null) {
+ // There is user customization we need to recover
+ conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
+ customization.isArchived(), participants, customization.isMuted(),
+ customization.noVibrate(), customization.getNotificationSoundUri());
+ if (customization.isArchived()) {
+ mArchivedConversations.add(conversationId);
+ }
+ } else {
+ conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
+ false/*archived*/, participants, false/*noNotification*/,
+ false/*noVibrate*/, null/*soundUri*/);
+ }
+
+ if (conversationId != null) {
+ mThreadToConversationId.put(threadId, conversationId);
+ return conversationId;
+ }
+
+ return null;
+ }
+
+
+ /**
+ * Load the recipients of a thread from telephony provider. If we fail, use
+ * a predefined unknown recipient. This should not return null.
+ *
+ * @param threadId
+ */
+ public synchronized List<String> getThreadRecipients(final long threadId) {
+ List<String> recipients = mThreadToRecipients.get(threadId);
+ if (recipients == null) {
+ recipients = MmsUtils.getRecipientsByThread(threadId);
+ if (recipients != null && recipients.size() > 0) {
+ mThreadToRecipients.put(threadId, recipients);
+ }
+ }
+
+ if (recipients == null || recipients.isEmpty()) {
+ LogUtil.w(TAG, "SyncManager : using unknown sender since thread " + threadId +
+ " couldn't find any recipients.");
+
+ // We want to try our best to load the messages,
+ // so if recipient info is broken, try to fix it with unknown recipient
+ recipients = Lists.newArrayList();
+ recipients.add(ParticipantData.getUnknownSenderDestination());
+ }
+
+ return recipients;
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/Action.java b/src/com/android/messaging/datamodel/action/Action.java
new file mode 100644
index 0000000..e4c332e
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/Action.java
@@ -0,0 +1,295 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DataModelException;
+import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener;
+import com.android.messaging.datamodel.action.ActionMonitor.ActionExecutedListener;
+import com.android.messaging.util.LogUtil;
+
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * Base class for operations that perform application business logic off the main UI thread while
+ * holding a wake lock.
+ * .
+ * Note all derived classes need to provide real implementation of Parcelable (this is abstract)
+ */
+public abstract class Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ // Members holding the parameters common to all actions - no action state
+ public final String actionKey;
+
+ // If derived classes keep their data in actionParameters then parcelable is trivial
+ protected Bundle actionParameters;
+
+ // This does not get written to the parcel
+ private final List<Action> mBackgroundActions = new LinkedList<Action>();
+
+ /**
+ * Process the action locally - runs on action service thread.
+ * TODO: Currently, there is no way for this method to indicate failure
+ * @return result to be passed in to {@link ActionExecutedListener#onActionExecuted}. It is
+ * also the result passed in to {@link ActionCompletedListener#onActionSucceeded} if
+ * there is no background work.
+ */
+ protected Object executeAction() {
+ return null;
+ }
+
+ /**
+ * Queues up background work ie. {@link #doBackgroundWork} will be called on the
+ * background worker thread.
+ */
+ protected void requestBackgroundWork() {
+ mBackgroundActions.add(this);
+ }
+
+ /**
+ * Queues up background actions for background processing after the current action has
+ * completed its processing ({@link #executeAction}, {@link processBackgroundCompletion}
+ * or {@link #processBackgroundFailure}) on the Action thread.
+ * @param backgroundAction
+ */
+ protected void requestBackgroundWork(final Action backgroundAction) {
+ mBackgroundActions.add(backgroundAction);
+ }
+
+ /**
+ * Return flag indicating if any actions have been queued
+ */
+ public boolean hasBackgroundActions() {
+ return !mBackgroundActions.isEmpty();
+ }
+
+ /**
+ * Send queued actions to the background worker provided
+ */
+ public void sendBackgroundActions(final BackgroundWorker worker) {
+ worker.queueBackgroundWork(mBackgroundActions);
+ mBackgroundActions.clear();
+ }
+
+ /**
+ * Do work in a long running background worker thread.
+ * {@link #requestBackgroundWork} needs to be called for this method to
+ * be called. {@link #processBackgroundFailure} will be called on the Action service thread
+ * if this method throws {@link DataModelException}.
+ * @return response that is to be passed to {@link #processBackgroundResponse}
+ */
+ protected Bundle doBackgroundWork() throws DataModelException {
+ return null;
+ }
+
+ /**
+ * Process the success response from the background worker. Runs on action service thread.
+ * @param response the response returned by {@link #doBackgroundWork}
+ * @return result to be passed in to {@link ActionCompletedListener#onActionSucceeded}
+ */
+ protected Object processBackgroundResponse(final Bundle response) {
+ return null;
+ }
+
+ /**
+ * Called in case of failures when sending background actions. Runs on action service thread
+ * @return result to be passed in to {@link ActionCompletedListener#onActionFailed}
+ */
+ protected Object processBackgroundFailure() {
+ return null;
+ }
+
+ /**
+ * Constructor
+ */
+ protected Action(final String key) {
+ this.actionKey = key;
+ this.actionParameters = new Bundle();
+ }
+
+ /**
+ * Constructor
+ */
+ protected Action() {
+ this.actionKey = generateUniqueActionKey(getClass().getSimpleName());
+ this.actionParameters = new Bundle();
+ }
+
+ /**
+ * Queue an action and monitor for processing by the ActionService via the factory helper
+ */
+ protected void start(final ActionMonitor monitor) {
+ ActionMonitor.registerActionMonitor(this.actionKey, monitor);
+ DataModel.startActionService(this);
+ }
+
+ /**
+ * Queue an action for processing by the ActionService via the factory helper
+ */
+ public void start() {
+ DataModel.startActionService(this);
+ }
+
+ /**
+ * Queue an action for delayed processing by the ActionService via the factory helper
+ */
+ public void schedule(final int requestCode, final long delayMs) {
+ DataModel.scheduleAction(this, requestCode, delayMs);
+ }
+
+ /**
+ * Called when action queues ActionService intent
+ */
+ protected final void markStart() {
+ ActionMonitor.setState(this, ActionMonitor.STATE_CREATED,
+ ActionMonitor.STATE_QUEUED);
+ }
+
+ /**
+ * Mark the beginning of local action execution
+ */
+ protected final void markBeginExecute() {
+ ActionMonitor.setState(this, ActionMonitor.STATE_QUEUED,
+ ActionMonitor.STATE_EXECUTING);
+ }
+
+ /**
+ * Mark the end of local action execution - either completes the action or queues
+ * background actions
+ */
+ protected final void markEndExecute(final Object result) {
+ final boolean hasBackgroundActions = hasBackgroundActions();
+ ActionMonitor.setExecutedState(this, ActionMonitor.STATE_EXECUTING,
+ hasBackgroundActions, result);
+ if (!hasBackgroundActions) {
+ ActionMonitor.setCompleteState(this, ActionMonitor.STATE_EXECUTING,
+ result, true);
+ }
+ }
+
+ /**
+ * Update action state to indicate that the background worker is starting
+ */
+ protected final void markBackgroundWorkStarting() {
+ ActionMonitor.setState(this,
+ ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED,
+ ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION);
+ }
+
+ /**
+ * Update action state to indicate that the background worker has posted its response
+ * (or failure) to the Action service
+ */
+ protected final void markBackgroundCompletionQueued() {
+ ActionMonitor.setState(this,
+ ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION,
+ ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED);
+ }
+
+ /**
+ * Update action state to indicate the background action failed but is being re-queued for retry
+ */
+ protected final void markBackgroundWorkQueued() {
+ ActionMonitor.setState(this,
+ ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION,
+ ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED);
+ }
+
+ /**
+ * Called by ActionService to process a response from the background worker
+ * @param response the response returned by {@link #doBackgroundWork}
+ */
+ protected final void processBackgroundWorkResponse(final Bundle response) {
+ ActionMonitor.setState(this,
+ ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED,
+ ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE);
+ final Object result = processBackgroundResponse(response);
+ ActionMonitor.setCompleteState(this,
+ ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE, result, true);
+ }
+
+ /**
+ * Called by ActionService when a background action fails
+ */
+ protected final void processBackgroundWorkFailure() {
+ final Object result = processBackgroundFailure();
+ ActionMonitor.setCompleteState(this, ActionMonitor.STATE_UNDEFINED,
+ result, false);
+ }
+
+ private static final Object sLock = new Object();
+ private static long sActionIdx = System.currentTimeMillis() * 1000;
+
+ /**
+ * Helper method to generate a unique operation index
+ */
+ protected static long getActionIdx() {
+ long idx = 0;
+ synchronized (sLock) {
+ idx = ++sActionIdx;
+ }
+ return idx;
+ }
+
+ /**
+ * This helper can be used to generate a unique key used to identify an action.
+ * @param baseKey - key generated to identify the action parameters
+ * @return - composite key generated by appending unique index
+ */
+ protected static String generateUniqueActionKey(final String baseKey) {
+ final StringBuilder key = new StringBuilder();
+ if (!TextUtils.isEmpty(baseKey)) {
+ key.append(baseKey);
+ }
+ key.append(":");
+ key.append(getActionIdx());
+ return key.toString();
+ }
+
+ /**
+ * Most derived classes use this base implementation (unless they include files handles)
+ */
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ /**
+ * Derived classes need to implement writeToParcel (but typically should call this method
+ * to parcel Action member variables before they parcel their member variables).
+ */
+ public void writeActionToParcel(final Parcel parcel, final int flags) {
+ parcel.writeString(this.actionKey);
+ parcel.writeBundle(this.actionParameters);
+ }
+
+ /**
+ * Helper for derived classes to implement parcelable
+ */
+ public Action(final Parcel in) {
+ this.actionKey = in.readString();
+ // Note: Need to set classloader to ensure we can un-parcel classes from this package
+ this.actionParameters = in.readBundle(Action.class.getClassLoader());
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/ActionMonitor.java b/src/com/android/messaging/datamodel/action/ActionMonitor.java
new file mode 100644
index 0000000..cb080aa
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/ActionMonitor.java
@@ -0,0 +1,477 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.os.Handler;
+import android.support.v4.util.SimpleArrayMap;
+import android.text.TextUtils;
+
+import com.android.messaging.util.Assert.RunsOnAnyThread;
+import com.android.messaging.util.Assert.RunsOnMainThread;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.ThreadUtil;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.TimeZone;
+
+/**
+ * Base class for action monitors
+ * Actions come in various flavors but
+ * o) Fire and forget - no monitor
+ * o) Immediate local processing only - will trigger ActionCompletedListener when done
+ * o) Background worker processing only - will trigger ActionCompletedListener when done
+ * o) Immediate local processing followed by background work followed by more local processing
+ * - will trigger ActionExecutedListener once local processing complete and
+ * ActionCompletedListener when second set of local process (dealing with background
+ * worker response) is complete
+ */
+public class ActionMonitor {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ /**
+ * Interface used to notify on completion of local execution for an action
+ */
+ public interface ActionExecutedListener {
+ /**
+ * @param result value returned by {@link Action#executeAction}
+ */
+ @RunsOnMainThread
+ abstract void onActionExecuted(ActionMonitor monitor, final Action action,
+ final Object data, final Object result);
+ }
+
+ /**
+ * Interface used to notify action completion
+ */
+ public interface ActionCompletedListener {
+ /**
+ * @param result object returned from processing the action. This is the value returned by
+ * {@link Action#executeAction} if there is no background work, or
+ * else the value returned by
+ * {@link Action#processBackgroundResponse}
+ */
+ @RunsOnMainThread
+ abstract void onActionSucceeded(ActionMonitor monitor,
+ final Action action, final Object data, final Object result);
+ /**
+ * @param result value returned by {@link Action#processBackgroundFailure}
+ */
+ @RunsOnMainThread
+ abstract void onActionFailed(ActionMonitor monitor, final Action action,
+ final Object data, final Object result);
+ }
+
+ /**
+ * Interface for being notified of action state changes - used for profiling, testing only
+ */
+ protected interface ActionStateChangedListener {
+ /**
+ * @param action the action that is changing state
+ * @param state the new state of the action
+ */
+ @RunsOnAnyThread
+ void onActionStateChanged(Action action, int state);
+ }
+
+ /**
+ * Operations always start out as STATE_CREATED and finish as STATE_COMPLETE.
+ * Some common state transition sequences in between include:
+ * <ul>
+ * <li>Local data change only : STATE_QUEUED - STATE_EXECUTING
+ * <li>Background worker request only : STATE_BACKGROUND_ACTIONS_QUEUED
+ * - STATE_EXECUTING_BACKGROUND_ACTION
+ * - STATE_BACKGROUND_COMPLETION_QUEUED
+ * - STATE_PROCESSING_BACKGROUND_RESPONSE
+ * <li>Local plus background worker request : STATE_QUEUED - STATE_EXECUTING
+ * - STATE_BACKGROUND_ACTIONS_QUEUED
+ * - STATE_EXECUTING_BACKGROUND_ACTION
+ * - STATE_BACKGROUND_COMPLETION_QUEUED
+ * - STATE_PROCESSING_BACKGROUND_RESPONSE
+ * </ul>
+ */
+ protected static final int STATE_UNDEFINED = 0;
+ protected static final int STATE_CREATED = 1; // Just created
+ protected static final int STATE_QUEUED = 2; // Action queued for processing
+ protected static final int STATE_EXECUTING = 3; // Action processing on datamodel thread
+ protected static final int STATE_BACKGROUND_ACTIONS_QUEUED = 4;
+ protected static final int STATE_EXECUTING_BACKGROUND_ACTION = 5;
+ // The background work has completed, either returning a success response or resulting in a
+ // failure
+ protected static final int STATE_BACKGROUND_COMPLETION_QUEUED = 6;
+ protected static final int STATE_PROCESSING_BACKGROUND_RESPONSE = 7;
+ protected static final int STATE_COMPLETE = 8; // Action complete
+
+ /**
+ * Lock used to protect access to state and listeners
+ */
+ private final Object mLock = new Object();
+
+ /**
+ * Current state of action
+ */
+ @VisibleForTesting
+ protected int mState;
+
+ /**
+ * Listener which is notified on action completion
+ */
+ private ActionCompletedListener mCompletedListener;
+
+ /**
+ * Listener which is notified on action executed
+ */
+ private ActionExecutedListener mExecutedListener;
+
+ /**
+ * Listener which is notified of state changes
+ */
+ private ActionStateChangedListener mStateChangedListener;
+
+ /**
+ * Handler used to post results back to caller
+ */
+ private final Handler mHandler;
+
+ /**
+ * Data passed back to listeners (associated with the action when it is created)
+ */
+ private final Object mData;
+
+ /**
+ * The action key is used to determine equivalence of operations and their requests
+ */
+ private final String mActionKey;
+
+ /**
+ * Get action key identifying associated action
+ */
+ public String getActionKey() {
+ return mActionKey;
+ }
+
+ /**
+ * Unregister listeners so that they will not be called back - override this method if needed
+ */
+ public void unregister() {
+ clearListeners();
+ }
+
+ /**
+ * Unregister listeners so that they will not be called
+ */
+ protected final void clearListeners() {
+ synchronized (mLock) {
+ mCompletedListener = null;
+ mExecutedListener = null;
+ }
+ }
+
+ /**
+ * Create a monitor associated with a particular action instance
+ */
+ protected ActionMonitor(final int initialState, final String actionKey,
+ final Object data) {
+ mHandler = ThreadUtil.getMainThreadHandler();
+ mActionKey = actionKey;
+ mState = initialState;
+ mData = data;
+ }
+
+ /**
+ * Return flag to indicate if action is complete
+ */
+ public boolean isComplete() {
+ boolean complete = false;
+ synchronized (mLock) {
+ complete = (mState == STATE_COMPLETE);
+ }
+ return complete;
+ }
+
+ /**
+ * Set listener that will be called with action completed result
+ */
+ protected final void setCompletedListener(final ActionCompletedListener listener) {
+ synchronized (mLock) {
+ mCompletedListener = listener;
+ }
+ }
+
+ /**
+ * Set listener that will be called with local execution result
+ */
+ protected final void setExecutedListener(final ActionExecutedListener listener) {
+ synchronized (mLock) {
+ mExecutedListener = listener;
+ }
+ }
+
+ /**
+ * Set listener that will be called with local execution result
+ */
+ protected final void setStateChangedListener(final ActionStateChangedListener listener) {
+ synchronized (mLock) {
+ mStateChangedListener = listener;
+ }
+ }
+
+ /**
+ * Perform a state update transition
+ * @param action - action whose state is updating
+ * @param expectedOldState - expected existing state of action (can be UNKNOWN)
+ * @param newState - new state which will be set
+ */
+ @VisibleForTesting
+ protected void updateState(final Action action, final int expectedOldState,
+ final int newState) {
+ ActionStateChangedListener listener = null;
+ synchronized (mLock) {
+ if (expectedOldState != STATE_UNDEFINED &&
+ mState != expectedOldState) {
+ throw new IllegalStateException("On updateState to " + newState + " was " + mState
+ + " expecting " + expectedOldState);
+ }
+ if (newState != mState) {
+ mState = newState;
+ listener = mStateChangedListener;
+ }
+ }
+ if (listener != null) {
+ listener.onActionStateChanged(action, newState);
+ }
+ }
+
+ /**
+ * Perform a state update transition
+ * @param action - action whose state is updating
+ * @param expectedOldState - expected existing state of action (can be UNKNOWN)
+ * @param newState - new state which will be set
+ */
+ static void setState(final Action action, final int expectedOldState,
+ final int newState) {
+ int oldMonitorState = expectedOldState;
+ int newMonitorState = newState;
+ final ActionMonitor monitor
+ = ActionMonitor.lookupActionMonitor(action.actionKey);
+ if (monitor != null) {
+ oldMonitorState = monitor.mState;
+ monitor.updateState(action, expectedOldState, newState);
+ newMonitorState = monitor.mState;
+ }
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
+ df.setTimeZone(TimeZone.getTimeZone("UTC"));
+ LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date())
+ + "UTC State = " + oldMonitorState + " - " + newMonitorState);
+ }
+ }
+
+ /**
+ * Mark action complete
+ * @param action - action whose state is updating
+ * @param expectedOldState - expected existing state of action (can be UNKNOWN)
+ * @param result - object returned from processing the action. This is the value returned by
+ * {@link Action#executeAction} if there is no background work, or
+ * else the value returned by {@link Action#processBackgroundResponse}
+ * or {@link Action#processBackgroundFailure}
+ */
+ private final void complete(final Action action,
+ final int expectedOldState, final Object result,
+ final boolean succeeded) {
+ ActionCompletedListener completedListener = null;
+ synchronized (mLock) {
+ setState(action, expectedOldState, STATE_COMPLETE);
+ completedListener = mCompletedListener;
+ mExecutedListener = null;
+ mStateChangedListener = null;
+ }
+ if (completedListener != null) {
+ // Marshal to UI thread
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ ActionCompletedListener listener = null;
+ synchronized (mLock) {
+ if (mCompletedListener != null) {
+ listener = mCompletedListener;
+ }
+ mCompletedListener = null;
+ }
+ if (listener != null) {
+ if (succeeded) {
+ listener.onActionSucceeded(ActionMonitor.this,
+ action, mData, result);
+ } else {
+ listener.onActionFailed(ActionMonitor.this,
+ action, mData, result);
+ }
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * Mark action complete
+ * @param action - action whose state is updating
+ * @param expectedOldState - expected existing state of action (can be UNKNOWN)
+ * @param result - object returned from processing the action. This is the value returned by
+ * {@link Action#executeAction} if there is no background work, or
+ * else the value returned by {@link Action#processBackgroundResponse}
+ * or {@link Action#processBackgroundFailure}
+ */
+ static void setCompleteState(final Action action, final int expectedOldState,
+ final Object result, final boolean succeeded) {
+ int oldMonitorState = expectedOldState;
+ final ActionMonitor monitor
+ = ActionMonitor.lookupActionMonitor(action.actionKey);
+ if (monitor != null) {
+ oldMonitorState = monitor.mState;
+ monitor.complete(action, expectedOldState, result, succeeded);
+ unregisterActionMonitorIfComplete(action.actionKey, monitor);
+ }
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
+ df.setTimeZone(TimeZone.getTimeZone("UTC"));
+ LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date())
+ + "UTC State = " + oldMonitorState + " - " + STATE_COMPLETE);
+ }
+ }
+
+ /**
+ * Mark action complete
+ * @param action - action whose state is updating
+ * @param expectedOldState - expected existing state of action (can be UNKNOWN)
+ * @param hasBackgroundActions - has the completing action requested background work
+ * @param result - the return value of {@link Action#executeAction}
+ */
+ final void executed(final Action action,
+ final int expectedOldState, final boolean hasBackgroundActions, final Object result) {
+ ActionExecutedListener executedListener = null;
+ synchronized (mLock) {
+ if (hasBackgroundActions) {
+ setState(action, expectedOldState, STATE_BACKGROUND_ACTIONS_QUEUED);
+ }
+ executedListener = mExecutedListener;
+ }
+ if (executedListener != null) {
+ // Marshal to UI thread
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ ActionExecutedListener listener = null;
+ synchronized (mLock) {
+ if (mExecutedListener != null) {
+ listener = mExecutedListener;
+ mExecutedListener = null;
+ }
+ }
+ if (listener != null) {
+ listener.onActionExecuted(ActionMonitor.this,
+ action, mData, result);
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * Mark action complete
+ * @param action - action whose state is updating
+ * @param expectedOldState - expected existing state of action (can be UNKNOWN)
+ * @param hasBackgroundActions - has the completing action requested background work
+ * @param result - the return value of {@link Action#executeAction}
+ */
+ static void setExecutedState(final Action action,
+ final int expectedOldState, final boolean hasBackgroundActions, final Object result) {
+ int oldMonitorState = expectedOldState;
+ final ActionMonitor monitor
+ = ActionMonitor.lookupActionMonitor(action.actionKey);
+ if (monitor != null) {
+ oldMonitorState = monitor.mState;
+ monitor.executed(action, expectedOldState, hasBackgroundActions, result);
+ }
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
+ df.setTimeZone(TimeZone.getTimeZone("UTC"));
+ LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date())
+ + "UTC State = " + oldMonitorState + " - EXECUTED");
+ }
+ }
+
+ /**
+ * Map of action monitors indexed by actionKey
+ */
+ @VisibleForTesting
+ static SimpleArrayMap<String, ActionMonitor> sActionMonitors =
+ new SimpleArrayMap<String, ActionMonitor>();
+
+ /**
+ * Insert new monitor into map
+ */
+ static void registerActionMonitor(final String actionKey,
+ final ActionMonitor monitor) {
+ if (monitor != null
+ && (TextUtils.isEmpty(monitor.getActionKey())
+ || TextUtils.isEmpty(actionKey)
+ || !actionKey.equals(monitor.getActionKey()))) {
+ throw new IllegalArgumentException("Monitor key " + monitor.getActionKey()
+ + " not compatible with action key " + actionKey);
+ }
+ synchronized (sActionMonitors) {
+ sActionMonitors.put(actionKey, monitor);
+ }
+ }
+
+ /**
+ * Find monitor associated with particular action
+ */
+ private static ActionMonitor lookupActionMonitor(final String actionKey) {
+ ActionMonitor monitor = null;
+ synchronized (sActionMonitors) {
+ monitor = sActionMonitors.get(actionKey);
+ }
+ return monitor;
+ }
+
+ /**
+ * Remove monitor from map
+ */
+ @VisibleForTesting
+ static void unregisterActionMonitor(final String actionKey,
+ final ActionMonitor monitor) {
+ if (monitor != null) {
+ synchronized (sActionMonitors) {
+ sActionMonitors.remove(actionKey);
+ }
+ }
+ }
+
+ /**
+ * Remove monitor from map if the action is complete
+ */
+ static void unregisterActionMonitorIfComplete(final String actionKey,
+ final ActionMonitor monitor) {
+ if (monitor != null && monitor.isComplete()) {
+ synchronized (sActionMonitors) {
+ sActionMonitors.remove(actionKey);
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/ActionService.java b/src/com/android/messaging/datamodel/action/ActionService.java
new file mode 100644
index 0000000..29225fa
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/ActionService.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.datamodel.action;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.os.Bundle;
+
+/**
+ * Class providing interface for the ActionService - can be stubbed for testing
+ */
+public class ActionService {
+ protected static PendingIntent makeStartActionPendingIntent(final Context context,
+ final Action action, final int requestCode, final boolean launchesAnActivity) {
+ return ActionServiceImpl.makeStartActionPendingIntent(context, action, requestCode,
+ launchesAnActivity);
+ }
+
+ /**
+ * Start an action by posting it over the the ActionService
+ */
+ public void startAction(final Action action) {
+ ActionServiceImpl.startAction(action);
+ }
+
+ /**
+ * Schedule a delayed action by posting it over the the ActionService
+ */
+ public void scheduleAction(final Action action, final int code,
+ final long delayMs) {
+ ActionServiceImpl.scheduleAction(action, code, delayMs);
+ }
+
+ /**
+ * Process a response from the BackgroundWorker in the ActionService
+ */
+ protected void handleResponseFromBackgroundWorker(
+ final Action action, final Bundle response) {
+ ActionServiceImpl.handleResponseFromBackgroundWorker(action, response);
+ }
+
+ /**
+ * Process a failure from the BackgroundWorker in the ActionService
+ */
+ protected void handleFailureFromBackgroundWorker(final Action action,
+ final Exception exception) {
+ ActionServiceImpl.handleFailureFromBackgroundWorker(action, exception);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/ActionServiceImpl.java b/src/com/android/messaging/datamodel/action/ActionServiceImpl.java
new file mode 100644
index 0000000..a408dac
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/ActionServiceImpl.java
@@ -0,0 +1,341 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.app.AlarmManager;
+import android.app.IntentService;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.SystemClock;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.LoggingTimer;
+import com.android.messaging.util.WakeLockHelper;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * ActionService used to perform background processing for data model
+ */
+public class ActionServiceImpl extends IntentService {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+ private static final boolean VERBOSE = false;
+
+ public ActionServiceImpl() {
+ super("ActionService");
+ }
+
+ /**
+ * Start action by sending intent to the service
+ * @param action - action to start
+ */
+ protected static void startAction(final Action action) {
+ final Intent intent = makeIntent(OP_START_ACTION);
+ final Bundle actionBundle = new Bundle();
+ actionBundle.putParcelable(BUNDLE_ACTION, action);
+ intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
+ action.markStart();
+ startServiceWithIntent(intent);
+ }
+
+ /**
+ * Schedule an action to run after specified delay using alarm manager to send pendingintent
+ * @param action - action to start
+ * @param requestCode - request code used to collapse requests
+ * @param delayMs - delay in ms (from now) before action will start
+ */
+ protected static void scheduleAction(final Action action, final int requestCode,
+ final long delayMs) {
+ final Intent intent = PendingActionReceiver.makeIntent(OP_START_ACTION);
+ final Bundle actionBundle = new Bundle();
+ actionBundle.putParcelable(BUNDLE_ACTION, action);
+ intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
+
+ PendingActionReceiver.scheduleAlarm(intent, requestCode, delayMs);
+ }
+
+ /**
+ * Handle response returned by BackgroundWorker
+ * @param request - request generating response
+ * @param response - response from service
+ */
+ protected static void handleResponseFromBackgroundWorker(final Action action,
+ final Bundle response) {
+ final Intent intent = makeIntent(OP_RECEIVE_BACKGROUND_RESPONSE);
+
+ final Bundle actionBundle = new Bundle();
+ actionBundle.putParcelable(BUNDLE_ACTION, action);
+ intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
+ intent.putExtra(EXTRA_WORKER_RESPONSE, response);
+
+ startServiceWithIntent(intent);
+ }
+
+ /**
+ * Handle response returned by BackgroundWorker
+ * @param request - request generating failure
+ */
+ protected static void handleFailureFromBackgroundWorker(final Action action,
+ final Exception exception) {
+ final Intent intent = makeIntent(OP_RECEIVE_BACKGROUND_FAILURE);
+
+ final Bundle actionBundle = new Bundle();
+ actionBundle.putParcelable(BUNDLE_ACTION, action);
+ intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
+ intent.putExtra(EXTRA_WORKER_EXCEPTION, exception);
+
+ startServiceWithIntent(intent);
+ }
+
+ // ops
+ @VisibleForTesting
+ protected static final int OP_START_ACTION = 200;
+ @VisibleForTesting
+ protected static final int OP_RECEIVE_BACKGROUND_RESPONSE = 201;
+ @VisibleForTesting
+ protected static final int OP_RECEIVE_BACKGROUND_FAILURE = 202;
+
+ // extras
+ @VisibleForTesting
+ protected static final String EXTRA_OP_CODE = "op";
+ @VisibleForTesting
+ protected static final String EXTRA_ACTION_BUNDLE = "datamodel_action_bundle";
+ @VisibleForTesting
+ protected static final String EXTRA_WORKER_EXCEPTION = "worker_exception";
+ @VisibleForTesting
+ protected static final String EXTRA_WORKER_RESPONSE = "worker_response";
+ @VisibleForTesting
+ protected static final String EXTRA_WORKER_UPDATE = "worker_update";
+ @VisibleForTesting
+ protected static final String BUNDLE_ACTION = "bundle_action";
+
+ private BackgroundWorker mBackgroundWorker;
+
+ /**
+ * Allocate an intent with a specific opcode.
+ */
+ private static Intent makeIntent(final int opcode) {
+ final Intent intent = new Intent(Factory.get().getApplicationContext(),
+ ActionServiceImpl.class);
+ intent.putExtra(EXTRA_OP_CODE, opcode);
+ return intent;
+ }
+
+ /**
+ * Broadcast receiver for alarms scheduled through ActionService.
+ */
+ public static class PendingActionReceiver extends BroadcastReceiver {
+ static final String ACTION = "com.android.messaging.datamodel.PENDING_ACTION";
+
+ /**
+ * Allocate an intent with a specific opcode and alarm action.
+ */
+ public static Intent makeIntent(final int opcode) {
+ final Intent intent = new Intent(Factory.get().getApplicationContext(),
+ PendingActionReceiver.class);
+ intent.setAction(ACTION);
+ intent.putExtra(EXTRA_OP_CODE, opcode);
+ return intent;
+ }
+
+ public static void scheduleAlarm(final Intent intent, final int requestCode,
+ final long delayMs) {
+ final Context context = Factory.get().getApplicationContext();
+ final PendingIntent pendingIntent = PendingIntent.getBroadcast(
+ context, requestCode, intent, PendingIntent.FLAG_CANCEL_CURRENT);
+
+ final AlarmManager mgr =
+ (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+
+ if (delayMs < Long.MAX_VALUE) {
+ mgr.set(AlarmManager.ELAPSED_REALTIME_WAKEUP,
+ SystemClock.elapsedRealtime() + delayMs, pendingIntent);
+ } else {
+ mgr.cancel(pendingIntent);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ ActionServiceImpl.startServiceWithIntent(intent);
+ }
+ }
+
+ /**
+ * Creates a pending intent that will trigger a data model action when the intent is
+ * triggered
+ */
+ public static PendingIntent makeStartActionPendingIntent(final Context context,
+ final Action action, final int requestCode, final boolean launchesAnActivity) {
+ final Intent intent = PendingActionReceiver.makeIntent(OP_START_ACTION);
+ final Bundle actionBundle = new Bundle();
+ actionBundle.putParcelable(BUNDLE_ACTION, action);
+ intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
+ if (launchesAnActivity) {
+ intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
+ }
+ return PendingIntent.getBroadcast(context, requestCode, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ mBackgroundWorker = DataModel.get().getBackgroundWorkerForActionService();
+ DataModel.get().getConnectivityUtil().registerForSignalStrength();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ DataModel.get().getConnectivityUtil().unregisterForSignalStrength();
+ }
+
+ private static final String WAKELOCK_ID = "bugle_datamodel_service_wakelock";
+ @VisibleForTesting
+ static WakeLockHelper sWakeLock = new WakeLockHelper(WAKELOCK_ID);
+
+ /**
+ * Queue intent to the ActionService after acquiring wake lock
+ */
+ private static void startServiceWithIntent(final Intent intent) {
+ final Context context = Factory.get().getApplicationContext();
+ final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0);
+ // Increase refCount on wake lock - acquiring if necessary
+ if (VERBOSE) {
+ LogUtil.v(TAG, "acquiring wakelock for opcode " + opcode);
+ }
+ sWakeLock.acquire(context, intent, opcode);
+ intent.setClass(context, ActionServiceImpl.class);
+
+ // TODO: Note that intent will be quietly discarded if it exceeds available rpc
+ // memory (in total around 1MB). See this article for background
+ // http://developer.android.com/reference/android/os/TransactionTooLargeException.html
+ // Perhaps we should keep large structures in the action monitor?
+ if (context.startService(intent) == null) {
+ LogUtil.e(TAG,
+ "ActionService.startServiceWithIntent: failed to start service for intent "
+ + intent);
+ sWakeLock.release(intent, opcode);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected void onHandleIntent(final Intent intent) {
+ if (intent == null) {
+ // Shouldn't happen but sometimes does following another crash.
+ LogUtil.w(TAG, "ActionService.onHandleIntent: Called with null intent");
+ return;
+ }
+ final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0);
+ sWakeLock.ensure(intent, opcode);
+
+ try {
+ Action action;
+ final Bundle actionBundle = intent.getBundleExtra(EXTRA_ACTION_BUNDLE);
+ actionBundle.setClassLoader(getClassLoader());
+ switch(opcode) {
+ case OP_START_ACTION: {
+ action = (Action) actionBundle.getParcelable(BUNDLE_ACTION);
+ executeAction(action);
+ break;
+ }
+
+ case OP_RECEIVE_BACKGROUND_RESPONSE: {
+ action = (Action) actionBundle.getParcelable(BUNDLE_ACTION);
+ final Bundle response = intent.getBundleExtra(EXTRA_WORKER_RESPONSE);
+ processBackgroundResponse(action, response);
+ break;
+ }
+
+ case OP_RECEIVE_BACKGROUND_FAILURE: {
+ action = (Action) actionBundle.getParcelable(BUNDLE_ACTION);
+ processBackgroundFailure(action);
+ break;
+ }
+
+ default:
+ throw new RuntimeException("Unrecognized opcode in ActionServiceImpl");
+ }
+
+ action.sendBackgroundActions(mBackgroundWorker);
+ } finally {
+ // Decrease refCount on wake lock - releasing if necessary
+ sWakeLock.release(intent, opcode);
+ }
+ }
+
+ private static final long EXECUTION_TIME_WARN_LIMIT_MS = 1000; // 1 second
+ /**
+ * Local execution of action on ActionService thread
+ */
+ private void executeAction(final Action action) {
+ action.markBeginExecute();
+
+ final LoggingTimer timer = createLoggingTimer(action, "#executeAction");
+ timer.start();
+
+ final Object result = action.executeAction();
+
+ timer.stopAndLog();
+
+ action.markEndExecute(result);
+ }
+
+ /**
+ * Process response on ActionService thread
+ */
+ private void processBackgroundResponse(final Action action, final Bundle response) {
+ final LoggingTimer timer = createLoggingTimer(action, "#processBackgroundResponse");
+ timer.start();
+
+ action.processBackgroundWorkResponse(response);
+
+ timer.stopAndLog();
+ }
+
+ /**
+ * Process failure on ActionService thread
+ */
+ private void processBackgroundFailure(final Action action) {
+ final LoggingTimer timer = createLoggingTimer(action, "#processBackgroundFailure");
+ timer.start();
+
+ action.processBackgroundWorkFailure();
+
+ timer.stopAndLog();
+ }
+
+ private static LoggingTimer createLoggingTimer(
+ final Action action, final String methodName) {
+ return new LoggingTimer(TAG, action.getClass().getSimpleName() + methodName,
+ EXECUTION_TIME_WARN_LIMIT_MS);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/BackgroundWorker.java b/src/com/android/messaging/datamodel/action/BackgroundWorker.java
new file mode 100644
index 0000000..aad3c07
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/BackgroundWorker.java
@@ -0,0 +1,32 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import java.util.List;
+
+/**
+ * Interface between action service and its workers
+ */
+public class BackgroundWorker {
+
+ /**
+ * Send list of requests from action service to a worker
+ */
+ public void queueBackgroundWork(final List<Action> backgroundActions) {
+ BackgroundWorkerService.queueBackgroundWork(backgroundActions);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/BackgroundWorkerService.java b/src/com/android/messaging/datamodel/action/BackgroundWorkerService.java
new file mode 100644
index 0000000..4d4b150
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/BackgroundWorkerService.java
@@ -0,0 +1,168 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.app.IntentService;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DataModelException;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.LoggingTimer;
+import com.android.messaging.util.WakeLockHelper;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.List;
+
+/**
+ * Background worker service is an initial example of a background work queue handler
+ * Used to actually "send" messages which may take some time and should not block ActionService
+ * or UI
+ */
+public class BackgroundWorkerService extends IntentService {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+ private static final boolean VERBOSE = false;
+
+ private static final String WAKELOCK_ID = "bugle_background_worker_wakelock";
+ @VisibleForTesting
+ static WakeLockHelper sWakeLock = new WakeLockHelper(WAKELOCK_ID);
+
+ private final ActionService mHost;
+
+ public BackgroundWorkerService() {
+ super("BackgroundWorker");
+ mHost = DataModel.get().getActionService();
+ }
+
+ /**
+ * Queue a list of requests from action service to this worker
+ */
+ public static void queueBackgroundWork(final List<Action> actions) {
+ for (final Action action : actions) {
+ startServiceWithAction(action, 0);
+ }
+ }
+
+ // ops
+ @VisibleForTesting
+ protected static final int OP_PROCESS_REQUEST = 400;
+
+ // extras
+ @VisibleForTesting
+ protected static final String EXTRA_OP_CODE = "op";
+ @VisibleForTesting
+ protected static final String EXTRA_ACTION = "action";
+ @VisibleForTesting
+ protected static final String EXTRA_ATTEMPT = "retry_attempt";
+
+ /**
+ * Queue action intent to the BackgroundWorkerService after acquiring wake lock
+ */
+ private static void startServiceWithAction(final Action action,
+ final int retryCount) {
+ final Intent intent = new Intent();
+ intent.putExtra(EXTRA_ACTION, action);
+ intent.putExtra(EXTRA_ATTEMPT, retryCount);
+ startServiceWithIntent(OP_PROCESS_REQUEST, intent);
+ }
+
+ /**
+ * Queue intent to the BackgroundWorkerService after acquiring wake lock
+ */
+ private static void startServiceWithIntent(final int opcode, final Intent intent) {
+ final Context context = Factory.get().getApplicationContext();
+
+ intent.setClass(context, BackgroundWorkerService.class);
+ intent.putExtra(EXTRA_OP_CODE, opcode);
+ sWakeLock.acquire(context, intent, opcode);
+ if (VERBOSE) {
+ LogUtil.v(TAG, "acquiring wakelock for opcode " + opcode);
+ }
+
+ if (context.startService(intent) == null) {
+ LogUtil.e(TAG,
+ "BackgroundWorkerService.startServiceWithAction: failed to start service for "
+ + opcode);
+ sWakeLock.release(intent, opcode);
+ }
+ }
+
+ @Override
+ protected void onHandleIntent(final Intent intent) {
+ if (intent == null) {
+ // Shouldn't happen but sometimes does following another crash.
+ LogUtil.w(TAG, "BackgroundWorkerService.onHandleIntent: Called with null intent");
+ return;
+ }
+ final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0);
+ sWakeLock.ensure(intent, opcode);
+
+ try {
+ switch(opcode) {
+ case OP_PROCESS_REQUEST: {
+ final Action action = intent.getParcelableExtra(EXTRA_ACTION);
+ final int attempt = intent.getIntExtra(EXTRA_ATTEMPT, -1);
+ doBackgroundWork(action, attempt);
+ break;
+ }
+
+ default:
+ throw new RuntimeException("Unrecognized opcode in BackgroundWorkerService");
+ }
+ } finally {
+ sWakeLock.release(intent, opcode);
+ }
+ }
+
+ /**
+ * Local execution of background work for action on ActionService thread
+ */
+ private void doBackgroundWork(final Action action, final int attempt) {
+ action.markBackgroundWorkStarting();
+ Bundle response = null;
+ try {
+ final LoggingTimer timer = new LoggingTimer(
+ TAG, action.getClass().getSimpleName() + "#doBackgroundWork");
+ timer.start();
+
+ response = action.doBackgroundWork();
+
+ timer.stopAndLog();
+ action.markBackgroundCompletionQueued();
+ mHost.handleResponseFromBackgroundWorker(action, response);
+ } catch (final Exception exception) {
+ final boolean retry = false;
+ LogUtil.e(TAG, "Error in background worker", exception);
+ if (!(exception instanceof DataModelException)) {
+ // DataModelException is expected (sort-of) and handled in handleFailureFromWorker
+ // below, but other exceptions should crash ENG builds
+ Assert.fail("Unexpected error in background worker - abort");
+ }
+ if (retry) {
+ action.markBackgroundWorkQueued();
+ startServiceWithAction(action, attempt + 1);
+ } else {
+ action.markBackgroundCompletionQueued();
+ mHost.handleFailureFromBackgroundWorker(action, exception);
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/BugleActionToasts.java b/src/com/android/messaging/datamodel/action/BugleActionToasts.java
new file mode 100644
index 0000000..f60facd
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/BugleActionToasts.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.messaging.datamodel.action;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.widget.Toast;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.ThreadUtil;
+
+import javax.annotation.Nullable;
+
+/**
+ * Shows one-time, transient notifications in response to action failures (i.e. permanent failures
+ * when sending a message) by showing toasts.
+ */
+public class BugleActionToasts {
+ /**
+ * Called when SendMessageAction or DownloadMmsAction finishes
+ * @param conversationId the conversation of the sent or downloaded message
+ * @param success did the action succeed
+ * @param status the message sending status
+ * @param isSms whether the message is sent using SMS
+ * @param subId the subId of the SIM related to this send
+ * @param isSend whether it is a send (false for download)
+ */
+ static void onSendMessageOrManualDownloadActionCompleted(
+ final String conversationId,
+ final boolean success,
+ final int status,
+ final boolean isSms,
+ final int subId,
+ final boolean isSend) {
+ // We only show notifications for two cases, i.e. when mobile data is off or when we are
+ // in airplane mode, both of which fail fast with permanent failures.
+ if (!success && status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) {
+ final PhoneUtils phoneUtils = PhoneUtils.get(subId);
+ if (phoneUtils.isAirplaneModeOn()) {
+ if (isSend) {
+ showToast(R.string.send_message_failure_airplane_mode);
+ } else {
+ showToast(R.string.download_message_failure_airplane_mode);
+ }
+ return;
+ } else if (!isSms && !phoneUtils.isMobileDataEnabled()) {
+ if (isSend) {
+ showToast(R.string.send_message_failure_no_data);
+ } else {
+ showToast(R.string.download_message_failure_no_data);
+ }
+ return;
+ }
+ }
+
+ if (AccessibilityUtil.isTouchExplorationEnabled(Factory.get().getApplicationContext())) {
+ final boolean isFocusedConversation = DataModel.get().isFocusedConversation(conversationId);
+ if (isFocusedConversation && success) {
+ // Using View.announceForAccessibility may be preferable, but we do not have a
+ // View, and so we use a toast instead.
+ showToast(isSend ? R.string.send_message_success
+ : R.string.download_message_success);
+ return;
+ }
+
+ // {@link MessageNotificationState#checkFailedMessages} does not post a notification for
+ // failures in observable conversations. For accessibility, we provide an indication
+ // here.
+ final boolean isObservableConversation = DataModel.get().isNewMessageObservable(
+ conversationId);
+ if (isObservableConversation && !success) {
+ showToast(isSend ? R.string.send_message_failure
+ : R.string.download_message_failure);
+ }
+ }
+ }
+
+ public static void onMessageReceived(final String conversationId,
+ @Nullable final ParticipantData sender, @Nullable final MessageData message) {
+ final Context context = Factory.get().getApplicationContext();
+ if (AccessibilityUtil.isTouchExplorationEnabled(context)) {
+ final boolean isFocusedConversation = DataModel.get().isFocusedConversation(
+ conversationId);
+ if (isFocusedConversation) {
+ final Resources res = context.getResources();
+ final String senderDisplayName = (sender == null)
+ ? res.getString(R.string.unknown_sender) : sender.getDisplayName(false);
+ final String announcement = res.getString(
+ R.string.incoming_message_announcement, senderDisplayName,
+ (message == null) ? "" : message.getMessageText());
+ showToast(announcement);
+ }
+ }
+ }
+
+ public static void onConversationDeleted() {
+ showToast(R.string.conversation_deleted);
+ }
+
+ private static void showToast(final int messageResId) {
+ ThreadUtil.getMainThreadHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(getApplicationContext(),
+ getApplicationContext().getString(messageResId), Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+
+ private static void showToast(final String message) {
+ ThreadUtil.getMainThreadHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+
+ private static Context getApplicationContext() {
+ return Factory.get().getApplicationContext();
+ }
+
+ private static class UpdateDestinationBlockedActionToast
+ implements UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener {
+ private final Context mContext;
+
+ UpdateDestinationBlockedActionToast(final Context context) {
+ mContext = context;
+ }
+
+ @Override
+ public void onUpdateDestinationBlockedAction(
+ final UpdateDestinationBlockedAction action,
+ final boolean success,
+ final boolean block,
+ final String destination) {
+ if (success) {
+ Toast.makeText(mContext,
+ block
+ ? R.string.update_destination_blocked
+ : R.string.update_destination_unblocked,
+ Toast.LENGTH_LONG
+ ).show();
+ }
+ }
+ }
+
+ public static UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener
+ makeUpdateDestinationBlockedActionListener(final Context context) {
+ return new UpdateDestinationBlockedActionToast(context);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/DeleteConversationAction.java b/src/com/android/messaging/datamodel/action/DeleteConversationAction.java
new file mode 100644
index 0000000..a00f6d6
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/DeleteConversationAction.java
@@ -0,0 +1,205 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DataModelException;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.widget.WidgetConversationProvider;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Action used to delete a conversation.
+ */
+public class DeleteConversationAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ public static void deleteConversation(final String conversationId, final long cutoffTimestamp) {
+ final DeleteConversationAction action = new DeleteConversationAction(conversationId,
+ cutoffTimestamp);
+ action.start();
+ }
+
+ private static final String KEY_CONVERSATION_ID = "conversation_id";
+ private static final String KEY_CUTOFF_TIMESTAMP = "cutoff_timestamp";
+
+ private DeleteConversationAction(final String conversationId, final long cutoffTimestamp) {
+ super();
+ actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
+ // TODO: Should we set cuttoff timestamp to prevent us deleting new messages?
+ actionParameters.putLong(KEY_CUTOFF_TIMESTAMP, cutoffTimestamp);
+ }
+
+ // Delete conversation from both the local DB and telephony in the background so sync cannot
+ // run concurrently and incorrectly try to recreate the conversation's messages locally. The
+ // telephony database can sometimes be quite slow to delete conversations, so we delete from
+ // the local DB first, notify the UI, and then delete from telephony.
+ @Override
+ protected Bundle doBackgroundWork() throws DataModelException {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
+ final long cutoffTimestamp = actionParameters.getLong(KEY_CUTOFF_TIMESTAMP);
+
+ if (!TextUtils.isEmpty(conversationId)) {
+ // First find the thread id for this conversation.
+ final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId);
+
+ if (BugleDatabaseOperations.deleteConversation(db, conversationId, cutoffTimestamp)) {
+ LogUtil.i(TAG, "DeleteConversationAction: Deleted local conversation "
+ + conversationId);
+
+ BugleActionToasts.onConversationDeleted();
+
+ // Remove notifications if necessary
+ BugleNotifications.update(true /* silent */, null /* conversationId */,
+ BugleNotifications.UPDATE_MESSAGES);
+
+ // We have changed the conversation list
+ MessagingContentProvider.notifyConversationListChanged();
+
+ // Notify the widget the conversation is deleted so it can go into its configure state.
+ WidgetConversationProvider.notifyConversationDeleted(
+ Factory.get().getApplicationContext(),
+ conversationId);
+ } else {
+ LogUtil.w(TAG, "DeleteConversationAction: Could not delete local conversation "
+ + conversationId);
+ return null;
+ }
+
+ // Now delete from telephony DB. MmsSmsProvider throws an exception if the thread id is
+ // less than 0. If it's greater than zero, it will delete all messages with that thread
+ // id, even if there's no corresponding row in the threads table.
+ if (threadId >= 0) {
+ final int count = MmsUtils.deleteThread(threadId, cutoffTimestamp);
+ if (count > 0) {
+ LogUtil.i(TAG, "DeleteConversationAction: Deleted telephony thread "
+ + threadId + " (cutoffTimestamp = " + cutoffTimestamp + ")");
+ } else {
+ LogUtil.w(TAG, "DeleteConversationAction: Could not delete thread from "
+ + "telephony: conversationId = " + conversationId + ", thread id = "
+ + threadId);
+ }
+ } else {
+ LogUtil.w(TAG, "DeleteConversationAction: Local conversation " + conversationId
+ + " has an invalid telephony thread id; will delete messages individually");
+ deleteConversationMessagesFromTelephony();
+ }
+ } else {
+ LogUtil.e(TAG, "DeleteConversationAction: conversationId is empty");
+ }
+
+ return null;
+ }
+
+ /**
+ * Deletes all the telephony messages for the local conversation being deleted.
+ * <p>
+ * This is a fallback used when the conversation is not associated with any telephony thread,
+ * or its thread id is invalid (e.g. negative). This is not common, but can happen sometimes
+ * (e.g. the Unknown Sender conversation). In the usual case of deleting a conversation, we
+ * don't need this because the telephony provider automatically deletes messages when a thread
+ * is deleted.
+ */
+ private void deleteConversationMessagesFromTelephony() {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
+ Assert.notNull(conversationId);
+
+ final List<Uri> messageUris = new ArrayList<>();
+ Cursor cursor = null;
+ try {
+ cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
+ new String[] { MessageColumns.SMS_MESSAGE_URI },
+ MessageColumns.CONVERSATION_ID + "=?",
+ new String[] { conversationId },
+ null, null, null);
+ while (cursor.moveToNext()) {
+ String messageUri = cursor.getString(0);
+ try {
+ messageUris.add(Uri.parse(messageUri));
+ } catch (Exception e) {
+ LogUtil.e(TAG, "DeleteConversationAction: Could not parse message uri "
+ + messageUri);
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ for (Uri messageUri : messageUris) {
+ int count = MmsUtils.deleteMessage(messageUri);
+ if (count > 0) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "DeleteConversationAction: Deleted telephony message "
+ + messageUri);
+ }
+ } else {
+ LogUtil.w(TAG, "DeleteConversationAction: Could not delete telephony message "
+ + messageUri);
+ }
+ }
+ }
+
+ @Override
+ protected Object executeAction() {
+ requestBackgroundWork();
+ return null;
+ }
+
+ private DeleteConversationAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<DeleteConversationAction> CREATOR
+ = new Parcelable.Creator<DeleteConversationAction>() {
+ @Override
+ public DeleteConversationAction createFromParcel(final Parcel in) {
+ return new DeleteConversationAction(in);
+ }
+
+ @Override
+ public DeleteConversationAction[] newArray(final int size) {
+ return new DeleteConversationAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/DeleteMessageAction.java b/src/com/android/messaging/datamodel/action/DeleteMessageAction.java
new file mode 100644
index 0000000..9ddb2a6
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/DeleteMessageAction.java
@@ -0,0 +1,135 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Action used to delete a single message.
+ */
+public class DeleteMessageAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ public static void deleteMessage(final String messageId) {
+ final DeleteMessageAction action = new DeleteMessageAction(messageId);
+ action.start();
+ }
+
+ private static final String KEY_MESSAGE_ID = "message_id";
+
+ private DeleteMessageAction(final String messageId) {
+ super();
+ actionParameters.putString(KEY_MESSAGE_ID, messageId);
+ }
+
+ // Doing this work in the background so that we're not competing with sync
+ // which could bring the deleted message back to life between the time we deleted
+ // it locally and deleted it in telephony (sync is also done on doBackgroundWork).
+ //
+ // Previously this block of code deleted from telephony first but that can be very
+ // slow (on the order of seconds) so this was modified to first delete locally, trigger
+ // the UI update, then delete from telephony.
+ @Override
+ protected Bundle doBackgroundWork() {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ // First find the thread id for this conversation.
+ final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
+
+ if (!TextUtils.isEmpty(messageId)) {
+ // Check message still exists
+ final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
+ if (message != null) {
+ // Delete from local DB
+ int count = BugleDatabaseOperations.deleteMessage(db, messageId);
+ if (count > 0) {
+ LogUtil.i(TAG, "DeleteMessageAction: Deleted local message "
+ + messageId);
+ } else {
+ LogUtil.w(TAG, "DeleteMessageAction: Could not delete local message "
+ + messageId);
+ }
+ MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
+ // We may have changed the conversation list
+ MessagingContentProvider.notifyConversationListChanged();
+
+ final Uri messageUri = message.getSmsMessageUri();
+ if (messageUri != null) {
+ // Delete from telephony DB
+ count = MmsUtils.deleteMessage(messageUri);
+ if (count > 0) {
+ LogUtil.i(TAG, "DeleteMessageAction: Deleted telephony message "
+ + messageUri);
+ } else {
+ LogUtil.w(TAG, "DeleteMessageAction: Could not delete message from "
+ + "telephony: messageId = " + messageId + ", telephony uri = "
+ + messageUri);
+ }
+ } else {
+ LogUtil.i(TAG, "DeleteMessageAction: Local message " + messageId
+ + " has no telephony uri.");
+ }
+ } else {
+ LogUtil.w(TAG, "DeleteMessageAction: Message " + messageId + " no longer exists");
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Delete the message.
+ */
+ @Override
+ protected Object executeAction() {
+ requestBackgroundWork();
+ return null;
+ }
+
+ private DeleteMessageAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<DeleteMessageAction> CREATOR
+ = new Parcelable.Creator<DeleteMessageAction>() {
+ @Override
+ public DeleteMessageAction createFromParcel(final Parcel in) {
+ return new DeleteMessageAction(in);
+ }
+
+ @Override
+ public DeleteMessageAction[] newArray(final int size) {
+ return new DeleteMessageAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/DownloadMmsAction.java b/src/com/android/messaging/datamodel/action/DownloadMmsAction.java
new file mode 100644
index 0000000..7a8c907
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/DownloadMmsAction.java
@@ -0,0 +1,340 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.SyncManager;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.RunsOnMainThread;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Downloads an MMS message.
+ * <p>
+ * This class is public (not package-private) because the SMS/MMS (e.g. MmsUtils) classes need to
+ * access the EXTRA_* fields for setting up the 'downloaded' pending intent.
+ */
+public class DownloadMmsAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ /**
+ * Interface for DownloadMmsAction listeners
+ */
+ public interface DownloadMmsActionListener {
+ @RunsOnMainThread
+ abstract void onDownloadMessageStarting(final ActionMonitor monitor,
+ final Object data, final MessageData message);
+ @RunsOnMainThread
+ abstract void onDownloadMessageSucceeded(final ActionMonitor monitor,
+ final Object data, final MessageData message);
+ @RunsOnMainThread
+ abstract void onDownloadMessageFailed(final ActionMonitor monitor,
+ final Object data, final MessageData message);
+ }
+
+ /**
+ * Queue download of an mms notification message (can only be called during execute of action)
+ */
+ static boolean queueMmsForDownloadInBackground(final String messageId,
+ final Action processingAction) {
+ // When this method is being called, it is always from auto download
+ final DownloadMmsAction action = new DownloadMmsAction();
+ // This could queue nothing
+ return action.queueAction(messageId, processingAction);
+ }
+
+ private static final String KEY_MESSAGE_ID = "message_id";
+ private static final String KEY_CONVERSATION_ID = "conversation_id";
+ private static final String KEY_PARTICIPANT_ID = "participant_id";
+ private static final String KEY_CONTENT_LOCATION = "content_location";
+ private static final String KEY_TRANSACTION_ID = "transaction_id";
+ private static final String KEY_NOTIFICATION_URI = "notification_uri";
+ private static final String KEY_SUB_ID = "sub_id";
+ private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number";
+ private static final String KEY_AUTO_DOWNLOAD = "auto_download";
+ private static final String KEY_FAILURE_STATUS = "failure_status";
+
+ // Values we attach to the pending intent that's fired when the message is downloaded.
+ // Only applicable when downloading via the platform APIs on L+.
+ public static final String EXTRA_MESSAGE_ID = "message_id";
+ public static final String EXTRA_CONTENT_URI = "content_uri";
+ public static final String EXTRA_NOTIFICATION_URI = "notification_uri";
+ public static final String EXTRA_SUB_ID = "sub_id";
+ public static final String EXTRA_SUB_PHONE_NUMBER = "sub_phone_number";
+ public static final String EXTRA_TRANSACTION_ID = "transaction_id";
+ public static final String EXTRA_CONTENT_LOCATION = "content_location";
+ public static final String EXTRA_AUTO_DOWNLOAD = "auto_download";
+ public static final String EXTRA_RECEIVED_TIMESTAMP = "received_timestamp";
+ public static final String EXTRA_CONVERSATION_ID = "conversation_id";
+ public static final String EXTRA_PARTICIPANT_ID = "participant_id";
+ public static final String EXTRA_STATUS_IF_FAILED = "status_if_failed";
+
+ private DownloadMmsAction() {
+ super();
+ }
+
+ @Override
+ protected Object executeAction() {
+ Assert.fail("DownloadMmsAction must be queued rather than started");
+ return null;
+ }
+
+ protected boolean queueAction(final String messageId, final Action processingAction) {
+ actionParameters.putString(KEY_MESSAGE_ID, messageId);
+
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ // Read the message from local db
+ final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
+ if (message != null && message.canDownloadMessage()) {
+ final Uri notificationUri = message.getSmsMessageUri();
+ final String conversationId = message.getConversationId();
+ final int status = message.getStatus();
+
+ final String selfId = message.getSelfId();
+ final ParticipantData self = BugleDatabaseOperations
+ .getExistingParticipant(db, selfId);
+ final int subId = self.getSubId();
+ actionParameters.putInt(KEY_SUB_ID, subId);
+ actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
+ actionParameters.putString(KEY_PARTICIPANT_ID, message.getParticipantId());
+ actionParameters.putString(KEY_CONTENT_LOCATION, message.getMmsContentLocation());
+ actionParameters.putString(KEY_TRANSACTION_ID, message.getMmsTransactionId());
+ actionParameters.putParcelable(KEY_NOTIFICATION_URI, notificationUri);
+ actionParameters.putBoolean(KEY_AUTO_DOWNLOAD, isAutoDownload(status));
+
+ final long now = System.currentTimeMillis();
+ if (message.getInDownloadWindow(now)) {
+ // We can still retry
+ actionParameters.putString(KEY_SUB_PHONE_NUMBER, self.getNormalizedDestination());
+
+ final int downloadingStatus = getDownloadingStatus(status);
+ // Update message status to indicate downloading.
+ updateMessageStatus(notificationUri, messageId, conversationId,
+ downloadingStatus, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED);
+ // Pre-compute the next status when failed so we don't have to load from db again
+ actionParameters.putInt(KEY_FAILURE_STATUS, getFailureStatus(downloadingStatus));
+
+ // Actual download happens in background
+ processingAction.requestBackgroundWork(this);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG,
+ "DownloadMmsAction: Queued download of MMS message " + messageId);
+ }
+ return true;
+ } else {
+ LogUtil.w(TAG, "DownloadMmsAction: Download of MMS message " + messageId
+ + " failed (outside download window)");
+
+ // Retries depleted and we failed. Update the message status so we won't retry again
+ updateMessageStatus(notificationUri, messageId, conversationId,
+ MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED,
+ MessageData.RAW_TELEPHONY_STATUS_UNDEFINED);
+ if (status == MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD) {
+ // For auto download failure, we should send a DEFERRED NotifyRespInd
+ // to carrier to indicate we will manual download later
+ ProcessDownloadedMmsAction.sendDeferredRespStatus(
+ messageId, message.getMmsTransactionId(),
+ message.getMmsContentLocation(), subId);
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Find out the auto download state of this message based on its starting status
+ *
+ * @param status The starting status of the message.
+ * @return True if this is a message doing auto downloading, false otherwise
+ */
+ private static boolean isAutoDownload(final int status) {
+ switch (status) {
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
+ return false;
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
+ return true;
+ default:
+ Assert.fail("isAutoDownload: invalid input status " + status);
+ return false;
+ }
+ }
+
+ /**
+ * Get the corresponding downloading status based on the starting status of the message
+ *
+ * @param status The starting status of the message.
+ * @return The downloading status
+ */
+ private static int getDownloadingStatus(final int status) {
+ switch (status) {
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
+ return MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING;
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
+ return MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING;
+ default:
+ Assert.fail("isAutoDownload: invalid input status " + status);
+ return MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING;
+ }
+ }
+
+ /**
+ * Get the corresponding failed status based on the current downloading status
+ *
+ * @param status The downloading status
+ * @return The status the message should have if downloading failed
+ */
+ private static int getFailureStatus(final int status) {
+ switch (status) {
+ case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING:
+ return MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD;
+ case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING:
+ return MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD;
+ default:
+ Assert.fail("isAutoDownload: invalid input status " + status);
+ return MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD;
+ }
+ }
+
+ @Override
+ protected Bundle doBackgroundWork() {
+ final Context context = Factory.get().getApplicationContext();
+ final int subId = actionParameters.getInt(KEY_SUB_ID);
+ final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
+ final Uri notificationUri = actionParameters.getParcelable(KEY_NOTIFICATION_URI);
+ final String subPhoneNumber = actionParameters.getString(KEY_SUB_PHONE_NUMBER);
+ final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
+ final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
+ final boolean autoDownload = actionParameters.getBoolean(KEY_AUTO_DOWNLOAD);
+ final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
+ final String participantId = actionParameters.getString(KEY_PARTICIPANT_ID);
+ final int statusIfFailed = actionParameters.getInt(KEY_FAILURE_STATUS);
+
+ final long receivedTimestampRoundedToSecond =
+ 1000 * ((System.currentTimeMillis() + 500) / 1000);
+
+ LogUtil.i(TAG, "DownloadMmsAction: Downloading MMS message " + messageId
+ + " (" + (autoDownload ? "auto" : "manual") + ")");
+
+ // Bundle some values we'll need after the message is downloaded (via platform APIs)
+ final Bundle extras = new Bundle();
+ extras.putString(EXTRA_MESSAGE_ID, messageId);
+ extras.putString(EXTRA_CONVERSATION_ID, conversationId);
+ extras.putString(EXTRA_PARTICIPANT_ID, participantId);
+ extras.putInt(EXTRA_STATUS_IF_FAILED, statusIfFailed);
+
+ // Start the download
+ final MmsUtils.StatusPlusUri status = MmsUtils.downloadMmsMessage(context,
+ notificationUri, subId, subPhoneNumber, transactionId, contentLocation,
+ autoDownload, receivedTimestampRoundedToSecond / 1000L, extras);
+ if (status == MmsUtils.STATUS_PENDING) {
+ // Async download; no status yet
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "DownloadMmsAction: Downloading MMS message " + messageId
+ + " asynchronously; waiting for pending intent to signal completion");
+ }
+ } else {
+ // Inform sync that message has been added at local received timestamp
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+ syncManager.onNewMessageInserted(receivedTimestampRoundedToSecond);
+ // Handle downloaded message
+ ProcessDownloadedMmsAction.processMessageDownloadFastFailed(messageId,
+ notificationUri, conversationId, participantId, contentLocation, subId,
+ subPhoneNumber, statusIfFailed, autoDownload, transactionId,
+ status.resultCode);
+ }
+ return null;
+ }
+
+ @Override
+ protected Object processBackgroundResponse(final Bundle response) {
+ // Nothing to do here; post-download actions handled by ProcessDownloadedMmsAction
+ return null;
+ }
+
+ @Override
+ protected Object processBackgroundFailure() {
+ final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
+ final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
+ final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
+ final String participantId = actionParameters.getString(KEY_PARTICIPANT_ID);
+ final int statusIfFailed = actionParameters.getInt(KEY_FAILURE_STATUS);
+ final int subId = actionParameters.getInt(KEY_SUB_ID);
+
+ ProcessDownloadedMmsAction.processDownloadActionFailure(messageId,
+ MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED,
+ conversationId, participantId, statusIfFailed, subId, transactionId);
+
+ return null;
+ }
+
+ static void updateMessageStatus(final Uri messageUri, final String messageId,
+ final String conversationId, final int status, final int rawStatus) {
+ final Context context = Factory.get().getApplicationContext();
+ // Downloading status just kept in local DB but need to fix up telephony DB first
+ if (status == MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING ||
+ status == MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING) {
+ MmsUtils.clearMmsStatus(context, messageUri);
+ }
+ // Then mark downloading status in our local DB
+ final ContentValues values = new ContentValues();
+ values.put(MessageColumns.STATUS, status);
+ values.put(MessageColumns.RAW_TELEPHONY_STATUS, rawStatus);
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ BugleDatabaseOperations.updateMessageRowIfExists(db, messageId, values);
+
+ MessagingContentProvider.notifyMessagesChanged(conversationId);
+ }
+
+ private DownloadMmsAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<DownloadMmsAction> CREATOR
+ = new Parcelable.Creator<DownloadMmsAction>() {
+ @Override
+ public DownloadMmsAction createFromParcel(final Parcel in) {
+ return new DownloadMmsAction(in);
+ }
+
+ @Override
+ public DownloadMmsAction[] newArray(final int size) {
+ return new DownloadMmsAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/DumpDatabaseAction.java b/src/com/android/messaging/datamodel/action/DumpDatabaseAction.java
new file mode 100644
index 0000000..ab320bf
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/DumpDatabaseAction.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.messaging.datamodel.action;
+
+import android.content.Context;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.LogUtil;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+public class DumpDatabaseAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+ public static final String DUMP_NAME = "db_copy.db";
+ private static final int BUFFER_SIZE = 16384;
+
+ /**
+ * Copy the database to external storage
+ */
+ public static void dumpDatabase() {
+ final DumpDatabaseAction action = new DumpDatabaseAction();
+ action.start();
+ }
+
+ private DumpDatabaseAction() {
+ }
+
+ @Override
+ protected Object executeAction() {
+ final Context context = Factory.get().getApplicationContext();
+ final String dbName = DatabaseHelper.DATABASE_NAME;
+ BufferedOutputStream bos = null;
+ BufferedInputStream bis = null;
+
+ long originalSize = 0;
+ final File inFile = context.getDatabasePath(dbName);
+ if (inFile.exists() && inFile.isFile()) {
+ originalSize = inFile.length();
+ }
+ final File outFile = DebugUtils.getDebugFile(DUMP_NAME, true);
+ if (outFile != null) {
+ int totalBytes = 0;
+ try {
+ bos = new BufferedOutputStream(new FileOutputStream(outFile));
+ bis = new BufferedInputStream(new FileInputStream(inFile));
+
+ final byte[] buffer = new byte[BUFFER_SIZE];
+ int bytesRead;
+ while ((bytesRead = bis.read(buffer)) > 0) {
+ bos.write(buffer, 0, bytesRead);
+ totalBytes += bytesRead;
+ }
+ } catch (final IOException e) {
+ LogUtil.w(TAG, "Exception copying the database;"
+ + " destination may not be complete.", e);
+ } finally {
+ if (bos != null) {
+ try {
+ bos.close();
+ } catch (final IOException e) {
+ // Nothing to do
+ }
+ }
+
+ if (bis != null) {
+ try {
+ bis.close();
+ } catch (final IOException e) {
+ // Nothing to do
+ }
+ }
+ DebugUtils.ensureReadable(outFile);
+ LogUtil.i(TAG, "Dump complete; orig size: " + originalSize +
+ ", copy size: " + totalBytes);
+ }
+ }
+ return null;
+ }
+
+ private DumpDatabaseAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<DumpDatabaseAction> CREATOR
+ = new Parcelable.Creator<DumpDatabaseAction>() {
+ @Override
+ public DumpDatabaseAction createFromParcel(final Parcel in) {
+ return new DumpDatabaseAction(in);
+ }
+
+ @Override
+ public DumpDatabaseAction[] newArray(final int size) {
+ return new DumpDatabaseAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/FixupMessageStatusOnStartupAction.java b/src/com/android/messaging/datamodel/action/FixupMessageStatusOnStartupAction.java
new file mode 100644
index 0000000..e3d131d
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/FixupMessageStatusOnStartupAction.java
@@ -0,0 +1,114 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.ContentValues;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Action used to fixup actively downloading or sending status at startup - just in case we
+ * crash - never run this when a message might actually be sending or downloading.
+ */
+public class FixupMessageStatusOnStartupAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ public static void fixupMessageStatus() {
+ final FixupMessageStatusOnStartupAction action = new FixupMessageStatusOnStartupAction();
+ action.start();
+ }
+
+ private FixupMessageStatusOnStartupAction() {
+ }
+
+ @Override
+ protected Object executeAction() {
+ // Now mark any messages in active sending or downloading state as inactive
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ db.beginTransaction();
+ int downloadFailedCnt = 0;
+ int sendFailedCnt = 0;
+ try {
+ // For both sending and downloading messages, let's assume they failed.
+ // For MMS sent/downloaded via platform, the sent/downloaded pending intent
+ // may come back. That will update the message. User may see the message
+ // in wrong status within a short window if that happens. But this should
+ // rarely happen. This is a simple solution to situations like app gets killed
+ // while the pending intent is still in the fly. Alternatively, we could
+ // keep the status for platform sent/downloaded MMS and timeout these messages.
+ // But that is much more complex.
+ final ContentValues values = new ContentValues();
+ values.put(DatabaseHelper.MessageColumns.STATUS,
+ MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED);
+ downloadFailedCnt += db.update(DatabaseHelper.MESSAGES_TABLE, values,
+ DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
+ new String[]{
+ Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING),
+ Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING)
+ });
+ values.clear();
+
+ values.clear();
+ values.put(DatabaseHelper.MessageColumns.STATUS,
+ MessageData.BUGLE_STATUS_OUTGOING_FAILED);
+ sendFailedCnt = db.update(DatabaseHelper.MESSAGES_TABLE, values,
+ DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
+ new String[]{
+ Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING),
+ Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING)
+ });
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ LogUtil.i(TAG, "Fixup: Send failed - " + sendFailedCnt
+ + " Download failed - " + downloadFailedCnt);
+
+ // Don't send contentObserver notifications as displayed text should not change
+ return null;
+ }
+
+ private FixupMessageStatusOnStartupAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<FixupMessageStatusOnStartupAction> CREATOR
+ = new Parcelable.Creator<FixupMessageStatusOnStartupAction>() {
+ @Override
+ public FixupMessageStatusOnStartupAction createFromParcel(final Parcel in) {
+ return new FixupMessageStatusOnStartupAction(in);
+ }
+
+ @Override
+ public FixupMessageStatusOnStartupAction[] newArray(final int size) {
+ return new FixupMessageStatusOnStartupAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/GetOrCreateConversationAction.java b/src/com/android/messaging/datamodel/action/GetOrCreateConversationAction.java
new file mode 100644
index 0000000..b262141
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/GetOrCreateConversationAction.java
@@ -0,0 +1,173 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener;
+import com.android.messaging.datamodel.data.LaunchConversationData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.RunsOnMainThread;
+import com.android.messaging.util.LogUtil;
+
+import java.util.ArrayList;
+
+/**
+ * Action used to get or create a conversation for a list of conversation participants.
+ */
+public class GetOrCreateConversationAction extends Action implements Parcelable {
+ /**
+ * Interface for GetOrCreateConversationAction listeners
+ */
+ public interface GetOrCreateConversationActionListener {
+ @RunsOnMainThread
+ abstract void onGetOrCreateConversationSucceeded(final ActionMonitor monitor,
+ final Object data, final String conversationId);
+
+ @RunsOnMainThread
+ abstract void onGetOrCreateConversationFailed(final ActionMonitor monitor,
+ final Object data);
+ }
+
+ public static GetOrCreateConversationActionMonitor getOrCreateConversation(
+ final ArrayList<ParticipantData> participants, final Object data,
+ final GetOrCreateConversationActionListener listener) {
+ final GetOrCreateConversationActionMonitor monitor = new
+ GetOrCreateConversationActionMonitor(data, listener);
+ final GetOrCreateConversationAction action = new GetOrCreateConversationAction(participants,
+ monitor.getActionKey());
+ action.start(monitor);
+ return monitor;
+ }
+
+
+ public static GetOrCreateConversationActionMonitor getOrCreateConversation(
+ final String[] recipients, final Object data, final LaunchConversationData listener) {
+ final ArrayList<ParticipantData> participants = new ArrayList<>();
+ for (String recipient : recipients) {
+ recipient = recipient.trim();
+ if (!TextUtils.isEmpty(recipient)) {
+ participants.add(ParticipantData.getFromRawPhoneBySystemLocale(recipient));
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "getOrCreateConversation hit empty recipient");
+ }
+ }
+ return getOrCreateConversation(participants, data, listener);
+ }
+
+ private static final String KEY_PARTICIPANTS_LIST = "participants_list";
+
+ private GetOrCreateConversationAction(final ArrayList<ParticipantData> participants,
+ final String actionKey) {
+ super(actionKey);
+ actionParameters.putParcelableArrayList(KEY_PARTICIPANTS_LIST, participants);
+ }
+
+ /**
+ * Lookup the conversation or create a new one.
+ */
+ @Override
+ protected Object executeAction() {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ // First find the thread id for this list of participants.
+ final ArrayList<ParticipantData> participants =
+ actionParameters.getParcelableArrayList(KEY_PARTICIPANTS_LIST);
+ BugleDatabaseOperations.sanitizeConversationParticipants(participants);
+ final ArrayList<String> recipients =
+ BugleDatabaseOperations.getRecipientsFromConversationParticipants(participants);
+
+ final long threadId = MmsUtils.getOrCreateThreadId(Factory.get().getApplicationContext(),
+ recipients);
+
+ if (threadId < 0) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't create a threadId in SMS db for numbers : " +
+ LogUtil.sanitizePII(recipients.toString()));
+ // TODO: Add a better way to indicate an error from executeAction.
+ return null;
+ }
+
+ final String conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
+ false, participants, false, false, null);
+
+ return conversationId;
+ }
+
+ /**
+ * A monitor that notifies a listener upon completion
+ */
+ public static class GetOrCreateConversationActionMonitor extends ActionMonitor
+ implements ActionCompletedListener {
+ private final GetOrCreateConversationActionListener mListener;
+
+ GetOrCreateConversationActionMonitor(final Object data,
+ final GetOrCreateConversationActionListener listener) {
+ super(STATE_CREATED, generateUniqueActionKey("GetOrCreateConversationAction"), data);
+ setCompletedListener(this);
+ mListener = listener;
+ }
+
+ @Override
+ public void onActionSucceeded(final ActionMonitor monitor,
+ final Action action, final Object data, final Object result) {
+ if (result == null) {
+ mListener.onGetOrCreateConversationFailed(monitor, data);
+ } else {
+ mListener.onGetOrCreateConversationSucceeded(monitor, data, (String) result);
+ }
+ }
+
+ @Override
+ public void onActionFailed(final ActionMonitor monitor,
+ final Action action, final Object data, final Object result) {
+ // TODO: Currently onActionFailed is only called if there is an error in
+ // processing requests, not for errors in the local processing.
+ Assert.fail("Unreachable");
+ mListener.onGetOrCreateConversationFailed(monitor, data);
+ }
+ }
+
+ private GetOrCreateConversationAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<GetOrCreateConversationAction> CREATOR
+ = new Parcelable.Creator<GetOrCreateConversationAction>() {
+ @Override
+ public GetOrCreateConversationAction createFromParcel(final Parcel in) {
+ return new GetOrCreateConversationAction(in);
+ }
+
+ @Override
+ public GetOrCreateConversationAction[] newArray(final int size) {
+ return new GetOrCreateConversationAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/HandleLowStorageAction.java b/src/com/android/messaging/datamodel/action/HandleLowStorageAction.java
new file mode 100644
index 0000000..7bfcfe0
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/HandleLowStorageAction.java
@@ -0,0 +1,94 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.sms.SmsReleaseStorage;
+import com.android.messaging.util.Assert;
+
+/**
+ * Action used to handle low storage related issues on the device.
+ */
+public class HandleLowStorageAction extends Action implements Parcelable {
+ private static final int SUB_OP_CODE_CLEAR_MEDIA_MESSAGES = 100;
+ private static final int SUB_OP_CODE_CLEAR_OLD_MESSAGES = 101;
+
+ public static void handleDeleteMediaMessages(final long durationInMillis) {
+ final HandleLowStorageAction action = new HandleLowStorageAction(
+ SUB_OP_CODE_CLEAR_MEDIA_MESSAGES, durationInMillis);
+ action.start();
+ }
+
+ public static void handleDeleteOldMessages(final long durationInMillis) {
+ final HandleLowStorageAction action = new HandleLowStorageAction(
+ SUB_OP_CODE_CLEAR_OLD_MESSAGES, durationInMillis);
+ action.start();
+ }
+
+ private static final String KEY_SUB_OP_CODE = "sub_op_code";
+ private static final String KEY_CUTOFF_DURATION_MILLIS = "cutoff_duration_millis";
+
+ private HandleLowStorageAction(final int subOpcode, final long durationInMillis) {
+ super();
+ actionParameters.putInt(KEY_SUB_OP_CODE, subOpcode);
+ actionParameters.putLong(KEY_CUTOFF_DURATION_MILLIS, durationInMillis);
+ }
+
+ @Override
+ protected Object executeAction() {
+ final int subOpCode = actionParameters.getInt(KEY_SUB_OP_CODE);
+ final long durationInMillis = actionParameters.getLong(KEY_CUTOFF_DURATION_MILLIS);
+ switch (subOpCode) {
+ case SUB_OP_CODE_CLEAR_MEDIA_MESSAGES:
+ SmsReleaseStorage.deleteMessages(0, durationInMillis);
+ break;
+
+ case SUB_OP_CODE_CLEAR_OLD_MESSAGES:
+ SmsReleaseStorage.deleteMessages(1, durationInMillis);
+ break;
+
+ default:
+ Assert.fail("Unsupported action type!");
+ break;
+ }
+ return true;
+ }
+
+ private HandleLowStorageAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<HandleLowStorageAction> CREATOR
+ = new Parcelable.Creator<HandleLowStorageAction>() {
+ @Override
+ public HandleLowStorageAction createFromParcel(final Parcel in) {
+ return new HandleLowStorageAction(in);
+ }
+
+ @Override
+ public HandleLowStorageAction[] newArray(final int size) {
+ return new HandleLowStorageAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/InsertNewMessageAction.java b/src/com/android/messaging/datamodel/action/InsertNewMessageAction.java
new file mode 100644
index 0000000..2567ca9
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/InsertNewMessageAction.java
@@ -0,0 +1,480 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.Telephony;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.SyncManager;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Action used to convert a draft message to an outgoing message. Its writes SMS messages to
+ * the telephony db, but {@link SendMessageAction} is responsible for inserting MMS message into
+ * the telephony DB. The latter also does the actual sending of the message in the background.
+ * The latter is also responsible for re-sending a failed message.
+ */
+public class InsertNewMessageAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ private static long sLastSentMessageTimestamp = -1;
+
+ /**
+ * Insert message (no listener)
+ */
+ public static void insertNewMessage(final MessageData message) {
+ final InsertNewMessageAction action = new InsertNewMessageAction(message);
+ action.start();
+ }
+
+ /**
+ * Insert message (no listener) with a given non-default subId.
+ */
+ public static void insertNewMessage(final MessageData message, final int subId) {
+ Assert.isFalse(subId == ParticipantData.DEFAULT_SELF_SUB_ID);
+ final InsertNewMessageAction action = new InsertNewMessageAction(message, subId);
+ action.start();
+ }
+
+ /**
+ * Insert message (no listener)
+ */
+ public static void insertNewMessage(final int subId, final String recipients,
+ final String messageText, final String subject) {
+ final InsertNewMessageAction action = new InsertNewMessageAction(
+ subId, recipients, messageText, subject);
+ action.start();
+ }
+
+ public static long getLastSentMessageTimestamp() {
+ return sLastSentMessageTimestamp;
+ }
+
+ private static final String KEY_SUB_ID = "sub_id";
+ private static final String KEY_MESSAGE = "message";
+ private static final String KEY_RECIPIENTS = "recipients";
+ private static final String KEY_MESSAGE_TEXT = "message_text";
+ private static final String KEY_SUBJECT_TEXT = "subject_text";
+
+ private InsertNewMessageAction(final MessageData message) {
+ this(message, ParticipantData.DEFAULT_SELF_SUB_ID);
+ actionParameters.putParcelable(KEY_MESSAGE, message);
+ }
+
+ private InsertNewMessageAction(final MessageData message, final int subId) {
+ super();
+ actionParameters.putParcelable(KEY_MESSAGE, message);
+ actionParameters.putInt(KEY_SUB_ID, subId);
+ }
+
+ private InsertNewMessageAction(final int subId, final String recipients,
+ final String messageText, final String subject) {
+ super();
+ if (TextUtils.isEmpty(recipients) || TextUtils.isEmpty(messageText)) {
+ Assert.fail("InsertNewMessageAction: Can't have empty recipients or message");
+ }
+ actionParameters.putInt(KEY_SUB_ID, subId);
+ actionParameters.putString(KEY_RECIPIENTS, recipients);
+ actionParameters.putString(KEY_MESSAGE_TEXT, messageText);
+ actionParameters.putString(KEY_SUBJECT_TEXT, subject);
+ }
+
+ /**
+ * Add message to database in pending state and queue actual sending
+ */
+ @Override
+ protected Object executeAction() {
+ LogUtil.i(TAG, "InsertNewMessageAction: inserting new message");
+ MessageData message = actionParameters.getParcelable(KEY_MESSAGE);
+ if (message == null) {
+ LogUtil.i(TAG, "InsertNewMessageAction: Creating MessageData with provided data");
+ message = createMessage();
+ if (message == null) {
+ LogUtil.w(TAG, "InsertNewMessageAction: Could not create MessageData");
+ return null;
+ }
+ }
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final String conversationId = message.getConversationId();
+
+ final ParticipantData self = getSelf(db, conversationId, message);
+ if (self == null) {
+ return null;
+ }
+ message.bindSelfId(self.getId());
+ // If the user taps the Send button before the conversation draft is created/loaded by
+ // ReadDraftDataAction (maybe the action service thread was busy), the MessageData may not
+ // have the participant id set. It should be equal to the self id, so we'll use that.
+ if (message.getParticipantId() == null) {
+ message.bindParticipantId(self.getId());
+ }
+
+ final long timestamp = System.currentTimeMillis();
+ final ArrayList<String> recipients =
+ BugleDatabaseOperations.getRecipientsForConversation(db, conversationId);
+ if (recipients.size() < 1) {
+ LogUtil.w(TAG, "InsertNewMessageAction: message recipients is empty");
+ return null;
+ }
+ final int subId = self.getSubId();
+
+ // TODO: Work out whether to send with SMS or MMS (taking into account recipients)?
+ final boolean isSms = (message.getProtocol() == MessageData.PROTOCOL_SMS);
+ if (isSms) {
+ String sendingConversationId = conversationId;
+ if (recipients.size() > 1) {
+ // Broadcast SMS - put message in "fake conversation" before farming out to real 1:1
+ final long laterTimestamp = timestamp + 1;
+ // Send a single message
+ insertBroadcastSmsMessage(conversationId, message, subId,
+ laterTimestamp, recipients);
+
+ sendingConversationId = null;
+ }
+
+ for (final String recipient : recipients) {
+ // Start actual sending
+ insertSendingSmsMessage(message, subId, recipient,
+ timestamp, sendingConversationId);
+ }
+
+ // Can now clear draft from conversation (deleting attachments if necessary)
+ BugleDatabaseOperations.updateDraftMessageData(db, conversationId,
+ null /* message */, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT);
+ } else {
+ final long timestampRoundedToSecond = 1000 * ((timestamp + 500) / 1000);
+ // Write place holder message directly referencing parts from the draft
+ final MessageData messageToSend = insertSendingMmsMessage(conversationId,
+ message, timestampRoundedToSecond);
+
+ // Can now clear draft from conversation (preserving attachments which are now
+ // referenced by messageToSend)
+ BugleDatabaseOperations.updateDraftMessageData(db, conversationId,
+ messageToSend, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT);
+ }
+ MessagingContentProvider.notifyConversationListChanged();
+ ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
+
+ return message;
+ }
+
+ private ParticipantData getSelf(
+ final DatabaseWrapper db, final String conversationId, final MessageData message) {
+ ParticipantData self;
+ // Check if we are asked to bind to a non-default subId. This is directly passed in from
+ // the UI thread so that the sub id may be locked as soon as the user clicks on the Send
+ // button.
+ final int requestedSubId = actionParameters.getInt(
+ KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
+ if (requestedSubId != ParticipantData.DEFAULT_SELF_SUB_ID) {
+ self = BugleDatabaseOperations.getOrCreateSelf(db, requestedSubId);
+ } else {
+ String selfId = message.getSelfId();
+ if (selfId == null) {
+ // The conversation draft provides no self id hint, meaning that 1) conversation
+ // self id was not loaded AND 2) the user didn't pick a SIM from the SIM selector.
+ // In this case, use the conversation's self id.
+ final ConversationListItemData conversation =
+ ConversationListItemData.getExistingConversation(db, conversationId);
+ if (conversation != null) {
+ selfId = conversation.getSelfId();
+ } else {
+ LogUtil.w(LogUtil.BUGLE_DATAMODEL_TAG, "Conversation " + conversationId +
+ "already deleted before sending draft message " +
+ message.getMessageId() + ". Aborting InsertNewMessageAction.");
+ return null;
+ }
+ }
+
+ // We do not use SubscriptionManager.DEFAULT_SUB_ID for sending a message, so we need
+ // to bind the message to the system default subscription if it's unbound.
+ final ParticipantData unboundSelf = BugleDatabaseOperations.getExistingParticipant(
+ db, selfId);
+ if (unboundSelf.getSubId() == ParticipantData.DEFAULT_SELF_SUB_ID
+ && OsUtil.isAtLeastL_MR1()) {
+ final int defaultSubId = PhoneUtils.getDefault().getDefaultSmsSubscriptionId();
+ self = BugleDatabaseOperations.getOrCreateSelf(db, defaultSubId);
+ } else {
+ self = unboundSelf;
+ }
+ }
+ return self;
+ }
+
+ /** Create MessageData using KEY_RECIPIENTS, KEY_MESSAGE_TEXT and KEY_SUBJECT */
+ private MessageData createMessage() {
+ // First find the thread id for this list of participants.
+ final String recipientsList = actionParameters.getString(KEY_RECIPIENTS);
+ final String messageText = actionParameters.getString(KEY_MESSAGE_TEXT);
+ final String subjectText = actionParameters.getString(KEY_SUBJECT_TEXT);
+ final int subId = actionParameters.getInt(
+ KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
+
+ final ArrayList<ParticipantData> participants = new ArrayList<>();
+ for (final String recipient : recipientsList.split(",")) {
+ participants.add(ParticipantData.getFromRawPhoneBySimLocale(recipient, subId));
+ }
+ if (participants.size() == 0) {
+ Assert.fail("InsertNewMessage: Empty participants");
+ return null;
+ }
+
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ BugleDatabaseOperations.sanitizeConversationParticipants(participants);
+ final ArrayList<String> recipients =
+ BugleDatabaseOperations.getRecipientsFromConversationParticipants(participants);
+ if (recipients.size() == 0) {
+ Assert.fail("InsertNewMessage: Empty recipients");
+ return null;
+ }
+
+ final long threadId = MmsUtils.getOrCreateThreadId(Factory.get().getApplicationContext(),
+ recipients);
+
+ if (threadId < 0) {
+ Assert.fail("InsertNewMessage: Couldn't get threadId in SMS db for these recipients: "
+ + recipients.toString());
+ // TODO: How do we fail the action?
+ return null;
+ }
+
+ final String conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
+ false, participants, false, false, null);
+
+ final ParticipantData self = BugleDatabaseOperations.getOrCreateSelf(db, subId);
+
+ if (TextUtils.isEmpty(subjectText)) {
+ return MessageData.createDraftSmsMessage(conversationId, self.getId(), messageText);
+ } else {
+ return MessageData.createDraftMmsMessage(conversationId, self.getId(), messageText,
+ subjectText);
+ }
+ }
+
+ private void insertBroadcastSmsMessage(final String conversationId,
+ final MessageData message, final int subId, final long laterTimestamp,
+ final ArrayList<String> recipients) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "InsertNewMessageAction: Inserting broadcast SMS message "
+ + message.getMessageId());
+ }
+ final Context context = Factory.get().getApplicationContext();
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ // Inform sync that message is being added at timestamp
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+ syncManager.onNewMessageInserted(laterTimestamp);
+
+ final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId);
+ final String address = TextUtils.join(" ", recipients);
+
+ final String messageText = message.getMessageText();
+ // Insert message into telephony database sms message table
+ final Uri messageUri = MmsUtils.insertSmsMessage(context,
+ Telephony.Sms.CONTENT_URI,
+ subId,
+ address,
+ messageText,
+ laterTimestamp,
+ Telephony.Sms.STATUS_COMPLETE,
+ Telephony.Sms.MESSAGE_TYPE_SENT, threadId);
+ if (messageUri != null && !TextUtils.isEmpty(messageUri.toString())) {
+ db.beginTransaction();
+ try {
+ message.updateSendingMessage(conversationId, messageUri, laterTimestamp);
+ message.markMessageSent(laterTimestamp);
+
+ BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
+
+ BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
+ conversationId, message.getMessageId(), laterTimestamp,
+ false /* senderBlocked */, false /* shouldAutoSwitchSelfId */);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "InsertNewMessageAction: Inserted broadcast SMS message "
+ + message.getMessageId() + ", uri = " + message.getSmsMessageUri());
+ }
+ MessagingContentProvider.notifyMessagesChanged(conversationId);
+ MessagingContentProvider.notifyPartsChanged();
+ } else {
+ // Ignore error as we only really care about the individual messages?
+ LogUtil.e(TAG,
+ "InsertNewMessageAction: No uri for broadcast SMS " + message.getMessageId()
+ + " inserted into telephony DB");
+ }
+ }
+
+ /**
+ * Insert SMS messaging into our database and telephony db.
+ */
+ private MessageData insertSendingSmsMessage(final MessageData content, final int subId,
+ final String recipient, final long timestamp, final String sendingConversationId) {
+ sLastSentMessageTimestamp = timestamp;
+
+ final Context context = Factory.get().getApplicationContext();
+
+ // Inform sync that message is being added at timestamp
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+ syncManager.onNewMessageInserted(timestamp);
+
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ // Send a single message
+ long threadId;
+ String conversationId;
+ if (sendingConversationId == null) {
+ // For 1:1 message generated sending broadcast need to look up threadId+conversationId
+ threadId = MmsUtils.getOrCreateSmsThreadId(context, recipient);
+ conversationId = BugleDatabaseOperations.getOrCreateConversationFromRecipient(
+ db, threadId, false /* sender blocked */,
+ ParticipantData.getFromRawPhoneBySimLocale(recipient, subId));
+ } else {
+ // Otherwise just look up threadId
+ threadId = BugleDatabaseOperations.getThreadId(db, sendingConversationId);
+ conversationId = sendingConversationId;
+ }
+
+ final String messageText = content.getMessageText();
+
+ // Insert message into telephony database sms message table
+ final Uri messageUri = MmsUtils.insertSmsMessage(context,
+ Telephony.Sms.CONTENT_URI,
+ subId,
+ recipient,
+ messageText,
+ timestamp,
+ Telephony.Sms.STATUS_NONE,
+ Telephony.Sms.MESSAGE_TYPE_SENT, threadId);
+
+ MessageData message = null;
+ if (messageUri != null && !TextUtils.isEmpty(messageUri.toString())) {
+ db.beginTransaction();
+ try {
+ message = MessageData.createDraftSmsMessage(conversationId,
+ content.getSelfId(), messageText);
+ message.updateSendingMessage(conversationId, messageUri, timestamp);
+
+ BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
+
+ // Do not update the conversation summary to reflect autogenerated 1:1 messages
+ if (sendingConversationId != null) {
+ BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
+ conversationId, message.getMessageId(), timestamp,
+ false /* senderBlocked */, false /* shouldAutoSwitchSelfId */);
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "InsertNewMessageAction: Inserted SMS message "
+ + message.getMessageId() + " (uri = " + message.getSmsMessageUri()
+ + ", timestamp = " + message.getReceivedTimeStamp() + ")");
+ }
+ MessagingContentProvider.notifyMessagesChanged(conversationId);
+ MessagingContentProvider.notifyPartsChanged();
+ } else {
+ LogUtil.e(TAG, "InsertNewMessageAction: No uri for SMS inserted into telephony DB");
+ }
+
+ return message;
+ }
+
+ /**
+ * Insert MMS messaging into our database.
+ */
+ private MessageData insertSendingMmsMessage(final String conversationId,
+ final MessageData message, final long timestamp) {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ db.beginTransaction();
+ final List<MessagePartData> attachmentsUpdated = new ArrayList<>();
+ try {
+ sLastSentMessageTimestamp = timestamp;
+
+ // Insert "draft" message as placeholder until the final message is written to
+ // the telephony db
+ message.updateSendingMessage(conversationId, null/*messageUri*/, timestamp);
+
+ // No need to inform SyncManager as message currently has no Uri...
+ BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
+
+ BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
+ conversationId, message.getMessageId(), timestamp,
+ false /* senderBlocked */, false /* shouldAutoSwitchSelfId */);
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "InsertNewMessageAction: Inserted MMS message "
+ + message.getMessageId() + " (timestamp = " + timestamp + ")");
+ }
+ MessagingContentProvider.notifyMessagesChanged(conversationId);
+ MessagingContentProvider.notifyPartsChanged();
+
+ return message;
+ }
+
+ private InsertNewMessageAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<InsertNewMessageAction> CREATOR
+ = new Parcelable.Creator<InsertNewMessageAction>() {
+ @Override
+ public InsertNewMessageAction createFromParcel(final Parcel in) {
+ return new InsertNewMessageAction(in);
+ }
+
+ @Override
+ public InsertNewMessageAction[] newArray(final int size) {
+ return new InsertNewMessageAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/LogTelephonyDatabaseAction.java b/src/com/android/messaging/datamodel/action/LogTelephonyDatabaseAction.java
new file mode 100644
index 0000000..441a5a2
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/LogTelephonyDatabaseAction.java
@@ -0,0 +1,153 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.Telephony.Threads;
+import android.provider.Telephony.ThreadsColumns;
+
+import com.android.messaging.Factory;
+import com.android.messaging.mmslib.SqliteWrapper;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.LogUtil;
+
+public class LogTelephonyDatabaseAction extends Action implements Parcelable {
+ // Because we use sanitizePII, we should also use BUGLE_TAG
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private static final String[] ALL_THREADS_PROJECTION = {
+ Threads._ID,
+ Threads.DATE,
+ Threads.MESSAGE_COUNT,
+ Threads.RECIPIENT_IDS,
+ Threads.SNIPPET,
+ Threads.SNIPPET_CHARSET,
+ Threads.READ,
+ Threads.ERROR,
+ Threads.HAS_ATTACHMENT };
+
+ // Constants from the Telephony Database
+ private static final int ID = 0;
+ private static final int DATE = 1;
+ private static final int MESSAGE_COUNT = 2;
+ private static final int RECIPIENT_IDS = 3;
+ private static final int SNIPPET = 4;
+ private static final int SNIPPET_CHAR_SET = 5;
+ private static final int READ = 6;
+ private static final int ERROR = 7;
+ private static final int HAS_ATTACHMENT = 8;
+
+ /**
+ * Log telephony data to logcat
+ */
+ public static void dumpDatabase() {
+ final LogTelephonyDatabaseAction action = new LogTelephonyDatabaseAction();
+ action.start();
+ }
+
+ private LogTelephonyDatabaseAction() {
+ }
+
+ @Override
+ protected Object executeAction() {
+ final Context context = Factory.get().getApplicationContext();
+
+ if (!DebugUtils.isDebugEnabled()) {
+ LogUtil.e(TAG, "Can't log telephony database unless debugging is enabled");
+ return null;
+ }
+
+ if (!LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.w(TAG, "Can't log telephony database unless DEBUG is turned on for TAG: " +
+ TAG);
+ return null;
+ }
+
+ LogUtil.d(TAG, "\n");
+ LogUtil.d(TAG, "Dump of canoncial_addresses table");
+ LogUtil.d(TAG, "*********************************");
+
+ Cursor cursor = SqliteWrapper.query(context, context.getContentResolver(),
+ Uri.parse("content://mms-sms/canonical-addresses"), null, null, null, null);
+
+ if (cursor == null) {
+ LogUtil.w(TAG, "null Cursor in content://mms-sms/canonical-addresses");
+ } else {
+ try {
+ while (cursor.moveToNext()) {
+ long id = cursor.getLong(0);
+ String number = cursor.getString(1);
+ LogUtil.d(TAG, LogUtil.sanitizePII("id: " + id + " number: " + number));
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+ LogUtil.d(TAG, "\n");
+ LogUtil.d(TAG, "Dump of threads table");
+ LogUtil.d(TAG, "*********************");
+
+ cursor = SqliteWrapper.query(context, context.getContentResolver(),
+ Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build(),
+ ALL_THREADS_PROJECTION, null, null, "date ASC");
+ try {
+ while (cursor.moveToNext()) {
+ LogUtil.d(TAG, LogUtil.sanitizePII("threadId: " + cursor.getLong(ID) +
+ " " + ThreadsColumns.DATE + " : " + cursor.getLong(DATE) +
+ " " + ThreadsColumns.MESSAGE_COUNT + " : " + cursor.getInt(MESSAGE_COUNT) +
+ " " + ThreadsColumns.SNIPPET + " : " + cursor.getString(SNIPPET) +
+ " " + ThreadsColumns.READ + " : " + cursor.getInt(READ) +
+ " " + ThreadsColumns.ERROR + " : " + cursor.getInt(ERROR) +
+ " " + ThreadsColumns.HAS_ATTACHMENT + " : " +
+ cursor.getInt(HAS_ATTACHMENT) +
+ " " + ThreadsColumns.RECIPIENT_IDS + " : " +
+ cursor.getString(RECIPIENT_IDS)));
+ }
+ } finally {
+ cursor.close();
+ }
+
+ return null;
+ }
+
+ private LogTelephonyDatabaseAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<LogTelephonyDatabaseAction> CREATOR
+ = new Parcelable.Creator<LogTelephonyDatabaseAction>() {
+ @Override
+ public LogTelephonyDatabaseAction createFromParcel(final Parcel in) {
+ return new LogTelephonyDatabaseAction(in);
+ }
+
+ @Override
+ public LogTelephonyDatabaseAction[] newArray(final int size) {
+ return new LogTelephonyDatabaseAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/MarkAsReadAction.java b/src/com/android/messaging/datamodel/action/MarkAsReadAction.java
new file mode 100644
index 0000000..31bc59d
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/MarkAsReadAction.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.messaging.datamodel.action;
+
+import android.content.ContentValues;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Action used to mark all the messages in a conversation as read
+ */
+public class MarkAsReadAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ private static final String KEY_CONVERSATION_ID = "conversation_id";
+
+ /**
+ * Mark all the messages as read for a particular conversation.
+ */
+ public static void markAsRead(final String conversationId) {
+ final MarkAsReadAction action = new MarkAsReadAction(conversationId);
+ action.start();
+ }
+
+ private MarkAsReadAction(final String conversationId) {
+ actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
+ }
+
+ @Override
+ protected Object executeAction() {
+ final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
+
+ // TODO: Consider doing this in background service to avoid delaying other actions
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ // Mark all messages in thread as read in telephony
+ final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId);
+ if (threadId != -1) {
+ MmsUtils.updateSmsReadStatus(threadId, Long.MAX_VALUE);
+ }
+
+ // Update local db
+ db.beginTransaction();
+ try {
+ final ContentValues values = new ContentValues();
+ values.put(MessageColumns.CONVERSATION_ID, conversationId);
+ values.put(MessageColumns.READ, 1);
+ values.put(MessageColumns.SEEN, 1); // if they read it, they saw it
+
+ final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values,
+ "(" + MessageColumns.READ + " !=1 OR " +
+ MessageColumns.SEEN + " !=1 ) AND " +
+ MessageColumns.CONVERSATION_ID + "=?",
+ new String[] { conversationId });
+ if (count > 0) {
+ MessagingContentProvider.notifyMessagesChanged(conversationId);
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ // After marking messages as read, update the notifications. This will
+ // clear the now stale notifications.
+ BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL);
+ return null;
+ }
+
+ private MarkAsReadAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<MarkAsReadAction> CREATOR
+ = new Parcelable.Creator<MarkAsReadAction>() {
+ @Override
+ public MarkAsReadAction createFromParcel(final Parcel in) {
+ return new MarkAsReadAction(in);
+ }
+
+ @Override
+ public MarkAsReadAction[] newArray(final int size) {
+ return new MarkAsReadAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java b/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java
new file mode 100644
index 0000000..28f55fd
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java
@@ -0,0 +1,126 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.ContentValues;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Action used to mark all messages as seen
+ */
+public class MarkAsSeenAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+ private static final String KEY_CONVERSATION_ID = "conversation_id";
+
+ /**
+ * Mark all messages as seen.
+ */
+ public static void markAllAsSeen() {
+ final MarkAsSeenAction action = new MarkAsSeenAction((String) null/*conversationId*/);
+ action.start();
+ }
+
+ /**
+ * Mark all messages of a given conversation as seen.
+ */
+ public static void markAsSeen(final String conversationId) {
+ final MarkAsSeenAction action = new MarkAsSeenAction(conversationId);
+ action.start();
+ }
+
+ /**
+ * ctor for MarkAsSeenAction.
+ * @param conversationId the conversation id for which to mark as seen, or null to mark all
+ * messages as seen
+ */
+ public MarkAsSeenAction(final String conversationId) {
+ actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
+ }
+
+ @Override
+ protected Object executeAction() {
+ final String conversationId =
+ actionParameters.getString(KEY_CONVERSATION_ID);
+ final boolean hasSpecificConversation = !TextUtils.isEmpty(conversationId);
+
+ // Everything in telephony should already have the seen bit set.
+ // Possible exception are messages which did not have seen set and
+ // were sync'ed into bugle.
+
+ // Now mark the messages as seen in the bugle db
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ db.beginTransaction();
+
+ try {
+ final ContentValues values = new ContentValues();
+ values.put(MessageColumns.SEEN, 1);
+
+ if (hasSpecificConversation) {
+ final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values,
+ MessageColumns.SEEN + " != 1 AND " +
+ MessageColumns.CONVERSATION_ID + "=?",
+ new String[] { conversationId });
+ if (count > 0) {
+ MessagingContentProvider.notifyMessagesChanged(conversationId);
+ }
+ } else {
+ db.update(DatabaseHelper.MESSAGES_TABLE, values,
+ MessageColumns.SEEN + " != 1", null/*selectionArgs*/);
+ }
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ // After marking messages as seen, update the notifications. This will
+ // clear the now stale notifications.
+ BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL);
+ return null;
+ }
+
+ private MarkAsSeenAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<MarkAsSeenAction> CREATOR
+ = new Parcelable.Creator<MarkAsSeenAction>() {
+ @Override
+ public MarkAsSeenAction createFromParcel(final Parcel in) {
+ return new MarkAsSeenAction(in);
+ }
+
+ @Override
+ public MarkAsSeenAction[] newArray(final int size) {
+ return new MarkAsSeenAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/ProcessDeliveryReportAction.java b/src/com/android/messaging/datamodel/action/ProcessDeliveryReportAction.java
new file mode 100644
index 0000000..fbd4e82
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/ProcessDeliveryReportAction.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.messaging.datamodel.action;
+
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.Telephony;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+
+import java.util.concurrent.TimeUnit;
+
+public class ProcessDeliveryReportAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ private static final String KEY_URI = "uri";
+ private static final String KEY_STATUS = "status";
+
+ private ProcessDeliveryReportAction(final Uri uri, final int status) {
+ actionParameters.putParcelable(KEY_URI, uri);
+ actionParameters.putInt(KEY_STATUS, status);
+ }
+
+ public static void deliveryReportReceived(final Uri uri, final int status) {
+ final ProcessDeliveryReportAction action = new ProcessDeliveryReportAction(uri, status);
+ action.start();
+ }
+
+ @Override
+ protected Object executeAction() {
+ final Uri smsMessageUri = actionParameters.getParcelable(KEY_URI);
+ final int status = actionParameters.getInt(KEY_STATUS);
+
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ final long messageRowId = ContentUris.parseId(smsMessageUri);
+ if (messageRowId < 0) {
+ LogUtil.e(TAG, "ProcessDeliveryReportAction: can't find message");
+ return null;
+ }
+ final long timeSentInMillis = System.currentTimeMillis();
+ // Update telephony provider
+ if (smsMessageUri != null) {
+ MmsUtils.updateSmsStatusAndDateSent(smsMessageUri, status, timeSentInMillis);
+ }
+
+ // Update local message
+ db.beginTransaction();
+ try {
+ final ContentValues values = new ContentValues();
+ final int bugleStatus = SyncMessageBatch.bugleStatusForSms(true /*outgoing*/,
+ Telephony.Sms.MESSAGE_TYPE_SENT /* type */, status);
+ values.put(DatabaseHelper.MessageColumns.STATUS, bugleStatus);
+ values.put(DatabaseHelper.MessageColumns.SENT_TIMESTAMP,
+ TimeUnit.MILLISECONDS.toMicros(timeSentInMillis));
+
+ final MessageData messageData =
+ BugleDatabaseOperations.readMessageData(db, smsMessageUri);
+
+ // Check the message was not removed before the delivery report comes in
+ if (messageData != null) {
+ Assert.isTrue(smsMessageUri.equals(messageData.getSmsMessageUri()));
+
+ // Row must exist as was just loaded above (on ActionService thread)
+ BugleDatabaseOperations.updateMessageRow(db, messageData.getMessageId(), values);
+
+ MessagingContentProvider.notifyMessagesChanged(messageData.getConversationId());
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ return null;
+ }
+
+ private ProcessDeliveryReportAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<ProcessDeliveryReportAction> CREATOR
+ = new Parcelable.Creator<ProcessDeliveryReportAction>() {
+ @Override
+ public ProcessDeliveryReportAction createFromParcel(final Parcel in) {
+ return new ProcessDeliveryReportAction(in);
+ }
+
+ @Override
+ public ProcessDeliveryReportAction[] newArray(final int size) {
+ return new ProcessDeliveryReportAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/ProcessDownloadedMmsAction.java b/src/com/android/messaging/datamodel/action/ProcessDownloadedMmsAction.java
new file mode 100644
index 0000000..757ea05
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/ProcessDownloadedMmsAction.java
@@ -0,0 +1,573 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.app.Activity;
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.Telephony.Mms;
+import android.telephony.SmsManager;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DataModelException;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.MmsFileProvider;
+import com.android.messaging.datamodel.SyncManager;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.mmslib.SqliteWrapper;
+import com.android.messaging.mmslib.pdu.PduHeaders;
+import com.android.messaging.mmslib.pdu.RetrieveConf;
+import com.android.messaging.sms.DatabaseMessages;
+import com.android.messaging.sms.MmsSender;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.google.common.io.Files;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Processes an MMS message after it has been downloaded.
+ * NOTE: This action must queue a ProcessPendingMessagesAction when it is done (success or failure).
+ */
+public class ProcessDownloadedMmsAction extends Action {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ // Always set when message downloaded
+ private static final String KEY_DOWNLOADED_BY_PLATFORM = "downloaded_by_platform";
+ private static final String KEY_MESSAGE_ID = "message_id";
+ private static final String KEY_NOTIFICATION_URI = "notification_uri";
+ private static final String KEY_CONVERSATION_ID = "conversation_id";
+ private static final String KEY_PARTICIPANT_ID = "participant_id";
+ private static final String KEY_STATUS_IF_FAILED = "status_if_failed";
+
+ // Set when message downloaded by platform (L+)
+ private static final String KEY_RESULT_CODE = "result_code";
+ private static final String KEY_HTTP_STATUS_CODE = "http_status_code";
+ private static final String KEY_CONTENT_URI = "content_uri";
+ private static final String KEY_SUB_ID = "sub_id";
+ private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number";
+ private static final String KEY_TRANSACTION_ID = "transaction_id";
+ private static final String KEY_CONTENT_LOCATION = "content_location";
+ private static final String KEY_AUTO_DOWNLOAD = "auto_download";
+ private static final String KEY_RECEIVED_TIMESTAMP = "received_timestamp";
+
+ // Set when message downloaded by us (legacy)
+ private static final String KEY_STATUS = "status";
+ private static final String KEY_RAW_STATUS = "raw_status";
+ private static final String KEY_MMS_URI = "mms_uri";
+
+ // Used to send a deferred response in response to auto-download failure
+ private static final String KEY_SEND_DEFERRED_RESP_STATUS = "send_deferred_resp_status";
+
+ // Results passed from background worker to processCompletion
+ private static final String BUNDLE_REQUEST_STATUS = "request_status";
+ private static final String BUNDLE_RAW_TELEPHONY_STATUS = "raw_status";
+ private static final String BUNDLE_MMS_URI = "mms_uri";
+
+ // This is called when MMS lib API returns via PendingIntent
+ public static void processMessageDownloaded(final int resultCode, final Bundle extras) {
+ final String messageId = extras.getString(DownloadMmsAction.EXTRA_MESSAGE_ID);
+ final Uri contentUri = extras.getParcelable(DownloadMmsAction.EXTRA_CONTENT_URI);
+ final Uri notificationUri = extras.getParcelable(DownloadMmsAction.EXTRA_NOTIFICATION_URI);
+ final String conversationId = extras.getString(DownloadMmsAction.EXTRA_CONVERSATION_ID);
+ final String participantId = extras.getString(DownloadMmsAction.EXTRA_PARTICIPANT_ID);
+ Assert.notNull(messageId);
+ Assert.notNull(contentUri);
+ Assert.notNull(notificationUri);
+ Assert.notNull(conversationId);
+ Assert.notNull(participantId);
+
+ final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
+ final Bundle params = action.actionParameters;
+ params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, true);
+ params.putString(KEY_MESSAGE_ID, messageId);
+ params.putInt(KEY_RESULT_CODE, resultCode);
+ params.putInt(KEY_HTTP_STATUS_CODE,
+ extras.getInt(SmsManager.EXTRA_MMS_HTTP_STATUS, 0));
+ params.putParcelable(KEY_CONTENT_URI, contentUri);
+ params.putParcelable(KEY_NOTIFICATION_URI, notificationUri);
+ params.putInt(KEY_SUB_ID,
+ extras.getInt(DownloadMmsAction.EXTRA_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID));
+ params.putString(KEY_SUB_PHONE_NUMBER,
+ extras.getString(DownloadMmsAction.EXTRA_SUB_PHONE_NUMBER));
+ params.putString(KEY_TRANSACTION_ID,
+ extras.getString(DownloadMmsAction.EXTRA_TRANSACTION_ID));
+ params.putString(KEY_CONTENT_LOCATION,
+ extras.getString(DownloadMmsAction.EXTRA_CONTENT_LOCATION));
+ params.putBoolean(KEY_AUTO_DOWNLOAD,
+ extras.getBoolean(DownloadMmsAction.EXTRA_AUTO_DOWNLOAD));
+ params.putLong(KEY_RECEIVED_TIMESTAMP,
+ extras.getLong(DownloadMmsAction.EXTRA_RECEIVED_TIMESTAMP));
+ params.putString(KEY_CONVERSATION_ID, conversationId);
+ params.putString(KEY_PARTICIPANT_ID, participantId);
+ params.putInt(KEY_STATUS_IF_FAILED,
+ extras.getInt(DownloadMmsAction.EXTRA_STATUS_IF_FAILED));
+ action.start();
+ }
+
+ // This is called for fast failing downloading (due to airplane mode or mobile data )
+ public static void processMessageDownloadFastFailed(final String messageId,
+ final Uri notificationUri, final String conversationId, final String participantId,
+ final String contentLocation, final int subId, final String subPhoneNumber,
+ final int statusIfFailed, final boolean autoDownload, final String transactionId,
+ final int resultCode) {
+ Assert.notNull(messageId);
+ Assert.notNull(notificationUri);
+ Assert.notNull(conversationId);
+ Assert.notNull(participantId);
+
+ final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
+ final Bundle params = action.actionParameters;
+ params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, true);
+ params.putString(KEY_MESSAGE_ID, messageId);
+ params.putInt(KEY_RESULT_CODE, resultCode);
+ params.putParcelable(KEY_NOTIFICATION_URI, notificationUri);
+ params.putInt(KEY_SUB_ID, subId);
+ params.putString(KEY_SUB_PHONE_NUMBER, subPhoneNumber);
+ params.putString(KEY_CONTENT_LOCATION, contentLocation);
+ params.putBoolean(KEY_AUTO_DOWNLOAD, autoDownload);
+ params.putString(KEY_CONVERSATION_ID, conversationId);
+ params.putString(KEY_PARTICIPANT_ID, participantId);
+ params.putInt(KEY_STATUS_IF_FAILED, statusIfFailed);
+ params.putString(KEY_TRANSACTION_ID, transactionId);
+ action.start();
+ }
+
+ public static void processDownloadActionFailure(final String messageId, final int status,
+ final int rawStatus, final String conversationId, final String participantId,
+ final int statusIfFailed, final int subId, final String transactionId) {
+ Assert.notNull(messageId);
+ Assert.notNull(conversationId);
+ Assert.notNull(participantId);
+
+ final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
+ final Bundle params = action.actionParameters;
+ params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, false);
+ params.putString(KEY_MESSAGE_ID, messageId);
+ params.putInt(KEY_STATUS, status);
+ params.putInt(KEY_RAW_STATUS, rawStatus);
+ params.putString(KEY_CONVERSATION_ID, conversationId);
+ params.putString(KEY_PARTICIPANT_ID, participantId);
+ params.putInt(KEY_STATUS_IF_FAILED, statusIfFailed);
+ params.putInt(KEY_SUB_ID, subId);
+ params.putString(KEY_TRANSACTION_ID, transactionId);
+ action.start();
+ }
+
+ public static void sendDeferredRespStatus(final String messageId, final String transactionId,
+ final String contentLocation, final int subId) {
+ final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
+ final Bundle params = action.actionParameters;
+ params.putString(KEY_MESSAGE_ID, messageId);
+ params.putString(KEY_TRANSACTION_ID, transactionId);
+ params.putString(KEY_CONTENT_LOCATION, contentLocation);
+ params.putBoolean(KEY_SEND_DEFERRED_RESP_STATUS, true);
+ params.putInt(KEY_SUB_ID, subId);
+ action.start();
+ }
+
+ private ProcessDownloadedMmsAction() {
+ // Callers must use one of the static methods above
+ }
+
+ @Override
+ protected Object executeAction() {
+ // Fire up the background worker
+ requestBackgroundWork();
+ return null;
+ }
+
+ @Override
+ protected Bundle doBackgroundWork() throws DataModelException {
+ final Context context = Factory.get().getApplicationContext();
+ final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
+ final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
+ final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
+ final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
+ final boolean sendDeferredRespStatus =
+ actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS, false);
+
+ // Send a response indicating that auto-download failed
+ if (sendDeferredRespStatus) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "DownloadMmsAction: Auto-download of message " + messageId
+ + " failed; sending DEFERRED NotifyRespInd");
+ }
+ MmsUtils.sendNotifyResponseForMmsDownload(
+ context,
+ subId,
+ MmsUtils.stringToBytes(transactionId, "UTF-8"),
+ contentLocation,
+ PduHeaders.STATUS_DEFERRED);
+ return null;
+ }
+
+ // Processing a real MMS download
+ final boolean downloadedByPlatform = actionParameters.getBoolean(
+ KEY_DOWNLOADED_BY_PLATFORM);
+
+ final int status;
+ int rawStatus = MmsUtils.PDU_HEADER_VALUE_UNDEFINED;
+ Uri mmsUri = null;
+
+ if (downloadedByPlatform) {
+ final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
+ if (resultCode == Activity.RESULT_OK) {
+ final Uri contentUri = actionParameters.getParcelable(KEY_CONTENT_URI);
+ final File downloadedFile = MmsFileProvider.getFile(contentUri);
+ byte[] downloadedData = null;
+ try {
+ downloadedData = Files.toByteArray(downloadedFile);
+ } catch (final FileNotFoundException e) {
+ LogUtil.e(TAG, "ProcessDownloadedMmsAction: MMS download file not found: "
+ + downloadedFile.getAbsolutePath());
+ } catch (final IOException e) {
+ LogUtil.e(TAG, "ProcessDownloadedMmsAction: Error reading MMS download file: "
+ + downloadedFile.getAbsolutePath(), e);
+ }
+
+ // Can delete the temp file now
+ if (downloadedFile.exists()) {
+ downloadedFile.delete();
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "ProcessDownloadedMmsAction: Deleted temp file with "
+ + "downloaded MMS pdu: " + downloadedFile.getAbsolutePath());
+ }
+ }
+
+ if (downloadedData != null) {
+ final RetrieveConf retrieveConf =
+ MmsSender.parseRetrieveConf(downloadedData, subId);
+ if (MmsUtils.isDumpMmsEnabled()) {
+ MmsUtils.dumpPdu(downloadedData, retrieveConf);
+ }
+ if (retrieveConf != null) {
+ // Insert the downloaded MMS into telephony
+ final Uri notificationUri = actionParameters.getParcelable(
+ KEY_NOTIFICATION_URI);
+ final String subPhoneNumber = actionParameters.getString(
+ KEY_SUB_PHONE_NUMBER);
+ final boolean autoDownload = actionParameters.getBoolean(
+ KEY_AUTO_DOWNLOAD);
+ final long receivedTimestampInSeconds =
+ actionParameters.getLong(KEY_RECEIVED_TIMESTAMP);
+
+ // Inform sync we're adding a message to telephony
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+ syncManager.onNewMessageInserted(receivedTimestampInSeconds * 1000L);
+
+ final MmsUtils.StatusPlusUri result =
+ MmsUtils.insertDownloadedMessageAndSendResponse(context,
+ notificationUri, subId, subPhoneNumber, transactionId,
+ contentLocation, autoDownload, receivedTimestampInSeconds,
+ retrieveConf);
+ status = result.status;
+ rawStatus = result.rawStatus;
+ mmsUri = result.uri;
+ } else {
+ // Invalid response PDU
+ status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
+ }
+ } else {
+ // Failed to read download file
+ status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
+ }
+ } else {
+ LogUtil.w(TAG, "ProcessDownloadedMmsAction: Platform returned error resultCode: "
+ + resultCode);
+ final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
+ status = MmsSender.getErrorResultStatus(resultCode, httpStatusCode);
+ }
+ } else {
+ // Message was already processed by the internal API, or the download action failed.
+ // In either case, we just need to copy the status to the response bundle.
+ status = actionParameters.getInt(KEY_STATUS);
+ rawStatus = actionParameters.getInt(KEY_RAW_STATUS);
+ mmsUri = actionParameters.getParcelable(KEY_MMS_URI);
+ }
+
+ final Bundle response = new Bundle();
+ response.putInt(BUNDLE_REQUEST_STATUS, status);
+ response.putInt(BUNDLE_RAW_TELEPHONY_STATUS, rawStatus);
+ response.putParcelable(BUNDLE_MMS_URI, mmsUri);
+ return response;
+ }
+
+ @Override
+ protected Object processBackgroundResponse(final Bundle response) {
+ if (response == null) {
+ // No message download to process; doBackgroundWork sent a notify deferred response
+ Assert.isTrue(actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS));
+ return null;
+ }
+
+ final int status = response.getInt(BUNDLE_REQUEST_STATUS);
+ final int rawStatus = response.getInt(BUNDLE_RAW_TELEPHONY_STATUS);
+ final Uri messageUri = response.getParcelable(BUNDLE_MMS_URI);
+ final boolean autoDownload = actionParameters.getBoolean(KEY_AUTO_DOWNLOAD);
+ final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
+
+ // Do post-processing on downloaded message
+ final MessageData message = processResult(status, rawStatus, messageUri);
+
+ final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
+ // If we were trying to auto-download but have failed need to send the deferred response
+ if (autoDownload && message == null && status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) {
+ final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
+ final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
+ sendDeferredRespStatus(messageId, transactionId, contentLocation, subId);
+ }
+
+ if (autoDownload) {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ MessageData toastMessage = message;
+ if (toastMessage == null) {
+ // If the downloaded failed (message is null), then we should announce the
+ // receiving of the wap push message. Load the wap push message here instead.
+ toastMessage = BugleDatabaseOperations.readMessageData(db, messageId);
+ }
+ if (toastMessage != null) {
+ final ParticipantData sender = ParticipantData.getFromId(
+ db, toastMessage.getParticipantId());
+ BugleActionToasts.onMessageReceived(
+ toastMessage.getConversationId(), sender, toastMessage);
+ }
+ } else {
+ final boolean success = message != null && status == MmsUtils.MMS_REQUEST_SUCCEEDED;
+ BugleActionToasts.onSendMessageOrManualDownloadActionCompleted(
+ // If download failed, use the wap push message's conversation instead
+ success ? message.getConversationId()
+ : actionParameters.getString(KEY_CONVERSATION_ID),
+ success, status, false/*isSms*/, subId, false /*isSend*/);
+ }
+
+ final boolean failed = (messageUri == null);
+ ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(failed, this);
+ if (failed) {
+ BugleNotifications.update(false, BugleNotifications.UPDATE_ERRORS);
+ }
+
+ return message;
+ }
+
+ @Override
+ protected Object processBackgroundFailure() {
+ if (actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS)) {
+ // We can early-out for these failures. processResult is only designed to handle
+ // post-processing of MMS downloads (whether successful or not).
+ LogUtil.w(TAG,
+ "ProcessDownloadedMmsAction: Exception while sending deferred NotifyRespInd");
+ return null;
+ }
+
+ // Background worker threw an exception; require manual retry
+ processResult(MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED,
+ null /* mmsUri */);
+
+ ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(true /* failed */,
+ this);
+
+ return null;
+ }
+
+ private MessageData processResult(final int status, final int rawStatus, final Uri mmsUri) {
+ final Context context = Factory.get().getApplicationContext();
+ final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
+ final Uri mmsNotificationUri = actionParameters.getParcelable(KEY_NOTIFICATION_URI);
+ final String notificationConversationId = actionParameters.getString(KEY_CONVERSATION_ID);
+ final String notificationParticipantId = actionParameters.getString(KEY_PARTICIPANT_ID);
+ final int statusIfFailed = actionParameters.getInt(KEY_STATUS_IF_FAILED);
+ final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
+
+ Assert.notNull(messageId);
+
+ LogUtil.i(TAG, "ProcessDownloadedMmsAction: Processed MMS download of message " + messageId
+ + "; status is " + MmsUtils.getRequestStatusDescription(status));
+
+ DatabaseMessages.MmsMessage mms = null;
+ if (status == MmsUtils.MMS_REQUEST_SUCCEEDED && mmsUri != null) {
+ // Delete the initial M-Notification.ind from telephony
+ SqliteWrapper.delete(context, context.getContentResolver(),
+ mmsNotificationUri, null, null);
+
+ // Read the sent MMS from the telephony provider
+ mms = MmsUtils.loadMms(mmsUri);
+ }
+
+ boolean messageInFocusedConversation = false;
+ boolean messageInObservableConversation = false;
+ String conversationId = null;
+ MessageData message = null;
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ db.beginTransaction();
+ try {
+ if (mms != null) {
+ final ParticipantData self = ParticipantData.getSelfParticipant(mms.getSubId());
+ final String selfId =
+ BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
+
+ final List<String> recipients = MmsUtils.getRecipientsByThread(mms.mThreadId);
+ String from = MmsUtils.getMmsSender(recipients, mms.getUri());
+ if (from == null) {
+ LogUtil.w(TAG,
+ "Downloaded an MMS without sender address; using unknown sender.");
+ from = ParticipantData.getUnknownSenderDestination();
+ }
+ final ParticipantData sender = ParticipantData.getFromRawPhoneBySimLocale(from,
+ subId);
+ final String senderParticipantId =
+ BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender);
+ if (!senderParticipantId.equals(notificationParticipantId)) {
+ LogUtil.e(TAG, "ProcessDownloadedMmsAction: Downloaded MMS message "
+ + messageId + " has different sender (participantId = "
+ + senderParticipantId + ") than notification ("
+ + notificationParticipantId + ")");
+ }
+ final boolean blockedSender = BugleDatabaseOperations.isBlockedDestination(
+ db, sender.getNormalizedDestination());
+ conversationId = BugleDatabaseOperations.getOrCreateConversationFromThreadId(db,
+ mms.mThreadId, blockedSender, subId);
+
+ messageInFocusedConversation =
+ DataModel.get().isFocusedConversation(conversationId);
+ messageInObservableConversation =
+ DataModel.get().isNewMessageObservable(conversationId);
+
+ // TODO: Also write these values to the telephony provider
+ mms.mRead = messageInFocusedConversation;
+ mms.mSeen = messageInObservableConversation;
+
+ // Translate to our format
+ message = MmsUtils.createMmsMessage(mms, conversationId, senderParticipantId,
+ selfId, MessageData.BUGLE_STATUS_INCOMING_COMPLETE);
+ // Update image sizes.
+ message.updateSizesForImageParts();
+ // Inform sync that message has been added at local received timestamp
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+ syncManager.onNewMessageInserted(message.getReceivedTimeStamp());
+ final MessageData current = BugleDatabaseOperations.readMessageData(db, messageId);
+ if (current == null) {
+ LogUtil.w(TAG, "Message deleted prior to update");
+ BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
+ } else {
+ // Overwrite existing notification message
+ message.updateMessageId(messageId);
+ // Write message
+ BugleDatabaseOperations.updateMessageInTransaction(db, message);
+ }
+
+ if (!TextUtils.equals(notificationConversationId, conversationId)) {
+ // If this is a group conversation, the message is moved. So the original
+ // 1v1 conversation (as referenced by notificationConversationId) could
+ // be left with no non-draft message. Delete the conversation if that
+ // happens. See the comment for the method below for why we need to do this.
+ if (!BugleDatabaseOperations.deleteConversationIfEmptyInTransaction(
+ db, notificationConversationId)) {
+ BugleDatabaseOperations.maybeRefreshConversationMetadataInTransaction(
+ db, notificationConversationId, messageId,
+ true /*shouldAutoSwitchSelfId*/, blockedSender /*keepArchived*/);
+ }
+ }
+
+ BugleDatabaseOperations.refreshConversationMetadataInTransaction(db, conversationId,
+ true /*shouldAutoSwitchSelfId*/, blockedSender /*keepArchived*/);
+ } else {
+ messageInFocusedConversation =
+ DataModel.get().isFocusedConversation(notificationConversationId);
+
+ // Default to retry status unless status indicates otherwise
+ int bugleStatus = statusIfFailed;
+ if (status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) {
+ bugleStatus = MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED;
+ } else if (status == MmsUtils.MMS_REQUEST_NO_RETRY) {
+ bugleStatus = MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE;
+ }
+ DownloadMmsAction.updateMessageStatus(mmsNotificationUri, messageId,
+ notificationConversationId, bugleStatus, rawStatus);
+
+ // Log MMS download failed
+ final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
+ final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
+
+ // Just in case this was the latest message update the summary data
+ BugleDatabaseOperations.refreshConversationMetadataInTransaction(db,
+ notificationConversationId, true /*shouldAutoSwitchSelfId*/,
+ false /*keepArchived*/);
+ }
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ if (mmsUri != null) {
+ // Update mms table with read status now we know the conversation id
+ final ContentValues values = new ContentValues(1);
+ values.put(Mms.READ, messageInFocusedConversation);
+ SqliteWrapper.update(context, context.getContentResolver(), mmsUri, values,
+ null, null);
+ }
+
+ // Show a notification to let the user know a new message has arrived
+ BugleNotifications.update(false /*silent*/, conversationId, BugleNotifications.UPDATE_ALL);
+
+ // Messages may have changed in two conversations
+ if (conversationId != null) {
+ MessagingContentProvider.notifyMessagesChanged(conversationId);
+ }
+ MessagingContentProvider.notifyMessagesChanged(notificationConversationId);
+ MessagingContentProvider.notifyPartsChanged();
+
+ return message;
+ }
+
+ private ProcessDownloadedMmsAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<ProcessDownloadedMmsAction> CREATOR
+ = new Parcelable.Creator<ProcessDownloadedMmsAction>() {
+ @Override
+ public ProcessDownloadedMmsAction createFromParcel(final Parcel in) {
+ return new ProcessDownloadedMmsAction(in);
+ }
+
+ @Override
+ public ProcessDownloadedMmsAction[] newArray(final int size) {
+ return new ProcessDownloadedMmsAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/ProcessPendingMessagesAction.java b/src/com/android/messaging/datamodel/action/ProcessPendingMessagesAction.java
new file mode 100644
index 0000000..8a41f4a
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/ProcessPendingMessagesAction.java
@@ -0,0 +1,470 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.ConnectivityManager;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.telephony.ServiceState;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.BuglePrefsKeys;
+import com.android.messaging.util.ConnectivityUtil.ConnectivityListener;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Action used to lookup any messages in the pending send/download state and either fail them or
+ * retry their action. This action only initiates one retry at a time - further retries should be
+ * triggered by successful sending of a message, network status change or exponential backoff timer.
+ */
+public class ProcessPendingMessagesAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+ private static final int PENDING_INTENT_REQUEST_CODE = 101;
+
+ public static void processFirstPendingMessage() {
+ // Clear any pending alarms or connectivity events
+ unregister();
+ // Clear retry count
+ setRetry(0);
+
+ // Start action
+ final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
+ action.start();
+ }
+
+ public static void scheduleProcessPendingMessagesAction(final boolean failed,
+ final Action processingAction) {
+ LogUtil.i(TAG, "ProcessPendingMessagesAction: Scheduling pending messages"
+ + (failed ? "(message failed)" : ""));
+ // Can safely clear any pending alarms or connectivity events as either an action
+ // is currently running or we will run now or register if pending actions possible.
+ unregister();
+
+ final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp();
+ boolean scheduleAlarm = false;
+ // If message succeeded and if Bugle is default SMS app just carry on with next message
+ if (!failed && isDefaultSmsApp) {
+ // Clear retry attempt count as something just succeeded
+ setRetry(0);
+
+ // Lookup and queue next message for immediate processing by background worker
+ // iff there are no pending messages this will do nothing and return true.
+ final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
+ if (action.queueActions(processingAction)) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ if (processingAction.hasBackgroundActions()) {
+ LogUtil.v(TAG, "ProcessPendingMessagesAction: Action queued");
+ } else {
+ LogUtil.v(TAG, "ProcessPendingMessagesAction: No actions to queue");
+ }
+ }
+ // Have queued next action if needed, nothing more to do
+ return;
+ }
+ // In case of error queuing schedule a retry
+ scheduleAlarm = true;
+ LogUtil.w(TAG, "ProcessPendingMessagesAction: Action failed to queue; retrying");
+ }
+ if (getHavePendingMessages() || scheduleAlarm) {
+ // Still have a pending message that needs to be queued for processing
+ final ConnectivityListener listener = new ConnectivityListener() {
+ @Override
+ public void onConnectivityStateChanged(final Context context, final Intent intent) {
+ final int networkType =
+ MmsUtils.getConnectivityEventNetworkType(context, intent);
+ if (networkType != ConnectivityManager.TYPE_MOBILE) {
+ return;
+ }
+ final boolean isConnected = !intent.getBooleanExtra(
+ ConnectivityManager.EXTRA_NO_CONNECTIVITY, false);
+ // TODO: Should we check in more detail?
+ if (isConnected) {
+ onConnected();
+ }
+ }
+
+ @Override
+ public void onPhoneStateChanged(final Context context, final int serviceState) {
+ if (serviceState == ServiceState.STATE_IN_SERVICE) {
+ onConnected();
+ }
+ }
+
+ private void onConnected() {
+ LogUtil.i(TAG, "ProcessPendingMessagesAction: Now connected; starting action");
+
+ // Clear any pending alarms or connectivity events but leave attempt count alone
+ unregister();
+
+ // Start action
+ final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
+ action.start();
+ }
+ };
+ // Read and increment attempt number from shared prefs
+ final int retryAttempt = getNextRetry();
+ register(listener, retryAttempt);
+ } else {
+ // No more pending messages (presumably the message that failed has expired) or it
+ // may be possible that a send and a download are already in process.
+ // Clear retry attempt count.
+ // TODO Might be premature if send and download in process...
+ // but worst case means we try to send a bit more often.
+ setRetry(0);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "ProcessPendingMessagesAction: No more pending messages");
+ }
+ }
+ }
+
+ private static void register(final ConnectivityListener listener, final int retryAttempt) {
+ int retryNumber = retryAttempt;
+
+ // Register to be notified about connectivity changes
+ DataModel.get().getConnectivityUtil().register(listener);
+
+ final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
+ final long initialBackoffMs = BugleGservices.get().getLong(
+ BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS,
+ BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS_DEFAULT);
+ final long maxDelayMs = BugleGservices.get().getLong(
+ BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS,
+ BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS_DEFAULT);
+ long delayMs;
+ long nextDelayMs = initialBackoffMs;
+ do {
+ delayMs = nextDelayMs;
+ retryNumber--;
+ nextDelayMs = delayMs * 2;
+ }
+ while (retryNumber > 0 && nextDelayMs < maxDelayMs);
+
+ LogUtil.i(TAG, "ProcessPendingMessagesAction: Registering for retry #" + retryAttempt
+ + " in " + delayMs + " ms");
+
+ action.schedule(PENDING_INTENT_REQUEST_CODE, delayMs);
+ }
+
+ private static void unregister() {
+ // Clear any pending alarms or connectivity events
+ DataModel.get().getConnectivityUtil().unregister();
+
+ final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
+ action.schedule(PENDING_INTENT_REQUEST_CODE, Long.MAX_VALUE);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "ProcessPendingMessagesAction: Unregistering for connectivity changed "
+ + "events and clearing scheduled alarm");
+ }
+ }
+
+ private static void setRetry(final int retryAttempt) {
+ final BuglePrefs prefs = Factory.get().getApplicationPrefs();
+ prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt);
+ }
+
+ private static int getNextRetry() {
+ final BuglePrefs prefs = Factory.get().getApplicationPrefs();
+ final int retryAttempt =
+ prefs.getInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, 0) + 1;
+ prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt);
+ return retryAttempt;
+ }
+
+ private ProcessPendingMessagesAction() {
+ }
+
+ /**
+ * Read from the DB and determine if there are any messages we should process
+ * @return true if we have pending messages
+ */
+ private static boolean getHavePendingMessages() {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final long now = System.currentTimeMillis();
+
+ final String toSendMessageId = findNextMessageToSend(db, now);
+ if (toSendMessageId != null) {
+ return true;
+ } else {
+ final String toDownloadMessageId = findNextMessageToDownload(db, now);
+ if (toDownloadMessageId != null) {
+ return true;
+ }
+ }
+ // Messages may be in the process of sending/downloading even when there are no pending
+ // messages...
+ return false;
+ }
+
+ /**
+ * Queue any pending actions
+ * @param actionState
+ * @return true if action queued (or no actions to queue) else false
+ */
+ private boolean queueActions(final Action processingAction) {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final long now = System.currentTimeMillis();
+ boolean succeeded = true;
+
+ // Will queue no more than one message to send plus one message to download
+ // This keeps outgoing messages "in order" but allow downloads to happen even if sending
+ // gets blocked until messages time out. Manual resend bumps messages to head of queue.
+ final String toSendMessageId = findNextMessageToSend(db, now);
+ final String toDownloadMessageId = findNextMessageToDownload(db, now);
+ if (toSendMessageId != null) {
+ LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toSendMessageId
+ + " for sending");
+ // This could queue nothing
+ if (!SendMessageAction.queueForSendInBackground(toSendMessageId, processingAction)) {
+ LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message "
+ + toSendMessageId + " for sending");
+ succeeded = false;
+ }
+ }
+ if (toDownloadMessageId != null) {
+ LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toDownloadMessageId
+ + " for download");
+ // This could queue nothing
+ if (!DownloadMmsAction.queueMmsForDownloadInBackground(toDownloadMessageId,
+ processingAction)) {
+ LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message "
+ + toDownloadMessageId + " for download");
+ succeeded = false;
+ }
+ }
+ if (toSendMessageId == null && toDownloadMessageId == null) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "ProcessPendingMessagesAction: No messages to send or download");
+ }
+ }
+ return succeeded;
+ }
+
+ @Override
+ protected Object executeAction() {
+ // If triggered by alarm will not have unregistered yet
+ unregister();
+
+ if (PhoneUtils.getDefault().isDefaultSmsApp()) {
+ queueActions(this);
+ } else {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "ProcessPendingMessagesAction: Not default SMS app; rescheduling");
+ }
+ scheduleProcessPendingMessagesAction(true, this);
+ }
+
+ return null;
+ }
+
+ private static String findNextMessageToSend(final DatabaseWrapper db, final long now) {
+ String toSendMessageId = null;
+ db.beginTransaction();
+ Cursor sending = null;
+ Cursor cursor = null;
+ int sendingCnt = 0;
+ int pendingCnt = 0;
+ int failedCnt = 0;
+ try {
+ // First check to see if we have any messages already sending
+ sending = db.query(DatabaseHelper.MESSAGES_TABLE,
+ MessageData.getProjection(),
+ DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
+ new String[]{Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING),
+ Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING)},
+ null,
+ null,
+ DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
+ final boolean messageCurrentlySending = sending.moveToNext();
+ sendingCnt = sending.getCount();
+ // Look for messages we could send
+ final ContentValues values = new ContentValues();
+ values.put(DatabaseHelper.MessageColumns.STATUS,
+ MessageData.BUGLE_STATUS_OUTGOING_FAILED);
+ cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
+ MessageData.getProjection(),
+ DatabaseHelper.MessageColumns.STATUS + " IN ("
+ + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND + ","
+ + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY + ")",
+ null,
+ null,
+ null,
+ DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
+ pendingCnt = cursor.getCount();
+
+ while (cursor.moveToNext()) {
+ final MessageData message = new MessageData();
+ message.bind(cursor);
+ if (message.getInResendWindow(now)) {
+ // If no messages currently sending
+ if (!messageCurrentlySending) {
+ // Resend this message
+ toSendMessageId = message.getMessageId();
+ // Before queuing the message for resending, check if the message's self is
+ // active. If not, switch back to the system's default subscription.
+ if (OsUtil.isAtLeastL_MR1()) {
+ final ParticipantData messageSelf = BugleDatabaseOperations
+ .getExistingParticipant(db, message.getSelfId());
+ if (messageSelf == null || !messageSelf.isActiveSubscription()) {
+ final ParticipantData defaultSelf = BugleDatabaseOperations
+ .getOrCreateSelf(db, PhoneUtils.getDefault()
+ .getDefaultSmsSubscriptionId());
+ if (defaultSelf != null) {
+ message.bindSelfId(defaultSelf.getId());
+ final ContentValues selfValues = new ContentValues();
+ selfValues.put(MessageColumns.SELF_PARTICIPANT_ID,
+ defaultSelf.getId());
+ BugleDatabaseOperations.updateMessageRow(db,
+ message.getMessageId(), selfValues);
+ MessagingContentProvider.notifyMessagesChanged(
+ message.getConversationId());
+ }
+ }
+ }
+ }
+ break;
+ } else {
+ failedCnt++;
+
+ // Mark message as failed
+ BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values);
+ MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
+ }
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ if (cursor != null) {
+ cursor.close();
+ }
+ if (sending != null) {
+ sending.close();
+ }
+ }
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "ProcessPendingMessagesAction: "
+ + sendingCnt + " messages already sending, "
+ + pendingCnt + " messages to send, "
+ + failedCnt + " failed messages");
+ }
+
+ return toSendMessageId;
+ }
+
+ private static String findNextMessageToDownload(final DatabaseWrapper db, final long now) {
+ String toDownloadMessageId = null;
+ db.beginTransaction();
+ Cursor cursor = null;
+ int downloadingCnt = 0;
+ int pendingCnt = 0;
+ try {
+ // First check if we have any messages already downloading
+ downloadingCnt = (int) db.queryNumEntries(DatabaseHelper.MESSAGES_TABLE,
+ DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
+ new String[] {
+ Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING),
+ Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING)
+ });
+
+ // TODO: This query is not actually needed if downloadingCnt == 0.
+ cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
+ MessageData.getProjection(),
+ DatabaseHelper.MessageColumns.STATUS + " =? OR "
+ + DatabaseHelper.MessageColumns.STATUS + " =?",
+ new String[]{
+ Integer.toString(
+ MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD),
+ Integer.toString(
+ MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD)
+ },
+ null,
+ null,
+ DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
+
+ pendingCnt = cursor.getCount();
+
+ // If no messages are currently downloading and there is a download pending,
+ // queue the download of the oldest pending message.
+ if (downloadingCnt == 0 && cursor.moveToNext()) {
+ // Always start the next pending message. We will check if a download has
+ // expired in DownloadMmsAction and mark message failed there.
+ final MessageData message = new MessageData();
+ message.bind(cursor);
+ toDownloadMessageId = message.getMessageId();
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "ProcessPendingMessagesAction: "
+ + downloadingCnt + " messages already downloading, "
+ + pendingCnt + " messages to download");
+ }
+
+ return toDownloadMessageId;
+ }
+
+ private ProcessPendingMessagesAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<ProcessPendingMessagesAction> CREATOR
+ = new Parcelable.Creator<ProcessPendingMessagesAction>() {
+ @Override
+ public ProcessPendingMessagesAction createFromParcel(final Parcel in) {
+ return new ProcessPendingMessagesAction(in);
+ }
+
+ @Override
+ public ProcessPendingMessagesAction[] newArray(final int size) {
+ return new ProcessPendingMessagesAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/ProcessSentMessageAction.java b/src/com/android/messaging/datamodel/action/ProcessSentMessageAction.java
new file mode 100644
index 0000000..f408e47
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/ProcessSentMessageAction.java
@@ -0,0 +1,310 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.telephony.PhoneNumberUtils;
+import android.telephony.SmsManager;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MmsFileProvider;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.mmslib.pdu.SendConf;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.sms.MmsSender;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+
+import java.io.File;
+import java.util.ArrayList;
+
+/**
+* Update message status to reflect success or failure
+* Can also update the message itself if a "final" message is now available from telephony db
+*/
+public class ProcessSentMessageAction extends Action {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ // These are always set
+ private static final String KEY_SMS = "is_sms";
+ private static final String KEY_SENT_BY_PLATFORM = "sent_by_platform";
+
+ // These are set when we're processing a message sent by the user. They are null for messages
+ // sent automatically (e.g. a NotifyRespInd/AcknowledgeInd sent in response to a download).
+ private static final String KEY_MESSAGE_ID = "message_id";
+ private static final String KEY_MESSAGE_URI = "message_uri";
+ private static final String KEY_UPDATED_MESSAGE_URI = "updated_message_uri";
+ private static final String KEY_SUB_ID = "sub_id";
+
+ // These are set for messages sent by the platform (L+)
+ public static final String KEY_RESULT_CODE = "result_code";
+ public static final String KEY_HTTP_STATUS_CODE = "http_status_code";
+ private static final String KEY_CONTENT_URI = "content_uri";
+ private static final String KEY_RESPONSE = "response";
+ private static final String KEY_RESPONSE_IMPORTANT = "response_important";
+
+ // These are set for messages we sent ourself (legacy), or which we fast-failed before sending.
+ private static final String KEY_STATUS = "status";
+ private static final String KEY_RAW_STATUS = "raw_status";
+
+ // This is called when MMS lib API returns via PendingIntent
+ public static void processMmsSent(final int resultCode, final Uri messageUri,
+ final Bundle extras) {
+ final ProcessSentMessageAction action = new ProcessSentMessageAction();
+ final Bundle params = action.actionParameters;
+ params.putBoolean(KEY_SMS, false);
+ params.putBoolean(KEY_SENT_BY_PLATFORM, true);
+ params.putString(KEY_MESSAGE_ID, extras.getString(SendMessageAction.EXTRA_MESSAGE_ID));
+ params.putParcelable(KEY_MESSAGE_URI, messageUri);
+ params.putParcelable(KEY_UPDATED_MESSAGE_URI,
+ extras.getParcelable(SendMessageAction.EXTRA_UPDATED_MESSAGE_URI));
+ params.putInt(KEY_SUB_ID,
+ extras.getInt(SendMessageAction.KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID));
+ params.putInt(KEY_RESULT_CODE, resultCode);
+ params.putInt(KEY_HTTP_STATUS_CODE, extras.getInt(SmsManager.EXTRA_MMS_HTTP_STATUS, 0));
+ params.putParcelable(KEY_CONTENT_URI,
+ extras.getParcelable(SendMessageAction.EXTRA_CONTENT_URI));
+ params.putByteArray(KEY_RESPONSE, extras.getByteArray(SmsManager.EXTRA_MMS_DATA));
+ params.putBoolean(KEY_RESPONSE_IMPORTANT,
+ extras.getBoolean(SendMessageAction.EXTRA_RESPONSE_IMPORTANT));
+ action.start();
+ }
+
+ public static void processMessageSentFastFailed(final String messageId,
+ final Uri messageUri, final Uri updatedMessageUri, final int subId, final boolean isSms,
+ final int status, final int rawStatus, final int resultCode) {
+ final ProcessSentMessageAction action = new ProcessSentMessageAction();
+ final Bundle params = action.actionParameters;
+ params.putBoolean(KEY_SMS, isSms);
+ params.putBoolean(KEY_SENT_BY_PLATFORM, false);
+ params.putString(KEY_MESSAGE_ID, messageId);
+ params.putParcelable(KEY_MESSAGE_URI, messageUri);
+ params.putParcelable(KEY_UPDATED_MESSAGE_URI, updatedMessageUri);
+ params.putInt(KEY_SUB_ID, subId);
+ params.putInt(KEY_STATUS, status);
+ params.putInt(KEY_RAW_STATUS, rawStatus);
+ params.putInt(KEY_RESULT_CODE, resultCode);
+ action.start();
+ }
+
+ private ProcessSentMessageAction() {
+ // Callers must use one of the static methods above
+ }
+
+ /**
+ * Update message status to reflect success or failure
+ * Can also update the message itself if a "final" message is now available from telephony db
+ */
+ @Override
+ protected Object executeAction() {
+ final Context context = Factory.get().getApplicationContext();
+ final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
+ final Uri messageUri = actionParameters.getParcelable(KEY_MESSAGE_URI);
+ final Uri updatedMessageUri = actionParameters.getParcelable(KEY_UPDATED_MESSAGE_URI);
+ final boolean isSms = actionParameters.getBoolean(KEY_SMS);
+ final boolean sentByPlatform = actionParameters.getBoolean(KEY_SENT_BY_PLATFORM);
+
+ int status = actionParameters.getInt(KEY_STATUS, MmsUtils.MMS_REQUEST_MANUAL_RETRY);
+ int rawStatus = actionParameters.getInt(KEY_RAW_STATUS,
+ MmsUtils.PDU_HEADER_VALUE_UNDEFINED);
+ final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
+
+ if (sentByPlatform) {
+ // Delete temporary file backing the contentUri passed to MMS service
+ final Uri contentUri = actionParameters.getParcelable(KEY_CONTENT_URI);
+ Assert.isTrue(contentUri != null);
+ final File tempFile = MmsFileProvider.getFile(contentUri);
+ long messageSize = 0;
+ if (tempFile.exists()) {
+ messageSize = tempFile.length();
+ tempFile.delete();
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "ProcessSentMessageAction: Deleted temp file with outgoing "
+ + "MMS pdu: " + contentUri);
+ }
+ }
+
+ final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
+ final boolean responseImportant = actionParameters.getBoolean(KEY_RESPONSE_IMPORTANT);
+ if (resultCode == Activity.RESULT_OK) {
+ if (responseImportant) {
+ // Get the status from the response PDU and update telephony
+ final byte[] response = actionParameters.getByteArray(KEY_RESPONSE);
+ final SendConf sendConf = MmsSender.parseSendConf(response, subId);
+ if (sendConf != null) {
+ final MmsUtils.StatusPlusUri result =
+ MmsUtils.updateSentMmsMessageStatus(context, messageUri, sendConf);
+ status = result.status;
+ rawStatus = result.rawStatus;
+ }
+ }
+ } else {
+ String errorMsg = "ProcessSentMessageAction: Platform returned error resultCode: "
+ + resultCode;
+ final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
+ if (httpStatusCode != 0) {
+ errorMsg += (", HTTP status code: " + httpStatusCode);
+ }
+ LogUtil.w(TAG, errorMsg);
+ status = MmsSender.getErrorResultStatus(resultCode, httpStatusCode);
+
+ // Check for MMS messages that failed because they exceeded the maximum size,
+ // indicated by an I/O error from the platform.
+ if (resultCode == SmsManager.MMS_ERROR_IO_ERROR) {
+ if (messageSize > MmsConfig.get(subId).getMaxMessageSize()) {
+ rawStatus = MessageData.RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG;
+ }
+ }
+ }
+ }
+ if (messageId != null) {
+ final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
+ final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
+ processResult(
+ messageId, updatedMessageUri, status, rawStatus, isSms, this, subId,
+ resultCode, httpStatusCode);
+ } else {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "ProcessSentMessageAction: No sent message to process (it was "
+ + "probably a notify response for an MMS download)");
+ }
+ }
+ return null;
+ }
+
+ static void processResult(final String messageId, Uri updatedMessageUri, int status,
+ final int rawStatus, final boolean isSms, final Action processingAction,
+ final int subId, final int resultCode, final int httpStatusCode) {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
+ final MessageData originalMessage = message;
+ if (message == null) {
+ LogUtil.w(TAG, "ProcessSentMessageAction: Sent message " + messageId
+ + " missing from local database");
+ return;
+ }
+ final String conversationId = message.getConversationId();
+ if (updatedMessageUri != null) {
+ // Update message if we have newly written final message in the telephony db
+ final MessageData update = MmsUtils.readSendingMmsMessage(updatedMessageUri,
+ conversationId, message.getParticipantId(), message.getSelfId());
+ if (update != null) {
+ // Set message Id of final message to that of the existing place holder.
+ update.updateMessageId(message.getMessageId());
+ // Update image sizes.
+ update.updateSizesForImageParts();
+ // Temp attachments are no longer needed
+ for (final MessagePartData part : message.getParts()) {
+ part.destroySync();
+ }
+ message = update;
+ // processResult will rewrite the complete message as part of update
+ } else {
+ updatedMessageUri = null;
+ status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
+ LogUtil.e(TAG, "ProcessSentMessageAction: Unable to read sending message");
+ }
+ }
+
+ final long timestamp = System.currentTimeMillis();
+ boolean failed;
+ if (status == MmsUtils.MMS_REQUEST_SUCCEEDED) {
+ message.markMessageSent(timestamp);
+ failed = false;
+ } else if (status == MmsUtils.MMS_REQUEST_AUTO_RETRY
+ && message.getInResendWindow(timestamp)) {
+ message.markMessageNotSent(timestamp);
+ message.setRawTelephonyStatus(rawStatus);
+ failed = false;
+ } else {
+ message.markMessageFailed(timestamp);
+ message.setRawTelephonyStatus(rawStatus);
+ message.setMessageSeen(false);
+ failed = true;
+ }
+
+ // We have special handling for when a message to an emergency number fails. In this case,
+ // we notify immediately of any failure (even if we auto-retry), and instruct the user to
+ // try calling the emergency number instead.
+ if (status != MmsUtils.MMS_REQUEST_SUCCEEDED) {
+ final ArrayList<String> recipients =
+ BugleDatabaseOperations.getRecipientsForConversation(db, conversationId);
+ for (final String recipient : recipients) {
+ if (PhoneNumberUtils.isEmergencyNumber(recipient)) {
+ BugleNotifications.notifyEmergencySmsFailed(recipient, conversationId);
+ message.markMessageFailedEmergencyNumber(timestamp);
+ failed = true;
+ break;
+ }
+ }
+ }
+
+ // Update the message status and optionally refresh the message with final parts/values.
+ if (SendMessageAction.updateMessageAndStatus(isSms, message, updatedMessageUri, failed)) {
+ // We shouldn't show any notifications if we're not allowed to modify Telephony for
+ // this message.
+ if (failed) {
+ BugleNotifications.update(false, BugleNotifications.UPDATE_ERRORS);
+ }
+ BugleActionToasts.onSendMessageOrManualDownloadActionCompleted(
+ conversationId, !failed, status, isSms, subId, true/*isSend*/);
+ }
+
+ LogUtil.i(TAG, "ProcessSentMessageAction: Done sending " + (isSms ? "SMS" : "MMS")
+ + " message " + message.getMessageId()
+ + " in conversation " + conversationId
+ + "; status is " + MmsUtils.getRequestStatusDescription(status));
+
+ // Whether we succeeded or failed we will check and maybe schedule some more work
+ ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(
+ status != MmsUtils.MMS_REQUEST_SUCCEEDED, processingAction);
+ }
+
+ private ProcessSentMessageAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<ProcessSentMessageAction> CREATOR
+ = new Parcelable.Creator<ProcessSentMessageAction>() {
+ @Override
+ public ProcessSentMessageAction createFromParcel(final Parcel in) {
+ return new ProcessSentMessageAction(in);
+ }
+
+ @Override
+ public ProcessSentMessageAction[] newArray(final int size) {
+ return new ProcessSentMessageAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/ReadDraftDataAction.java b/src/com/android/messaging/datamodel/action/ReadDraftDataAction.java
new file mode 100644
index 0000000..7ac646b
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/ReadDraftDataAction.java
@@ -0,0 +1,166 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.RunsOnMainThread;
+import com.android.messaging.util.LogUtil;
+import com.google.common.annotations.VisibleForTesting;
+
+public class ReadDraftDataAction extends Action implements Parcelable {
+
+ /**
+ * Interface for ReadDraftDataAction listeners
+ */
+ public interface ReadDraftDataActionListener {
+ @RunsOnMainThread
+ abstract void onReadDraftDataSucceeded(final ReadDraftDataAction action,
+ final Object data, final MessageData message,
+ final ConversationListItemData conversation);
+ @RunsOnMainThread
+ abstract void onReadDraftDataFailed(final ReadDraftDataAction action, final Object data);
+ }
+
+ /**
+ * Read draft message and associated data (with listener)
+ */
+ public static ReadDraftDataActionMonitor readDraftData(final String conversationId,
+ final MessageData incomingDraft, final Object data,
+ final ReadDraftDataActionListener listener) {
+ final ReadDraftDataActionMonitor monitor = new ReadDraftDataActionMonitor(data,
+ listener);
+ final ReadDraftDataAction action = new ReadDraftDataAction(conversationId,
+ incomingDraft, monitor.getActionKey());
+ action.start(monitor);
+ return monitor;
+ }
+
+ private static final String KEY_CONVERSATION_ID = "conversationId";
+ private static final String KEY_INCOMING_DRAFT = "draftMessage";
+
+ private ReadDraftDataAction(final String conversationId, final MessageData incomingDraft,
+ final String actionKey) {
+ super(actionKey);
+ actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
+ actionParameters.putParcelable(KEY_INCOMING_DRAFT, incomingDraft);
+ }
+
+ @VisibleForTesting
+ class DraftData {
+ public final MessageData message;
+ public final ConversationListItemData conversation;
+
+ DraftData(final MessageData message, final ConversationListItemData conversation) {
+ this.message = message;
+ this.conversation = conversation;
+ }
+ }
+
+ @Override
+ protected Object executeAction() {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
+ final MessageData incomingDraft = actionParameters.getParcelable(KEY_INCOMING_DRAFT);
+ final ConversationListItemData conversation =
+ ConversationListItemData.getExistingConversation(db, conversationId);
+ MessageData message = null;
+ if (conversation != null) {
+ if (incomingDraft == null) {
+ message = BugleDatabaseOperations.readDraftMessageData(db, conversationId,
+ conversation.getSelfId());
+ }
+ if (message == null) {
+ message = MessageData.createDraftMessage(conversationId, conversation.getSelfId(),
+ incomingDraft);
+ LogUtil.d(LogUtil.BUGLE_TAG, "ReadDraftMessage: created draft. "
+ + "conversationId=" + conversationId
+ + " selfId=" + conversation.getSelfId());
+ } else {
+ LogUtil.d(LogUtil.BUGLE_TAG, "ReadDraftMessage: read draft. "
+ + "conversationId=" + conversationId
+ + " selfId=" + conversation.getSelfId());
+ }
+ return new DraftData(message, conversation);
+ }
+ return null;
+ }
+
+ /**
+ * An operation that notifies a listener upon completion
+ */
+ public static class ReadDraftDataActionMonitor extends ActionMonitor
+ implements ActionCompletedListener {
+
+ private final ReadDraftDataActionListener mListener;
+
+ ReadDraftDataActionMonitor(final Object data,
+ final ReadDraftDataActionListener completed) {
+ super(STATE_CREATED, generateUniqueActionKey("ReadDraftDataAction"), data);
+ setCompletedListener(this);
+ mListener = completed;
+ }
+
+ @Override
+ public void onActionSucceeded(final ActionMonitor monitor,
+ final Action action, final Object data, final Object result) {
+ final DraftData draft = (DraftData) result;
+ if (draft == null) {
+ mListener.onReadDraftDataFailed((ReadDraftDataAction) action, data);
+ } else {
+ mListener.onReadDraftDataSucceeded((ReadDraftDataAction) action, data,
+ draft.message, draft.conversation);
+ }
+ }
+
+ @Override
+ public void onActionFailed(final ActionMonitor monitor,
+ final Action action, final Object data, final Object result) {
+ Assert.fail("Reading draft should not fail");
+ }
+ }
+
+ private ReadDraftDataAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<ReadDraftDataAction> CREATOR
+ = new Parcelable.Creator<ReadDraftDataAction>() {
+ @Override
+ public ReadDraftDataAction createFromParcel(final Parcel in) {
+ return new ReadDraftDataAction(in);
+ }
+
+ @Override
+ public ReadDraftDataAction[] newArray(final int size) {
+ return new ReadDraftDataAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/ReceiveMmsMessageAction.java b/src/com/android/messaging/datamodel/action/ReceiveMmsMessageAction.java
new file mode 100644
index 0000000..6794b17
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/ReceiveMmsMessageAction.java
@@ -0,0 +1,197 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DataModelException;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.SyncManager;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.mmslib.pdu.PduHeaders;
+import com.android.messaging.sms.DatabaseMessages;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.LogUtil;
+
+import java.util.List;
+
+/**
+ * Action used to "receive" an incoming message
+ */
+public class ReceiveMmsMessageAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ private static final String KEY_SUB_ID = "sub_id";
+ private static final String KEY_PUSH_DATA = "push_data";
+ private static final String KEY_TRANSACTION_ID = "transaction_id";
+ private static final String KEY_CONTENT_LOCATION = "content_location";
+
+ /**
+ * Create a message received from a particular number in a particular conversation
+ */
+ public ReceiveMmsMessageAction(final int subId, final byte[] pushData) {
+ actionParameters.putInt(KEY_SUB_ID, subId);
+ actionParameters.putByteArray(KEY_PUSH_DATA, pushData);
+ }
+
+ @Override
+ protected Object executeAction() {
+ final Context context = Factory.get().getApplicationContext();
+ final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
+ final byte[] pushData = actionParameters.getByteArray(KEY_PUSH_DATA);
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ // Write received message to telephony DB
+ MessageData message = null;
+ final ParticipantData self = BugleDatabaseOperations.getOrCreateSelf(db, subId);
+
+ final long received = System.currentTimeMillis();
+ // Inform sync that message has been added at local received timestamp
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+ syncManager.onNewMessageInserted(received);
+
+ // TODO: Should use local time to set received time in MMS message
+ final DatabaseMessages.MmsMessage mms = MmsUtils.processReceivedPdu(
+ context, pushData, self.getSubId(), self.getNormalizedDestination());
+
+ if (mms != null) {
+ final List<String> recipients = MmsUtils.getRecipientsByThread(mms.mThreadId);
+ String from = MmsUtils.getMmsSender(recipients, mms.getUri());
+ if (from == null) {
+ LogUtil.w(TAG, "Received an MMS without sender address; using unknown sender.");
+ from = ParticipantData.getUnknownSenderDestination();
+ }
+ final ParticipantData rawSender = ParticipantData.getFromRawPhoneBySimLocale(
+ from, subId);
+ final boolean blocked = BugleDatabaseOperations.isBlockedDestination(
+ db, rawSender.getNormalizedDestination());
+ final boolean autoDownload = (!blocked && MmsUtils.allowMmsAutoRetrieve(subId));
+ final String conversationId =
+ BugleDatabaseOperations.getOrCreateConversationFromThreadId(db, mms.mThreadId,
+ blocked, subId);
+
+ final boolean messageInFocusedConversation =
+ DataModel.get().isFocusedConversation(conversationId);
+ final boolean messageInObservableConversation =
+ DataModel.get().isNewMessageObservable(conversationId);
+
+ // TODO: Also write these values to the telephony provider
+ mms.mRead = messageInFocusedConversation;
+ mms.mSeen = messageInObservableConversation || blocked;
+
+ // Write received placeholder message to our DB
+ db.beginTransaction();
+ try {
+ final String participantId =
+ BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, rawSender);
+ final String selfId =
+ BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
+
+ message = MmsUtils.createMmsMessage(mms, conversationId, participantId, selfId,
+ (autoDownload ? MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD :
+ MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD));
+ // Write the message
+ BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
+
+ if (!autoDownload) {
+ BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
+ conversationId, message.getMessageId(), message.getReceivedTimeStamp(),
+ blocked, true /* shouldAutoSwitchSelfId */);
+ final ParticipantData sender = ParticipantData .getFromId(
+ db, participantId);
+ BugleActionToasts.onMessageReceived(conversationId, sender, message);
+ }
+ // else update the conversation once we have downloaded final message (or failed)
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ // Update conversation if not immediately initiating a download
+ if (!autoDownload) {
+ MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
+ MessagingContentProvider.notifyPartsChanged();
+
+ // Show a notification to let the user know a new message has arrived
+ BugleNotifications.update(false/*silent*/, conversationId,
+ BugleNotifications.UPDATE_ALL);
+
+ // Send the NotifyRespInd with DEFERRED status since no auto download
+ actionParameters.putString(KEY_TRANSACTION_ID, mms.mTransactionId);
+ actionParameters.putString(KEY_CONTENT_LOCATION, mms.mContentLocation);
+ requestBackgroundWork();
+ }
+
+ LogUtil.i(TAG, "ReceiveMmsMessageAction: Received MMS message " + message.getMessageId()
+ + " in conversation " + message.getConversationId()
+ + ", uri = " + message.getSmsMessageUri());
+ } else {
+ LogUtil.e(TAG, "ReceiveMmsMessageAction: Skipping processing of incoming PDU");
+ }
+
+ ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
+
+ return message;
+ }
+
+ @Override
+ protected Bundle doBackgroundWork() throws DataModelException {
+ final Context context = Factory.get().getApplicationContext();
+ final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
+ final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
+ final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
+ MmsUtils.sendNotifyResponseForMmsDownload(
+ context,
+ subId,
+ MmsUtils.stringToBytes(transactionId, "UTF-8"),
+ contentLocation,
+ PduHeaders.STATUS_DEFERRED);
+ // We don't need to return anything.
+ return null;
+ }
+
+ private ReceiveMmsMessageAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<ReceiveMmsMessageAction> CREATOR
+ = new Parcelable.Creator<ReceiveMmsMessageAction>() {
+ @Override
+ public ReceiveMmsMessageAction createFromParcel(final Parcel in) {
+ return new ReceiveMmsMessageAction(in);
+ }
+
+ @Override
+ public ReceiveMmsMessageAction[] newArray(final int size) {
+ return new ReceiveMmsMessageAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java b/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java
new file mode 100644
index 0000000..5ffb35d
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java
@@ -0,0 +1,198 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.Telephony.Sms;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.SyncManager;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsSmsUtils;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+
+/**
+ * Action used to "receive" an incoming message
+ */
+public class ReceiveSmsMessageAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ private static final String KEY_MESSAGE_VALUES = "message_values";
+
+ /**
+ * Create a message received from a particular number in a particular conversation
+ */
+ public ReceiveSmsMessageAction(final ContentValues messageValues) {
+ actionParameters.putParcelable(KEY_MESSAGE_VALUES, messageValues);
+ }
+
+ @Override
+ protected Object executeAction() {
+ final Context context = Factory.get().getApplicationContext();
+ final ContentValues messageValues = actionParameters.getParcelable(KEY_MESSAGE_VALUES);
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ // Get the SIM subscription ID
+ Integer subId = messageValues.getAsInteger(Sms.SUBSCRIPTION_ID);
+ if (subId == null) {
+ subId = ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+ // Make sure we have a sender address
+ String address = messageValues.getAsString(Sms.ADDRESS);
+ if (TextUtils.isEmpty(address)) {
+ LogUtil.w(TAG, "Received an SMS without an address; using unknown sender.");
+ address = ParticipantData.getUnknownSenderDestination();
+ messageValues.put(Sms.ADDRESS, address);
+ }
+ final ParticipantData rawSender = ParticipantData.getFromRawPhoneBySimLocale(
+ address, subId);
+
+ // TODO: Should use local timestamp for this?
+ final long received = messageValues.getAsLong(Sms.DATE);
+ // Inform sync that message has been added at local received timestamp
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+ syncManager.onNewMessageInserted(received);
+
+ // Make sure we've got a thread id
+ final long threadId = MmsSmsUtils.Threads.getOrCreateThreadId(context, address);
+ messageValues.put(Sms.THREAD_ID, threadId);
+ final boolean blocked = BugleDatabaseOperations.isBlockedDestination(
+ db, rawSender.getNormalizedDestination());
+ final String conversationId = BugleDatabaseOperations.
+ getOrCreateConversationFromRecipient(db, threadId, blocked, rawSender);
+
+ final boolean messageInFocusedConversation =
+ DataModel.get().isFocusedConversation(conversationId);
+ final boolean messageInObservableConversation =
+ DataModel.get().isNewMessageObservable(conversationId);
+
+ MessageData message = null;
+ // Only the primary user gets to insert the message into the telephony db and into bugle's
+ // db. The secondary user goes through this path, but skips doing the actual insert. It
+ // goes through this path because it needs to compute messageInFocusedConversation in order
+ // to calculate whether to skip the notification and play a soft sound if the user is
+ // already in the conversation.
+ if (!OsUtil.isSecondaryUser()) {
+ final boolean read = messageValues.getAsBoolean(Sms.Inbox.READ)
+ || messageInFocusedConversation;
+ // If you have read it you have seen it
+ final boolean seen = read || messageInObservableConversation || blocked;
+ messageValues.put(Sms.Inbox.READ, read ? Integer.valueOf(1) : Integer.valueOf(0));
+
+ // incoming messages are marked as seen in the telephony db
+ messageValues.put(Sms.Inbox.SEEN, 1);
+
+ // Insert into telephony
+ final Uri messageUri = context.getContentResolver().insert(Sms.Inbox.CONTENT_URI,
+ messageValues);
+
+ if (messageUri != null) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "ReceiveSmsMessageAction: Inserted SMS message into telephony, "
+ + "uri = " + messageUri);
+ }
+ } else {
+ LogUtil.e(TAG, "ReceiveSmsMessageAction: Failed to insert SMS into telephony!");
+ }
+
+ final String text = messageValues.getAsString(Sms.BODY);
+ final String subject = messageValues.getAsString(Sms.SUBJECT);
+ final long sent = messageValues.getAsLong(Sms.DATE_SENT);
+ final ParticipantData self = ParticipantData.getSelfParticipant(subId);
+ final Integer pathPresent = messageValues.getAsInteger(Sms.REPLY_PATH_PRESENT);
+ final String smsServiceCenter = messageValues.getAsString(Sms.SERVICE_CENTER);
+ String conversationServiceCenter = null;
+ // Only set service center if message REPLY_PATH_PRESENT = 1
+ if (pathPresent != null && pathPresent == 1 && !TextUtils.isEmpty(smsServiceCenter)) {
+ conversationServiceCenter = smsServiceCenter;
+ }
+ db.beginTransaction();
+ try {
+ final String participantId =
+ BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, rawSender);
+ final String selfId =
+ BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
+
+ message = MessageData.createReceivedSmsMessage(messageUri, conversationId,
+ participantId, selfId, text, subject, sent, received, seen, read);
+
+ BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
+
+ BugleDatabaseOperations.updateConversationMetadataInTransaction(db, conversationId,
+ message.getMessageId(), message.getReceivedTimeStamp(), blocked,
+ conversationServiceCenter, true /* shouldAutoSwitchSelfId */);
+
+ final ParticipantData sender = ParticipantData.getFromId(db, participantId);
+ BugleActionToasts.onMessageReceived(conversationId, sender, message);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ LogUtil.i(TAG, "ReceiveSmsMessageAction: Received SMS message " + message.getMessageId()
+ + " in conversation " + message.getConversationId()
+ + ", uri = " + messageUri);
+
+ ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
+ } else {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "ReceiveSmsMessageAction: Not inserting received SMS message for "
+ + "secondary user.");
+ }
+ }
+ // Show a notification to let the user know a new message has arrived
+ BugleNotifications.update(false/*silent*/, conversationId, BugleNotifications.UPDATE_ALL);
+
+ MessagingContentProvider.notifyMessagesChanged(conversationId);
+ MessagingContentProvider.notifyPartsChanged();
+
+ return message;
+ }
+
+ private ReceiveSmsMessageAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<ReceiveSmsMessageAction> CREATOR
+ = new Parcelable.Creator<ReceiveSmsMessageAction>() {
+ @Override
+ public ReceiveSmsMessageAction createFromParcel(final Parcel in) {
+ return new ReceiveSmsMessageAction(in);
+ }
+
+ @Override
+ public ReceiveSmsMessageAction[] newArray(final int size) {
+ return new ReceiveSmsMessageAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/RedownloadMmsAction.java b/src/com/android/messaging/datamodel/action/RedownloadMmsAction.java
new file mode 100644
index 0000000..e899b0c
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/RedownloadMmsAction.java
@@ -0,0 +1,128 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.app.PendingIntent;
+import android.content.ContentValues;
+import android.content.Context;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Action to manually start an MMS download (after failed or manual mms download)
+ */
+public class RedownloadMmsAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+ private static final int REQUEST_CODE_PENDING_INTENT = 102;
+
+ /**
+ * Download an MMS message
+ */
+ public static void redownloadMessage(final String messageId) {
+ final RedownloadMmsAction action = new RedownloadMmsAction(messageId);
+ action.start();
+ }
+
+ /**
+ * Get a pending intent of for downloading an MMS
+ */
+ public static PendingIntent getPendingIntentForRedownloadMms(
+ final Context context, final String messageId) {
+ final Action action = new RedownloadMmsAction(messageId);
+ return ActionService.makeStartActionPendingIntent(context,
+ action, REQUEST_CODE_PENDING_INTENT, false /*launchesAnActivity*/);
+ }
+
+ // Core parameters needed for all types of message
+ private static final String KEY_MESSAGE_ID = "message_id";
+
+ /**
+ * Constructor used for retrying sending in the background (only message id available)
+ */
+ RedownloadMmsAction(final String messageId) {
+ super();
+ actionParameters.putString(KEY_MESSAGE_ID, messageId);
+ }
+
+ /**
+ * Read message from database and change status to allow downloading
+ */
+ @Override
+ protected Object executeAction() {
+ final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
+
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
+ // Check message can be redownloaded
+ if (message != null && message.canRedownloadMessage()) {
+ final long timestamp = System.currentTimeMillis();
+
+ final ContentValues values = new ContentValues(2);
+ values.put(DatabaseHelper.MessageColumns.STATUS,
+ MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD);
+ values.put(DatabaseHelper.MessageColumns.RETRY_START_TIMESTAMP, timestamp);
+
+ // Row must exist as was just loaded above (on ActionService thread)
+ BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values);
+
+ MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
+
+ // Whether we succeeded or failed we will check and maybe schedule some more work
+ ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
+ } else {
+ message = null;
+ LogUtil.e(LogUtil.BUGLE_TAG,
+ "Attempt to download a missing or un-redownloadable message");
+ }
+ // Immediately update the notifications in case we came from the download action from a
+ // heads-up notification. This will dismiss the heads-up notification.
+ BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL);
+ return message;
+ }
+
+ private RedownloadMmsAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<RedownloadMmsAction> CREATOR
+ = new Parcelable.Creator<RedownloadMmsAction>() {
+ @Override
+ public RedownloadMmsAction createFromParcel(final Parcel in) {
+ return new RedownloadMmsAction(in);
+ }
+
+ @Override
+ public RedownloadMmsAction[] newArray(final int size) {
+ return new RedownloadMmsAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/ResendMessageAction.java b/src/com/android/messaging/datamodel/action/ResendMessageAction.java
new file mode 100644
index 0000000..2201965
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/ResendMessageAction.java
@@ -0,0 +1,128 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.ContentValues;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Action used to manually resend an outgoing message
+ */
+public class ResendMessageAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ /**
+ * Manual send of existing message (no listener)
+ */
+ public static void resendMessage(final String messageId) {
+ final ResendMessageAction action = new ResendMessageAction(messageId);
+ action.start();
+ }
+
+ // Core parameters needed for all types of message
+ private static final String KEY_MESSAGE_ID = "message_id";
+
+ /**
+ * Constructor used for retrying sending in the background (only message id available)
+ */
+ ResendMessageAction(final String messageId) {
+ super();
+ actionParameters.putString(KEY_MESSAGE_ID, messageId);
+ }
+
+ /**
+ * Read message from database and change status to allow sending
+ */
+ @Override
+ protected Object executeAction() {
+ final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
+
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
+ // Check message can be resent
+ if (message != null && message.canResendMessage()) {
+ final boolean isMms = message.getIsMms();
+ long timestamp = System.currentTimeMillis();
+ if (isMms) {
+ // MMS expects timestamp rounded to nearest second
+ timestamp = 1000 * ((timestamp + 500) / 1000);
+ }
+
+ LogUtil.i(TAG, "ResendMessageAction: Resending message " + messageId
+ + "; changed timestamp from " + message.getReceivedTimeStamp() + " to "
+ + timestamp);
+
+ final ContentValues values = new ContentValues();
+ values.put(MessageColumns.STATUS, MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND);
+ values.put(MessageColumns.RECEIVED_TIMESTAMP, timestamp);
+ values.put(MessageColumns.SENT_TIMESTAMP, timestamp);
+ values.put(MessageColumns.RETRY_START_TIMESTAMP, timestamp);
+
+ // Row must exist as was just loaded above (on ActionService thread)
+ BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values);
+
+ MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
+
+ // Whether we succeeded or failed we will check and maybe schedule some more work
+ ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
+
+ return message;
+ } else {
+ String error = "ResendMessageAction: Cannot resend message " + messageId + "; ";
+ if (message != null) {
+ error += ("status = " + MessageData.getStatusDescription(message.getStatus()));
+ } else {
+ error += "not found in database";
+ }
+ LogUtil.e(TAG, error);
+ }
+
+ return null;
+ }
+
+ private ResendMessageAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<ResendMessageAction> CREATOR
+ = new Parcelable.Creator<ResendMessageAction>() {
+ @Override
+ public ResendMessageAction createFromParcel(final Parcel in) {
+ return new ResendMessageAction(in);
+ }
+
+ @Override
+ public ResendMessageAction[] newArray(final int size) {
+ return new ResendMessageAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/SendMessageAction.java b/src/com/android/messaging/datamodel/action/SendMessageAction.java
new file mode 100644
index 0000000..d7ebe8f
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/SendMessageAction.java
@@ -0,0 +1,447 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.Telephony.Mms;
+import android.provider.Telephony.Sms;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.SyncManager;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+
+import java.util.ArrayList;
+
+/**
+ * Action used to send an outgoing message. It writes MMS messages to the telephony db
+ * ({@link InsertNewMessageAction}) writes SMS messages to the telephony db). It also
+ * initiates the actual sending. It will all be used for re-sending a failed message.
+ * NOTE: This action must queue a ProcessPendingMessagesAction when it is done (success or failure).
+ * <p>
+ * This class is public (not package-private) because the SMS/MMS (e.g. MmsUtils) classes need to
+ * access the EXTRA_* fields for setting up the 'sent' pending intent.
+ */
+public class SendMessageAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ /**
+ * Queue sending of existing message (can only be called during execute of action)
+ */
+ static boolean queueForSendInBackground(final String messageId,
+ final Action processingAction) {
+ final SendMessageAction action = new SendMessageAction();
+ return action.queueAction(messageId, processingAction);
+ }
+
+ public static final boolean DEFAULT_DELIVERY_REPORT_MODE = false;
+ public static final int MAX_SMS_RETRY = 3;
+
+ // Core parameters needed for all types of message
+ private static final String KEY_MESSAGE_ID = "message_id";
+ private static final String KEY_MESSAGE = "message";
+ private static final String KEY_MESSAGE_URI = "message_uri";
+ private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number";
+
+ // For sms messages a few extra values are included in the bundle
+ private static final String KEY_RECIPIENT = "recipient";
+ private static final String KEY_RECIPIENTS = "recipients";
+ private static final String KEY_SMS_SERVICE_CENTER = "sms_service_center";
+
+ // Values we attach to the pending intent that's fired when the message is sent.
+ // Only applicable when sending via the platform APIs on L+.
+ public static final String KEY_SUB_ID = "sub_id";
+ public static final String EXTRA_MESSAGE_ID = "message_id";
+ public static final String EXTRA_UPDATED_MESSAGE_URI = "updated_message_uri";
+ public static final String EXTRA_CONTENT_URI = "content_uri";
+ public static final String EXTRA_RESPONSE_IMPORTANT = "response_important";
+
+ /**
+ * Constructor used for retrying sending in the background (only message id available)
+ */
+ private SendMessageAction() {
+ super();
+ }
+
+ /**
+ * Read message from database and queue actual sending
+ */
+ private boolean queueAction(final String messageId, final Action processingAction) {
+ actionParameters.putString(KEY_MESSAGE_ID, messageId);
+
+ final long timestamp = System.currentTimeMillis();
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
+ // Check message can be resent
+ if (message != null && message.canSendMessage()) {
+ final boolean isSms = (message.getProtocol() == MessageData.PROTOCOL_SMS);
+
+ final ParticipantData self = BugleDatabaseOperations.getExistingParticipant(
+ db, message.getSelfId());
+ final Uri messageUri = message.getSmsMessageUri();
+ final String conversationId = message.getConversationId();
+
+ // Update message status
+ if (message.getYetToSend()) {
+ // Initial sending of message
+ message.markMessageSending(timestamp);
+ } else {
+ // Automatic resend of message
+ message.markMessageResending(timestamp);
+ }
+ if (!updateMessageAndStatus(isSms, message, null /* messageUri */, false /*notify*/)) {
+ // If message is missing in the telephony database we don't need to send it
+ return false;
+ }
+
+ final ArrayList<String> recipients =
+ BugleDatabaseOperations.getRecipientsForConversation(db, conversationId);
+
+ // Update action state with parameters needed for background sending
+ actionParameters.putParcelable(KEY_MESSAGE_URI, messageUri);
+ actionParameters.putParcelable(KEY_MESSAGE, message);
+ actionParameters.putStringArrayList(KEY_RECIPIENTS, recipients);
+ actionParameters.putInt(KEY_SUB_ID, self.getSubId());
+ actionParameters.putString(KEY_SUB_PHONE_NUMBER, self.getNormalizedDestination());
+
+ if (isSms) {
+ final String smsc = BugleDatabaseOperations.getSmsServiceCenterForConversation(
+ db, conversationId);
+ actionParameters.putString(KEY_SMS_SERVICE_CENTER, smsc);
+
+ if (recipients.size() == 1) {
+ final String recipient = recipients.get(0);
+
+ actionParameters.putString(KEY_RECIPIENT, recipient);
+ // Queue actual sending for SMS
+ processingAction.requestBackgroundWork(this);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SendMessageAction: Queued SMS message " + messageId
+ + " for sending");
+ }
+ return true;
+ } else {
+ LogUtil.wtf(TAG, "Trying to resend a broadcast SMS - not allowed");
+ }
+ } else {
+ // Queue actual sending for MMS
+ processingAction.requestBackgroundWork(this);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SendMessageAction: Queued MMS message " + messageId
+ + " for sending");
+ }
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+
+ /**
+ * Never called
+ */
+ @Override
+ protected Object executeAction() {
+ Assert.fail("SendMessageAction must be queued rather than started");
+ return null;
+ }
+
+ /**
+ * Send message on background worker thread
+ */
+ @Override
+ protected Bundle doBackgroundWork() {
+ final MessageData message = actionParameters.getParcelable(KEY_MESSAGE);
+ final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
+ Uri messageUri = actionParameters.getParcelable(KEY_MESSAGE_URI);
+ Uri updatedMessageUri = null;
+ final boolean isSms = message.getProtocol() == MessageData.PROTOCOL_SMS;
+ final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
+ final String subPhoneNumber = actionParameters.getString(KEY_SUB_PHONE_NUMBER);
+
+ LogUtil.i(TAG, "SendMessageAction: Sending " + (isSms ? "SMS" : "MMS") + " message "
+ + messageId + " in conversation " + message.getConversationId());
+
+ int status;
+ int rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED;
+ int resultCode = MessageData.UNKNOWN_RESULT_CODE;
+ if (isSms) {
+ Assert.notNull(messageUri);
+ final String recipient = actionParameters.getString(KEY_RECIPIENT);
+ final String messageText = message.getMessageText();
+ final String smsServiceCenter = actionParameters.getString(KEY_SMS_SERVICE_CENTER);
+ final boolean deliveryReportRequired = MmsUtils.isDeliveryReportRequired(subId);
+
+ status = MmsUtils.sendSmsMessage(recipient, messageText, messageUri, subId,
+ smsServiceCenter, deliveryReportRequired);
+ } else {
+ final Context context = Factory.get().getApplicationContext();
+ final ArrayList<String> recipients =
+ actionParameters.getStringArrayList(KEY_RECIPIENTS);
+ if (messageUri == null) {
+ final long timestamp = message.getReceivedTimeStamp();
+
+ // Inform sync that message has been added at local received timestamp
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+ syncManager.onNewMessageInserted(timestamp);
+
+ // For MMS messages first need to write to telephony (resizing images if needed)
+ updatedMessageUri = MmsUtils.insertSendingMmsMessage(context, recipients,
+ message, subId, subPhoneNumber, timestamp);
+ if (updatedMessageUri != null) {
+ messageUri = updatedMessageUri;
+ // To prevent Sync seeing inconsistent state must write to DB on this thread
+ updateMessageUri(messageId, updatedMessageUri);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SendMessageAction: Updated message " + messageId
+ + " with new uri " + messageUri);
+ }
+ }
+ }
+ if (messageUri != null) {
+ // Actually send the MMS
+ final Bundle extras = new Bundle();
+ extras.putString(EXTRA_MESSAGE_ID, messageId);
+ extras.putParcelable(EXTRA_UPDATED_MESSAGE_URI, updatedMessageUri);
+ final MmsUtils.StatusPlusUri result = MmsUtils.sendMmsMessage(context, subId,
+ messageUri, extras);
+ if (result == MmsUtils.STATUS_PENDING) {
+ // Async send, so no status yet
+ LogUtil.d(TAG, "SendMessageAction: Sending MMS message " + messageId
+ + " asynchronously; waiting for callback to finish processing");
+ return null;
+ }
+ status = result.status;
+ rawStatus = result.rawStatus;
+ resultCode = result.resultCode;
+ } else {
+ status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
+ }
+ }
+
+ // When we fast-fail before calling the MMS lib APIs (e.g. airplane mode,
+ // sending message is deleted).
+ ProcessSentMessageAction.processMessageSentFastFailed(messageId, messageUri,
+ updatedMessageUri, subId, isSms, status, rawStatus, resultCode);
+ return null;
+ }
+
+ private void updateMessageUri(final String messageId, final Uri updatedMessageUri) {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ db.beginTransaction();
+ try {
+ final ContentValues values = new ContentValues();
+ values.put(MessageColumns.SMS_MESSAGE_URI, updatedMessageUri.toString());
+ BugleDatabaseOperations.updateMessageRow(db, messageId, values);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ @Override
+ protected Object processBackgroundResponse(final Bundle response) {
+ // Nothing to do here, post-send tasks handled by ProcessSentMessageAction
+ return null;
+ }
+
+ /**
+ * Update message status to reflect success or failure
+ */
+ @Override
+ protected Object processBackgroundFailure() {
+ final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
+ final MessageData message = actionParameters.getParcelable(KEY_MESSAGE);
+ final boolean isSms = message.getProtocol() == MessageData.PROTOCOL_SMS;
+ final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
+ final int resultCode = actionParameters.getInt(ProcessSentMessageAction.KEY_RESULT_CODE);
+ final int httpStatusCode =
+ actionParameters.getInt(ProcessSentMessageAction.KEY_HTTP_STATUS_CODE);
+
+ ProcessSentMessageAction.processResult(messageId, null /* updatedMessageUri */,
+ MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED,
+ isSms, this, subId, resultCode, httpStatusCode);
+
+ // Whether we succeeded or failed we will check and maybe schedule some more work
+ ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(true, this);
+
+ return null;
+ }
+
+ /**
+ * Update the message status (and message itself if necessary)
+ * @param isSms whether this is an SMS or MMS
+ * @param message message to update
+ * @param updatedMessageUri message uri for newly-inserted messages; null otherwise
+ * @param clearSeen whether the message 'seen' status should be reset if error occurs
+ */
+ public static boolean updateMessageAndStatus(final boolean isSms, final MessageData message,
+ final Uri updatedMessageUri, final boolean clearSeen) {
+ final Context context = Factory.get().getApplicationContext();
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ // TODO: We're optimistically setting the type/box of outgoing messages to
+ // 'SENT' even before they actually are. We should technically be using QUEUED or OUTBOX
+ // instead, but if we do that, it's possible that the Messaging app will try to send them
+ // as part of its clean-up logic that runs when it starts (http://b/18155366).
+ //
+ // We also use the wrong status when inserting queued SMS messages in
+ // InsertNewMessageAction.insertBroadcastSmsMessage and insertSendingSmsMessage (should be
+ // QUEUED or OUTBOX), and in MmsUtils.insertSendReq (should be OUTBOX).
+
+ boolean updatedTelephony = true;
+ int messageBox;
+ int type;
+ switch(message.getStatus()) {
+ case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE:
+ case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED:
+ type = Sms.MESSAGE_TYPE_SENT;
+ messageBox = Mms.MESSAGE_BOX_SENT;
+ break;
+ case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND:
+ case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
+ type = Sms.MESSAGE_TYPE_SENT;
+ messageBox = Mms.MESSAGE_BOX_SENT;
+ break;
+ case MessageData.BUGLE_STATUS_OUTGOING_SENDING:
+ case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
+ type = Sms.MESSAGE_TYPE_SENT;
+ messageBox = Mms.MESSAGE_BOX_SENT;
+ break;
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED:
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
+ type = Sms.MESSAGE_TYPE_FAILED;
+ messageBox = Mms.MESSAGE_BOX_FAILED;
+ break;
+ default:
+ type = Sms.MESSAGE_TYPE_ALL;
+ messageBox = Mms.MESSAGE_BOX_ALL;
+ break;
+ }
+ // First in the telephony DB
+ if (isSms) {
+ // Ignore update message Uri
+ if (type != Sms.MESSAGE_TYPE_ALL) {
+ if (!MmsUtils.updateSmsMessageSendingStatus(context, message.getSmsMessageUri(),
+ type, message.getReceivedTimeStamp())) {
+ message.markMessageFailed(message.getSentTimeStamp());
+ updatedTelephony = false;
+ }
+ }
+ } else if (message.getSmsMessageUri() != null) {
+ if (messageBox != Mms.MESSAGE_BOX_ALL) {
+ if (!MmsUtils.updateMmsMessageSendingStatus(context, message.getSmsMessageUri(),
+ messageBox, message.getReceivedTimeStamp())) {
+ message.markMessageFailed(message.getSentTimeStamp());
+ updatedTelephony = false;
+ }
+ }
+ }
+ if (updatedTelephony) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SendMessageAction: Updated " + (isSms ? "SMS" : "MMS")
+ + " message " + message.getMessageId()
+ + " in telephony (" + message.getSmsMessageUri() + ")");
+ }
+ } else {
+ LogUtil.w(TAG, "SendMessageAction: Failed to update " + (isSms ? "SMS" : "MMS")
+ + " message " + message.getMessageId()
+ + " in telephony (" + message.getSmsMessageUri() + "); marking message failed");
+ }
+
+ // Update the local DB
+ db.beginTransaction();
+ try {
+ if (updatedMessageUri != null) {
+ // Update all message and part fields
+ BugleDatabaseOperations.updateMessageInTransaction(db, message);
+ BugleDatabaseOperations.refreshConversationMetadataInTransaction(
+ db, message.getConversationId(), false/* shouldAutoSwitchSelfId */,
+ false/*archived*/);
+ } else {
+ final ContentValues values = new ContentValues();
+ values.put(MessageColumns.STATUS, message.getStatus());
+
+ if (clearSeen) {
+ // When a message fails to send, the message needs to
+ // be unseen to be selected as an error notification.
+ values.put(MessageColumns.SEEN, 0);
+ }
+ values.put(MessageColumns.RECEIVED_TIMESTAMP, message.getReceivedTimeStamp());
+ values.put(MessageColumns.RAW_TELEPHONY_STATUS, message.getRawTelephonyStatus());
+
+ BugleDatabaseOperations.updateMessageRowIfExists(db, message.getMessageId(),
+ values);
+ }
+ db.setTransactionSuccessful();
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SendMessageAction: Updated " + (isSms ? "SMS" : "MMS")
+ + " message " + message.getMessageId() + " in local db. Timestamp = "
+ + message.getReceivedTimeStamp());
+ }
+ } finally {
+ db.endTransaction();
+ }
+
+ MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
+ if (updatedMessageUri != null) {
+ MessagingContentProvider.notifyPartsChanged();
+ }
+
+ return updatedTelephony;
+ }
+
+ private SendMessageAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<SendMessageAction> CREATOR
+ = new Parcelable.Creator<SendMessageAction>() {
+ @Override
+ public SendMessageAction createFromParcel(final Parcel in) {
+ return new SendMessageAction(in);
+ }
+
+ @Override
+ public SendMessageAction[] newArray(final int size) {
+ return new SendMessageAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/SyncCursorPair.java b/src/com/android/messaging/datamodel/action/SyncCursorPair.java
new file mode 100644
index 0000000..b3a2676
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/SyncCursorPair.java
@@ -0,0 +1,712 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteException;
+import android.provider.Telephony.Mms;
+import android.provider.Telephony.Sms;
+import android.support.v4.util.LongSparseArray;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.SyncManager;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.SyncManager.ThreadInfoCache;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.mmslib.SqliteWrapper;
+import com.android.messaging.sms.DatabaseMessages;
+import com.android.messaging.sms.DatabaseMessages.DatabaseMessage;
+import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage;
+import com.android.messaging.sms.DatabaseMessages.MmsMessage;
+import com.android.messaging.sms.DatabaseMessages.SmsMessage;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.google.common.collect.Sets;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Class holding a pair of cursors - one for local db and one for telephony provider - allowing
+ * synchronous stepping through messages as part of sync.
+ */
+class SyncCursorPair {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ static final long SYNC_COMPLETE = -1L;
+ static final long SYNC_STARTING = Long.MAX_VALUE;
+
+ private CursorIterator mLocalCursorIterator;
+ private CursorIterator mRemoteCursorsIterator;
+
+ private final String mLocalSelection;
+ private final String mRemoteSmsSelection;
+ private final String mRemoteMmsSelection;
+
+ /**
+ * Check if SMS has been synchronized. We compare the counts of messages on both
+ * sides and return true if they are equal.
+ *
+ * Note that this may not be the most reliable way to tell if messages are in sync.
+ * For example, the local misses one message and has one obsolete message.
+ * However, we have background sms sync once a while, also some other events might
+ * trigger a full sync. So we will eventually catch up. And this should be rare to
+ * happen.
+ *
+ * @return If sms is in sync with telephony sms/mms providers
+ */
+ static boolean allSynchronized(final DatabaseWrapper db) {
+ return isSynchronized(db, LOCAL_MESSAGES_SELECTION, null,
+ getSmsTypeSelectionSql(), null, getMmsTypeSelectionSql(), null);
+ }
+
+ SyncCursorPair(final long lowerBound, final long upperBound) {
+ mLocalSelection = getTimeConstrainedQuery(
+ LOCAL_MESSAGES_SELECTION,
+ MessageColumns.RECEIVED_TIMESTAMP,
+ lowerBound,
+ upperBound,
+ null /* threadColumn */, null /* threadId */);
+ mRemoteSmsSelection = getTimeConstrainedQuery(
+ getSmsTypeSelectionSql(),
+ "date",
+ lowerBound,
+ upperBound,
+ null /* threadColumn */, null /* threadId */);
+ mRemoteMmsSelection = getTimeConstrainedQuery(
+ getMmsTypeSelectionSql(),
+ "date",
+ ((lowerBound < 0) ? lowerBound : (lowerBound + 999) / 1000), /*seconds*/
+ ((upperBound < 0) ? upperBound : (upperBound + 999) / 1000), /*seconds*/
+ null /* threadColumn */, null /* threadId */);
+ }
+
+ SyncCursorPair(final long threadId, final String conversationId) {
+ mLocalSelection = getTimeConstrainedQuery(
+ LOCAL_MESSAGES_SELECTION,
+ MessageColumns.RECEIVED_TIMESTAMP,
+ -1L,
+ -1L,
+ MessageColumns.CONVERSATION_ID, conversationId);
+ // Find all SMS messages (excluding drafts) within the sync window
+ mRemoteSmsSelection = getTimeConstrainedQuery(
+ getSmsTypeSelectionSql(),
+ "date",
+ -1L,
+ -1L,
+ Sms.THREAD_ID, Long.toString(threadId));
+ mRemoteMmsSelection = getTimeConstrainedQuery(
+ getMmsTypeSelectionSql(),
+ "date",
+ -1L, /*seconds*/
+ -1L, /*seconds*/
+ Mms.THREAD_ID, Long.toString(threadId));
+ }
+
+ void query(final DatabaseWrapper db) {
+ // Load local messages in the sync window
+ mLocalCursorIterator = new LocalCursorIterator(db, mLocalSelection);
+ // Load remote messages in the sync window
+ mRemoteCursorsIterator = new RemoteCursorsIterator(mRemoteSmsSelection,
+ mRemoteMmsSelection);
+ }
+
+ boolean isSynchronized(final DatabaseWrapper db) {
+ return isSynchronized(db, mLocalSelection, null, mRemoteSmsSelection,
+ null, mRemoteMmsSelection, null);
+ }
+
+ void close() {
+ if (mLocalCursorIterator != null) {
+ mLocalCursorIterator.close();
+ }
+ if (mRemoteCursorsIterator != null) {
+ mRemoteCursorsIterator.close();
+ }
+ }
+
+ long scan(final int maxMessagesToScan,
+ final int maxMessagesToUpdate, final ArrayList<SmsMessage> smsToAdd,
+ final LongSparseArray<MmsMessage> mmsToAdd,
+ final ArrayList<LocalDatabaseMessage> messagesToDelete,
+ final SyncManager.ThreadInfoCache threadInfoCache) {
+ // Set of local messages matched with the timestamp of a remote message
+ final Set<DatabaseMessage> matchedLocalMessages = Sets.newHashSet();
+ // Set of remote messages matched with the timestamp of a local message
+ final Set<DatabaseMessage> matchedRemoteMessages = Sets.newHashSet();
+ long lastTimestampMillis = SYNC_STARTING;
+ // Number of messages scanned local and remote
+ int localCount = 0;
+ int remoteCount = 0;
+ // Seed the initial values of remote and local messages for comparison
+ DatabaseMessage remoteMessage = mRemoteCursorsIterator.next();
+ DatabaseMessage localMessage = mLocalCursorIterator.next();
+ // Iterate through messages on both sides in reverse time order
+ // Import messages in remote not in local, delete messages in local not in remote
+ while (localCount + remoteCount < maxMessagesToScan && smsToAdd.size()
+ + mmsToAdd.size() + messagesToDelete.size() < maxMessagesToUpdate) {
+ if (remoteMessage == null && localMessage == null) {
+ // No more message on both sides - scan complete
+ lastTimestampMillis = SYNC_COMPLETE;
+ break;
+ } else if ((remoteMessage == null && localMessage != null) ||
+ (localMessage != null && remoteMessage != null &&
+ localMessage.getTimestampInMillis()
+ > remoteMessage.getTimestampInMillis())) {
+ // Found a local message that is not in remote db
+ // Delete the local message
+ messagesToDelete.add((LocalDatabaseMessage) localMessage);
+ lastTimestampMillis = Math.min(lastTimestampMillis,
+ localMessage.getTimestampInMillis());
+ // Advance to next local message
+ localMessage = mLocalCursorIterator.next();
+ localCount += 1;
+ } else if ((localMessage == null && remoteMessage != null) ||
+ (localMessage != null && remoteMessage != null &&
+ localMessage.getTimestampInMillis()
+ < remoteMessage.getTimestampInMillis())) {
+ // Found a remote message that is not in local db
+ // Add the remote message
+ saveMessageToAdd(smsToAdd, mmsToAdd, remoteMessage, threadInfoCache);
+ lastTimestampMillis = Math.min(lastTimestampMillis,
+ remoteMessage.getTimestampInMillis());
+ // Advance to next remote message
+ remoteMessage = mRemoteCursorsIterator.next();
+ remoteCount += 1;
+ } else {
+ // Found remote and local messages at the same timestamp
+ final long matchedTimestamp = localMessage.getTimestampInMillis();
+ lastTimestampMillis = Math.min(lastTimestampMillis, matchedTimestamp);
+ // Get the next local and remote messages
+ final DatabaseMessage remoteMessagePeek = mRemoteCursorsIterator.next();
+ final DatabaseMessage localMessagePeek = mLocalCursorIterator.next();
+ // Check if only one message on each side matches the current timestamp
+ // by looking at the next messages on both sides. If they are either null
+ // (meaning no more messages) or having a different timestamp. We want
+ // to optimize for this since this is the most common case when majority
+ // of the messages are in sync (so they one-to-one pair up at each timestamp),
+ // by not allocating the data structures required to compare a set of
+ // messages from both sides.
+ if ((remoteMessagePeek == null ||
+ remoteMessagePeek.getTimestampInMillis() != matchedTimestamp) &&
+ (localMessagePeek == null ||
+ localMessagePeek.getTimestampInMillis() != matchedTimestamp)) {
+ // Optimize the common case where only one message on each side
+ // that matches the same timestamp
+ if (!remoteMessage.equals(localMessage)) {
+ // local != remote
+ // Delete local message
+ messagesToDelete.add((LocalDatabaseMessage) localMessage);
+ // Add remote message
+ saveMessageToAdd(smsToAdd, mmsToAdd, remoteMessage, threadInfoCache);
+ }
+ // Get next local and remote messages
+ localMessage = localMessagePeek;
+ remoteMessage = remoteMessagePeek;
+ localCount += 1;
+ remoteCount += 1;
+ } else {
+ // Rare case in which multiple messages are in the same timestamp
+ // on either or both sides
+ // Gather all the matched remote messages
+ matchedRemoteMessages.clear();
+ matchedRemoteMessages.add(remoteMessage);
+ remoteCount += 1;
+ remoteMessage = remoteMessagePeek;
+ while (remoteMessage != null &&
+ remoteMessage.getTimestampInMillis() == matchedTimestamp) {
+ Assert.isTrue(!matchedRemoteMessages.contains(remoteMessage));
+ matchedRemoteMessages.add(remoteMessage);
+ remoteCount += 1;
+ remoteMessage = mRemoteCursorsIterator.next();
+ }
+ // Gather all the matched local messages
+ matchedLocalMessages.clear();
+ matchedLocalMessages.add(localMessage);
+ localCount += 1;
+ localMessage = localMessagePeek;
+ while (localMessage != null &&
+ localMessage.getTimestampInMillis() == matchedTimestamp) {
+ if (matchedLocalMessages.contains(localMessage)) {
+ // Duplicate message is local database is deleted
+ messagesToDelete.add((LocalDatabaseMessage) localMessage);
+ } else {
+ matchedLocalMessages.add(localMessage);
+ }
+ localCount += 1;
+ localMessage = mLocalCursorIterator.next();
+ }
+ // Delete messages local only
+ for (final DatabaseMessage msg : Sets.difference(
+ matchedLocalMessages, matchedRemoteMessages)) {
+ messagesToDelete.add((LocalDatabaseMessage) msg);
+ }
+ // Add messages remote only
+ for (final DatabaseMessage msg : Sets.difference(
+ matchedRemoteMessages, matchedLocalMessages)) {
+ saveMessageToAdd(smsToAdd, mmsToAdd, msg, threadInfoCache);
+ }
+ }
+ }
+ }
+ return lastTimestampMillis;
+ }
+
+ DatabaseMessage getLocalMessage() {
+ return mLocalCursorIterator.next();
+ }
+
+ DatabaseMessage getRemoteMessage() {
+ return mRemoteCursorsIterator.next();
+ }
+
+ int getLocalPosition() {
+ return mLocalCursorIterator.getPosition();
+ }
+
+ int getRemotePosition() {
+ return mRemoteCursorsIterator.getPosition();
+ }
+
+ int getLocalCount() {
+ return mLocalCursorIterator.getCount();
+ }
+
+ int getRemoteCount() {
+ return mRemoteCursorsIterator.getCount();
+ }
+
+ /**
+ * An iterator for a database cursor
+ */
+ interface CursorIterator {
+ /**
+ * Move to next element in the cursor
+ *
+ * @return The next element (which becomes the current)
+ */
+ public DatabaseMessage next();
+ /**
+ * Close the cursor
+ */
+ public void close();
+ /**
+ * Get the position
+ */
+ public int getPosition();
+ /**
+ * Get the count
+ */
+ public int getCount();
+ }
+
+ private static final String ORDER_BY_DATE_DESC = "date DESC";
+
+ // A subquery that selects SMS/MMS messages in Bugle which are also in telephony
+ private static final String LOCAL_MESSAGES_SELECTION = String.format(
+ Locale.US,
+ "(%s NOTNULL)",
+ MessageColumns.SMS_MESSAGE_URI);
+
+ private static final String ORDER_BY_TIMESTAMP_DESC =
+ MessageColumns.RECEIVED_TIMESTAMP + " DESC";
+
+ // TODO : This should move into the provider
+ private static class LocalMessageQuery {
+ private static final String[] PROJECTION = new String[] {
+ MessageColumns._ID,
+ MessageColumns.RECEIVED_TIMESTAMP,
+ MessageColumns.SMS_MESSAGE_URI,
+ MessageColumns.PROTOCOL,
+ MessageColumns.CONVERSATION_ID,
+ };
+ private static final int INDEX_MESSAGE_ID = 0;
+ private static final int INDEX_MESSAGE_TIMESTAMP = 1;
+ private static final int INDEX_SMS_MESSAGE_URI = 2;
+ private static final int INDEX_MESSAGE_SMS_TYPE = 3;
+ private static final int INDEX_CONVERSATION_ID = 4;
+ }
+
+ /**
+ * This class provides the same DatabaseMessage interface over a local SMS db message
+ */
+ private static LocalDatabaseMessage getLocalDatabaseMessage(final Cursor cursor) {
+ if (cursor == null) {
+ return null;
+ }
+ return new LocalDatabaseMessage(
+ cursor.getLong(LocalMessageQuery.INDEX_MESSAGE_ID),
+ cursor.getInt(LocalMessageQuery.INDEX_MESSAGE_SMS_TYPE),
+ cursor.getString(LocalMessageQuery.INDEX_SMS_MESSAGE_URI),
+ cursor.getLong(LocalMessageQuery.INDEX_MESSAGE_TIMESTAMP),
+ cursor.getString(LocalMessageQuery.INDEX_CONVERSATION_ID));
+ }
+
+ /**
+ * The buffered cursor iterator for local SMS
+ */
+ private static class LocalCursorIterator implements CursorIterator {
+ private Cursor mCursor;
+ private final DatabaseWrapper mDatabase;
+
+ LocalCursorIterator(final DatabaseWrapper database, final String selection)
+ throws SQLiteException {
+ mDatabase = database;
+ try {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SyncCursorPair: Querying for local messages; selection = "
+ + selection);
+ }
+ mCursor = mDatabase.query(
+ DatabaseHelper.MESSAGES_TABLE,
+ LocalMessageQuery.PROJECTION,
+ selection,
+ null /*selectionArgs*/,
+ null/*groupBy*/,
+ null/*having*/,
+ ORDER_BY_TIMESTAMP_DESC);
+ } catch (final SQLiteException e) {
+ LogUtil.e(TAG, "SyncCursorPair: failed to query local sms/mms", e);
+ // Can't query local database. So let's throw up the exception and abort sync
+ // because we may end up import duplicate messages.
+ throw e;
+ }
+ }
+
+ @Override
+ public DatabaseMessage next() {
+ if (mCursor != null && mCursor.moveToNext()) {
+ return getLocalDatabaseMessage(mCursor);
+ }
+ return null;
+ }
+
+ @Override
+ public int getCount() {
+ return (mCursor == null ? 0 : mCursor.getCount());
+ }
+
+ @Override
+ public int getPosition() {
+ return (mCursor == null ? 0 : mCursor.getPosition());
+ }
+
+ @Override
+ public void close() {
+ if (mCursor != null) {
+ mCursor.close();
+ mCursor = null;
+ }
+ }
+ }
+
+ /**
+ * The cursor iterator for remote sms.
+ * Since SMS and MMS are stored in different tables in telephony provider,
+ * this class merges the two cursors and provides a unified view of messages
+ * from both cursors. Note that the order is DESC.
+ */
+ private static class RemoteCursorsIterator implements CursorIterator {
+ private Cursor mSmsCursor;
+ private Cursor mMmsCursor;
+ private DatabaseMessage mNextSms;
+ private DatabaseMessage mNextMms;
+
+ RemoteCursorsIterator(final String smsSelection, final String mmsSelection)
+ throws SQLiteException {
+ mSmsCursor = null;
+ mMmsCursor = null;
+ try {
+ final Context context = Factory.get().getApplicationContext();
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SyncCursorPair: Querying for remote SMS; selection = "
+ + smsSelection);
+ }
+ mSmsCursor = SqliteWrapper.query(
+ context,
+ context.getContentResolver(),
+ Sms.CONTENT_URI,
+ SmsMessage.getProjection(),
+ smsSelection,
+ null /* selectionArgs */,
+ ORDER_BY_DATE_DESC);
+ if (mSmsCursor == null) {
+ LogUtil.w(TAG, "SyncCursorPair: Remote SMS query returned null cursor; "
+ + "need to cancel sync");
+ throw new RuntimeException("Null cursor from remote SMS query");
+ }
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SyncCursorPair: Querying for remote MMS; selection = "
+ + mmsSelection);
+ }
+ mMmsCursor = SqliteWrapper.query(
+ context,
+ context.getContentResolver(),
+ Mms.CONTENT_URI,
+ DatabaseMessages.MmsMessage.getProjection(),
+ mmsSelection,
+ null /* selectionArgs */,
+ ORDER_BY_DATE_DESC);
+ if (mMmsCursor == null) {
+ LogUtil.w(TAG, "SyncCursorPair: Remote MMS query returned null cursor; "
+ + "need to cancel sync");
+ throw new RuntimeException("Null cursor from remote MMS query");
+ }
+ // Move to the first element in the combined stream from both cursors
+ mNextSms = getSmsCursorNext();
+ mNextMms = getMmsCursorNext();
+ } catch (final SQLiteException e) {
+ LogUtil.e(TAG, "SyncCursorPair: failed to query remote messages", e);
+ // If we ignore this, the following code would think there is no remote message
+ // and will delete all the local sms. We should be cautious here. So instead,
+ // let's throw the exception to the caller and abort sms sync. We do the same
+ // thing if either of the remote cursors is null.
+ throw e;
+ }
+ }
+
+ @Override
+ public DatabaseMessage next() {
+ DatabaseMessage result = null;
+ if (mNextSms != null && mNextMms != null) {
+ if (mNextSms.getTimestampInMillis() >= mNextMms.getTimestampInMillis()) {
+ result = mNextSms;
+ mNextSms = getSmsCursorNext();
+ } else {
+ result = mNextMms;
+ mNextMms = getMmsCursorNext();
+ }
+ } else {
+ if (mNextSms != null) {
+ result = mNextSms;
+ mNextSms = getSmsCursorNext();
+ } else {
+ result = mNextMms;
+ mNextMms = getMmsCursorNext();
+ }
+ }
+ return result;
+ }
+
+ private DatabaseMessage getSmsCursorNext() {
+ if (mSmsCursor != null && mSmsCursor.moveToNext()) {
+ return SmsMessage.get(mSmsCursor);
+ }
+ return null;
+ }
+
+ private DatabaseMessage getMmsCursorNext() {
+ if (mMmsCursor != null && mMmsCursor.moveToNext()) {
+ return MmsMessage.get(mMmsCursor);
+ }
+ return null;
+ }
+
+ @Override
+ // Return approximate cursor position allowing for read ahead on two cursors (hence -1)
+ public int getPosition() {
+ return (mSmsCursor == null ? 0 : mSmsCursor.getPosition()) +
+ (mMmsCursor == null ? 0 : mMmsCursor.getPosition()) - 1;
+ }
+
+ @Override
+ public int getCount() {
+ return (mSmsCursor == null ? 0 : mSmsCursor.getCount()) +
+ (mMmsCursor == null ? 0 : mMmsCursor.getCount());
+ }
+
+ @Override
+ public void close() {
+ if (mSmsCursor != null) {
+ mSmsCursor.close();
+ mSmsCursor = null;
+ }
+ if (mMmsCursor != null) {
+ mMmsCursor.close();
+ mMmsCursor = null;
+ }
+ }
+ }
+
+ /**
+ * Type selection for importing sms messages. Only SENT and INBOX messages are imported.
+ *
+ * @return The SQL selection for importing sms messages
+ */
+ public static String getSmsTypeSelectionSql() {
+ return MmsUtils.getSmsTypeSelectionSql();
+ }
+
+ /**
+ * Type selection for importing mms messages.
+ *
+ * Criteria:
+ * MESSAGE_BOX is INBOX, SENT or OUTBOX
+ * MESSAGE_TYPE is SEND_REQ (sent), RETRIEVE_CONF (received) or NOTIFICATION_IND (download)
+ *
+ * @return The SQL selection for importing mms messages. This selects the message type,
+ * not including the selection on timestamp.
+ */
+ public static String getMmsTypeSelectionSql() {
+ return MmsUtils.getMmsTypeSelectionSql();
+ }
+
+ /**
+ * Get a SQL selection string using an existing selection and time window limits
+ * The limits are not applied if the value is < 0
+ *
+ * @param typeSelection The existing selection
+ * @param from The inclusive lower bound
+ * @param to The exclusive upper bound
+ * @return The created SQL selection
+ */
+ private static String getTimeConstrainedQuery(final String typeSelection,
+ final String timeColumn, final long from, final long to,
+ final String threadColumn, final String threadId) {
+ final StringBuilder queryBuilder = new StringBuilder();
+ queryBuilder.append(typeSelection);
+ if (from > 0) {
+ queryBuilder.append(" AND ").append(timeColumn).append(">=").append(from);
+ }
+ if (to > 0) {
+ queryBuilder.append(" AND ").append(timeColumn).append("<").append(to);
+ }
+ if (!TextUtils.isEmpty(threadColumn) && !TextUtils.isEmpty(threadId)) {
+ queryBuilder.append(" AND ").append(threadColumn).append("=").append(threadId);
+ }
+ return queryBuilder.toString();
+ }
+
+ private static final String[] COUNT_PROJECTION = new String[] { "count()" };
+
+ private static int getCountFromCursor(final Cursor cursor) {
+ if (cursor != null && cursor.moveToFirst()) {
+ return cursor.getInt(0);
+ }
+ // We should only return a number if we were able to read it from the cursor.
+ // Otherwise, we throw an exception to cancel the sync.
+ String cursorDesc = "";
+ if (cursor == null) {
+ cursorDesc = "null";
+ } else if (cursor.getCount() == 0) {
+ cursorDesc = "empty";
+ }
+ throw new IllegalArgumentException("Cannot get count from " + cursorDesc + " cursor");
+ }
+
+ private void saveMessageToAdd(final List<SmsMessage> smsToAdd,
+ final LongSparseArray<MmsMessage> mmsToAdd, final DatabaseMessage message,
+ final ThreadInfoCache threadInfoCache) {
+ long threadId;
+ if (message.getProtocol() == MessageData.PROTOCOL_MMS) {
+ final MmsMessage mms = (MmsMessage) message;
+ mmsToAdd.append(mms.getId(), mms);
+ threadId = mms.mThreadId;
+ } else {
+ final SmsMessage sms = (SmsMessage) message;
+ smsToAdd.add(sms);
+ threadId = sms.mThreadId;
+ }
+ // Cache the lookup and canonicalization of the phone number outside of the transaction...
+ threadInfoCache.getThreadRecipients(threadId);
+ }
+
+ /**
+ * Check if SMS has been synchronized. We compare the counts of messages on both
+ * sides and return true if they are equal.
+ *
+ * Note that this may not be the most reliable way to tell if messages are in sync.
+ * For example, the local misses one message and has one obsolete message.
+ * However, we have background sms sync once a while, also some other events might
+ * trigger a full sync. So we will eventually catch up. And this should be rare to
+ * happen.
+ *
+ * @return If sms is in sync with telephony sms/mms providers
+ */
+ private static boolean isSynchronized(final DatabaseWrapper db, final String localSelection,
+ final String[] localSelectionArgs, final String smsSelection,
+ final String[] smsSelectionArgs, final String mmsSelection,
+ final String[] mmsSelectionArgs) {
+ final Context context = Factory.get().getApplicationContext();
+ Cursor localCursor = null;
+ Cursor remoteSmsCursor = null;
+ Cursor remoteMmsCursor = null;
+ try {
+ localCursor = db.query(
+ DatabaseHelper.MESSAGES_TABLE,
+ COUNT_PROJECTION,
+ localSelection,
+ localSelectionArgs,
+ null/*groupBy*/,
+ null/*having*/,
+ null/*orderBy*/);
+ final int localCount = getCountFromCursor(localCursor);
+ remoteSmsCursor = SqliteWrapper.query(
+ context,
+ context.getContentResolver(),
+ Sms.CONTENT_URI,
+ COUNT_PROJECTION,
+ smsSelection,
+ smsSelectionArgs,
+ null/*orderBy*/);
+ final int smsCount = getCountFromCursor(remoteSmsCursor);
+ remoteMmsCursor = SqliteWrapper.query(
+ context,
+ context.getContentResolver(),
+ Mms.CONTENT_URI,
+ COUNT_PROJECTION,
+ mmsSelection,
+ mmsSelectionArgs,
+ null/*orderBy*/);
+ final int mmsCount = getCountFromCursor(remoteMmsCursor);
+ final int remoteCount = smsCount + mmsCount;
+ final boolean isInSync = (localCount == remoteCount);
+ if (isInSync) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncCursorPair: Same # of local and remote messages = "
+ + localCount);
+ }
+ } else {
+ LogUtil.i(TAG, "SyncCursorPair: Not in sync; # local messages = " + localCount
+ + ", # remote message = " + remoteCount);
+ }
+ return isInSync;
+ } catch (final Exception e) {
+ LogUtil.e(TAG, "SyncCursorPair: failed to query local or remote message counts", e);
+ // If something is wrong in querying database, assume we are synced so
+ // we don't retry indefinitely
+ } finally {
+ if (localCursor != null) {
+ localCursor.close();
+ }
+ if (remoteSmsCursor != null) {
+ remoteSmsCursor.close();
+ }
+ if (remoteMmsCursor != null) {
+ remoteMmsCursor.close();
+ }
+ }
+ return true;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/SyncMessageBatch.java b/src/com/android/messaging/datamodel/action/SyncMessageBatch.java
new file mode 100644
index 0000000..972d691
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/SyncMessageBatch.java
@@ -0,0 +1,383 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.database.Cursor;
+import android.database.sqlite.SQLiteConstraintException;
+import android.provider.Telephony;
+import android.provider.Telephony.Mms;
+import android.provider.Telephony.Sms;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.SyncManager.ThreadInfoCache;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.mmslib.pdu.PduHeaders;
+import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage;
+import com.android.messaging.sms.DatabaseMessages.MmsMessage;
+import com.android.messaging.sms.DatabaseMessages.SmsMessage;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Update local database with a batch of messages to add/delete in one transaction
+ */
+class SyncMessageBatch {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ // Variables used during executeAction
+ private final HashSet<String> mConversationsToUpdate;
+ // Cache of thread->conversationId map
+ private final ThreadInfoCache mCache;
+
+ // Set of SMS messages to add
+ private final ArrayList<SmsMessage> mSmsToAdd;
+ // Set of MMS messages to add
+ private final ArrayList<MmsMessage> mMmsToAdd;
+ // Set of local messages to delete
+ private final ArrayList<LocalDatabaseMessage> mMessagesToDelete;
+
+ SyncMessageBatch(final ArrayList<SmsMessage> smsToAdd,
+ final ArrayList<MmsMessage> mmsToAdd,
+ final ArrayList<LocalDatabaseMessage> messagesToDelete,
+ final ThreadInfoCache cache) {
+ mSmsToAdd = smsToAdd;
+ mMmsToAdd = mmsToAdd;
+ mMessagesToDelete = messagesToDelete;
+ mCache = cache;
+ mConversationsToUpdate = new HashSet<String>();
+ }
+
+ void updateLocalDatabase() {
+ // Perform local database changes in one transaction
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ db.beginTransaction();
+ try {
+ // Store all the SMS messages
+ for (final SmsMessage sms : mSmsToAdd) {
+ storeSms(db, sms);
+ }
+ // Store all the MMS messages
+ for (final MmsMessage mms : mMmsToAdd) {
+ storeMms(db, mms);
+ }
+ // Keep track of conversations with messages deleted
+ for (final LocalDatabaseMessage message : mMessagesToDelete) {
+ mConversationsToUpdate.add(message.getConversationId());
+ }
+ // Batch delete local messages
+ batchDelete(db, DatabaseHelper.MESSAGES_TABLE, MessageColumns._ID,
+ messageListToIds(mMessagesToDelete));
+
+ for (final LocalDatabaseMessage message : mMessagesToDelete) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SyncMessageBatch: Deleted message " + message.getLocalId()
+ + " for SMS/MMS " + message.getUri() + " with timestamp "
+ + message.getTimestampInMillis());
+ }
+ }
+
+ // Update conversation state for imported messages, like snippet,
+ updateConversations(db);
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ private static String[] messageListToIds(final List<LocalDatabaseMessage> messagesToDelete) {
+ final String[] ids = new String[messagesToDelete.size()];
+ for (int i = 0; i < ids.length; i++) {
+ ids[i] = Long.toString(messagesToDelete.get(i).getLocalId());
+ }
+ return ids;
+ }
+
+ /**
+ * Store the SMS message into local database.
+ *
+ * @param sms
+ */
+ private void storeSms(final DatabaseWrapper db, final SmsMessage sms) {
+ if (sms.mBody == null) {
+ LogUtil.w(TAG, "SyncMessageBatch: SMS " + sms.mUri + " has no body; adding empty one");
+ // try to fix it
+ sms.mBody = "";
+ }
+
+ if (TextUtils.isEmpty(sms.mAddress)) {
+ LogUtil.e(TAG, "SyncMessageBatch: SMS has no address; using unknown sender");
+ // try to fix it
+ sms.mAddress = ParticipantData.getUnknownSenderDestination();
+ }
+
+ // TODO : We need to also deal with messages in a failed/retry state
+ final boolean isOutgoing = sms.mType != Sms.MESSAGE_TYPE_INBOX;
+
+ final String otherPhoneNumber = sms.mAddress;
+
+ // A forced resync of all messages should still keep the archived states.
+ // The database upgrade code notifies sync manager of this. We need to
+ // honor the original customization to this conversation if created.
+ final String conversationId = mCache.getOrCreateConversation(db, sms.mThreadId, sms.mSubId,
+ DataModel.get().getSyncManager().getCustomizationForThread(sms.mThreadId));
+ if (conversationId == null) {
+ // Cannot create conversation for this message? This should not happen.
+ LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for SMS thread "
+ + sms.mThreadId);
+ return;
+ }
+ final ParticipantData self = ParticipantData.getSelfParticipant(sms.getSubId());
+ final String selfId =
+ BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
+ final ParticipantData sender = isOutgoing ?
+ self :
+ ParticipantData.getFromRawPhoneBySimLocale(otherPhoneNumber, sms.getSubId());
+ final String participantId = (isOutgoing ? selfId :
+ BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender));
+
+ final int bugleStatus = bugleStatusForSms(isOutgoing, sms.mType, sms.mStatus);
+
+ final MessageData message = MessageData.createSmsMessage(
+ sms.mUri,
+ participantId,
+ selfId,
+ conversationId,
+ bugleStatus,
+ sms.mSeen,
+ sms.mRead,
+ sms.mTimestampSentInMillis,
+ sms.mTimestampInMillis,
+ sms.mBody);
+
+ // Inserting sms content into messages table
+ try {
+ BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
+ } catch (SQLiteConstraintException e) {
+ rethrowSQLiteConstraintExceptionWithDetails(e, db, sms.mUri, sms.mThreadId,
+ conversationId, selfId, participantId);
+ }
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId()
+ + " for SMS " + message.getSmsMessageUri() + " received at "
+ + message.getReceivedTimeStamp());
+ }
+
+ // Keep track of updated conversation for later updating the conversation snippet, etc.
+ mConversationsToUpdate.add(conversationId);
+ }
+
+ public static int bugleStatusForSms(final boolean isOutgoing, final int type,
+ final int status) {
+ int bugleStatus = MessageData.BUGLE_STATUS_UNKNOWN;
+ // For a message we sync either
+ if (isOutgoing) {
+ // Outgoing message not yet been sent
+ if (type == Telephony.Sms.MESSAGE_TYPE_FAILED ||
+ type == Telephony.Sms.MESSAGE_TYPE_OUTBOX ||
+ type == Telephony.Sms.MESSAGE_TYPE_QUEUED ||
+ (type == Telephony.Sms.MESSAGE_TYPE_SENT &&
+ status == Telephony.Sms.STATUS_FAILED)) {
+ // Not sent counts as failed and available for manual resend
+ bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_FAILED;
+ } else if (status == Sms.STATUS_COMPLETE) {
+ bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_DELIVERED;
+ } else {
+ // Otherwise outgoing message is complete
+ bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE;
+ }
+ } else {
+ // All incoming SMS messages are complete
+ bugleStatus = MessageData.BUGLE_STATUS_INCOMING_COMPLETE;
+ }
+ return bugleStatus;
+ }
+
+ /**
+ * Store the MMS message into local database
+ *
+ * @param mms
+ */
+ private void storeMms(final DatabaseWrapper db, final MmsMessage mms) {
+ if (mms.mParts.size() < 1) {
+ LogUtil.w(TAG, "SyncMessageBatch: MMS " + mms.mUri + " has no parts");
+ }
+
+ // TODO : We need to also deal with messages in a failed/retry state
+ final boolean isOutgoing = mms.mType != Mms.MESSAGE_BOX_INBOX;
+ final boolean isNotification = (mms.mMmsMessageType ==
+ PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND);
+
+ final String senderId = mms.mSender;
+
+ // A forced resync of all messages should still keep the archived states.
+ // The database upgrade code notifies sync manager of this. We need to
+ // honor the original customization to this conversation if created.
+ final String conversationId = mCache.getOrCreateConversation(db, mms.mThreadId, mms.mSubId,
+ DataModel.get().getSyncManager().getCustomizationForThread(mms.mThreadId));
+ if (conversationId == null) {
+ LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for MMS thread "
+ + mms.mThreadId);
+ return;
+ }
+ final ParticipantData self = ParticipantData.getSelfParticipant(mms.getSubId());
+ final String selfId =
+ BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
+ final ParticipantData sender = isOutgoing ?
+ self : ParticipantData.getFromRawPhoneBySimLocale(senderId, mms.getSubId());
+ final String participantId = (isOutgoing ? selfId :
+ BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender));
+
+ final int bugleStatus = MmsUtils.bugleStatusForMms(isOutgoing, isNotification, mms.mType);
+
+ // Import message and all of the parts.
+ // TODO : For now we are importing these in the order we found them in the MMS
+ // database. Ideally we would load and parse the SMIL which describes how the parts relate
+ // to one another.
+
+ // TODO: Need to set correct status on message
+ final MessageData message = MmsUtils.createMmsMessage(mms, conversationId, participantId,
+ selfId, bugleStatus);
+
+ // Inserting mms content into messages table
+ try {
+ BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
+ } catch (SQLiteConstraintException e) {
+ rethrowSQLiteConstraintExceptionWithDetails(e, db, mms.mUri, mms.mThreadId,
+ conversationId, selfId, participantId);
+ }
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId()
+ + " for MMS " + message.getSmsMessageUri() + " received at "
+ + message.getReceivedTimeStamp());
+ }
+
+ // Keep track of updated conversation for later updating the conversation snippet, etc.
+ mConversationsToUpdate.add(conversationId);
+ }
+
+ // TODO: Remove this after we no longer see this crash (b/18375758)
+ private static void rethrowSQLiteConstraintExceptionWithDetails(SQLiteConstraintException e,
+ DatabaseWrapper db, String messageUri, long threadId, String conversationId,
+ String selfId, String senderId) {
+ // Add some extra debug information to the exception for tracking down b/18375758.
+ // The default detail message for SQLiteConstraintException tells us that a foreign
+ // key constraint failed, but not which one! Messages have foreign keys to 3 tables:
+ // conversations, participants (self), participants (sender). We'll query each one
+ // to determine which one(s) violated the constraint, and then throw a new exception
+ // with those details.
+
+ String foundConversationId = null;
+ Cursor cursor = null;
+ try {
+ // Look for an existing conversation in the db with the conversation id
+ cursor = db.rawQuery("SELECT " + ConversationColumns._ID
+ + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE
+ + " WHERE " + ConversationColumns._ID + "=" + conversationId,
+ null);
+ if (cursor != null && cursor.moveToFirst()) {
+ Assert.isTrue(cursor.getCount() == 1);
+ foundConversationId = cursor.getString(0);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ ParticipantData foundSelfParticipant =
+ BugleDatabaseOperations.getExistingParticipant(db, selfId);
+ ParticipantData foundSenderParticipant =
+ BugleDatabaseOperations.getExistingParticipant(db, senderId);
+
+ String errorMsg = "SQLiteConstraintException while inserting message for " + messageUri
+ + "; conversation id from getOrCreateConversation = " + conversationId
+ + " (lookup thread = " + threadId + "), found conversation id = "
+ + foundConversationId + ", found self participant = "
+ + LogUtil.sanitizePII(foundSelfParticipant.getNormalizedDestination())
+ + " (lookup id = " + selfId + "), found sender participant = "
+ + LogUtil.sanitizePII(foundSenderParticipant.getNormalizedDestination())
+ + " (lookup id = " + senderId + ")";
+ throw new RuntimeException(errorMsg, e);
+ }
+
+ /**
+ * Use the tracked latest message info to update conversations, including
+ * latest chat message and sort timestamp.
+ */
+ private void updateConversations(final DatabaseWrapper db) {
+ for (final String conversationId : mConversationsToUpdate) {
+ if (BugleDatabaseOperations.deleteConversationIfEmptyInTransaction(db,
+ conversationId)) {
+ continue;
+ }
+
+ final boolean archived = mCache.isArchived(conversationId);
+ // Always attempt to auto-switch conversation self id for sync/import case.
+ BugleDatabaseOperations.maybeRefreshConversationMetadataInTransaction(db,
+ conversationId, true /*shouldAutoSwitchSelfId*/, archived /*keepArchived*/);
+ }
+ }
+
+
+ /**
+ * Batch delete database rows by matching a column with a list of values, usually some
+ * kind of IDs.
+ *
+ * @param table
+ * @param column
+ * @param ids
+ * @return Total number of deleted messages
+ */
+ private static int batchDelete(final DatabaseWrapper db, final String table,
+ final String column, final String[] ids) {
+ int totalDeleted = 0;
+ final int totalIds = ids.length;
+ for (int start = 0; start < totalIds; start += MmsUtils.MAX_IDS_PER_QUERY) {
+ final int end = Math.min(start + MmsUtils.MAX_IDS_PER_QUERY, totalIds); //excluding
+ final int count = end - start;
+ final String batchSelection = String.format(
+ Locale.US,
+ "%s IN %s",
+ column,
+ MmsUtils.getSqlInOperand(count));
+ final String[] batchSelectionArgs = Arrays.copyOfRange(ids, start, end);
+ final int deleted = db.delete(
+ table,
+ batchSelection,
+ batchSelectionArgs);
+ totalDeleted += deleted;
+ }
+ return totalDeleted;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/SyncMessagesAction.java b/src/com/android/messaging/datamodel/action/SyncMessagesAction.java
new file mode 100644
index 0000000..f4a3e1f
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/SyncMessagesAction.java
@@ -0,0 +1,637 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteException;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.SystemClock;
+import android.provider.Telephony.Mms;
+import android.support.v4.util.LongSparseArray;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.SyncManager;
+import com.android.messaging.datamodel.SyncManager.ThreadInfoCache;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.mmslib.SqliteWrapper;
+import com.android.messaging.sms.DatabaseMessages;
+import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage;
+import com.android.messaging.sms.DatabaseMessages.MmsMessage;
+import com.android.messaging.sms.DatabaseMessages.SmsMessage;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.BuglePrefsKeys;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Action used to sync messages from smsmms db to local database
+ */
+public class SyncMessagesAction extends Action implements Parcelable {
+ static final long SYNC_FAILED = Long.MIN_VALUE;
+
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ private static final String KEY_START_TIMESTAMP = "start_timestamp";
+ private static final String KEY_MAX_UPDATE = "max_update";
+ private static final String KEY_LOWER_BOUND = "lower_bound";
+ private static final String KEY_UPPER_BOUND = "upper_bound";
+ private static final String BUNDLE_KEY_LAST_TIMESTAMP = "last_timestamp";
+ private static final String BUNDLE_KEY_SMS_MESSAGES = "sms_to_add";
+ private static final String BUNDLE_KEY_MMS_MESSAGES = "mms_to_add";
+ private static final String BUNDLE_KEY_MESSAGES_TO_DELETE = "messages_to_delete";
+
+ /**
+ * Start a full sync (backed off a few seconds to avoid pulling sending/receiving messages).
+ */
+ public static void fullSync() {
+ final BugleGservices bugleGservices = BugleGservices.get();
+ final long smsSyncBackoffTimeMillis = bugleGservices.getLong(
+ BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS,
+ BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT);
+
+ final long now = System.currentTimeMillis();
+ // TODO: Could base this off most recent message in db but now should be okay...
+ final long startTimestamp = now - smsSyncBackoffTimeMillis;
+
+ final SyncMessagesAction action = new SyncMessagesAction(-1L, startTimestamp,
+ 0, startTimestamp);
+ action.start();
+ }
+
+ /**
+ * Start an incremental sync to pull messages since last sync (backed off a few seconds)..
+ */
+ public static void sync() {
+ final BugleGservices bugleGservices = BugleGservices.get();
+ final long smsSyncBackoffTimeMillis = bugleGservices.getLong(
+ BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS,
+ BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT);
+
+ final long now = System.currentTimeMillis();
+ // TODO: Could base this off most recent message in db but now should be okay...
+ final long startTimestamp = now - smsSyncBackoffTimeMillis;
+
+ sync(startTimestamp);
+ }
+
+ /**
+ * Start an incremental sync when the application starts up (no back off as not yet
+ * sending/receiving).
+ */
+ public static void immediateSync() {
+ final long now = System.currentTimeMillis();
+ // TODO: Could base this off most recent message in db but now should be okay...
+ final long startTimestamp = now;
+
+ sync(startTimestamp);
+ }
+
+ private static void sync(final long startTimestamp) {
+ if (!OsUtil.hasSmsPermission()) {
+ // Sync requires READ_SMS permission
+ return;
+ }
+
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ // Lower bound is end of previous sync
+ final long syncLowerBoundTimeMillis = prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME,
+ BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT);
+
+ final SyncMessagesAction action = new SyncMessagesAction(syncLowerBoundTimeMillis,
+ startTimestamp, 0, startTimestamp);
+ action.start();
+ }
+
+ private SyncMessagesAction(final long lowerBound, final long upperBound,
+ final int maxMessagesToUpdate, final long startTimestamp) {
+ actionParameters.putLong(KEY_LOWER_BOUND, lowerBound);
+ actionParameters.putLong(KEY_UPPER_BOUND, upperBound);
+ actionParameters.putInt(KEY_MAX_UPDATE, maxMessagesToUpdate);
+ actionParameters.putLong(KEY_START_TIMESTAMP, startTimestamp);
+ }
+
+ @Override
+ protected Object executeAction() {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND);
+ final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND);
+ final int initialMaxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE);
+ final long startTimestamp = actionParameters.getLong(KEY_START_TIMESTAMP);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncMessagesAction: Request to sync messages from "
+ + lowerBoundTimeMillis + " to " + upperBoundTimeMillis + " (start timestamp = "
+ + startTimestamp + ", message update limit = " + initialMaxMessagesToUpdate
+ + ")");
+ }
+
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+ if (lowerBoundTimeMillis >= 0) {
+ // Cursors
+ final SyncCursorPair cursors = new SyncCursorPair(-1L, lowerBoundTimeMillis);
+ final boolean inSync = cursors.isSynchronized(db);
+ if (!inSync) {
+ if (syncManager.delayUntilFullSync(startTimestamp) == 0) {
+ lowerBoundTimeMillis = -1;
+ actionParameters.putLong(KEY_LOWER_BOUND, lowerBoundTimeMillis);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncMessagesAction: Messages before "
+ + lowerBoundTimeMillis + " not in sync; promoting to full sync");
+ }
+ } else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncMessagesAction: Messages before "
+ + lowerBoundTimeMillis + " not in sync; will do incremental sync");
+ }
+ } else {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncMessagesAction: Messages before " + lowerBoundTimeMillis
+ + " are in sync");
+ }
+ }
+ }
+
+ // Check if sync allowed (can be too soon after last or one is already running)
+ if (syncManager.shouldSync(lowerBoundTimeMillis < 0, startTimestamp)) {
+ syncManager.startSyncBatch(upperBoundTimeMillis);
+ requestBackgroundWork();
+ }
+
+ return null;
+ }
+
+ @Override
+ protected Bundle doBackgroundWork() {
+ final BugleGservices bugleGservices = BugleGservices.get();
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+
+ final int maxMessagesToScan = bugleGservices.getInt(
+ BugleGservicesKeys.SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN,
+ BugleGservicesKeys.SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN_DEFAULT);
+
+ final int initialMaxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE);
+ final int smsSyncSubsequentBatchSizeMin = bugleGservices.getInt(
+ BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MIN,
+ BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MIN_DEFAULT);
+ final int smsSyncSubsequentBatchSizeMax = bugleGservices.getInt(
+ BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MAX,
+ BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MAX_DEFAULT);
+
+ // Cap sync size to GServices limits
+ final int maxMessagesToUpdate = Math.max(smsSyncSubsequentBatchSizeMin,
+ Math.min(initialMaxMessagesToUpdate, smsSyncSubsequentBatchSizeMax));
+
+ final long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND);
+ final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND);
+
+ LogUtil.i(TAG, "SyncMessagesAction: Starting batch for messages from "
+ + lowerBoundTimeMillis + " to " + upperBoundTimeMillis
+ + " (message update limit = " + maxMessagesToUpdate + ", message scan limit = "
+ + maxMessagesToScan + ")");
+
+ // Clear last change time so that we can work out if this batch is dirty when it completes
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+
+ // Clear the singleton cache that maps threads to recipients and to conversations.
+ final SyncManager.ThreadInfoCache cache = syncManager.getThreadInfoCache();
+ cache.clear();
+
+ // Sms messages to store
+ final ArrayList<SmsMessage> smsToAdd = new ArrayList<SmsMessage>();
+ // Mms messages to store
+ final LongSparseArray<MmsMessage> mmsToAdd = new LongSparseArray<MmsMessage>();
+ // List of local SMS/MMS to remove
+ final ArrayList<LocalDatabaseMessage> messagesToDelete =
+ new ArrayList<LocalDatabaseMessage>();
+
+ long lastTimestampMillis = SYNC_FAILED;
+ if (syncManager.isSyncing(upperBoundTimeMillis)) {
+ // Cursors
+ final SyncCursorPair cursors = new SyncCursorPair(lowerBoundTimeMillis,
+ upperBoundTimeMillis);
+
+ // Actually compare the messages using cursor pair
+ lastTimestampMillis = syncCursorPair(db, cursors, smsToAdd, mmsToAdd,
+ messagesToDelete, maxMessagesToScan, maxMessagesToUpdate, cache);
+ }
+ final Bundle response = new Bundle();
+
+ // If comparison succeeds bundle up the changes for processing in ActionService
+ if (lastTimestampMillis > SYNC_FAILED) {
+ final ArrayList<MmsMessage> mmsToAddList = new ArrayList<MmsMessage>();
+ for (int i = 0; i < mmsToAdd.size(); i++) {
+ final MmsMessage mms = mmsToAdd.valueAt(i);
+ mmsToAddList.add(mms);
+ }
+
+ response.putParcelableArrayList(BUNDLE_KEY_SMS_MESSAGES, smsToAdd);
+ response.putParcelableArrayList(BUNDLE_KEY_MMS_MESSAGES, mmsToAddList);
+ response.putParcelableArrayList(BUNDLE_KEY_MESSAGES_TO_DELETE, messagesToDelete);
+ }
+ response.putLong(BUNDLE_KEY_LAST_TIMESTAMP, lastTimestampMillis);
+
+ return response;
+ }
+
+ /**
+ * Compare messages based on timestamp and uri
+ * @param db local database wrapper
+ * @param cursors cursor pair holding references to local and remote messages
+ * @param smsToAdd newly found sms messages to add
+ * @param mmsToAdd newly found mms messages to add
+ * @param messagesToDelete messages not found needing deletion
+ * @param maxMessagesToScan max messages to scan for changes
+ * @param maxMessagesToUpdate max messages to return for updates
+ * @param cache cache for conversation id / thread id / recipient set mapping
+ * @return timestamp of the oldest message seen during the sync scan
+ */
+ private long syncCursorPair(final DatabaseWrapper db, final SyncCursorPair cursors,
+ final ArrayList<SmsMessage> smsToAdd, final LongSparseArray<MmsMessage> mmsToAdd,
+ final ArrayList<LocalDatabaseMessage> messagesToDelete, final int maxMessagesToScan,
+ final int maxMessagesToUpdate, final ThreadInfoCache cache) {
+ long lastTimestampMillis;
+ final long startTimeMillis = SystemClock.elapsedRealtime();
+
+ // Number of messages scanned local and remote
+ int localPos = 0;
+ int remotePos = 0;
+ int localTotal = 0;
+ int remoteTotal = 0;
+ // Scan through the messages on both sides and prepare messages for local message table
+ // changes (including adding and deleting)
+ try {
+ cursors.query(db);
+
+ localTotal = cursors.getLocalCount();
+ remoteTotal = cursors.getRemoteCount();
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncMessagesAction: Scanning cursors (local count = " + localTotal
+ + ", remote count = " + remoteTotal + ", message update limit = "
+ + maxMessagesToUpdate + ", message scan limit = " + maxMessagesToScan
+ + ")");
+ }
+
+ lastTimestampMillis = cursors.scan(maxMessagesToScan, maxMessagesToUpdate,
+ smsToAdd, mmsToAdd, messagesToDelete, cache);
+
+ localPos = cursors.getLocalPosition();
+ remotePos = cursors.getRemotePosition();
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncMessagesAction: Scanned cursors (local position = " + localPos
+ + " of " + localTotal + ", remote position = " + remotePos + " of "
+ + remoteTotal + ")");
+ }
+
+ // Batch loading the parts of the MMS messages in this batch
+ loadMmsParts(mmsToAdd);
+ // Lookup senders for incoming mms messages
+ setMmsSenders(mmsToAdd, cache);
+ } catch (final SQLiteException e) {
+ LogUtil.e(TAG, "SyncMessagesAction: Database exception", e);
+ // Let's abort
+ lastTimestampMillis = SYNC_FAILED;
+ } catch (final Exception e) {
+ // We want to catch anything unexpected since this is running in a separate thread
+ // and any unexpected exception will just fail this thread silently.
+ // Let's crash for dogfooders!
+ LogUtil.wtf(TAG, "SyncMessagesAction: unexpected failure in scan", e);
+ lastTimestampMillis = SYNC_FAILED;
+ } finally {
+ if (cursors != null) {
+ cursors.close();
+ }
+ }
+
+ final long endTimeMillis = SystemClock.elapsedRealtime();
+
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncMessagesAction: Scan complete (took "
+ + (endTimeMillis - startTimeMillis) + " ms). " + smsToAdd.size()
+ + " remote SMS to add, " + mmsToAdd.size() + " MMS to add, "
+ + messagesToDelete.size() + " local messages to delete. "
+ + "Oldest timestamp seen = " + lastTimestampMillis);
+ }
+
+ return lastTimestampMillis;
+ }
+
+ /**
+ * Perform local database updates and schedule follow on sync actions
+ */
+ @Override
+ protected Object processBackgroundResponse(final Bundle response) {
+ final long lastTimestampMillis = response.getLong(BUNDLE_KEY_LAST_TIMESTAMP);
+ final long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND);
+ final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND);
+ final int maxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE);
+ final long startTimestamp = actionParameters.getLong(KEY_START_TIMESTAMP);
+
+ // Check with the sync manager if any conflicting updates have been made to databases
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+ final boolean orphan = !syncManager.isSyncing(upperBoundTimeMillis);
+
+ // lastTimestampMillis used to indicate failure
+ if (orphan) {
+ // This batch does not match current in progress timestamp.
+ LogUtil.w(TAG, "SyncMessagesAction: Ignoring orphan sync batch for messages from "
+ + lowerBoundTimeMillis + " to " + upperBoundTimeMillis);
+ } else {
+ final boolean dirty = syncManager.isBatchDirty(lastTimestampMillis);
+ if (lastTimestampMillis == SYNC_FAILED) {
+ LogUtil.e(TAG, "SyncMessagesAction: Sync failed - terminating");
+
+ // Failed - update last sync times to throttle our failure rate
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ // Save sync completion time so next sync will start from here
+ prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, startTimestamp);
+ // Remember last full sync so that don't start background full sync right away
+ prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, startTimestamp);
+
+ syncManager.complete();
+ } else if (dirty) {
+ LogUtil.w(TAG, "SyncMessagesAction: Redoing dirty sync batch of messages from "
+ + lowerBoundTimeMillis + " to " + upperBoundTimeMillis);
+
+ // Redo this batch
+ final SyncMessagesAction nextBatch =
+ new SyncMessagesAction(lowerBoundTimeMillis, upperBoundTimeMillis,
+ maxMessagesToUpdate, startTimestamp);
+
+ syncManager.startSyncBatch(upperBoundTimeMillis);
+ requestBackgroundWork(nextBatch);
+ } else {
+ // Succeeded
+ final ArrayList<SmsMessage> smsToAdd =
+ response.getParcelableArrayList(BUNDLE_KEY_SMS_MESSAGES);
+ final ArrayList<MmsMessage> mmsToAdd =
+ response.getParcelableArrayList(BUNDLE_KEY_MMS_MESSAGES);
+ final ArrayList<LocalDatabaseMessage> messagesToDelete =
+ response.getParcelableArrayList(BUNDLE_KEY_MESSAGES_TO_DELETE);
+
+ final int messagesUpdated = smsToAdd.size() + mmsToAdd.size()
+ + messagesToDelete.size();
+
+ // Perform local database changes in one transaction
+ long txnTimeMillis = 0;
+ if (messagesUpdated > 0) {
+ final long startTimeMillis = SystemClock.elapsedRealtime();
+ final SyncMessageBatch batch = new SyncMessageBatch(smsToAdd, mmsToAdd,
+ messagesToDelete, syncManager.getThreadInfoCache());
+ batch.updateLocalDatabase();
+ final long endTimeMillis = SystemClock.elapsedRealtime();
+ txnTimeMillis = endTimeMillis - startTimeMillis;
+
+ LogUtil.i(TAG, "SyncMessagesAction: Updated local database "
+ + "(took " + txnTimeMillis + " ms). Added "
+ + smsToAdd.size() + " SMS, added " + mmsToAdd.size() + " MMS, deleted "
+ + messagesToDelete.size() + " messages.");
+
+ // TODO: Investigate whether we can make this more fine-grained.
+ MessagingContentProvider.notifyEverythingChanged();
+ } else {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncMessagesAction: No local database updates to make");
+ }
+
+ if (!syncManager.getHasFirstSyncCompleted()) {
+ // If we have never completed a sync before (fresh install) and there are
+ // no messages, still inform the UI of a change so it can update syncing
+ // messages shown to the user
+ MessagingContentProvider.notifyConversationListChanged();
+ MessagingContentProvider.notifyPartsChanged();
+ }
+ }
+ // Determine if there are more messages that need to be scanned
+ if (lastTimestampMillis >= 0 && lastTimestampMillis >= lowerBoundTimeMillis) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SyncMessagesAction: More messages to sync; scheduling next "
+ + "sync batch now.");
+ }
+
+ // Include final millisecond of last sync in next sync
+ final long newUpperBoundTimeMillis = lastTimestampMillis + 1;
+ final int newMaxMessagesToUpdate = nextBatchSize(messagesUpdated,
+ txnTimeMillis);
+
+ final SyncMessagesAction nextBatch =
+ new SyncMessagesAction(lowerBoundTimeMillis, newUpperBoundTimeMillis,
+ newMaxMessagesToUpdate, startTimestamp);
+
+ // Proceed with next batch
+ syncManager.startSyncBatch(newUpperBoundTimeMillis);
+ requestBackgroundWork(nextBatch);
+ } else {
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ // Save sync completion time so next sync will start from here
+ prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, startTimestamp);
+ if (lowerBoundTimeMillis < 0) {
+ // Remember last full sync so that don't start another full sync right away
+ prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, startTimestamp);
+ }
+
+ final long now = System.currentTimeMillis();
+
+ // After any sync check if new messages have arrived
+ final SyncCursorPair recents = new SyncCursorPair(startTimestamp, now);
+ final SyncCursorPair olders = new SyncCursorPair(-1L, startTimestamp);
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ if (!recents.isSynchronized(db)) {
+ LogUtil.i(TAG, "SyncMessagesAction: Changed messages after sync; "
+ + "scheduling an incremental sync now.");
+
+ // Just add a new batch for recent messages
+ final SyncMessagesAction nextBatch =
+ new SyncMessagesAction(startTimestamp, now, 0, startTimestamp);
+ syncManager.startSyncBatch(now);
+ requestBackgroundWork(nextBatch);
+ // After partial sync verify sync state
+ } else if (lowerBoundTimeMillis >= 0 && !olders.isSynchronized(db)) {
+ // Add a batch going back to start of time
+ LogUtil.w(TAG, "SyncMessagesAction: Changed messages before sync batch; "
+ + "scheduling a full sync now.");
+
+ final SyncMessagesAction nextBatch =
+ new SyncMessagesAction(-1L, startTimestamp, 0, startTimestamp);
+
+ syncManager.startSyncBatch(startTimestamp);
+ requestBackgroundWork(nextBatch);
+ } else {
+ LogUtil.i(TAG, "SyncMessagesAction: All messages now in sync");
+
+ // All done, in sync
+ syncManager.complete();
+ }
+ }
+ // Either sync should be complete or we should have a follow up request
+ Assert.isTrue(hasBackgroundActions() || !syncManager.isSyncing());
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Decide the next batch size based on the stats we collected with past batch
+ * @param messagesUpdated number of messages updated in this batch
+ * @param txnTimeMillis time the transaction took in ms
+ * @return Target number of messages to sync for next batch
+ */
+ private static int nextBatchSize(final int messagesUpdated, final long txnTimeMillis) {
+ final BugleGservices bugleGservices = BugleGservices.get();
+ final long smsSyncSubsequentBatchTimeLimitMillis = bugleGservices.getLong(
+ BugleGservicesKeys.SMS_SYNC_BATCH_TIME_LIMIT_MILLIS,
+ BugleGservicesKeys.SMS_SYNC_BATCH_TIME_LIMIT_MILLIS_DEFAULT);
+
+ if (txnTimeMillis <= 0) {
+ return 0;
+ }
+ // Number of messages we can sync within the batch time limit using
+ // the average sync time calculated based on the stats we collected
+ // in previous batch
+ return (int) ((double) (messagesUpdated) / (double) txnTimeMillis
+ * smsSyncSubsequentBatchTimeLimitMillis);
+ }
+
+ /**
+ * Batch loading MMS parts for the messages in current batch
+ */
+ private void loadMmsParts(final LongSparseArray<MmsMessage> mmses) {
+ final Context context = Factory.get().getApplicationContext();
+ final int totalIds = mmses.size();
+ for (int start = 0; start < totalIds; start += MmsUtils.MAX_IDS_PER_QUERY) {
+ final int end = Math.min(start + MmsUtils.MAX_IDS_PER_QUERY, totalIds); //excluding
+ final int count = end - start;
+ final String batchSelection = String.format(
+ Locale.US,
+ "%s != '%s' AND %s IN %s",
+ Mms.Part.CONTENT_TYPE,
+ ContentType.APP_SMIL,
+ Mms.Part.MSG_ID,
+ MmsUtils.getSqlInOperand(count));
+ final String[] batchSelectionArgs = new String[count];
+ for (int i = 0; i < count; i++) {
+ batchSelectionArgs[i] = Long.toString(mmses.valueAt(start + i).getId());
+ }
+ final Cursor cursor = SqliteWrapper.query(
+ context,
+ context.getContentResolver(),
+ MmsUtils.MMS_PART_CONTENT_URI,
+ DatabaseMessages.MmsPart.PROJECTION,
+ batchSelection,
+ batchSelectionArgs,
+ null/*sortOrder*/);
+ if (cursor != null) {
+ try {
+ while (cursor.moveToNext()) {
+ // Delay loading the media content for parsing for efficiency
+ // TODO: load the media and fill in the dimensions when
+ // we actually display it
+ final DatabaseMessages.MmsPart part =
+ DatabaseMessages.MmsPart.get(cursor, false/*loadMedia*/);
+ final DatabaseMessages.MmsMessage mms = mmses.get(part.mMessageId);
+ if (mms != null) {
+ mms.addPart(part);
+ }
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+ }
+
+ /**
+ * Batch loading MMS sender for the messages in current batch
+ */
+ private void setMmsSenders(final LongSparseArray<MmsMessage> mmses,
+ final ThreadInfoCache cache) {
+ // Store all the MMS messages
+ for (int i = 0; i < mmses.size(); i++) {
+ final MmsMessage mms = mmses.valueAt(i);
+
+ final boolean isOutgoing = mms.mType != Mms.MESSAGE_BOX_INBOX;
+ String senderId = null;
+ if (!isOutgoing) {
+ // We only need to find out sender phone number for received message
+ senderId = getMmsSender(mms, cache);
+ if (senderId == null) {
+ LogUtil.w(TAG, "SyncMessagesAction: Could not find sender of incoming MMS "
+ + "message " + mms.getUri() + "; using 'unknown sender' instead");
+ senderId = ParticipantData.getUnknownSenderDestination();
+ }
+ }
+ mms.setSender(senderId);
+ }
+ }
+
+ /**
+ * Find out the sender of an MMS message
+ */
+ private String getMmsSender(final MmsMessage mms, final ThreadInfoCache cache) {
+ final List<String> recipients = cache.getThreadRecipients(mms.mThreadId);
+ Assert.notNull(recipients);
+ Assert.isTrue(recipients.size() > 0);
+
+ if (recipients.size() == 1
+ && recipients.get(0).equals(ParticipantData.getUnknownSenderDestination())) {
+ LogUtil.w(TAG, "SyncMessagesAction: MMS message " + mms.mUri + " has unknown sender "
+ + "(thread id = " + mms.mThreadId + ")");
+ }
+
+ return MmsUtils.getMmsSender(recipients, mms.mUri);
+ }
+
+ private SyncMessagesAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<SyncMessagesAction> CREATOR
+ = new Parcelable.Creator<SyncMessagesAction>() {
+ @Override
+ public SyncMessagesAction createFromParcel(final Parcel in) {
+ return new SyncMessagesAction(in);
+ }
+
+ @Override
+ public SyncMessagesAction[] newArray(final int size) {
+ return new SyncMessagesAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/UpdateConversationArchiveStatusAction.java b/src/com/android/messaging/datamodel/action/UpdateConversationArchiveStatusAction.java
new file mode 100644
index 0000000..066ad74
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/UpdateConversationArchiveStatusAction.java
@@ -0,0 +1,93 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.util.Assert;
+
+public class UpdateConversationArchiveStatusAction extends Action {
+
+ public static void archiveConversation(final String conversationId) {
+ final UpdateConversationArchiveStatusAction action =
+ new UpdateConversationArchiveStatusAction(conversationId, true /* isArchive */);
+ action.start();
+ }
+
+ public static void unarchiveConversation(final String conversationId) {
+ final UpdateConversationArchiveStatusAction action =
+ new UpdateConversationArchiveStatusAction(conversationId, false /* isArchive */);
+ action.start();
+ }
+
+ private static final String KEY_CONVERSATION_ID = "conversation_id";
+ private static final String KEY_IS_ARCHIVE = "is_archive";
+
+ protected UpdateConversationArchiveStatusAction(
+ final String conversationId, final boolean isArchive) {
+ Assert.isTrue(!TextUtils.isEmpty(conversationId));
+ actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
+ actionParameters.putBoolean(KEY_IS_ARCHIVE, isArchive);
+ }
+
+ @Override
+ protected Object executeAction() {
+ final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
+ final boolean isArchived = actionParameters.getBoolean(KEY_IS_ARCHIVE);
+
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ db.beginTransaction();
+ try {
+ BugleDatabaseOperations.updateConversationArchiveStatusInTransaction(
+ db, conversationId, isArchived);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ MessagingContentProvider.notifyConversationListChanged();
+ MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
+ return null;
+ }
+
+ protected UpdateConversationArchiveStatusAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<UpdateConversationArchiveStatusAction> CREATOR
+ = new Parcelable.Creator<UpdateConversationArchiveStatusAction>() {
+ @Override
+ public UpdateConversationArchiveStatusAction createFromParcel(final Parcel in) {
+ return new UpdateConversationArchiveStatusAction(in);
+ }
+
+ @Override
+ public UpdateConversationArchiveStatusAction[] newArray(final int size) {
+ return new UpdateConversationArchiveStatusAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/UpdateConversationOptionsAction.java b/src/com/android/messaging/datamodel/action/UpdateConversationOptionsAction.java
new file mode 100644
index 0000000..6c9e739
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/UpdateConversationOptionsAction.java
@@ -0,0 +1,156 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.content.ContentValues;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.util.Assert;
+
+/**
+ * Action used to update conversation options such as notification settings.
+ */
+public class UpdateConversationOptionsAction extends Action
+ implements Parcelable {
+ /**
+ * Enable/disable conversation notifications.
+ */
+ public static void enableConversationNotifications(final String conversationId,
+ final boolean enableNotification) {
+ Assert.notNull(conversationId);
+
+ final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction(
+ conversationId, enableNotification, null, null);
+ action.start();
+ }
+
+ /**
+ * Sets conversation notification sound.
+ */
+ public static void setConversationNotificationSound(final String conversationId,
+ final String ringtoneUri) {
+ Assert.notNull(conversationId);
+
+ final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction(
+ conversationId, null, ringtoneUri, null);
+ action.start();
+ }
+
+ /**
+ * Enable/disable vibrations for conversation notification.
+ */
+ public static void enableVibrationForConversationNotification(final String conversationId,
+ final boolean enableVibration) {
+ Assert.notNull(conversationId);
+
+ final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction(
+ conversationId, null, null, enableVibration);
+ action.start();
+ }
+
+ private static final String KEY_CONVERSATION_ID = "conversation_id";
+
+ // Keys for all settable settings.
+ private static final String KEY_ENABLE_NOTIFICATION = "enable_notification";
+ private static final String KEY_RINGTONE_URI = "ringtone_uri";
+ private static final String KEY_ENABLE_VIBRATION = "enable_vibration";
+
+ protected UpdateConversationOptionsAction(final String conversationId,
+ final Boolean enableNotification, final String ringtoneUri,
+ final Boolean enableVibration) {
+ Assert.notNull(conversationId);
+ actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
+ if (enableNotification != null) {
+ actionParameters.putBoolean(KEY_ENABLE_NOTIFICATION, enableNotification);
+ }
+
+ if (ringtoneUri != null) {
+ actionParameters.putString(KEY_RINGTONE_URI, ringtoneUri);
+ }
+
+ if (enableVibration != null) {
+ actionParameters.putBoolean(KEY_ENABLE_VIBRATION, enableVibration);
+ }
+ }
+
+ protected void putOptionValuesInTransaction(final ContentValues values,
+ final DatabaseWrapper dbWrapper) {
+ Assert.isTrue(dbWrapper.getDatabase().inTransaction());
+ if (actionParameters.containsKey(KEY_ENABLE_NOTIFICATION)) {
+ values.put(ConversationColumns.NOTIFICATION_ENABLED,
+ actionParameters.getBoolean(KEY_ENABLE_NOTIFICATION));
+ }
+
+ if (actionParameters.containsKey(KEY_RINGTONE_URI)) {
+ values.put(ConversationColumns.NOTIFICATION_SOUND_URI,
+ actionParameters.getString(KEY_RINGTONE_URI));
+ }
+
+ if (actionParameters.containsKey(KEY_ENABLE_VIBRATION)) {
+ values.put(ConversationColumns.NOTIFICATION_VIBRATION,
+ actionParameters.getBoolean(KEY_ENABLE_VIBRATION));
+ }
+ }
+
+ @Override
+ protected Object executeAction() {
+ final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
+
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ db.beginTransaction();
+ try {
+ final ContentValues values = new ContentValues();
+ putOptionValuesInTransaction(values, db);
+
+ BugleDatabaseOperations.updateConversationRowIfExists(db, conversationId, values);
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
+ return null;
+ }
+
+ protected UpdateConversationOptionsAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<UpdateConversationOptionsAction> CREATOR
+ = new Parcelable.Creator<UpdateConversationOptionsAction>() {
+ @Override
+ public UpdateConversationOptionsAction createFromParcel(final Parcel in) {
+ return new UpdateConversationOptionsAction(in);
+ }
+
+ @Override
+ public UpdateConversationOptionsAction[] newArray(final int size) {
+ return new UpdateConversationOptionsAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/UpdateDestinationBlockedAction.java b/src/com/android/messaging/datamodel/action/UpdateDestinationBlockedAction.java
new file mode 100644
index 0000000..c74096d
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/UpdateDestinationBlockedAction.java
@@ -0,0 +1,148 @@
+/*
+ * 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.messaging.datamodel.action;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.util.Assert;
+
+public class UpdateDestinationBlockedAction extends Action {
+ public interface UpdateDestinationBlockedActionListener {
+ @Assert.RunsOnMainThread
+ abstract void onUpdateDestinationBlockedAction(final UpdateDestinationBlockedAction action,
+ final boolean success,
+ final boolean block,
+ final String destination);
+ }
+
+ public static class UpdateDestinationBlockedActionMonitor extends ActionMonitor
+ implements ActionMonitor.ActionCompletedListener {
+ private final UpdateDestinationBlockedActionListener mListener;
+
+ public UpdateDestinationBlockedActionMonitor(
+ Object data, UpdateDestinationBlockedActionListener mListener) {
+ super(STATE_CREATED, generateUniqueActionKey("UpdateDestinationBlockedAction"), data);
+ setCompletedListener(this);
+ this.mListener = mListener;
+ }
+
+ private void onActionDone(final boolean succeeded,
+ final ActionMonitor monitor,
+ final Action action,
+ final Object data,
+ final Object result) {
+ mListener.onUpdateDestinationBlockedAction(
+ (UpdateDestinationBlockedAction) action,
+ succeeded,
+ action.actionParameters.getBoolean(KEY_BLOCKED),
+ action.actionParameters.getString(KEY_DESTINATION));
+ }
+
+ @Override
+ public void onActionSucceeded(final ActionMonitor monitor,
+ final Action action,
+ final Object data,
+ final Object result) {
+ onActionDone(true, monitor, action, data, result);
+ }
+
+ @Override
+ public void onActionFailed(final ActionMonitor monitor,
+ final Action action,
+ final Object data,
+ final Object result) {
+ onActionDone(false, monitor, action, data, result);
+ }
+ }
+
+
+ public static UpdateDestinationBlockedActionMonitor updateDestinationBlocked(
+ final String destination, final boolean blocked, final String conversationId,
+ final UpdateDestinationBlockedActionListener listener) {
+ Assert.notNull(listener);
+ final UpdateDestinationBlockedActionMonitor monitor =
+ new UpdateDestinationBlockedActionMonitor(null, listener);
+ final UpdateDestinationBlockedAction action =
+ new UpdateDestinationBlockedAction(destination, blocked, conversationId,
+ monitor.getActionKey());
+ action.start(monitor);
+ return monitor;
+ }
+
+ private static final String KEY_CONVERSATION_ID = "conversation_id";
+ private static final String KEY_DESTINATION = "destination";
+ private static final String KEY_BLOCKED = "blocked";
+
+ protected UpdateDestinationBlockedAction(
+ final String destination, final boolean blocked, final String conversationId,
+ final String actionKey) {
+ super(actionKey);
+ Assert.isTrue(!TextUtils.isEmpty(destination));
+ actionParameters.putString(KEY_DESTINATION, destination);
+ actionParameters.putBoolean(KEY_BLOCKED, blocked);
+ actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
+ }
+
+ @Override
+ protected Object executeAction() {
+ final String destination = actionParameters.getString(KEY_DESTINATION);
+ final boolean isBlocked = actionParameters.getBoolean(KEY_BLOCKED);
+ String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ BugleDatabaseOperations.updateDestination(db, destination, isBlocked);
+ if (conversationId == null) {
+ conversationId = BugleDatabaseOperations
+ .getConversationFromOtherParticipantDestination(db, destination);
+ }
+ if (conversationId != null) {
+ if (isBlocked) {
+ UpdateConversationArchiveStatusAction.archiveConversation(conversationId);
+ } else {
+ UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId);
+ }
+ MessagingContentProvider.notifyParticipantsChanged(conversationId);
+ }
+ return null;
+ }
+
+ protected UpdateDestinationBlockedAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<UpdateDestinationBlockedAction> CREATOR
+ = new Parcelable.Creator<UpdateDestinationBlockedAction>() {
+ @Override
+ public UpdateDestinationBlockedAction createFromParcel(final Parcel in) {
+ return new UpdateDestinationBlockedAction(in);
+ }
+
+ @Override
+ public UpdateDestinationBlockedAction[] newArray(final int size) {
+ return new UpdateDestinationBlockedAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/UpdateMessageNotificationAction.java b/src/com/android/messaging/datamodel/action/UpdateMessageNotificationAction.java
new file mode 100644
index 0000000..94e6f3b
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/UpdateMessageNotificationAction.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.datamodel.action;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.datamodel.BugleNotifications;
+
+/**
+ * Updates the message notification (generally, to include voice replies we've
+ * made since the notification was first posted).
+ */
+public class UpdateMessageNotificationAction extends Action {
+
+ public static void updateMessageNotification() {
+ new UpdateMessageNotificationAction().start();
+ }
+
+ private UpdateMessageNotificationAction() {
+ }
+
+ @Override
+ protected Object executeAction() {
+ BugleNotifications.update(true /* silent */, BugleNotifications.UPDATE_MESSAGES);
+ return null;
+ }
+
+ private UpdateMessageNotificationAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<UpdateMessageNotificationAction> CREATOR
+ = new Parcelable.Creator<UpdateMessageNotificationAction>() {
+ @Override
+ public UpdateMessageNotificationAction createFromParcel(final Parcel in) {
+ return new UpdateMessageNotificationAction(in);
+ }
+
+ @Override
+ public UpdateMessageNotificationAction[] newArray(final int size) {
+ return new UpdateMessageNotificationAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/UpdateMessagePartSizeAction.java b/src/com/android/messaging/datamodel/action/UpdateMessagePartSizeAction.java
new file mode 100644
index 0000000..273dce9
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/UpdateMessagePartSizeAction.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.datamodel.action;
+
+import android.content.ContentValues;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.DatabaseHelper.PartColumns;
+import com.android.messaging.util.Assert;
+
+/**
+ * Action used to update size fields of a single part
+ */
+public class UpdateMessagePartSizeAction extends Action implements Parcelable {
+ /**
+ * Update size of part
+ */
+ public static void updateSize(final String partId, final int width, final int height) {
+ Assert.notNull(partId);
+ Assert.inRange(width, 0, Integer.MAX_VALUE);
+ Assert.inRange(height, 0, Integer.MAX_VALUE);
+
+ final UpdateMessagePartSizeAction action = new UpdateMessagePartSizeAction(
+ partId, width, height);
+ action.start();
+ }
+
+ private static final String KEY_PART_ID = "part_id";
+ private static final String KEY_WIDTH = "width";
+ private static final String KEY_HEIGHT = "height";
+
+ private UpdateMessagePartSizeAction(final String partId, final int width, final int height) {
+ actionParameters.putString(KEY_PART_ID, partId);
+ actionParameters.putInt(KEY_WIDTH, width);
+ actionParameters.putInt(KEY_HEIGHT, height);
+ }
+
+ @Override
+ protected Object executeAction() {
+ final String partId = actionParameters.getString(KEY_PART_ID);
+ final int width = actionParameters.getInt(KEY_WIDTH);
+ final int height = actionParameters.getInt(KEY_HEIGHT);
+
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ db.beginTransaction();
+ try {
+ final ContentValues values = new ContentValues();
+
+ values.put(PartColumns.WIDTH, width);
+ values.put(PartColumns.HEIGHT, height);
+
+ // Part may have been deleted so allow update to fail without asserting
+ BugleDatabaseOperations.updateRowIfExists(db, DatabaseHelper.PARTS_TABLE,
+ PartColumns._ID, partId, values);
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ return null;
+ }
+
+ private UpdateMessagePartSizeAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<UpdateMessagePartSizeAction> CREATOR
+ = new Parcelable.Creator<UpdateMessagePartSizeAction>() {
+ @Override
+ public UpdateMessagePartSizeAction createFromParcel(final Parcel in) {
+ return new UpdateMessagePartSizeAction(in);
+ }
+
+ @Override
+ public UpdateMessagePartSizeAction[] newArray(final int size) {
+ return new UpdateMessagePartSizeAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/action/WriteDraftMessageAction.java b/src/com/android/messaging/datamodel/action/WriteDraftMessageAction.java
new file mode 100644
index 0000000..c1f39e1
--- /dev/null
+++ b/src/com/android/messaging/datamodel/action/WriteDraftMessageAction.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.messaging.datamodel.action;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.util.LogUtil;
+
+public class WriteDraftMessageAction extends Action implements Parcelable {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+
+ /**
+ * Set draft message (no listener)
+ */
+ public static void writeDraftMessage(final String conversationId, final MessageData message) {
+ final WriteDraftMessageAction action = new WriteDraftMessageAction(conversationId, message);
+ action.start();
+ }
+
+ private static final String KEY_CONVERSATION_ID = "conversationId";
+ private static final String KEY_MESSAGE = "message";
+
+ private WriteDraftMessageAction(final String conversationId, final MessageData message) {
+ actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
+ actionParameters.putParcelable(KEY_MESSAGE, message);
+ }
+
+ @Override
+ protected Object executeAction() {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
+ final MessageData message = actionParameters.getParcelable(KEY_MESSAGE);
+ if (message.getSelfId() == null || message.getParticipantId() == null) {
+ // This could happen when this occurs before the draft message is loaded
+ // In this case, we just use the conversation's current self id as draft's
+ // self id and/or participant id
+ final ConversationListItemData conversation =
+ ConversationListItemData.getExistingConversation(db, conversationId);
+ if (conversation != null) {
+ final String senderAndSelf = conversation.getSelfId();
+ if (message.getSelfId() == null) {
+ message.bindSelfId(senderAndSelf);
+ }
+ if (message.getParticipantId() == null) {
+ message.bindParticipantId(senderAndSelf);
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_DATAMODEL_TAG, "Conversation " + conversationId +
+ "already deleted before saving draft message " +
+ message.getMessageId() + ". Aborting WriteDraftMessageAction.");
+ return null;
+ }
+ }
+ // Drafts are only kept in the local DB...
+ final String messageId = BugleDatabaseOperations.updateDraftMessageData(
+ db, conversationId, message, BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT);
+ MessagingContentProvider.notifyConversationListChanged();
+ MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
+ return messageId;
+ }
+
+ private WriteDraftMessageAction(final Parcel in) {
+ super(in);
+ }
+
+ public static final Parcelable.Creator<WriteDraftMessageAction> CREATOR
+ = new Parcelable.Creator<WriteDraftMessageAction>() {
+ @Override
+ public WriteDraftMessageAction createFromParcel(final Parcel in) {
+ return new WriteDraftMessageAction(in);
+ }
+
+ @Override
+ public WriteDraftMessageAction[] newArray(final int size) {
+ return new WriteDraftMessageAction[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ writeActionToParcel(parcel, flags);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/binding/BindableData.java b/src/com/android/messaging/datamodel/binding/BindableData.java
new file mode 100644
index 0000000..5446098
--- /dev/null
+++ b/src/com/android/messaging/datamodel/binding/BindableData.java
@@ -0,0 +1,72 @@
+/*
+ * 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.messaging.datamodel.binding;
+
+/**
+ * Base class for data objects that will be bound to a piece of the UI
+ */
+public abstract class BindableData {
+ /**
+ * Called by Binding during unbind to allow data to proactively unregister callbacks
+ * Data instance should release all listeners that may call back to the host UI
+ */
+ protected abstract void unregisterListeners();
+
+ /**
+ * Key used to identify the piece of UI that the data is currently bound to
+ */
+ private String mBindingId;
+
+ /**
+ * Bind this data to the ui host - checks data is currently unbound
+ */
+ public void bind(final String bindingId) {
+ if (isBound() || bindingId == null) {
+ throw new IllegalStateException();
+ }
+ mBindingId = bindingId;
+ }
+
+ /**
+ * Unbind this data from the ui host - checks that the data is currently bound to specified id
+ */
+ public void unbind(final String bindingId) {
+ if (!isBound(bindingId)) {
+ throw new IllegalStateException();
+ }
+ unregisterListeners();
+ mBindingId = null;
+ }
+
+ /**
+ * Check to see if the data is bound to anything
+ *
+ * TODO: This should be package private because it's supposed to only be used by Binding,
+ * however, several classes call this directly. We want the classes to track what they are
+ * bound to.
+ */
+ protected boolean isBound() {
+ return (mBindingId != null);
+ }
+
+ /**
+ * Check to see if data is still bound with specified bindingId before calling over to ui
+ */
+ public boolean isBound(final String bindingId) {
+ return (bindingId.equals(mBindingId));
+ }
+}
diff --git a/src/com/android/messaging/datamodel/binding/BindableOnceData.java b/src/com/android/messaging/datamodel/binding/BindableOnceData.java
new file mode 100644
index 0000000..08e11da
--- /dev/null
+++ b/src/com/android/messaging/datamodel/binding/BindableOnceData.java
@@ -0,0 +1,42 @@
+/*
+ * 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.messaging.datamodel.binding;
+
+/**
+ * A BindableData that's only used to be bound once. If the client needs to rebind, it needs
+ * to create a new instance of the BindableOnceData.
+ */
+public abstract class BindableOnceData extends BindableData {
+ private boolean boundOnce = false;
+
+ @Override
+ public void bind(final String bindingId) {
+ // Ensures that we can't re-bind again after the first binding.
+ if (boundOnce) {
+ throw new IllegalStateException();
+ }
+ super.bind(bindingId);
+ boundOnce = true;
+ }
+
+ /**
+ * Checks if the instance is bound to anything.
+ */
+ @Override
+ public boolean isBound() {
+ return super.isBound();
+ }
+}
diff --git a/src/com/android/messaging/datamodel/binding/Binding.java b/src/com/android/messaging/datamodel/binding/Binding.java
new file mode 100644
index 0000000..3ec01dd
--- /dev/null
+++ b/src/com/android/messaging/datamodel/binding/Binding.java
@@ -0,0 +1,94 @@
+/*
+ * 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.messaging.datamodel.binding;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+public class Binding<T extends BindableData> extends BindingBase<T> {
+ private static AtomicLong sBindingIdx = new AtomicLong(System.currentTimeMillis() * 1000);
+
+ private String mBindingId;
+ private T mData;
+ private final Object mOwner;
+ private boolean mWasBound;
+
+ /**
+ * Initialize a binding instance - the owner is typically the containing class
+ */
+ Binding(final Object owner) {
+ mOwner = owner;
+ }
+
+ @Override
+ public T getData() {
+ ensureBound();
+ return mData;
+ }
+
+ @Override
+ public boolean isBound() {
+ return (mData != null && mData.isBound(mBindingId));
+ }
+
+ @Override
+ public boolean isBound(final T data) {
+ return (isBound() && data == mData);
+ }
+
+ @Override
+ public void ensureBound() {
+ if (!isBound()) {
+ throw new IllegalStateException("not bound; wasBound = " + mWasBound);
+ }
+ }
+
+ @Override
+ public void ensureBound(final T data) {
+ if (!isBound()) {
+ throw new IllegalStateException("not bound; wasBound = " + mWasBound);
+ } else if (data != mData) {
+ throw new IllegalStateException("not bound to correct data " + data + " vs " + mData);
+ }
+ }
+
+ @Override
+ public String getBindingId() {
+ return mBindingId;
+ }
+
+ public void bind(final T data) {
+ // Check both this binding and the data not already bound
+ if (mData != null || data.isBound()) {
+ throw new IllegalStateException("already bound when binding to " + data);
+ }
+ // Generate a unique identifier for this bind call
+ mBindingId = Long.toHexString(sBindingIdx.getAndIncrement());
+ data.bind(mBindingId);
+ mData = data;
+ mWasBound = true;
+ }
+
+ public void unbind() {
+ // Check this binding is bound and that data is bound to this binding
+ if (mData == null || !mData.isBound(mBindingId)) {
+ throw new IllegalStateException("not bound when unbind");
+ }
+ mData.unbind(mBindingId);
+ mData = null;
+ mBindingId = null;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/binding/BindingBase.java b/src/com/android/messaging/datamodel/binding/BindingBase.java
new file mode 100644
index 0000000..3d6da9b
--- /dev/null
+++ b/src/com/android/messaging/datamodel/binding/BindingBase.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.datamodel.binding;
+
+/**
+ * The binding class keeps track of a binding between a ui component and an item of BindableData
+ * It allows each side to ensure that when it communicates with the other they are still bound
+ * together.
+ * NOTE: Ensure that the UI component uses the same binding instance for it's whole lifetime
+ * (DO NOT CREATE A NEW BINDING EACH TIME A NEW PIECE OF DATA IS BOUND)...
+ *
+ * The ui component owns the binding instance.
+ * It can use it [isBound(data)] to see if the binding still binds to the right piece of data
+ *
+ * Upon binding the data is informed of a unique binding key generated in this class and can use
+ * that to ensure that it is still issuing callbacks to the right piece of ui.
+ */
+public abstract class BindingBase<T extends BindableData> {
+ /**
+ * Creates a new exclusively owned binding for the owner object.
+ */
+ public static <T extends BindableData> Binding<T> createBinding(final Object owner) {
+ return new Binding<T>(owner);
+ }
+
+ /**
+ * Creates a new read-only binding referencing the source binding object.
+ * TODO: We may want to refcount the Binding references, so that when the binding owner
+ * calls unbind() when there's still outstanding references we can catch it.
+ */
+ public static <T extends BindableData> ImmutableBindingRef<T> createBindingReference(
+ final BindingBase<T> srcBinding) {
+ return new ImmutableBindingRef<T>(srcBinding);
+ }
+
+ /**
+ * Creates a detachable binding for the owner object. Use this if your owner object is a UI
+ * component that may undergo a "detached from window" -> "re-attached to window" transition.
+ */
+ public static <T extends BindableData> DetachableBinding<T> createDetachableBinding(
+ final Object owner) {
+ return new DetachableBinding<T>(owner);
+ }
+
+ public abstract T getData();
+
+ /**
+ * Check if binding connects to the specified data instance
+ */
+ public abstract boolean isBound();
+
+ /**
+ * Check if binding connects to the specified data instance
+ */
+ public abstract boolean isBound(final T data);
+
+ /**
+ * Throw if binding connects to the specified data instance
+ */
+ public abstract void ensureBound();
+
+ /**
+ * Throw if binding connects to the specified data instance
+ */
+ public abstract void ensureBound(final T data);
+
+ /**
+ * Return the binding id for this binding (will be null if not bound)
+ */
+ public abstract String getBindingId();
+}
diff --git a/src/com/android/messaging/datamodel/binding/DetachableBinding.java b/src/com/android/messaging/datamodel/binding/DetachableBinding.java
new file mode 100644
index 0000000..a414c3b
--- /dev/null
+++ b/src/com/android/messaging/datamodel/binding/DetachableBinding.java
@@ -0,0 +1,56 @@
+/*
+ * 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.messaging.datamodel.binding;
+
+import com.android.messaging.util.Assert;
+
+/**
+ * An extension on {@link Binding} that allows for temporary data detachment from the UI component.
+ * This is used when, instead of destruction or data rebinding, the owning UI undergoes a
+ * "detached from window" -> "re-attached to window" transition, in which case we want to
+ * temporarily unbind the data and remember it so that it can be rebound when the UI is re-attached
+ * to window later.
+ */
+public class DetachableBinding<T extends BindableData> extends Binding<T> {
+ private T mDetachedData;
+
+ DetachableBinding(Object owner) {
+ super(owner);
+ }
+
+ @Override
+ public void bind(T data) {
+ super.bind(data);
+ // Rebinding before re-attaching. Pre-emptively throw away the detached data because
+ // it's now stale.
+ mDetachedData = null;
+ }
+
+ public void detach() {
+ Assert.isNull(mDetachedData);
+ Assert.isTrue(isBound());
+ mDetachedData = getData();
+ unbind();
+ }
+
+ public void reAttachIfPossible() {
+ if (mDetachedData != null) {
+ Assert.isFalse(isBound());
+ bind(mDetachedData);
+ mDetachedData = null;
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/binding/ImmutableBindingRef.java b/src/com/android/messaging/datamodel/binding/ImmutableBindingRef.java
new file mode 100644
index 0000000..9a0a3d6
--- /dev/null
+++ b/src/com/android/messaging/datamodel/binding/ImmutableBindingRef.java
@@ -0,0 +1,83 @@
+/*
+ * 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.messaging.datamodel.binding;
+
+import com.android.messaging.util.Assert;
+
+/**
+ * A immutable wrapper around a Binding object. Callers can only access readonly methods like
+ * getData(), isBound() and ensureBound() but not bind() and unbind(). This is used for MVC pattern
+ * where both the View and the Controller needs access to a centrally bound Model object. The View
+ * is the one that owns the bind/unbind logic of the Binding, whereas controller only serves as a
+ * consumer.
+ */
+public class ImmutableBindingRef<T extends BindableData> extends BindingBase<T> {
+ /**
+ * The referenced, read-only binding object.
+ */
+ private final BindingBase<T> mBinding;
+
+ /**
+ * Hidden ctor.
+ */
+ ImmutableBindingRef(final BindingBase<T> binding) {
+ mBinding = resolveBinding(binding);
+ }
+
+ @Override
+ public T getData() {
+ return mBinding.getData();
+ }
+
+ @Override
+ public boolean isBound() {
+ return mBinding.isBound();
+ }
+
+ @Override
+ public boolean isBound(final T data) {
+ return mBinding.isBound(data);
+ }
+
+ @Override
+ public void ensureBound() {
+ mBinding.ensureBound();
+ }
+
+ @Override
+ public void ensureBound(final T data) {
+ mBinding.ensureBound(data);
+ }
+
+ @Override
+ public String getBindingId() {
+ return mBinding.getBindingId();
+ }
+
+ /**
+ * Resolve the source binding to the real BindingImpl it's referencing. This avoids the
+ * redundancy of multiple wrapper calls when creating a binding reference from an existing
+ * binding reference.
+ */
+ private BindingBase<T> resolveBinding(final BindingBase<T> binding) {
+ BindingBase<T> resolvedBinding = binding;
+ while (resolvedBinding instanceof ImmutableBindingRef<?>) {
+ resolvedBinding = ((ImmutableBindingRef<T>) resolvedBinding).mBinding;
+ }
+ Assert.isTrue(resolvedBinding instanceof Binding<?>);
+ return resolvedBinding;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/BlockedParticipantsData.java b/src/com/android/messaging/datamodel/data/BlockedParticipantsData.java
new file mode 100644
index 0000000..4e94ee1
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/BlockedParticipantsData.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.datamodel.data;
+
+import android.app.LoaderManager;
+import android.content.Context;
+import android.content.Loader;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+
+import com.android.messaging.datamodel.BoundCursorLoader;
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.binding.BindableData;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.util.Assert;
+
+/**
+ * Services data needs for BlockedParticipantsFragment
+ */
+public class BlockedParticipantsData extends BindableData implements
+ LoaderManager.LoaderCallbacks<Cursor> {
+ public interface BlockedParticipantsDataListener {
+ public void onBlockedParticipantsCursorUpdated(final Cursor cursor);
+ }
+ private static final String BINDING_ID = "bindingId";
+ private static final int BLOCKED_PARTICIPANTS_LOADER = 1;
+ private final Context mContext;
+ private LoaderManager mLoaderManager;
+ private BlockedParticipantsDataListener mListener;
+
+ public BlockedParticipantsData(final Context context,
+ final BlockedParticipantsDataListener listener) {
+ mContext = context;
+ mListener = listener;
+ }
+
+ @Override
+ public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
+ Assert.isTrue(id == BLOCKED_PARTICIPANTS_LOADER);
+ final String bindingId = args.getString(BINDING_ID);
+ // Check if data still bound to the requesting ui element
+ if (isBound(bindingId)) {
+ final Uri uri = MessagingContentProvider.PARTICIPANTS_URI;
+ return new BoundCursorLoader(bindingId, mContext, uri,
+ ParticipantData.ParticipantsQuery.PROJECTION,
+ ParticipantColumns.BLOCKED + "=1", null, null);
+ }
+ return null;
+ }
+
+ @Override
+ public void onLoadFinished(final Loader<Cursor> loader, final Cursor cursor) {
+ Assert.isTrue(loader.getId() == BLOCKED_PARTICIPANTS_LOADER);
+ final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
+ Assert.isTrue(isBound(cursorLoader.getBindingId()));
+ mListener.onBlockedParticipantsCursorUpdated(cursor);
+ }
+
+ @Override
+ public void onLoaderReset(final Loader<Cursor> loader) {
+ Assert.isTrue(loader.getId() == BLOCKED_PARTICIPANTS_LOADER);
+ final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
+ Assert.isTrue(isBound(cursorLoader.getBindingId()));
+ mListener.onBlockedParticipantsCursorUpdated(null);
+ }
+
+ public void init(final LoaderManager loaderManager,
+ final BindingBase<BlockedParticipantsData> binding) {
+ final Bundle args = new Bundle();
+ args.putString(BINDING_ID, binding.getBindingId());
+ mLoaderManager = loaderManager;
+ mLoaderManager.initLoader(BLOCKED_PARTICIPANTS_LOADER, args, this);
+ }
+
+ @Override
+ protected void unregisterListeners() {
+ mListener = null;
+ if (mLoaderManager != null) {
+ mLoaderManager.destroyLoader(BLOCKED_PARTICIPANTS_LOADER);
+ mLoaderManager = null;
+ }
+ }
+
+ public ParticipantListItemData createParticipantListItemData(Cursor cursor) {
+ return new ParticipantListItemData(ParticipantData.getFromCursor(cursor));
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/ContactListItemData.java b/src/com/android/messaging/datamodel/data/ContactListItemData.java
new file mode 100644
index 0000000..dcc7e20
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/ContactListItemData.java
@@ -0,0 +1,160 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract.DisplayNameSources;
+
+import com.android.ex.chips.RecipientEntry;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContactRecipientEntryUtils;
+import com.android.messaging.util.ContactUtil;
+
+/**
+ * Data model object used to power ContactListItemViews, which may be displayed either in
+ * our contact list, or in the chips UI search drop down presented by ContactDropdownLayouter.
+ */
+public class ContactListItemData {
+ // Keeps the contact data in the form of RecipientEntry that RecipientEditTextView can
+ // directly use.
+ private RecipientEntry mRecipientEntry;
+
+ private CharSequence mStyledName;
+ private CharSequence mStyledDestination;
+
+ // If this contact is the first in the list for its first letter, then this will be the
+ // first letter, otherwise this is null.
+ private String mAlphabetHeader;
+
+ // Is the contact the only item in the list (happens when the user clicks on an
+ // existing chip for which we show full contact detail for the selected contact).
+ private boolean mSingleRecipient;
+
+ /**
+ * Bind to a contact cursor in the contact list.
+ */
+ public void bind(final Cursor cursor, final String alphabetHeader) {
+ final long dataId = cursor.getLong(ContactUtil.INDEX_DATA_ID);
+ final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID);
+ final String lookupKey = cursor.getString(ContactUtil.INDEX_LOOKUP_KEY);
+ final String displayName = cursor.getString(ContactUtil.INDEX_DISPLAY_NAME);
+ final String photoThumbnailUri = cursor.getString(ContactUtil.INDEX_PHOTO_URI);
+ final String destination = cursor.getString(ContactUtil.INDEX_PHONE_EMAIL);
+ final int destinationType = cursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE);
+ final String destinationLabel = cursor.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL);
+ mStyledName = null;
+ mStyledDestination = null;
+ mAlphabetHeader = alphabetHeader;
+ mSingleRecipient = false;
+
+ // Check whether this contact is first level (i.e. whether it's the first entry of this
+ // contact in the contact list).
+ boolean isFirstLevel = true;
+ if (!cursor.isFirst() && cursor.moveToPrevious()) {
+ final long contactIdPrevious = cursor.getLong(ContactUtil.INDEX_CONTACT_ID);
+ if (contactId == contactIdPrevious) {
+ isFirstLevel = false;
+ }
+ cursor.moveToNext();
+ }
+
+ mRecipientEntry = ContactUtil.createRecipientEntry(displayName,
+ DisplayNameSources.STRUCTURED_NAME, destination, destinationType, destinationLabel,
+ contactId, lookupKey, dataId, photoThumbnailUri, isFirstLevel);
+ }
+
+ /**
+ * Bind to a RecipientEntry produced by the chips text view in the search drop down, plus
+ * optional styled name & destination for showing bold search match.
+ */
+ public void bind(final RecipientEntry entry, final CharSequence styledName,
+ final CharSequence styledDestination, final boolean singleRecipient) {
+ Assert.isTrue(entry.isValid());
+ mRecipientEntry = entry;
+ mStyledName = styledName;
+ mStyledDestination = styledDestination;
+ mAlphabetHeader = null;
+ mSingleRecipient = singleRecipient;
+ }
+
+ public CharSequence getDisplayName() {
+ final CharSequence displayName = mStyledName != null ? mStyledName :
+ ContactRecipientEntryUtils.getDisplayNameForContactList(mRecipientEntry);
+ return displayName == null ? "" : displayName;
+ }
+
+ public Uri getPhotoThumbnailUri() {
+ return mRecipientEntry.getPhotoThumbnailUri() == null ? null :
+ mRecipientEntry.getPhotoThumbnailUri();
+ }
+
+ public CharSequence getDestination() {
+ final CharSequence destination = mStyledDestination != null ?
+ mStyledDestination : ContactRecipientEntryUtils.formatDestination(mRecipientEntry);
+ return destination == null ? "" : destination;
+ }
+
+ public int getDestinationType() {
+ return mRecipientEntry.getDestinationType();
+ }
+
+ public String getDestinationLabel() {
+ return mRecipientEntry.getDestinationLabel();
+ }
+
+ public long getContactId() {
+ return mRecipientEntry.getContactId();
+ }
+
+ public String getLookupKey() {
+ return mRecipientEntry.getLookupKey();
+ }
+
+ /**
+ * Returns if this item is "first-level," i.e. whether it's the first entry of the contact
+ * that it represents in the list. For example, if John Smith has 3 different phone numbers,
+ * then the first number is considered first-level, while the other two are considered
+ * second-level.
+ */
+ public boolean getIsFirstLevel() {
+ // Treat the item as first level if it's a top-level recipient entry, or if it's the only
+ // item in the list.
+ return mRecipientEntry.isFirstLevel() || mSingleRecipient;
+ }
+
+ /**
+ * Returns if this item is simple, i.e. it has only avatar and a display name with phone number
+ * embedded so we can hide everything else.
+ */
+ public boolean getIsSimpleContactItem() {
+ return ContactRecipientEntryUtils.isAvatarAndNumberOnlyContact(mRecipientEntry) ||
+ ContactRecipientEntryUtils.isSendToDestinationContact(mRecipientEntry);
+ }
+
+ public String getAlphabetHeader() {
+ return mAlphabetHeader;
+ }
+
+ /**
+ * Returns a RecipientEntry instance readily usable by the RecipientEditTextView.
+ */
+ public RecipientEntry getRecipientEntry() {
+ return mRecipientEntry;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/ContactPickerData.java b/src/com/android/messaging/datamodel/data/ContactPickerData.java
new file mode 100644
index 0000000..fd6fca0
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/ContactPickerData.java
@@ -0,0 +1,194 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.app.LoaderManager;
+import android.content.Context;
+import android.content.Loader;
+import android.database.Cursor;
+import android.os.Bundle;
+
+import com.android.messaging.datamodel.BoundCursorLoader;
+import com.android.messaging.datamodel.FrequentContactsCursorBuilder;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.binding.BindableData;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContactUtil;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Class to access phone contacts.
+ * The caller is responsible for ensuring that the app has READ_CONTACTS permission (see
+ * {@link ContactUtil#hasReadContactsPermission()}) before instantiating this class.
+ */
+public class ContactPickerData extends BindableData implements
+ LoaderManager.LoaderCallbacks<Cursor> {
+ public interface ContactPickerDataListener {
+ void onAllContactsCursorUpdated(Cursor data);
+ void onFrequentContactsCursorUpdated(Cursor data);
+ void onContactCustomColorLoaded(ContactPickerData data);
+ }
+
+ private static final String BINDING_ID = "bindingId";
+ private final Context mContext;
+ private LoaderManager mLoaderManager;
+ private ContactPickerDataListener mListener;
+ private final FrequentContactsCursorBuilder mFrequentContactsCursorBuilder;
+
+ public ContactPickerData(final Context context, final ContactPickerDataListener listener) {
+ mListener = listener;
+ mContext = context;
+ mFrequentContactsCursorBuilder = new FrequentContactsCursorBuilder();
+ }
+
+ private static final int ALL_CONTACTS_LOADER = 1;
+ private static final int FREQUENT_CONTACTS_LOADER = 2;
+ private static final int PARTICIPANT_LOADER = 3;
+
+ @Override
+ public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
+ final String bindingId = args.getString(BINDING_ID);
+ // Check if data still bound to the requesting ui element
+ if (isBound(bindingId)) {
+ switch (id) {
+ case ALL_CONTACTS_LOADER:
+ return ContactUtil.getPhones(mContext)
+ .createBoundCursorLoader(bindingId);
+ case FREQUENT_CONTACTS_LOADER:
+ return ContactUtil.getFrequentContacts(mContext)
+ .createBoundCursorLoader(bindingId);
+ case PARTICIPANT_LOADER:
+ return new BoundCursorLoader(bindingId, mContext,
+ MessagingContentProvider.PARTICIPANTS_URI,
+ ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
+ default:
+ Assert.fail("Unknown loader id for contact picker!");
+ break;
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding the contacts list");
+ }
+ return null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onLoadFinished(final Loader<Cursor> loader, final Cursor data) {
+ final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
+ if (isBound(cursorLoader.getBindingId())) {
+ switch (loader.getId()) {
+ case ALL_CONTACTS_LOADER:
+ mListener.onAllContactsCursorUpdated(data);
+ mFrequentContactsCursorBuilder.setAllContacts(data);
+ break;
+ case FREQUENT_CONTACTS_LOADER:
+ mFrequentContactsCursorBuilder.setFrequents(data);
+ break;
+ case PARTICIPANT_LOADER:
+ mListener.onContactCustomColorLoaded(this);
+ break;
+ default:
+ Assert.fail("Unknown loader id for contact picker!");
+ break;
+ }
+
+ if (loader.getId() != PARTICIPANT_LOADER) {
+ // The frequent contacts cursor to be used in the UI depends on results from both
+ // all contacts and frequent contacts loader, and we don't know which will finish
+ // first. Therefore, try to build the cursor and notify the listener if it's
+ // successfully built.
+ final Cursor frequentContactsCursor = mFrequentContactsCursorBuilder.build();
+ if (frequentContactsCursor != null) {
+ mListener.onFrequentContactsCursorUpdated(frequentContactsCursor);
+ }
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding the contacts list");
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onLoaderReset(final Loader<Cursor> loader) {
+ final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
+ if (isBound(cursorLoader.getBindingId())) {
+ switch (loader.getId()) {
+ case ALL_CONTACTS_LOADER:
+ mListener.onAllContactsCursorUpdated(null);
+ mFrequentContactsCursorBuilder.setAllContacts(null);
+ break;
+ case FREQUENT_CONTACTS_LOADER:
+ mListener.onFrequentContactsCursorUpdated(null);
+ mFrequentContactsCursorBuilder.setFrequents(null);
+ break;
+ case PARTICIPANT_LOADER:
+ mListener.onContactCustomColorLoaded(this);
+ break;
+ default:
+ Assert.fail("Unknown loader id for contact picker!");
+ break;
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding the contacts list");
+ }
+ }
+
+ public void init(final LoaderManager loaderManager,
+ final BindingBase<ContactPickerData> binding) {
+ final Bundle args = new Bundle();
+ args.putString(BINDING_ID, binding.getBindingId());
+ mLoaderManager = loaderManager;
+ mLoaderManager.initLoader(ALL_CONTACTS_LOADER, args, this);
+ mLoaderManager.initLoader(FREQUENT_CONTACTS_LOADER, args, this);
+ mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this);
+ }
+
+ @Override
+ protected void unregisterListeners() {
+ mListener = null;
+
+
+ // This could be null if we bind but the caller doesn't init the BindableData
+ if (mLoaderManager != null) {
+ mLoaderManager.destroyLoader(ALL_CONTACTS_LOADER);
+ mLoaderManager.destroyLoader(FREQUENT_CONTACTS_LOADER);
+ mLoaderManager.destroyLoader(PARTICIPANT_LOADER);
+ mLoaderManager = null;
+ }
+ mFrequentContactsCursorBuilder.resetBuilder();
+ }
+
+ public static boolean isTooManyParticipants(final int participantCount) {
+ // When creating a conversation, the conversation will be created using the system's
+ // default SIM, so use the default MmsConfig's recipient limit.
+ return (participantCount > MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID)
+ .getRecipientLimit());
+ }
+
+ public static boolean getCanAddMoreParticipants(final int participantCount) {
+ // When creating a conversation, the conversation will be created using the system's
+ // default SIM, so use the default MmsConfig's recipient limit.
+ return (participantCount < MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID)
+ .getRecipientLimit());
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/ConversationData.java b/src/com/android/messaging/datamodel/data/ConversationData.java
new file mode 100644
index 0000000..d504928
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/ConversationData.java
@@ -0,0 +1,849 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.app.LoaderManager;
+import android.content.Context;
+import android.content.Loader;
+import android.database.Cursor;
+import android.database.CursorWrapper;
+import android.database.sqlite.SQLiteFullException;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+
+import com.android.common.contacts.DataUsageStatUpdater;
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.BoundCursorLoader;
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.action.DeleteConversationAction;
+import com.android.messaging.datamodel.action.DeleteMessageAction;
+import com.android.messaging.datamodel.action.InsertNewMessageAction;
+import com.android.messaging.datamodel.action.RedownloadMmsAction;
+import com.android.messaging.datamodel.action.ResendMessageAction;
+import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction;
+import com.android.messaging.datamodel.binding.BindableData;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.sms.MmsSmsUtils;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.RunsOnMainThread;
+import com.android.messaging.util.ContactUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.SafeAsyncTask;
+import com.android.messaging.widget.WidgetConversationProvider;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class ConversationData extends BindableData {
+
+ private static final String TAG = "bugle_datamodel";
+ private static final String BINDING_ID = "bindingId";
+ private static final long LAST_MESSAGE_TIMESTAMP_NaN = -1;
+ private static final int MESSAGE_COUNT_NaN = -1;
+
+ /**
+ * Takes a conversation id and a list of message ids and computes the positions
+ * for each message.
+ */
+ public List<Integer> getPositions(final String conversationId, final List<Long> ids) {
+ final ArrayList<Integer> result = new ArrayList<Integer>();
+
+ if (ids.isEmpty()) {
+ return result;
+ }
+
+ final Cursor c = new ConversationData.ReversedCursor(
+ DataModel.get().getDatabase().rawQuery(
+ ConversationMessageData.getConversationMessageIdsQuerySql(),
+ new String [] { conversationId }));
+ if (c != null) {
+ try {
+ final Set<Long> idsSet = new HashSet<Long>(ids);
+ if (c.moveToLast()) {
+ do {
+ final long messageId = c.getLong(0);
+ if (idsSet.contains(messageId)) {
+ result.add(c.getPosition());
+ }
+ } while (c.moveToPrevious());
+ }
+ } finally {
+ c.close();
+ }
+ }
+ Collections.sort(result);
+ return result;
+ }
+
+ public interface ConversationDataListener {
+ public void onConversationMessagesCursorUpdated(ConversationData data, Cursor cursor,
+ @Nullable ConversationMessageData newestMessage, boolean isSync);
+ public void onConversationMetadataUpdated(ConversationData data);
+ public void closeConversation(String conversationId);
+ public void onConversationParticipantDataLoaded(ConversationData data);
+ public void onSubscriptionListDataLoaded(ConversationData data);
+ }
+
+ private static class ReversedCursor extends CursorWrapper {
+ final int mCount;
+
+ public ReversedCursor(final Cursor cursor) {
+ super(cursor);
+ mCount = cursor.getCount();
+ }
+
+ @Override
+ public boolean moveToPosition(final int position) {
+ return super.moveToPosition(mCount - position - 1);
+ }
+
+ @Override
+ public int getPosition() {
+ return mCount - super.getPosition() - 1;
+ }
+
+ @Override
+ public boolean isAfterLast() {
+ return super.isBeforeFirst();
+ }
+
+ @Override
+ public boolean isBeforeFirst() {
+ return super.isAfterLast();
+ }
+
+ @Override
+ public boolean isFirst() {
+ return super.isLast();
+ }
+
+ @Override
+ public boolean isLast() {
+ return super.isFirst();
+ }
+
+ @Override
+ public boolean move(final int offset) {
+ return super.move(-offset);
+ }
+
+ @Override
+ public boolean moveToFirst() {
+ return super.moveToLast();
+ }
+
+ @Override
+ public boolean moveToLast() {
+ return super.moveToFirst();
+ }
+
+ @Override
+ public boolean moveToNext() {
+ return super.moveToPrevious();
+ }
+
+ @Override
+ public boolean moveToPrevious() {
+ return super.moveToNext();
+ }
+ }
+
+ /**
+ * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times.
+ */
+ private class MetadataLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
+ Assert.equals(CONVERSATION_META_DATA_LOADER, id);
+ Loader<Cursor> loader = null;
+
+ final String bindingId = args.getString(BINDING_ID);
+ // Check if data still bound to the requesting ui element
+ if (isBound(bindingId)) {
+ final Uri uri =
+ MessagingContentProvider.buildConversationMetadataUri(mConversationId);
+ loader = new BoundCursorLoader(bindingId, mContext, uri,
+ ConversationListItemData.PROJECTION, null, null, null);
+ } else {
+ LogUtil.w(TAG, "Creating messages loader after unbinding mConversationId = " +
+ mConversationId);
+ }
+ return loader;
+ }
+
+ @Override
+ public void onLoadFinished(final Loader<Cursor> generic, final Cursor data) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+
+ // Check if data still bound to the requesting ui element
+ if (isBound(loader.getBindingId())) {
+ if (data.moveToNext()) {
+ Assert.isTrue(data.getCount() == 1);
+ mConversationMetadata.bind(data);
+ mListeners.onConversationMetadataUpdated(ConversationData.this);
+ } else {
+ // Close the conversation, no meta data means conversation was deleted
+ LogUtil.w(TAG, "Meta data loader returned nothing for mConversationId = " +
+ mConversationId);
+ mListeners.closeConversation(mConversationId);
+ // Notify the widget the conversation is deleted so it can go into its
+ // configure state.
+ WidgetConversationProvider.notifyConversationDeleted(
+ Factory.get().getApplicationContext(),
+ mConversationId);
+ }
+ } else {
+ LogUtil.w(TAG, "Meta data loader finished after unbinding mConversationId = " +
+ mConversationId);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(final Loader<Cursor> generic) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+
+ // Check if data still bound to the requesting ui element
+ if (isBound(loader.getBindingId())) {
+ // Clear the conversation meta data
+ mConversationMetadata = new ConversationListItemData();
+ mListeners.onConversationMetadataUpdated(ConversationData.this);
+ } else {
+ LogUtil.w(TAG, "Meta data loader reset after unbinding mConversationId = " +
+ mConversationId);
+ }
+ }
+ }
+
+ /**
+ * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times.
+ */
+ private class MessagesLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
+ Assert.equals(CONVERSATION_MESSAGES_LOADER, id);
+ Loader<Cursor> loader = null;
+
+ final String bindingId = args.getString(BINDING_ID);
+ // Check if data still bound to the requesting ui element
+ if (isBound(bindingId)) {
+ final Uri uri =
+ MessagingContentProvider.buildConversationMessagesUri(mConversationId);
+ loader = new BoundCursorLoader(bindingId, mContext, uri,
+ ConversationMessageData.getProjection(), null, null, null);
+ mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN;
+ mMessageCount = MESSAGE_COUNT_NaN;
+ } else {
+ LogUtil.w(TAG, "Creating messages loader after unbinding mConversationId = " +
+ mConversationId);
+ }
+ return loader;
+ }
+
+ @Override
+ public void onLoadFinished(final Loader<Cursor> generic, final Cursor rawData) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+
+ // Check if data still bound to the requesting ui element
+ if (isBound(loader.getBindingId())) {
+ // Check if we have a new message, or if we had a message sync.
+ ConversationMessageData newMessage = null;
+ boolean isSync = false;
+ Cursor data = null;
+ if (rawData != null) {
+ // Note that the cursor is sorted DESC so here we reverse it.
+ // This is a performance issue (improvement) for large cursors.
+ data = new ReversedCursor(rawData);
+
+ final int messageCountOld = mMessageCount;
+ mMessageCount = data.getCount();
+ final ConversationMessageData lastMessage = getLastMessage(data);
+ if (lastMessage != null) {
+ final long lastMessageTimestampOld = mLastMessageTimestamp;
+ mLastMessageTimestamp = lastMessage.getReceivedTimeStamp();
+ final String lastMessageIdOld = mLastMessageId;
+ mLastMessageId = lastMessage.getMessageId();
+ if (TextUtils.equals(lastMessageIdOld, mLastMessageId) &&
+ messageCountOld < mMessageCount) {
+ // Last message stays the same (no incoming message) but message
+ // count increased, which means there has been a message sync.
+ isSync = true;
+ } else if (messageCountOld != MESSAGE_COUNT_NaN && // Ignore initial load
+ mLastMessageTimestamp != LAST_MESSAGE_TIMESTAMP_NaN &&
+ mLastMessageTimestamp > lastMessageTimestampOld) {
+ newMessage = lastMessage;
+ }
+ } else {
+ mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN;
+ }
+ } else {
+ mMessageCount = MESSAGE_COUNT_NaN;
+ }
+
+ mListeners.onConversationMessagesCursorUpdated(ConversationData.this, data,
+ newMessage, isSync);
+ } else {
+ LogUtil.w(TAG, "Messages loader finished after unbinding mConversationId = " +
+ mConversationId);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(final Loader<Cursor> generic) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+
+ // Check if data still bound to the requesting ui element
+ if (isBound(loader.getBindingId())) {
+ mListeners.onConversationMessagesCursorUpdated(ConversationData.this, null, null,
+ false);
+ mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN;
+ mMessageCount = MESSAGE_COUNT_NaN;
+ } else {
+ LogUtil.w(TAG, "Messages loader reset after unbinding mConversationId = " +
+ mConversationId);
+ }
+ }
+
+ private ConversationMessageData getLastMessage(final Cursor cursor) {
+ if (cursor != null && cursor.getCount() > 0) {
+ final int position = cursor.getPosition();
+ if (cursor.moveToLast()) {
+ final ConversationMessageData messageData = new ConversationMessageData();
+ messageData.bind(cursor);
+ cursor.move(position);
+ return messageData;
+ }
+ }
+ return null;
+ }
+ }
+
+ /**
+ * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times.
+ */
+ private class ParticipantLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
+ Assert.equals(PARTICIPANT_LOADER, id);
+ Loader<Cursor> loader = null;
+
+ final String bindingId = args.getString(BINDING_ID);
+ // Check if data still bound to the requesting ui element
+ if (isBound(bindingId)) {
+ final Uri uri =
+ MessagingContentProvider.buildConversationParticipantsUri(mConversationId);
+ loader = new BoundCursorLoader(bindingId, mContext, uri,
+ ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
+ } else {
+ LogUtil.w(TAG, "Creating participant loader after unbinding mConversationId = " +
+ mConversationId);
+ }
+ return loader;
+ }
+
+ @Override
+ public void onLoadFinished(final Loader<Cursor> generic, final Cursor data) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+
+ // Check if data still bound to the requesting ui element
+ if (isBound(loader.getBindingId())) {
+ mParticipantData.bind(data);
+ mListeners.onConversationParticipantDataLoaded(ConversationData.this);
+ } else {
+ LogUtil.w(TAG, "Participant loader finished after unbinding mConversationId = " +
+ mConversationId);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(final Loader<Cursor> generic) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+
+ // Check if data still bound to the requesting ui element
+ if (isBound(loader.getBindingId())) {
+ mParticipantData.bind(null);
+ } else {
+ LogUtil.w(TAG, "Participant loader reset after unbinding mConversationId = " +
+ mConversationId);
+ }
+ }
+ }
+
+ /**
+ * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times.
+ */
+ private class SelfParticipantLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
+ Assert.equals(SELF_PARTICIPANT_LOADER, id);
+ Loader<Cursor> loader = null;
+
+ final String bindingId = args.getString(BINDING_ID);
+ // Check if data still bound to the requesting ui element
+ if (isBound(bindingId)) {
+ loader = new BoundCursorLoader(bindingId, mContext,
+ MessagingContentProvider.PARTICIPANTS_URI,
+ ParticipantData.ParticipantsQuery.PROJECTION,
+ ParticipantColumns.SUB_ID + " <> ?",
+ new String[] { String.valueOf(ParticipantData.OTHER_THAN_SELF_SUB_ID) },
+ null);
+ } else {
+ LogUtil.w(TAG, "Creating self loader after unbinding mConversationId = " +
+ mConversationId);
+ }
+ return loader;
+ }
+
+ @Override
+ public void onLoadFinished(final Loader<Cursor> generic, final Cursor data) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+
+ // Check if data still bound to the requesting ui element
+ if (isBound(loader.getBindingId())) {
+ mSelfParticipantsData.bind(data);
+ mSubscriptionListData.bind(mSelfParticipantsData.getSelfParticipants(true));
+ mListeners.onSubscriptionListDataLoaded(ConversationData.this);
+ } else {
+ LogUtil.w(TAG, "Self loader finished after unbinding mConversationId = " +
+ mConversationId);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(final Loader<Cursor> generic) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+
+ // Check if data still bound to the requesting ui element
+ if (isBound(loader.getBindingId())) {
+ mSelfParticipantsData.bind(null);
+ } else {
+ LogUtil.w(TAG, "Self loader reset after unbinding mConversationId = " +
+ mConversationId);
+ }
+ }
+ }
+
+ private final ConversationDataEventDispatcher mListeners;
+ private final MetadataLoaderCallbacks mMetadataLoaderCallbacks;
+ private final MessagesLoaderCallbacks mMessagesLoaderCallbacks;
+ private final ParticipantLoaderCallbacks mParticipantsLoaderCallbacks;
+ private final SelfParticipantLoaderCallbacks mSelfParticipantLoaderCallbacks;
+ private final Context mContext;
+ private final String mConversationId;
+ private final ConversationParticipantsData mParticipantData;
+ private final SelfParticipantsData mSelfParticipantsData;
+ private ConversationListItemData mConversationMetadata;
+ private final SubscriptionListData mSubscriptionListData;
+ private LoaderManager mLoaderManager;
+ private long mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN;
+ private int mMessageCount = MESSAGE_COUNT_NaN;
+ private String mLastMessageId;
+
+ public ConversationData(final Context context, final ConversationDataListener listener,
+ final String conversationId) {
+ Assert.isTrue(conversationId != null);
+ mContext = context;
+ mConversationId = conversationId;
+ mMetadataLoaderCallbacks = new MetadataLoaderCallbacks();
+ mMessagesLoaderCallbacks = new MessagesLoaderCallbacks();
+ mParticipantsLoaderCallbacks = new ParticipantLoaderCallbacks();
+ mSelfParticipantLoaderCallbacks = new SelfParticipantLoaderCallbacks();
+ mParticipantData = new ConversationParticipantsData();
+ mConversationMetadata = new ConversationListItemData();
+ mSelfParticipantsData = new SelfParticipantsData();
+ mSubscriptionListData = new SubscriptionListData(context);
+
+ mListeners = new ConversationDataEventDispatcher();
+ mListeners.add(listener);
+ }
+
+ @RunsOnMainThread
+ public void addConversationDataListener(final ConversationDataListener listener) {
+ Assert.isMainThread();
+ mListeners.add(listener);
+ }
+
+ public String getConversationName() {
+ return mConversationMetadata.getName();
+ }
+
+ public boolean getIsArchived() {
+ return mConversationMetadata.getIsArchived();
+ }
+
+ public String getIcon() {
+ return mConversationMetadata.getIcon();
+ }
+
+ public String getConversationId() {
+ return mConversationId;
+ }
+
+ public void setFocus() {
+ DataModel.get().setFocusedConversation(mConversationId);
+ // As we are loading the conversation assume the user has read the messages...
+ // Do this late though so that it doesn't get in the way of other actions
+ BugleNotifications.markMessagesAsRead(mConversationId);
+ }
+
+ public void unsetFocus() {
+ DataModel.get().setFocusedConversation(null);
+ }
+
+ public boolean isFocused() {
+ return isBound() && DataModel.get().isFocusedConversation(mConversationId);
+ }
+
+ private static final int CONVERSATION_META_DATA_LOADER = 1;
+ private static final int CONVERSATION_MESSAGES_LOADER = 2;
+ private static final int PARTICIPANT_LOADER = 3;
+ private static final int SELF_PARTICIPANT_LOADER = 4;
+
+ public void init(final LoaderManager loaderManager,
+ final BindingBase<ConversationData> binding) {
+ // Remember the binding id so that loader callbacks can check if data is still bound
+ // to same ui component
+ final Bundle args = new Bundle();
+ args.putString(BINDING_ID, binding.getBindingId());
+ mLoaderManager = loaderManager;
+ mLoaderManager.initLoader(CONVERSATION_META_DATA_LOADER, args, mMetadataLoaderCallbacks);
+ mLoaderManager.initLoader(CONVERSATION_MESSAGES_LOADER, args, mMessagesLoaderCallbacks);
+ mLoaderManager.initLoader(PARTICIPANT_LOADER, args, mParticipantsLoaderCallbacks);
+ mLoaderManager.initLoader(SELF_PARTICIPANT_LOADER, args, mSelfParticipantLoaderCallbacks);
+ }
+
+ @Override
+ protected void unregisterListeners() {
+ mListeners.clear();
+ // Make sure focus has moved away from this conversation
+ // TODO: May false trigger if destroy happens after "new" conversation is focused.
+ // Assert.isTrue(!DataModel.get().isFocusedConversation(mConversationId));
+
+ // This could be null if we bind but the caller doesn't init the BindableData
+ if (mLoaderManager != null) {
+ mLoaderManager.destroyLoader(CONVERSATION_META_DATA_LOADER);
+ mLoaderManager.destroyLoader(CONVERSATION_MESSAGES_LOADER);
+ mLoaderManager.destroyLoader(PARTICIPANT_LOADER);
+ mLoaderManager.destroyLoader(SELF_PARTICIPANT_LOADER);
+ mLoaderManager = null;
+ }
+ }
+
+ /**
+ * Gets the default self participant in the participant table (NOT the conversation's self).
+ * This is available as soon as self participant data is loaded.
+ */
+ public ParticipantData getDefaultSelfParticipant() {
+ return mSelfParticipantsData.getDefaultSelfParticipant();
+ }
+
+ public List<ParticipantData> getSelfParticipants(final boolean activeOnly) {
+ return mSelfParticipantsData.getSelfParticipants(activeOnly);
+ }
+
+ public int getSelfParticipantsCountExcludingDefault(final boolean activeOnly) {
+ return mSelfParticipantsData.getSelfParticipantsCountExcludingDefault(activeOnly);
+ }
+
+ public ParticipantData getSelfParticipantById(final String selfId) {
+ return mSelfParticipantsData.getSelfParticipantById(selfId);
+ }
+
+ /**
+ * For a 1:1 conversation return the other (not self) participant (else null)
+ */
+ public ParticipantData getOtherParticipant() {
+ return mParticipantData.getOtherParticipant();
+ }
+
+ /**
+ * Return true once the participants are loaded
+ */
+ public boolean getParticipantsLoaded() {
+ return mParticipantData.isLoaded();
+ }
+
+ public void sendMessage(final BindingBase<ConversationData> binding,
+ final MessageData message) {
+ Assert.isTrue(TextUtils.equals(mConversationId, message.getConversationId()));
+ Assert.isTrue(binding.getData() == this);
+
+ if (!OsUtil.isAtLeastL_MR1() || message.getSelfId() == null) {
+ InsertNewMessageAction.insertNewMessage(message);
+ } else {
+ final int systemDefaultSubId = PhoneUtils.getDefault().getDefaultSmsSubscriptionId();
+ if (systemDefaultSubId != ParticipantData.DEFAULT_SELF_SUB_ID &&
+ mSelfParticipantsData.isDefaultSelf(message.getSelfId())) {
+ // Lock the sub selection to the system default SIM as soon as the user clicks on
+ // the send button to avoid races between this and when InsertNewMessageAction is
+ // actually executed on the data model thread, during which the user can potentially
+ // change the system default SIM in Settings.
+ InsertNewMessageAction.insertNewMessage(message, systemDefaultSubId);
+ } else {
+ InsertNewMessageAction.insertNewMessage(message);
+ }
+ }
+ // Update contacts so Frequents will reflect messaging activity.
+ if (!getParticipantsLoaded()) {
+ return; // oh well, not critical
+ }
+ final ArrayList<String> phones = new ArrayList<>();
+ final ArrayList<String> emails = new ArrayList<>();
+ for (final ParticipantData participant : mParticipantData) {
+ if (!participant.isSelf()) {
+ if (participant.isEmail()) {
+ emails.add(participant.getSendDestination());
+ } else {
+ phones.add(participant.getSendDestination());
+ }
+ }
+ }
+
+ if (ContactUtil.hasReadContactsPermission()) {
+ SafeAsyncTask.executeOnThreadPool(new Runnable() {
+ @Override
+ public void run() {
+ final DataUsageStatUpdater updater = new DataUsageStatUpdater(
+ Factory.get().getApplicationContext());
+ try {
+ if (!phones.isEmpty()) {
+ updater.updateWithPhoneNumber(phones);
+ }
+ if (!emails.isEmpty()) {
+ updater.updateWithAddress(emails);
+ }
+ } catch (final SQLiteFullException ex) {
+ LogUtil.w(TAG, "Unable to update contact", ex);
+ }
+ }
+ });
+ }
+ }
+
+ public void downloadMessage(final BindingBase<ConversationData> binding,
+ final String messageId) {
+ Assert.isTrue(binding.getData() == this);
+ Assert.notNull(messageId);
+ RedownloadMmsAction.redownloadMessage(messageId);
+ }
+
+ public void resendMessage(final BindingBase<ConversationData> binding, final String messageId) {
+ Assert.isTrue(binding.getData() == this);
+ Assert.notNull(messageId);
+ ResendMessageAction.resendMessage(messageId);
+ }
+
+ public void deleteMessage(final BindingBase<ConversationData> binding, final String messageId) {
+ Assert.isTrue(binding.getData() == this);
+ Assert.notNull(messageId);
+ DeleteMessageAction.deleteMessage(messageId);
+ }
+
+ public void deleteConversation(final Binding<ConversationData> binding) {
+ Assert.isTrue(binding.getData() == this);
+ // If possible use timestamp of last message shown to delete only messages user is aware of
+ if (mConversationMetadata == null) {
+ DeleteConversationAction.deleteConversation(mConversationId,
+ System.currentTimeMillis());
+ } else {
+ mConversationMetadata.deleteConversation();
+ }
+ }
+
+ public void archiveConversation(final BindingBase<ConversationData> binding) {
+ Assert.isTrue(binding.getData() == this);
+ UpdateConversationArchiveStatusAction.archiveConversation(mConversationId);
+ }
+
+ public void unarchiveConversation(final BindingBase<ConversationData> binding) {
+ Assert.isTrue(binding.getData() == this);
+ UpdateConversationArchiveStatusAction.unarchiveConversation(mConversationId);
+ }
+
+ public ConversationParticipantsData getParticipants() {
+ return mParticipantData;
+ }
+
+ /**
+ * Returns a dialable phone number for the participant if we are in a 1-1 conversation.
+ * @return the participant phone number, or null if the phone number is not valid or if there
+ * are more than one participant.
+ */
+ public String getParticipantPhoneNumber() {
+ final ParticipantData participant = this.getOtherParticipant();
+ if (participant != null) {
+ final String phoneNumber = participant.getSendDestination();
+ if (!TextUtils.isEmpty(phoneNumber) && MmsSmsUtils.isPhoneNumber(phoneNumber)) {
+ return phoneNumber;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Create a message to be forwarded from an existing message.
+ */
+ public MessageData createForwardedMessage(final ConversationMessageData message) {
+ final MessageData forwardedMessage = new MessageData();
+
+ final String originalSubject =
+ MmsUtils.cleanseMmsSubject(mContext.getResources(), message.getMmsSubject());
+ if (!TextUtils.isEmpty(originalSubject)) {
+ forwardedMessage.setMmsSubject(
+ mContext.getResources().getString(R.string.message_fwd, originalSubject));
+ }
+
+ for (final MessagePartData part : message.getParts()) {
+ MessagePartData forwardedPart;
+
+ // Depending on the part type, if it is text, we can directly create a text part;
+ // if it is attachment, then we need to create a pending attachment data out of it, so
+ // that we may persist the attachment locally in the scratch folder when the user picks
+ // a conversation to forward to.
+ if (part.isText()) {
+ forwardedPart = MessagePartData.createTextMessagePart(part.getText());
+ } else {
+ final PendingAttachmentData pendingAttachmentData = PendingAttachmentData
+ .createPendingAttachmentData(part.getContentType(), part.getContentUri());
+ forwardedPart = pendingAttachmentData;
+ }
+ forwardedMessage.addPart(forwardedPart);
+ }
+ return forwardedMessage;
+ }
+
+ public int getNumberOfParticipantsExcludingSelf() {
+ return mParticipantData.getNumberOfParticipantsExcludingSelf();
+ }
+
+ /**
+ * Returns {@link com.android.messaging.datamodel.data.SubscriptionListData
+ * .SubscriptionListEntry} for a given self participant so UI can display SIM-related info
+ * (icon, name etc.) for multi-SIM.
+ */
+ public SubscriptionListEntry getSubscriptionEntryForSelfParticipant(
+ final String selfParticipantId, final boolean excludeDefault) {
+ return getSubscriptionEntryForSelfParticipant(selfParticipantId, excludeDefault,
+ mSubscriptionListData, mSelfParticipantsData);
+ }
+
+ /**
+ * Returns {@link com.android.messaging.datamodel.data.SubscriptionListData
+ * .SubscriptionListEntry} for a given self participant so UI can display SIM-related info
+ * (icon, name etc.) for multi-SIM.
+ */
+ public static SubscriptionListEntry getSubscriptionEntryForSelfParticipant(
+ final String selfParticipantId, final boolean excludeDefault,
+ final SubscriptionListData subscriptionListData,
+ final SelfParticipantsData selfParticipantsData) {
+ // SIM indicators are shown in the UI only if:
+ // 1. Framework has MSIM support AND
+ // 2. The device has had multiple *active* subscriptions. AND
+ // 3. The message's subscription is active.
+ if (OsUtil.isAtLeastL_MR1() &&
+ selfParticipantsData.getSelfParticipantsCountExcludingDefault(true) > 1) {
+ return subscriptionListData.getActiveSubscriptionEntryBySelfId(selfParticipantId,
+ excludeDefault);
+ }
+ return null;
+ }
+
+ public SubscriptionListData getSubscriptionListData() {
+ return mSubscriptionListData;
+ }
+
+ /**
+ * A dummy implementation of {@link ConversationDataListener} so that subclasses may opt to
+ * implement some, but not all, of the interface methods.
+ */
+ public static class SimpleConversationDataListener implements ConversationDataListener {
+
+ @Override
+ public void onConversationMessagesCursorUpdated(final ConversationData data, final Cursor cursor,
+ @Nullable
+ final
+ ConversationMessageData newestMessage, final boolean isSync) {}
+
+ @Override
+ public void onConversationMetadataUpdated(final ConversationData data) {}
+
+ @Override
+ public void closeConversation(final String conversationId) {}
+
+ @Override
+ public void onConversationParticipantDataLoaded(final ConversationData data) {}
+
+ @Override
+ public void onSubscriptionListDataLoaded(final ConversationData data) {}
+
+ }
+
+ private class ConversationDataEventDispatcher
+ extends ArrayList<ConversationDataListener>
+ implements ConversationDataListener {
+
+ @Override
+ public void onConversationMessagesCursorUpdated(final ConversationData data, final Cursor cursor,
+ @Nullable
+ final ConversationMessageData newestMessage, final boolean isSync) {
+ for (final ConversationDataListener listener : this) {
+ listener.onConversationMessagesCursorUpdated(data, cursor, newestMessage, isSync);
+ }
+ }
+
+ @Override
+ public void onConversationMetadataUpdated(final ConversationData data) {
+ for (final ConversationDataListener listener : this) {
+ listener.onConversationMetadataUpdated(data);
+ }
+ }
+
+ @Override
+ public void closeConversation(final String conversationId) {
+ for (final ConversationDataListener listener : this) {
+ listener.closeConversation(conversationId);
+ }
+ }
+
+ @Override
+ public void onConversationParticipantDataLoaded(final ConversationData data) {
+ for (final ConversationDataListener listener : this) {
+ listener.onConversationParticipantDataLoaded(data);
+ }
+ }
+
+ @Override
+ public void onSubscriptionListDataLoaded(final ConversationData data) {
+ for (final ConversationDataListener listener : this) {
+ listener.onSubscriptionListDataLoaded(data);
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/ConversationListData.java b/src/com/android/messaging/datamodel/data/ConversationListData.java
new file mode 100644
index 0000000..3d27ecd
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/ConversationListData.java
@@ -0,0 +1,211 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.app.LoaderManager;
+import android.content.Context;
+import android.content.Loader;
+import android.database.Cursor;
+import android.os.Bundle;
+
+import com.android.messaging.datamodel.BoundCursorLoader;
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.SyncManager;
+import com.android.messaging.datamodel.binding.BindableData;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.ConversationListItemData.ConversationListViewColumns;
+import com.android.messaging.receiver.SmsReceiver;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+
+import java.util.HashSet;
+
+public class ConversationListData extends BindableData
+ implements LoaderManager.LoaderCallbacks<Cursor> {
+
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+ private static final String BINDING_ID = "bindingId";
+ public static final String SORT_ORDER =
+ ConversationListViewColumns.SORT_TIMESTAMP + " DESC";
+
+ private static final String WHERE_ARCHIVED =
+ "(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 1)";
+ public static final String WHERE_NOT_ARCHIVED =
+ "(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 0)";
+
+ public interface ConversationListDataListener {
+ public void onConversationListCursorUpdated(ConversationListData data, Cursor cursor);
+ public void setBlockedParticipantsAvailable(boolean blockedAvailable);
+ }
+
+ private ConversationListDataListener mListener;
+ private final Context mContext;
+ private final boolean mArchivedMode;
+ private LoaderManager mLoaderManager;
+
+ public ConversationListData(final Context context, final ConversationListDataListener listener,
+ final boolean archivedMode) {
+ mListener = listener;
+ mContext = context;
+ mArchivedMode = archivedMode;
+ }
+
+ private static final int CONVERSATION_LIST_LOADER = 1;
+ private static final int BLOCKED_PARTICIPANTS_AVAILABLE_LOADER = 2;
+
+ private static final String[] BLOCKED_PARTICIPANTS_PROJECTION = new String[] {
+ ParticipantColumns._ID,
+ ParticipantColumns.NORMALIZED_DESTINATION,
+ };
+ private static final int INDEX_BLOCKED_PARTICIPANTS_ID = 0;
+ private static final int INDEX_BLOCKED_PARTICIPANTS_NORMALIZED_DESTINATION = 1;
+
+ // all blocked participants
+ private final HashSet<String> mBlockedParticipants = new HashSet<String>();
+
+ @Override
+ public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
+ final String bindingId = args.getString(BINDING_ID);
+ Loader<Cursor> loader = null;
+ // Check if data still bound to the requesting ui element
+ if (isBound(bindingId)) {
+ switch (id) {
+ case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER:
+ loader = new BoundCursorLoader(bindingId, mContext,
+ MessagingContentProvider.PARTICIPANTS_URI,
+ BLOCKED_PARTICIPANTS_PROJECTION,
+ ParticipantColumns.BLOCKED + "=1", null, null);
+ break;
+ case CONVERSATION_LIST_LOADER:
+ loader = new BoundCursorLoader(bindingId, mContext,
+ MessagingContentProvider.CONVERSATIONS_URI,
+ ConversationListItemData.PROJECTION,
+ mArchivedMode ? WHERE_ARCHIVED : WHERE_NOT_ARCHIVED,
+ null, // selection args
+ SORT_ORDER);
+ break;
+ default:
+ Assert.fail("Unknown loader id");
+ break;
+ }
+ } else {
+ LogUtil.w(TAG, "Creating loader after unbinding list");
+ }
+ return loader;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onLoadFinished(final Loader<Cursor> generic, final Cursor data) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+ if (isBound(loader.getBindingId())) {
+ switch (loader.getId()) {
+ case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER:
+ mBlockedParticipants.clear();
+ for (int i = 0; i < data.getCount(); i++) {
+ data.moveToPosition(i);
+ mBlockedParticipants.add(data.getString(
+ INDEX_BLOCKED_PARTICIPANTS_NORMALIZED_DESTINATION));
+ }
+ mListener.setBlockedParticipantsAvailable(data != null && data.getCount() > 0);
+ break;
+ case CONVERSATION_LIST_LOADER:
+ mListener.onConversationListCursorUpdated(this, data);
+ break;
+ default:
+ Assert.fail("Unknown loader id");
+ break;
+ }
+ } else {
+ LogUtil.w(TAG, "Loader finished after unbinding list");
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onLoaderReset(final Loader<Cursor> generic) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+ if (isBound(loader.getBindingId())) {
+ switch (loader.getId()) {
+ case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER:
+ mListener.setBlockedParticipantsAvailable(false);
+ break;
+ case CONVERSATION_LIST_LOADER:
+ mListener.onConversationListCursorUpdated(this, null);
+ break;
+ default:
+ Assert.fail("Unknown loader id");
+ break;
+ }
+ } else {
+ LogUtil.w(TAG, "Loader reset after unbinding list");
+ }
+ }
+
+ private Bundle mArgs;
+
+ public void init(final LoaderManager loaderManager,
+ final BindingBase<ConversationListData> binding) {
+ mArgs = new Bundle();
+ mArgs.putString(BINDING_ID, binding.getBindingId());
+ mLoaderManager = loaderManager;
+ mLoaderManager.initLoader(CONVERSATION_LIST_LOADER, mArgs, this);
+ mLoaderManager.initLoader(BLOCKED_PARTICIPANTS_AVAILABLE_LOADER, mArgs, this);
+ }
+
+ public void handleMessagesSeen() {
+ BugleNotifications.markAllMessagesAsSeen();
+
+ SmsReceiver.cancelSecondaryUserNotification();
+ }
+
+ @Override
+ protected void unregisterListeners() {
+ mListener = null;
+
+ // This could be null if we bind but the caller doesn't init the BindableData
+ if (mLoaderManager != null) {
+ mLoaderManager.destroyLoader(CONVERSATION_LIST_LOADER);
+ mLoaderManager.destroyLoader(BLOCKED_PARTICIPANTS_AVAILABLE_LOADER);
+ mLoaderManager = null;
+ }
+ }
+
+ public boolean getHasFirstSyncCompleted() {
+ final SyncManager syncManager = DataModel.get().getSyncManager();
+ return syncManager.getHasFirstSyncCompleted();
+ }
+
+ public void setScrolledToNewestConversation(boolean scrolledToNewestConversation) {
+ DataModel.get().setConversationListScrolledToNewestConversation(
+ scrolledToNewestConversation);
+ if (scrolledToNewestConversation) {
+ handleMessagesSeen();
+ }
+ }
+
+ public HashSet<String> getBlockedParticipants() {
+ return mBlockedParticipants;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/ConversationListItemData.java b/src/com/android/messaging/datamodel/data/ConversationListItemData.java
new file mode 100644
index 0000000..b2e6e1c
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/ConversationListItemData.java
@@ -0,0 +1,510 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.BaseColumns;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.action.DeleteConversationAction;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Dates;
+import com.google.common.base.Joiner;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Class wrapping the conversation list view used to display each item in conversation list
+ */
+public class ConversationListItemData {
+ private String mConversationId;
+ private String mName;
+ private String mIcon;
+ private boolean mIsRead;
+ private long mTimestamp;
+ private String mSnippetText;
+ private Uri mPreviewUri;
+ private String mPreviewContentType;
+ private long mParticipantContactId;
+ private String mParticipantLookupKey;
+ private String mOtherParticipantNormalizedDestination;
+ private String mSelfId;
+ private int mParticipantCount;
+ private boolean mNotificationEnabled;
+ private String mNotificationSoundUri;
+ private boolean mNotificationVibrate;
+ private boolean mIncludeEmailAddress;
+ private int mMessageStatus;
+ private int mMessageRawTelephonyStatus;
+ private boolean mShowDraft;
+ private Uri mDraftPreviewUri;
+ private String mDraftPreviewContentType;
+ private String mDraftSnippetText;
+ private boolean mIsArchived;
+ private String mSubject;
+ private String mDraftSubject;
+ private String mSnippetSenderFirstName;
+ private String mSnippetSenderDisplayDestination;
+
+ public ConversationListItemData() {
+ }
+
+ public void bind(final Cursor cursor) {
+ bind(cursor, false);
+ }
+
+ public void bind(final Cursor cursor, final boolean ignoreDraft) {
+ mConversationId = cursor.getString(INDEX_ID);
+ mName = cursor.getString(INDEX_CONVERSATION_NAME);
+ mIcon = cursor.getString(INDEX_CONVERSATION_ICON);
+ mSnippetText = cursor.getString(INDEX_SNIPPET_TEXT);
+ mTimestamp = cursor.getLong(INDEX_SORT_TIMESTAMP);
+ mIsRead = cursor.getInt(INDEX_READ) == 1;
+ final String previewUriString = cursor.getString(INDEX_PREVIEW_URI);
+ mPreviewUri = TextUtils.isEmpty(previewUriString) ? null : Uri.parse(previewUriString);
+ mPreviewContentType = cursor.getString(INDEX_PREVIEW_CONTENT_TYPE);
+ mParticipantContactId = cursor.getLong(INDEX_PARTICIPANT_CONTACT_ID);
+ mParticipantLookupKey = cursor.getString(INDEX_PARTICIPANT_LOOKUP_KEY);
+ mOtherParticipantNormalizedDestination = cursor.getString(
+ INDEX_OTHER_PARTICIPANT_NORMALIZED_DESTINATION);
+ mSelfId = cursor.getString(INDEX_SELF_ID);
+ mParticipantCount = cursor.getInt(INDEX_PARTICIPANT_COUNT);
+ mNotificationEnabled = cursor.getInt(INDEX_NOTIFICATION_ENABLED) == 1;
+ mNotificationSoundUri = cursor.getString(INDEX_NOTIFICATION_SOUND_URI);
+ mNotificationVibrate = cursor.getInt(INDEX_NOTIFICATION_VIBRATION) == 1;
+ mIncludeEmailAddress = cursor.getInt(INDEX_INCLUDE_EMAIL_ADDRESS) == 1;
+ mMessageStatus = cursor.getInt(INDEX_MESSAGE_STATUS);
+ mMessageRawTelephonyStatus = cursor.getInt(INDEX_MESSAGE_RAW_TELEPHONY_STATUS);
+ if (!ignoreDraft) {
+ mShowDraft = cursor.getInt(INDEX_SHOW_DRAFT) == 1;
+ final String draftPreviewUriString = cursor.getString(INDEX_DRAFT_PREVIEW_URI);
+ mDraftPreviewUri = TextUtils.isEmpty(draftPreviewUriString) ?
+ null : Uri.parse(draftPreviewUriString);
+ mDraftPreviewContentType = cursor.getString(INDEX_DRAFT_PREVIEW_CONTENT_TYPE);
+ mDraftSnippetText = cursor.getString(INDEX_DRAFT_SNIPPET_TEXT);
+ mDraftSubject = cursor.getString(INDEX_DRAFT_SUBJECT_TEXT);
+ } else {
+ mShowDraft = false;
+ mDraftPreviewUri = null;
+ mDraftPreviewContentType = null;
+ mDraftSnippetText = null;
+ mDraftSubject = null;
+ }
+
+ mIsArchived = cursor.getInt(INDEX_ARCHIVE_STATUS) == 1;
+ mSubject = cursor.getString(INDEX_SUBJECT_TEXT);
+ mSnippetSenderFirstName = cursor.getString(INDEX_SNIPPET_SENDER_FIRST_NAME);
+ mSnippetSenderDisplayDestination =
+ cursor.getString(INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION);
+ }
+
+ public String getConversationId() {
+ return mConversationId;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public String getIcon() {
+ return mIcon;
+ }
+
+ public boolean getIsRead() {
+ return mIsRead;
+ }
+
+ public String getFormattedTimestamp() {
+ return Dates.getConversationTimeString(mTimestamp).toString();
+ }
+
+ public long getTimestamp() {
+ return mTimestamp;
+ }
+
+ public String getSnippetText() {
+ return mSnippetText;
+ }
+
+ public Uri getPreviewUri() {
+ return mPreviewUri;
+ }
+
+ public String getPreviewContentType() {
+ return mPreviewContentType;
+ }
+
+ public long getParticipantContactId() {
+ return mParticipantContactId;
+ }
+
+ public String getParticipantLookupKey() {
+ return mParticipantLookupKey;
+ }
+
+ public String getOtherParticipantNormalizedDestination() {
+ return mOtherParticipantNormalizedDestination;
+ }
+
+ public String getSelfId() {
+ return mSelfId;
+ }
+
+ public int getParticipantCount() {
+ return mParticipantCount;
+ }
+
+ public boolean getIsGroup() {
+ // Participant count excludes self
+ return (mParticipantCount > 1);
+ }
+
+ public boolean getIncludeEmailAddress() {
+ return mIncludeEmailAddress;
+ }
+
+ public boolean getNotificationEnabled() {
+ return mNotificationEnabled;
+ }
+
+ public String getNotificationSoundUri() {
+ return mNotificationSoundUri;
+ }
+
+ public boolean getNotifiationVibrate() {
+ return mNotificationVibrate;
+ }
+
+ public final boolean getIsFailedStatus() {
+ return (mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_FAILED ||
+ mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER ||
+ mMessageStatus == MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED ||
+ mMessageStatus == MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE);
+ }
+
+ public final boolean getIsSendRequested() {
+ return (mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND ||
+ mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY ||
+ mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_SENDING ||
+ mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_RESENDING);
+ }
+
+ public boolean getIsMessageTypeOutgoing() {
+ return !MessageData.getIsIncoming(mMessageStatus);
+ }
+
+ public int getMessageRawTelephonyStatus() {
+ return mMessageRawTelephonyStatus;
+ }
+
+ public int getMessageStatus() {
+ return mMessageStatus;
+ }
+
+ public boolean getShowDraft() {
+ return mShowDraft;
+ }
+
+ public String getDraftSnippetText() {
+ return mDraftSnippetText;
+ }
+
+ public Uri getDraftPreviewUri() {
+ return mDraftPreviewUri;
+ }
+
+ public String getDraftPreviewContentType() {
+ return mDraftPreviewContentType;
+ }
+
+ public boolean getIsArchived() {
+ return mIsArchived;
+ }
+
+ public String getSubject() {
+ return mSubject;
+ }
+
+ public String getDraftSubject() {
+ return mDraftSubject;
+ }
+
+ public String getSnippetSenderName() {
+ if (!TextUtils.isEmpty(mSnippetSenderFirstName)) {
+ return mSnippetSenderFirstName;
+ }
+ return mSnippetSenderDisplayDestination;
+ }
+
+ public void deleteConversation() {
+ DeleteConversationAction.deleteConversation(mConversationId, mTimestamp);
+ }
+
+ /**
+ * Get the name of the view for this data item
+ */
+ public static final String getConversationListView() {
+ return CONVERSATION_LIST_VIEW;
+ }
+
+ public static final String getConversationListViewSql() {
+ return CONVERSATION_LIST_VIEW_SQL;
+ }
+
+ private static final String CONVERSATION_LIST_VIEW = "conversation_list_view";
+
+ private static final String CONVERSATION_LIST_VIEW_PROJECTION =
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns._ID
+ + " as " + ConversationListViewColumns._ID + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NAME
+ + " as " + ConversationListViewColumns.NAME + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.CURRENT_SELF_ID
+ + " as " + ConversationListViewColumns.CURRENT_SELF_ID + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ARCHIVE_STATUS
+ + " as " + ConversationListViewColumns.ARCHIVE_STATUS + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ
+ + " as " + ConversationListViewColumns.READ + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ICON
+ + " as " + ConversationListViewColumns.ICON + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_CONTACT_ID
+ + " as " + ConversationListViewColumns.PARTICIPANT_CONTACT_ID + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_LOOKUP_KEY
+ + " as " + ConversationListViewColumns.PARTICIPANT_LOOKUP_KEY + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.'
+ + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION
+ + " as " + ConversationListViewColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SORT_TIMESTAMP
+ + " as " + ConversationListViewColumns.SORT_TIMESTAMP + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SHOW_DRAFT
+ + " as " + ConversationListViewColumns.SHOW_DRAFT + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_SNIPPET_TEXT
+ + " as " + ConversationListViewColumns.DRAFT_SNIPPET_TEXT + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_PREVIEW_URI
+ + " as " + ConversationListViewColumns.DRAFT_PREVIEW_URI + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_SUBJECT_TEXT
+ + " as " + ConversationListViewColumns.DRAFT_SUBJECT_TEXT + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.'
+ + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE
+ + " as " + ConversationListViewColumns.DRAFT_PREVIEW_CONTENT_TYPE + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PREVIEW_URI
+ + " as " + ConversationListViewColumns.PREVIEW_URI + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PREVIEW_CONTENT_TYPE
+ + " as " + ConversationListViewColumns.PREVIEW_CONTENT_TYPE + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_COUNT
+ + " as " + ConversationListViewColumns.PARTICIPANT_COUNT + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NOTIFICATION_ENABLED
+ + " as " + ConversationListViewColumns.NOTIFICATION_ENABLED + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NOTIFICATION_SOUND_URI
+ + " as " + ConversationListViewColumns.NOTIFICATION_SOUND_URI + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NOTIFICATION_VIBRATION
+ + " as " + ConversationListViewColumns.NOTIFICATION_VIBRATION + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' +
+ ConversationColumns.INCLUDE_EMAIL_ADDRESS
+ + " as " + ConversationListViewColumns.INCLUDE_EMAIL_ADDRESS + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS
+ + " as " + ConversationListViewColumns.MESSAGE_STATUS + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RAW_TELEPHONY_STATUS
+ + " as " + ConversationListViewColumns.MESSAGE_RAW_TELEPHONY_STATUS + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID
+ + " as " + ConversationListViewColumns.MESSAGE_ID + ", "
+ + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FIRST_NAME
+ + " as " + ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME + ", "
+ + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION
+ + " as " + ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION;
+
+ private static final String JOIN_PARTICIPANTS =
+ " LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE + " ON ("
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID
+ + '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + DatabaseHelper.ParticipantColumns._ID
+ + ") ";
+
+ // View that makes latest message read flag available with rest of conversation data.
+ private static final String CONVERSATION_LIST_VIEW_SQL = "CREATE VIEW " +
+ CONVERSATION_LIST_VIEW + " AS SELECT "
+ + CONVERSATION_LIST_VIEW_PROJECTION + ", "
+ // Snippet not part of the base projection shared with search view
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SNIPPET_TEXT
+ + " as " + ConversationListViewColumns.SNIPPET_TEXT + ", "
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SUBJECT_TEXT
+ + " as " + ConversationListViewColumns.SUBJECT_TEXT + " "
+ + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE
+ + " LEFT JOIN " + DatabaseHelper.MESSAGES_TABLE + " ON ("
+ + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.LATEST_MESSAGE_ID
+ + '=' + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID + ") "
+ + JOIN_PARTICIPANTS
+ + "ORDER BY " + DatabaseHelper.CONVERSATIONS_TABLE + '.'
+ + ConversationColumns.SORT_TIMESTAMP + " DESC";
+
+ public static class ConversationListViewColumns implements BaseColumns {
+ public static final String _ID = ConversationColumns._ID;
+ static final String NAME = ConversationColumns.NAME;
+ static final String ARCHIVE_STATUS = ConversationColumns.ARCHIVE_STATUS;
+ static final String READ = MessageColumns.READ;
+ static final String SORT_TIMESTAMP = ConversationColumns.SORT_TIMESTAMP;
+ static final String PREVIEW_URI = ConversationColumns.PREVIEW_URI;
+ static final String PREVIEW_CONTENT_TYPE = ConversationColumns.PREVIEW_CONTENT_TYPE;
+ static final String SNIPPET_TEXT = ConversationColumns.SNIPPET_TEXT;
+ static final String SUBJECT_TEXT = ConversationColumns.SUBJECT_TEXT;
+ static final String ICON = ConversationColumns.ICON;
+ static final String SHOW_DRAFT = ConversationColumns.SHOW_DRAFT;
+ static final String DRAFT_SUBJECT_TEXT = ConversationColumns.DRAFT_SUBJECT_TEXT;
+ static final String DRAFT_PREVIEW_URI = ConversationColumns.DRAFT_PREVIEW_URI;
+ static final String DRAFT_PREVIEW_CONTENT_TYPE =
+ ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE;
+ static final String DRAFT_SNIPPET_TEXT = ConversationColumns.DRAFT_SNIPPET_TEXT;
+ static final String PARTICIPANT_CONTACT_ID = ConversationColumns.PARTICIPANT_CONTACT_ID;
+ static final String PARTICIPANT_LOOKUP_KEY = ConversationColumns.PARTICIPANT_LOOKUP_KEY;
+ static final String OTHER_PARTICIPANT_NORMALIZED_DESTINATION =
+ ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION;
+ static final String CURRENT_SELF_ID = ConversationColumns.CURRENT_SELF_ID;
+ static final String PARTICIPANT_COUNT = ConversationColumns.PARTICIPANT_COUNT;
+ static final String NOTIFICATION_ENABLED = ConversationColumns.NOTIFICATION_ENABLED;
+ static final String NOTIFICATION_SOUND_URI = ConversationColumns.NOTIFICATION_SOUND_URI;
+ static final String NOTIFICATION_VIBRATION = ConversationColumns.NOTIFICATION_VIBRATION;
+ static final String INCLUDE_EMAIL_ADDRESS =
+ ConversationColumns.INCLUDE_EMAIL_ADDRESS;
+ static final String MESSAGE_STATUS = MessageColumns.STATUS;
+ static final String MESSAGE_RAW_TELEPHONY_STATUS = MessageColumns.RAW_TELEPHONY_STATUS;
+ static final String MESSAGE_ID = "message_id";
+ static final String SNIPPET_SENDER_FIRST_NAME = "snippet_sender_first_name";
+ static final String SNIPPET_SENDER_DISPLAY_DESTINATION =
+ "snippet_sender_display_destination";
+ }
+
+ public static final String[] PROJECTION = {
+ ConversationListViewColumns._ID,
+ ConversationListViewColumns.NAME,
+ ConversationListViewColumns.ICON,
+ ConversationListViewColumns.SNIPPET_TEXT,
+ ConversationListViewColumns.SORT_TIMESTAMP,
+ ConversationListViewColumns.READ,
+ ConversationListViewColumns.PREVIEW_URI,
+ ConversationListViewColumns.PREVIEW_CONTENT_TYPE,
+ ConversationListViewColumns.PARTICIPANT_CONTACT_ID,
+ ConversationListViewColumns.PARTICIPANT_LOOKUP_KEY,
+ ConversationListViewColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION,
+ ConversationListViewColumns.PARTICIPANT_COUNT,
+ ConversationListViewColumns.CURRENT_SELF_ID,
+ ConversationListViewColumns.NOTIFICATION_ENABLED,
+ ConversationListViewColumns.NOTIFICATION_SOUND_URI,
+ ConversationListViewColumns.NOTIFICATION_VIBRATION,
+ ConversationListViewColumns.INCLUDE_EMAIL_ADDRESS,
+ ConversationListViewColumns.MESSAGE_STATUS,
+ ConversationListViewColumns.SHOW_DRAFT,
+ ConversationListViewColumns.DRAFT_PREVIEW_URI,
+ ConversationListViewColumns.DRAFT_PREVIEW_CONTENT_TYPE,
+ ConversationListViewColumns.DRAFT_SNIPPET_TEXT,
+ ConversationListViewColumns.ARCHIVE_STATUS,
+ ConversationListViewColumns.MESSAGE_ID,
+ ConversationListViewColumns.SUBJECT_TEXT,
+ ConversationListViewColumns.DRAFT_SUBJECT_TEXT,
+ ConversationListViewColumns.MESSAGE_RAW_TELEPHONY_STATUS,
+ ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME,
+ ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION,
+ };
+
+ private static final int INDEX_ID = 0;
+ private static final int INDEX_CONVERSATION_NAME = 1;
+ private static final int INDEX_CONVERSATION_ICON = 2;
+ private static final int INDEX_SNIPPET_TEXT = 3;
+ private static final int INDEX_SORT_TIMESTAMP = 4;
+ private static final int INDEX_READ = 5;
+ private static final int INDEX_PREVIEW_URI = 6;
+ private static final int INDEX_PREVIEW_CONTENT_TYPE = 7;
+ private static final int INDEX_PARTICIPANT_CONTACT_ID = 8;
+ private static final int INDEX_PARTICIPANT_LOOKUP_KEY = 9;
+ private static final int INDEX_OTHER_PARTICIPANT_NORMALIZED_DESTINATION = 10;
+ private static final int INDEX_PARTICIPANT_COUNT = 11;
+ private static final int INDEX_SELF_ID = 12;
+ private static final int INDEX_NOTIFICATION_ENABLED = 13;
+ private static final int INDEX_NOTIFICATION_SOUND_URI = 14;
+ private static final int INDEX_NOTIFICATION_VIBRATION = 15;
+ private static final int INDEX_INCLUDE_EMAIL_ADDRESS = 16;
+ private static final int INDEX_MESSAGE_STATUS = 17;
+ private static final int INDEX_SHOW_DRAFT = 18;
+ private static final int INDEX_DRAFT_PREVIEW_URI = 19;
+ private static final int INDEX_DRAFT_PREVIEW_CONTENT_TYPE = 20;
+ private static final int INDEX_DRAFT_SNIPPET_TEXT = 21;
+ private static final int INDEX_ARCHIVE_STATUS = 22;
+ private static final int INDEX_MESSAGE_ID = 23;
+ private static final int INDEX_SUBJECT_TEXT = 24;
+ private static final int INDEX_DRAFT_SUBJECT_TEXT = 25;
+ private static final int INDEX_MESSAGE_RAW_TELEPHONY_STATUS = 26;
+ private static final int INDEX_SNIPPET_SENDER_FIRST_NAME = 27;
+ private static final int INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION = 28;
+
+ private static final String DIVIDER_TEXT = ", ";
+
+ /**
+ * Get a conversation from the local DB based on the conversation id.
+ *
+ * @param dbWrapper The database
+ * @param conversationId The conversation Id to read
+ * @return The existing conversation or null
+ */
+ public static ConversationListItemData getExistingConversation(final DatabaseWrapper dbWrapper,
+ final String conversationId) {
+ ConversationListItemData conversation = null;
+
+ // Look for an existing conversation in the db with this conversation id
+ Cursor cursor = null;
+ try {
+ // TODO: Should we be able to read a row from just the conversation table?
+ cursor = dbWrapper.query(getConversationListView(),
+ PROJECTION,
+ ConversationColumns._ID + "=?",
+ new String[] { conversationId },
+ null, null, null);
+ Assert.inRange(cursor.getCount(), 0, 1);
+ if (cursor.moveToFirst()) {
+ conversation = new ConversationListItemData();
+ conversation.bind(cursor);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ return conversation;
+ }
+
+ public static String generateConversationName(final List<ParticipantData>
+ participants) {
+ if (participants.size() == 1) {
+ // Prefer full name over first name for 1:1 conversation
+ return participants.get(0).getDisplayName(true);
+ }
+
+ final ArrayList<String> participantNames = new ArrayList<String>();
+ for (final ParticipantData participant : participants) {
+ // Prefer first name over full name for group conversation
+ participantNames.add(participant.getDisplayName(false));
+ }
+
+ final Joiner joiner = Joiner.on(DIVIDER_TEXT).skipNulls();
+ return joiner.join(participantNames);
+ }
+
+}
diff --git a/src/com/android/messaging/datamodel/data/ConversationMessageBubbleData.java b/src/com/android/messaging/datamodel/data/ConversationMessageBubbleData.java
new file mode 100644
index 0000000..f329f46
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/ConversationMessageBubbleData.java
@@ -0,0 +1,37 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.text.TextUtils;
+
+/**
+ * Holds data for conversation message bubble which keeps track of whether it's been bound to
+ * a new message.
+ */
+public class ConversationMessageBubbleData {
+ private String mMessageId;
+
+ /**
+ * Binds to ConversationMessageData instance.
+ * @return true if we are binding to a different message, false if we are binding to the
+ * same message (e.g. in order to update the status text)
+ */
+ public boolean bind(final ConversationMessageData data) {
+ final boolean changed = !TextUtils.equals(mMessageId, data.getMessageId());
+ mMessageId = data.getMessageId();
+ return changed;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/ConversationMessageData.java b/src/com/android/messaging/datamodel/data/ConversationMessageData.java
new file mode 100644
index 0000000..19e1b97
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/ConversationMessageData.java
@@ -0,0 +1,917 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.BaseColumns;
+import android.provider.ContactsContract;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseHelper.PartColumns;
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.Dates;
+import com.android.messaging.util.LogUtil;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Predicate;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * Class representing a message within a conversation sequence. The message parts
+ * are available via the getParts() method.
+ *
+ * TODO: See if we can delegate to MessageData for the logic that this class duplicates
+ * (e.g. getIsMms).
+ */
+public class ConversationMessageData {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private String mMessageId;
+ private String mConversationId;
+ private String mParticipantId;
+ private int mPartsCount;
+ private List<MessagePartData> mParts;
+ private long mSentTimestamp;
+ private long mReceivedTimestamp;
+ private boolean mSeen;
+ private boolean mRead;
+ private int mProtocol;
+ private int mStatus;
+ private String mSmsMessageUri;
+ private int mSmsPriority;
+ private int mSmsMessageSize;
+ private String mMmsSubject;
+ private long mMmsExpiry;
+ private int mRawTelephonyStatus;
+ private String mSenderFullName;
+ private String mSenderFirstName;
+ private String mSenderDisplayDestination;
+ private String mSenderNormalizedDestination;
+ private String mSenderProfilePhotoUri;
+ private long mSenderContactId;
+ private String mSenderContactLookupKey;
+ private String mSelfParticipantId;
+
+ /** Are we similar enough to the previous/next messages that we can cluster them? */
+ private boolean mCanClusterWithPreviousMessage;
+ private boolean mCanClusterWithNextMessage;
+
+ public ConversationMessageData() {
+ }
+
+ public void bind(final Cursor cursor) {
+ mMessageId = cursor.getString(INDEX_MESSAGE_ID);
+ mConversationId = cursor.getString(INDEX_CONVERSATION_ID);
+ mParticipantId = cursor.getString(INDEX_PARTICIPANT_ID);
+ mPartsCount = cursor.getInt(INDEX_PARTS_COUNT);
+
+ mParts = makeParts(
+ cursor.getString(INDEX_PARTS_IDS),
+ cursor.getString(INDEX_PARTS_CONTENT_TYPES),
+ cursor.getString(INDEX_PARTS_CONTENT_URIS),
+ cursor.getString(INDEX_PARTS_WIDTHS),
+ cursor.getString(INDEX_PARTS_HEIGHTS),
+ cursor.getString(INDEX_PARTS_TEXTS),
+ mPartsCount,
+ mMessageId);
+
+ mSentTimestamp = cursor.getLong(INDEX_SENT_TIMESTAMP);
+ mReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP);
+ mSeen = (cursor.getInt(INDEX_SEEN) != 0);
+ mRead = (cursor.getInt(INDEX_READ) != 0);
+ mProtocol = cursor.getInt(INDEX_PROTOCOL);
+ mStatus = cursor.getInt(INDEX_STATUS);
+ mSmsMessageUri = cursor.getString(INDEX_SMS_MESSAGE_URI);
+ mSmsPriority = cursor.getInt(INDEX_SMS_PRIORITY);
+ mSmsMessageSize = cursor.getInt(INDEX_SMS_MESSAGE_SIZE);
+ mMmsSubject = cursor.getString(INDEX_MMS_SUBJECT);
+ mMmsExpiry = cursor.getLong(INDEX_MMS_EXPIRY);
+ mRawTelephonyStatus = cursor.getInt(INDEX_RAW_TELEPHONY_STATUS);
+ mSenderFullName = cursor.getString(INDEX_SENDER_FULL_NAME);
+ mSenderFirstName = cursor.getString(INDEX_SENDER_FIRST_NAME);
+ mSenderDisplayDestination = cursor.getString(INDEX_SENDER_DISPLAY_DESTINATION);
+ mSenderNormalizedDestination = cursor.getString(INDEX_SENDER_NORMALIZED_DESTINATION);
+ mSenderProfilePhotoUri = cursor.getString(INDEX_SENDER_PROFILE_PHOTO_URI);
+ mSenderContactId = cursor.getLong(INDEX_SENDER_CONTACT_ID);
+ mSenderContactLookupKey = cursor.getString(INDEX_SENDER_CONTACT_LOOKUP_KEY);
+ mSelfParticipantId = cursor.getString(INDEX_SELF_PARTICIPIANT_ID);
+
+ if (!cursor.isFirst() && cursor.moveToPrevious()) {
+ mCanClusterWithPreviousMessage = canClusterWithMessage(cursor);
+ cursor.moveToNext();
+ } else {
+ mCanClusterWithPreviousMessage = false;
+ }
+ if (!cursor.isLast() && cursor.moveToNext()) {
+ mCanClusterWithNextMessage = canClusterWithMessage(cursor);
+ cursor.moveToPrevious();
+ } else {
+ mCanClusterWithNextMessage = false;
+ }
+ }
+
+ private boolean canClusterWithMessage(final Cursor cursor) {
+ final String otherParticipantId = cursor.getString(INDEX_PARTICIPANT_ID);
+ if (!TextUtils.equals(getParticipantId(), otherParticipantId)) {
+ return false;
+ }
+ final int otherStatus = cursor.getInt(INDEX_STATUS);
+ final boolean otherIsIncoming = (otherStatus >= MessageData.BUGLE_STATUS_FIRST_INCOMING);
+ if (getIsIncoming() != otherIsIncoming) {
+ return false;
+ }
+ final long otherReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP);
+ final long timestampDeltaMillis = Math.abs(mReceivedTimestamp - otherReceivedTimestamp);
+ if (timestampDeltaMillis > DateUtils.MINUTE_IN_MILLIS) {
+ return false;
+ }
+ final String otherSelfId = cursor.getString(INDEX_SELF_PARTICIPIANT_ID);
+ if (!TextUtils.equals(getSelfParticipantId(), otherSelfId)) {
+ return false;
+ }
+ return true;
+ }
+
+ private static final Character QUOTE_CHAR = '\'';
+ private static final char DIVIDER = '|';
+
+ // statics to avoid unnecessary object allocation
+ private static final StringBuilder sUnquoteStringBuilder = new StringBuilder();
+ private static final ArrayList<String> sUnquoteResults = new ArrayList<String>();
+
+ // this lock is used to guard access to the above statics
+ private static final Object sUnquoteLock = new Object();
+
+ private static void addResult(final ArrayList<String> results, final StringBuilder value) {
+ if (value.length() > 0) {
+ results.add(value.toString());
+ } else {
+ results.add(EMPTY_STRING);
+ }
+ }
+
+ @VisibleForTesting
+ static String[] splitUnquotedString(final String inputString) {
+ if (TextUtils.isEmpty(inputString)) {
+ return new String[0];
+ }
+
+ return inputString.split("\\" + DIVIDER);
+ }
+
+ /**
+ * Takes a group-concated and quoted string and decomposes it into its constituent
+ * parts. A quoted string starts and ends with a single quote. Actual single quotes
+ * within the string are escaped using a second single quote. So, for example, an
+ * input string with 3 constituent parts might look like this:
+ *
+ * 'now is the time'|'I can''t do it'|'foo'
+ *
+ * This would be returned as an array of 3 strings as follows:
+ * now is the time
+ * I can't do it
+ * foo
+ *
+ * This is achieved by walking through the inputString, character by character,
+ * ignoring the outer quotes and the divider and replacing any pair of consecutive
+ * single quotes with a single single quote.
+ *
+ * @param inputString
+ * @return array of constituent strings
+ */
+ @VisibleForTesting
+ static String[] splitQuotedString(final String inputString) {
+ if (TextUtils.isEmpty(inputString)) {
+ return new String[0];
+ }
+
+ // this method can be called from multiple threads but it uses a static
+ // string builder
+ synchronized (sUnquoteLock) {
+ final int length = inputString.length();
+ final ArrayList<String> results = sUnquoteResults;
+ results.clear();
+
+ int characterPos = -1;
+ while (++characterPos < length) {
+ final char mustBeQuote = inputString.charAt(characterPos);
+ Assert.isTrue(QUOTE_CHAR == mustBeQuote);
+ while (++characterPos < length) {
+ final char currentChar = inputString.charAt(characterPos);
+ if (currentChar == QUOTE_CHAR) {
+ final char peekAhead = characterPos < length - 1
+ ? inputString.charAt(characterPos + 1) : 0;
+
+ if (peekAhead == QUOTE_CHAR) {
+ characterPos += 1; // skip the second quote
+ } else {
+ addResult(results, sUnquoteStringBuilder);
+ sUnquoteStringBuilder.setLength(0);
+
+ Assert.isTrue((peekAhead == DIVIDER) || (peekAhead == (char) 0));
+ characterPos += 1; // skip the divider
+ break;
+ }
+ }
+ sUnquoteStringBuilder.append(currentChar);
+ }
+ }
+ return results.toArray(new String[results.size()]);
+ }
+ }
+
+ static MessagePartData makePartData(
+ final String partId,
+ final String contentType,
+ final String contentUriString,
+ final String contentWidth,
+ final String contentHeight,
+ final String text,
+ final String messageId) {
+ if (ContentType.isTextType(contentType)) {
+ final MessagePartData textPart = MessagePartData.createTextMessagePart(text);
+ textPart.updatePartId(partId);
+ textPart.updateMessageId(messageId);
+ return textPart;
+ } else {
+ final Uri contentUri = Uri.parse(contentUriString);
+ final int width = Integer.parseInt(contentWidth);
+ final int height = Integer.parseInt(contentHeight);
+ final MessagePartData attachmentPart = MessagePartData.createMediaMessagePart(
+ contentType, contentUri, width, height);
+ attachmentPart.updatePartId(partId);
+ attachmentPart.updateMessageId(messageId);
+ return attachmentPart;
+ }
+ }
+
+ @VisibleForTesting
+ static List<MessagePartData> makeParts(
+ final String rawIds,
+ final String rawContentTypes,
+ final String rawContentUris,
+ final String rawWidths,
+ final String rawHeights,
+ final String rawTexts,
+ final int partsCount,
+ final String messageId) {
+ final List<MessagePartData> parts = new LinkedList<MessagePartData>();
+ if (partsCount == 1) {
+ parts.add(makePartData(
+ rawIds,
+ rawContentTypes,
+ rawContentUris,
+ rawWidths,
+ rawHeights,
+ rawTexts,
+ messageId));
+ } else {
+ unpackMessageParts(
+ parts,
+ splitUnquotedString(rawIds),
+ splitQuotedString(rawContentTypes),
+ splitQuotedString(rawContentUris),
+ splitUnquotedString(rawWidths),
+ splitUnquotedString(rawHeights),
+ splitQuotedString(rawTexts),
+ partsCount,
+ messageId);
+ }
+ return parts;
+ }
+
+ @VisibleForTesting
+ static void unpackMessageParts(
+ final List<MessagePartData> parts,
+ final String[] ids,
+ final String[] contentTypes,
+ final String[] contentUris,
+ final String[] contentWidths,
+ final String[] contentHeights,
+ final String[] texts,
+ final int partsCount,
+ final String messageId) {
+
+ Assert.equals(partsCount, ids.length);
+ Assert.equals(partsCount, contentTypes.length);
+ Assert.equals(partsCount, contentUris.length);
+ Assert.equals(partsCount, contentWidths.length);
+ Assert.equals(partsCount, contentHeights.length);
+ Assert.equals(partsCount, texts.length);
+
+ for (int i = 0; i < partsCount; i++) {
+ parts.add(makePartData(
+ ids[i],
+ contentTypes[i],
+ contentUris[i],
+ contentWidths[i],
+ contentHeights[i],
+ texts[i],
+ messageId));
+ }
+
+ if (parts.size() != partsCount) {
+ LogUtil.wtf(TAG, "Only unpacked " + parts.size() + " parts from message (id="
+ + messageId + "), expected " + partsCount + " parts");
+ }
+ }
+
+ public final String getMessageId() {
+ return mMessageId;
+ }
+
+ public final String getConversationId() {
+ return mConversationId;
+ }
+
+ public final String getParticipantId() {
+ return mParticipantId;
+ }
+
+ public List<MessagePartData> getParts() {
+ return mParts;
+ }
+
+ public boolean hasText() {
+ for (final MessagePartData part : mParts) {
+ if (part.isText()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get a concatenation of all text parts
+ *
+ * @return the text that is a concatenation of all text parts
+ */
+ public String getText() {
+ // This is optimized for single text part case, which is the majority
+
+ // For single text part, we just return the part without creating the StringBuilder
+ String firstTextPart = null;
+ boolean foundText = false;
+ // For multiple text parts, we need the StringBuilder and the separator for concatenation
+ StringBuilder sb = null;
+ String separator = null;
+ for (final MessagePartData part : mParts) {
+ if (part.isText()) {
+ if (!foundText) {
+ // First text part
+ firstTextPart = part.getText();
+ foundText = true;
+ } else {
+ // Second and beyond
+ if (sb == null) {
+ // Need the StringBuilder and the separator starting from 2nd text part
+ sb = new StringBuilder();
+ if (!TextUtils.isEmpty(firstTextPart)) {
+ sb.append(firstTextPart);
+ }
+ separator = BugleGservices.get().getString(
+ BugleGservicesKeys.MMS_TEXT_CONCAT_SEPARATOR,
+ BugleGservicesKeys.MMS_TEXT_CONCAT_SEPARATOR_DEFAULT);
+ }
+ final String partText = part.getText();
+ if (!TextUtils.isEmpty(partText)) {
+ if (!TextUtils.isEmpty(separator) && sb.length() > 0) {
+ sb.append(separator);
+ }
+ sb.append(partText);
+ }
+ }
+ }
+ }
+ if (sb == null) {
+ // Only one text part
+ return firstTextPart;
+ } else {
+ // More than one
+ return sb.toString();
+ }
+ }
+
+ public boolean hasAttachments() {
+ for (final MessagePartData part : mParts) {
+ if (part.isAttachment()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public List<MessagePartData> getAttachments() {
+ return getAttachments(null);
+ }
+
+ public List<MessagePartData> getAttachments(final Predicate<MessagePartData> filter) {
+ if (mParts.isEmpty()) {
+ return Collections.emptyList();
+ }
+ final List<MessagePartData> attachmentParts = new LinkedList<>();
+ for (final MessagePartData part : mParts) {
+ if (part.isAttachment()) {
+ if (filter == null || filter.apply(part)) {
+ attachmentParts.add(part);
+ }
+ }
+ }
+ return attachmentParts;
+ }
+
+ public final long getSentTimeStamp() {
+ return mSentTimestamp;
+ }
+
+ public final long getReceivedTimeStamp() {
+ return mReceivedTimestamp;
+ }
+
+ public final String getFormattedReceivedTimeStamp() {
+ return Dates.getMessageTimeString(mReceivedTimestamp).toString();
+ }
+
+ public final boolean getIsSeen() {
+ return mSeen;
+ }
+
+ public final boolean getIsRead() {
+ return mRead;
+ }
+
+ public final boolean getIsMms() {
+ return (mProtocol == MessageData.PROTOCOL_MMS ||
+ mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION);
+ }
+
+ public final boolean getIsMmsNotification() {
+ return (mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION);
+ }
+
+ public final boolean getIsSms() {
+ return mProtocol == (MessageData.PROTOCOL_SMS);
+ }
+
+ final int getProtocol() {
+ return mProtocol;
+ }
+
+ public final int getStatus() {
+ return mStatus;
+ }
+
+ public final String getSmsMessageUri() {
+ return mSmsMessageUri;
+ }
+
+ public final int getSmsPriority() {
+ return mSmsPriority;
+ }
+
+ public final int getSmsMessageSize() {
+ return mSmsMessageSize;
+ }
+
+ public final String getMmsSubject() {
+ return mMmsSubject;
+ }
+
+ public final long getMmsExpiry() {
+ return mMmsExpiry;
+ }
+
+ public final int getRawTelephonyStatus() {
+ return mRawTelephonyStatus;
+ }
+
+ public final String getSelfParticipantId() {
+ return mSelfParticipantId;
+ }
+
+ public boolean getIsIncoming() {
+ return (mStatus >= MessageData.BUGLE_STATUS_FIRST_INCOMING);
+ }
+
+ public boolean hasIncomingErrorStatus() {
+ return (mStatus == MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE ||
+ mStatus == MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED);
+ }
+
+ public boolean getIsSendComplete() {
+ return mStatus == MessageData.BUGLE_STATUS_OUTGOING_COMPLETE;
+ }
+
+ public String getSenderFullName() {
+ return mSenderFullName;
+ }
+
+ public String getSenderFirstName() {
+ return mSenderFirstName;
+ }
+
+ public String getSenderDisplayDestination() {
+ return mSenderDisplayDestination;
+ }
+
+ public String getSenderNormalizedDestination() {
+ return mSenderNormalizedDestination;
+ }
+
+ public Uri getSenderProfilePhotoUri() {
+ return mSenderProfilePhotoUri == null ? null : Uri.parse(mSenderProfilePhotoUri);
+ }
+
+ public long getSenderContactId() {
+ return mSenderContactId;
+ }
+
+ public String getSenderDisplayName() {
+ if (!TextUtils.isEmpty(mSenderFullName)) {
+ return mSenderFullName;
+ }
+ if (!TextUtils.isEmpty(mSenderFirstName)) {
+ return mSenderFirstName;
+ }
+ return mSenderDisplayDestination;
+ }
+
+ public String getSenderContactLookupKey() {
+ return mSenderContactLookupKey;
+ }
+
+ public boolean getShowDownloadMessage() {
+ return MessageData.getShowDownloadMessage(mStatus);
+ }
+
+ public boolean getShowResendMessage() {
+ return MessageData.getShowResendMessage(mStatus);
+ }
+
+ public boolean getCanForwardMessage() {
+ // Even for outgoing messages, we only allow forwarding if the message has finished sending
+ // as media often has issues when send isn't complete
+ return (mStatus == MessageData.BUGLE_STATUS_OUTGOING_COMPLETE ||
+ mStatus == MessageData.BUGLE_STATUS_INCOMING_COMPLETE);
+ }
+
+ public boolean getCanCopyMessageToClipboard() {
+ return (hasText() &&
+ (!getIsIncoming() || mStatus == MessageData.BUGLE_STATUS_INCOMING_COMPLETE));
+ }
+
+ public boolean getOneClickResendMessage() {
+ return MessageData.getOneClickResendMessage(mStatus, mRawTelephonyStatus);
+ }
+
+ /**
+ * Get sender's lookup uri.
+ * This method doesn't support corp contacts.
+ *
+ * @return Lookup uri of sender's contact
+ */
+ public Uri getSenderContactLookupUri() {
+ if (mSenderContactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED
+ && !TextUtils.isEmpty(mSenderContactLookupKey)) {
+ return ContactsContract.Contacts.getLookupUri(mSenderContactId,
+ mSenderContactLookupKey);
+ }
+ return null;
+ }
+
+ public boolean getCanClusterWithPreviousMessage() {
+ return mCanClusterWithPreviousMessage;
+ }
+
+ public boolean getCanClusterWithNextMessage() {
+ return mCanClusterWithNextMessage;
+ }
+
+ @Override
+ public String toString() {
+ return MessageData.toString(mMessageId, mParts);
+ }
+
+ // Data definitions
+
+ public static final String getConversationMessagesQuerySql() {
+ return CONVERSATION_MESSAGES_QUERY_SQL
+ + " AND "
+ // Inject the conversation id
+ + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?)"
+ + CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY;
+ }
+
+ static final String getConversationMessageIdsQuerySql() {
+ return CONVERSATION_MESSAGES_IDS_QUERY_SQL
+ + " AND "
+ // Inject the conversation id
+ + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?)"
+ + CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY;
+ }
+
+ public static final String getNotificationQuerySql() {
+ return CONVERSATION_MESSAGES_QUERY_SQL
+ + " AND "
+ + "(" + DatabaseHelper.MessageColumns.STATUS + " in ("
+ + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + ", "
+ + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD + ")"
+ + " AND "
+ + DatabaseHelper.MessageColumns.SEEN + " = 0)"
+ + ")"
+ + NOTIFICATION_QUERY_SQL_GROUP_BY;
+ }
+
+ public static final String getWearableQuerySql() {
+ return CONVERSATION_MESSAGES_QUERY_SQL
+ + " AND "
+ + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?"
+ + " AND "
+ + DatabaseHelper.MessageColumns.STATUS + " IN ("
+ + MessageData.BUGLE_STATUS_OUTGOING_DELIVERED + ", "
+ + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + ", "
+ + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND + ", "
+ + MessageData.BUGLE_STATUS_OUTGOING_SENDING + ", "
+ + MessageData.BUGLE_STATUS_OUTGOING_RESENDING + ", "
+ + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY + ", "
+ + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + ", "
+ + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD + ")"
+ + ")"
+ + NOTIFICATION_QUERY_SQL_GROUP_BY;
+ }
+
+ /*
+ * Generate a sqlite snippet to call the quote function on the columnName argument.
+ * The columnName doesn't strictly have to be a column name (e.g. it could be an
+ * expression).
+ */
+ private static String quote(final String columnName) {
+ return "quote(" + columnName + ")";
+ }
+
+ private static String makeGroupConcatString(final String column) {
+ return "group_concat(" + column + ", '" + DIVIDER + "')";
+ }
+
+ private static String makeIfNullString(final String column) {
+ return "ifnull(" + column + "," + "''" + ")";
+ }
+
+ private static String makePartsTableColumnString(final String column) {
+ return DatabaseHelper.PARTS_TABLE + '.' + column;
+ }
+
+ private static String makeCaseWhenString(final String column,
+ final boolean quote,
+ final String asColumn) {
+ final String fullColumn = makeIfNullString(makePartsTableColumnString(column));
+ final String groupConcatTerm = quote
+ ? makeGroupConcatString(quote(fullColumn))
+ : makeGroupConcatString(fullColumn);
+ return "CASE WHEN (" + CONVERSATION_MESSAGE_VIEW_PARTS_COUNT + ">1) THEN " + groupConcatTerm
+ + " ELSE " + makePartsTableColumnString(column) + " END AS " + asColumn;
+ }
+
+ private static final String CONVERSATION_MESSAGE_VIEW_PARTS_COUNT =
+ "count(" + DatabaseHelper.PARTS_TABLE + '.' + PartColumns._ID + ")";
+
+ private static final String EMPTY_STRING = "";
+
+ private static final String CONVERSATION_MESSAGES_QUERY_PROJECTION_SQL =
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID
+ + " as " + ConversationMessageViewColumns._ID + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID
+ + " as " + ConversationMessageViewColumns.CONVERSATION_ID + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID
+ + " as " + ConversationMessageViewColumns.PARTICIPANT_ID + ", "
+
+ + makeCaseWhenString(PartColumns._ID, false,
+ ConversationMessageViewColumns.PARTS_IDS) + ", "
+ + makeCaseWhenString(PartColumns.CONTENT_TYPE, true,
+ ConversationMessageViewColumns.PARTS_CONTENT_TYPES) + ", "
+ + makeCaseWhenString(PartColumns.CONTENT_URI, true,
+ ConversationMessageViewColumns.PARTS_CONTENT_URIS) + ", "
+ + makeCaseWhenString(PartColumns.WIDTH, false,
+ ConversationMessageViewColumns.PARTS_WIDTHS) + ", "
+ + makeCaseWhenString(PartColumns.HEIGHT, false,
+ ConversationMessageViewColumns.PARTS_HEIGHTS) + ", "
+ + makeCaseWhenString(PartColumns.TEXT, true,
+ ConversationMessageViewColumns.PARTS_TEXTS) + ", "
+
+ + CONVERSATION_MESSAGE_VIEW_PARTS_COUNT
+ + " as " + ConversationMessageViewColumns.PARTS_COUNT + ", "
+
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENT_TIMESTAMP
+ + " as " + ConversationMessageViewColumns.SENT_TIMESTAMP + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP
+ + " as " + ConversationMessageViewColumns.RECEIVED_TIMESTAMP + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SEEN
+ + " as " + ConversationMessageViewColumns.SEEN + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ
+ + " as " + ConversationMessageViewColumns.READ + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.PROTOCOL
+ + " as " + ConversationMessageViewColumns.PROTOCOL + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS
+ + " as " + ConversationMessageViewColumns.STATUS + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_MESSAGE_URI
+ + " as " + ConversationMessageViewColumns.SMS_MESSAGE_URI + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_PRIORITY
+ + " as " + ConversationMessageViewColumns.SMS_PRIORITY + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_MESSAGE_SIZE
+ + " as " + ConversationMessageViewColumns.SMS_MESSAGE_SIZE + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.MMS_SUBJECT
+ + " as " + ConversationMessageViewColumns.MMS_SUBJECT + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.MMS_EXPIRY
+ + " as " + ConversationMessageViewColumns.MMS_EXPIRY + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RAW_TELEPHONY_STATUS
+ + " as " + ConversationMessageViewColumns.RAW_TELEPHONY_STATUS + ", "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SELF_PARTICIPANT_ID
+ + " as " + ConversationMessageViewColumns.SELF_PARTICIPANT_ID + ", "
+ + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FULL_NAME
+ + " as " + ConversationMessageViewColumns.SENDER_FULL_NAME + ", "
+ + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FIRST_NAME
+ + " as " + ConversationMessageViewColumns.SENDER_FIRST_NAME + ", "
+ + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION
+ + " as " + ConversationMessageViewColumns.SENDER_DISPLAY_DESTINATION + ", "
+ + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.NORMALIZED_DESTINATION
+ + " as " + ConversationMessageViewColumns.SENDER_NORMALIZED_DESTINATION + ", "
+ + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.PROFILE_PHOTO_URI
+ + " as " + ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI + ", "
+ + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.CONTACT_ID
+ + " as " + ConversationMessageViewColumns.SENDER_CONTACT_ID + ", "
+ + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.LOOKUP_KEY
+ + " as " + ConversationMessageViewColumns.SENDER_CONTACT_LOOKUP_KEY + " ";
+
+ private static final String CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL =
+ " FROM " + DatabaseHelper.MESSAGES_TABLE
+ + " LEFT JOIN " + DatabaseHelper.PARTS_TABLE
+ + " ON (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns._ID
+ + "=" + DatabaseHelper.PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ") "
+ + " LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE
+ + " ON (" + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID
+ + '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns._ID + ")"
+ // Exclude draft messages from main view
+ + " WHERE (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.STATUS
+ + " <> " + MessageData.BUGLE_STATUS_OUTGOING_DRAFT;
+
+ // This query is mostly static, except for the injection of conversation id. This is for
+ // performance reasons, to ensure that the query uses indices and does not trigger full scans
+ // of the messages table. See b/17160946 for more details.
+ private static final String CONVERSATION_MESSAGES_QUERY_SQL = "SELECT "
+ + CONVERSATION_MESSAGES_QUERY_PROJECTION_SQL
+ + CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL;
+
+ private static final String CONVERSATION_MESSAGE_IDS_PROJECTION_SQL =
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID
+ + " as " + ConversationMessageViewColumns._ID + " ";
+
+ private static final String CONVERSATION_MESSAGES_IDS_QUERY_SQL = "SELECT "
+ + CONVERSATION_MESSAGE_IDS_PROJECTION_SQL
+ + CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL;
+
+ // Note that we sort DESC and ConversationData reverses the cursor. This is a performance
+ // issue (improvement) for large cursors.
+ private static final String CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY =
+ " GROUP BY " + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.MESSAGE_ID
+ + " ORDER BY "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " DESC";
+
+ private static final String NOTIFICATION_QUERY_SQL_GROUP_BY =
+ " GROUP BY " + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.MESSAGE_ID
+ + " ORDER BY "
+ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " DESC";
+
+ interface ConversationMessageViewColumns extends BaseColumns {
+ static final String _ID = MessageColumns._ID;
+ static final String CONVERSATION_ID = MessageColumns.CONVERSATION_ID;
+ static final String PARTICIPANT_ID = MessageColumns.SENDER_PARTICIPANT_ID;
+ static final String PARTS_COUNT = "parts_count";
+ static final String SENT_TIMESTAMP = MessageColumns.SENT_TIMESTAMP;
+ static final String RECEIVED_TIMESTAMP = MessageColumns.RECEIVED_TIMESTAMP;
+ static final String SEEN = MessageColumns.SEEN;
+ static final String READ = MessageColumns.READ;
+ static final String PROTOCOL = MessageColumns.PROTOCOL;
+ static final String STATUS = MessageColumns.STATUS;
+ static final String SMS_MESSAGE_URI = MessageColumns.SMS_MESSAGE_URI;
+ static final String SMS_PRIORITY = MessageColumns.SMS_PRIORITY;
+ static final String SMS_MESSAGE_SIZE = MessageColumns.SMS_MESSAGE_SIZE;
+ static final String MMS_SUBJECT = MessageColumns.MMS_SUBJECT;
+ static final String MMS_EXPIRY = MessageColumns.MMS_EXPIRY;
+ static final String RAW_TELEPHONY_STATUS = MessageColumns.RAW_TELEPHONY_STATUS;
+ static final String SELF_PARTICIPANT_ID = MessageColumns.SELF_PARTICIPANT_ID;
+ static final String SENDER_FULL_NAME = ParticipantColumns.FULL_NAME;
+ static final String SENDER_FIRST_NAME = ParticipantColumns.FIRST_NAME;
+ static final String SENDER_DISPLAY_DESTINATION = ParticipantColumns.DISPLAY_DESTINATION;
+ static final String SENDER_NORMALIZED_DESTINATION =
+ ParticipantColumns.NORMALIZED_DESTINATION;
+ static final String SENDER_PROFILE_PHOTO_URI = ParticipantColumns.PROFILE_PHOTO_URI;
+ static final String SENDER_CONTACT_ID = ParticipantColumns.CONTACT_ID;
+ static final String SENDER_CONTACT_LOOKUP_KEY = ParticipantColumns.LOOKUP_KEY;
+ static final String PARTS_IDS = "parts_ids";
+ static final String PARTS_CONTENT_TYPES = "parts_content_types";
+ static final String PARTS_CONTENT_URIS = "parts_content_uris";
+ static final String PARTS_WIDTHS = "parts_widths";
+ static final String PARTS_HEIGHTS = "parts_heights";
+ static final String PARTS_TEXTS = "parts_texts";
+ }
+
+ private static int sIndexIncrementer = 0;
+
+ private static final int INDEX_MESSAGE_ID = sIndexIncrementer++;
+ private static final int INDEX_CONVERSATION_ID = sIndexIncrementer++;
+ private static final int INDEX_PARTICIPANT_ID = sIndexIncrementer++;
+
+ private static final int INDEX_PARTS_IDS = sIndexIncrementer++;
+ private static final int INDEX_PARTS_CONTENT_TYPES = sIndexIncrementer++;
+ private static final int INDEX_PARTS_CONTENT_URIS = sIndexIncrementer++;
+ private static final int INDEX_PARTS_WIDTHS = sIndexIncrementer++;
+ private static final int INDEX_PARTS_HEIGHTS = sIndexIncrementer++;
+ private static final int INDEX_PARTS_TEXTS = sIndexIncrementer++;
+
+ private static final int INDEX_PARTS_COUNT = sIndexIncrementer++;
+
+ private static final int INDEX_SENT_TIMESTAMP = sIndexIncrementer++;
+ private static final int INDEX_RECEIVED_TIMESTAMP = sIndexIncrementer++;
+ private static final int INDEX_SEEN = sIndexIncrementer++;
+ private static final int INDEX_READ = sIndexIncrementer++;
+ private static final int INDEX_PROTOCOL = sIndexIncrementer++;
+ private static final int INDEX_STATUS = sIndexIncrementer++;
+ private static final int INDEX_SMS_MESSAGE_URI = sIndexIncrementer++;
+ private static final int INDEX_SMS_PRIORITY = sIndexIncrementer++;
+ private static final int INDEX_SMS_MESSAGE_SIZE = sIndexIncrementer++;
+ private static final int INDEX_MMS_SUBJECT = sIndexIncrementer++;
+ private static final int INDEX_MMS_EXPIRY = sIndexIncrementer++;
+ private static final int INDEX_RAW_TELEPHONY_STATUS = sIndexIncrementer++;
+ private static final int INDEX_SELF_PARTICIPIANT_ID = sIndexIncrementer++;
+ private static final int INDEX_SENDER_FULL_NAME = sIndexIncrementer++;
+ private static final int INDEX_SENDER_FIRST_NAME = sIndexIncrementer++;
+ private static final int INDEX_SENDER_DISPLAY_DESTINATION = sIndexIncrementer++;
+ private static final int INDEX_SENDER_NORMALIZED_DESTINATION = sIndexIncrementer++;
+ private static final int INDEX_SENDER_PROFILE_PHOTO_URI = sIndexIncrementer++;
+ private static final int INDEX_SENDER_CONTACT_ID = sIndexIncrementer++;
+ private static final int INDEX_SENDER_CONTACT_LOOKUP_KEY = sIndexIncrementer++;
+
+
+ private static String[] sProjection = {
+ ConversationMessageViewColumns._ID,
+ ConversationMessageViewColumns.CONVERSATION_ID,
+ ConversationMessageViewColumns.PARTICIPANT_ID,
+
+ ConversationMessageViewColumns.PARTS_IDS,
+ ConversationMessageViewColumns.PARTS_CONTENT_TYPES,
+ ConversationMessageViewColumns.PARTS_CONTENT_URIS,
+ ConversationMessageViewColumns.PARTS_WIDTHS,
+ ConversationMessageViewColumns.PARTS_HEIGHTS,
+ ConversationMessageViewColumns.PARTS_TEXTS,
+
+ ConversationMessageViewColumns.PARTS_COUNT,
+ ConversationMessageViewColumns.SENT_TIMESTAMP,
+ ConversationMessageViewColumns.RECEIVED_TIMESTAMP,
+ ConversationMessageViewColumns.SEEN,
+ ConversationMessageViewColumns.READ,
+ ConversationMessageViewColumns.PROTOCOL,
+ ConversationMessageViewColumns.STATUS,
+ ConversationMessageViewColumns.SMS_MESSAGE_URI,
+ ConversationMessageViewColumns.SMS_PRIORITY,
+ ConversationMessageViewColumns.SMS_MESSAGE_SIZE,
+ ConversationMessageViewColumns.MMS_SUBJECT,
+ ConversationMessageViewColumns.MMS_EXPIRY,
+ ConversationMessageViewColumns.RAW_TELEPHONY_STATUS,
+ ConversationMessageViewColumns.SELF_PARTICIPANT_ID,
+ ConversationMessageViewColumns.SENDER_FULL_NAME,
+ ConversationMessageViewColumns.SENDER_FIRST_NAME,
+ ConversationMessageViewColumns.SENDER_DISPLAY_DESTINATION,
+ ConversationMessageViewColumns.SENDER_NORMALIZED_DESTINATION,
+ ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI,
+ ConversationMessageViewColumns.SENDER_CONTACT_ID,
+ ConversationMessageViewColumns.SENDER_CONTACT_LOOKUP_KEY,
+ };
+
+ public static String[] getProjection() {
+ return sProjection;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/ConversationParticipantsData.java b/src/com/android/messaging/datamodel/data/ConversationParticipantsData.java
new file mode 100644
index 0000000..0b5ef51
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/ConversationParticipantsData.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.datamodel.data;
+
+import android.database.Cursor;
+import android.support.v4.util.SimpleArrayMap;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import junit.framework.Assert;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+/**
+ * A class that contains the list of all participants potentially involved in a conversation.
+ * Includes both the participant records for each participant referenced in conversation
+ * participants table (i.e. "other" phone numbers) plus all participants representing self
+ * (i.e. one per sim recorded in the subscription manager db).
+ */
+public class ConversationParticipantsData implements Iterable<ParticipantData> {
+ // A map from a participant id to a participant
+ private final SimpleArrayMap<String, ParticipantData> mConversationParticipantsMap;
+ private int mParticipantCountExcludingSelf = 0;
+
+ public ConversationParticipantsData() {
+ mConversationParticipantsMap = new SimpleArrayMap<String, ParticipantData>();
+ }
+
+ public void bind(final Cursor cursor) {
+ mConversationParticipantsMap.clear();
+ mParticipantCountExcludingSelf = 0;
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ final ParticipantData newParticipant = ParticipantData.getFromCursor(cursor);
+ if (!newParticipant.isSelf()) {
+ mParticipantCountExcludingSelf++;
+ }
+ mConversationParticipantsMap.put(newParticipant.getId(), newParticipant);
+ }
+ }
+ }
+
+ @VisibleForTesting
+ ParticipantData getParticipantById(final String participantId) {
+ return mConversationParticipantsMap.get(participantId);
+ }
+
+ ArrayList<ParticipantData> getParticipantListExcludingSelf() {
+ final ArrayList<ParticipantData> retList =
+ new ArrayList<ParticipantData>(mConversationParticipantsMap.size());
+ for (int i = 0; i < mConversationParticipantsMap.size(); i++) {
+ final ParticipantData participant = mConversationParticipantsMap.valueAt(i);
+ if (!participant.isSelf()) {
+ retList.add(participant);
+ }
+ }
+ return retList;
+ }
+
+ /**
+ * For a 1:1 conversation return the other (not self) participant
+ */
+ public ParticipantData getOtherParticipant() {
+ if (mParticipantCountExcludingSelf == 1) {
+ for (int i = 0; i < mConversationParticipantsMap.size(); i++) {
+ final ParticipantData participant = mConversationParticipantsMap.valueAt(i);
+ if (!participant.isSelf()) {
+ return participant;
+ }
+ }
+ Assert.fail();
+ }
+ return null;
+ }
+
+ public int getNumberOfParticipantsExcludingSelf() {
+ return mParticipantCountExcludingSelf;
+ }
+
+ public boolean isLoaded() {
+ return !mConversationParticipantsMap.isEmpty();
+ }
+
+ @Override
+ public Iterator<ParticipantData> iterator() {
+ return new Iterator<ParticipantData>() {
+ private int mCurrentIndex = -1;
+
+ @Override
+ public boolean hasNext() {
+ return mCurrentIndex < mConversationParticipantsMap.size() - 1;
+ }
+
+ @Override
+ public ParticipantData next() {
+ mCurrentIndex++;
+ if (mCurrentIndex >= mConversationParticipantsMap.size()) {
+ throw new NoSuchElementException();
+ }
+ return mConversationParticipantsMap.valueAt(mCurrentIndex);
+ }
+
+ @Override
+ public void remove() {
+ throw new UnsupportedOperationException();
+ }
+ };
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/DraftMessageData.java b/src/com/android/messaging/datamodel/data/DraftMessageData.java
new file mode 100644
index 0000000..7a7199a
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/DraftMessageData.java
@@ -0,0 +1,855 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.net.Uri;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.MessageTextStats;
+import com.android.messaging.datamodel.action.ReadDraftDataAction;
+import com.android.messaging.datamodel.action.ReadDraftDataAction.ReadDraftDataActionListener;
+import com.android.messaging.datamodel.action.ReadDraftDataAction.ReadDraftDataActionMonitor;
+import com.android.messaging.datamodel.action.WriteDraftMessageAction;
+import com.android.messaging.datamodel.binding.BindableData;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.sms.MmsSmsUtils;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.Assert.RunsOnMainThread;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.SafeAsyncTask;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+public class DraftMessageData extends BindableData implements ReadDraftDataActionListener {
+
+ /**
+ * Interface for DraftMessageData listeners
+ */
+ public interface DraftMessageDataListener {
+ @RunsOnMainThread
+ void onDraftChanged(DraftMessageData data, int changeFlags);
+
+ @RunsOnMainThread
+ void onDraftAttachmentLimitReached(DraftMessageData data);
+
+ @RunsOnMainThread
+ void onDraftAttachmentLoadFailed();
+ }
+
+ /**
+ * Interface for providing subscription-related data to DraftMessageData
+ */
+ public interface DraftMessageSubscriptionDataProvider {
+ int getConversationSelfSubId();
+ }
+
+ // Flags sent to onDraftChanged to help the receiver limit the amount of work done
+ public static int ATTACHMENTS_CHANGED = 0x0001;
+ public static int MESSAGE_TEXT_CHANGED = 0x0002;
+ public static int MESSAGE_SUBJECT_CHANGED = 0x0004;
+ // Whether the self participant data has been loaded
+ public static int SELF_CHANGED = 0x0008;
+ public static int ALL_CHANGED = 0x00FF;
+ // ALL_CHANGED intentionally doesn't include WIDGET_CHANGED. ConversationFragment needs to
+ // be notified if the draft it is looking at is changed externally (by a desktop widget) so it
+ // can reload the draft.
+ public static int WIDGET_CHANGED = 0x0100;
+
+ private final String mConversationId;
+ private ReadDraftDataActionMonitor mMonitor;
+ private final DraftMessageDataEventDispatcher mListeners;
+ private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider;
+
+ private boolean mIncludeEmailAddress;
+ private boolean mIsGroupConversation;
+ private String mMessageText;
+ private String mMessageSubject;
+ private String mSelfId;
+ private MessageTextStats mMessageTextStats;
+ private boolean mSending;
+
+ /** Keeps track of completed attachments in the message draft. This data is persisted to db */
+ private final List<MessagePartData> mAttachments;
+
+ /** A read-only wrapper on mAttachments surfaced to the UI layer for rendering */
+ private final List<MessagePartData> mReadOnlyAttachments;
+
+ /** Keeps track of pending attachments that are being loaded. The pending attachments are
+ * transient, because they are not persisted to the database and are dropped once we go
+ * to the background (after the UI calls saveToStorage) */
+ private final List<PendingAttachmentData> mPendingAttachments;
+
+ /** A read-only wrapper on mPendingAttachments surfaced to the UI layer for rendering */
+ private final List<PendingAttachmentData> mReadOnlyPendingAttachments;
+
+ /** Is the current draft a cached copy of what's been saved to the database. If so, we
+ * may skip loading from database if we are still bound */
+ private boolean mIsDraftCachedCopy;
+
+ /** Whether we are currently asynchronously validating the draft before sending. */
+ private CheckDraftForSendTask mCheckDraftForSendTask;
+
+ public DraftMessageData(final String conversationId) {
+ mConversationId = conversationId;
+ mAttachments = new ArrayList<MessagePartData>();
+ mReadOnlyAttachments = Collections.unmodifiableList(mAttachments);
+ mPendingAttachments = new ArrayList<PendingAttachmentData>();
+ mReadOnlyPendingAttachments = Collections.unmodifiableList(mPendingAttachments);
+ mListeners = new DraftMessageDataEventDispatcher();
+ mMessageTextStats = new MessageTextStats();
+ }
+
+ public void addListener(final DraftMessageDataListener listener) {
+ mListeners.add(listener);
+ }
+
+ public void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) {
+ mSubscriptionDataProvider = provider;
+ }
+
+ public void updateFromMessageData(final MessageData message, final String bindingId) {
+ // New attachments have arrived - only update if the user hasn't already edited
+ Assert.notNull(bindingId);
+ // The draft is now synced with actual MessageData and no longer a cached copy.
+ mIsDraftCachedCopy = false;
+ // Do not use the loaded draft if the user began composing a message before the draft loaded
+ // During config changes (orientation), the text fields preserve their data, so allow them
+ // to be the same and still consider the draft unchanged by the user
+ if (isDraftEmpty() || (TextUtils.equals(mMessageText, message.getMessageText()) &&
+ TextUtils.equals(mMessageSubject, message.getMmsSubject()) &&
+ mAttachments.isEmpty())) {
+ // No need to clear as just checked it was empty or a subset
+ setMessageText(message.getMessageText(), false /* notify */);
+ setMessageSubject(message.getMmsSubject(), false /* notify */);
+ for (final MessagePartData part : message.getParts()) {
+ if (part.isAttachment() && getAttachmentCount() >= getAttachmentLimit()) {
+ dispatchAttachmentLimitReached();
+ break;
+ }
+
+ if (part instanceof PendingAttachmentData) {
+ // This is a pending attachment data from share intent (e.g. an shared image
+ // that we need to persist locally).
+ final PendingAttachmentData data = (PendingAttachmentData) part;
+ Assert.equals(PendingAttachmentData.STATE_PENDING, data.getCurrentState());
+ addOnePendingAttachmentNoNotify(data, bindingId);
+ } else if (part.isAttachment()) {
+ addOneAttachmentNoNotify(part);
+ }
+ }
+ dispatchChanged(ALL_CHANGED);
+ } else {
+ // The user has started a new message so we throw out the draft message data if there
+ // is one but we also loaded the self metadata and need to let our listeners know.
+ dispatchChanged(SELF_CHANGED);
+ }
+ }
+
+ /**
+ * Create a MessageData object containing a copy of all the parts in this DraftMessageData.
+ *
+ * @param clearLocalCopy whether we should clear out the in-memory copy of the parts. If we
+ * are simply pausing/resuming and not sending the message, then we can keep
+ * @return the MessageData for the draft, null if self id is not set
+ */
+ public MessageData createMessageWithCurrentAttachments(final boolean clearLocalCopy) {
+ MessageData message = null;
+ if (getIsMms()) {
+ message = MessageData.createDraftMmsMessage(mConversationId, mSelfId,
+ mMessageText, mMessageSubject);
+ for (final MessagePartData attachment : mAttachments) {
+ message.addPart(attachment);
+ }
+ } else {
+ message = MessageData.createDraftSmsMessage(mConversationId, mSelfId,
+ mMessageText);
+ }
+
+ if (clearLocalCopy) {
+ // The message now owns all the attachments and the text...
+ clearLocalDraftCopy();
+ dispatchChanged(ALL_CHANGED);
+ } else {
+ // The draft message becomes a cached copy for UI.
+ mIsDraftCachedCopy = true;
+ }
+ return message;
+ }
+
+ private void clearLocalDraftCopy() {
+ mIsDraftCachedCopy = false;
+ mAttachments.clear();
+ setMessageText("");
+ setMessageSubject("");
+ }
+
+ public String getConversationId() {
+ return mConversationId;
+ }
+
+ public String getMessageText() {
+ return mMessageText;
+ }
+
+ public String getMessageSubject() {
+ return mMessageSubject;
+ }
+
+ public boolean getIsMms() {
+ final int selfSubId = getSelfSubId();
+ return MmsSmsUtils.getRequireMmsForEmailAddress(mIncludeEmailAddress, selfSubId) ||
+ (mIsGroupConversation && MmsUtils.groupMmsEnabled(selfSubId)) ||
+ mMessageTextStats.getMessageLengthRequiresMms() || !mAttachments.isEmpty() ||
+ !TextUtils.isEmpty(mMessageSubject);
+ }
+
+ public boolean getIsGroupMmsConversation() {
+ return getIsMms() && mIsGroupConversation;
+ }
+
+ public String getSelfId() {
+ return mSelfId;
+ }
+
+ public int getNumMessagesToBeSent() {
+ return mMessageTextStats.getNumMessagesToBeSent();
+ }
+
+ public int getCodePointsRemainingInCurrentMessage() {
+ return mMessageTextStats.getCodePointsRemainingInCurrentMessage();
+ }
+
+ public int getSelfSubId() {
+ return mSubscriptionDataProvider == null ? ParticipantData.DEFAULT_SELF_SUB_ID :
+ mSubscriptionDataProvider.getConversationSelfSubId();
+ }
+
+ private void setMessageText(final String messageText, final boolean notify) {
+ mMessageText = messageText;
+ mMessageTextStats.updateMessageTextStats(getSelfSubId(), mMessageText);
+ if (notify) {
+ dispatchChanged(MESSAGE_TEXT_CHANGED);
+ }
+ }
+
+ private void setMessageSubject(final String subject, final boolean notify) {
+ mMessageSubject = subject;
+ if (notify) {
+ dispatchChanged(MESSAGE_SUBJECT_CHANGED);
+ }
+ }
+
+ public void setMessageText(final String messageText) {
+ setMessageText(messageText, false);
+ }
+
+ public void setMessageSubject(final String subject) {
+ setMessageSubject(subject, false);
+ }
+
+ public void addAttachments(final Collection<? extends MessagePartData> attachments) {
+ // If the incoming attachments contains a single-only attachment, we need to clear
+ // the existing attachments.
+ for (final MessagePartData data : attachments) {
+ if (data.isSinglePartOnly()) {
+ // clear any existing attachments because the attachment we're adding can only
+ // exist by itself.
+ destroyAttachments();
+ break;
+ }
+ }
+ // If the existing attachments contain a single-only attachment, we need to clear the
+ // existing attachments to make room for the incoming attachment.
+ for (final MessagePartData data : mAttachments) {
+ if (data.isSinglePartOnly()) {
+ // clear any existing attachments because the single attachment can only exist
+ // by itself
+ destroyAttachments();
+ break;
+ }
+ }
+ // If any of the pending attachments contain a single-only attachment, we need to clear the
+ // existing attachments to make room for the incoming attachment.
+ for (final MessagePartData data : mPendingAttachments) {
+ if (data.isSinglePartOnly()) {
+ // clear any existing attachments because the single attachment can only exist
+ // by itself
+ destroyAttachments();
+ break;
+ }
+ }
+
+ boolean reachedLimit = false;
+ for (final MessagePartData data : attachments) {
+ // Don't break out of loop even if limit has been reached so we can destroy all
+ // of the over-limit attachments.
+ reachedLimit |= addOneAttachmentNoNotify(data);
+ }
+ if (reachedLimit) {
+ dispatchAttachmentLimitReached();
+ }
+ dispatchChanged(ATTACHMENTS_CHANGED);
+ }
+
+ public boolean containsAttachment(final Uri contentUri) {
+ for (final MessagePartData existingAttachment : mAttachments) {
+ if (existingAttachment.getContentUri().equals(contentUri)) {
+ return true;
+ }
+ }
+
+ for (final PendingAttachmentData pendingAttachment : mPendingAttachments) {
+ if (pendingAttachment.getContentUri().equals(contentUri)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Try to add one attachment to the attachment list, while guarding against duplicates and
+ * going over the limit.
+ * @return true if the attachment limit was reached, false otherwise
+ */
+ private boolean addOneAttachmentNoNotify(final MessagePartData attachment) {
+ Assert.isTrue(attachment.isAttachment());
+ final boolean reachedLimit = getAttachmentCount() >= getAttachmentLimit();
+ if (reachedLimit || containsAttachment(attachment.getContentUri())) {
+ // Never go over the limit. Never add duplicated attachments.
+ attachment.destroyAsync();
+ return reachedLimit;
+ } else {
+ addAttachment(attachment, null /*pendingAttachment*/);
+ return false;
+ }
+ }
+
+ private void addAttachment(final MessagePartData attachment,
+ final PendingAttachmentData pendingAttachment) {
+ if (attachment != null && attachment.isSinglePartOnly()) {
+ // clear any existing attachments because the attachment we're adding can only
+ // exist by itself.
+ destroyAttachments();
+ }
+ if (pendingAttachment != null && pendingAttachment.isSinglePartOnly()) {
+ // clear any existing attachments because the attachment we're adding can only
+ // exist by itself.
+ destroyAttachments();
+ }
+ // If the existing attachments contain a single-only attachment, we need to clear the
+ // existing attachments to make room for the incoming attachment.
+ for (final MessagePartData data : mAttachments) {
+ if (data.isSinglePartOnly()) {
+ // clear any existing attachments because the single attachment can only exist
+ // by itself
+ destroyAttachments();
+ break;
+ }
+ }
+ // If any of the pending attachments contain a single-only attachment, we need to clear the
+ // existing attachments to make room for the incoming attachment.
+ for (final MessagePartData data : mPendingAttachments) {
+ if (data.isSinglePartOnly()) {
+ // clear any existing attachments because the single attachment can only exist
+ // by itself
+ destroyAttachments();
+ break;
+ }
+ }
+ if (attachment != null) {
+ mAttachments.add(attachment);
+ } else if (pendingAttachment != null) {
+ mPendingAttachments.add(pendingAttachment);
+ }
+ }
+
+ public void addPendingAttachment(final PendingAttachmentData pendingAttachment,
+ final BindingBase<DraftMessageData> binding) {
+ final boolean reachedLimit = addOnePendingAttachmentNoNotify(pendingAttachment,
+ binding.getBindingId());
+ if (reachedLimit) {
+ dispatchAttachmentLimitReached();
+ }
+ dispatchChanged(ATTACHMENTS_CHANGED);
+ }
+
+ /**
+ * Try to add one pending attachment, while guarding against duplicates and
+ * going over the limit.
+ * @return true if the attachment limit was reached, false otherwise
+ */
+ private boolean addOnePendingAttachmentNoNotify(final PendingAttachmentData pendingAttachment,
+ final String bindingId) {
+ final boolean reachedLimit = getAttachmentCount() >= getAttachmentLimit();
+ if (reachedLimit || containsAttachment(pendingAttachment.getContentUri())) {
+ // Never go over the limit. Never add duplicated attachments.
+ pendingAttachment.destroyAsync();
+ return reachedLimit;
+ } else {
+ Assert.isTrue(!mPendingAttachments.contains(pendingAttachment));
+ Assert.equals(PendingAttachmentData.STATE_PENDING, pendingAttachment.getCurrentState());
+ addAttachment(null /*attachment*/, pendingAttachment);
+
+ pendingAttachment.loadAttachmentForDraft(this, bindingId);
+ return false;
+ }
+ }
+
+ public void setSelfId(final String selfId, final boolean notify) {
+ LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: set selfId=" + selfId
+ + " for conversationId=" + mConversationId);
+ mSelfId = selfId;
+ if (notify) {
+ dispatchChanged(SELF_CHANGED);
+ }
+ }
+
+ public boolean hasAttachments() {
+ return !mAttachments.isEmpty();
+ }
+
+ public boolean hasPendingAttachments() {
+ return !mPendingAttachments.isEmpty();
+ }
+
+ private int getAttachmentCount() {
+ return mAttachments.size() + mPendingAttachments.size();
+ }
+
+ private int getVideoAttachmentCount() {
+ int count = 0;
+ for (MessagePartData part : mAttachments) {
+ if (part.isVideo()) {
+ count++;
+ }
+ }
+ for (MessagePartData part : mPendingAttachments) {
+ if (part.isVideo()) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private int getAttachmentLimit() {
+ return BugleGservices.get().getInt(
+ BugleGservicesKeys.MMS_ATTACHMENT_LIMIT,
+ BugleGservicesKeys.MMS_ATTACHMENT_LIMIT_DEFAULT);
+ }
+
+ public void removeAttachment(final MessagePartData attachment) {
+ for (final MessagePartData existingAttachment : mAttachments) {
+ if (existingAttachment.getContentUri().equals(attachment.getContentUri())) {
+ mAttachments.remove(existingAttachment);
+ existingAttachment.destroyAsync();
+ dispatchChanged(ATTACHMENTS_CHANGED);
+ break;
+ }
+ }
+ }
+
+ public void removeExistingAttachments(final Set<MessagePartData> attachmentsToRemove) {
+ boolean removed = false;
+ final Iterator<MessagePartData> iterator = mAttachments.iterator();
+ while (iterator.hasNext()) {
+ final MessagePartData existingAttachment = iterator.next();
+ if (attachmentsToRemove.contains(existingAttachment)) {
+ iterator.remove();
+ existingAttachment.destroyAsync();
+ removed = true;
+ }
+ }
+
+ if (removed) {
+ dispatchChanged(ATTACHMENTS_CHANGED);
+ }
+ }
+
+ public void removePendingAttachment(final PendingAttachmentData pendingAttachment) {
+ for (final PendingAttachmentData existingAttachment : mPendingAttachments) {
+ if (existingAttachment.getContentUri().equals(pendingAttachment.getContentUri())) {
+ mPendingAttachments.remove(pendingAttachment);
+ pendingAttachment.destroyAsync();
+ dispatchChanged(ATTACHMENTS_CHANGED);
+ break;
+ }
+ }
+ }
+
+ public void updatePendingAttachment(final MessagePartData updatedAttachment,
+ final PendingAttachmentData pendingAttachment) {
+ for (final PendingAttachmentData existingAttachment : mPendingAttachments) {
+ if (existingAttachment.getContentUri().equals(pendingAttachment.getContentUri())) {
+ mPendingAttachments.remove(pendingAttachment);
+ if (pendingAttachment.isSinglePartOnly()) {
+ updatedAttachment.setSinglePartOnly(true);
+ }
+ mAttachments.add(updatedAttachment);
+ dispatchChanged(ATTACHMENTS_CHANGED);
+ return;
+ }
+ }
+
+ // If we are here, this means the pending attachment has been dropped before the task
+ // to load it was completed. In this case destroy the temporarily staged file since it
+ // is no longer needed.
+ updatedAttachment.destroyAsync();
+ }
+
+ /**
+ * Remove the attachments from the draft and notify any listeners.
+ * @param flags typically this will be ATTACHMENTS_CHANGED. When attachments are cleared in a
+ * widget, flags will also contain WIDGET_CHANGED.
+ */
+ public void clearAttachments(final int flags) {
+ destroyAttachments();
+ dispatchChanged(flags);
+ }
+
+ public List<MessagePartData> getReadOnlyAttachments() {
+ return mReadOnlyAttachments;
+ }
+
+ public List<PendingAttachmentData> getReadOnlyPendingAttachments() {
+ return mReadOnlyPendingAttachments;
+ }
+
+ public boolean loadFromStorage(final BindingBase<DraftMessageData> binding,
+ final MessageData optionalIncomingDraft, boolean clearLocalDraft) {
+ LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: "
+ + (optionalIncomingDraft == null ? "loading" : "setting")
+ + " for conversationId=" + mConversationId);
+ if (clearLocalDraft) {
+ clearLocalDraftCopy();
+ }
+ final boolean isDraftCachedCopy = mIsDraftCachedCopy;
+ mIsDraftCachedCopy = false;
+ // Before reading message from db ensure the caller is bound to us (and knows the id)
+ if (mMonitor == null && !isDraftCachedCopy && isBound(binding.getBindingId())) {
+ mMonitor = ReadDraftDataAction.readDraftData(mConversationId,
+ optionalIncomingDraft, binding.getBindingId(), this);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Saves the current draft to db. This will save the draft and drop any pending attachments
+ * we have. The UI typically goes into the background when this is called, and instead of
+ * trying to persist the state of the pending attachments (the app may be killed, the activity
+ * may be destroyed), we simply drop the pending attachments for consistency.
+ */
+ public void saveToStorage(final BindingBase<DraftMessageData> binding) {
+ saveToStorageInternal(binding);
+ dropPendingAttachments();
+ }
+
+ private void saveToStorageInternal(final BindingBase<DraftMessageData> binding) {
+ // Create MessageData to store to db, but don't clear the in-memory copy so UI will
+ // continue to display it.
+ // If self id is null then we'll not attempt to change the conversation's self id.
+ final MessageData message = createMessageWithCurrentAttachments(false /* clearLocalCopy */);
+ // Before writing message to db ensure the caller is bound to us (and knows the id)
+ if (isBound(binding.getBindingId())){
+ WriteDraftMessageAction.writeDraftMessage(mConversationId, message);
+ }
+ }
+
+ /**
+ * Called when we are ready to send the message. This will assemble/return the MessageData for
+ * sending and clear the local draft data, both from memory and from DB. This will also bind
+ * the message data with a self Id through which the message will be sent.
+ *
+ * @param binding the binding object from our consumer. We need to make sure we are still bound
+ * to that binding before saving to storage.
+ */
+ public MessageData prepareMessageForSending(final BindingBase<DraftMessageData> binding) {
+ // We can't send the message while there's still stuff pending.
+ Assert.isTrue(!hasPendingAttachments());
+ mSending = true;
+ // Assembles the message to send and empty working draft data.
+ // If self id is null then message is sent with conversation's self id.
+ final MessageData messageToSend =
+ createMessageWithCurrentAttachments(true /* clearLocalCopy */);
+ // Note sending message will empty the draft data in DB.
+ mSending = false;
+ return messageToSend;
+ }
+
+ public boolean isSending() {
+ return mSending;
+ }
+
+ @Override // ReadDraftMessageActionListener.onReadDraftMessageSucceeded
+ public void onReadDraftDataSucceeded(final ReadDraftDataAction action, final Object data,
+ final MessageData message, final ConversationListItemData conversation) {
+ final String bindingId = (String) data;
+
+ // Before passing draft message on to ui ensure the data is bound to the same bindingid
+ if (isBound(bindingId)) {
+ mSelfId = message.getSelfId();
+ mIsGroupConversation = conversation.getIsGroup();
+ mIncludeEmailAddress = conversation.getIncludeEmailAddress();
+ updateFromMessageData(message, bindingId);
+ LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: draft loaded. "
+ + "conversationId=" + mConversationId + " selfId=" + mSelfId);
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "DraftMessageData: draft loaded but not bound. "
+ + "conversationId=" + mConversationId);
+ }
+ mMonitor = null;
+ }
+
+ @Override // ReadDraftMessageActionListener.onReadDraftDataFailed
+ public void onReadDraftDataFailed(final ReadDraftDataAction action, final Object data) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "DraftMessageData: draft not loaded. "
+ + "conversationId=" + mConversationId);
+ // The draft is now synced with actual MessageData and no longer a cached copy.
+ mIsDraftCachedCopy = false;
+ // Just clear the monitor - no update to draft data
+ mMonitor = null;
+ }
+
+ /**
+ * Check if Bugle is default sms app
+ * @return
+ */
+ public boolean getIsDefaultSmsApp() {
+ return PhoneUtils.getDefault().isDefaultSmsApp();
+ }
+
+ @Override //BindableData.unregisterListeners
+ protected void unregisterListeners() {
+ if (mMonitor != null) {
+ mMonitor.unregister();
+ }
+ mMonitor = null;
+ mListeners.clear();
+ }
+
+ private void destroyAttachments() {
+ for (final MessagePartData attachment : mAttachments) {
+ attachment.destroyAsync();
+ }
+ mAttachments.clear();
+ mPendingAttachments.clear();
+ }
+
+ private void dispatchChanged(final int changeFlags) {
+ // No change is expected to be made to the draft if it is in cached copy state.
+ if (mIsDraftCachedCopy) {
+ return;
+ }
+ // Any change in the draft will cancel any pending draft checking task, since the
+ // size/status of the draft may have changed.
+ if (mCheckDraftForSendTask != null) {
+ mCheckDraftForSendTask.cancel(true /* mayInterruptIfRunning */);
+ mCheckDraftForSendTask = null;
+ }
+ mListeners.onDraftChanged(this, changeFlags);
+ }
+
+ private void dispatchAttachmentLimitReached() {
+ mListeners.onDraftAttachmentLimitReached(this);
+ }
+
+ /**
+ * Drop any pending attachments that haven't finished. This is called after the UI goes to
+ * the background and we persist the draft data to the database.
+ */
+ private void dropPendingAttachments() {
+ mPendingAttachments.clear();
+ }
+
+ private boolean isDraftEmpty() {
+ return TextUtils.isEmpty(mMessageText) && mAttachments.isEmpty() &&
+ TextUtils.isEmpty(mMessageSubject);
+ }
+
+ public boolean isCheckingDraft() {
+ return mCheckDraftForSendTask != null && !mCheckDraftForSendTask.isCancelled();
+ }
+
+ public void checkDraftForAction(final boolean checkMessageSize, final int selfSubId,
+ final CheckDraftTaskCallback callback, final Binding<DraftMessageData> binding) {
+ new CheckDraftForSendTask(checkMessageSize, selfSubId, callback, binding)
+ .executeOnThreadPool((Void) null);
+ }
+
+ /**
+ * Allows us to have multiple data listeners for DraftMessageData
+ */
+ private class DraftMessageDataEventDispatcher
+ extends ArrayList<DraftMessageDataListener>
+ implements DraftMessageDataListener {
+
+ @Override
+ @RunsOnMainThread
+ public void onDraftChanged(DraftMessageData data, int changeFlags) {
+ Assert.isMainThread();
+ for (final DraftMessageDataListener listener : this) {
+ listener.onDraftChanged(data, changeFlags);
+ }
+ }
+
+ @Override
+ @RunsOnMainThread
+ public void onDraftAttachmentLimitReached(DraftMessageData data) {
+ Assert.isMainThread();
+ for (final DraftMessageDataListener listener : this) {
+ listener.onDraftAttachmentLimitReached(data);
+ }
+ }
+
+ @Override
+ @RunsOnMainThread
+ public void onDraftAttachmentLoadFailed() {
+ Assert.isMainThread();
+ for (final DraftMessageDataListener listener : this) {
+ listener.onDraftAttachmentLoadFailed();
+ }
+ }
+ }
+
+ public interface CheckDraftTaskCallback {
+ void onDraftChecked(DraftMessageData data, int result);
+ }
+
+ public class CheckDraftForSendTask extends SafeAsyncTask<Void, Void, Integer> {
+ public static final int RESULT_PASSED = 0;
+ public static final int RESULT_HAS_PENDING_ATTACHMENTS = 1;
+ public static final int RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS = 2;
+ public static final int RESULT_MESSAGE_OVER_LIMIT = 3;
+ public static final int RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED = 4;
+ public static final int RESULT_SIM_NOT_READY = 5;
+ private final boolean mCheckMessageSize;
+ private final int mSelfSubId;
+ private final CheckDraftTaskCallback mCallback;
+ private final String mBindingId;
+ private final List<MessagePartData> mAttachmentsCopy;
+ private int mPreExecuteResult = RESULT_PASSED;
+
+ public CheckDraftForSendTask(final boolean checkMessageSize, final int selfSubId,
+ final CheckDraftTaskCallback callback, final Binding<DraftMessageData> binding) {
+ mCheckMessageSize = checkMessageSize;
+ mSelfSubId = selfSubId;
+ mCallback = callback;
+ mBindingId = binding.getBindingId();
+ // Obtain an immutable copy of the attachment list so we can operate on it in the
+ // background thread.
+ mAttachmentsCopy = new ArrayList<MessagePartData>(mAttachments);
+
+ mCheckDraftForSendTask = this;
+ }
+
+ @Override
+ protected void onPreExecute() {
+ // Perform checking work that can happen on the main thread.
+ if (hasPendingAttachments()) {
+ mPreExecuteResult = RESULT_HAS_PENDING_ATTACHMENTS;
+ return;
+ }
+ if (getIsGroupMmsConversation()) {
+ try {
+ if (TextUtils.isEmpty(PhoneUtils.get(mSelfSubId).getSelfRawNumber(true))) {
+ mPreExecuteResult = RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS;
+ return;
+ }
+ } catch (IllegalStateException e) {
+ // This happens when there is no active subscription, e.g. on Nova
+ // when the phone switches carrier.
+ mPreExecuteResult = RESULT_SIM_NOT_READY;
+ return;
+ }
+ }
+ if (getVideoAttachmentCount() > MmsUtils.MAX_VIDEO_ATTACHMENT_COUNT) {
+ mPreExecuteResult = RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED;
+ return;
+ }
+ }
+
+ @Override
+ protected Integer doInBackgroundTimed(Void... params) {
+ if (mPreExecuteResult != RESULT_PASSED) {
+ return mPreExecuteResult;
+ }
+
+ if (mCheckMessageSize && getIsMessageOverLimit()) {
+ return RESULT_MESSAGE_OVER_LIMIT;
+ }
+ return RESULT_PASSED;
+ }
+
+ @Override
+ protected void onPostExecute(Integer result) {
+ mCheckDraftForSendTask = null;
+ // Only call back if we are bound to the original binding.
+ if (isBound(mBindingId) && !isCancelled()) {
+ mCallback.onDraftChecked(DraftMessageData.this, result);
+ } else {
+ if (!isBound(mBindingId)) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: draft not bound");
+ }
+ if (isCancelled()) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: draft is cancelled");
+ }
+ }
+ }
+
+ @Override
+ protected void onCancelled() {
+ mCheckDraftForSendTask = null;
+ }
+
+ /**
+ * 1. Check if the draft message contains too many attachments to send
+ * 2. Computes the minimum size that this message could be compressed/downsampled/encoded
+ * before sending and check if it meets the carrier max size for sending.
+ * @see MessagePartData#getMinimumSizeInBytesForSending()
+ */
+ @DoesNotRunOnMainThread
+ private boolean getIsMessageOverLimit() {
+ Assert.isNotMainThread();
+ if (mAttachmentsCopy.size() > getAttachmentLimit()) {
+ return true;
+ }
+
+ // Aggregate the size from all the attachments.
+ long totalSize = 0;
+ for (final MessagePartData attachment : mAttachmentsCopy) {
+ totalSize += attachment.getMinimumSizeInBytesForSending();
+ }
+ return totalSize > MmsConfig.get(mSelfSubId).getMaxMessageSize();
+ }
+ }
+
+ public void onPendingAttachmentLoadFailed(PendingAttachmentData data) {
+ mListeners.onDraftAttachmentLoadFailed();
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/GalleryGridItemData.java b/src/com/android/messaging/datamodel/data/GalleryGridItemData.java
new file mode 100644
index 0000000..6649757
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/GalleryGridItemData.java
@@ -0,0 +1,128 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.database.Cursor;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.provider.BaseColumns;
+import android.provider.MediaStore.Images.Media;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.media.FileImageRequestDescriptor;
+import com.android.messaging.datamodel.media.ImageRequest;
+import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
+import com.android.messaging.util.Assert;
+
+/**
+ * Provides data for GalleryGridItemView
+ */
+public class GalleryGridItemData {
+ public static final String[] IMAGE_PROJECTION = new String[] {
+ Media._ID,
+ Media.DATA,
+ Media.WIDTH,
+ Media.HEIGHT,
+ Media.MIME_TYPE,
+ Media.DATE_MODIFIED};
+
+ public static final String[] SPECIAL_ITEM_COLUMNS = new String[] {
+ BaseColumns._ID
+ };
+
+ private static final int INDEX_ID = 0;
+
+ // For local image gallery.
+ private static final int INDEX_DATA_PATH = 1;
+ private static final int INDEX_WIDTH = 2;
+ private static final int INDEX_HEIGHT = 3;
+ private static final int INDEX_MIME_TYPE = 4;
+ private static final int INDEX_DATE_MODIFIED = 5;
+
+ /** A special item's id for picking images from document picker */
+ public static final String ID_DOCUMENT_PICKER_ITEM = "-1";
+
+ private UriImageRequestDescriptor mImageData;
+ private String mContentType;
+ private boolean mIsDocumentPickerItem;
+ private long mDateSeconds;
+
+ public GalleryGridItemData() {
+ }
+
+ public void bind(final Cursor cursor, final int desiredWidth, final int desiredHeight) {
+ mIsDocumentPickerItem = TextUtils.equals(cursor.getString(INDEX_ID),
+ ID_DOCUMENT_PICKER_ITEM);
+ if (mIsDocumentPickerItem) {
+ mImageData = null;
+ mContentType = null;
+ } else {
+ int sourceWidth = cursor.getInt(INDEX_WIDTH);
+ int sourceHeight = cursor.getInt(INDEX_HEIGHT);
+
+ // Guard against bad data
+ if (sourceWidth <= 0) {
+ sourceWidth = ImageRequest.UNSPECIFIED_SIZE;
+ }
+ if (sourceHeight <= 0) {
+ sourceHeight = ImageRequest.UNSPECIFIED_SIZE;
+ }
+
+ mContentType = cursor.getString(INDEX_MIME_TYPE);
+ final String dateModified = cursor.getString(INDEX_DATE_MODIFIED);
+ mDateSeconds = !TextUtils.isEmpty(dateModified) ? Long.parseLong(dateModified) : -1;
+ mImageData = new FileImageRequestDescriptor(
+ cursor.getString(INDEX_DATA_PATH),
+ desiredWidth,
+ desiredHeight,
+ sourceWidth,
+ sourceHeight,
+ true /* canUseThumbnail */,
+ true /* allowCompression */,
+ true /* isStatic */);
+ }
+ }
+
+ public boolean isDocumentPickerItem() {
+ return mIsDocumentPickerItem;
+ }
+
+ public Uri getImageUri() {
+ return mImageData.uri;
+ }
+
+ public UriImageRequestDescriptor getImageRequestDescriptor() {
+ return mImageData;
+ }
+
+ public MessagePartData constructMessagePartData(final Rect startRect) {
+ Assert.isTrue(!mIsDocumentPickerItem);
+ return new MediaPickerMessagePartData(startRect, mContentType,
+ mImageData.uri, mImageData.sourceWidth, mImageData.sourceHeight);
+ }
+
+ /**
+ * @return The date in seconds. This can be negative if we could not retreive date info
+ */
+ public long getDateSeconds() {
+ return mDateSeconds;
+ }
+
+ public String getContentType() {
+ return mContentType;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/LaunchConversationData.java b/src/com/android/messaging/datamodel/data/LaunchConversationData.java
new file mode 100644
index 0000000..7eea580
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/LaunchConversationData.java
@@ -0,0 +1,90 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import com.android.messaging.datamodel.action.ActionMonitor;
+import com.android.messaging.datamodel.action.GetOrCreateConversationAction;
+import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener;
+import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor;
+import com.android.messaging.datamodel.binding.BindableData;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.RunsOnMainThread;
+import com.android.messaging.util.LogUtil;
+
+public class LaunchConversationData extends BindableData implements
+ GetOrCreateConversationActionListener {
+ public interface LaunchConversationDataListener {
+ void onGetOrCreateNewConversation(String conversationId);
+ void onGetOrCreateNewConversationFailed();
+ }
+
+ private LaunchConversationDataListener mListener;
+ private GetOrCreateConversationActionMonitor mMonitor;
+
+ public LaunchConversationData(final LaunchConversationDataListener listener) {
+ mListener = listener;
+ }
+
+ @Override
+ protected void unregisterListeners() {
+ mListener = null;
+ if (mMonitor != null) {
+ mMonitor.unregister();
+ }
+ mMonitor = null;
+ }
+
+ public void getOrCreateConversation(final BindingBase<LaunchConversationData> binding,
+ final String[] recipients) {
+ final String bindingId = binding.getBindingId();
+
+ // Start a new conversation from the list of contacts.
+ if (isBound(bindingId) && mMonitor == null) {
+ mMonitor = GetOrCreateConversationAction.getOrCreateConversation(recipients,
+ bindingId, this);
+ }
+ }
+
+ @Override
+ @RunsOnMainThread
+ public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor,
+ final Object data, final String conversationId) {
+ Assert.isTrue(monitor == mMonitor);
+ Assert.isTrue(conversationId != null);
+
+ final String bindingId = (String) data;
+ if (isBound(bindingId) && mListener != null) {
+ mListener.onGetOrCreateNewConversation(conversationId);
+ }
+
+ mMonitor = null;
+ }
+
+ @Override
+ @RunsOnMainThread
+ public void onGetOrCreateConversationFailed(final ActionMonitor monitor,
+ final Object data) {
+ Assert.isTrue(monitor == mMonitor);
+ final String bindingId = (String) data;
+ if (isBound(bindingId) && mListener != null) {
+ mListener.onGetOrCreateNewConversationFailed();
+ }
+ LogUtil.e(LogUtil.BUGLE_TAG, "onGetOrCreateConversationFailed");
+ mMonitor = null;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/MediaPickerData.java b/src/com/android/messaging/datamodel/data/MediaPickerData.java
new file mode 100644
index 0000000..b0c8bf7
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/MediaPickerData.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.datamodel.data;
+
+import android.app.LoaderManager;
+import android.content.Context;
+import android.content.Loader;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+
+import com.android.messaging.datamodel.BoundCursorLoader;
+import com.android.messaging.datamodel.GalleryBoundCursorLoader;
+import com.android.messaging.datamodel.binding.BindableData;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.BuglePrefsKeys;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Services data needs for MediaPicker.
+ */
+public class MediaPickerData extends BindableData {
+ public interface MediaPickerDataListener {
+ void onMediaPickerDataUpdated(MediaPickerData mediaPickerData, Object data, int loaderId);
+ }
+
+ private static final String BINDING_ID = "bindingId";
+ private final Context mContext;
+ private LoaderManager mLoaderManager;
+ private final GalleryLoaderCallbacks mGalleryLoaderCallbacks;
+ private MediaPickerDataListener mListener;
+
+ public MediaPickerData(final Context context) {
+ mContext = context;
+ mGalleryLoaderCallbacks = new GalleryLoaderCallbacks();
+ }
+
+ public static final int GALLERY_IMAGE_LOADER = 1;
+
+ /**
+ * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times.
+ */
+ private class GalleryLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
+ final String bindingId = args.getString(BINDING_ID);
+ // Check if data still bound to the requesting ui element
+ if (isBound(bindingId)) {
+ switch (id) {
+ case GALLERY_IMAGE_LOADER:
+ return new GalleryBoundCursorLoader(bindingId, mContext);
+
+ default:
+ Assert.fail("Unknown loader id for gallery picker!");
+ break;
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding the media picker");
+ }
+ return null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onLoadFinished(final Loader<Cursor> loader, final Cursor data) {
+ final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
+ if (isBound(cursorLoader.getBindingId())) {
+ switch (loader.getId()) {
+ case GALLERY_IMAGE_LOADER:
+ mListener.onMediaPickerDataUpdated(MediaPickerData.this, data,
+ GALLERY_IMAGE_LOADER);
+ break;
+
+ default:
+ Assert.fail("Unknown loader id for gallery picker!");
+ break;
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding the media picker");
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onLoaderReset(final Loader<Cursor> loader) {
+ final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
+ if (isBound(cursorLoader.getBindingId())) {
+ switch (loader.getId()) {
+ case GALLERY_IMAGE_LOADER:
+ mListener.onMediaPickerDataUpdated(MediaPickerData.this, null,
+ GALLERY_IMAGE_LOADER);
+ break;
+
+ default:
+ Assert.fail("Unknown loader id for media picker!");
+ break;
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding the media picker");
+ }
+ }
+ }
+
+
+
+ public void startLoader(final int loaderId, final BindingBase<MediaPickerData> binding,
+ @Nullable Bundle args, final MediaPickerDataListener listener) {
+ if (args == null) {
+ args = new Bundle();
+ }
+ args.putString(BINDING_ID, binding.getBindingId());
+ if (loaderId == GALLERY_IMAGE_LOADER) {
+ mLoaderManager.initLoader(loaderId, args, mGalleryLoaderCallbacks).forceLoad();
+ } else {
+ Assert.fail("Unsupported loader id for media picker!");
+ }
+ mListener = listener;
+ }
+
+ public void destroyLoader(final int loaderId) {
+ mLoaderManager.destroyLoader(loaderId);
+ }
+
+ public void init(final LoaderManager loaderManager) {
+ mLoaderManager = loaderManager;
+ }
+
+ @Override
+ protected void unregisterListeners() {
+ // This could be null if we bind but the caller doesn't init the BindableData
+ if (mLoaderManager != null) {
+ mLoaderManager.destroyLoader(GALLERY_IMAGE_LOADER);
+ mLoaderManager = null;
+ }
+ }
+
+ /**
+ * Gets the last selected chooser index, or -1 if no selection has been saved.
+ */
+ public int getSelectedChooserIndex() {
+ return BuglePrefs.getApplicationPrefs().getInt(
+ BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX,
+ BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX_DEFAULT);
+ }
+
+ /**
+ * Saves the selected media chooser index.
+ * @param selectedIndex the selected media chooser index.
+ */
+ public void saveSelectedChooserIndex(final int selectedIndex) {
+ BuglePrefs.getApplicationPrefs().putInt(BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX,
+ selectedIndex);
+ }
+
+} \ No newline at end of file
diff --git a/src/com/android/messaging/datamodel/data/MediaPickerMessagePartData.java b/src/com/android/messaging/datamodel/data/MediaPickerMessagePartData.java
new file mode 100644
index 0000000..7de9166
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/MediaPickerMessagePartData.java
@@ -0,0 +1,64 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.graphics.Rect;
+import android.net.Uri;
+
+public class MediaPickerMessagePartData extends MessagePartData {
+ private final Rect mStartRect;
+
+ public MediaPickerMessagePartData(final Rect startRect, final String contentType,
+ final Uri contentUri, final int width, final int height) {
+ this(startRect, null /* messageText */, contentType, contentUri, width, height);
+ }
+
+ public MediaPickerMessagePartData(final Rect startRect, final String messageText,
+ final String contentType, final Uri contentUri, final int width, final int height) {
+ this(startRect, messageText, contentType, contentUri, width, height,
+ false /*onlySingleAttachment*/);
+ }
+
+ public MediaPickerMessagePartData(final Rect startRect, final String contentType,
+ final Uri contentUri, final int width, final int height,
+ final boolean onlySingleAttachment) {
+ this(startRect, null /* messageText */, contentType, contentUri, width, height,
+ onlySingleAttachment);
+ }
+
+ public MediaPickerMessagePartData(final Rect startRect, final String messageText,
+ final String contentType, final Uri contentUri, final int width, final int height,
+ final boolean onlySingleAttachment) {
+ super(messageText, contentType, contentUri, width, height, onlySingleAttachment);
+ mStartRect = startRect;
+ }
+
+ /**
+ * @return The starting rect to animate the attachment preview from in order to perform a smooth
+ * transition
+ */
+ public Rect getStartRect() {
+ return mStartRect;
+ }
+
+ /**
+ * Modify the start rect of the attachment.
+ */
+ public void setStartRect(final Rect startRect) {
+ mStartRect.set(startRect);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/MessageData.java b/src/com/android/messaging/datamodel/data/MessageData.java
new file mode 100644
index 0000000..a3698a9
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/MessageData.java
@@ -0,0 +1,922 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteStatement;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.Dates;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.OsUtil;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class MessageData implements Parcelable {
+ private static final String[] sProjection = {
+ MessageColumns._ID,
+ MessageColumns.CONVERSATION_ID,
+ MessageColumns.SENDER_PARTICIPANT_ID,
+ MessageColumns.SELF_PARTICIPANT_ID,
+ MessageColumns.SENT_TIMESTAMP,
+ MessageColumns.RECEIVED_TIMESTAMP,
+ MessageColumns.SEEN,
+ MessageColumns.READ,
+ MessageColumns.PROTOCOL,
+ MessageColumns.STATUS,
+ MessageColumns.SMS_MESSAGE_URI,
+ MessageColumns.SMS_PRIORITY,
+ MessageColumns.SMS_MESSAGE_SIZE,
+ MessageColumns.MMS_SUBJECT,
+ MessageColumns.MMS_TRANSACTION_ID,
+ MessageColumns.MMS_CONTENT_LOCATION,
+ MessageColumns.MMS_EXPIRY,
+ MessageColumns.RAW_TELEPHONY_STATUS,
+ MessageColumns.RETRY_START_TIMESTAMP,
+ };
+
+ private static final int INDEX_ID = 0;
+ private static final int INDEX_CONVERSATION_ID = 1;
+ private static final int INDEX_PARTICIPANT_ID = 2;
+ private static final int INDEX_SELF_ID = 3;
+ private static final int INDEX_SENT_TIMESTAMP = 4;
+ private static final int INDEX_RECEIVED_TIMESTAMP = 5;
+ private static final int INDEX_SEEN = 6;
+ private static final int INDEX_READ = 7;
+ private static final int INDEX_PROTOCOL = 8;
+ private static final int INDEX_BUGLE_STATUS = 9;
+ private static final int INDEX_SMS_MESSAGE_URI = 10;
+ private static final int INDEX_SMS_PRIORITY = 11;
+ private static final int INDEX_SMS_MESSAGE_SIZE = 12;
+ private static final int INDEX_MMS_SUBJECT = 13;
+ private static final int INDEX_MMS_TRANSACTION_ID = 14;
+ private static final int INDEX_MMS_CONTENT_LOCATION = 15;
+ private static final int INDEX_MMS_EXPIRY = 16;
+ private static final int INDEX_RAW_TELEPHONY_STATUS = 17;
+ private static final int INDEX_RETRY_START_TIMESTAMP = 18;
+
+ // SQL statement to insert a "complete" message row (columns based on the projection above).
+ private static final String INSERT_MESSAGE_SQL =
+ "INSERT INTO " + DatabaseHelper.MESSAGES_TABLE + " ( "
+ + TextUtils.join(", ", Arrays.copyOfRange(sProjection, 1,
+ INDEX_RETRY_START_TIMESTAMP + 1))
+ + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+
+ private String mMessageId;
+ private String mConversationId;
+ private String mParticipantId;
+ private String mSelfId;
+ private long mSentTimestamp;
+ private long mReceivedTimestamp;
+ private boolean mSeen;
+ private boolean mRead;
+ private int mProtocol;
+ private Uri mSmsMessageUri;
+ private int mSmsPriority;
+ private long mSmsMessageSize;
+ private String mMmsSubject;
+ private String mMmsTransactionId;
+ private String mMmsContentLocation;
+ private long mMmsExpiry;
+ private int mRawStatus;
+ private int mStatus;
+ private final ArrayList<MessagePartData> mParts;
+ private long mRetryStartTimestamp;
+
+ // PROTOCOL Values
+ public static final int PROTOCOL_UNKNOWN = -1; // Unknown type
+ public static final int PROTOCOL_SMS = 0; // SMS message
+ public static final int PROTOCOL_MMS = 1; // MMS message
+ public static final int PROTOCOL_MMS_PUSH_NOTIFICATION = 2; // MMS WAP push notification
+
+ // Bugle STATUS Values
+ public static final int BUGLE_STATUS_UNKNOWN = 0;
+
+ // Outgoing
+ public static final int BUGLE_STATUS_OUTGOING_COMPLETE = 1;
+ public static final int BUGLE_STATUS_OUTGOING_DELIVERED = 2;
+ // Transitions to either YET_TO_SEND or SEND_AFTER_PROCESSING depending attachments.
+ public static final int BUGLE_STATUS_OUTGOING_DRAFT = 3;
+ public static final int BUGLE_STATUS_OUTGOING_YET_TO_SEND = 4;
+ public static final int BUGLE_STATUS_OUTGOING_SENDING = 5;
+ public static final int BUGLE_STATUS_OUTGOING_RESENDING = 6;
+ public static final int BUGLE_STATUS_OUTGOING_AWAITING_RETRY = 7;
+ public static final int BUGLE_STATUS_OUTGOING_FAILED = 8;
+ public static final int BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER = 9;
+
+ // Incoming
+ public static final int BUGLE_STATUS_INCOMING_COMPLETE = 100;
+ public static final int BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD = 101;
+ public static final int BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD = 102;
+ public static final int BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING = 103;
+ public static final int BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD = 104;
+ public static final int BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING = 105;
+ public static final int BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED = 106;
+ public static final int BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE = 107;
+
+ public static final String getStatusDescription(int status) {
+ switch (status) {
+ case BUGLE_STATUS_UNKNOWN:
+ return "UNKNOWN";
+ case BUGLE_STATUS_OUTGOING_COMPLETE:
+ return "OUTGOING_COMPLETE";
+ case BUGLE_STATUS_OUTGOING_DELIVERED:
+ return "OUTGOING_DELIVERED";
+ case BUGLE_STATUS_OUTGOING_DRAFT:
+ return "OUTGOING_DRAFT";
+ case BUGLE_STATUS_OUTGOING_YET_TO_SEND:
+ return "OUTGOING_YET_TO_SEND";
+ case BUGLE_STATUS_OUTGOING_SENDING:
+ return "OUTGOING_SENDING";
+ case BUGLE_STATUS_OUTGOING_RESENDING:
+ return "OUTGOING_RESENDING";
+ case BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
+ return "OUTGOING_AWAITING_RETRY";
+ case BUGLE_STATUS_OUTGOING_FAILED:
+ return "OUTGOING_FAILED";
+ case BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
+ return "OUTGOING_FAILED_EMERGENCY_NUMBER";
+ case BUGLE_STATUS_INCOMING_COMPLETE:
+ return "INCOMING_COMPLETE";
+ case BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD:
+ return "INCOMING_YET_TO_MANUAL_DOWNLOAD";
+ case BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
+ return "INCOMING_RETRYING_MANUAL_DOWNLOAD";
+ case BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING:
+ return "INCOMING_MANUAL_DOWNLOADING";
+ case BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
+ return "INCOMING_RETRYING_AUTO_DOWNLOAD";
+ case BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING:
+ return "INCOMING_AUTO_DOWNLOADING";
+ case BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED:
+ return "INCOMING_DOWNLOAD_FAILED";
+ case BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE:
+ return "INCOMING_EXPIRED_OR_NOT_AVAILABLE";
+ default:
+ return String.valueOf(status) + " (check MessageData)";
+ }
+ }
+
+ // All incoming messages expect to have status >= BUGLE_STATUS_FIRST_INCOMING
+ public static final int BUGLE_STATUS_FIRST_INCOMING = BUGLE_STATUS_INCOMING_COMPLETE;
+
+ // Detailed MMS failures. Most of the values are defined in PduHeaders. However, a few are
+ // defined here instead. These are never returned in the MMS HTTP response, but are used
+ // internally. The values here must not conflict with any of the existing PduHeader values.
+ public static final int RAW_TELEPHONY_STATUS_UNDEFINED = MmsUtils.PDU_HEADER_VALUE_UNDEFINED;
+ public static final int RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG = 10000;
+
+ // Unknown result code for MMS sending/downloading. This is used as the default value
+ // for result code returned from platform MMS API.
+ public static final int UNKNOWN_RESULT_CODE = 0;
+
+ /**
+ * Create an "empty" message
+ */
+ public MessageData() {
+ mParts = new ArrayList<MessagePartData>();
+ }
+
+ public static String[] getProjection() {
+ return sProjection;
+ }
+
+ /**
+ * Create a draft message for a particular conversation based on supplied content
+ */
+ public static MessageData createDraftMessage(final String conversationId,
+ final String selfId, final MessageData content) {
+ final MessageData message = new MessageData();
+ message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT;
+ message.mProtocol = PROTOCOL_UNKNOWN;
+ message.mConversationId = conversationId;
+ message.mParticipantId = selfId;
+ message.mReceivedTimestamp = System.currentTimeMillis();
+ if (content == null) {
+ message.mParts.add(MessagePartData.createTextMessagePart(""));
+ } else {
+ if (!TextUtils.isEmpty(content.mParticipantId)) {
+ message.mParticipantId = content.mParticipantId;
+ }
+ if (!TextUtils.isEmpty(content.mMmsSubject)) {
+ message.mMmsSubject = content.mMmsSubject;
+ }
+ for (final MessagePartData part : content.getParts()) {
+ message.mParts.add(part);
+ }
+ }
+ message.mSelfId = selfId;
+ return message;
+ }
+
+ /**
+ * Create a draft sms message for a particular conversation
+ */
+ public static MessageData createDraftSmsMessage(final String conversationId,
+ final String selfId, final String messageText) {
+ final MessageData message = new MessageData();
+ message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT;
+ message.mProtocol = PROTOCOL_SMS;
+ message.mConversationId = conversationId;
+ message.mParticipantId = selfId;
+ message.mSelfId = selfId;
+ message.mParts.add(MessagePartData.createTextMessagePart(messageText));
+ message.mReceivedTimestamp = System.currentTimeMillis();
+ return message;
+ }
+
+ /**
+ * Create a draft mms message for a particular conversation
+ */
+ public static MessageData createDraftMmsMessage(final String conversationId,
+ final String selfId, final String messageText, final String subjectText) {
+ final MessageData message = new MessageData();
+ message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT;
+ message.mProtocol = PROTOCOL_MMS;
+ message.mConversationId = conversationId;
+ message.mParticipantId = selfId;
+ message.mSelfId = selfId;
+ message.mMmsSubject = subjectText;
+ message.mReceivedTimestamp = System.currentTimeMillis();
+ if (!TextUtils.isEmpty(messageText)) {
+ message.mParts.add(MessagePartData.createTextMessagePart(messageText));
+ }
+ return message;
+ }
+
+ /**
+ * Create a message received from a particular number in a particular conversation
+ */
+ public static MessageData createReceivedSmsMessage(final Uri uri, final String conversationId,
+ final String participantId, final String selfId, final String messageText,
+ final String subject, final long sent, final long recieved,
+ final boolean seen, final boolean read) {
+ final MessageData message = new MessageData();
+ message.mSmsMessageUri = uri;
+ message.mConversationId = conversationId;
+ message.mParticipantId = participantId;
+ message.mSelfId = selfId;
+ message.mProtocol = PROTOCOL_SMS;
+ message.mStatus = BUGLE_STATUS_INCOMING_COMPLETE;
+ message.mMmsSubject = subject;
+ message.mReceivedTimestamp = recieved;
+ message.mSentTimestamp = sent;
+ message.mParts.add(MessagePartData.createTextMessagePart(messageText));
+ message.mSeen = seen;
+ message.mRead = read;
+ return message;
+ }
+
+ /**
+ * Create a message not yet associated with a particular conversation
+ */
+ public static MessageData createSharedMessage(final String messageText) {
+ final MessageData message = new MessageData();
+ message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT;
+ if (!TextUtils.isEmpty(messageText)) {
+ message.mParts.add(MessagePartData.createTextMessagePart(messageText));
+ }
+ return message;
+ }
+
+ /**
+ * Create a message from Sms table fields
+ */
+ public static MessageData createSmsMessage(final String messageUri, final String participantId,
+ final String selfId, final String conversationId, final int bugleStatus,
+ final boolean seen, final boolean read, final long sent,
+ final long recieved, final String messageText) {
+ final MessageData message = new MessageData();
+ message.mParticipantId = participantId;
+ message.mSelfId = selfId;
+ message.mConversationId = conversationId;
+ message.mSentTimestamp = sent;
+ message.mReceivedTimestamp = recieved;
+ message.mSeen = seen;
+ message.mRead = read;
+ message.mProtocol = PROTOCOL_SMS;
+ message.mStatus = bugleStatus;
+ message.mSmsMessageUri = Uri.parse(messageUri);
+ message.mParts.add(MessagePartData.createTextMessagePart(messageText));
+ return message;
+ }
+
+ /**
+ * Create a message from Mms table fields
+ */
+ public static MessageData createMmsMessage(final String messageUri, final String participantId,
+ final String selfId, final String conversationId, final boolean isNotification,
+ final int bugleStatus, final String contentLocation, final String transactionId,
+ final int smsPriority, final String subject, final boolean seen, final boolean read,
+ final long size, final int rawStatus, final long expiry, final long sent,
+ final long received) {
+ final MessageData message = new MessageData();
+ message.mParticipantId = participantId;
+ message.mSelfId = selfId;
+ message.mConversationId = conversationId;
+ message.mSentTimestamp = sent;
+ message.mReceivedTimestamp = received;
+ message.mMmsContentLocation = contentLocation;
+ message.mMmsTransactionId = transactionId;
+ message.mSeen = seen;
+ message.mRead = read;
+ message.mStatus = bugleStatus;
+ message.mProtocol = (isNotification ? PROTOCOL_MMS_PUSH_NOTIFICATION : PROTOCOL_MMS);
+ message.mSmsMessageUri = Uri.parse(messageUri);
+ message.mSmsPriority = smsPriority;
+ message.mSmsMessageSize = size;
+ message.mMmsSubject = subject;
+ message.mMmsExpiry = expiry;
+ message.mRawStatus = rawStatus;
+ if (bugleStatus == BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD ||
+ bugleStatus == BUGLE_STATUS_OUTGOING_RESENDING) {
+ // Set the retry start timestamp if this message is already in process of retrying
+ // Either as autodownload is starting or sending already in progress (MMS update)
+ message.mRetryStartTimestamp = received;
+ }
+ return message;
+ }
+
+ public void addPart(final MessagePartData part) {
+ if (part instanceof PendingAttachmentData) {
+ // Pending attachments may only be added to shared message data that's not associated
+ // with any particular conversation, in order to store shared images.
+ Assert.isTrue(mConversationId == null);
+ }
+ mParts.add(part);
+ }
+
+ public Iterable<MessagePartData> getParts() {
+ return mParts;
+ }
+
+ public void bind(final Cursor cursor) {
+ mMessageId = cursor.getString(INDEX_ID);
+ mConversationId = cursor.getString(INDEX_CONVERSATION_ID);
+ mParticipantId = cursor.getString(INDEX_PARTICIPANT_ID);
+ mSelfId = cursor.getString(INDEX_SELF_ID);
+ mSentTimestamp = cursor.getLong(INDEX_SENT_TIMESTAMP);
+ mReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP);
+ mSeen = (cursor.getInt(INDEX_SEEN) != 0);
+ mRead = (cursor.getInt(INDEX_READ) != 0);
+ mProtocol = cursor.getInt(INDEX_PROTOCOL);
+ mStatus = cursor.getInt(INDEX_BUGLE_STATUS);
+ final String smsMessageUri = cursor.getString(INDEX_SMS_MESSAGE_URI);
+ mSmsMessageUri = (smsMessageUri == null) ? null : Uri.parse(smsMessageUri);
+ mSmsPriority = cursor.getInt(INDEX_SMS_PRIORITY);
+ mSmsMessageSize = cursor.getLong(INDEX_SMS_MESSAGE_SIZE);
+ mMmsExpiry = cursor.getLong(INDEX_MMS_EXPIRY);
+ mRawStatus = cursor.getInt(INDEX_RAW_TELEPHONY_STATUS);
+ mMmsSubject = cursor.getString(INDEX_MMS_SUBJECT);
+ mMmsTransactionId = cursor.getString(INDEX_MMS_TRANSACTION_ID);
+ mMmsContentLocation = cursor.getString(INDEX_MMS_CONTENT_LOCATION);
+ mRetryStartTimestamp = cursor.getLong(INDEX_RETRY_START_TIMESTAMP);
+ }
+
+ /**
+ * Bind to the draft message data for a conversation. The conversation's self id is used as
+ * the draft's self id.
+ */
+ public void bindDraft(final Cursor cursor, final String conversationSelfId) {
+ bind(cursor);
+ mSelfId = conversationSelfId;
+ }
+
+ protected static String getParticipantId(final Cursor cursor) {
+ return cursor.getString(INDEX_PARTICIPANT_ID);
+ }
+
+ public void populate(final ContentValues values) {
+ values.put(MessageColumns.CONVERSATION_ID, mConversationId);
+ values.put(MessageColumns.SENDER_PARTICIPANT_ID, mParticipantId);
+ values.put(MessageColumns.SELF_PARTICIPANT_ID, mSelfId);
+ values.put(MessageColumns.SENT_TIMESTAMP, mSentTimestamp);
+ values.put(MessageColumns.RECEIVED_TIMESTAMP, mReceivedTimestamp);
+ values.put(MessageColumns.SEEN, mSeen ? 1 : 0);
+ values.put(MessageColumns.READ, mRead ? 1 : 0);
+ values.put(MessageColumns.PROTOCOL, mProtocol);
+ values.put(MessageColumns.STATUS, mStatus);
+ final String smsMessageUri = ((mSmsMessageUri == null) ? null : mSmsMessageUri.toString());
+ values.put(MessageColumns.SMS_MESSAGE_URI, smsMessageUri);
+ values.put(MessageColumns.SMS_PRIORITY, mSmsPriority);
+ values.put(MessageColumns.SMS_MESSAGE_SIZE, mSmsMessageSize);
+ values.put(MessageColumns.MMS_EXPIRY, mMmsExpiry);
+ values.put(MessageColumns.MMS_SUBJECT, mMmsSubject);
+ values.put(MessageColumns.MMS_TRANSACTION_ID, mMmsTransactionId);
+ values.put(MessageColumns.MMS_CONTENT_LOCATION, mMmsContentLocation);
+ values.put(MessageColumns.RAW_TELEPHONY_STATUS, mRawStatus);
+ values.put(MessageColumns.RETRY_START_TIMESTAMP, mRetryStartTimestamp);
+ }
+
+ /**
+ * Note this is not thread safe so callers need to make sure they own the wrapper + statements
+ * while they call this and use the returned value.
+ */
+ public SQLiteStatement getInsertStatement(final DatabaseWrapper db) {
+ final SQLiteStatement insert = db.getStatementInTransaction(
+ DatabaseWrapper.INDEX_INSERT_MESSAGE, INSERT_MESSAGE_SQL);
+ insert.clearBindings();
+ insert.bindString(INDEX_CONVERSATION_ID, mConversationId);
+ insert.bindString(INDEX_PARTICIPANT_ID, mParticipantId);
+ insert.bindString(INDEX_SELF_ID, mSelfId);
+ insert.bindLong(INDEX_SENT_TIMESTAMP, mSentTimestamp);
+ insert.bindLong(INDEX_RECEIVED_TIMESTAMP, mReceivedTimestamp);
+ insert.bindLong(INDEX_SEEN, mSeen ? 1 : 0);
+ insert.bindLong(INDEX_READ, mRead ? 1 : 0);
+ insert.bindLong(INDEX_PROTOCOL, mProtocol);
+ insert.bindLong(INDEX_BUGLE_STATUS, mStatus);
+ if (mSmsMessageUri != null) {
+ insert.bindString(INDEX_SMS_MESSAGE_URI, mSmsMessageUri.toString());
+ }
+ insert.bindLong(INDEX_SMS_PRIORITY, mSmsPriority);
+ insert.bindLong(INDEX_SMS_MESSAGE_SIZE, mSmsMessageSize);
+ insert.bindLong(INDEX_MMS_EXPIRY, mMmsExpiry);
+ if (mMmsSubject != null) {
+ insert.bindString(INDEX_MMS_SUBJECT, mMmsSubject);
+ }
+ if (mMmsTransactionId != null) {
+ insert.bindString(INDEX_MMS_TRANSACTION_ID, mMmsTransactionId);
+ }
+ if (mMmsContentLocation != null) {
+ insert.bindString(INDEX_MMS_CONTENT_LOCATION, mMmsContentLocation);
+ }
+ insert.bindLong(INDEX_RAW_TELEPHONY_STATUS, mRawStatus);
+ insert.bindLong(INDEX_RETRY_START_TIMESTAMP, mRetryStartTimestamp);
+ return insert;
+ }
+
+ public final String getMessageId() {
+ return mMessageId;
+ }
+
+ public final String getConversationId() {
+ return mConversationId;
+ }
+
+ public final String getParticipantId() {
+ return mParticipantId;
+ }
+
+ public final String getSelfId() {
+ return mSelfId;
+ }
+
+ public final long getSentTimeStamp() {
+ return mSentTimestamp;
+ }
+
+ public final long getReceivedTimeStamp() {
+ return mReceivedTimestamp;
+ }
+
+ public final String getFormattedReceivedTimeStamp() {
+ return Dates.getMessageTimeString(mReceivedTimestamp).toString();
+ }
+
+ public final int getProtocol() {
+ return mProtocol;
+ }
+
+ public final int getStatus() {
+ return mStatus;
+ }
+
+ public final Uri getSmsMessageUri() {
+ return mSmsMessageUri;
+ }
+
+ public final int getSmsPriority() {
+ return mSmsPriority;
+ }
+
+ public final long getSmsMessageSize() {
+ return mSmsMessageSize;
+ }
+
+ public final String getMmsSubject() {
+ return mMmsSubject;
+ }
+
+ public final void setMmsSubject(final String subject) {
+ mMmsSubject = subject;
+ }
+
+ public final String getMmsContentLocation() {
+ return mMmsContentLocation;
+ }
+
+ public final String getMmsTransactionId() {
+ return mMmsTransactionId;
+ }
+
+ public final boolean getMessageSeen() {
+ return mSeen;
+ }
+
+ /**
+ * For incoming MMS messages this returns the retrieve-status value
+ * For sent MMS messages this returns the response-status value
+ * See PduHeaders.java for possible values
+ * Otherwise (SMS etc) this is RAW_TELEPHONY_STATUS_UNDEFINED
+ */
+ public final int getRawTelephonyStatus() {
+ return mRawStatus;
+ }
+
+ public final void setMessageSeen(final boolean hasSeen) {
+ mSeen = hasSeen;
+ }
+
+ public final boolean getInResendWindow(final long now) {
+ final long maxAgeToResend = BugleGservices.get().getLong(
+ BugleGservicesKeys.MESSAGE_RESEND_TIMEOUT_MS,
+ BugleGservicesKeys.MESSAGE_RESEND_TIMEOUT_MS_DEFAULT);
+ final long age = now - mRetryStartTimestamp;
+ return age < maxAgeToResend;
+ }
+
+ public final boolean getInDownloadWindow(final long now) {
+ final long maxAgeToRedownload = BugleGservices.get().getLong(
+ BugleGservicesKeys.MESSAGE_DOWNLOAD_TIMEOUT_MS,
+ BugleGservicesKeys.MESSAGE_DOWNLOAD_TIMEOUT_MS_DEFAULT);
+ final long age = now - mRetryStartTimestamp;
+ return age < maxAgeToRedownload;
+ }
+
+ static boolean getShowDownloadMessage(final int status) {
+ if (OsUtil.isSecondaryUser()) {
+ // Secondary users can't download mms's. Mms's are downloaded by bugle running as the
+ // primary user.
+ return false;
+ }
+ // Should show option for manual download iff status is manual download or failed
+ return (status == BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED ||
+ status == BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD ||
+ // If debug is enabled, allow to download an expired or unavailable message.
+ (DebugUtils.isDebugEnabled()
+ && status == BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE));
+ }
+
+ public boolean canDownloadMessage() {
+ if (OsUtil.isSecondaryUser()) {
+ // Secondary users can't download mms's. Mms's are downloaded by bugle running as the
+ // primary user.
+ return false;
+ }
+ // Can download iff status is retrying auto/manual downloading
+ return (mStatus == BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD ||
+ mStatus == BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD);
+ }
+
+ public boolean canRedownloadMessage() {
+ if (OsUtil.isSecondaryUser()) {
+ // Secondary users can't download mms's. Mms's are downloaded by bugle running as the
+ // primary user.
+ return false;
+ }
+ // Can redownload iff status is manual download not started or download failed
+ return (mStatus == BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED ||
+ mStatus == BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD ||
+ // If debug is enabled, allow to download an expired or unavailable message.
+ (DebugUtils.isDebugEnabled()
+ && mStatus == BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE));
+ }
+
+ static boolean getShowResendMessage(final int status) {
+ // Should show option to resend iff status is failed
+ return (status == BUGLE_STATUS_OUTGOING_FAILED);
+ }
+
+ static boolean getOneClickResendMessage(final int status, final int rawStatus) {
+ // Should show option to resend iff status is failed
+ return (status == BUGLE_STATUS_OUTGOING_FAILED
+ && rawStatus == RAW_TELEPHONY_STATUS_UNDEFINED);
+ }
+
+ public boolean canResendMessage() {
+ // Manual retry allowed only from failed
+ return (mStatus == BUGLE_STATUS_OUTGOING_FAILED);
+ }
+
+ public boolean canSendMessage() {
+ // Sending messages must be in yet_to_send or awaiting_retry state
+ return (mStatus == BUGLE_STATUS_OUTGOING_YET_TO_SEND ||
+ mStatus == BUGLE_STATUS_OUTGOING_AWAITING_RETRY);
+ }
+
+ public final boolean getYetToSend() {
+ return (mStatus == BUGLE_STATUS_OUTGOING_YET_TO_SEND);
+ }
+
+ public final boolean getIsMms() {
+ return mProtocol == MessageData.PROTOCOL_MMS
+ || mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION;
+ }
+
+ public static final boolean getIsMmsNotification(final int protocol) {
+ return (protocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION);
+ }
+
+ public final boolean getIsMmsNotification() {
+ return getIsMmsNotification(mProtocol);
+ }
+
+ public static final boolean getIsSms(final int protocol) {
+ return protocol == (MessageData.PROTOCOL_SMS);
+ }
+
+ public final boolean getIsSms() {
+ return getIsSms(mProtocol);
+ }
+
+ public static boolean getIsIncoming(final int status) {
+ return (status >= MessageData.BUGLE_STATUS_FIRST_INCOMING);
+ }
+
+ public boolean getIsIncoming() {
+ return getIsIncoming(mStatus);
+ }
+
+ public long getRetryStartTimestamp() {
+ return mRetryStartTimestamp;
+ }
+
+ public final String getMessageText() {
+ final String separator = System.getProperty("line.separator");
+ final StringBuilder text = new StringBuilder();
+ for (final MessagePartData part : mParts) {
+ if (!part.isAttachment() && !TextUtils.isEmpty(part.getText())) {
+ if (text.length() > 0) {
+ text.append(separator);
+ }
+ text.append(part.getText());
+ }
+ }
+ return text.toString();
+ }
+
+ /**
+ * Takes all captions from attachments and adds them as a prefix to the first text part or
+ * appends a text part
+ */
+ public final void consolidateText() {
+ final String separator = System.getProperty("line.separator");
+ final StringBuilder captionText = new StringBuilder();
+ MessagePartData firstTextPart = null;
+ int firstTextPartIndex = -1;
+ for (int i = 0; i < mParts.size(); i++) {
+ final MessagePartData part = mParts.get(i);
+ if (firstTextPart == null && !part.isAttachment()) {
+ firstTextPart = part;
+ firstTextPartIndex = i;
+ }
+ if (part.isAttachment() && !TextUtils.isEmpty(part.getText())) {
+ if (captionText.length() > 0) {
+ captionText.append(separator);
+ }
+ captionText.append(part.getText());
+ }
+ }
+
+ if (captionText.length() == 0) {
+ // Nothing to consolidate
+ return;
+ }
+
+ if (firstTextPart == null) {
+ addPart(MessagePartData.createTextMessagePart(captionText.toString()));
+ } else {
+ final String partText = firstTextPart.getText();
+ if (partText.length() > 0) {
+ captionText.append(separator);
+ captionText.append(partText);
+ }
+ mParts.set(firstTextPartIndex,
+ MessagePartData.createTextMessagePart(captionText.toString()));
+ }
+ }
+
+ public final MessagePartData getFirstAttachment() {
+ for (final MessagePartData part : mParts) {
+ if (part.isAttachment()) {
+ return part;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Updates the messageId for this message.
+ * Can be used to reset the messageId prior to persisting (which will assign a new messageId)
+ * or can be called on a message that does not yet have a valid messageId to set it.
+ */
+ public void updateMessageId(final String messageId) {
+ Assert.isTrue(TextUtils.isEmpty(messageId) || TextUtils.isEmpty(mMessageId));
+ mMessageId = messageId;
+
+ // TODO : This should probably also call updateMessageId on the message parts. We
+ // may also want to make messages effectively immutable once they have a valid message id.
+ }
+
+ public final void updateSendingMessage(final String conversationId, final Uri messageUri,
+ final long timestamp) {
+ mConversationId = conversationId;
+ mSmsMessageUri = messageUri;
+ mRead = true;
+ mSeen = true;
+ mReceivedTimestamp = timestamp;
+ mSentTimestamp = timestamp;
+ mStatus = BUGLE_STATUS_OUTGOING_YET_TO_SEND;
+ mRetryStartTimestamp = timestamp;
+ }
+
+ public final void markMessageManualResend(final long timestamp) {
+ // Manual send updates timestamp and transitions back to initial sending status.
+ mReceivedTimestamp = timestamp;
+ mSentTimestamp = timestamp;
+ mStatus = BUGLE_STATUS_OUTGOING_SENDING;
+ }
+
+ public final void markMessageSending(final long timestamp) {
+ // Initial send
+ mStatus = BUGLE_STATUS_OUTGOING_SENDING;
+ mSentTimestamp = timestamp;
+ }
+
+ public final void markMessageResending(final long timestamp) {
+ // Auto resend of message
+ mStatus = BUGLE_STATUS_OUTGOING_RESENDING;
+ mSentTimestamp = timestamp;
+ }
+
+ public final void markMessageSent(final long timestamp) {
+ mSentTimestamp = timestamp;
+ mStatus = BUGLE_STATUS_OUTGOING_COMPLETE;
+ }
+
+ public final void markMessageFailed(final long timestamp) {
+ mSentTimestamp = timestamp;
+ mStatus = BUGLE_STATUS_OUTGOING_FAILED;
+ }
+
+ public final void markMessageFailedEmergencyNumber(final long timestamp) {
+ mSentTimestamp = timestamp;
+ mStatus = BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER;
+ }
+
+ public final void markMessageNotSent(final long timestamp) {
+ mSentTimestamp = timestamp;
+ mStatus = BUGLE_STATUS_OUTGOING_AWAITING_RETRY;
+ }
+
+ public final void updateSizesForImageParts() {
+ for (final MessagePartData part : getParts()) {
+ part.decodeAndSaveSizeIfImage(false /* saveToStorage */);
+ }
+ }
+
+ public final void setRetryStartTimestamp(final long timestamp) {
+ mRetryStartTimestamp = timestamp;
+ }
+
+ public final void setRawTelephonyStatus(final int rawStatus) {
+ mRawStatus = rawStatus;
+ }
+
+ public boolean hasContent() {
+ return !TextUtils.isEmpty(mMmsSubject) ||
+ getFirstAttachment() != null ||
+ !TextUtils.isEmpty(getMessageText());
+ }
+
+ public final void bindSelfId(final String selfId) {
+ mSelfId = selfId;
+ }
+
+ public final void bindParticipantId(final String participantId) {
+ mParticipantId = participantId;
+ }
+
+ protected MessageData(final Parcel in) {
+ mMessageId = in.readString();
+ mConversationId = in.readString();
+ mParticipantId = in.readString();
+ mSelfId = in.readString();
+ mSentTimestamp = in.readLong();
+ mReceivedTimestamp = in.readLong();
+ mSeen = (in.readInt() != 0);
+ mRead = (in.readInt() != 0);
+ mProtocol = in.readInt();
+ mStatus = in.readInt();
+ final String smsMessageUri = in.readString();
+ mSmsMessageUri = (smsMessageUri == null ? null : Uri.parse(smsMessageUri));
+ mSmsPriority = in.readInt();
+ mSmsMessageSize = in.readLong();
+ mMmsExpiry = in.readLong();
+ mMmsSubject = in.readString();
+ mMmsTransactionId = in.readString();
+ mMmsContentLocation = in.readString();
+ mRawStatus = in.readInt();
+ mRetryStartTimestamp = in.readLong();
+
+ // Read parts
+ mParts = new ArrayList<MessagePartData>();
+ final int partCount = in.readInt();
+ for (int i = 0; i < partCount; i++) {
+ mParts.add((MessagePartData) in.readParcelable(MessagePartData.class.getClassLoader()));
+ }
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeString(mMessageId);
+ dest.writeString(mConversationId);
+ dest.writeString(mParticipantId);
+ dest.writeString(mSelfId);
+ dest.writeLong(mSentTimestamp);
+ dest.writeLong(mReceivedTimestamp);
+ dest.writeInt(mRead ? 1 : 0);
+ dest.writeInt(mSeen ? 1 : 0);
+ dest.writeInt(mProtocol);
+ dest.writeInt(mStatus);
+ final String smsMessageUri = (mSmsMessageUri == null) ? null : mSmsMessageUri.toString();
+ dest.writeString(smsMessageUri);
+ dest.writeInt(mSmsPriority);
+ dest.writeLong(mSmsMessageSize);
+ dest.writeLong(mMmsExpiry);
+ dest.writeString(mMmsSubject);
+ dest.writeString(mMmsTransactionId);
+ dest.writeString(mMmsContentLocation);
+ dest.writeInt(mRawStatus);
+ dest.writeLong(mRetryStartTimestamp);
+
+ // Write parts
+ dest.writeInt(mParts.size());
+ for (final MessagePartData messagePartData : mParts) {
+ dest.writeParcelable(messagePartData, flags);
+ }
+ }
+
+ public static final Parcelable.Creator<MessageData> CREATOR
+ = new Parcelable.Creator<MessageData>() {
+ @Override
+ public MessageData createFromParcel(final Parcel in) {
+ return new MessageData(in);
+ }
+
+ @Override
+ public MessageData[] newArray(final int size) {
+ return new MessageData[size];
+ }
+ };
+
+ @Override
+ public String toString() {
+ return toString(mMessageId, mParts);
+ }
+
+ public static String toString(String messageId, List<MessagePartData> parts) {
+ StringBuilder sb = new StringBuilder();
+ if (messageId != null) {
+ sb.append(messageId);
+ sb.append(": ");
+ }
+ for (MessagePartData part : parts) {
+ sb.append(part.toString());
+ sb.append(" ");
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/MessagePartData.java b/src/com/android/messaging/datamodel/data/MessagePartData.java
new file mode 100644
index 0000000..fffaca8
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/MessagePartData.java
@@ -0,0 +1,534 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteStatement;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseHelper.PartColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.datamodel.MediaScratchFileProvider;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.action.UpdateMessagePartSizeAction;
+import com.android.messaging.datamodel.media.ImageRequest;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.GifTranscoder;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.SafeAsyncTask;
+import com.android.messaging.util.UriUtil;
+
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Represents a single message part. Messages consist of one or more parts which may contain
+ * either text or media.
+ */
+public class MessagePartData implements Parcelable {
+ public static final int UNSPECIFIED_SIZE = MessagingContentProvider.UNSPECIFIED_SIZE;
+ public static final String[] ACCEPTABLE_IMAGE_TYPES =
+ new String[] { ContentType.IMAGE_JPEG, ContentType.IMAGE_JPG, ContentType.IMAGE_PNG,
+ ContentType.IMAGE_GIF };
+
+ private static final String[] sProjection = {
+ PartColumns._ID,
+ PartColumns.MESSAGE_ID,
+ PartColumns.TEXT,
+ PartColumns.CONTENT_URI,
+ PartColumns.CONTENT_TYPE,
+ PartColumns.WIDTH,
+ PartColumns.HEIGHT,
+ };
+
+ private static final int INDEX_ID = 0;
+ private static final int INDEX_MESSAGE_ID = 1;
+ private static final int INDEX_TEXT = 2;
+ private static final int INDEX_CONTENT_URI = 3;
+ private static final int INDEX_CONTENT_TYPE = 4;
+ private static final int INDEX_WIDTH = 5;
+ private static final int INDEX_HEIGHT = 6;
+ // This isn't part of the projection
+ private static final int INDEX_CONVERSATION_ID = 7;
+
+ // SQL statement to insert a "complete" message part row (columns based on projection above).
+ private static final String INSERT_MESSAGE_PART_SQL =
+ "INSERT INTO " + DatabaseHelper.PARTS_TABLE + " ( "
+ + TextUtils.join(",", Arrays.copyOfRange(sProjection, 1, INDEX_CONVERSATION_ID))
+ + ", " + PartColumns.CONVERSATION_ID
+ + ") VALUES (?, ?, ?, ?, ?, ?, ?)";
+
+ // Used for stuff that's ignored or arbitrarily compressed.
+ private static final long NO_MINIMUM_SIZE = 0;
+
+ private String mPartId;
+ private String mMessageId;
+ private String mText;
+ private Uri mContentUri;
+ private String mContentType;
+ private int mWidth;
+ private int mHeight;
+ // This kind of part can only be attached once and with no other attachment
+ private boolean mSinglePartOnly;
+
+ /** Transient data: true if destroy was already called */
+ private boolean mDestroyed;
+
+ /**
+ * Create an "empty" message part
+ */
+ protected MessagePartData() {
+ this(null, null, UNSPECIFIED_SIZE, UNSPECIFIED_SIZE);
+ }
+
+ /**
+ * Create a populated text message part
+ */
+ protected MessagePartData(final String messageText) {
+ this(null, messageText, ContentType.TEXT_PLAIN, null, UNSPECIFIED_SIZE, UNSPECIFIED_SIZE,
+ false /*singlePartOnly*/);
+ }
+
+ /**
+ * Create a populated attachment message part
+ */
+ protected MessagePartData(final String contentType, final Uri contentUri,
+ final int width, final int height) {
+ this(null, null, contentType, contentUri, width, height, false /*singlePartOnly*/);
+ }
+
+ /**
+ * Create a populated attachment message part, with additional caption text
+ */
+ protected MessagePartData(final String messageText, final String contentType,
+ final Uri contentUri, final int width, final int height) {
+ this(null, messageText, contentType, contentUri, width, height, false /*singlePartOnly*/);
+ }
+
+ /**
+ * Create a populated attachment message part, with additional caption text, single part only
+ */
+ protected MessagePartData(final String messageText, final String contentType,
+ final Uri contentUri, final int width, final int height, final boolean singlePartOnly) {
+ this(null, messageText, contentType, contentUri, width, height, singlePartOnly);
+ }
+
+ /**
+ * Create a populated message part
+ */
+ private MessagePartData(final String messageId, final String messageText,
+ final String contentType, final Uri contentUri, final int width, final int height,
+ final boolean singlePartOnly) {
+ mMessageId = messageId;
+ mText = messageText;
+ mContentType = contentType;
+ mContentUri = contentUri;
+ mWidth = width;
+ mHeight = height;
+ mSinglePartOnly = singlePartOnly;
+ }
+
+ /**
+ * Create a "text" message part
+ */
+ public static MessagePartData createTextMessagePart(final String messageText) {
+ return new MessagePartData(messageText);
+ }
+
+ /**
+ * Create a "media" message part
+ */
+ public static MessagePartData createMediaMessagePart(final String contentType,
+ final Uri contentUri, final int width, final int height) {
+ return new MessagePartData(contentType, contentUri, width, height);
+ }
+
+ /**
+ * Create a "media" message part with caption
+ */
+ public static MessagePartData createMediaMessagePart(final String caption,
+ final String contentType, final Uri contentUri, final int width, final int height) {
+ return new MessagePartData(null, caption, contentType, contentUri, width, height,
+ false /*singlePartOnly*/
+ );
+ }
+
+ /**
+ * Create an empty "text" message part
+ */
+ public static MessagePartData createEmptyMessagePart() {
+ return new MessagePartData("");
+ }
+
+ /**
+ * Creates a new message part reading from the cursor
+ */
+ public static MessagePartData createFromCursor(final Cursor cursor) {
+ final MessagePartData part = new MessagePartData();
+ part.bind(cursor);
+ return part;
+ }
+
+ public static String[] getProjection() {
+ return sProjection;
+ }
+
+ /**
+ * Updates the part id.
+ * Can be used to reset the partId just prior to persisting (which will assign a new partId)
+ * or can be called on a part that does not yet have a valid part id to set it.
+ */
+ public void updatePartId(final String partId) {
+ Assert.isTrue(TextUtils.isEmpty(partId) || TextUtils.isEmpty(mPartId));
+ mPartId = partId;
+ }
+
+ /**
+ * Updates the messageId for the part.
+ * Can be used to reset the messageId prior to persisting (which will assign a new messageId)
+ * or can be called on a part that does not yet have a valid messageId to set it.
+ */
+ public void updateMessageId(final String messageId) {
+ Assert.isTrue(TextUtils.isEmpty(messageId) || TextUtils.isEmpty(mMessageId));
+ mMessageId = messageId;
+ }
+
+ protected static String getMessageId(final Cursor cursor) {
+ return cursor.getString(INDEX_MESSAGE_ID);
+ }
+
+ protected void bind(final Cursor cursor) {
+ mPartId = cursor.getString(INDEX_ID);
+ mMessageId = cursor.getString(INDEX_MESSAGE_ID);
+ mText = cursor.getString(INDEX_TEXT);
+ mContentUri = UriUtil.uriFromString(cursor.getString(INDEX_CONTENT_URI));
+ mContentType = cursor.getString(INDEX_CONTENT_TYPE);
+ mWidth = cursor.getInt(INDEX_WIDTH);
+ mHeight = cursor.getInt(INDEX_HEIGHT);
+ }
+
+ public final void populate(final ContentValues values) {
+ // Must have a valid messageId on a part
+ Assert.isTrue(!TextUtils.isEmpty(mMessageId));
+ values.put(PartColumns.MESSAGE_ID, mMessageId);
+ values.put(PartColumns.TEXT, mText);
+ values.put(PartColumns.CONTENT_URI, UriUtil.stringFromUri(mContentUri));
+ values.put(PartColumns.CONTENT_TYPE, mContentType);
+ if (mWidth != UNSPECIFIED_SIZE) {
+ values.put(PartColumns.WIDTH, mWidth);
+ }
+ if (mHeight != UNSPECIFIED_SIZE) {
+ values.put(PartColumns.HEIGHT, mHeight);
+ }
+ }
+
+ /**
+ * Note this is not thread safe so callers need to make sure they own the wrapper + statements
+ * while they call this and use the returned value.
+ */
+ public SQLiteStatement getInsertStatement(final DatabaseWrapper db,
+ final String conversationId) {
+ final SQLiteStatement insert = db.getStatementInTransaction(
+ DatabaseWrapper.INDEX_INSERT_MESSAGE_PART, INSERT_MESSAGE_PART_SQL);
+ insert.clearBindings();
+ insert.bindString(INDEX_MESSAGE_ID, mMessageId);
+ if (mText != null) {
+ insert.bindString(INDEX_TEXT, mText);
+ }
+ if (mContentUri != null) {
+ insert.bindString(INDEX_CONTENT_URI, mContentUri.toString());
+ }
+ if (mContentType != null) {
+ insert.bindString(INDEX_CONTENT_TYPE, mContentType);
+ }
+ insert.bindLong(INDEX_WIDTH, mWidth);
+ insert.bindLong(INDEX_HEIGHT, mHeight);
+ insert.bindString(INDEX_CONVERSATION_ID, conversationId);
+ return insert;
+ }
+
+ public final String getPartId() {
+ return mPartId;
+ }
+
+ public final String getMessageId() {
+ return mMessageId;
+ }
+
+ public final String getText() {
+ return mText;
+ }
+
+ public final Uri getContentUri() {
+ return mContentUri;
+ }
+
+ public boolean isAttachment() {
+ return mContentUri != null;
+ }
+
+ public boolean isText() {
+ return ContentType.isTextType(mContentType);
+ }
+
+ public boolean isImage() {
+ return ContentType.isImageType(mContentType);
+ }
+
+ public boolean isMedia() {
+ return ContentType.isMediaType(mContentType);
+ }
+
+ public boolean isVCard() {
+ return ContentType.isVCardType(mContentType);
+ }
+
+ public boolean isAudio() {
+ return ContentType.isAudioType(mContentType);
+ }
+
+ public boolean isVideo() {
+ return ContentType.isVideoType(mContentType);
+ }
+
+ public final String getContentType() {
+ return mContentType;
+ }
+
+ public final int getWidth() {
+ return mWidth;
+ }
+
+ public final int getHeight() {
+ return mHeight;
+ }
+
+ /**
+ *
+ * @return true if this part can only exist by itself, with no other attachments
+ */
+ public boolean getSinglePartOnly() {
+ return mSinglePartOnly;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ protected MessagePartData(final Parcel in) {
+ mMessageId = in.readString();
+ mText = in.readString();
+ mContentUri = UriUtil.uriFromString(in.readString());
+ mContentType = in.readString();
+ mWidth = in.readInt();
+ mHeight = in.readInt();
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ Assert.isTrue(!mDestroyed);
+ dest.writeString(mMessageId);
+ dest.writeString(mText);
+ dest.writeString(UriUtil.stringFromUri(mContentUri));
+ dest.writeString(mContentType);
+ dest.writeInt(mWidth);
+ dest.writeInt(mHeight);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+
+ if (!(o instanceof MessagePartData)) {
+ return false;
+ }
+
+ MessagePartData lhs = (MessagePartData) o;
+ return mWidth == lhs.mWidth && mHeight == lhs.mHeight &&
+ TextUtils.equals(mMessageId, lhs.mMessageId) &&
+ TextUtils.equals(mText, lhs.mText) &&
+ TextUtils.equals(mContentType, lhs.mContentType) &&
+ (mContentUri == null ? lhs.mContentUri == null
+ : mContentUri.equals(lhs.mContentUri));
+ }
+
+ @Override public int hashCode() {
+ int result = 17;
+ result = 31 * result + mWidth;
+ result = 31 * result + mHeight;
+ result = 31 * result + (mMessageId == null ? 0 : mMessageId.hashCode());
+ result = 31 * result + (mText == null ? 0 : mText.hashCode());
+ result = 31 * result + (mContentType == null ? 0 : mContentType.hashCode());
+ result = 31 * result + (mContentUri == null ? 0 : mContentUri.hashCode());
+ return result;
+ }
+
+ public static final Parcelable.Creator<MessagePartData> CREATOR
+ = new Parcelable.Creator<MessagePartData>() {
+ @Override
+ public MessagePartData createFromParcel(final Parcel in) {
+ return new MessagePartData(in);
+ }
+
+ @Override
+ public MessagePartData[] newArray(final int size) {
+ return new MessagePartData[size];
+ }
+ };
+
+ protected Uri shouldDestroy() {
+ // We should never double-destroy.
+ Assert.isTrue(!mDestroyed);
+ mDestroyed = true;
+ Uri contentUri = mContentUri;
+ mContentUri = null;
+ mContentType = null;
+ // Only destroy the image if it's staged in our scratch space.
+ if (!MediaScratchFileProvider.isMediaScratchSpaceUri(contentUri)) {
+ contentUri = null;
+ }
+ return contentUri;
+ }
+
+ /**
+ * If application owns content associated with this part delete it (on background thread)
+ */
+ public void destroyAsync() {
+ final Uri contentUri = shouldDestroy();
+ if (contentUri != null) {
+ SafeAsyncTask.executeOnThreadPool(new Runnable() {
+ @Override
+ public void run() {
+ Factory.get().getApplicationContext().getContentResolver().delete(
+ contentUri, null, null);
+ }
+ });
+ }
+ }
+
+ /**
+ * If application owns content associated with this part delete it
+ */
+ public void destroySync() {
+ final Uri contentUri = shouldDestroy();
+ if (contentUri != null) {
+ Factory.get().getApplicationContext().getContentResolver().delete(
+ contentUri, null, null);
+ }
+ }
+
+ /**
+ * If this is an image part, decode the image header and potentially save the size to the db.
+ */
+ public void decodeAndSaveSizeIfImage(final boolean saveToStorage) {
+ if (isImage()) {
+ final Rect imageSize = ImageUtils.decodeImageBounds(
+ Factory.get().getApplicationContext(), mContentUri);
+ if (imageSize.width() != ImageRequest.UNSPECIFIED_SIZE &&
+ imageSize.height() != ImageRequest.UNSPECIFIED_SIZE) {
+ mWidth = imageSize.width();
+ mHeight = imageSize.height();
+ if (saveToStorage) {
+ UpdateMessagePartSizeAction.updateSize(mPartId, mWidth, mHeight);
+ }
+ }
+ }
+ }
+
+ /**
+ * Computes the minimum size that this MessagePartData could be compressed/downsampled/encoded
+ * before sending to meet the maximum message size imposed by the carriers. This is used to
+ * determine right before sending a message whether a message could possibly be sent. If not
+ * then the user is given a chance to unselect some/all of the attachments.
+ *
+ * TODO: computing the minimum size could be expensive. Should we cache the
+ * computed value in db to be retrieved later?
+ *
+ * @return the carrier-independent minimum size, in bytes.
+ */
+ @DoesNotRunOnMainThread
+ public long getMinimumSizeInBytesForSending() {
+ Assert.isNotMainThread();
+ if (!isAttachment()) {
+ // No limit is imposed on non-attachment part (i.e. plain text), so treat it as zero.
+ return NO_MINIMUM_SIZE;
+ } else if (isImage()) {
+ // GIFs are resized by the native transcoder (exposed by GifTranscoder).
+ if (ImageUtils.isGif(mContentType, mContentUri)) {
+ final long originalImageSize = UriUtil.getContentSize(mContentUri);
+ // Wish we could save the size here, but we don't have a part id yet
+ decodeAndSaveSizeIfImage(false /* saveToStorage */);
+ return GifTranscoder.canBeTranscoded(mWidth, mHeight) ?
+ GifTranscoder.estimateFileSizeAfterTranscode(originalImageSize)
+ : originalImageSize;
+ }
+ // Other images should be arbitrarily resized by ImageResizer before sending.
+ return MmsUtils.MIN_IMAGE_BYTE_SIZE;
+ } else if (isAudio()) {
+ // Audios are already recorded with the lowest sampling settings (AMR_NB), so just
+ // return the file size as the minimum size.
+ return UriUtil.getContentSize(mContentUri);
+ } else if (isVideo()) {
+ final int mediaDurationMs = UriUtil.getMediaDurationMs(mContentUri);
+ return MmsUtils.MIN_VIDEO_BYTES_PER_SECOND * mediaDurationMs
+ / TimeUnit.SECONDS.toMillis(1);
+ } else if (isVCard()) {
+ // We can't compress vCards.
+ return UriUtil.getContentSize(mContentUri);
+ } else {
+ // This is some unknown media type that we don't know how to handle. Log an error
+ // and try sending it anyway.
+ LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Unknown attachment type " + getContentType());
+ return NO_MINIMUM_SIZE;
+ }
+ }
+
+ @Override
+ public String toString() {
+ if (isText()) {
+ return LogUtil.sanitizePII(getText());
+ } else {
+ return getContentType() + " (" + getContentUri() + ")";
+ }
+ }
+
+ /**
+ *
+ * @return true if this part can only exist by itself, with no other attachments
+ */
+ public boolean isSinglePartOnly() {
+ return mSinglePartOnly;
+ }
+
+ public void setSinglePartOnly(final boolean isSinglePartOnly) {
+ mSinglePartOnly = isSinglePartOnly;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/ParticipantData.java b/src/com/android/messaging/datamodel/data/ParticipantData.java
new file mode 100644
index 0000000..521c354
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/ParticipantData.java
@@ -0,0 +1,569 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.content.ContentValues;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.v7.mms.MmsManager;
+import android.telephony.SubscriptionInfo;
+import android.text.TextUtils;
+
+import com.android.ex.chips.RecipientEntry;
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DatabaseHelper;
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
+import com.android.messaging.datamodel.DatabaseWrapper;
+import com.android.messaging.sms.MmsSmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.TextUtil;
+
+/**
+ * A class that encapsulates all of the data for a specific participant in a conversation.
+ */
+public class ParticipantData implements Parcelable {
+ // We always use -1 as default/invalid sub id although system may give us anything negative
+ public static final int DEFAULT_SELF_SUB_ID = MmsManager.DEFAULT_SUB_ID;
+
+ // This needs to be something apart from valid or DEFAULT_SELF_SUB_ID
+ public static final int OTHER_THAN_SELF_SUB_ID = DEFAULT_SELF_SUB_ID - 1;
+
+ // Active slot ids are non-negative. Using -1 to designate to inactive self participants.
+ public static final int INVALID_SLOT_ID = -1;
+
+ // TODO: may make sense to move this to common place?
+ public static final long PARTICIPANT_CONTACT_ID_NOT_RESOLVED = -1;
+ public static final long PARTICIPANT_CONTACT_ID_NOT_FOUND = -2;
+
+ public static class ParticipantsQuery {
+ public static final String[] PROJECTION = new String[] {
+ ParticipantColumns._ID,
+ ParticipantColumns.SUB_ID,
+ ParticipantColumns.SIM_SLOT_ID,
+ ParticipantColumns.NORMALIZED_DESTINATION,
+ ParticipantColumns.SEND_DESTINATION,
+ ParticipantColumns.DISPLAY_DESTINATION,
+ ParticipantColumns.FULL_NAME,
+ ParticipantColumns.FIRST_NAME,
+ ParticipantColumns.PROFILE_PHOTO_URI,
+ ParticipantColumns.CONTACT_ID,
+ ParticipantColumns.LOOKUP_KEY,
+ ParticipantColumns.BLOCKED,
+ ParticipantColumns.SUBSCRIPTION_COLOR,
+ ParticipantColumns.SUBSCRIPTION_NAME,
+ ParticipantColumns.CONTACT_DESTINATION,
+ };
+
+ public static final int INDEX_ID = 0;
+ public static final int INDEX_SUB_ID = 1;
+ public static final int INDEX_SIM_SLOT_ID = 2;
+ public static final int INDEX_NORMALIZED_DESTINATION = 3;
+ public static final int INDEX_SEND_DESTINATION = 4;
+ public static final int INDEX_DISPLAY_DESTINATION = 5;
+ public static final int INDEX_FULL_NAME = 6;
+ public static final int INDEX_FIRST_NAME = 7;
+ public static final int INDEX_PROFILE_PHOTO_URI = 8;
+ public static final int INDEX_CONTACT_ID = 9;
+ public static final int INDEX_LOOKUP_KEY = 10;
+ public static final int INDEX_BLOCKED = 11;
+ public static final int INDEX_SUBSCRIPTION_COLOR = 12;
+ public static final int INDEX_SUBSCRIPTION_NAME = 13;
+ public static final int INDEX_CONTACT_DESTINATION = 14;
+ }
+
+ /**
+ * @return The MMS unknown sender participant entity
+ */
+ public static String getUnknownSenderDestination() {
+ // This is a hard coded string rather than a localized one because we don't want it to
+ // change when you change locale.
+ return "\u02BCUNKNOWN_SENDER!\u02BC";
+ }
+
+ private String mParticipantId;
+ private int mSubId;
+ private int mSlotId;
+ private String mNormalizedDestination;
+ private String mSendDestination;
+ private String mDisplayDestination;
+ private String mContactDestination;
+ private String mFullName;
+ private String mFirstName;
+ private String mProfilePhotoUri;
+ private long mContactId;
+ private String mLookupKey;
+ private int mSubscriptionColor;
+ private String mSubscriptionName;
+ private boolean mIsEmailAddress;
+ private boolean mBlocked;
+
+ // Don't call constructor directly
+ private ParticipantData() {
+ }
+
+ public static ParticipantData getFromCursor(final Cursor cursor) {
+ final ParticipantData pd = new ParticipantData();
+ pd.mParticipantId = cursor.getString(ParticipantsQuery.INDEX_ID);
+ pd.mSubId = cursor.getInt(ParticipantsQuery.INDEX_SUB_ID);
+ pd.mSlotId = cursor.getInt(ParticipantsQuery.INDEX_SIM_SLOT_ID);
+ pd.mNormalizedDestination = cursor.getString(
+ ParticipantsQuery.INDEX_NORMALIZED_DESTINATION);
+ pd.mSendDestination = cursor.getString(ParticipantsQuery.INDEX_SEND_DESTINATION);
+ pd.mDisplayDestination = cursor.getString(ParticipantsQuery.INDEX_DISPLAY_DESTINATION);
+ pd.mContactDestination = cursor.getString(ParticipantsQuery.INDEX_CONTACT_DESTINATION);
+ pd.mFullName = cursor.getString(ParticipantsQuery.INDEX_FULL_NAME);
+ pd.mFirstName = cursor.getString(ParticipantsQuery.INDEX_FIRST_NAME);
+ pd.mProfilePhotoUri = cursor.getString(ParticipantsQuery.INDEX_PROFILE_PHOTO_URI);
+ pd.mContactId = cursor.getLong(ParticipantsQuery.INDEX_CONTACT_ID);
+ pd.mLookupKey = cursor.getString(ParticipantsQuery.INDEX_LOOKUP_KEY);
+ pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination);
+ pd.mBlocked = cursor.getInt(ParticipantsQuery.INDEX_BLOCKED) != 0;
+ pd.mSubscriptionColor = cursor.getInt(ParticipantsQuery.INDEX_SUBSCRIPTION_COLOR);
+ pd.mSubscriptionName = cursor.getString(ParticipantsQuery.INDEX_SUBSCRIPTION_NAME);
+ pd.maybeSetupUnknownSender();
+ return pd;
+ }
+
+ public static ParticipantData getFromId(final DatabaseWrapper dbWrapper,
+ final String participantId) {
+ Cursor cursor = null;
+ try {
+ cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE,
+ ParticipantsQuery.PROJECTION,
+ ParticipantColumns._ID + " =?",
+ new String[] { participantId }, null, null, null);
+
+ if (cursor.moveToFirst()) {
+ return ParticipantData.getFromCursor(cursor);
+ } else {
+ return null;
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+
+ public static ParticipantData getFromRecipientEntry(final RecipientEntry recipientEntry) {
+ final ParticipantData pd = new ParticipantData();
+ pd.mParticipantId = null;
+ pd.mSubId = OTHER_THAN_SELF_SUB_ID;
+ pd.mSlotId = INVALID_SLOT_ID;
+ pd.mSendDestination = TextUtil.replaceUnicodeDigits(recipientEntry.getDestination());
+ pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination);
+ pd.mNormalizedDestination = pd.mIsEmailAddress ?
+ pd.mSendDestination :
+ PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination);
+ pd.mDisplayDestination = pd.mIsEmailAddress ?
+ pd.mNormalizedDestination :
+ PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination);
+ pd.mFullName = recipientEntry.getDisplayName();
+ pd.mFirstName = null;
+ pd.mProfilePhotoUri = (recipientEntry.getPhotoThumbnailUri() == null) ? null :
+ recipientEntry.getPhotoThumbnailUri().toString();
+ pd.mContactId = recipientEntry.getContactId();
+ if (pd.mContactId < 0) {
+ // ParticipantData only supports real contact ids (>=0) based on faith that the contacts
+ // provider will continue to only use non-negative ids. The UI uses contactId < 0 for
+ // special handling. We convert those to 'not resolved'
+ pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED;
+ }
+ pd.mLookupKey = recipientEntry.getLookupKey();
+ pd.mBlocked = false;
+ pd.mSubscriptionColor = Color.TRANSPARENT;
+ pd.mSubscriptionName = null;
+ pd.maybeSetupUnknownSender();
+ return pd;
+ }
+
+ // Shared code for getFromRawPhoneBySystemLocale and getFromRawPhoneBySimLocale
+ private static ParticipantData getFromRawPhone(final String phoneNumber) {
+ Assert.isTrue(phoneNumber != null);
+ final ParticipantData pd = new ParticipantData();
+ pd.mParticipantId = null;
+ pd.mSubId = OTHER_THAN_SELF_SUB_ID;
+ pd.mSlotId = INVALID_SLOT_ID;
+ pd.mSendDestination = TextUtil.replaceUnicodeDigits(phoneNumber);
+ pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination);
+ pd.mFullName = null;
+ pd.mFirstName = null;
+ pd.mProfilePhotoUri = null;
+ pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED;
+ pd.mLookupKey = null;
+ pd.mBlocked = false;
+ pd.mSubscriptionColor = Color.TRANSPARENT;
+ pd.mSubscriptionName = null;
+ return pd;
+ }
+
+ /**
+ * Get an instance from a raw phone number and using system locale to normalize it.
+ *
+ * Use this when creating a participant that is for displaying UI and not associated
+ * with a specific SIM. For example, when creating a conversation using user entered
+ * phone number.
+ *
+ * @param phoneNumber The raw phone number
+ * @return instance
+ */
+ public static ParticipantData getFromRawPhoneBySystemLocale(final String phoneNumber) {
+ final ParticipantData pd = getFromRawPhone(phoneNumber);
+ pd.mNormalizedDestination = pd.mIsEmailAddress ?
+ pd.mSendDestination :
+ PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination);
+ pd.mDisplayDestination = pd.mIsEmailAddress ?
+ pd.mNormalizedDestination :
+ PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination);
+ pd.maybeSetupUnknownSender();
+ return pd;
+ }
+
+ /**
+ * Get an instance from a raw phone number and using SIM or system locale to normalize it.
+ *
+ * Use this when creating a participant that is associated with a specific SIM. For example,
+ * the sender of a received message or the recipient of a sending message that is already
+ * targeted at a specific SIM.
+ *
+ * @param phoneNumber The raw phone number
+ * @return instance
+ */
+ public static ParticipantData getFromRawPhoneBySimLocale(
+ final String phoneNumber, final int subId) {
+ final ParticipantData pd = getFromRawPhone(phoneNumber);
+ pd.mNormalizedDestination = pd.mIsEmailAddress ?
+ pd.mSendDestination :
+ PhoneUtils.get(subId).getCanonicalBySimLocale(pd.mSendDestination);
+ pd.mDisplayDestination = pd.mIsEmailAddress ?
+ pd.mNormalizedDestination :
+ PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination);
+ pd.maybeSetupUnknownSender();
+ return pd;
+ }
+
+ public static ParticipantData getSelfParticipant(final int subId) {
+ Assert.isTrue(subId != OTHER_THAN_SELF_SUB_ID);
+ final ParticipantData pd = new ParticipantData();
+ pd.mParticipantId = null;
+ pd.mSubId = subId;
+ pd.mSlotId = INVALID_SLOT_ID;
+ pd.mIsEmailAddress = false;
+ pd.mSendDestination = null;
+ pd.mNormalizedDestination = null;
+ pd.mDisplayDestination = null;
+ pd.mFullName = null;
+ pd.mFirstName = null;
+ pd.mProfilePhotoUri = null;
+ pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED;
+ pd.mLookupKey = null;
+ pd.mBlocked = false;
+ pd.mSubscriptionColor = Color.TRANSPARENT;
+ pd.mSubscriptionName = null;
+ return pd;
+ }
+
+ private void maybeSetupUnknownSender() {
+ if (isUnknownSender()) {
+ // Because your locale may change, we setup the display string for the unknown sender
+ // on the fly rather than relying on the version in the database.
+ final Resources resources = Factory.get().getApplicationContext().getResources();
+ mDisplayDestination = resources.getString(R.string.unknown_sender);
+ mFullName = mDisplayDestination;
+ }
+ }
+
+ public String getNormalizedDestination() {
+ return mNormalizedDestination;
+ }
+
+ public String getSendDestination() {
+ return mSendDestination;
+ }
+
+ public String getDisplayDestination() {
+ return mDisplayDestination;
+ }
+
+ public String getContactDestination() {
+ return mContactDestination;
+ }
+
+ public String getFullName() {
+ return mFullName;
+ }
+
+ public String getFirstName() {
+ return mFirstName;
+ }
+
+ public String getDisplayName(final boolean preferFullName) {
+ if (preferFullName) {
+ // Prefer full name over first name
+ if (!TextUtils.isEmpty(mFullName)) {
+ return mFullName;
+ }
+ if (!TextUtils.isEmpty(mFirstName)) {
+ return mFirstName;
+ }
+ } else {
+ // Prefer first name over full name
+ if (!TextUtils.isEmpty(mFirstName)) {
+ return mFirstName;
+ }
+ if (!TextUtils.isEmpty(mFullName)) {
+ return mFullName;
+ }
+ }
+
+ // Fallback to the display destination
+ if (!TextUtils.isEmpty(mDisplayDestination)) {
+ return mDisplayDestination;
+ }
+
+ return Factory.get().getApplicationContext().getResources().getString(
+ R.string.unknown_sender);
+ }
+
+ public String getProfilePhotoUri() {
+ return mProfilePhotoUri;
+ }
+
+ public long getContactId() {
+ return mContactId;
+ }
+
+ public String getLookupKey() {
+ return mLookupKey;
+ }
+
+ public boolean updatePhoneNumberForSelfIfChanged() {
+ final String phoneNumber =
+ PhoneUtils.get(mSubId).getCanonicalForSelf(true/*allowOverride*/);
+ boolean changed = false;
+ if (isSelf() && !TextUtils.equals(phoneNumber, mNormalizedDestination)) {
+ mNormalizedDestination = phoneNumber;
+ mSendDestination = phoneNumber;
+ mDisplayDestination = mIsEmailAddress ?
+ phoneNumber :
+ PhoneUtils.getDefault().formatForDisplay(phoneNumber);
+ changed = true;
+ }
+ return changed;
+ }
+
+ public boolean updateSubscriptionInfoForSelfIfChanged(final SubscriptionInfo subscriptionInfo) {
+ boolean changed = false;
+ if (isSelf()) {
+ if (subscriptionInfo == null) {
+ // The subscription is inactive. Check if the participant is still active.
+ if (isActiveSubscription()) {
+ mSlotId = INVALID_SLOT_ID;
+ mSubscriptionColor = Color.TRANSPARENT;
+ mSubscriptionName = "";
+ changed = true;
+ }
+ } else {
+ final int slotId = subscriptionInfo.getSimSlotIndex();
+ final int color = subscriptionInfo.getIconTint();
+ final CharSequence name = subscriptionInfo.getDisplayName();
+ if (mSlotId != slotId || mSubscriptionColor != color || mSubscriptionName != name) {
+ mSlotId = slotId;
+ mSubscriptionColor = color;
+ mSubscriptionName = name.toString();
+ changed = true;
+ }
+ }
+ }
+ return changed;
+ }
+
+ public void setFullName(final String fullName) {
+ mFullName = fullName;
+ }
+
+ public void setFirstName(final String firstName) {
+ mFirstName = firstName;
+ }
+
+ public void setProfilePhotoUri(final String profilePhotoUri) {
+ mProfilePhotoUri = profilePhotoUri;
+ }
+
+ public void setContactId(final long contactId) {
+ mContactId = contactId;
+ }
+
+ public void setLookupKey(final String lookupKey) {
+ mLookupKey = lookupKey;
+ }
+
+ public void setSendDestination(final String destination) {
+ mSendDestination = destination;
+ }
+
+ public void setContactDestination(final String destination) {
+ mContactDestination = destination;
+ }
+
+ public int getSubId() {
+ return mSubId;
+ }
+
+ /**
+ * @return whether this sub is active. Note that {@link ParticipantData#DEFAULT_SELF_SUB_ID} is
+ * is considered as active if there is any active SIM.
+ */
+ public boolean isActiveSubscription() {
+ return mSlotId != INVALID_SLOT_ID;
+ }
+
+ public boolean isDefaultSelf() {
+ return mSubId == ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+
+ public int getSlotId() {
+ return mSlotId;
+ }
+
+ /**
+ * Slot IDs in the subscription manager is zero-based, but we want to show it
+ * as 1-based in UI.
+ */
+ public int getDisplaySlotId() {
+ return getSlotId() + 1;
+ }
+
+ public int getSubscriptionColor() {
+ Assert.isTrue(isActiveSubscription());
+ // Force the alpha channel to 0xff to ensure the returned color is solid.
+ return mSubscriptionColor | 0xff000000;
+ }
+
+ public String getSubscriptionName() {
+ Assert.isTrue(isActiveSubscription());
+ return mSubscriptionName;
+ }
+
+ public String getId() {
+ return mParticipantId;
+ }
+
+ public boolean isSelf() {
+ return (mSubId != OTHER_THAN_SELF_SUB_ID);
+ }
+
+ public boolean isEmail() {
+ return mIsEmailAddress;
+ }
+
+ public boolean isContactIdResolved() {
+ return (mContactId != PARTICIPANT_CONTACT_ID_NOT_RESOLVED);
+ }
+
+ public boolean isBlocked() {
+ return mBlocked;
+ }
+
+ public boolean isUnknownSender() {
+ final String unknownSender = ParticipantData.getUnknownSenderDestination();
+ return (TextUtils.equals(mSendDestination, unknownSender));
+ }
+
+ public ContentValues toContentValues() {
+ final ContentValues values = new ContentValues();
+ values.put(ParticipantColumns.SUB_ID, mSubId);
+ values.put(ParticipantColumns.SIM_SLOT_ID, mSlotId);
+ values.put(DatabaseHelper.ParticipantColumns.SEND_DESTINATION, mSendDestination);
+
+ if (!isUnknownSender()) {
+ values.put(DatabaseHelper.ParticipantColumns.DISPLAY_DESTINATION, mDisplayDestination);
+ values.put(DatabaseHelper.ParticipantColumns.NORMALIZED_DESTINATION,
+ mNormalizedDestination);
+ values.put(ParticipantColumns.FULL_NAME, mFullName);
+ values.put(ParticipantColumns.FIRST_NAME, mFirstName);
+ }
+
+ values.put(ParticipantColumns.PROFILE_PHOTO_URI, mProfilePhotoUri);
+ values.put(ParticipantColumns.CONTACT_ID, mContactId);
+ values.put(ParticipantColumns.LOOKUP_KEY, mLookupKey);
+ values.put(ParticipantColumns.BLOCKED, mBlocked);
+ values.put(ParticipantColumns.SUBSCRIPTION_COLOR, mSubscriptionColor);
+ values.put(ParticipantColumns.SUBSCRIPTION_NAME, mSubscriptionName);
+ return values;
+ }
+
+ public ParticipantData(final Parcel in) {
+ mParticipantId = in.readString();
+ mSubId = in.readInt();
+ mSlotId = in.readInt();
+ mNormalizedDestination = in.readString();
+ mSendDestination = in.readString();
+ mDisplayDestination = in.readString();
+ mFullName = in.readString();
+ mFirstName = in.readString();
+ mProfilePhotoUri = in.readString();
+ mContactId = in.readLong();
+ mLookupKey = in.readString();
+ mIsEmailAddress = in.readInt() != 0;
+ mBlocked = in.readInt() != 0;
+ mSubscriptionColor = in.readInt();
+ mSubscriptionName = in.readString();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeString(mParticipantId);
+ dest.writeInt(mSubId);
+ dest.writeInt(mSlotId);
+ dest.writeString(mNormalizedDestination);
+ dest.writeString(mSendDestination);
+ dest.writeString(mDisplayDestination);
+ dest.writeString(mFullName);
+ dest.writeString(mFirstName);
+ dest.writeString(mProfilePhotoUri);
+ dest.writeLong(mContactId);
+ dest.writeString(mLookupKey);
+ dest.writeInt(mIsEmailAddress ? 1 : 0);
+ dest.writeInt(mBlocked ? 1 : 0);
+ dest.writeInt(mSubscriptionColor);
+ dest.writeString(mSubscriptionName);
+ }
+
+ public static final Parcelable.Creator<ParticipantData> CREATOR
+ = new Parcelable.Creator<ParticipantData>() {
+ @Override
+ public ParticipantData createFromParcel(final Parcel in) {
+ return new ParticipantData(in);
+ }
+
+ @Override
+ public ParticipantData[] newArray(final int size) {
+ return new ParticipantData[size];
+ }
+ };
+}
diff --git a/src/com/android/messaging/datamodel/data/ParticipantListItemData.java b/src/com/android/messaging/datamodel/data/ParticipantListItemData.java
new file mode 100644
index 0000000..f6c9b5f
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/ParticipantListItemData.java
@@ -0,0 +1,95 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.action.BugleActionToasts;
+import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction;
+import com.android.messaging.util.AvatarUriUtil;
+
+/**
+ * Helps visualize a ParticipantData in a PersonItemView
+ */
+public class ParticipantListItemData extends PersonItemData {
+ private final Uri mAvatarUri;
+ private final String mDisplayName;
+ private final String mDetails;
+ private final long mContactId;
+ private final String mLookupKey;
+ private final String mNormalizedDestination;
+
+ /**
+ * Constructor. Takes necessary info from the incoming ParticipantData.
+ */
+ public ParticipantListItemData(final ParticipantData participant) {
+ mAvatarUri = AvatarUriUtil.createAvatarUri(participant);
+ mContactId = participant.getContactId();
+ mLookupKey = participant.getLookupKey();
+ mNormalizedDestination = participant.getNormalizedDestination();
+ if (TextUtils.isEmpty(participant.getFullName())) {
+ mDisplayName = participant.getSendDestination();
+ mDetails = null;
+ } else {
+ mDisplayName = participant.getFullName();
+ mDetails = (participant.isUnknownSender()) ? null : participant.getSendDestination();
+ }
+ }
+
+ @Override
+ public Uri getAvatarUri() {
+ return mAvatarUri;
+ }
+
+ @Override
+ public String getDisplayName() {
+ return mDisplayName;
+ }
+
+ @Override
+ public String getDetails() {
+ return mDetails;
+ }
+
+ @Override
+ public Intent getClickIntent() {
+ return null;
+ }
+
+ @Override
+ public long getContactId() {
+ return mContactId;
+ }
+
+ @Override
+ public String getLookupKey() {
+ return mLookupKey;
+ }
+
+ @Override
+ public String getNormalizedDestination() {
+ return mNormalizedDestination;
+ }
+
+ public void unblock(final Context context) {
+ UpdateDestinationBlockedAction.updateDestinationBlocked(
+ mNormalizedDestination, false, null,
+ BugleActionToasts.makeUpdateDestinationBlockedActionListener(context));
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/PendingAttachmentData.java b/src/com/android/messaging/datamodel/data/PendingAttachmentData.java
new file mode 100644
index 0000000..5e079f8
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/PendingAttachmentData.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.datamodel.data;
+
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.annotation.NonNull;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.SafeAsyncTask;
+import com.android.messaging.util.UriUtil;
+
+/**
+ * Represents a "pending" message part that acts as a placeholder for the actual attachment being
+ * loaded. It handles the task to load and persist the attachment from a Uri to local scratch
+ * folder. This item is not persisted to the database.
+ */
+public class PendingAttachmentData extends MessagePartData {
+ /** The pending state. This is the initial state where we haven't started loading yet */
+ public static final int STATE_PENDING = 0;
+
+ /** The state for when we are currently loading the attachment to the scratch space */
+ public static final int STATE_LOADING = 1;
+
+ /** The attachment has been successfully loaded and no longer pending */
+ public static final int STATE_LOADED = 2;
+
+ /** The attachment failed to load */
+ public static final int STATE_FAILED = 3;
+
+ private static final int LOAD_MEDIA_TIME_LIMIT_MILLIS = 60 * 1000; // 60s
+
+ /** The current state of the pending attachment. Refer to the STATE_* states above */
+ private int mCurrentState;
+
+ /**
+ * Create a new instance of PendingAttachmentData with an output Uri.
+ * @param sourceUri the source Uri of the attachment. The Uri maybe temporary or remote,
+ * so we need to persist it to local storage.
+ */
+ protected PendingAttachmentData(final String caption, final String contentType,
+ @NonNull final Uri sourceUri, final int width, final int height,
+ final boolean onlySingleAttachment) {
+ super(caption, contentType, sourceUri, width, height, onlySingleAttachment);
+ mCurrentState = STATE_PENDING;
+ }
+
+ /**
+ * Creates a pending attachment data that is able to load from the given source uri and
+ * persist the media resource locally in the scratch folder.
+ */
+ public static PendingAttachmentData createPendingAttachmentData(final String contentType,
+ final Uri sourceUri) {
+ return createPendingAttachmentData(null, contentType, sourceUri, UNSPECIFIED_SIZE,
+ UNSPECIFIED_SIZE);
+ }
+
+ public static PendingAttachmentData createPendingAttachmentData(final String caption,
+ final String contentType, final Uri sourceUri, final int width, final int height) {
+ Assert.isTrue(ContentType.isMediaType(contentType));
+ return new PendingAttachmentData(caption, contentType, sourceUri, width, height,
+ false /*onlySingleAttachment*/);
+ }
+
+ public static PendingAttachmentData createPendingAttachmentData(final String caption,
+ final String contentType, final Uri sourceUri, final int width, final int height,
+ final boolean onlySingleAttachment) {
+ Assert.isTrue(ContentType.isMediaType(contentType));
+ return new PendingAttachmentData(caption, contentType, sourceUri, width, height,
+ onlySingleAttachment);
+ }
+
+ public int getCurrentState() {
+ return mCurrentState;
+ }
+
+ public void loadAttachmentForDraft(final DraftMessageData draftMessageData,
+ final String bindingId) {
+ if (mCurrentState != STATE_PENDING) {
+ return;
+ }
+ mCurrentState = STATE_LOADING;
+
+ // Kick off a SafeAsyncTask to load the content of the media and persist it locally.
+ // Note: we need to persist the media locally even if it's not remote, because we
+ // want to be able to resend the media in case the message failed to send.
+ new SafeAsyncTask<Void, Void, MessagePartData>(LOAD_MEDIA_TIME_LIMIT_MILLIS,
+ true /* cancelExecutionOnTimeout */) {
+ @Override
+ protected MessagePartData doInBackgroundTimed(final Void... params) {
+ final Uri contentUri = getContentUri();
+ final Uri persistedUri = UriUtil.persistContentToScratchSpace(contentUri);
+ if (persistedUri != null) {
+ return MessagePartData.createMediaMessagePart(
+ getText(),
+ getContentType(),
+ persistedUri,
+ getWidth(),
+ getHeight());
+ }
+ return null;
+ }
+
+ @Override
+ protected void onCancelled() {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Timeout while retrieving media");
+ mCurrentState = STATE_FAILED;
+ if (draftMessageData.isBound(bindingId)) {
+ draftMessageData.removePendingAttachment(PendingAttachmentData.this);
+ }
+ }
+
+ @Override
+ protected void onPostExecute(final MessagePartData attachment) {
+ if (attachment != null) {
+ mCurrentState = STATE_LOADED;
+ if (draftMessageData.isBound(bindingId)) {
+ draftMessageData.updatePendingAttachment(attachment,
+ PendingAttachmentData.this);
+ } else {
+ // The draft message data is no longer bound, drop the loaded attachment.
+ attachment.destroyAsync();
+ }
+ } else {
+ // Media load failed. We already logged in doInBackground() so don't need to
+ // do that again.
+ mCurrentState = STATE_FAILED;
+ if (draftMessageData.isBound(bindingId)) {
+ draftMessageData.onPendingAttachmentLoadFailed(PendingAttachmentData.this);
+ draftMessageData.removePendingAttachment(PendingAttachmentData.this);
+ }
+ }
+ }
+ }.executeOnThreadPool();
+ }
+
+ protected PendingAttachmentData(final Parcel in) {
+ super(in);
+ mCurrentState = in.readInt();
+ }
+
+ @Override
+ public void writeToParcel(final Parcel out, final int flags) {
+ super.writeToParcel(out, flags);
+ out.writeInt(mCurrentState);
+ }
+
+ public static final Parcelable.Creator<PendingAttachmentData> CREATOR
+ = new Parcelable.Creator<PendingAttachmentData>() {
+ @Override
+ public PendingAttachmentData createFromParcel(final Parcel in) {
+ return new PendingAttachmentData(in);
+ }
+
+ @Override
+ public PendingAttachmentData[] newArray(final int size) {
+ return new PendingAttachmentData[size];
+ }
+ };
+}
diff --git a/src/com/android/messaging/datamodel/data/PeopleAndOptionsData.java b/src/com/android/messaging/datamodel/data/PeopleAndOptionsData.java
new file mode 100644
index 0000000..650a037
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/PeopleAndOptionsData.java
@@ -0,0 +1,210 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.app.LoaderManager;
+import android.content.Context;
+import android.content.Loader;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+
+import com.android.messaging.datamodel.BoundCursorLoader;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.action.BugleActionToasts;
+import com.android.messaging.datamodel.action.UpdateConversationOptionsAction;
+import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction;
+import com.android.messaging.datamodel.binding.BindableData;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+
+import java.util.List;
+
+/**
+ * Services data needs for PeopleAndOptionsFragment.
+ */
+public class PeopleAndOptionsData extends BindableData implements
+ LoaderManager.LoaderCallbacks<Cursor> {
+ public interface PeopleAndOptionsDataListener {
+ void onOptionsCursorUpdated(PeopleAndOptionsData data, Cursor cursor);
+ void onParticipantsListLoaded(PeopleAndOptionsData data,
+ List<ParticipantData> participants);
+ }
+
+ private static final String BINDING_ID = "bindingId";
+ private final Context mContext;
+ private final String mConversationId;
+ private final ConversationParticipantsData mParticipantData;
+ private LoaderManager mLoaderManager;
+ private PeopleAndOptionsDataListener mListener;
+
+ public PeopleAndOptionsData(final String conversationId, final Context context,
+ final PeopleAndOptionsDataListener listener) {
+ mListener = listener;
+ mContext = context;
+ mConversationId = conversationId;
+ mParticipantData = new ConversationParticipantsData();
+ }
+
+ private static final int CONVERSATION_OPTIONS_LOADER = 1;
+ private static final int PARTICIPANT_LOADER = 2;
+
+ @Override
+ public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
+ final String bindingId = args.getString(BINDING_ID);
+ // Check if data still bound to the requesting ui element
+ if (isBound(bindingId)) {
+ switch (id) {
+ case CONVERSATION_OPTIONS_LOADER: {
+ final Uri uri =
+ MessagingContentProvider.buildConversationMetadataUri(mConversationId);
+ return new BoundCursorLoader(bindingId, mContext, uri,
+ PeopleOptionsItemData.PROJECTION, null, null, null);
+ }
+
+ case PARTICIPANT_LOADER: {
+ final Uri uri =
+ MessagingContentProvider
+ .buildConversationParticipantsUri(mConversationId);
+ return new BoundCursorLoader(bindingId, mContext, uri,
+ ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
+ }
+
+ default:
+ Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
+ break;
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding PeopleAndOptionsFragment");
+ }
+ return null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onLoadFinished(final Loader<Cursor> loader, final Cursor data) {
+ final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
+ if (isBound(cursorLoader.getBindingId())) {
+ switch (loader.getId()) {
+ case CONVERSATION_OPTIONS_LOADER:
+ mListener.onOptionsCursorUpdated(this, data);
+ break;
+
+ case PARTICIPANT_LOADER:
+ mParticipantData.bind(data);
+ mListener.onParticipantsListLoaded(this,
+ mParticipantData.getParticipantListExcludingSelf());
+ break;
+
+ default:
+ Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
+ break;
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG,
+ "Loader finished after unbinding PeopleAndOptionsFragment");
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onLoaderReset(final Loader<Cursor> loader) {
+ final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
+ if (isBound(cursorLoader.getBindingId())) {
+ switch (loader.getId()) {
+ case CONVERSATION_OPTIONS_LOADER:
+ mListener.onOptionsCursorUpdated(this, null);
+ break;
+
+ case PARTICIPANT_LOADER:
+ mParticipantData.bind(null);
+ break;
+
+ default:
+ Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
+ break;
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding PeopleAndOptionsFragment");
+ }
+ }
+
+ public void init(final LoaderManager loaderManager,
+ final BindingBase<PeopleAndOptionsData> binding) {
+ final Bundle args = new Bundle();
+ args.putString(BINDING_ID, binding.getBindingId());
+ mLoaderManager = loaderManager;
+ mLoaderManager.initLoader(CONVERSATION_OPTIONS_LOADER, args, this);
+ mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this);
+ }
+
+ @Override
+ protected void unregisterListeners() {
+ mListener = null;
+
+ // This could be null if we bind but the caller doesn't init the BindableData
+ if (mLoaderManager != null) {
+ mLoaderManager.destroyLoader(CONVERSATION_OPTIONS_LOADER);
+ mLoaderManager.destroyLoader(PARTICIPANT_LOADER);
+ mLoaderManager = null;
+ }
+ }
+
+ public void enableConversationNotifications(final BindingBase<PeopleAndOptionsData> binding,
+ final boolean enable) {
+ final String bindingId = binding.getBindingId();
+ if (isBound(bindingId)) {
+ UpdateConversationOptionsAction.enableConversationNotifications(
+ mConversationId, enable);
+ }
+ }
+
+ public void setConversationNotificationSound(final BindingBase<PeopleAndOptionsData> binding,
+ final String ringtoneUri) {
+ final String bindingId = binding.getBindingId();
+ if (isBound(bindingId)) {
+ UpdateConversationOptionsAction.setConversationNotificationSound(mConversationId,
+ ringtoneUri);
+ }
+ }
+
+ public void enableConversationNotificationVibration(
+ final BindingBase<PeopleAndOptionsData> binding, final boolean enable) {
+ final String bindingId = binding.getBindingId();
+ if (isBound(bindingId)) {
+ UpdateConversationOptionsAction.enableVibrationForConversationNotification(
+ mConversationId, enable);
+ }
+ }
+
+ public void setDestinationBlocked(final BindingBase<PeopleAndOptionsData> binding,
+ final boolean blocked) {
+ final String bindingId = binding.getBindingId();
+ final ParticipantData participantData = mParticipantData.getOtherParticipant();
+ if (isBound(bindingId) && participantData != null) {
+ UpdateDestinationBlockedAction.updateDestinationBlocked(
+ participantData.getNormalizedDestination(),
+ blocked, mConversationId,
+ BugleActionToasts.makeUpdateDestinationBlockedActionListener(mContext));
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/PeopleOptionsItemData.java b/src/com/android/messaging/datamodel/data/PeopleOptionsItemData.java
new file mode 100644
index 0000000..5af6a30
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/PeopleOptionsItemData.java
@@ -0,0 +1,155 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.media.Ringtone;
+import android.media.RingtoneManager;
+import android.net.Uri;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ConversationListItemData.ConversationListViewColumns;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.RingtoneUtil;
+
+public class PeopleOptionsItemData {
+ public static final String[] PROJECTION = {
+ ConversationListViewColumns.NOTIFICATION_ENABLED,
+ ConversationListViewColumns.NOTIFICATION_SOUND_URI,
+ ConversationListViewColumns.NOTIFICATION_VIBRATION,
+ };
+
+ // Column index for query projection.
+ private static final int INDEX_NOTIFICATION_ENABLED = 0;
+ private static final int INDEX_NOTIFICATION_SOUND_URI = 1;
+ private static final int INDEX_NOTIFICATION_VIBRATION = 2;
+
+ // Identification for each setting that's surfaced to the UI layer.
+ public static final int SETTING_NOTIFICATION_ENABLED = 0;
+ public static final int SETTING_NOTIFICATION_SOUND_URI = 1;
+ public static final int SETTING_NOTIFICATION_VIBRATION = 2;
+ public static final int SETTING_BLOCKED = 3;
+ public static final int SETTINGS_COUNT = 4;
+
+ // Type of UI switch to show for the toggle button.
+ public static final int TOGGLE_TYPE_CHECKBOX = 0;
+ public static final int TOGGLE_TYPE_SWITCH = 1;
+
+ private String mTitle;
+ private String mSubtitle;
+ private Uri mRingtoneUri;
+ private boolean mCheckable;
+ private boolean mChecked;
+ private boolean mEnabled;
+ private int mItemId;
+ private ParticipantData mOtherParticipant;
+
+ private final Context mContext;
+
+ public PeopleOptionsItemData(final Context context) {
+ mContext = context;
+ }
+
+ /**
+ * Bind to a specific setting column on conversation metadata cursor. (Note
+ * that it binds to columns because it treats individual columns of the cursor as
+ * separate options to display for the conversation, e.g. notification settings).
+ */
+ public void bind(
+ final Cursor cursor, final ParticipantData otherParticipant, final int settingType) {
+ mSubtitle = null;
+ mRingtoneUri = null;
+ mCheckable = true;
+ mEnabled = true;
+ mItemId = settingType;
+ mOtherParticipant = otherParticipant;
+
+ final boolean notificationEnabled = cursor.getInt(INDEX_NOTIFICATION_ENABLED) == 1;
+ switch (settingType) {
+ case SETTING_NOTIFICATION_ENABLED:
+ mTitle = mContext.getString(R.string.notifications_enabled_conversation_pref_title);
+ mChecked = notificationEnabled;
+ break;
+
+ case SETTING_NOTIFICATION_SOUND_URI:
+ mTitle = mContext.getString(R.string.notification_sound_pref_title);
+ final String ringtoneString = cursor.getString(INDEX_NOTIFICATION_SOUND_URI);
+ Uri ringtoneUri = RingtoneUtil.getNotificationRingtoneUri(ringtoneString);
+
+ mSubtitle = mContext.getString(R.string.silent_ringtone);
+ if (ringtoneUri != null) {
+ final Ringtone ringtone = RingtoneManager.getRingtone(mContext, ringtoneUri);
+ if (ringtone != null) {
+ mSubtitle = ringtone.getTitle(mContext);
+ }
+ }
+ mCheckable = false;
+ mRingtoneUri = ringtoneUri;
+ mEnabled = notificationEnabled;
+ break;
+
+ case SETTING_NOTIFICATION_VIBRATION:
+ mTitle = mContext.getString(R.string.notification_vibrate_pref_title);
+ mChecked = cursor.getInt(INDEX_NOTIFICATION_VIBRATION) == 1;
+ mEnabled = notificationEnabled;
+ break;
+
+ case SETTING_BLOCKED:
+ Assert.notNull(otherParticipant);
+ final int resourceId = otherParticipant.isBlocked() ?
+ R.string.unblock_contact_title : R.string.block_contact_title;
+ mTitle = mContext.getString(resourceId, otherParticipant.getDisplayDestination());
+ mCheckable = false;
+ break;
+
+ default:
+ Assert.fail("Unsupported conversation option type!");
+ }
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public String getSubtitle() {
+ return mSubtitle;
+ }
+
+ public boolean getCheckable() {
+ return mCheckable;
+ }
+
+ public boolean getChecked() {
+ return mChecked;
+ }
+
+ public boolean getEnabled() {
+ return mEnabled;
+ }
+
+ public int getItemId() {
+ return mItemId;
+ }
+
+ public Uri getRingtoneUri() {
+ return mRingtoneUri;
+ }
+
+ public ParticipantData getOtherParticipant() {
+ return mOtherParticipant;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/PersonItemData.java b/src/com/android/messaging/datamodel/data/PersonItemData.java
new file mode 100644
index 0000000..a0a1ce8
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/PersonItemData.java
@@ -0,0 +1,67 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.content.Intent;
+import android.net.Uri;
+
+import com.android.messaging.datamodel.binding.BindableData;
+
+/**
+ * Bridges between any particpant/contact related data and data displayed in the PersonItemView.
+ */
+public abstract class PersonItemData extends BindableData {
+ /**
+ * The UI component that listens for data change and update accordingly.
+ */
+ public interface PersonItemDataListener {
+ void onPersonDataUpdated(PersonItemData data);
+ void onPersonDataFailed(PersonItemData data, Exception exception);
+ }
+
+ private PersonItemDataListener mListener;
+
+ public abstract Uri getAvatarUri();
+ public abstract String getDisplayName();
+ public abstract String getDetails();
+ public abstract Intent getClickIntent();
+ public abstract long getContactId();
+ public abstract String getLookupKey();
+ public abstract String getNormalizedDestination();
+
+ public void setListener(final PersonItemDataListener listener) {
+ if (isBound()) {
+ mListener = listener;
+ }
+ }
+
+ protected void notifyDataUpdated() {
+ if (isBound() && mListener != null) {
+ mListener.onPersonDataUpdated(this);
+ }
+ }
+
+ protected void notifyDataFailed(final Exception exception) {
+ if (isBound() && mListener != null) {
+ mListener.onPersonDataFailed(this, exception);
+ }
+ }
+
+ @Override
+ protected void unregisterListeners() {
+ mListener = null;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/SelfParticipantsData.java b/src/com/android/messaging/datamodel/data/SelfParticipantsData.java
new file mode 100644
index 0000000..43302ed
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/SelfParticipantsData.java
@@ -0,0 +1,107 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.database.Cursor;
+import android.support.v4.util.ArrayMap;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.android.messaging.util.OsUtil;
+
+/**
+ * A class that contains the list of all self participants potentially involved in a conversation.
+ * This class contains both active/inactive self entries when there is multi-SIM support.
+ */
+public class SelfParticipantsData {
+ /**
+ * The map from self participant ids to self-participant data entries in the participants table.
+ * This includes both active, inactive and default (with subId ==
+ * {@link ParticipantData#DEFAULT_SELF_SUB_ID}) subscriptions.
+ */
+ private final ArrayMap<String, ParticipantData> mSelfParticipantMap;
+
+ public SelfParticipantsData() {
+ mSelfParticipantMap = new ArrayMap<String, ParticipantData>();
+ }
+
+ public void bind(final Cursor cursor) {
+ mSelfParticipantMap.clear();
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ final ParticipantData newParticipant = ParticipantData.getFromCursor(cursor);
+ mSelfParticipantMap.put(newParticipant.getId(), newParticipant);
+ }
+ }
+ }
+
+ /**
+ * Gets the list of self participants for all subscriptions.
+ * @param activeOnly if set, returns active self entries only (i.e. those with SIMs plugged in).
+ */
+ public List<ParticipantData> getSelfParticipants(final boolean activeOnly) {
+ List<ParticipantData> list = new ArrayList<ParticipantData>();
+ for (final ParticipantData self : mSelfParticipantMap.values()) {
+ if (!activeOnly || self.isActiveSubscription()) {
+ list.add(self);
+ }
+ }
+ return list;
+ }
+
+ /**
+ * Gets the self participant corresponding to the given self id.
+ */
+ ParticipantData getSelfParticipantById(final String selfId) {
+ return mSelfParticipantMap.get(selfId);
+ }
+
+ /**
+ * Returns if a given self id represents the default self.
+ */
+ boolean isDefaultSelf(final String selfId) {
+ if (!OsUtil.isAtLeastL_MR1()) {
+ return true;
+ }
+ final ParticipantData self = getSelfParticipantById(selfId);
+ return self == null ? false : self.getSubId() == ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+
+ public int getSelfParticipantsCountExcludingDefault(final boolean activeOnly) {
+ int count = 0;
+ for (final ParticipantData self : mSelfParticipantMap.values()) {
+ if (!self.isDefaultSelf() && (!activeOnly || self.isActiveSubscription())) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ public ParticipantData getDefaultSelfParticipant() {
+ for (final ParticipantData self : mSelfParticipantMap.values()) {
+ if (self.isDefaultSelf()) {
+ return self;
+ }
+ }
+ return null;
+ }
+
+ boolean isLoaded() {
+ return !mSelfParticipantMap.isEmpty();
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/SettingsData.java b/src/com/android/messaging/datamodel/data/SettingsData.java
new file mode 100644
index 0000000..7474619
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/SettingsData.java
@@ -0,0 +1,223 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.app.LoaderManager;
+import android.content.Context;
+import android.content.Loader;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.text.TextUtils;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.BoundCursorLoader;
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.binding.BindableData;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Services SettingsFragment's data needs for loading active self participants to display
+ * the list of active subscriptions.
+ */
+public class SettingsData extends BindableData implements
+ LoaderManager.LoaderCallbacks<Cursor> {
+ public interface SettingsDataListener {
+ void onSelfParticipantDataLoaded(SettingsData data);
+ }
+
+ public static class SettingsItem {
+ public static final int TYPE_GENERAL_SETTINGS = 1;
+ public static final int TYPE_PER_SUBSCRIPTION_SETTINGS = 2;
+
+ private final String mDisplayName;
+ private final String mDisplayDetail;
+ private final String mActivityTitle;
+ private final int mType;
+ private final int mSubId;
+
+ private SettingsItem(final String displayName, final String displayDetail,
+ final String activityTitle, final int type, final int subId) {
+ mDisplayName = displayName;
+ mDisplayDetail = displayDetail;
+ mActivityTitle = activityTitle;
+ mType = type;
+ mSubId = subId;
+ }
+
+ public String getDisplayName() {
+ return mDisplayName;
+ }
+
+ public String getDisplayDetail() {
+ return mDisplayDetail;
+ }
+
+ public int getType() {
+ return mType;
+ }
+
+ public int getSubId() {
+ return mSubId;
+ }
+
+ public String getActivityTitle() {
+ return mActivityTitle;
+ }
+
+ public static SettingsItem fromSelfParticipant(final Context context,
+ final ParticipantData self) {
+ Assert.isTrue(self.isSelf());
+ Assert.isTrue(self.isActiveSubscription());
+ final String displayDetail = TextUtils.isEmpty(self.getDisplayDestination()) ?
+ context.getString(R.string.sim_settings_unknown_number) :
+ self.getDisplayDestination();
+ final String displayName = context.getString(R.string.sim_specific_settings,
+ self.getSubscriptionName());
+ return new SettingsItem(displayName, displayDetail, displayName,
+ TYPE_PER_SUBSCRIPTION_SETTINGS, self.getSubId());
+ }
+
+ public static SettingsItem createGeneralSettingsItem(final Context context) {
+ return new SettingsItem(context.getString(R.string.general_settings),
+ null, context.getString(R.string.general_settings_activity_title),
+ TYPE_GENERAL_SETTINGS, -1);
+ }
+
+ public static SettingsItem createDefaultMmsSettingsItem(final Context context,
+ final int subId) {
+ return new SettingsItem(context.getString(R.string.advanced_settings),
+ null, context.getString(R.string.advanced_settings_activity_title),
+ TYPE_PER_SUBSCRIPTION_SETTINGS, subId);
+ }
+ }
+
+ private static final String BINDING_ID = "bindingId";
+ private final Context mContext;
+ private final SelfParticipantsData mSelfParticipantsData;
+ private LoaderManager mLoaderManager;
+ private SettingsDataListener mListener;
+
+ public SettingsData(final Context context,
+ final SettingsDataListener listener) {
+ mListener = listener;
+ mContext = context;
+ mSelfParticipantsData = new SelfParticipantsData();
+ }
+
+ private static final int SELF_PARTICIPANT_LOADER = 1;
+
+ @Override
+ public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
+ Assert.equals(SELF_PARTICIPANT_LOADER, id);
+ Loader<Cursor> loader = null;
+
+ final String bindingId = args.getString(BINDING_ID);
+ // Check if data still bound to the requesting ui element
+ if (isBound(bindingId)) {
+ loader = new BoundCursorLoader(bindingId, mContext,
+ MessagingContentProvider.PARTICIPANTS_URI,
+ ParticipantData.ParticipantsQuery.PROJECTION,
+ ParticipantColumns.SUB_ID + " <> ?",
+ new String[] { String.valueOf(ParticipantData.OTHER_THAN_SELF_SUB_ID) },
+ null);
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Creating self loader after unbinding");
+ }
+ return loader;
+ }
+
+ @Override
+ public void onLoadFinished(final Loader<Cursor> generic, final Cursor data) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+
+ // Check if data still bound to the requesting ui element
+ if (isBound(loader.getBindingId())) {
+ mSelfParticipantsData.bind(data);
+ mListener.onSelfParticipantDataLoaded(this);
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Self loader finished after unbinding");
+ }
+ }
+
+ @Override
+ public void onLoaderReset(final Loader<Cursor> generic) {
+ final BoundCursorLoader loader = (BoundCursorLoader) generic;
+
+ // Check if data still bound to the requesting ui element
+ if (isBound(loader.getBindingId())) {
+ mSelfParticipantsData.bind(null);
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Self loader reset after unbinding");
+ }
+ }
+
+ public void init(final LoaderManager loaderManager,
+ final BindingBase<SettingsData> binding) {
+ final Bundle args = new Bundle();
+ args.putString(BINDING_ID, binding.getBindingId());
+ mLoaderManager = loaderManager;
+ mLoaderManager.initLoader(SELF_PARTICIPANT_LOADER, args, this);
+ }
+
+ @Override
+ protected void unregisterListeners() {
+ mListener = null;
+
+ // This could be null if we bind but the caller doesn't init the BindableData
+ if (mLoaderManager != null) {
+ mLoaderManager.destroyLoader(SELF_PARTICIPANT_LOADER);
+ mLoaderManager = null;
+ }
+ }
+
+ public List<SettingsItem> getSettingsItems() {
+ final List<ParticipantData> selfs = mSelfParticipantsData.getSelfParticipants(true);
+ final List<SettingsItem> settingsItems = new ArrayList<SettingsItem>();
+ // First goes the general settings, followed by per-subscription settings.
+ settingsItems.add(SettingsItem.createGeneralSettingsItem(mContext));
+ // For per-subscription settings, show the actual SIM name with phone number if the
+ // platorm is at least L-MR1 and there are multiple active SIMs.
+ final int activeSubCountExcludingDefault =
+ mSelfParticipantsData.getSelfParticipantsCountExcludingDefault(true);
+ if (OsUtil.isAtLeastL_MR1() && activeSubCountExcludingDefault > 0) {
+ for (ParticipantData self : selfs) {
+ if (!self.isDefaultSelf()) {
+ if (activeSubCountExcludingDefault > 1) {
+ settingsItems.add(SettingsItem.fromSelfParticipant(mContext, self));
+ } else {
+ // This is the only active non-default SIM.
+ settingsItems.add(SettingsItem.createDefaultMmsSettingsItem(mContext,
+ self.getSubId()));
+ break;
+ }
+ }
+ }
+ } else {
+ // Either pre-L-MR1, or there's no active SIM, so show the default MMS settings.
+ settingsItems.add(SettingsItem.createDefaultMmsSettingsItem(mContext,
+ ParticipantData.DEFAULT_SELF_SUB_ID));
+ }
+ return settingsItems;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/SubscriptionListData.java b/src/com/android/messaging/datamodel/data/SubscriptionListData.java
new file mode 100644
index 0000000..b5d4e4b
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/SubscriptionListData.java
@@ -0,0 +1,128 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.content.Context;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import com.android.messaging.R;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * This is a UI facing data model component that holds a list of
+ * {@link SubscriptionListData.SubscriptionListEntry}'s, one for each *active* subscriptions.
+ *
+ * This is used to:
+ * 1) Show a list of SIMs in the SIM Selector
+ * 2) Show the currently selected SIM in the compose message view
+ * 3) Show SIM indicators on conversation message views
+ *
+ * It builds on top of SelfParticipantsData and performs additional logic such as determining
+ * the set of icons to use for the individual Subs.
+ */
+public class SubscriptionListData {
+ /**
+ * Represents a single sub that backs UI.
+ */
+ public static class SubscriptionListEntry {
+ public final String selfParticipantId;
+ public final Uri iconUri;
+ public final Uri selectedIconUri;
+ public final String displayName;
+ public final int displayColor;
+ public final String displayDestination;
+
+ private SubscriptionListEntry(final String selfParticipantId, final Uri iconUri,
+ final Uri selectedIconUri, final String displayName, final int displayColor,
+ final String displayDestination) {
+ this.selfParticipantId = selfParticipantId;
+ this.iconUri = iconUri;
+ this.selectedIconUri = selectedIconUri;
+ this.displayName = displayName;
+ this.displayColor = displayColor;
+ this.displayDestination = displayDestination;
+ }
+
+ static SubscriptionListEntry fromSelfParticipantData(
+ final ParticipantData selfParticipantData, final Context context) {
+ Assert.isTrue(selfParticipantData.isSelf());
+ Assert.isTrue(selfParticipantData.isActiveSubscription());
+ final int slotId = selfParticipantData.getDisplaySlotId();
+ final String iconIdentifier = String.format(Locale.getDefault(), "%d", slotId);
+ final String subscriptionName = selfParticipantData.getSubscriptionName();
+ final String displayName = TextUtils.isEmpty(subscriptionName) ?
+ context.getString(R.string.sim_slot_identifier, slotId) : subscriptionName;
+ return new SubscriptionListEntry(selfParticipantData.getId(),
+ AvatarUriUtil.createAvatarUri(selfParticipantData, iconIdentifier,
+ false /* selected */, false /* incoming */),
+ AvatarUriUtil.createAvatarUri(selfParticipantData, iconIdentifier,
+ true /* selected */, false /* incoming */),
+ displayName, selfParticipantData.getSubscriptionColor(),
+ selfParticipantData.getDisplayDestination());
+ }
+ }
+
+ private final List<SubscriptionListEntry> mEntriesExcludingDefault;
+ private SubscriptionListEntry mDefaultEntry;
+ private final Context mContext;
+
+ public SubscriptionListData(final Context context) {
+ mEntriesExcludingDefault = new ArrayList<SubscriptionListEntry>();
+ mContext = context;
+ }
+
+ public void bind(final List<ParticipantData> subs) {
+ mEntriesExcludingDefault.clear();
+ mDefaultEntry = null;
+ for (final ParticipantData sub : subs) {
+ final SubscriptionListEntry entry =
+ SubscriptionListEntry.fromSelfParticipantData(sub, mContext);
+ if (!sub.isDefaultSelf()) {
+ mEntriesExcludingDefault.add(entry);
+ } else {
+ mDefaultEntry = entry;
+ }
+ }
+ }
+
+ public List<SubscriptionListEntry> getActiveSubscriptionEntriesExcludingDefault() {
+ return mEntriesExcludingDefault;
+ }
+
+ public SubscriptionListEntry getActiveSubscriptionEntryBySelfId(final String selfId,
+ final boolean excludeDefault) {
+ if (mDefaultEntry != null && TextUtils.equals(mDefaultEntry.selfParticipantId, selfId)) {
+ return excludeDefault ? null : mDefaultEntry;
+ }
+
+ for (final SubscriptionListEntry entry : mEntriesExcludingDefault) {
+ if (TextUtils.equals(entry.selfParticipantId, selfId)) {
+ return entry;
+ }
+ }
+ return null;
+ }
+
+ public boolean hasData() {
+ return !mEntriesExcludingDefault.isEmpty() || mDefaultEntry != null;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/data/VCardContactItemData.java b/src/com/android/messaging/datamodel/data/VCardContactItemData.java
new file mode 100644
index 0000000..8abf493
--- /dev/null
+++ b/src/com/android/messaging/datamodel/data/VCardContactItemData.java
@@ -0,0 +1,185 @@
+/*
+ * 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.messaging.datamodel.data;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.media.BindableMediaRequest;
+import com.android.messaging.datamodel.media.MediaRequest;
+import com.android.messaging.datamodel.media.MediaResourceManager;
+import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener;
+import com.android.messaging.datamodel.media.VCardRequestDescriptor;
+import com.android.messaging.datamodel.media.VCardResource;
+import com.android.messaging.datamodel.media.VCardResourceEntry;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.ContactUtil;
+
+import java.util.List;
+
+/**
+ * Data class for visualizing and loading data for a VCard contact.
+ */
+public class VCardContactItemData extends PersonItemData
+ implements MediaResourceLoadListener<VCardResource> {
+ private final Context mContext;
+ private final Uri mVCardUri;
+ private String mDetails;
+ private final Binding<BindableMediaRequest<VCardResource>> mBinding =
+ BindingBase.createBinding(this);
+ private VCardResource mVCardResource;
+
+ private static final Uri sDefaultAvatarUri =
+ AvatarUriUtil.createAvatarUri(null, null, null, null);
+
+ /**
+ * Constructor. This parses data from the given MessagePartData describing the vcard
+ */
+ public VCardContactItemData(final Context context, final MessagePartData messagePartData) {
+ this(context, messagePartData.getContentUri());
+ Assert.isTrue(messagePartData.isVCard());
+ }
+
+ /**
+ * Constructor. This parses data from the given VCard Uri
+ */
+ public VCardContactItemData(final Context context, final Uri vCardUri) {
+ mContext = context;
+ mDetails = mContext.getString(R.string.loading_vcard);
+ mVCardUri = vCardUri;
+ }
+
+ @Override
+ public Uri getAvatarUri() {
+ if (hasValidVCard()) {
+ final List<VCardResourceEntry> vcards = mVCardResource.getVCards();
+ Assert.isTrue(vcards.size() > 0);
+ if (vcards.size() == 1) {
+ return vcards.get(0).getAvatarUri();
+ }
+ }
+ return sDefaultAvatarUri;
+ }
+
+ @Override
+ public String getDisplayName() {
+ if (hasValidVCard()) {
+ final List<VCardResourceEntry> vcards = mVCardResource.getVCards();
+ Assert.isTrue(vcards.size() > 0);
+ if (vcards.size() == 1) {
+ return vcards.get(0).getDisplayName();
+ } else {
+ return mContext.getResources().getQuantityString(
+ R.plurals.vcard_multiple_display_name, vcards.size(), vcards.size());
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public String getDetails() {
+ return mDetails;
+ }
+
+ @Override
+ public Intent getClickIntent() {
+ return null;
+ }
+
+ @Override
+ public long getContactId() {
+ return ContactUtil.INVALID_CONTACT_ID;
+ }
+
+ @Override
+ public String getLookupKey() {
+ return null;
+ }
+
+ @Override
+ public String getNormalizedDestination() {
+ return null;
+ }
+
+ public VCardResource getVCardResource() {
+ return hasValidVCard() ? mVCardResource : null;
+ }
+
+ public Uri getVCardUri() {
+ return hasValidVCard() ? mVCardUri : null;
+ }
+
+ public boolean hasValidVCard() {
+ return isBound() && mVCardResource != null;
+ }
+
+ @Override
+ public void bind(final String bindingId) {
+ super.bind(bindingId);
+
+ // Bind and request the VCard from media resource manager.
+ mBinding.bind(new VCardRequestDescriptor(mVCardUri).buildAsyncMediaRequest(mContext, this));
+ MediaResourceManager.get().requestMediaResourceAsync(mBinding.getData());
+ }
+
+ @Override
+ public void unbind(final String bindingId) {
+ super.unbind(bindingId);
+ mBinding.unbind();
+ if (mVCardResource != null) {
+ mVCardResource.release();
+ mVCardResource = null;
+ }
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+
+ if (!(o instanceof VCardContactItemData)) {
+ return false;
+ }
+
+ final VCardContactItemData lhs = (VCardContactItemData) o;
+ return mVCardUri.equals(lhs.mVCardUri);
+ }
+
+ @Override
+ public void onMediaResourceLoaded(final MediaRequest<VCardResource> request,
+ final VCardResource resource, final boolean isCached) {
+ Assert.isTrue(mVCardResource == null);
+ mBinding.ensureBound();
+ mDetails = mContext.getString(R.string.vcard_tap_hint);
+ mVCardResource = resource;
+ mVCardResource.addRef();
+ notifyDataUpdated();
+ }
+
+ @Override
+ public void onMediaResourceLoadError(final MediaRequest<VCardResource> request,
+ final Exception exception) {
+ mBinding.ensureBound();
+ mDetails = mContext.getString(R.string.failed_loading_vcard);
+ notifyDataFailed(exception);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java b/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java
new file mode 100644
index 0000000..380d93c
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java
@@ -0,0 +1,74 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener;
+
+import java.util.List;
+
+/**
+ * A mix-in style class that wraps around a normal, threading-agnostic MediaRequest object with
+ * functionalities offered by {@link BindableMediaRequest} to allow for async processing.
+ */
+class AsyncMediaRequestWrapper<T extends RefCountedMediaResource> extends BindableMediaRequest<T> {
+
+ /**
+ * Create a new async media request wrapper instance given the listener.
+ */
+ public static <T extends RefCountedMediaResource> AsyncMediaRequestWrapper<T>
+ createWith(final MediaRequest<T> wrappedRequest,
+ final MediaResourceLoadListener<T> listener) {
+ return new AsyncMediaRequestWrapper<T>(listener, wrappedRequest);
+ }
+
+ private final MediaRequest<T> mWrappedRequest;
+
+ private AsyncMediaRequestWrapper(final MediaResourceLoadListener<T> listener,
+ final MediaRequest<T> wrappedRequest) {
+ super(listener);
+ mWrappedRequest = wrappedRequest;
+ }
+
+ @Override
+ public String getKey() {
+ return mWrappedRequest.getKey();
+ }
+
+ @Override
+ public MediaCache<T> getMediaCache() {
+ return mWrappedRequest.getMediaCache();
+ }
+
+ @Override
+ public int getRequestType() {
+ return mWrappedRequest.getRequestType();
+ }
+
+ @Override
+ public T loadMediaBlocking(List<MediaRequest<T>> chainedTask) throws Exception {
+ return mWrappedRequest.loadMediaBlocking(chainedTask);
+ }
+
+ @Override
+ public int getCacheId() {
+ return mWrappedRequest.getCacheId();
+ }
+
+ @Override
+ public MediaRequestDescriptor<T> getDescriptor() {
+ return mWrappedRequest.getDescriptor();
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java b/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java
new file mode 100644
index 0000000..719b296
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java
@@ -0,0 +1,159 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Context;
+import android.graphics.RectF;
+import android.net.Uri;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class AvatarGroupRequestDescriptor extends CompositeImageRequestDescriptor {
+ private static final int MAX_GROUP_SIZE = 4;
+
+ public AvatarGroupRequestDescriptor(final Uri uri, final int desiredWidth,
+ final int desiredHeight) {
+ this(convertToDescriptor(uri, desiredWidth, desiredHeight), desiredWidth, desiredHeight);
+ }
+
+ public AvatarGroupRequestDescriptor(final List<? extends ImageRequestDescriptor> descriptors,
+ final int desiredWidth, final int desiredHeight) {
+ super(descriptors, desiredWidth, desiredHeight);
+ Assert.isTrue(descriptors.size() <= MAX_GROUP_SIZE);
+ }
+
+ private static List<? extends ImageRequestDescriptor> convertToDescriptor(final Uri uri,
+ final int desiredWidth, final int desiredHeight) {
+ final List<String> participantUriStrings = AvatarUriUtil.getGroupParticipantUris(uri);
+ final List<AvatarRequestDescriptor> avatarDescriptors =
+ new ArrayList<AvatarRequestDescriptor>(participantUriStrings.size());
+ for (final String uriString : participantUriStrings) {
+ final AvatarRequestDescriptor descriptor = new AvatarRequestDescriptor(
+ Uri.parse(uriString), desiredWidth, desiredHeight);
+ avatarDescriptors.add(descriptor);
+ }
+ return avatarDescriptors;
+ }
+
+ @Override
+ public CompositeImageRequest<?> buildBatchImageRequest(final Context context) {
+ return new CompositeImageRequest<AvatarGroupRequestDescriptor>(context, this);
+ }
+
+ @Override
+ public List<RectF> getChildRequestTargetRects() {
+ return Arrays.asList(generateDestRectArray());
+ }
+
+ /**
+ * Generates an array of {@link RectF} which represents where each of the individual avatar
+ * should be located in the final group avatar image. The location of each avatar depends on
+ * the size of the group and the size of the overall group avatar size.
+ */
+ private RectF[] generateDestRectArray() {
+ final int groupSize = mDescriptors.size();
+ final float width = desiredWidth;
+ final float height = desiredHeight;
+ final float halfWidth = width / 2F;
+ final float halfHeight = height / 2F;
+ final RectF[] destArray = new RectF[groupSize];
+ switch (groupSize) {
+ case 2:
+ /**
+ * +-------+
+ * | 0 | |
+ * +-------+
+ * | | 1 |
+ * +-------+
+ *
+ * We want two circles which touches in the center. To get this we know that the
+ * diagonal of the overall group avatar is squareRoot(2) * w We also know that the
+ * two circles touches the at the center of the overall group avatar and the
+ * distance from the center of the circle to the corner of the group avatar is
+ * radius * squareRoot(2). Therefore, the following emerges.
+ *
+ * w * squareRoot(2) = 2 (radius + radius * squareRoot(2))
+ * Solving for radius we get:
+ * d = 2 * radius = ( squareRoot(2) / (squareRoot(2) + 1)) * w
+ * d = (2 - squareRoot(2)) * w
+ */
+ final float diameter = (float) ((2 - Math.sqrt(2)) * width);
+ destArray[0] = new RectF(0, 0, diameter, diameter);
+ destArray[1] = new RectF(width - diameter, height - diameter, width, height);
+ break;
+ case 3:
+ /**
+ * +-------+
+ * | | 0 | |
+ * +-------+
+ * | 1 | 2 |
+ * +-------+
+ * i0
+ * |\
+ * a | \ c
+ * --- i2
+ * b
+ *
+ * a = radius * squareRoot(3) due to the triangle being a 30-60-90 right triangle.
+ * b = radius of circle
+ * c = 2 * radius of circle
+ *
+ * All three of the images are circles and therefore image zero will not touch
+ * image one or image two. Move image zero down so it touches image one and image
+ * two. This can be done by keeping image zero in the center and moving it down
+ * slightly. The amount to move down can be calculated by solving a right triangle.
+ * We know that the center x of image two to the center x of image zero is the
+ * radius of the circle, this is the length of edge b. Also we know that the
+ * distance from image zero to image two's center is 2 * radius, edge c. From this
+ * we know that the distance from center y of image two to center y of image one,
+ * edge a, is equal to radius * squareRoot(3) due to this triangle being a 30-60-90
+ * right triangle.
+ */
+ final float quarterWidth = width / 4F;
+ final float threeQuarterWidth = 3 * quarterWidth;
+ final float radius = height / 4F;
+ final float imageTwoCenterY = height - radius;
+ final float lengthOfEdgeA = (float) (radius * Math.sqrt(3));
+ final float imageZeroCenterY = imageTwoCenterY - lengthOfEdgeA;
+ final float imageZeroTop = imageZeroCenterY - radius;
+ final float imageZeroBottom = imageZeroCenterY + radius;
+ destArray[0] = new RectF(
+ quarterWidth, imageZeroTop, threeQuarterWidth, imageZeroBottom);
+ destArray[1] = new RectF(0, halfHeight, halfWidth, height);
+ destArray[2] = new RectF(halfWidth, halfHeight, width, height);
+ break;
+ default:
+ /**
+ * +-------+
+ * | 0 | 1 |
+ * +-------+
+ * | 2 | 3 |
+ * +-------+
+ */
+ destArray[0] = new RectF(0, 0, halfWidth, halfHeight);
+ destArray[1] = new RectF(halfWidth, 0, width, halfHeight);
+ destArray[2] = new RectF(0, halfHeight, halfWidth, height);
+ destArray[3] = new RectF(halfWidth, halfHeight, width, height);
+ break;
+ }
+ return destArray;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/AvatarRequest.java b/src/com/android/messaging/datamodel/media/AvatarRequest.java
new file mode 100644
index 0000000..22d5ccc
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/AvatarRequest.java
@@ -0,0 +1,189 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.graphics.drawable.BitmapDrawable;
+import android.media.ExifInterface;
+import android.net.Uri;
+
+import com.android.messaging.R;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.UriUtil;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+public class AvatarRequest extends UriImageRequest<AvatarRequestDescriptor> {
+ private static Bitmap sDefaultPersonBitmap;
+ private static Bitmap sDefaultPersonBitmapLarge;
+
+ public AvatarRequest(final Context context,
+ final AvatarRequestDescriptor descriptor) {
+ super(context, descriptor);
+ }
+
+ @Override
+ protected InputStream getInputStreamForResource() throws FileNotFoundException {
+ if (UriUtil.isLocalResourceUri(mDescriptor.uri)) {
+ return super.getInputStreamForResource();
+ } else {
+ final Uri primaryUri = AvatarUriUtil.getPrimaryUri(mDescriptor.uri);
+ Assert.isTrue(UriUtil.isLocalResourceUri(primaryUri));
+ return mContext.getContentResolver().openInputStream(primaryUri);
+ }
+ }
+
+ /**
+ * We can load multiple types of images for avatars depending on the uri. The uri should be
+ * built by {@link com.android.messaging.util.AvatarUriUtil} which will decide on
+ * what uri to build based on the available profile photo and name. Here we will check if the
+ * image is a local resource (ie profile photo uri), if the resource isn't a local one we will
+ * generate a tile with the first letter of the name.
+ */
+ @Override
+ protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTasks)
+ throws IOException {
+ Assert.isNotMainThread();
+ String avatarType = AvatarUriUtil.getAvatarType(mDescriptor.uri);
+ Bitmap bitmap = null;
+ int orientation = ExifInterface.ORIENTATION_NORMAL;
+ final boolean isLocalResourceUri = UriUtil.isLocalResourceUri(mDescriptor.uri) ||
+ AvatarUriUtil.TYPE_LOCAL_RESOURCE_URI.equals(avatarType);
+ if (isLocalResourceUri) {
+ try {
+ ImageResource imageResource = super.loadMediaInternal(chainedTasks);
+ bitmap = imageResource.getBitmap();
+ orientation = imageResource.mOrientation;
+ } catch (Exception ex) {
+ // If we encountered any exceptions trying to load the local avatar resource,
+ // fall back to generated avatar.
+ LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "AvatarRequest: failed to load local avatar " +
+ "resource, switching to fallback rendering", ex);
+ }
+ }
+
+ final int width = mDescriptor.desiredWidth;
+ final int height = mDescriptor.desiredHeight;
+ // Check to see if we already got the bitmap. If not get a fallback avatar
+ if (bitmap == null) {
+ Uri generatedUri = mDescriptor.uri;
+ if (isLocalResourceUri) {
+ // If we are here, we just failed to load the local resource. Use the fallback Uri
+ // if possible.
+ generatedUri = AvatarUriUtil.getFallbackUri(mDescriptor.uri);
+ if (generatedUri == null) {
+ // No fallback Uri was provided, use the default avatar.
+ generatedUri = AvatarUriUtil.DEFAULT_BACKGROUND_AVATAR;
+ }
+ }
+
+ avatarType = AvatarUriUtil.getAvatarType(generatedUri);
+ if (AvatarUriUtil.TYPE_LETTER_TILE_URI.equals(avatarType)) {
+ final String name = AvatarUriUtil.getName(generatedUri);
+ bitmap = renderLetterTile(name, width, height);
+ } else {
+ bitmap = renderDefaultAvatar(width, height);
+ }
+ }
+ return new DecodedImageResource(getKey(), bitmap, orientation);
+ }
+
+ private Bitmap renderDefaultAvatar(final int width, final int height) {
+ final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height,
+ getBackgroundColor());
+ final Canvas canvas = new Canvas(bitmap);
+
+ if (sDefaultPersonBitmap == null) {
+ final BitmapDrawable defaultPerson = (BitmapDrawable) mContext.getResources()
+ .getDrawable(R.drawable.ic_person_light);
+ sDefaultPersonBitmap = defaultPerson.getBitmap();
+ }
+ if (sDefaultPersonBitmapLarge == null) {
+ final BitmapDrawable largeDefaultPerson = (BitmapDrawable) mContext.getResources()
+ .getDrawable(R.drawable.ic_person_light_large);
+ sDefaultPersonBitmapLarge = largeDefaultPerson.getBitmap();
+ }
+
+ Bitmap defaultPerson = null;
+ if (mDescriptor.isWearBackground) {
+ final BitmapDrawable wearDefaultPerson = (BitmapDrawable) mContext.getResources()
+ .getDrawable(R.drawable.ic_person_wear);
+ defaultPerson = wearDefaultPerson.getBitmap();
+ } else {
+ final boolean isLargeDefault = (width > sDefaultPersonBitmap.getWidth()) ||
+ (height > sDefaultPersonBitmap.getHeight());
+ defaultPerson =
+ isLargeDefault ? sDefaultPersonBitmapLarge : sDefaultPersonBitmap;
+ }
+
+ final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ final Matrix matrix = new Matrix();
+ final RectF source = new RectF(0, 0, defaultPerson.getWidth(), defaultPerson.getHeight());
+ final RectF dest = new RectF(0, 0, width, height);
+ matrix.setRectToRect(source, dest, Matrix.ScaleToFit.FILL);
+
+ canvas.drawBitmap(defaultPerson, matrix, paint);
+
+ return bitmap;
+ }
+
+ private Bitmap renderLetterTile(final String name, final int width, final int height) {
+ final float halfWidth = width / 2;
+ final float halfHeight = height / 2;
+ final int minOfWidthAndHeight = Math.min(width, height);
+ final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height,
+ getBackgroundColor());
+ final Resources resources = mContext.getResources();
+ final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ paint.setTypeface(Typeface.create("sans-serif-thin", Typeface.NORMAL));
+ paint.setColor(resources.getColor(R.color.letter_tile_font_color));
+ final float letterToTileRatio = resources.getFraction(R.dimen.letter_to_tile_ratio, 1, 1);
+ paint.setTextSize(letterToTileRatio * minOfWidthAndHeight);
+
+ final String firstCharString = name.substring(0, 1).toUpperCase();
+ final Rect textBound = new Rect();
+ paint.getTextBounds(firstCharString, 0, 1, textBound);
+
+ final Canvas canvas = new Canvas(bitmap);
+ final float xOffset = halfWidth - textBound.centerX();
+ final float yOffset = halfHeight - textBound.centerY();
+ canvas.drawText(firstCharString, xOffset, yOffset, paint);
+
+ return bitmap;
+ }
+
+ private int getBackgroundColor() {
+ return mContext.getResources().getColor(R.color.primary_color);
+ }
+
+ @Override
+ public int getCacheId() {
+ return BugleMediaCacheManager.AVATAR_IMAGE_CACHE;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java b/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java
new file mode 100644
index 0000000..9afa9ad
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java
@@ -0,0 +1,60 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Context;
+import android.net.Uri;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.UriUtil;
+
+public class AvatarRequestDescriptor extends UriImageRequestDescriptor {
+ final boolean isWearBackground;
+
+ public AvatarRequestDescriptor(final Uri uri, final int desiredWidth,
+ final int desiredHeight) {
+ this(uri, desiredWidth, desiredHeight, true /* cropToCircle */);
+ }
+
+ public AvatarRequestDescriptor(final Uri uri, final int desiredWidth,
+ final int desiredHeight, final boolean cropToCircle) {
+ this(uri, desiredWidth, desiredHeight, cropToCircle, false /* isWearBackground */);
+ }
+
+ public AvatarRequestDescriptor(final Uri uri, final int desiredWidth,
+ final int desiredHeight, boolean cropToCircle, boolean isWearBackground) {
+ super(uri, desiredWidth, desiredHeight, false /* allowCompression */, true /* isStatic */,
+ cropToCircle,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ Assert.isTrue(uri == null || UriUtil.isLocalResourceUri(uri) ||
+ AvatarUriUtil.isAvatarUri(uri));
+ this.isWearBackground = isWearBackground;
+ }
+
+ @Override
+ public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) {
+ final String avatarType = uri == null ? null : AvatarUriUtil.getAvatarType(uri);
+ if (AvatarUriUtil.TYPE_SIM_SELECTOR_URI.equals(avatarType)) {
+ return new SimSelectorAvatarRequest(context, this);
+ } else {
+ return new AvatarRequest(context, this);
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/BindableMediaRequest.java b/src/com/android/messaging/datamodel/media/BindableMediaRequest.java
new file mode 100644
index 0000000..36521d5
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/BindableMediaRequest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.datamodel.media;
+
+import com.android.messaging.datamodel.binding.BindableOnceData;
+import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener;
+
+/**
+ * The {@link MediaRequest} interface is threading-model-blind, allowing the implementations to
+ * be processed synchronously or asynchronously.
+ * This is a {@link MediaRequest} implementation that includes functionalities such as binding and
+ * event callbacks for multi-threaded media request processing.
+ */
+public abstract class BindableMediaRequest<T extends RefCountedMediaResource>
+ extends BindableOnceData
+ implements MediaRequest<T>, MediaResourceLoadListener<T> {
+ private MediaResourceLoadListener<T> mListener;
+
+ public BindableMediaRequest(final MediaResourceLoadListener<T> listener) {
+ mListener = listener;
+ }
+
+ /**
+ * Delegates the media resource callback to the listener. Performs binding check to ensure
+ * the listener is still bound to this request.
+ */
+ @Override
+ public void onMediaResourceLoaded(final MediaRequest<T> request, final T resource,
+ final boolean cached) {
+ if (isBound() && mListener != null) {
+ mListener.onMediaResourceLoaded(request, resource, cached);
+ }
+ }
+
+ /**
+ * Delegates the media resource callback to the listener. Performs binding check to ensure
+ * the listener is still bound to this request.
+ */
+ @Override
+ public void onMediaResourceLoadError(final MediaRequest<T> request, final Exception exception) {
+ if (isBound() && mListener != null) {
+ mListener.onMediaResourceLoadError(request, exception);
+ }
+ }
+
+ @Override
+ protected void unregisterListeners() {
+ mListener = null;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java b/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java
new file mode 100644
index 0000000..c41ba60
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java
@@ -0,0 +1,54 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import com.android.messaging.util.Assert;
+
+/**
+ * An implementation of {@link MediaCacheManager} that creates caches specific to Bugle's needs.
+ *
+ * To create a new type of cache, add to the list of cache ids and create a new MediaCache<>
+ * for your cache id / media resource type in createMediaCacheById().
+ */
+public class BugleMediaCacheManager extends MediaCacheManager {
+ // List of available cache ids.
+ public static final int DEFAULT_IMAGE_CACHE = 1;
+ public static final int AVATAR_IMAGE_CACHE = 2;
+ public static final int VCARD_CACHE = 3;
+
+ // VCard cache size - we compute the size by count, not by bytes.
+ private static final int VCARD_CACHE_SIZE = 5;
+ private static final int SHARED_IMAGE_CACHE_SIZE = 1024 * 10; // 10MB
+
+ @Override
+ protected MediaCache<?> createMediaCacheById(final int id) {
+ switch (id) {
+ case DEFAULT_IMAGE_CACHE:
+ return new PoolableImageCache(SHARED_IMAGE_CACHE_SIZE, id, "DefaultImageCache");
+
+ case AVATAR_IMAGE_CACHE:
+ return new PoolableImageCache(id, "AvatarImageCache");
+
+ case VCARD_CACHE:
+ return new MediaCache<VCardResource>(VCARD_CACHE_SIZE, id, "VCardCache");
+
+ default:
+ Assert.fail("BugleMediaCacheManager: unsupported cache id " + id);
+ break;
+ }
+ return null;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/CompositeImageRequest.java b/src/com/android/messaging/datamodel/media/CompositeImageRequest.java
new file mode 100644
index 0000000..66f1bff
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/CompositeImageRequest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.media.ExifInterface;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ImageUtils;
+
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.util.List;
+
+/**
+ * Requests a composite image resource. The composite image resource is constructed by first
+ * sequentially requesting a number of sub image resources specified by
+ * {@link CompositeImageRequestDescriptor#getChildRequestDescriptors()}. After this, the
+ * individual sub images are composed into the final image onto their respective target rects
+ * returned by {@link CompositeImageRequestDescriptor#getChildRequestTargetRects()}.
+ */
+public class CompositeImageRequest<D extends CompositeImageRequestDescriptor>
+ extends ImageRequest<D> {
+ private final Bitmap mBitmap;
+ private final Canvas mCanvas;
+ private final Paint mPaint;
+
+ public CompositeImageRequest(final Context context, final D descriptor) {
+ super(context, descriptor);
+ mBitmap = getBitmapPool().createOrReuseBitmap(
+ mDescriptor.desiredWidth, mDescriptor.desiredHeight);
+ mCanvas = new Canvas(mBitmap);
+ mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ }
+
+ @Override
+ protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTask) {
+ final List<? extends ImageRequestDescriptor> descriptors =
+ mDescriptor.getChildRequestDescriptors();
+ final List<RectF> targetRects = mDescriptor.getChildRequestTargetRects();
+ Assert.equals(descriptors.size(), targetRects.size());
+ Assert.isTrue(descriptors.size() > 1);
+
+ for (int i = 0; i < descriptors.size(); i++) {
+ final MediaRequest<ImageResource> request =
+ descriptors.get(i).buildSyncMediaRequest(mContext);
+ // Synchronously request the child image.
+ final ImageResource resource =
+ MediaResourceManager.get().requestMediaResourceSync(request);
+ if (resource != null) {
+ try {
+ final RectF avatarDestOnGroup = targetRects.get(i);
+
+ // Draw the bitmap into a smaller size with a circle mask.
+ final Bitmap resourceBitmap = resource.getBitmap();
+ final RectF resourceRect = new RectF(
+ 0, 0, resourceBitmap.getWidth(), resourceBitmap.getHeight());
+ final Bitmap smallCircleBitmap = getBitmapPool().createOrReuseBitmap(
+ Math.round(avatarDestOnGroup.width()),
+ Math.round(avatarDestOnGroup.height()));
+ final RectF smallCircleRect = new RectF(
+ 0, 0, smallCircleBitmap.getWidth(), smallCircleBitmap.getHeight());
+ final Canvas smallCircleCanvas = new Canvas(smallCircleBitmap);
+ ImageUtils.drawBitmapWithCircleOnCanvas(resource.getBitmap(), smallCircleCanvas,
+ resourceRect, smallCircleRect, null /* bitmapPaint */,
+ false /* fillBackground */,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ final Matrix matrix = new Matrix();
+ matrix.setRectToRect(smallCircleRect, avatarDestOnGroup,
+ Matrix.ScaleToFit.FILL);
+ mCanvas.drawBitmap(smallCircleBitmap, matrix, mPaint);
+ } finally {
+ resource.release();
+ }
+ }
+ }
+
+ return new DecodedImageResource(getKey(), mBitmap, ExifInterface.ORIENTATION_NORMAL);
+ }
+
+ @Override
+ public int getCacheId() {
+ return BugleMediaCacheManager.AVATAR_IMAGE_CACHE;
+ }
+
+ @Override
+ protected InputStream getInputStreamForResource() throws FileNotFoundException {
+ throw new IllegalStateException("Composite image request doesn't support input stream!");
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java
new file mode 100644
index 0000000..071130e
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.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.messaging.datamodel.media;
+
+import android.content.Context;
+import android.graphics.RectF;
+
+import com.google.common.base.Joiner;
+
+import java.util.List;
+
+public abstract class CompositeImageRequestDescriptor extends ImageRequestDescriptor {
+ protected final List<? extends ImageRequestDescriptor> mDescriptors;
+ private final String mKey;
+
+ public CompositeImageRequestDescriptor(final List<? extends ImageRequestDescriptor> descriptors,
+ final int desiredWidth, final int desiredHeight) {
+ super(desiredWidth, desiredHeight);
+ mDescriptors = descriptors;
+
+ final String[] keyParts = new String[descriptors.size()];
+ for (int i = 0; i < descriptors.size(); i++) {
+ keyParts[i] = descriptors.get(i).getKey();
+ }
+ mKey = Joiner.on(",").skipNulls().join(keyParts);
+ }
+
+ /**
+ * Gets a key that uniquely identify all the underlying image resource to be loaded (e.g. Uri or
+ * file path).
+ */
+ @Override
+ public String getKey() {
+ return mKey;
+ }
+
+ public List<? extends ImageRequestDescriptor> getChildRequestDescriptors(){
+ return mDescriptors;
+ }
+
+ public abstract List<RectF> getChildRequestTargetRects();
+ public abstract CompositeImageRequest<?> buildBatchImageRequest(final Context context);
+
+ @Override
+ public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) {
+ return buildBatchImageRequest(context);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/CustomVCardEntry.java b/src/com/android/messaging/datamodel/media/CustomVCardEntry.java
new file mode 100644
index 0000000..aee9fdc
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/CustomVCardEntry.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.messaging.datamodel.media;
+
+import android.accounts.Account;
+import android.support.v4.util.ArrayMap;
+
+import com.android.vcard.VCardEntry;
+import com.android.vcard.VCardProperty;
+
+import java.util.Map;
+
+/**
+ * Class which extends VCardEntry to add support for unknown properties. Currently there is a TODO
+ * to add this in the VCardEntry code, but we have to extend it to add the needed support
+ */
+public class CustomVCardEntry extends VCardEntry {
+ // List of properties keyed by their name for easy lookup
+ private final Map<String, VCardProperty> mAllProperties;
+
+ public CustomVCardEntry(int vCardType, Account account) {
+ super(vCardType, account);
+ mAllProperties = new ArrayMap<String, VCardProperty>();
+ }
+
+ @Override
+ public void addProperty(VCardProperty property) {
+ super.addProperty(property);
+ mAllProperties.put(property.getName(), property);
+ }
+
+ public VCardProperty getProperty(String name) {
+ return mAllProperties.get(name);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java b/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java
new file mode 100644
index 0000000..06b10a3
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java
@@ -0,0 +1,133 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.accounts.Account;
+import com.android.vcard.VCardConfig;
+import com.android.vcard.VCardInterpreter;
+import com.android.vcard.VCardProperty;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CustomVCardEntryConstructor implements VCardInterpreter {
+
+ public interface EntryHandler {
+ /**
+ * Called when the parsing started.
+ */
+ public void onStart();
+
+ /**
+ * The method called when one vCard entry is created. Children come before their parent in
+ * nested vCard files.
+ *
+ * e.g.
+ * In the following vCard, the entry for "entry2" comes before one for "entry1".
+ * <code>
+ * BEGIN:VCARD
+ * N:entry1
+ * BEGIN:VCARD
+ * N:entry2
+ * END:VCARD
+ * END:VCARD
+ * </code>
+ */
+ public void onEntryCreated(final CustomVCardEntry entry);
+
+ /**
+ * Called when the parsing ended.
+ * Able to be use this method for showing performance log, etc.
+ */
+ public void onEnd();
+ }
+
+ /**
+ * Represents current stack of VCardEntry. Used to support nested vCard (vCard 2.1).
+ */
+ private final List<CustomVCardEntry> mEntryStack = new ArrayList<CustomVCardEntry>();
+ private CustomVCardEntry mCurrentEntry;
+
+ private final int mVCardType;
+ private final Account mAccount;
+
+ private final List<EntryHandler> mEntryHandlers = new ArrayList<EntryHandler>();
+
+ public CustomVCardEntryConstructor() {
+ this(VCardConfig.VCARD_TYPE_V21_GENERIC, null);
+ }
+
+ public CustomVCardEntryConstructor(final int vcardType) {
+ this(vcardType, null);
+ }
+
+ public CustomVCardEntryConstructor(final int vcardType, final Account account) {
+ mVCardType = vcardType;
+ mAccount = account;
+ }
+
+ public void addEntryHandler(EntryHandler entryHandler) {
+ mEntryHandlers.add(entryHandler);
+ }
+
+ @Override
+ public void onVCardStarted() {
+ for (EntryHandler entryHandler : mEntryHandlers) {
+ entryHandler.onStart();
+ }
+ }
+
+ @Override
+ public void onVCardEnded() {
+ for (EntryHandler entryHandler : mEntryHandlers) {
+ entryHandler.onEnd();
+ }
+ }
+
+ public void clear() {
+ mCurrentEntry = null;
+ mEntryStack.clear();
+ }
+
+ @Override
+ public void onEntryStarted() {
+ mCurrentEntry = new CustomVCardEntry(mVCardType, mAccount);
+ mEntryStack.add(mCurrentEntry);
+ }
+
+ @Override
+ public void onEntryEnded() {
+ mCurrentEntry.consolidateFields();
+ for (EntryHandler entryHandler : mEntryHandlers) {
+ entryHandler.onEntryCreated(mCurrentEntry);
+ }
+
+ final int size = mEntryStack.size();
+ if (size > 1) {
+ CustomVCardEntry parent = mEntryStack.get(size - 2);
+ parent.addChild(mCurrentEntry);
+ mCurrentEntry = parent;
+ } else {
+ mCurrentEntry = null;
+ }
+ mEntryStack.remove(size - 1);
+ }
+
+ @Override
+ public void onPropertyCreated(VCardProperty property) {
+ mCurrentEntry.addProperty(property);
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/datamodel/media/DecodedImageResource.java b/src/com/android/messaging/datamodel/media/DecodedImageResource.java
new file mode 100644
index 0000000..3627ba4
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/DecodedImageResource.java
@@ -0,0 +1,254 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+
+import com.android.messaging.ui.OrientedBitmapDrawable;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+
+import java.util.List;
+
+
+/**
+ * Container class for holding a bitmap resource used by the MediaResourceManager. This resource
+ * can both be cached (albeit not very storage-efficiently) and directly used by the UI.
+ */
+public class DecodedImageResource extends ImageResource {
+ private static final int BITMAP_QUALITY = 100;
+ private static final int COMPRESS_QUALITY = 50;
+
+ private Bitmap mBitmap;
+ private final int mOrientation;
+ private boolean mCacheable = true;
+
+ public DecodedImageResource(final String key, final Bitmap bitmap, int orientation) {
+ super(key, orientation);
+ mBitmap = bitmap;
+ mOrientation = orientation;
+ }
+
+ /**
+ * Gets the contained bitmap.
+ */
+ @Override
+ public Bitmap getBitmap() {
+ acquireLock();
+ try {
+ return mBitmap;
+ } finally {
+ releaseLock();
+ }
+ }
+
+ /**
+ * Attempt to reuse the bitmap in the image resource and repurpose it for something else.
+ * After this, the image resource will relinquish ownership on the bitmap resource so that
+ * it doesn't try to recycle it when getting closed.
+ */
+ @Override
+ public Bitmap reuseBitmap() {
+ acquireLock();
+ try {
+ assertSingularRefCount();
+ final Bitmap retBitmap = mBitmap;
+ mBitmap = null;
+ return retBitmap;
+ } finally {
+ releaseLock();
+ }
+ }
+
+ @Override
+ public boolean supportsBitmapReuse() {
+ return true;
+ }
+
+ @Override
+ public byte[] getBytes() {
+ acquireLock();
+ try {
+ return ImageUtils.bitmapToBytes(mBitmap, BITMAP_QUALITY);
+ } catch (final Exception e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Error trying to get the bitmap bytes " + e);
+ } finally {
+ releaseLock();
+ }
+ return null;
+ }
+
+ /**
+ * Gets the orientation of the image as one of the ExifInterface.ORIENTATION_* constants
+ */
+ @Override
+ public int getOrientation() {
+ return mOrientation;
+ }
+
+ @Override
+ public int getMediaSize() {
+ acquireLock();
+ try {
+ Assert.notNull(mBitmap);
+ if (OsUtil.isAtLeastKLP()) {
+ return mBitmap.getAllocationByteCount();
+ } else {
+ return mBitmap.getRowBytes() * mBitmap.getHeight();
+ }
+ } finally {
+ releaseLock();
+ }
+ }
+
+ @Override
+ protected void close() {
+ acquireLock();
+ try {
+ if (mBitmap != null) {
+ mBitmap.recycle();
+ mBitmap = null;
+ }
+ } finally {
+ releaseLock();
+ }
+ }
+
+ @Override
+ public Drawable getDrawable(Resources resources) {
+ acquireLock();
+ try {
+ Assert.notNull(mBitmap);
+ return OrientedBitmapDrawable.create(getOrientation(), resources, mBitmap);
+ } finally {
+ releaseLock();
+ }
+ }
+
+ @Override
+ boolean isCacheable() {
+ return mCacheable;
+ }
+
+ public void setCacheable(final boolean cacheable) {
+ mCacheable = cacheable;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ MediaRequest<? extends RefCountedMediaResource> getMediaEncodingRequest(
+ final MediaRequest<? extends RefCountedMediaResource> originalRequest) {
+ Assert.isFalse(isEncoded());
+ if (getBitmap().hasAlpha()) {
+ // We can't compress images with alpha, as JPEG encoding doesn't support this.
+ return null;
+ }
+ return new EncodeImageRequest((MediaRequest<ImageResource>) originalRequest);
+ }
+
+ /**
+ * A MediaRequest that encodes the contained image resource.
+ */
+ private class EncodeImageRequest implements MediaRequest<ImageResource> {
+ private final MediaRequest<ImageResource> mOriginalImageRequest;
+
+ public EncodeImageRequest(MediaRequest<ImageResource> originalImageRequest) {
+ mOriginalImageRequest = originalImageRequest;
+ // Hold a ref onto the encoded resource before the request finishes.
+ DecodedImageResource.this.addRef();
+ }
+
+ @Override
+ public String getKey() {
+ return DecodedImageResource.this.getKey();
+ }
+
+ @Override
+ @DoesNotRunOnMainThread
+ public ImageResource loadMediaBlocking(List<MediaRequest<ImageResource>> chainedRequests)
+ throws Exception {
+ Assert.isNotMainThread();
+ acquireLock();
+ Bitmap scaledBitmap = null;
+ try {
+ Bitmap bitmap = getBitmap();
+ Assert.isFalse(bitmap.hasAlpha());
+ final int bitmapWidth = bitmap.getWidth();
+ final int bitmapHeight = bitmap.getHeight();
+ // The original bitmap was loaded using sub-sampling which was fast in terms of
+ // loading speed, but not optimized for caching, encoding and rendering (since
+ // bitmap resizing to fit the UI image views happens on the UI thread and should
+ // be avoided if possible). Therefore, try to resize the bitmap to the exact desired
+ // size before compressing it.
+ if (bitmapWidth > 0 && bitmapHeight > 0 &&
+ mOriginalImageRequest instanceof ImageRequest<?>) {
+ final ImageRequestDescriptor descriptor =
+ ((ImageRequest<?>) mOriginalImageRequest).getDescriptor();
+ final float targetScale = Math.max(
+ (float) descriptor.desiredWidth / bitmapWidth,
+ (float) descriptor.desiredHeight / bitmapHeight);
+ final int targetWidth = (int) (bitmapWidth * targetScale);
+ final int targetHeight = (int) (bitmapHeight * targetScale);
+ // Only try to scale down the image to the desired size.
+ if (targetScale < 1.0f && targetWidth > 0 && targetHeight > 0 &&
+ targetWidth != bitmapWidth && targetHeight != bitmapHeight) {
+ scaledBitmap = bitmap =
+ Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, false);
+ }
+ }
+ byte[] encodedBytes = ImageUtils.bitmapToBytes(bitmap, COMPRESS_QUALITY);
+ return new EncodedImageResource(getKey(), encodedBytes, getOrientation());
+ } catch (Exception ex) {
+ // Something went wrong during bitmap compression, fall back to just using the
+ // original bitmap.
+ LogUtil.e(LogUtil.BUGLE_IMAGE_TAG, "Error compressing bitmap", ex);
+ return DecodedImageResource.this;
+ } finally {
+ if (scaledBitmap != null && scaledBitmap != getBitmap()) {
+ scaledBitmap.recycle();
+ scaledBitmap = null;
+ }
+ releaseLock();
+ release();
+ }
+ }
+
+ @Override
+ public MediaCache<ImageResource> getMediaCache() {
+ return mOriginalImageRequest.getMediaCache();
+ }
+
+ @Override
+ public int getCacheId() {
+ return mOriginalImageRequest.getCacheId();
+ }
+
+ @Override
+ public int getRequestType() {
+ return REQUEST_ENCODE_MEDIA;
+ }
+
+ @Override
+ public MediaRequestDescriptor<ImageResource> getDescriptor() {
+ return mOriginalImageRequest.getDescriptor();
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/EncodedImageResource.java b/src/com/android/messaging/datamodel/media/EncodedImageResource.java
new file mode 100644
index 0000000..0bc94e5
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/EncodedImageResource.java
@@ -0,0 +1,162 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.drawable.Drawable;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * A cache-facing image resource that's much more compact than the raw Bitmap objects stored in
+ * {@link com.android.messaging.datamodel.media.DecodedImageResource}.
+ *
+ * This resource is created from a regular Bitmap-based ImageResource before being pushed to
+ * {@link com.android.messaging.datamodel.media.MediaCache}, if the image request
+ * allows for resource encoding/compression.
+ *
+ * During resource retrieval on cache hit,
+ * {@link #getMediaDecodingRequest(MediaRequest)} is invoked to create a async
+ * decode task, which decodes the compressed byte array back to a regular image resource to
+ * be consumed by the UI.
+ */
+public class EncodedImageResource extends ImageResource {
+ private final byte[] mImageBytes;
+
+ public EncodedImageResource(String key, byte[] imageBytes, int orientation) {
+ super(key, orientation);
+ mImageBytes = imageBytes;
+ }
+
+ @Override
+ @DoesNotRunOnMainThread
+ public Bitmap getBitmap() {
+ acquireLock();
+ try {
+ // This should only be called during the decode request.
+ Assert.isNotMainThread();
+ return BitmapFactory.decodeByteArray(mImageBytes, 0, mImageBytes.length);
+ } finally {
+ releaseLock();
+ }
+ }
+
+ @Override
+ public byte[] getBytes() {
+ acquireLock();
+ try {
+ return Arrays.copyOf(mImageBytes, mImageBytes.length);
+ } finally {
+ releaseLock();
+ }
+ }
+
+ @Override
+ public Bitmap reuseBitmap() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsBitmapReuse() {
+ return false;
+ }
+
+ @Override
+ public int getMediaSize() {
+ return mImageBytes.length;
+ }
+
+ @Override
+ protected void close() {
+ }
+
+ @Override
+ public Drawable getDrawable(Resources resources) {
+ return null;
+ }
+
+ @Override
+ boolean isEncoded() {
+ return true;
+ }
+
+ @Override
+ MediaRequest<? extends RefCountedMediaResource> getMediaDecodingRequest(
+ final MediaRequest<? extends RefCountedMediaResource> originalRequest) {
+ Assert.isTrue(isEncoded());
+ return new DecodeImageRequest();
+ }
+
+ /**
+ * A MediaRequest that decodes the encoded image resource. This class is chained to the
+ * original media request that requested the image, so it inherits the listener and
+ * properties such as binding.
+ */
+ private class DecodeImageRequest implements MediaRequest<ImageResource> {
+ public DecodeImageRequest() {
+ // Hold a ref onto the encoded resource before the request finishes.
+ addRef();
+ }
+
+ @Override
+ public String getKey() {
+ return EncodedImageResource.this.getKey();
+ }
+
+ @Override
+ @DoesNotRunOnMainThread
+ public ImageResource loadMediaBlocking(List<MediaRequest<ImageResource>> chainedTask)
+ throws Exception {
+ Assert.isNotMainThread();
+ acquireLock();
+ try {
+ final Bitmap decodedBitmap = BitmapFactory.decodeByteArray(mImageBytes, 0,
+ mImageBytes.length);
+ return new DecodedImageResource(getKey(), decodedBitmap, getOrientation());
+ } finally {
+ releaseLock();
+ release();
+ }
+ }
+
+ @Override
+ public MediaCache<ImageResource> getMediaCache() {
+ // Decoded resource is non-cachable, it's for UI consumption only (for now at least)
+ return null;
+ }
+
+ @Override
+ public int getCacheId() {
+ return 0;
+ }
+
+ @Override
+ public int getRequestType() {
+ return REQUEST_DECODE_MEDIA;
+ }
+
+ @Override
+ public MediaRequestDescriptor<ImageResource> getDescriptor() {
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/FileImageRequest.java b/src/com/android/messaging/datamodel/media/FileImageRequest.java
new file mode 100644
index 0000000..31c053a
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/FileImageRequest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.media.ExifInterface;
+
+import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.LogUtil;
+
+import java.io.IOException;
+
+/**
+ * Serves file system based image requests. Since file paths can be expressed in Uri form, this
+ * extends regular UriImageRequest but performs additional optimizations such as loading thumbnails
+ * directly from Exif information.
+ */
+public class FileImageRequest extends UriImageRequest {
+ private final String mPath;
+ private final boolean mCanUseThumbnail;
+
+ public FileImageRequest(final Context context,
+ final FileImageRequestDescriptor descriptor) {
+ super(context, descriptor);
+ mPath = descriptor.path;
+ mCanUseThumbnail = descriptor.canUseThumbnail;
+ }
+
+ @Override
+ protected Bitmap loadBitmapInternal()
+ throws IOException {
+ // Before using the FileInputStream, check if the Exif has a thumbnail that we can use.
+ if (mCanUseThumbnail) {
+ byte[] thumbnail = null;
+ try {
+ final ExifInterface exif = new ExifInterface(mPath);
+ if (exif.hasThumbnail()) {
+ thumbnail = exif.getThumbnail();
+ }
+ } catch (final IOException e) {
+ // Nothing to do
+ }
+
+ if (thumbnail != null) {
+ final BitmapFactory.Options options = PoolableImageCache.getBitmapOptionsForPool(
+ false /* scaled */, 0 /* inputDensity */, 0 /* targetDensity */);
+ // First, check dimensions of the bitmap.
+ options.inJustDecodeBounds = true;
+ BitmapFactory.decodeByteArray(thumbnail, 0, thumbnail.length, options);
+
+ // Calculate inSampleSize
+ options.inSampleSize = ImageUtils.get().calculateInSampleSize(options,
+ mDescriptor.desiredWidth, mDescriptor.desiredHeight);
+
+ options.inJustDecodeBounds = false;
+
+ // Actually decode the bitmap, optionally using the bitmap pool.
+ try {
+ // Get the orientation. We should be able to get the orientation from
+ // the thumbnail itself but at least on some phones, the thumbnail
+ // doesn't have an orientation tag. So use the outer image's orientation
+ // tag and hope for the best.
+ mOrientation = ImageUtils.getOrientation(getInputStreamForResource());
+ if (com.android.messaging.util.exif.ExifInterface.
+ getOrientationParams(mOrientation).invertDimensions) {
+ mDescriptor.updateSourceDimensions(options.outHeight, options.outWidth);
+ } else {
+ mDescriptor.updateSourceDimensions(options.outWidth, options.outHeight);
+ }
+ final ReusableImageResourcePool bitmapPool = getBitmapPool();
+ if (bitmapPool == null) {
+ return BitmapFactory.decodeByteArray(thumbnail, 0, thumbnail.length,
+ options);
+ } else {
+ final int sampledWidth = options.outWidth / options.inSampleSize;
+ final int sampledHeight = options.outHeight / options.inSampleSize;
+ return bitmapPool.decodeByteArray(thumbnail, options, sampledWidth,
+ sampledHeight);
+ }
+ } catch (IOException ex) {
+ // If the thumbnail is broken due to IOException, this will
+ // fall back to default bitmap loading.
+ LogUtil.e(LogUtil.BUGLE_IMAGE_TAG, "FileImageRequest: failed to load " +
+ "thumbnail from Exif", ex);
+ }
+ }
+ }
+
+ // Fall back to default InputStream-based loading if no thumbnails could be retrieved.
+ return super.loadBitmapInternal();
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java
new file mode 100644
index 0000000..00105f5
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java
@@ -0,0 +1,68 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Context;
+
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.UriUtil;
+
+/**
+ * Holds image request info about file system based image resource.
+ */
+public class FileImageRequestDescriptor extends UriImageRequestDescriptor {
+ public final String path;
+
+ // Can we use the thumbnail image from Exif data?
+ public final boolean canUseThumbnail;
+
+ /**
+ * Convenience constructor for when the image file's dimensions are not known.
+ */
+ public FileImageRequestDescriptor(final String path, final int desiredWidth,
+ final int desiredHeight, final boolean canUseThumbnail, final boolean canCompress,
+ final boolean isStatic) {
+ this(path, desiredWidth, desiredHeight, FileImageRequest.UNSPECIFIED_SIZE,
+ FileImageRequest.UNSPECIFIED_SIZE, canUseThumbnail, canCompress, isStatic);
+ }
+
+ /**
+ * Creates a new file image request with this descriptor. Oftentimes image file metadata
+ * has information such as the size of the image. Provide these metrics if they are known.
+ */
+ public FileImageRequestDescriptor(final String path, final int desiredWidth,
+ final int desiredHeight, final int sourceWidth, final int sourceHeight,
+ final boolean canUseThumbnail, final boolean canCompress, final boolean isStatic) {
+ super(UriUtil.getUriForResourceFile(path), desiredWidth, desiredHeight, sourceWidth,
+ sourceHeight, canCompress, isStatic, false /* cropToCircle */,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ this.path = path;
+ this.canUseThumbnail = canUseThumbnail;
+ }
+
+ @Override
+ public String getKey() {
+ final String prefixKey = super.getKey();
+ return prefixKey == null ? null : new StringBuilder(prefixKey).append(KEY_PART_DELIMITER)
+ .append(canUseThumbnail).toString();
+ }
+
+ @Override
+ public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) {
+ return new FileImageRequest(context, this);
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/datamodel/media/GifImageResource.java b/src/com/android/messaging/datamodel/media/GifImageResource.java
new file mode 100644
index 0000000..d50cf47
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/GifImageResource.java
@@ -0,0 +1,110 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.media.ExifInterface;
+import android.support.rastermill.FrameSequence;
+import android.support.rastermill.FrameSequenceDrawable;
+
+import com.android.messaging.util.Assert;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+public class GifImageResource extends ImageResource {
+ private FrameSequence mFrameSequence;
+
+ public GifImageResource(String key, FrameSequence frameSequence) {
+ // GIF does not support exif tags
+ super(key, ExifInterface.ORIENTATION_NORMAL);
+ mFrameSequence = frameSequence;
+ }
+
+ public static GifImageResource createGifImageResource(String key, InputStream inputStream) {
+ final FrameSequence frameSequence;
+ try {
+ frameSequence = FrameSequence.decodeStream(inputStream);
+ } finally {
+ try {
+ inputStream.close();
+ } catch (IOException e) {
+ // Nothing to do if we fail closing the stream
+ }
+ }
+ if (frameSequence == null) {
+ return null;
+ }
+ return new GifImageResource(key, frameSequence);
+ }
+
+ @Override
+ public Drawable getDrawable(Resources resources) {
+ return new FrameSequenceDrawable(mFrameSequence);
+ }
+
+ @Override
+ public Bitmap getBitmap() {
+ Assert.fail("GetBitmap() should never be called on a gif.");
+ return null;
+ }
+
+ @Override
+ public byte[] getBytes() {
+ Assert.fail("GetBytes() should never be called on a gif.");
+ return null;
+ }
+
+ @Override
+ public Bitmap reuseBitmap() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsBitmapReuse() {
+ // FrameSequenceDrawable a.) takes two bitmaps and thus does not fit into the current
+ // bitmap pool architecture b.) will rarely use bitmaps from one FrameSequenceDrawable to
+ // the next that are the same sizes since they are used by attachments.
+ return false;
+ }
+
+ @Override
+ public int getMediaSize() {
+ Assert.fail("GifImageResource should not be used by a media cache");
+ // Only used by the media cache, which this does not use.
+ return 0;
+ }
+
+ @Override
+ public boolean isCacheable() {
+ return false;
+ }
+
+ @Override
+ protected void close() {
+ acquireLock();
+ try {
+ if (mFrameSequence != null) {
+ mFrameSequence = null;
+ }
+ } finally {
+ releaseLock();
+ }
+ }
+
+}
diff --git a/src/com/android/messaging/datamodel/media/ImageRequest.java b/src/com/android/messaging/datamodel/media/ImageRequest.java
new file mode 100644
index 0000000..ab8880d
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/ImageRequest.java
@@ -0,0 +1,258 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.RectF;
+
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.exif.ExifInterface;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+/**
+ * Base class that serves an image request for resolving, retrieving and decoding bitmap resources.
+ *
+ * Subclasses may choose to load images from different medium, such as from the file system or
+ * from the local content resolver, by overriding the abstract getInputStreamForResource() method.
+ */
+public abstract class ImageRequest<D extends ImageRequestDescriptor>
+ implements MediaRequest<ImageResource> {
+ public static final int UNSPECIFIED_SIZE = MessagePartData.UNSPECIFIED_SIZE;
+
+ protected final Context mContext;
+ protected final D mDescriptor;
+ protected int mOrientation;
+
+ /**
+ * Creates a new image request with the given descriptor.
+ */
+ public ImageRequest(final Context context, final D descriptor) {
+ mContext = context;
+ mDescriptor = descriptor;
+ }
+
+ /**
+ * Gets a key that uniquely identify the underlying image resource to be loaded (e.g. Uri or
+ * file path).
+ */
+ @Override
+ public String getKey() {
+ return mDescriptor.getKey();
+ }
+
+ /**
+ * Returns the image request descriptor attached to this request.
+ */
+ @Override
+ public D getDescriptor() {
+ return mDescriptor;
+ }
+
+ @Override
+ public int getRequestType() {
+ return MediaRequest.REQUEST_LOAD_MEDIA;
+ }
+
+ /**
+ * Allows sub classes to specify that they want us to call getBitmapForResource rather than
+ * getInputStreamForResource
+ */
+ protected boolean hasBitmapObject() {
+ return false;
+ }
+
+ protected Bitmap getBitmapForResource() throws IOException {
+ return null;
+ }
+
+ /**
+ * Retrieves an input stream from which image resource could be loaded.
+ * @throws FileNotFoundException
+ */
+ protected abstract InputStream getInputStreamForResource() throws FileNotFoundException;
+
+ /**
+ * Loads the image resource. This method is final; to override the media loading behavior
+ * the subclass should override {@link #loadMediaInternal(List)}
+ */
+ @Override
+ public final ImageResource loadMediaBlocking(List<MediaRequest<ImageResource>> chainedTask)
+ throws IOException {
+ Assert.isNotMainThread();
+ final ImageResource loadedResource = loadMediaInternal(chainedTask);
+ return postProcessOnBitmapResourceLoaded(loadedResource);
+ }
+
+ protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTask)
+ throws IOException {
+ if (!mDescriptor.isStatic() && isGif()) {
+ final GifImageResource gifImageResource =
+ GifImageResource.createGifImageResource(getKey(), getInputStreamForResource());
+ if (gifImageResource == null) {
+ throw new RuntimeException("Error decoding gif");
+ }
+ return gifImageResource;
+ } else {
+ final Bitmap loadedBitmap = loadBitmapInternal();
+ if (loadedBitmap == null) {
+ throw new RuntimeException("failed decoding bitmap");
+ }
+ return new DecodedImageResource(getKey(), loadedBitmap, mOrientation);
+ }
+ }
+
+ protected boolean isGif() throws FileNotFoundException {
+ return ImageUtils.isGif(getInputStreamForResource());
+ }
+
+ /**
+ * The internal routine for loading the image. The caller may optionally provide the width
+ * and height of the source image if known so that we don't need to manually decode those.
+ */
+ protected Bitmap loadBitmapInternal() throws IOException {
+
+ final boolean unknownSize = mDescriptor.sourceWidth == UNSPECIFIED_SIZE ||
+ mDescriptor.sourceHeight == UNSPECIFIED_SIZE;
+
+ // If the ImageRequest has a Bitmap object rather than a stream, there's little to do here
+ if (hasBitmapObject()) {
+ final Bitmap bitmap = getBitmapForResource();
+ if (bitmap != null && unknownSize) {
+ mDescriptor.updateSourceDimensions(bitmap.getWidth(), bitmap.getHeight());
+ }
+ return bitmap;
+ }
+
+ mOrientation = ImageUtils.getOrientation(getInputStreamForResource());
+
+ final BitmapFactory.Options options = PoolableImageCache.getBitmapOptionsForPool(
+ false /* scaled */, 0 /* inputDensity */, 0 /* targetDensity */);
+ // First, check dimensions of the bitmap if not already known.
+ if (unknownSize) {
+ final InputStream inputStream = getInputStreamForResource();
+ if (inputStream != null) {
+ try {
+ options.inJustDecodeBounds = true;
+ BitmapFactory.decodeStream(inputStream, null, options);
+ // This is called when dimensions of image were unknown to allow db update
+ if (ExifInterface.getOrientationParams(mOrientation).invertDimensions) {
+ mDescriptor.updateSourceDimensions(options.outHeight, options.outWidth);
+ } else {
+ mDescriptor.updateSourceDimensions(options.outWidth, options.outHeight);
+ }
+ } finally {
+ inputStream.close();
+ }
+ } else {
+ throw new FileNotFoundException();
+ }
+ } else {
+ options.outWidth = mDescriptor.sourceWidth;
+ options.outHeight = mDescriptor.sourceHeight;
+ }
+
+ // Calculate inSampleSize
+ options.inSampleSize = ImageUtils.get().calculateInSampleSize(options,
+ mDescriptor.desiredWidth, mDescriptor.desiredHeight);
+ Assert.isTrue(options.inSampleSize > 0);
+
+ // Reopen the input stream and actually decode the bitmap. The initial
+ // BitmapFactory.decodeStream() reads the header portion of the bitmap stream and leave
+ // the input stream at the last read position. Since this input stream doesn't support
+ // mark() and reset(), the only viable way to reload the input stream is to re-open it.
+ // Alternatively, we could decode the bitmap into a byte array first and act on the byte
+ // array, but that also means the entire bitmap (for example a 10MB image from the gallery)
+ // without downsampling will have to be loaded into memory up front, which we don't want
+ // as it gives a much bigger possibility of OOM when handling big images. Therefore, the
+ // solution here is to close and reopen the bitmap input stream.
+ // For inline images the size is cached in DB and this hit is only taken once per image
+ final InputStream inputStream = getInputStreamForResource();
+ if (inputStream != null) {
+ try {
+ options.inJustDecodeBounds = false;
+
+ // Actually decode the bitmap, optionally using the bitmap pool.
+ final ReusableImageResourcePool bitmapPool = getBitmapPool();
+ if (bitmapPool == null) {
+ return BitmapFactory.decodeStream(inputStream, null, options);
+ } else {
+ final int sampledWidth = (options.outWidth + options.inSampleSize - 1) /
+ options.inSampleSize;
+ final int sampledHeight = (options.outHeight + options.inSampleSize - 1) /
+ options.inSampleSize;
+ return bitmapPool.decodeSampledBitmapFromInputStream(
+ inputStream, options, sampledWidth, sampledHeight);
+ }
+ } finally {
+ inputStream.close();
+ }
+ } else {
+ throw new FileNotFoundException();
+ }
+ }
+
+ private ImageResource postProcessOnBitmapResourceLoaded(final ImageResource loadedResource) {
+ if (mDescriptor.cropToCircle && loadedResource instanceof DecodedImageResource) {
+ final int width = mDescriptor.desiredWidth;
+ final int height = mDescriptor.desiredHeight;
+ final Bitmap sourceBitmap = loadedResource.getBitmap();
+ final Bitmap targetBitmap = getBitmapPool().createOrReuseBitmap(width, height);
+ final RectF dest = new RectF(0, 0, width, height);
+ final RectF source = new RectF(0, 0, sourceBitmap.getWidth(), sourceBitmap.getHeight());
+ final int backgroundColor = mDescriptor.circleBackgroundColor;
+ final int strokeColor = mDescriptor.circleStrokeColor;
+ ImageUtils.drawBitmapWithCircleOnCanvas(sourceBitmap, new Canvas(targetBitmap), source,
+ dest, null, backgroundColor == 0 ? false : true /* fillBackground */,
+ backgroundColor, strokeColor);
+ return new DecodedImageResource(getKey(), targetBitmap,
+ loadedResource.getOrientation());
+ }
+ return loadedResource;
+ }
+
+ /**
+ * Returns the bitmap pool for this image request.
+ */
+ protected ReusableImageResourcePool getBitmapPool() {
+ return MediaCacheManager.get().getOrCreateBitmapPoolForCache(getCacheId());
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public MediaCache<ImageResource> getMediaCache() {
+ return (MediaCache<ImageResource>) MediaCacheManager.get().getOrCreateMediaCacheById(
+ getCacheId());
+ }
+
+ /**
+ * Returns the cache id. Subclasses may override this to use a different cache.
+ */
+ @Override
+ public int getCacheId() {
+ return BugleMediaCacheManager.DEFAULT_IMAGE_CACHE;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java
new file mode 100644
index 0000000..20cb9af
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.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.messaging.datamodel.media;
+
+import android.content.Context;
+
+import com.android.messaging.util.Assert;
+
+/**
+ * The base ImageRequest descriptor that describes the requirement of the requested image
+ * resource, including the desired size. It holds request info that will be consumed by
+ * ImageRequest instances. Subclasses of ImageRequest are expected to take
+ * more descriptions such as content URI or file path.
+ */
+public abstract class ImageRequestDescriptor extends MediaRequestDescriptor<ImageResource> {
+ /** Desired size for the image (if known). This is used for bitmap downsampling */
+ public final int desiredWidth;
+ public final int desiredHeight;
+
+ /** Source size of the image (if known). This is used so that we don't have to manually decode
+ * the metrics from the image resource */
+ public final int sourceWidth;
+ public final int sourceHeight;
+
+ /**
+ * A static image resource is required, even if the image format supports animation (like Gif).
+ */
+ public final boolean isStatic;
+
+ /**
+ * The loaded image will be cropped to circular shape.
+ */
+ public final boolean cropToCircle;
+
+ /**
+ * The loaded image will be cropped to circular shape with the background color.
+ */
+ public final int circleBackgroundColor;
+
+ /**
+ * The loaded image will be cropped to circular shape with a stroke color.
+ */
+ public final int circleStrokeColor;
+
+ protected static final char KEY_PART_DELIMITER = '|';
+
+ /**
+ * Creates a new image request with unspecified width and height. In this case, the full
+ * bitmap is loaded and decoded, so unless you are sure that the image will be of
+ * reasonable size, you should consider limiting at least one of the two dimensions
+ * (for example, limiting the image width to the width of the ImageView container).
+ */
+ public ImageRequestDescriptor() {
+ this(ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE,
+ ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false, false, 0, 0);
+ }
+
+ public ImageRequestDescriptor(final int desiredWidth, final int desiredHeight) {
+ this(desiredWidth, desiredHeight,
+ ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false, false, 0, 0);
+ }
+
+ public ImageRequestDescriptor(final int desiredWidth,
+ final int desiredHeight, final int sourceWidth, final int sourceHeight,
+ final boolean isStatic, final boolean cropToCircle, final int circleBackgroundColor,
+ int circleStrokeColor) {
+ Assert.isTrue(desiredWidth == ImageRequest.UNSPECIFIED_SIZE || desiredWidth > 0);
+ Assert.isTrue(desiredHeight == ImageRequest.UNSPECIFIED_SIZE || desiredHeight > 0);
+ Assert.isTrue(sourceWidth == ImageRequest.UNSPECIFIED_SIZE || sourceWidth > 0);
+ Assert.isTrue(sourceHeight == ImageRequest.UNSPECIFIED_SIZE || sourceHeight > 0);
+ this.desiredWidth = desiredWidth;
+ this.desiredHeight = desiredHeight;
+ this.sourceWidth = sourceWidth;
+ this.sourceHeight = sourceHeight;
+ this.isStatic = isStatic;
+ this.cropToCircle = cropToCircle;
+ this.circleBackgroundColor = circleBackgroundColor;
+ this.circleStrokeColor = circleStrokeColor;
+ }
+
+ public String getKey() {
+ return new StringBuilder()
+ .append(desiredWidth).append(KEY_PART_DELIMITER)
+ .append(desiredHeight).append(KEY_PART_DELIMITER)
+ .append(String.valueOf(cropToCircle)).append(KEY_PART_DELIMITER)
+ .append(String.valueOf(circleBackgroundColor)).append(KEY_PART_DELIMITER)
+ .append(String.valueOf(isStatic)).toString();
+ }
+
+ public boolean isStatic() {
+ return isStatic;
+ }
+
+ @Override
+ public abstract MediaRequest<ImageResource> buildSyncMediaRequest(Context context);
+
+ // Called once source dimensions finally determined upon loading the image
+ public void updateSourceDimensions(final int sourceWidth, final int sourceHeight) {
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/datamodel/media/ImageResource.java b/src/com/android/messaging/datamodel/media/ImageResource.java
new file mode 100644
index 0000000..75d817d
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/ImageResource.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.datamodel.media;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+
+/**
+ * Base class for holding some form of image resource. The subclass gets to define the specific
+ * type of data format it's holding, whether it be Bitmap objects or compressed byte arrays.
+ */
+public abstract class ImageResource extends RefCountedMediaResource {
+ protected final int mOrientation;
+
+ public ImageResource(final String key, int orientation) {
+ super(key);
+ mOrientation = orientation;
+ }
+
+ /**
+ * Gets the contained image in drawable format.
+ */
+ public abstract Drawable getDrawable(Resources resources);
+
+ /**
+ * Gets the contained image in bitmap format.
+ */
+ public abstract Bitmap getBitmap();
+
+ /**
+ * Gets the contained image in byte array format.
+ */
+ public abstract byte[] getBytes();
+
+ /**
+ * Attempt to reuse the bitmap in the image resource and re-purpose it for something else.
+ * After this, the image resource will relinquish ownership on the bitmap resource so that
+ * it doesn't try to recycle it when getting closed.
+ */
+ public abstract Bitmap reuseBitmap();
+ public abstract boolean supportsBitmapReuse();
+
+ /**
+ * Gets the orientation of the image as one of the ExifInterface.ORIENTATION_* constants
+ */
+ public int getOrientation() {
+ return mOrientation;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/MediaBytes.java b/src/com/android/messaging/datamodel/media/MediaBytes.java
new file mode 100644
index 0000000..823bf27
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/MediaBytes.java
@@ -0,0 +1,42 @@
+/*
+ * 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.messaging.datamodel.media;
+
+
+/**
+ * Container class for handing around media information used by the MediaResourceManager.
+ */
+public class MediaBytes extends RefCountedMediaResource {
+ private final byte[] mBytes;
+
+ public MediaBytes(final String key, final byte[] bytes) {
+ super(key);
+ mBytes = bytes;
+ }
+
+ public byte[] getMediaBytes() {
+ return mBytes;
+ }
+
+ @Override
+ public int getMediaSize() {
+ return mBytes.length;
+ }
+
+ @Override
+ protected void close() {
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/MediaCache.java b/src/com/android/messaging/datamodel/media/MediaCache.java
new file mode 100644
index 0000000..510da2d
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/MediaCache.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.messaging.datamodel.media;
+
+import android.util.LruCache;
+
+import com.android.messaging.util.LogUtil;
+
+/**
+ * A modified LruCache that is able to hold RefCountedMediaResource instances. It releases
+ * ref on the entries as they are evicted from the cache, and it uses the media resource
+ * size in kilobytes, instead of the entry count, as the size of the cache.
+ *
+ * This class is used by the MediaResourceManager class to maintain a number of caches for
+ * holding different types of {@link RefCountedMediaResource}
+ */
+public class MediaCache<T extends RefCountedMediaResource> extends LruCache<String, T> {
+ private static final String TAG = LogUtil.BUGLE_IMAGE_TAG;
+
+ // Default memory cache size in kilobytes
+ protected static final int DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES = 1024 * 5; // 5MB
+
+ // Unique identifier for the cache.
+ private final int mId;
+ // Descriptive name given to the cache for debugging purposes.
+ private final String mName;
+
+ // Convenience constructor that uses the default cache size.
+ public MediaCache(final int id, final String name) {
+ this(DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES, id, name);
+ }
+
+ public MediaCache(final int maxSize, final int id, final String name) {
+ super(maxSize);
+ mId = id;
+ mName = name;
+ }
+
+ public void destroy() {
+ evictAll();
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public int getId() {
+ return mId;
+ }
+
+ /**
+ * Gets a media resource from this cache. Must use this method to get resource instead of get()
+ * to ensure addRef() on the resource.
+ */
+ public synchronized T fetchResourceFromCache(final String key) {
+ final T ret = get(key);
+ if (ret != null) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "cache hit in mediaCache @ " + getName() +
+ ", total cache hit = " + hitCount() +
+ ", total cache miss = " + missCount());
+ }
+ ret.addRef();
+ } else if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "cache miss in mediaCache @ " + getName() +
+ ", total cache hit = " + hitCount() +
+ ", total cache miss = " + missCount());
+ }
+ return ret;
+ }
+
+ /**
+ * Add a media resource to this cache. Must use this method to add resource instead of put()
+ * to ensure addRef() on the resource.
+ */
+ public synchronized T addResourceToCache(final String key, final T mediaResource) {
+ mediaResource.addRef();
+ return put(key, mediaResource);
+ }
+
+ /**
+ * Notify the removed entry that is no longer being cached
+ */
+ @Override
+ protected synchronized void entryRemoved(final boolean evicted, final String key,
+ final T oldValue, final T newValue) {
+ oldValue.release();
+ }
+
+ /**
+ * Measure item size in kilobytes rather than units which is more practical
+ * for a media resource cache
+ */
+ @Override
+ protected int sizeOf(final String key, final T value) {
+ final int mediaSizeInKilobytes = value.getMediaSize() / 1024;
+ // Never zero-count any resource, count as at least 1KB.
+ return mediaSizeInKilobytes == 0 ? 1 : mediaSizeInKilobytes;
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/datamodel/media/MediaCacheManager.java b/src/com/android/messaging/datamodel/media/MediaCacheManager.java
new file mode 100644
index 0000000..6e029f2
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/MediaCacheManager.java
@@ -0,0 +1,69 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.util.SparseArray;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.MemoryCacheManager;
+import com.android.messaging.datamodel.MemoryCacheManager.MemoryCache;
+import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool;
+
+/**
+ * Manages a set of media caches by id.
+ */
+public abstract class MediaCacheManager implements MemoryCache {
+ public static MediaCacheManager get() {
+ return Factory.get().getMediaCacheManager();
+ }
+
+ protected final SparseArray<MediaCache<?>> mCaches;
+
+ public MediaCacheManager() {
+ mCaches = new SparseArray<MediaCache<?>>();
+ MemoryCacheManager.get().registerMemoryCache(this);
+ }
+
+ @Override
+ public void reclaim() {
+ final int count = mCaches.size();
+ for (int i = 0; i < count; i++) {
+ mCaches.valueAt(i).destroy();
+ }
+ mCaches.clear();
+ }
+
+ public synchronized MediaCache<?> getOrCreateMediaCacheById(final int id) {
+ MediaCache<?> cache = mCaches.get(id);
+ if (cache == null) {
+ cache = createMediaCacheById(id);
+ if (cache != null) {
+ mCaches.put(id, cache);
+ }
+ }
+ return cache;
+ }
+
+ public ReusableImageResourcePool getOrCreateBitmapPoolForCache(final int cacheId) {
+ final MediaCache<?> cache = getOrCreateMediaCacheById(cacheId);
+ if (cache != null && cache instanceof PoolableImageCache) {
+ return ((PoolableImageCache) cache).asReusableBitmapPool();
+ }
+ return null;
+ }
+
+ protected abstract MediaCache<?> createMediaCacheById(final int id);
+} \ No newline at end of file
diff --git a/src/com/android/messaging/datamodel/media/MediaRequest.java b/src/com/android/messaging/datamodel/media/MediaRequest.java
new file mode 100644
index 0000000..703671b
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/MediaRequest.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.datamodel.media;
+
+import java.util.List;
+
+/**
+ * Keeps track of a media loading request. MediaResourceManager uses this interface to load, encode,
+ * decode, and cache different types of media resource.
+ *
+ * This interface defines a media request class that's threading-model-blind. Wrapper classes
+ * (such as {@link AsyncMediaRequestWrapper} wraps around any base media request to offer async
+ * extensions).
+ */
+public interface MediaRequest<T extends RefCountedMediaResource> {
+ public static final int REQUEST_ENCODE_MEDIA = 1;
+ public static final int REQUEST_DECODE_MEDIA = 2;
+ public static final int REQUEST_LOAD_MEDIA = 3;
+
+ /**
+ * Returns a unique key used for storing and looking up the MediaRequest.
+ */
+ String getKey();
+
+ /**
+ * This method performs the heavy-lifting work of synchronously loading the media bytes for
+ * this MediaRequest on a single threaded executor.
+ * @param chainedTask subsequent tasks to be performed after this request is complete. For
+ * example, an image request may need to compress the image resource before putting it in the
+ * cache
+ */
+ T loadMediaBlocking(List<MediaRequest<T>> chainedTask) throws Exception;
+
+ /**
+ * Returns the media cache where this MediaRequest wants to store the loaded
+ * media resource.
+ */
+ MediaCache<T> getMediaCache();
+
+ /**
+ * Returns the id of the cache where this MediaRequest wants to store the loaded
+ * media resource.
+ */
+ int getCacheId();
+
+ /**
+ * Returns the request type of this media request, i.e. one of {@link #REQUEST_ENCODE_MEDIA},
+ * {@link #REQUEST_DECODE_MEDIA}, or {@link #REQUEST_LOAD_MEDIA}. The default is
+ * {@link #REQUEST_LOAD_MEDIA}
+ */
+ int getRequestType();
+
+ /**
+ * Returns the descriptor defining the request.
+ */
+ MediaRequestDescriptor<T> getDescriptor();
+} \ No newline at end of file
diff --git a/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java
new file mode 100644
index 0000000..216b2a3
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.datamodel.media;
+
+import android.content.Context;
+
+import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener;
+
+/**
+ * The base data holder/builder class for constructing async/sync MediaRequest objects during
+ * runtime.
+ */
+public abstract class MediaRequestDescriptor<T extends RefCountedMediaResource> {
+ public abstract MediaRequest<T> buildSyncMediaRequest(Context context);
+
+ /**
+ * Builds an async media request to be used with
+ * {@link MediaResourceManager#requestMediaResourceAsync(MediaRequest)}
+ */
+ public BindableMediaRequest<T> buildAsyncMediaRequest(final Context context,
+ final MediaResourceLoadListener<T> listener) {
+ final MediaRequest<T> syncRequest = buildSyncMediaRequest(context);
+ return AsyncMediaRequestWrapper.createWith(syncRequest, listener);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/MediaResourceManager.java b/src/com/android/messaging/datamodel/media/MediaResourceManager.java
new file mode 100644
index 0000000..13f7291
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/MediaResourceManager.java
@@ -0,0 +1,325 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.os.AsyncTask;
+
+import com.android.messaging.Factory;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.RunsOnAnyThread;
+import com.android.messaging.util.LogUtil;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+
+/**
+ * <p>Loads and maintains a set of in-memory LRU caches for different types of media resources.
+ * Right now we don't utilize any disk cache as all media urls are expected to be resolved to
+ * local content.<p/>
+ *
+ * <p>The MediaResourceManager takes media loading requests through one of two ways:</p>
+ *
+ * <ol>
+ * <li>{@link #requestMediaResourceAsync(MediaRequest)} that takes a MediaRequest, which may be a
+ * regular request if the caller doesn't want to listen for events (fire-and-forget),
+ * or an async request wrapper if event callback is needed.</li>
+ * <li>{@link #requestMediaResourceSync(MediaRequest)} which takes a MediaRequest and synchronously
+ * returns the loaded result, or null if failed.</li>
+ * </ol>
+ *
+ * <p>For each media loading task, MediaResourceManager starts an AsyncTask that runs on a
+ * dedicated thread, which calls MediaRequest.loadMediaBlocking() to perform the actual media
+ * loading work. As the media resources are loaded, MediaResourceManager notifies the callers
+ * (which must implement the MediaResourceLoadListener interface) via onMediaResourceLoaded()
+ * callback. Meanwhile, MediaResourceManager also pushes the loaded resource onto its dedicated
+ * cache.</p>
+ *
+ * <p>The media resource caches ({@link MediaCache}) are maintained as a set of LRU caches. They are
+ * created on demand by the incoming MediaRequest's getCacheId() method. The implementations of
+ * MediaRequest (such as {@link ImageRequest}) get to determine the desired cache id. For Bugle,
+ * the list of available caches are in {@link BugleMediaCacheManager}</p>
+ *
+ * <p>Optionally, media loading can support on-demand media encoding and decoding.
+ * All {@link MediaRequest}'s can opt to chain additional {@link MediaRequest}'s to be executed
+ * after the completion of the main media loading task, by adding new tasks to the chained
+ * task list in {@link MediaRequest#loadMediaBlocking(List)}. One possible type of chained task is
+ * media encoding task. Loaded media will be encoded on a dedicated single threaded executor
+ * *after* the UI is notified of the loaded media. In this case, the encoded media resource will
+ * be eventually pushed to the cache, which will later be decoded before posting to the UI thread
+ * on cache hit.</p>
+ *
+ * <p><b>To add support for a new type of media resource,</b></p>
+ *
+ * <ol>
+ * <li>Create a new subclass of {@link RefCountedMediaResource} for the new resource type (example:
+ * {@link ImageResource} class).</li>
+ *
+ * <li>Implement the {@link MediaRequest} interface (example: {@link ImageRequest}). Perform the
+ * media loading work in loadMediaBlocking() and return a cache id in getCacheId().</li>
+ *
+ * <li>For the UI component that requests the media resource, let it implement
+ * {@link MediaResourceLoadListener} interface to listen for resource load callback. Let the
+ * UI component call MediaResourceManager.requestMediaResourceAsync() to request a media source.
+ * (example: {@link com.android.messaging.ui.ContactIconView}</li>
+ * </ol>
+ */
+public class MediaResourceManager {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ public static MediaResourceManager get() {
+ return Factory.get().getMediaResourceManager();
+ }
+
+ /**
+ * Listener for asynchronous callback from media loading events.
+ */
+ public interface MediaResourceLoadListener<T extends RefCountedMediaResource> {
+ void onMediaResourceLoaded(MediaRequest<T> request, T resource, boolean cached);
+ void onMediaResourceLoadError(MediaRequest<T> request, Exception exception);
+ }
+
+ // We use a fixed thread pool for handling media loading tasks. Using a cached thread pool
+ // allows for unlimited thread creation which can lead to OOMs so we limit the threads here.
+ private static final Executor MEDIA_LOADING_EXECUTOR = Executors.newFixedThreadPool(10);
+
+ // A dedicated single thread executor for performing background task after loading the resource
+ // on the media loading executor. This includes work such as encoding loaded media to be cached.
+ // These tasks are run on a single worker thread with low priority so as not to contend with the
+ // media loading tasks.
+ private static final Executor MEDIA_BACKGROUND_EXECUTOR = Executors.newSingleThreadExecutor(
+ new ThreadFactory() {
+ @Override
+ public Thread newThread(final Runnable runnable) {
+ final Thread encodingThread = new Thread(runnable);
+ encodingThread.setPriority(Thread.MIN_PRIORITY);
+ return encodingThread;
+ }
+ });
+
+ /**
+ * Requests a media resource asynchronously. Upon completion of the media loading task,
+ * the listener will be notified of success/failure iff it's still bound. A refcount on the
+ * resource is held and guaranteed for the caller for the duration of the
+ * {@link MediaResourceLoadListener#onMediaResourceLoaded(
+ * MediaRequest, RefCountedMediaResource, boolean)} callback.
+ * @param mediaRequest the media request. May be either an
+ * {@link AsyncMediaRequestWrapper} for listening for event callbacks, or a regular media
+ * request for fire-and-forget type of behavior.
+ */
+ public <T extends RefCountedMediaResource> void requestMediaResourceAsync(
+ final MediaRequest<T> mediaRequest) {
+ scheduleAsyncMediaRequest(mediaRequest, MEDIA_LOADING_EXECUTOR);
+ }
+
+ /**
+ * Requests a media resource synchronously.
+ * @return the loaded resource with a refcount reserved for the caller. The caller must call
+ * release() on the resource once it's done using it (like with Cursors).
+ */
+ public <T extends RefCountedMediaResource> T requestMediaResourceSync(
+ final MediaRequest<T> mediaRequest) {
+ Assert.isNotMainThread();
+ // Block and load media.
+ MediaLoadingResult<T> loadResult = null;
+ try {
+ loadResult = processMediaRequestInternal(mediaRequest);
+ // The loaded resource should have at least one refcount by now reserved for the caller.
+ Assert.isTrue(loadResult.loadedResource.getRefCount() > 0);
+ return loadResult.loadedResource;
+ } catch (final Exception e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Synchronous media loading failed, key=" +
+ mediaRequest.getKey(), e);
+ return null;
+ } finally {
+ if (loadResult != null) {
+ // Schedule the background requests chained to the main request.
+ loadResult.scheduleChainedRequests();
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private <T extends RefCountedMediaResource> MediaLoadingResult<T> processMediaRequestInternal(
+ final MediaRequest<T> mediaRequest)
+ throws Exception {
+ final List<MediaRequest<T>> chainedRequests = new ArrayList<>();
+ T loadedResource = null;
+ // Try fetching from cache first.
+ final T cachedResource = loadMediaFromCache(mediaRequest);
+ if (cachedResource != null) {
+ if (cachedResource.isEncoded()) {
+ // The resource is encoded, issue a decoding request.
+ final MediaRequest<T> decodeRequest = (MediaRequest<T>) cachedResource
+ .getMediaDecodingRequest(mediaRequest);
+ Assert.notNull(decodeRequest);
+ cachedResource.release();
+ loadedResource = loadMediaFromRequest(decodeRequest, chainedRequests);
+ } else {
+ // The resource is ready-to-use.
+ loadedResource = cachedResource;
+ }
+ } else {
+ // Actually load the media after cache miss.
+ loadedResource = loadMediaFromRequest(mediaRequest, chainedRequests);
+ }
+ return new MediaLoadingResult<>(loadedResource, cachedResource != null /* fromCache */,
+ chainedRequests);
+ }
+
+ private <T extends RefCountedMediaResource> T loadMediaFromCache(
+ final MediaRequest<T> mediaRequest) {
+ if (mediaRequest.getRequestType() != MediaRequest.REQUEST_LOAD_MEDIA) {
+ // Only look up in the cache if we are loading media.
+ return null;
+ }
+ final MediaCache<T> mediaCache = mediaRequest.getMediaCache();
+ if (mediaCache != null) {
+ final T mediaResource = mediaCache.fetchResourceFromCache(mediaRequest.getKey());
+ if (mediaResource != null) {
+ return mediaResource;
+ }
+ }
+ return null;
+ }
+
+ private <T extends RefCountedMediaResource> T loadMediaFromRequest(
+ final MediaRequest<T> mediaRequest, final List<MediaRequest<T>> chainedRequests)
+ throws Exception {
+ final T resource = mediaRequest.loadMediaBlocking(chainedRequests);
+ // mediaRequest.loadMediaBlocking() should never return null without
+ // throwing an exception.
+ Assert.notNull(resource);
+ // It's possible for the media to be evicted right after it's added to
+ // the cache (possibly because it's by itself too big for the cache).
+ // It's also possible that, after added to the cache, something else comes
+ // to the cache and evicts this media resource. To prevent this from
+ // recycling the underlying resource objects, make sure to add ref before
+ // adding to cache so that the caller is guaranteed a ref on the resource.
+ resource.addRef();
+ // Don't cache the media request if it is defined as non-cacheable.
+ if (resource.isCacheable()) {
+ addResourceToMemoryCache(mediaRequest, resource);
+ }
+ return resource;
+ }
+
+ /**
+ * Schedule an async media request on the given <code>executor</code>.
+ * @param mediaRequest the media request to be processed asynchronously. May be either an
+ * {@link AsyncMediaRequestWrapper} for listening for event callbacks, or a regular media
+ * request for fire-and-forget type of behavior.
+ */
+ private <T extends RefCountedMediaResource> void scheduleAsyncMediaRequest(
+ final MediaRequest<T> mediaRequest, final Executor executor) {
+ final BindableMediaRequest<T> bindableRequest =
+ (mediaRequest instanceof BindableMediaRequest<?>) ?
+ (BindableMediaRequest<T>) mediaRequest : null;
+ if (bindableRequest != null && !bindableRequest.isBound()) {
+ return; // Request is obsolete
+ }
+ // We don't use SafeAsyncTask here since it enforces the shared thread pool executor
+ // whereas we want a dedicated thread pool executor.
+ AsyncTask<Void, Void, MediaLoadingResult<T>> mediaLoadingTask =
+ new AsyncTask<Void, Void, MediaLoadingResult<T>>() {
+ private Exception mException;
+
+ @Override
+ protected MediaLoadingResult<T> doInBackground(Void... params) {
+ // Double check the request is still valid by the time we start processing it
+ if (bindableRequest != null && !bindableRequest.isBound()) {
+ return null; // Request is obsolete
+ }
+ try {
+ return processMediaRequestInternal(mediaRequest);
+ } catch (Exception e) {
+ mException = e;
+ return null;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(final MediaLoadingResult<T> result) {
+ if (result != null) {
+ Assert.isNull(mException);
+ Assert.isTrue(result.loadedResource.getRefCount() > 0);
+ try {
+ if (bindableRequest != null) {
+ bindableRequest.onMediaResourceLoaded(
+ bindableRequest, result.loadedResource, result.fromCache);
+ }
+ } finally {
+ result.loadedResource.release();
+ result.scheduleChainedRequests();
+ }
+ } else if (mException != null) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Asynchronous media loading failed, key=" +
+ mediaRequest.getKey(), mException);
+ if (bindableRequest != null) {
+ bindableRequest.onMediaResourceLoadError(bindableRequest, mException);
+ }
+ } else {
+ Assert.isTrue(bindableRequest == null || !bindableRequest.isBound());
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "media request not processed, no longer bound; key=" +
+ LogUtil.sanitizePII(mediaRequest.getKey()) /* key with phone# */);
+ }
+ }
+ }
+ };
+ mediaLoadingTask.executeOnExecutor(executor, (Void) null);
+ }
+
+ @VisibleForTesting
+ @RunsOnAnyThread
+ <T extends RefCountedMediaResource> void addResourceToMemoryCache(
+ final MediaRequest<T> mediaRequest, final T mediaResource) {
+ Assert.isTrue(mediaResource != null);
+ final MediaCache<T> mediaCache = mediaRequest.getMediaCache();
+ if (mediaCache != null) {
+ mediaCache.addResourceToCache(mediaRequest.getKey(), mediaResource);
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "added media resource to " + mediaCache.getName() + ". key=" +
+ LogUtil.sanitizePII(mediaRequest.getKey()) /* key can contain phone# */);
+ }
+ }
+ }
+
+ private class MediaLoadingResult<T extends RefCountedMediaResource> {
+ public final T loadedResource;
+ public final boolean fromCache;
+ private final List<MediaRequest<T>> mChainedRequests;
+
+ MediaLoadingResult(final T loadedResource, final boolean fromCache,
+ final List<MediaRequest<T>> chainedRequests) {
+ this.loadedResource = loadedResource;
+ this.fromCache = fromCache;
+ mChainedRequests = chainedRequests;
+ }
+
+ /**
+ * Asynchronously schedule a list of chained requests on the background thread.
+ */
+ public void scheduleChainedRequests() {
+ for (final MediaRequest<T> mediaRequest : mChainedRequests) {
+ scheduleAsyncMediaRequest(mediaRequest, MEDIA_BACKGROUND_EXECUTOR);
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java
new file mode 100644
index 0000000..1871e66
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java
@@ -0,0 +1,64 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.net.Uri;
+
+import com.android.messaging.datamodel.action.UpdateMessagePartSizeAction;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.util.ImageUtils;
+
+/**
+ * Image descriptor attached to a message part.
+ * Once image size is determined during loading this descriptor will update the db if necessary.
+ */
+public class MessagePartImageRequestDescriptor extends UriImageRequestDescriptor {
+ private final String mMessagePartId;
+
+ /**
+ * Creates a new image request for a message part.
+ */
+ public MessagePartImageRequestDescriptor(final MessagePartData messagePart,
+ final int desiredWidth, final int desiredHeight, boolean isStatic) {
+ // Pull image parameters out of the MessagePart record
+ this(messagePart.getPartId(), messagePart.getContentUri(), desiredWidth, desiredHeight,
+ messagePart.getWidth(), messagePart.getHeight(), isStatic);
+ }
+
+ protected MessagePartImageRequestDescriptor(final String messagePartId, final Uri contentUri,
+ final int desiredWidth, final int desiredHeight, final int sourceWidth,
+ final int sourceHeight, boolean isStatic) {
+ super(contentUri, desiredWidth, desiredHeight, sourceWidth, sourceHeight,
+ true /* allowCompression */, isStatic, false /* cropToCircle */,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ mMessagePartId = messagePartId;
+ }
+
+ @Override
+ public void updateSourceDimensions(final int updatedWidth, final int updatedHeight) {
+ // If the dimensions of the image do not match then queue a DB update with new size.
+ // Don't update if we don't have a part id, which happens if this part is loaded as
+ // draft through actions such as share intent/message forwarding.
+ if (mMessagePartId != null &&
+ updatedWidth != MessagePartData.UNSPECIFIED_SIZE &&
+ updatedHeight != MessagePartData.UNSPECIFIED_SIZE &&
+ updatedWidth != sourceWidth && updatedHeight != sourceHeight) {
+ UpdateMessagePartSizeAction.updateSize(mMessagePartId, updatedWidth, updatedHeight);
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java
new file mode 100644
index 0000000..ff11e92
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.datamodel.media;
+
+import android.content.Context;
+import android.net.Uri;
+
+import com.android.messaging.datamodel.data.MessagePartData;
+
+public class MessagePartVideoThumbnailRequestDescriptor extends MessagePartImageRequestDescriptor {
+ public MessagePartVideoThumbnailRequestDescriptor(MessagePartData messagePart) {
+ super(messagePart, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false);
+ }
+
+ public MessagePartVideoThumbnailRequestDescriptor(Uri uri) {
+ super(null, uri, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE,
+ ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false);
+ }
+
+ @Override
+ public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) {
+ return new VideoThumbnailRequest(context, this);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java b/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java
new file mode 100644
index 0000000..642e947
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.datamodel.media;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+import com.android.messaging.Factory;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+/**
+ * Serves network content URI based image requests.
+ */
+public class NetworkUriImageRequest<D extends UriImageRequestDescriptor> extends
+ ImageRequest<D> {
+
+ public NetworkUriImageRequest(Context context, D descriptor) {
+ super(context, descriptor);
+ mOrientation = android.media.ExifInterface.ORIENTATION_UNDEFINED;
+ }
+
+ @Override
+ protected InputStream getInputStreamForResource() throws FileNotFoundException {
+ Assert.isNotMainThread();
+ // Since we need to have an open urlConnection to get the stream, but we don't want to keep
+ // that connection open. There is no good way to perform this method.
+ return null;
+ }
+
+ @Override
+ protected boolean isGif() throws FileNotFoundException {
+ Assert.isNotMainThread();
+
+ HttpURLConnection connection = null;
+ try {
+ final URL url = new URL(mDescriptor.uri.toString());
+ connection = (HttpURLConnection) url.openConnection();
+ connection.connect();
+ if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) {
+ return ContentType.IMAGE_GIF.equalsIgnoreCase(connection.getContentType());
+ }
+ } catch (MalformedURLException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG,
+ "MalformedUrl for image with url: "
+ + mDescriptor.uri.toString(), e);
+ } catch (IOException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG,
+ "IOException trying to get inputStream for image with url: "
+ + mDescriptor.uri.toString(), e);
+ } finally {
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+ return false;
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public Bitmap loadBitmapInternal() throws IOException {
+ Assert.isNotMainThread();
+
+ InputStream inputStream = null;
+ Bitmap bitmap = null;
+ HttpURLConnection connection = null;
+ try {
+ final URL url = new URL(mDescriptor.uri.toString());
+ connection = (HttpURLConnection) url.openConnection();
+ connection.setDoInput(true);
+ connection.connect();
+ if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) {
+ bitmap = BitmapFactory.decodeStream(connection.getInputStream());
+ }
+ } catch (MalformedURLException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG,
+ "MalformedUrl for image with url: "
+ + mDescriptor.uri.toString(), e);
+ } catch (final OutOfMemoryError e) {
+ LogUtil.e(LogUtil.BUGLE_TAG,
+ "OutOfMemoryError for image with url: "
+ + mDescriptor.uri.toString(), e);
+ Factory.get().reclaimMemory();
+ } catch (IOException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG,
+ "IOException trying to get inputStream for image with url: "
+ + mDescriptor.uri.toString(), e);
+ } finally {
+ if (inputStream != null) {
+ inputStream.close();
+ }
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+ return bitmap;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/PoolableImageCache.java b/src/com/android/messaging/datamodel/media/PoolableImageCache.java
new file mode 100644
index 0000000..df814ba
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/PoolableImageCache.java
@@ -0,0 +1,419 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Color;
+import android.os.SystemClock;
+import android.support.annotation.NonNull;
+import android.util.SparseArray;
+
+import com.android.messaging.Factory;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.LinkedList;
+
+/**
+ * A media cache that holds image resources, which doubles as a bitmap pool that allows the
+ * consumer to optionally decode image resources using unused bitmaps stored in the cache.
+ */
+public class PoolableImageCache extends MediaCache<ImageResource> {
+ private static final int MIN_TIME_IN_POOL = 5000;
+
+ /** Encapsulates bitmap pool representation of the image cache */
+ private final ReusableImageResourcePool mReusablePoolAccessor = new ReusableImageResourcePool();
+
+ public PoolableImageCache(final int id, final String name) {
+ this(DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES, id, name);
+ }
+
+ public PoolableImageCache(final int maxSize, final int id, final String name) {
+ super(maxSize, id, name);
+ }
+
+ /**
+ * Creates a new BitmapFactory.Options for using the self-contained bitmap pool.
+ */
+ public static BitmapFactory.Options getBitmapOptionsForPool(final boolean scaled,
+ final int inputDensity, final int targetDensity) {
+ final BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inScaled = scaled;
+ options.inDensity = inputDensity;
+ options.inTargetDensity = targetDensity;
+ options.inSampleSize = 1;
+ options.inJustDecodeBounds = false;
+ options.inMutable = true;
+ return options;
+ }
+
+ @Override
+ public synchronized ImageResource addResourceToCache(final String key,
+ final ImageResource imageResource) {
+ mReusablePoolAccessor.onResourceEnterCache(imageResource);
+ return super.addResourceToCache(key, imageResource);
+ }
+
+ @Override
+ protected synchronized void entryRemoved(final boolean evicted, final String key,
+ final ImageResource oldValue, final ImageResource newValue) {
+ mReusablePoolAccessor.onResourceLeaveCache(oldValue);
+ super.entryRemoved(evicted, key, oldValue, newValue);
+ }
+
+ /**
+ * Returns a representation of the image cache as a reusable bitmap pool.
+ */
+ public ReusableImageResourcePool asReusableBitmapPool() {
+ return mReusablePoolAccessor;
+ }
+
+ /**
+ * A bitmap pool representation built on top of the image cache. It treats the image resources
+ * stored in the image cache as a self-contained bitmap pool and is able to create or
+ * reclaim bitmap resource as needed.
+ */
+ public class ReusableImageResourcePool {
+ private static final int MAX_SUPPORTED_IMAGE_DIMENSION = 0xFFFF;
+ private static final int INVALID_POOL_KEY = 0;
+
+ /**
+ * Number of reuse failures to skip before reporting.
+ * For debugging purposes, change to a lower number for more frequent reporting.
+ */
+ private static final int FAILED_REPORTING_FREQUENCY = 100;
+
+ /**
+ * Count of reuse failures which have occurred.
+ */
+ private volatile int mFailedBitmapReuseCount = 0;
+
+ /**
+ * Count of reuse successes which have occurred.
+ */
+ private volatile int mSucceededBitmapReuseCount = 0;
+
+ /**
+ * A sparse array from bitmap size to a list of image cache entries that match the
+ * given size. This map is used to quickly retrieve a usable bitmap to be reused by an
+ * incoming ImageRequest. We need to ensure that this sparse array always contains only
+ * elements currently in the image cache with no other consumer.
+ */
+ private final SparseArray<LinkedList<ImageResource>> mImageListSparseArray;
+
+ public ReusableImageResourcePool() {
+ mImageListSparseArray = new SparseArray<LinkedList<ImageResource>>();
+ }
+
+ /**
+ * Load an input stream into a bitmap. Uses a bitmap from the pool if possible to reduce
+ * memory turnover.
+ * @param inputStream InputStream load. Cannot be null.
+ * @param optionsTmp Should be the same options returned from getBitmapOptionsForPool().
+ * Cannot be null.
+ * @param width The width of the bitmap.
+ * @param height The height of the bitmap.
+ * @return The decoded Bitmap with the resource drawn in it.
+ * @throws IOException
+ */
+ public Bitmap decodeSampledBitmapFromInputStream(@NonNull final InputStream inputStream,
+ @NonNull final BitmapFactory.Options optionsTmp,
+ final int width, final int height) throws IOException {
+ if (width <= 0 || height <= 0) {
+ // This is an invalid / corrupted image of zero size.
+ LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache: Decoding bitmap with " +
+ "invalid size");
+ throw new IOException("Invalid size / corrupted image");
+ }
+ Assert.notNull(inputStream);
+ assignPoolBitmap(optionsTmp, width, height);
+ Bitmap b = null;
+ try {
+ b = BitmapFactory.decodeStream(inputStream, null, optionsTmp);
+ mSucceededBitmapReuseCount++;
+ } catch (final IllegalArgumentException e) {
+ // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap.
+ if (optionsTmp.inBitmap != null) {
+ optionsTmp.inBitmap.recycle();
+ optionsTmp.inBitmap = null;
+ b = BitmapFactory.decodeStream(inputStream, null, optionsTmp);
+ onFailedToReuse();
+ }
+ } catch (final OutOfMemoryError e) {
+ LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Oom decoding inputStream");
+ Factory.get().reclaimMemory();
+ }
+ return b;
+ }
+
+ /**
+ * Turn encoded bytes into a bitmap. Uses a bitmap from the pool if possible to reduce
+ * memory turnover.
+ * @param bytes Encoded bytes to draw on the bitmap. Cannot be null.
+ * @param optionsTmp The bitmap will set here and the input should be generated from
+ * getBitmapOptionsForPool(). Cannot be null.
+ * @param width The width of the bitmap.
+ * @param height The height of the bitmap.
+ * @return A Bitmap with the encoded bytes drawn in it.
+ * @throws IOException
+ */
+ public Bitmap decodeByteArray(@NonNull final byte[] bytes,
+ @NonNull final BitmapFactory.Options optionsTmp, final int width,
+ final int height) throws OutOfMemoryError, IOException {
+ if (width <= 0 || height <= 0) {
+ // This is an invalid / corrupted image of zero size.
+ LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache: Decoding bitmap with " +
+ "invalid size");
+ throw new IOException("Invalid size / corrupted image");
+ }
+ Assert.notNull(bytes);
+ Assert.notNull(optionsTmp);
+ assignPoolBitmap(optionsTmp, width, height);
+ Bitmap b = null;
+ try {
+ b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp);
+ mSucceededBitmapReuseCount++;
+ } catch (final IllegalArgumentException e) {
+ // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap.
+ // (i.e. without the bitmap from the pool)
+ if (optionsTmp.inBitmap != null) {
+ optionsTmp.inBitmap.recycle();
+ optionsTmp.inBitmap = null;
+ b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp);
+ onFailedToReuse();
+ }
+ } catch (final OutOfMemoryError e) {
+ LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Oom decoding inputStream");
+ Factory.get().reclaimMemory();
+ }
+ return b;
+ }
+
+ /**
+ * Called when a new image resource is added to the cache. We add the resource to the
+ * pool so it's properly keyed into the pool structure.
+ */
+ void onResourceEnterCache(final ImageResource imageResource) {
+ if (getPoolKey(imageResource) != INVALID_POOL_KEY) {
+ addResourceToPool(imageResource);
+ }
+ }
+
+ /**
+ * Called when an image resource is evicted from the cache. Bitmap pool's entries are
+ * strictly tied to their presence in the image cache. Once an image is evicted from the
+ * cache, it should be removed from the pool.
+ */
+ void onResourceLeaveCache(final ImageResource imageResource) {
+ if (getPoolKey(imageResource) != INVALID_POOL_KEY) {
+ removeResourceFromPool(imageResource);
+ }
+ }
+
+ private void addResourceToPool(final ImageResource imageResource) {
+ synchronized (PoolableImageCache.this) {
+ final int poolKey = getPoolKey(imageResource);
+ Assert.isTrue(poolKey != INVALID_POOL_KEY);
+ LinkedList<ImageResource> imageList = mImageListSparseArray.get(poolKey);
+ if (imageList == null) {
+ imageList = new LinkedList<ImageResource>();
+ mImageListSparseArray.put(poolKey, imageList);
+ }
+ imageList.addLast(imageResource);
+ }
+ }
+
+ private void removeResourceFromPool(final ImageResource imageResource) {
+ synchronized (PoolableImageCache.this) {
+ final int poolKey = getPoolKey(imageResource);
+ Assert.isTrue(poolKey != INVALID_POOL_KEY);
+ final LinkedList<ImageResource> imageList = mImageListSparseArray.get(poolKey);
+ if (imageList != null) {
+ imageList.remove(imageResource);
+ }
+ }
+ }
+
+ /**
+ * Try to get a reusable bitmap from the pool with the given width and height. As a
+ * result of this call, the caller will assume ownership of the returned bitmap.
+ */
+ private Bitmap getReusableBitmapFromPool(final int width, final int height) {
+ synchronized (PoolableImageCache.this) {
+ final int poolKey = getPoolKey(width, height);
+ if (poolKey != INVALID_POOL_KEY) {
+ final LinkedList<ImageResource> images = mImageListSparseArray.get(poolKey);
+ if (images != null && images.size() > 0) {
+ // Try to reuse the first available bitmap from the pool list. We start from
+ // the least recently added cache entry of the given size.
+ ImageResource imageToUse = null;
+ for (int i = 0; i < images.size(); i++) {
+ final ImageResource image = images.get(i);
+ if (image.getRefCount() == 1) {
+ image.acquireLock();
+ if (image.getRefCount() == 1) {
+ // The image is only used by the cache, so it's reusable.
+ imageToUse = images.remove(i);
+ break;
+ } else {
+ // Logically, this shouldn't happen, because as soon as the
+ // cache is the only user of this resource, it will not be
+ // used by anyone else until the next cache access, but we
+ // currently hold on to the cache lock. But technically
+ // future changes may violate this assumption, so warn about
+ // this.
+ LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Image refCount changed " +
+ "from 1 in getReusableBitmapFromPool()");
+ image.releaseLock();
+ }
+ }
+ }
+
+ if (imageToUse == null) {
+ return null;
+ }
+
+ try {
+ imageToUse.assertLockHeldByCurrentThread();
+
+ // Only reuse the bitmap if the last time we use was greater than 5s.
+ // This allows the cache a chance to reuse instead of always taking the
+ // oldest.
+ final long timeSinceLastRef = SystemClock.elapsedRealtime() -
+ imageToUse.getLastRefAddTimestamp();
+ if (timeSinceLastRef < MIN_TIME_IN_POOL) {
+ if (LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "Not reusing reusing " +
+ "first available bitmap from the pool because it " +
+ "has not been in the pool long enough. " +
+ "timeSinceLastRef=" + timeSinceLastRef);
+ }
+ // Put back the image and return no reuseable bitmap.
+ images.addLast(imageToUse);
+ return null;
+ }
+
+ // Add a temp ref on the image resource so it won't be GC'd after
+ // being removed from the cache.
+ imageToUse.addRef();
+
+ // Remove the image resource from the image cache.
+ final ImageResource removed = remove(imageToUse.getKey());
+ Assert.isTrue(removed == imageToUse);
+
+ // Try to reuse the bitmap from the image resource. This will transfer
+ // ownership of the bitmap object to the caller of this method.
+ final Bitmap reusableBitmap = imageToUse.reuseBitmap();
+
+ imageToUse.release();
+ return reusableBitmap;
+ } finally {
+ // We are either done with the reuse operation, or decided not to use
+ // the image. Either way, release the lock.
+ imageToUse.releaseLock();
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Try to locate and return a reusable bitmap from the pool, or create a new bitmap.
+ * @param width desired bitmap width
+ * @param height desired bitmap height
+ * @return the created or reused mutable bitmap that has its background cleared to
+ * {@value Color#TRANSPARENT}
+ */
+ public Bitmap createOrReuseBitmap(final int width, final int height) {
+ return createOrReuseBitmap(width, height, Color.TRANSPARENT);
+ }
+
+ /**
+ * Try to locate and return a reusable bitmap from the pool, or create a new bitmap.
+ * @param width desired bitmap width
+ * @param height desired bitmap height
+ * @param backgroundColor the background color for the returned bitmap
+ * @return the created or reused mutable bitmap with the requested background color
+ */
+ public Bitmap createOrReuseBitmap(final int width, final int height,
+ final int backgroundColor) {
+ Bitmap retBitmap = null;
+ try {
+ final Bitmap poolBitmap = getReusableBitmapFromPool(width, height);
+ retBitmap = (poolBitmap != null) ? poolBitmap :
+ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ retBitmap.eraseColor(backgroundColor);
+ } catch (final OutOfMemoryError e) {
+ LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache:try to createOrReuseBitmap");
+ Factory.get().reclaimMemory();
+ }
+ return retBitmap;
+ }
+
+ private void assignPoolBitmap(final BitmapFactory.Options optionsTmp, final int width,
+ final int height) {
+ if (optionsTmp.inJustDecodeBounds) {
+ return;
+ }
+ optionsTmp.inBitmap = getReusableBitmapFromPool(width, height);
+ }
+
+ /**
+ * @return The pool key for the provided image dimensions or 0 if either width or height is
+ * greater than the max supported image dimension.
+ */
+ private int getPoolKey(final int width, final int height) {
+ if (width > MAX_SUPPORTED_IMAGE_DIMENSION || height > MAX_SUPPORTED_IMAGE_DIMENSION) {
+ return INVALID_POOL_KEY;
+ }
+ return (width << 16) | height;
+ }
+
+ /**
+ * @return the pool key for a given image resource.
+ */
+ private int getPoolKey(final ImageResource imageResource) {
+ if (imageResource.supportsBitmapReuse()) {
+ final Bitmap bitmap = imageResource.getBitmap();
+ if (bitmap != null && bitmap.isMutable()) {
+ final int width = bitmap.getWidth();
+ final int height = bitmap.getHeight();
+ if (width > 0 && height > 0) {
+ return getPoolKey(width, height);
+ }
+ }
+ }
+ return INVALID_POOL_KEY;
+ }
+
+ /**
+ * Called when bitmap reuse fails. Conditionally report the failure with statistics.
+ */
+ private void onFailedToReuse() {
+ mFailedBitmapReuseCount++;
+ if (mFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) {
+ LogUtil.w(LogUtil.BUGLE_IMAGE_TAG,
+ "Pooled bitmap consistently not being reused. Failure count = " +
+ mFailedBitmapReuseCount + ", success count = " +
+ mSucceededBitmapReuseCount);
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java b/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java
new file mode 100644
index 0000000..c21f477
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java
@@ -0,0 +1,164 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.os.SystemClock;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.google.common.base.Throwables;
+
+import java.util.ArrayList;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * A ref-counted class that holds loaded media resource, be it bitmaps or media bytes.
+ * Subclasses must implement the close() method to release any resources (such as bitmaps)
+ * when it's no longer used.
+ *
+ * Instances of the subclasses are:
+ * 1. Loaded by their corresponding MediaRequest classes.
+ * 2. Maintained by MediaResourceManager in its MediaCache pool.
+ * 3. Used by the UI (such as ContactIconViews) to present the content.
+ *
+ * Note: all synchronized methods in this class (e.g. addRef()) should not attempt to make outgoing
+ * calls that could potentially acquire media cache locks due to the potential deadlock this can
+ * cause. To synchronize read/write access to shared resource, {@link #acquireLock()} and
+ * {@link #releaseLock()} must be used, instead of using synchronized keyword.
+ */
+public abstract class RefCountedMediaResource {
+ private final String mKey;
+ private int mRef = 0;
+ private long mLastRefAddTimestamp;
+
+ // Set DEBUG to true to enable detailed stack trace for each addRef() and release() operation
+ // to find out where each ref change happens.
+ private static final boolean DEBUG = false;
+ private static final String TAG = "bugle_media_ref_history";
+ private final ArrayList<String> mRefHistory = new ArrayList<String>();
+
+ // A lock that guards access to shared members in this class (and all its subclasses).
+ private final ReentrantLock mLock = new ReentrantLock();
+
+ public RefCountedMediaResource(final String key) {
+ mKey = key;
+ }
+
+ public String getKey() {
+ return mKey;
+ }
+
+ public void addRef() {
+ acquireLock();
+ try {
+ if (DEBUG) {
+ mRefHistory.add("Added ref current ref = " + mRef);
+ mRefHistory.add(Throwables.getStackTraceAsString(new Exception()));
+ }
+
+ mRef++;
+ mLastRefAddTimestamp = SystemClock.elapsedRealtime();
+ } finally {
+ releaseLock();
+ }
+ }
+
+ public void release() {
+ acquireLock();
+ try {
+ if (DEBUG) {
+ mRefHistory.add("Released ref current ref = " + mRef);
+ mRefHistory.add(Throwables.getStackTraceAsString(new Exception()));
+ }
+
+ mRef--;
+ if (mRef == 0) {
+ close();
+ } else if (mRef < 0) {
+ if (DEBUG) {
+ LogUtil.i(TAG, "Unwinding ref count history for RefCountedMediaResource "
+ + this);
+ for (final String ref : mRefHistory) {
+ LogUtil.i(TAG, ref);
+ }
+ }
+ Assert.fail("RefCountedMediaResource has unbalanced ref. Refcount=" + mRef);
+ }
+ } finally {
+ releaseLock();
+ }
+ }
+
+ public int getRefCount() {
+ acquireLock();
+ try {
+ return mRef;
+ } finally {
+ releaseLock();
+ }
+ }
+
+ public long getLastRefAddTimestamp() {
+ acquireLock();
+ try {
+ return mLastRefAddTimestamp;
+ } finally {
+ releaseLock();
+ }
+ }
+
+ public void assertSingularRefCount() {
+ acquireLock();
+ try {
+ Assert.equals(1, mRef);
+ } finally {
+ releaseLock();
+ }
+ }
+
+ void acquireLock() {
+ mLock.lock();
+ }
+
+ void releaseLock() {
+ mLock.unlock();
+ }
+
+ void assertLockHeldByCurrentThread() {
+ Assert.isTrue(mLock.isHeldByCurrentThread());
+ }
+
+ boolean isEncoded() {
+ return false;
+ }
+
+ boolean isCacheable() {
+ return true;
+ }
+
+ MediaRequest<? extends RefCountedMediaResource> getMediaDecodingRequest(
+ final MediaRequest<? extends RefCountedMediaResource> originalRequest) {
+ return null;
+ }
+
+ MediaRequest<? extends RefCountedMediaResource> getMediaEncodingRequest(
+ final MediaRequest<? extends RefCountedMediaResource> originalRequest) {
+ return null;
+ }
+
+ public abstract int getMediaSize();
+ protected abstract void close();
+} \ No newline at end of file
diff --git a/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java b/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java
new file mode 100644
index 0000000..e4f0334
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffColorFilter;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.graphics.drawable.BitmapDrawable;
+import android.media.ExifInterface;
+import android.text.TextUtils;
+
+import com.android.messaging.R;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+
+import java.io.IOException;
+import java.util.List;
+
+public class SimSelectorAvatarRequest extends AvatarRequest {
+ private static Bitmap sRegularSimIcon;
+
+ public SimSelectorAvatarRequest(final Context context,
+ final AvatarRequestDescriptor descriptor) {
+ super(context, descriptor);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTasks)
+ throws IOException {
+ Assert.isNotMainThread();
+ final String avatarType = AvatarUriUtil.getAvatarType(mDescriptor.uri);
+ if (AvatarUriUtil.TYPE_SIM_SELECTOR_URI.equals(avatarType)){
+ final int width = mDescriptor.desiredWidth;
+ final int height = mDescriptor.desiredHeight;
+ final String identifier = AvatarUriUtil.getIdentifier(mDescriptor.uri);
+ final boolean simSelected = AvatarUriUtil.getSimSelected(mDescriptor.uri);
+ final int simColor = AvatarUriUtil.getSimColor(mDescriptor.uri);
+ final boolean incoming = AvatarUriUtil.getSimIncoming(mDescriptor.uri);
+ return renderSimAvatarInternal(identifier, width, height, simColor, simSelected,
+ incoming);
+ }
+ return super.loadMediaInternal(chainedTasks);
+ }
+
+ private ImageResource renderSimAvatarInternal(final String identifier, final int width,
+ final int height, final int subColor, final boolean selected, final boolean incoming) {
+ final Resources resources = mContext.getResources();
+ final float halfWidth = width / 2;
+ final float halfHeight = height / 2;
+ final int minOfWidthAndHeight = Math.min(width, height);
+ final int backgroundColor = selected ? subColor : Color.WHITE;
+ final int textColor = selected ? subColor : Color.WHITE;
+ final int simColor = selected ? Color.WHITE : subColor;
+ final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height, backgroundColor);
+ final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ final Canvas canvas = new Canvas(bitmap);
+
+ if (sRegularSimIcon == null) {
+ final BitmapDrawable regularSim = (BitmapDrawable) mContext.getResources()
+ .getDrawable(R.drawable.ic_sim_card_send);
+ sRegularSimIcon = regularSim.getBitmap();
+ }
+
+ paint.setColorFilter(new PorterDuffColorFilter(simColor, PorterDuff.Mode.SRC_ATOP));
+ paint.setAlpha(0xff);
+ canvas.drawBitmap(sRegularSimIcon, halfWidth - sRegularSimIcon.getWidth() / 2,
+ halfHeight - sRegularSimIcon.getHeight() / 2, paint);
+ paint.setColorFilter(null);
+ paint.setAlpha(0xff);
+
+ if (!TextUtils.isEmpty(identifier)) {
+ paint.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL));
+ paint.setColor(textColor);
+ final float letterToTileRatio =
+ resources.getFraction(R.dimen.sim_identifier_to_tile_ratio, 1, 1);
+ paint.setTextSize(letterToTileRatio * minOfWidthAndHeight);
+
+ final String firstCharString = identifier.substring(0, 1).toUpperCase();
+ final Rect textBound = new Rect();
+ paint.getTextBounds(firstCharString, 0, 1, textBound);
+
+ final float xOffset = halfWidth - textBound.centerX();
+ final float yOffset = halfHeight - textBound.centerY();
+ canvas.drawText(firstCharString, xOffset, yOffset, paint);
+ }
+
+ return new DecodedImageResource(getKey(), bitmap, ExifInterface.ORIENTATION_NORMAL);
+ }
+
+ @Override
+ public int getCacheId() {
+ return BugleMediaCacheManager.AVATAR_IMAGE_CACHE;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/UriImageRequest.java b/src/com/android/messaging/datamodel/media/UriImageRequest.java
new file mode 100644
index 0000000..b4934ca
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/UriImageRequest.java
@@ -0,0 +1,58 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Context;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+/**
+ * Serves local content URI based image requests.
+ */
+public class UriImageRequest<D extends UriImageRequestDescriptor> extends ImageRequest<D> {
+ public UriImageRequest(final Context context, final D descriptor) {
+ super(context, descriptor);
+ }
+
+ @Override
+ protected InputStream getInputStreamForResource() throws FileNotFoundException {
+ return mContext.getContentResolver().openInputStream(mDescriptor.uri);
+ }
+
+ @Override
+ protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTasks)
+ throws IOException {
+ final ImageResource resource = super.loadMediaInternal(chainedTasks);
+ // Check if the caller asked for compression. If so, chain an encoding task if possible.
+ if (mDescriptor.allowCompression && chainedTasks != null) {
+ @SuppressWarnings("unchecked")
+ final MediaRequest<ImageResource> chainedTask = (MediaRequest<ImageResource>)
+ resource.getMediaEncodingRequest(this);
+ if (chainedTask != null) {
+ chainedTasks.add(chainedTask);
+ // Don't cache decoded image resource since we'll perform compression and cache
+ // the compressed resource.
+ if (resource instanceof DecodedImageResource) {
+ ((DecodedImageResource) resource).setCacheable(false);
+ }
+ }
+ }
+ return resource;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java
new file mode 100644
index 0000000..c5685d1
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java
@@ -0,0 +1,95 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Context;
+import android.net.Uri;
+
+import com.android.messaging.util.UriUtil;
+
+public class UriImageRequestDescriptor extends ImageRequestDescriptor {
+ public final Uri uri;
+ public final boolean allowCompression;
+
+ public UriImageRequestDescriptor(final Uri uri) {
+ this(uri, UriImageRequest.UNSPECIFIED_SIZE, UriImageRequest.UNSPECIFIED_SIZE, false, false,
+ false, 0, 0);
+ }
+
+ public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, final int desiredHeight)
+ {
+ this(uri, desiredWidth, desiredHeight, false, false, false, 0, 0);
+ }
+
+ public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, final int desiredHeight,
+ final boolean cropToCircle, final int circleBackgroundColor, int circleStrokeColor)
+ {
+ this(uri, desiredWidth, desiredHeight, false,
+ false, cropToCircle, circleBackgroundColor, circleStrokeColor);
+ }
+
+ public UriImageRequestDescriptor(final Uri uri, final int desiredWidth,
+ final int desiredHeight, final boolean allowCompression, boolean isStatic,
+ boolean cropToCircle, int circleBackgroundColor, int circleStrokeColor) {
+ this(uri, desiredWidth, desiredHeight, UriImageRequest.UNSPECIFIED_SIZE,
+ UriImageRequest.UNSPECIFIED_SIZE, allowCompression, isStatic, cropToCircle,
+ circleBackgroundColor, circleStrokeColor);
+ }
+
+ /**
+ * Creates a new Uri-based image request.
+ * @param uri the content Uri. Currently Bugle only supports local resources Uri (i.e. it has
+ * to begin with content: or android.resource:
+ * @param circleStrokeColor
+ */
+ public UriImageRequestDescriptor(final Uri uri, final int desiredWidth,
+ final int desiredHeight, final int sourceWidth, final int sourceHeight,
+ final boolean allowCompression, final boolean isStatic, final boolean cropToCircle,
+ final int circleBackgroundColor, int circleStrokeColor) {
+ super(desiredWidth, desiredHeight, sourceWidth, sourceHeight, isStatic,
+ cropToCircle, circleBackgroundColor, circleStrokeColor);
+ this.uri = uri;
+ this.allowCompression = allowCompression;
+ }
+
+ @Override
+ public String getKey() {
+ if (uri != null) {
+ final String key = super.getKey();
+ if (key != null) {
+ return new StringBuilder()
+ .append(uri).append(KEY_PART_DELIMITER)
+ .append(String.valueOf(allowCompression)).append(KEY_PART_DELIMITER)
+ .append(key).toString();
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) {
+ if (uri == null || UriUtil.isLocalUri(uri)) {
+ return new UriImageRequest<UriImageRequestDescriptor>(context, this);
+ } else {
+ return new NetworkUriImageRequest<UriImageRequestDescriptor>(context, this);
+ }
+ }
+
+ /** ID of the resource in MediaStore or null if this resource didn't come from MediaStore */
+ public Long getMediaStoreId() {
+ return null;
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/datamodel/media/VCardRequest.java b/src/com/android/messaging/datamodel/media/VCardRequest.java
new file mode 100644
index 0000000..d6e992c
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/VCardRequest.java
@@ -0,0 +1,328 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.net.Uri;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.UriUtil;
+import com.android.vcard.VCardConfig;
+import com.android.vcard.VCardEntry;
+import com.android.vcard.VCardEntryCounter;
+import com.android.vcard.VCardInterpreter;
+import com.android.vcard.VCardParser;
+import com.android.vcard.VCardParser_V21;
+import com.android.vcard.VCardParser_V30;
+import com.android.vcard.VCardSourceDetector;
+import com.android.vcard.exception.VCardException;
+import com.android.vcard.exception.VCardNestedException;
+import com.android.vcard.exception.VCardNotSupportedException;
+import com.android.vcard.exception.VCardVersionException;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Requests and parses VCard data. In Bugle, we need to display VCard details in the conversation
+ * view such as avatar icon and name, which can be expensive if we parse VCard every time.
+ * Therefore, we'd like to load the vcard once and cache it in our media cache using the
+ * MediaResourceManager component. To load the VCard, we use framework's VCard support to
+ * interpret the VCard content, which gives us information such as phone and email list, which
+ * we'll put in VCardResource object to be cached.
+ *
+ * Some particular attention is needed for the avatar icon. If the VCard contains avatar icon,
+ * it's in byte array form that can't easily be cached/persisted. Therefore, we persist the
+ * image bytes to the scratch directory and generate a content Uri for it, so that ContactIconView
+ * may use this Uri to display and cache the image if needed.
+ */
+public class VCardRequest implements MediaRequest<VCardResource> {
+ private final Context mContext;
+ private final VCardRequestDescriptor mDescriptor;
+ private final List<VCardResourceEntry> mLoadedVCards;
+ private VCardResource mLoadedResource;
+ private static final int VCARD_LOADING_TIMEOUT_MILLIS = 10000; // 10s
+ private static final String DEFAULT_VCARD_TYPE = "default";
+
+ VCardRequest(final Context context, final VCardRequestDescriptor descriptor) {
+ mDescriptor = descriptor;
+ mContext = context;
+ mLoadedVCards = new ArrayList<VCardResourceEntry>();
+ }
+
+ @Override
+ public String getKey() {
+ return mDescriptor.vCardUri.toString();
+ }
+
+ @Override
+ @DoesNotRunOnMainThread
+ public VCardResource loadMediaBlocking(List<MediaRequest<VCardResource>> chainedTask)
+ throws Exception {
+ Assert.isNotMainThread();
+ Assert.isTrue(mLoadedResource == null);
+ Assert.equals(0, mLoadedVCards.size());
+
+ // The VCard library doesn't support synchronously loading the media resource. Therefore,
+ // We have to burn the thread waiting for the result to come back.
+ final CountDownLatch signal = new CountDownLatch(1);
+ if (!parseVCard(mDescriptor.vCardUri, signal)) {
+ // Directly fail without actually going through the interpreter, return immediately.
+ throw new VCardException("Invalid vcard");
+ }
+
+ signal.await(VCARD_LOADING_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ if (mLoadedResource == null) {
+ // Maybe null if failed or timeout.
+ throw new VCardException("Failure or timeout loading vcard");
+ }
+ return mLoadedResource;
+ }
+
+ @Override
+ public int getCacheId() {
+ return BugleMediaCacheManager.VCARD_CACHE;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public MediaCache<VCardResource> getMediaCache() {
+ return (MediaCache<VCardResource>) MediaCacheManager.get().getOrCreateMediaCacheById(
+ getCacheId());
+ }
+
+ @DoesNotRunOnMainThread
+ private boolean parseVCard(final Uri targetUri, final CountDownLatch signal) {
+ Assert.isNotMainThread();
+ final VCardEntryCounter counter = new VCardEntryCounter();
+ final VCardSourceDetector detector = new VCardSourceDetector();
+ boolean result;
+ try {
+ // We don't know which type should be used to parse the Uri.
+ // It is possible to misinterpret the vCard, but we expect the parser
+ // lets VCardSourceDetector detect the type before the misinterpretation.
+ result = readOneVCardFile(targetUri, VCardConfig.VCARD_TYPE_UNKNOWN,
+ detector, true, null);
+ } catch (final VCardNestedException e) {
+ try {
+ final int estimatedVCardType = detector.getEstimatedType();
+ // Assume that VCardSourceDetector was able to detect the source.
+ // Try again with the detector.
+ result = readOneVCardFile(targetUri, estimatedVCardType,
+ counter, false, null);
+ } catch (final VCardNestedException e2) {
+ result = false;
+ LogUtil.e(LogUtil.BUGLE_TAG, "Must not reach here. " + e2);
+ }
+ }
+
+ if (!result) {
+ // Load failure.
+ return false;
+ }
+
+ return doActuallyReadOneVCard(targetUri, true, detector, null, signal);
+ }
+
+ @DoesNotRunOnMainThread
+ private boolean doActuallyReadOneVCard(final Uri uri, final boolean showEntryParseProgress,
+ final VCardSourceDetector detector, final List<String> errorFileNameList,
+ final CountDownLatch signal) {
+ Assert.isNotMainThread();
+ int vcardType = detector.getEstimatedType();
+ if (vcardType == VCardConfig.VCARD_TYPE_UNKNOWN) {
+ vcardType = VCardConfig.getVCardTypeFromString(DEFAULT_VCARD_TYPE);
+ }
+ final CustomVCardEntryConstructor builder =
+ new CustomVCardEntryConstructor(vcardType, null);
+ builder.addEntryHandler(new ContactVCardEntryHandler(signal));
+
+ try {
+ if (!readOneVCardFile(uri, vcardType, builder, false, null)) {
+ return false;
+ }
+ } catch (final VCardNestedException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Must not reach here. " + e);
+ return false;
+ }
+ return true;
+ }
+
+ @DoesNotRunOnMainThread
+ private boolean readOneVCardFile(final Uri uri, final int vcardType,
+ final VCardInterpreter interpreter,
+ final boolean throwNestedException, final List<String> errorFileNameList)
+ throws VCardNestedException {
+ Assert.isNotMainThread();
+ final ContentResolver resolver = mContext.getContentResolver();
+ VCardParser vCardParser;
+ InputStream is;
+ try {
+ is = resolver.openInputStream(uri);
+ vCardParser = new VCardParser_V21(vcardType);
+ vCardParser.addInterpreter(interpreter);
+
+ try {
+ vCardParser.parse(is);
+ } catch (final VCardVersionException e1) {
+ try {
+ is.close();
+ } catch (final IOException e) {
+ // Do nothing.
+ }
+ if (interpreter instanceof CustomVCardEntryConstructor) {
+ // Let the object clean up internal temporal objects,
+ ((CustomVCardEntryConstructor) interpreter).clear();
+ }
+
+ is = resolver.openInputStream(uri);
+
+ try {
+ vCardParser = new VCardParser_V30(vcardType);
+ vCardParser.addInterpreter(interpreter);
+ vCardParser.parse(is);
+ } catch (final VCardVersionException e2) {
+ throw new VCardException("vCard with unspported version.");
+ }
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (final IOException e) {
+ // Do nothing.
+ }
+ }
+ }
+ } catch (final IOException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "IOException was emitted: " + e.getMessage());
+
+ if (errorFileNameList != null) {
+ errorFileNameList.add(uri.toString());
+ }
+ return false;
+ } catch (final VCardNotSupportedException e) {
+ if ((e instanceof VCardNestedException) && throwNestedException) {
+ throw (VCardNestedException) e;
+ }
+ if (errorFileNameList != null) {
+ errorFileNameList.add(uri.toString());
+ }
+ return false;
+ } catch (final VCardException e) {
+ if (errorFileNameList != null) {
+ errorFileNameList.add(uri.toString());
+ }
+ return false;
+ }
+ return true;
+ }
+
+ class ContactVCardEntryHandler implements CustomVCardEntryConstructor.EntryHandler {
+ final CountDownLatch mSignal;
+
+ public ContactVCardEntryHandler(final CountDownLatch signal) {
+ mSignal = signal;
+ }
+
+ @Override
+ public void onStart() {
+ }
+
+ @Override
+ @DoesNotRunOnMainThread
+ public void onEntryCreated(final CustomVCardEntry entry) {
+ Assert.isNotMainThread();
+ final String displayName = entry.getDisplayName();
+ final List<VCardEntry.PhotoData> photos = entry.getPhotoList();
+ Uri avatarUri = null;
+ if (photos != null && photos.size() > 0) {
+ // The photo data is in bytes form, so we need to persist it in our temp directory
+ // so that ContactIconView can load it and display it later
+ // (and cache it, of course).
+ for (final VCardEntry.PhotoData photo : photos) {
+ final byte[] photoBytes = photo.getBytes();
+ if (photoBytes != null) {
+ final InputStream inputStream = new ByteArrayInputStream(photoBytes);
+ try {
+ avatarUri = UriUtil.persistContentToScratchSpace(inputStream);
+ if (avatarUri != null) {
+ // Just load the first avatar and be done. Want more? wait for V2.
+ break;
+ }
+ } finally {
+ try {
+ inputStream.close();
+ } catch (final IOException e) {
+ // Do nothing.
+ }
+ }
+ }
+ }
+ }
+
+ // Fall back to generated avatar.
+ if (avatarUri == null) {
+ String destination = null;
+ final List<VCardEntry.PhoneData> phones = entry.getPhoneList();
+ if (phones != null && phones.size() > 0) {
+ destination = PhoneUtils.getDefault().getCanonicalBySystemLocale(
+ phones.get(0).getNumber());
+ }
+
+ if (destination == null) {
+ final List<VCardEntry.EmailData> emails = entry.getEmailList();
+ if (emails != null && emails.size() > 0) {
+ destination = emails.get(0).getAddress();
+ }
+ }
+ avatarUri = AvatarUriUtil.createAvatarUri(null, displayName, destination, null);
+ }
+
+ // Add the loaded vcard to the list.
+ mLoadedVCards.add(new VCardResourceEntry(entry, avatarUri));
+ }
+
+ @Override
+ public void onEnd() {
+ // Finished loading all vCard entries, signal the loading thread to proceed with the
+ // result.
+ if (mLoadedVCards.size() > 0) {
+ mLoadedResource = new VCardResource(getKey(), mLoadedVCards);
+ }
+ mSignal.countDown();
+ }
+ }
+
+ @Override
+ public int getRequestType() {
+ return MediaRequest.REQUEST_LOAD_MEDIA;
+ }
+
+ @Override
+ public MediaRequestDescriptor<VCardResource> getDescriptor() {
+ return mDescriptor;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java b/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java
new file mode 100644
index 0000000..4084851
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.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.messaging.datamodel.media;
+
+import android.content.Context;
+import android.net.Uri;
+
+import com.android.messaging.util.Assert;
+
+public class VCardRequestDescriptor extends MediaRequestDescriptor<VCardResource> {
+ public final Uri vCardUri;
+
+ public VCardRequestDescriptor(final Uri vCardUri) {
+ Assert.notNull(vCardUri);
+ this.vCardUri = vCardUri;
+ }
+
+ @Override
+ public MediaRequest<VCardResource> buildSyncMediaRequest(Context context) {
+ return new VCardRequest(context, this);
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/VCardResource.java b/src/com/android/messaging/datamodel/media/VCardResource.java
new file mode 100644
index 0000000..edf5e88
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/VCardResource.java
@@ -0,0 +1,52 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import java.util.List;
+
+/**
+ * Holds cached information of VCard contact info.
+ * The temporarily persisted avatar icon Uri is tied to the VCardResource. As a result, whenever
+ * the VCardResource is no longer used (i.e. close() is called), we need to asynchronously
+ * delete the avatar image from temp storage since no one will have reference to the avatar Uri
+ * again. The next time the same VCard is displayed, since the old resource has been evicted from
+ * the memory cache, we'll load and persist the avatar icon again.
+ */
+public class VCardResource extends RefCountedMediaResource {
+ private final List<VCardResourceEntry> mVCards;
+
+ public VCardResource(final String key, final List<VCardResourceEntry> vcards) {
+ super(key);
+ mVCards = vcards;
+ }
+
+ public List<VCardResourceEntry> getVCards() {
+ return mVCards;
+ }
+
+ @Override
+ public int getMediaSize() {
+ // Instead of track VCards by size in kilobytes, we track them by count.
+ return 0;
+ }
+
+ @Override
+ protected void close() {
+ for (final VCardResourceEntry vcard : mVCards) {
+ vcard.close();
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/VCardResourceEntry.java b/src/com/android/messaging/datamodel/media/VCardResourceEntry.java
new file mode 100644
index 0000000..f76b796
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/VCardResourceEntry.java
@@ -0,0 +1,389 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Intent;
+import android.content.res.Resources;
+import android.content.res.Resources.NotFoundException;
+import android.net.Uri;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.support.v4.util.ArrayMap;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.MediaScratchFileProvider;
+import com.android.messaging.datamodel.data.PersonItemData;
+import com.android.messaging.util.ContactUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.SafeAsyncTask;
+import com.android.vcard.VCardEntry;
+import com.android.vcard.VCardEntry.EmailData;
+import com.android.vcard.VCardEntry.ImData;
+import com.android.vcard.VCardEntry.NoteData;
+import com.android.vcard.VCardEntry.OrganizationData;
+import com.android.vcard.VCardEntry.PhoneData;
+import com.android.vcard.VCardEntry.PostalData;
+import com.android.vcard.VCardEntry.WebsiteData;
+import com.android.vcard.VCardProperty;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Holds one entry item (i.e. a single contact) within a VCard resource. It is able to take
+ * a VCardEntry and extract relevant information from it.
+ */
+public class VCardResourceEntry {
+ public static final String PROPERTY_KIND = "KIND";
+
+ public static final String KIND_LOCATION = "location";
+
+ private final List<VCardResourceEntry.VCardResourceEntryDestinationItem> mContactInfo;
+ private final Uri mAvatarUri;
+ private final String mDisplayName;
+ private final CustomVCardEntry mVCard;
+
+ public VCardResourceEntry(final CustomVCardEntry vcard, final Uri avatarUri) {
+ mContactInfo = getContactInfoFromVCardEntry(vcard);
+ mDisplayName = getDisplayNameFromVCardEntry(vcard);
+ mAvatarUri = avatarUri;
+ mVCard = vcard;
+ }
+
+ void close() {
+ // If the avatar image was temporarily saved in the scratch folder, remove that.
+ if (MediaScratchFileProvider.isMediaScratchSpaceUri(mAvatarUri)) {
+ SafeAsyncTask.executeOnThreadPool(new Runnable() {
+ @Override
+ public void run() {
+ Factory.get().getApplicationContext().getContentResolver().delete(
+ mAvatarUri, null, null);
+ }
+ });
+ }
+ }
+
+ public String getKind() {
+ VCardProperty kindProperty = mVCard.getProperty(PROPERTY_KIND);
+ return kindProperty == null ? null : kindProperty.getRawValue();
+ }
+
+ public Uri getAvatarUri() {
+ return mAvatarUri;
+ }
+
+ public String getDisplayName() {
+ return mDisplayName;
+ }
+
+ public String getDisplayAddress() {
+ List<PostalData> postalList = mVCard.getPostalList();
+ if (postalList == null || postalList.size() < 1) {
+ return null;
+ }
+
+ return formatAddress(postalList.get(0));
+ }
+
+ public String getNotes() {
+ List<NoteData> notes = mVCard.getNotes();
+ if (notes == null || notes.size() == 0) {
+ return null;
+ }
+ StringBuilder noteBuilder = new StringBuilder();
+ for (NoteData note : notes) {
+ noteBuilder.append(note.getNote());
+ }
+ return noteBuilder.toString();
+ }
+
+ /**
+ * Returns a UI-facing representation that can be bound and consumed by the UI layer to display
+ * this VCard resource entry.
+ */
+ public PersonItemData getDisplayItem() {
+ return new PersonItemData() {
+ @Override
+ public Uri getAvatarUri() {
+ return VCardResourceEntry.this.getAvatarUri();
+ }
+
+ @Override
+ public String getDisplayName() {
+ return VCardResourceEntry.this.getDisplayName();
+ }
+
+ @Override
+ public String getDetails() {
+ return null;
+ }
+
+ @Override
+ public Intent getClickIntent() {
+ return null;
+ }
+
+ @Override
+ public long getContactId() {
+ return ContactUtil.INVALID_CONTACT_ID;
+ }
+
+ @Override
+ public String getLookupKey() {
+ return null;
+ }
+
+ @Override
+ public String getNormalizedDestination() {
+ return null;
+ }
+ };
+ }
+
+ public List<VCardResourceEntry.VCardResourceEntryDestinationItem> getContactInfo() {
+ return mContactInfo;
+ }
+
+ private static List<VCardResourceEntryDestinationItem> getContactInfoFromVCardEntry(
+ final VCardEntry vcard) {
+ final Resources resources = Factory.get().getApplicationContext().getResources();
+ final List<VCardResourceEntry.VCardResourceEntryDestinationItem> retList =
+ new ArrayList<VCardResourceEntry.VCardResourceEntryDestinationItem>();
+ if (vcard.getPhoneList() != null) {
+ for (final PhoneData phone : vcard.getPhoneList()) {
+ final Intent intent = new Intent(Intent.ACTION_DIAL);
+ intent.setData(Uri.parse("tel:" + phone.getNumber()));
+ retList.add(new VCardResourceEntryDestinationItem(phone.getNumber(),
+ Phone.getTypeLabel(resources, phone.getType(), phone.getLabel()).toString(),
+ intent));
+ }
+ }
+
+ if (vcard.getEmailList() != null) {
+ for (final EmailData email : vcard.getEmailList()) {
+ final Intent intent = new Intent(Intent.ACTION_SENDTO);
+ intent.setData(Uri.parse("mailto:"));
+ intent.putExtra(Intent.EXTRA_EMAIL, new String[] { email.getAddress() });
+ retList.add(new VCardResourceEntryDestinationItem(email.getAddress(),
+ Phone.getTypeLabel(resources, email.getType(),
+ email.getLabel()).toString(), intent));
+ }
+ }
+
+ if (vcard.getPostalList() != null) {
+ for (final PostalData postalData : vcard.getPostalList()) {
+ String type;
+ try {
+ type = resources.
+ getStringArray(android.R.array.postalAddressTypes)
+ [postalData.getType() - 1];
+ } catch (final NotFoundException ex) {
+ type = resources.getStringArray(android.R.array.postalAddressTypes)[2];
+ } catch (final Exception e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "createContactItem postal Exception:" + e);
+ type = resources.getStringArray(android.R.array.postalAddressTypes)[2];
+ }
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ final String address = formatAddress(postalData);
+ try {
+ intent.setData(Uri.parse("geo:0,0?q=" + URLEncoder.encode(address, "UTF-8")));
+ } catch (UnsupportedEncodingException e) {
+ intent = null;
+ }
+
+ retList.add(new VCardResourceEntryDestinationItem(address, type, intent));
+ }
+ }
+
+ if (vcard.getImList() != null) {
+ for (final ImData imData : vcard.getImList()) {
+ String type = null;
+ try {
+ type = resources.
+ getString(Im.getProtocolLabelResource(imData.getProtocol()));
+ } catch (final NotFoundException ex) {
+ // Do nothing since this implies an empty label.
+ }
+ retList.add(new VCardResourceEntryDestinationItem(imData.getAddress(), type, null));
+ }
+ }
+
+ if (vcard.getOrganizationList() != null) {
+ for (final OrganizationData organtization : vcard.getOrganizationList()) {
+ String type = null;
+ try {
+ type = resources.getString(Organization.getTypeLabelResource(
+ organtization.getType()));
+ } catch (final NotFoundException ex) {
+ //set other kind as "other"
+ type = resources.getStringArray(android.R.array.organizationTypes)[1];
+ } catch (final Exception e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "createContactItem org Exception:" + e);
+ type = resources.getStringArray(android.R.array.organizationTypes)[1];
+ }
+ retList.add(new VCardResourceEntryDestinationItem(
+ organtization.getOrganizationName(), type, null));
+ }
+ }
+
+ if (vcard.getWebsiteList() != null) {
+ for (final WebsiteData web : vcard.getWebsiteList()) {
+ if (web != null && TextUtils.isGraphic(web.getWebsite())){
+ String website = web.getWebsite();
+ if (!website.startsWith("http://") && !website.startsWith("https://")) {
+ // Prefix required for parsing to end up with a scheme and result in
+ // navigation
+ website = "http://" + website;
+ }
+ final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(website));
+ retList.add(new VCardResourceEntryDestinationItem(web.getWebsite(), null,
+ intent));
+ }
+ }
+ }
+
+ if (vcard.getBirthday() != null) {
+ final String birthday = vcard.getBirthday();
+ if (TextUtils.isGraphic(birthday)){
+ retList.add(new VCardResourceEntryDestinationItem(birthday,
+ resources.getString(R.string.vcard_detail_birthday_label), null));
+ }
+ }
+
+ if (vcard.getNotes() != null) {
+ for (final NoteData note : vcard.getNotes()) {
+ final ArrayMap<String, String> curChildMap = new ArrayMap<String, String>();
+ if (TextUtils.isGraphic(note.getNote())){
+ retList.add(new VCardResourceEntryDestinationItem(note.getNote(),
+ resources.getString(R.string.vcard_detail_notes_label), null));
+ }
+ }
+ }
+ return retList;
+ }
+
+ private static String formatAddress(final PostalData postalData) {
+ final StringBuilder sb = new StringBuilder();
+ final String poBox = postalData.getPobox();
+ if (!TextUtils.isEmpty(poBox)) {
+ sb.append(poBox);
+ sb.append(" ");
+ }
+ final String extendedAddress = postalData.getExtendedAddress();
+ if (!TextUtils.isEmpty(extendedAddress)) {
+ sb.append(extendedAddress);
+ sb.append(" ");
+ }
+ final String street = postalData.getStreet();
+ if (!TextUtils.isEmpty(street)) {
+ sb.append(street);
+ sb.append(" ");
+ }
+ final String localty = postalData.getLocalty();
+ if (!TextUtils.isEmpty(localty)) {
+ sb.append(localty);
+ sb.append(" ");
+ }
+ final String region = postalData.getRegion();
+ if (!TextUtils.isEmpty(region)) {
+ sb.append(region);
+ sb.append(" ");
+ }
+ final String postalCode = postalData.getPostalCode();
+ if (!TextUtils.isEmpty(postalCode)) {
+ sb.append(postalCode);
+ sb.append(" ");
+ }
+ final String country = postalData.getCountry();
+ if (!TextUtils.isEmpty(country)) {
+ sb.append(country);
+ }
+ return sb.toString();
+ }
+
+ private static String getDisplayNameFromVCardEntry(final VCardEntry vcard) {
+ String name = vcard.getDisplayName();
+ if (name == null) {
+ vcard.consolidateFields();
+ name = vcard.getDisplayName();
+ }
+ return name;
+ }
+
+ /**
+ * Represents one entry line (e.g. phone number and phone label) for a single contact. Each
+ * VCardResourceEntry may hold one or more VCardResourceEntryDestinationItem's.
+ */
+ public static class VCardResourceEntryDestinationItem {
+ private final String mDisplayDestination;
+ private final String mDestinationType;
+ private final Intent mClickIntent;
+ public VCardResourceEntryDestinationItem(final String displayDestination,
+ final String destinationType, final Intent clickIntent) {
+ mDisplayDestination = displayDestination;
+ mDestinationType = destinationType;
+ mClickIntent = clickIntent;
+ }
+
+ /**
+ * Returns a UI-facing representation that can be bound and consumed by the UI layer to
+ * display this VCard resource destination entry.
+ */
+ public PersonItemData getDisplayItem() {
+ return new PersonItemData() {
+ @Override
+ public Uri getAvatarUri() {
+ return null;
+ }
+
+ @Override
+ public String getDisplayName() {
+ return mDisplayDestination;
+ }
+
+ @Override
+ public String getDetails() {
+ return mDestinationType;
+ }
+
+ @Override
+ public Intent getClickIntent() {
+ return mClickIntent;
+ }
+
+ @Override
+ public long getContactId() {
+ return ContactUtil.INVALID_CONTACT_ID;
+ }
+
+ @Override
+ public String getLookupKey() {
+ return null;
+ }
+
+ @Override
+ public String getNormalizedDestination() {
+ return null;
+ }
+ };
+ }
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java b/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java
new file mode 100644
index 0000000..f17591c
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.provider.MediaStore.Video.Thumbnails;
+
+import com.android.messaging.Factory;
+import com.android.messaging.util.MediaMetadataRetrieverWrapper;
+import com.android.messaging.util.OsUtil;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Class to request a video thumbnail.
+ * Users of this class as responsible for checking {@link #shouldShowIncomingVideoThumbnails}
+ */
+public class VideoThumbnailRequest extends ImageRequest<UriImageRequestDescriptor> {
+
+ public VideoThumbnailRequest(final Context context,
+ final UriImageRequestDescriptor descriptor) {
+ super(context, descriptor);
+ }
+
+ public static boolean shouldShowIncomingVideoThumbnails() {
+ return OsUtil.isAtLeastM();
+ }
+
+ @Override
+ protected InputStream getInputStreamForResource() throws FileNotFoundException {
+ return null;
+ }
+
+ @Override
+ protected boolean hasBitmapObject() {
+ return true;
+ }
+
+ @Override
+ protected Bitmap getBitmapForResource() throws IOException {
+ final Long mediaId = mDescriptor.getMediaStoreId();
+ Bitmap bitmap = null;
+ if (mediaId != null) {
+ final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
+ bitmap = Thumbnails.getThumbnail(cr, mediaId, Thumbnails.MICRO_KIND, null);
+ } else {
+ final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper();
+ try {
+ retriever.setDataSource(mDescriptor.uri);
+ bitmap = retriever.getFrameAtTime();
+ } finally {
+ retriever.release();
+ }
+ }
+ if (bitmap != null) {
+ mDescriptor.updateSourceDimensions(bitmap.getWidth(), bitmap.getHeight());
+ }
+ return bitmap;
+ }
+}
diff --git a/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java b/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java
new file mode 100644
index 0000000..907bb8f
--- /dev/null
+++ b/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java
@@ -0,0 +1,44 @@
+/*
+ * 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.messaging.datamodel.media;
+
+import android.content.Context;
+
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.UriUtil;
+
+public class VideoThumbnailRequestDescriptor extends UriImageRequestDescriptor {
+ protected final long mMediaId;
+ public VideoThumbnailRequestDescriptor(final long id, String path, int desiredWidth,
+ int desiredHeight, int sourceWidth, int sourceHeight) {
+ super(UriUtil.getUriForResourceFile(path), desiredWidth, desiredHeight, sourceWidth,
+ sourceHeight, false /* canCompress */, false /* isStatic */,
+ false /* cropToCircle */,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ mMediaId = id;
+ }
+
+ @Override
+ public MediaRequest<ImageResource> buildSyncMediaRequest(Context context) {
+ return new VideoThumbnailRequest(context, this);
+ }
+
+ @Override
+ public Long getMediaStoreId() {
+ return mMediaId;
+ }
+}
diff --git a/src/com/android/messaging/mmslib/Downloads.java b/src/com/android/messaging/mmslib/Downloads.java
new file mode 100644
index 0000000..9afc48c
--- /dev/null
+++ b/src/com/android/messaging/mmslib/Downloads.java
@@ -0,0 +1,807 @@
+/*
+ * Copyright (C) 2008 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.messaging.mmslib;
+
+import android.app.DownloadManager;
+import android.content.Context;
+import android.net.Uri;
+import android.provider.BaseColumns;
+
+/**
+ * The Download Manager
+ *
+ * @pending
+ */
+public final class Downloads {
+ private Downloads() {}
+
+ /**
+ * Implementation details
+ *
+ * Exposes constants used to interact with the download manager's
+ * content provider.
+ * The constants URI ... STATUS are the names of columns in the downloads table.
+ *
+ * @hide
+ */
+ public static final class Impl implements BaseColumns {
+ private Impl() {}
+
+ /**
+ * The permission to access the download manager
+ */
+ public static final String PERMISSION_ACCESS = "android.permission.ACCESS_DOWNLOAD_MANAGER";
+
+ /**
+ * The permission to access the download manager's advanced functions
+ */
+ public static final String PERMISSION_ACCESS_ADVANCED =
+ "android.permission.ACCESS_DOWNLOAD_MANAGER_ADVANCED";
+
+ /**
+ * The permission to access the all the downloads in the manager.
+ */
+ public static final String PERMISSION_ACCESS_ALL =
+ "android.permission.ACCESS_ALL_DOWNLOADS";
+
+ /**
+ * The permission to directly access the download manager's cache
+ * directory
+ */
+ public static final String PERMISSION_CACHE = "android.permission.ACCESS_CACHE_FILESYSTEM";
+
+ /**
+ * The permission to send broadcasts on download completion
+ */
+ public static final String PERMISSION_SEND_INTENTS =
+ "android.permission.SEND_DOWNLOAD_COMPLETED_INTENTS";
+
+ /**
+ * The permission to download files to the cache partition that won't be automatically
+ * purged when space is needed.
+ */
+ public static final String PERMISSION_CACHE_NON_PURGEABLE =
+ "android.permission.DOWNLOAD_CACHE_NON_PURGEABLE";
+
+ /**
+ * The permission to download files without any system notification being shown.
+ */
+ public static final String PERMISSION_NO_NOTIFICATION =
+ "android.permission.DOWNLOAD_WITHOUT_NOTIFICATION";
+
+ /**
+ * The content:// URI to access downloads owned by the caller's UID.
+ */
+ public static final Uri CONTENT_URI =
+ Uri.parse("content://downloads/my_downloads");
+
+ /**
+ * The content URI for accessing all downloads across all UIDs (requires the
+ * ACCESS_ALL_DOWNLOADS permission).
+ */
+ public static final Uri ALL_DOWNLOADS_CONTENT_URI =
+ Uri.parse("content://downloads/all_downloads");
+
+ /** URI segment to access a publicly accessible downloaded file */
+ public static final String PUBLICLY_ACCESSIBLE_DOWNLOADS_URI_SEGMENT = "public_downloads";
+
+ /**
+ * The content URI for accessing publicly accessible downloads (i.e., it requires no
+ * permissions to access this downloaded file)
+ */
+ public static final Uri PUBLICLY_ACCESSIBLE_DOWNLOADS_URI =
+ Uri.parse("content://downloads/" + PUBLICLY_ACCESSIBLE_DOWNLOADS_URI_SEGMENT);
+
+ /**
+ * Broadcast Action: this is sent by the download manager to the app
+ * that had initiated a download when that download completes. The
+ * download's content: uri is specified in the intent's data.
+ */
+ public static final String ACTION_DOWNLOAD_COMPLETED =
+ "android.intent.action.DOWNLOAD_COMPLETED";
+
+ /**
+ * Broadcast Action: this is sent by the download manager to the app
+ * that had initiated a download when the user selects the notification
+ * associated with that download. The download's content: uri is specified
+ * in the intent's data if the click is associated with a single download,
+ * or Downloads.CONTENT_URI if the notification is associated with
+ * multiple downloads.
+ * Note: this is not currently sent for downloads that have completed
+ * successfully.
+ */
+ public static final String ACTION_NOTIFICATION_CLICKED =
+ "android.intent.action.DOWNLOAD_NOTIFICATION_CLICKED";
+
+ /**
+ * The name of the column containing the URI of the data being downloaded.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init/Read</P>
+ */
+ public static final String COLUMN_URI = "uri";
+
+ /**
+ * The name of the column containing application-specific data.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init/Read/Write</P>
+ */
+ public static final String COLUMN_APP_DATA = "entity";
+
+ /**
+ * The name of the column containing the flags that indicates whether
+ * the initiating application is capable of verifying the integrity of
+ * the downloaded file. When this flag is set, the download manager
+ * performs downloads and reports success even in some situations where
+ * it can't guarantee that the download has completed (e.g. when doing
+ * a byte-range request without an ETag, or when it can't determine
+ * whether a download fully completed).
+ * <P>Type: BOOLEAN</P>
+ * <P>Owner can Init</P>
+ */
+ public static final String COLUMN_NO_INTEGRITY = "no_integrity";
+
+ /**
+ * The name of the column containing the filename that the initiating
+ * application recommends. When possible, the download manager will attempt
+ * to use this filename, or a variation, as the actual name for the file.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init</P>
+ */
+ public static final String COLUMN_FILE_NAME_HINT = "hint";
+
+ /**
+ * The name of the column containing the filename where the downloaded data
+ * was actually stored.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Read</P>
+ */
+ public static final String _DATA = "_data";
+
+ /**
+ * The name of the column containing the MIME type of the downloaded data.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init/Read</P>
+ */
+ public static final String COLUMN_MIME_TYPE = "mimetype";
+
+ /**
+ * The name of the column containing the flag that controls the destination
+ * of the download. See the DESTINATION_* constants for a list of legal values.
+ * <P>Type: INTEGER</P>
+ * <P>Owner can Init</P>
+ */
+ public static final String COLUMN_DESTINATION = "destination";
+
+ /**
+ * The name of the column containing the flags that controls whether the
+ * download is displayed by the UI. See the VISIBILITY_* constants for
+ * a list of legal values.
+ * <P>Type: INTEGER</P>
+ * <P>Owner can Init/Read/Write</P>
+ */
+ public static final String COLUMN_VISIBILITY = "visibility";
+
+ /**
+ * The name of the column containing the current control state of the download.
+ * Applications can write to this to control (pause/resume) the download.
+ * the CONTROL_* constants for a list of legal values.
+ * <P>Type: INTEGER</P>
+ * <P>Owner can Read</P>
+ */
+ public static final String COLUMN_CONTROL = "control";
+
+ /**
+ * The name of the column containing the current status of the download.
+ * Applications can read this to follow the progress of each download. See
+ * the STATUS_* constants for a list of legal values.
+ * <P>Type: INTEGER</P>
+ * <P>Owner can Read</P>
+ */
+ public static final String COLUMN_STATUS = "status";
+
+ /**
+ * The name of the column containing the date at which some interesting
+ * status changed in the download. Stored as a System.currentTimeMillis()
+ * value.
+ * <P>Type: BIGINT</P>
+ * <P>Owner can Read</P>
+ */
+ public static final String COLUMN_LAST_MODIFICATION = "lastmod";
+
+ /**
+ * The name of the column containing the package name of the application
+ * that initiating the download. The download manager will send
+ * notifications to a component in this package when the download completes.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init/Read</P>
+ */
+ public static final String COLUMN_NOTIFICATION_PACKAGE = "notificationpackage";
+
+ /**
+ * The name of the column containing the component name of the class that
+ * will receive notifications associated with the download. The
+ * package/class combination is passed to
+ * Intent.setClassName(String,String).
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init/Read</P>
+ */
+ public static final String COLUMN_NOTIFICATION_CLASS = "notificationclass";
+
+ /**
+ * If extras are specified when requesting a download they will be provided in the intent
+ * that is sent to the specified class and package when a download has finished.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init</P>
+ */
+ public static final String COLUMN_NOTIFICATION_EXTRAS = "notificationextras";
+
+ /**
+ * The name of the column contain the values of the cookie to be used for
+ * the download. This is used directly as the value for the Cookie: HTTP
+ * header that gets sent with the request.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init</P>
+ */
+ public static final String COLUMN_COOKIE_DATA = "cookiedata";
+
+ /**
+ * The name of the column containing the user agent that the initiating
+ * application wants the download manager to use for this download.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init</P>
+ */
+ public static final String COLUMN_USER_AGENT = "useragent";
+
+ /**
+ * The name of the column containing the referer (sic) that the initiating
+ * application wants the download manager to use for this download.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init</P>
+ */
+ public static final String COLUMN_REFERER = "referer";
+
+ /**
+ * The name of the column containing the total size of the file being
+ * downloaded.
+ * <P>Type: INTEGER</P>
+ * <P>Owner can Read</P>
+ */
+ public static final String COLUMN_TOTAL_BYTES = "total_bytes";
+
+ /**
+ * The name of the column containing the size of the part of the file that
+ * has been downloaded so far.
+ * <P>Type: INTEGER</P>
+ * <P>Owner can Read</P>
+ */
+ public static final String COLUMN_CURRENT_BYTES = "current_bytes";
+
+ /**
+ * The name of the column where the initiating application can provide the
+ * UID of another application that is allowed to access this download. If
+ * multiple applications share the same UID, all those applications will be
+ * allowed to access this download. This column can be updated after the
+ * download is initiated. This requires the permission
+ * android.permission.ACCESS_DOWNLOAD_MANAGER_ADVANCED.
+ * <P>Type: INTEGER</P>
+ * <P>Owner can Init</P>
+ */
+ public static final String COLUMN_OTHER_UID = "otheruid";
+
+ /**
+ * The name of the column where the initiating application can provided the
+ * title of this download. The title will be displayed ito the user in the
+ * list of downloads.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init/Read/Write</P>
+ */
+ public static final String COLUMN_TITLE = "title";
+
+ /**
+ * The name of the column where the initiating application can provide the
+ * description of this download. The description will be displayed to the
+ * user in the list of downloads.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Init/Read/Write</P>
+ */
+ public static final String COLUMN_DESCRIPTION = "description";
+
+ /**
+ * The name of the column indicating whether the download was requesting through the public
+ * API. This controls some differences in behavior.
+ * <P>Type: BOOLEAN</P>
+ * <P>Owner can Init/Read</P>
+ */
+ public static final String COLUMN_IS_PUBLIC_API = "is_public_api";
+
+ /**
+ * The name of the column holding a bitmask of allowed network types. This is only used for
+ * public API downloads.
+ * <P>Type: INTEGER</P>
+ * <P>Owner can Init/Read</P>
+ */
+ public static final String COLUMN_ALLOWED_NETWORK_TYPES = "allowed_network_types";
+
+ /**
+ * The name of the column indicating whether roaming connections can be used. This is only
+ * used for public API downloads.
+ * <P>Type: BOOLEAN</P>
+ * <P>Owner can Init/Read</P>
+ */
+ public static final String COLUMN_ALLOW_ROAMING = "allow_roaming";
+
+ /**
+ * The name of the column indicating whether metered connections can be used. This is only
+ * used for public API downloads.
+ * <P>Type: BOOLEAN</P>
+ * <P>Owner can Init/Read</P>
+ */
+ public static final String COLUMN_ALLOW_METERED = "allow_metered";
+
+ /**
+ * Whether or not this download should be displayed in the system's Downloads UI. Defaults
+ * to true.
+ * <P>Type: INTEGER</P>
+ * <P>Owner can Init/Read</P>
+ */
+ public static final String COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI = "is_visible_in_downloads_ui";
+
+ /**
+ * If true, the user has confirmed that this download can proceed over the mobile network
+ * even though it exceeds the recommended maximum size.
+ * <P>Type: BOOLEAN</P>
+ */
+ public static final String COLUMN_BYPASS_RECOMMENDED_SIZE_LIMIT =
+ "bypass_recommended_size_limit";
+
+ /**
+ * Set to true if this download is deleted. It is completely removed from the database
+ * when MediaProvider database also deletes the metadata asociated with this downloaded
+ * file.
+ * <P>Type: BOOLEAN</P>
+ * <P>Owner can Read</P>
+ */
+ public static final String COLUMN_DELETED = "deleted";
+
+ /**
+ * The URI to the corresponding entry in MediaProvider for this downloaded entry. It is
+ * used to delete the entries from MediaProvider database when it is deleted from the
+ * downloaded list.
+ * <P>Type: TEXT</P>
+ * <P>Owner can Read</P>
+ */
+ public static final String COLUMN_MEDIAPROVIDER_URI = "mediaprovider_uri";
+
+ /**
+ * The column that is used to remember whether the media scanner was invoked.
+ * It can take the values: null or 0(not scanned), 1(scanned), 2 (not scannable).
+ * <P>Type: TEXT</P>
+ */
+ public static final String COLUMN_MEDIA_SCANNED = "scanned";
+
+ /**
+ * The column with errorMsg for a failed downloaded.
+ * Used only for debugging purposes.
+ * <P>Type: TEXT</P>
+ */
+ public static final String COLUMN_ERROR_MSG = "errorMsg";
+
+ /**
+ * This column stores the source of the last update to this row.
+ * This column is only for internal use.
+ * Valid values are indicated by LAST_UPDATESRC_* constants.
+ * <P>Type: INT</P>
+ */
+ public static final String COLUMN_LAST_UPDATESRC = "lastUpdateSrc";
+
+ /** The column that is used to count retries */
+ public static final String COLUMN_FAILED_CONNECTIONS = "numfailed";
+
+ /**
+ * default value for {@link #COLUMN_LAST_UPDATESRC}.
+ * This value is used when this column's value is not relevant.
+ */
+ public static final int LAST_UPDATESRC_NOT_RELEVANT = 0;
+
+ /**
+ * One of the values taken by {@link #COLUMN_LAST_UPDATESRC}.
+ * This value is used when the update is NOT to be relayed to the DownloadService
+ * (and thus spare DownloadService from scanning the database when this change occurs)
+ */
+ public static final int LAST_UPDATESRC_DONT_NOTIFY_DOWNLOADSVC = 1;
+
+ /*
+ * Lists the destinations that an application can specify for a download.
+ */
+
+ /**
+ * This download will be saved to the external storage. This is the
+ * default behavior, and should be used for any file that the user
+ * can freely access, copy, delete. Even with that destination,
+ * unencrypted DRM files are saved in secure internal storage.
+ * Downloads to the external destination only write files for which
+ * there is a registered handler. The resulting files are accessible
+ * by filename to all applications.
+ */
+ public static final int DESTINATION_EXTERNAL = 0;
+
+ /**
+ * This download will be saved to the download manager's private
+ * partition. This is the behavior used by applications that want to
+ * download private files that are used and deleted soon after they
+ * get downloaded. All file types are allowed, and only the initiating
+ * application can access the file (indirectly through a content
+ * provider). This requires the
+ * android.permission.ACCESS_DOWNLOAD_MANAGER_ADVANCED permission.
+ */
+ public static final int DESTINATION_CACHE_PARTITION = 1;
+
+ /**
+ * This download will be saved to the download manager's private
+ * partition and will be purged as necessary to make space. This is
+ * for private files (similar to CACHE_PARTITION) that aren't deleted
+ * immediately after they are used, and are kept around by the download
+ * manager as long as space is available.
+ */
+ public static final int DESTINATION_CACHE_PARTITION_PURGEABLE = 2;
+
+ /**
+ * This download will be saved to the download manager's private
+ * partition, as with DESTINATION_CACHE_PARTITION, but the download
+ * will not proceed if the user is on a roaming data connection.
+ */
+ public static final int DESTINATION_CACHE_PARTITION_NOROAMING = 3;
+
+ /**
+ * This download will be saved to the location given by the file URI in
+ * {@link #COLUMN_FILE_NAME_HINT}.
+ */
+ public static final int DESTINATION_FILE_URI = 4;
+
+ /**
+ * This download will be saved to the system cache ("/cache")
+ * partition. This option is only used by system apps and so it requires
+ * android.permission.ACCESS_CACHE_FILESYSTEM permission.
+ */
+ public static final int DESTINATION_SYSTEMCACHE_PARTITION = 5;
+
+ /**
+ * This download was completed by the caller (i.e., NOT downloadmanager)
+ * and caller wants to have this download displayed in Downloads App.
+ */
+ public static final int DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD = 6;
+
+ /**
+ * This download is allowed to run.
+ */
+ public static final int CONTROL_RUN = 0;
+
+ /**
+ * This download must pause at the first opportunity.
+ */
+ public static final int CONTROL_PAUSED = 1;
+
+ /*
+ * Lists the states that the download manager can set on a download
+ * to notify applications of the download progress.
+ * The codes follow the HTTP families:<br>
+ * 1xx: informational<br>
+ * 2xx: success<br>
+ * 3xx: redirects (not used by the download manager)<br>
+ * 4xx: client errors<br>
+ * 5xx: server errors
+ */
+
+ /**
+ * Returns whether the status is informational (i.e. 1xx).
+ */
+ public static boolean isStatusInformational(int status) {
+ return (status >= 100 && status < 200);
+ }
+
+ /**
+ * Returns whether the status is a success (i.e. 2xx).
+ */
+ public static boolean isStatusSuccess(int status) {
+ return (status >= 200 && status < 300);
+ }
+
+ /**
+ * Returns whether the status is an error (i.e. 4xx or 5xx).
+ */
+ public static boolean isStatusError(int status) {
+ return (status >= 400 && status < 600);
+ }
+
+ /**
+ * Returns whether the status is a client error (i.e. 4xx).
+ */
+ public static boolean isStatusClientError(int status) {
+ return (status >= 400 && status < 500);
+ }
+
+ /**
+ * Returns whether the status is a server error (i.e. 5xx).
+ */
+ public static boolean isStatusServerError(int status) {
+ return (status >= 500 && status < 600);
+ }
+
+ /**
+ * this method determines if a notification should be displayed for a
+ * given {@link #COLUMN_VISIBILITY} value
+ * @param visibility the value of {@link #COLUMN_VISIBILITY}.
+ * @return true if the notification should be displayed. false otherwise.
+ */
+ public static boolean isNotificationToBeDisplayed(int visibility) {
+ return visibility == DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED ||
+ visibility == DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION;
+ }
+
+ /**
+ * Returns whether the download has completed (either with success or
+ * error).
+ */
+ public static boolean isStatusCompleted(int status) {
+ return (status >= 200 && status < 300) || (status >= 400 && status < 600);
+ }
+
+ /**
+ * This download hasn't stated yet
+ */
+ public static final int STATUS_PENDING = 190;
+
+ /**
+ * This download has started
+ */
+ public static final int STATUS_RUNNING = 192;
+
+ /**
+ * This download has been paused by the owning app.
+ */
+ public static final int STATUS_PAUSED_BY_APP = 193;
+
+ /**
+ * This download encountered some network error and is waiting before retrying the request.
+ */
+ public static final int STATUS_WAITING_TO_RETRY = 194;
+
+ /**
+ * This download is waiting for network connectivity to proceed.
+ */
+ public static final int STATUS_WAITING_FOR_NETWORK = 195;
+
+ /**
+ * This download exceeded a size limit for mobile networks and is waiting for a Wi-Fi
+ * connection to proceed.
+ */
+ public static final int STATUS_QUEUED_FOR_WIFI = 196;
+
+ /**
+ * This download couldn't be completed due to insufficient storage
+ * space. Typically, this is because the SD card is full.
+ */
+ public static final int STATUS_INSUFFICIENT_SPACE_ERROR = 198;
+
+ /**
+ * This download couldn't be completed because no external storage
+ * device was found. Typically, this is because the SD card is not
+ * mounted.
+ */
+ public static final int STATUS_DEVICE_NOT_FOUND_ERROR = 199;
+
+ /**
+ * This download has successfully completed.
+ * Warning: there might be other status values that indicate success
+ * in the future.
+ * Use isSucccess() to capture the entire category.
+ */
+ public static final int STATUS_SUCCESS = 200;
+
+ /**
+ * This request couldn't be parsed. This is also used when processing
+ * requests with unknown/unsupported URI schemes.
+ */
+ public static final int STATUS_BAD_REQUEST = 400;
+
+ /**
+ * This download can't be performed because the content type cannot be
+ * handled.
+ */
+ public static final int STATUS_NOT_ACCEPTABLE = 406;
+
+ /**
+ * This download cannot be performed because the length cannot be
+ * determined accurately. This is the code for the HTTP error "Length
+ * Required", which is typically used when making requests that require
+ * a content length but don't have one, and it is also used in the
+ * client when a response is received whose length cannot be determined
+ * accurately (therefore making it impossible to know when a download
+ * completes).
+ */
+ public static final int STATUS_LENGTH_REQUIRED = 411;
+
+ /**
+ * This download was interrupted and cannot be resumed.
+ * This is the code for the HTTP error "Precondition Failed", and it is
+ * also used in situations where the client doesn't have an ETag at all.
+ */
+ public static final int STATUS_PRECONDITION_FAILED = 412;
+
+ /**
+ * The lowest-valued error status that is not an actual HTTP status code.
+ */
+ public static final int MIN_ARTIFICIAL_ERROR_STATUS = 488;
+
+ /**
+ * The requested destination file already exists.
+ */
+ public static final int STATUS_FILE_ALREADY_EXISTS_ERROR = 488;
+
+ /**
+ * Some possibly transient error occurred, but we can't resume the download.
+ */
+ public static final int STATUS_CANNOT_RESUME = 489;
+
+ /**
+ * This download was canceled
+ */
+ public static final int STATUS_CANCELED = 490;
+
+ /**
+ * This download has completed with an error.
+ * Warning: there will be other status values that indicate errors in
+ * the future. Use isStatusError() to capture the entire category.
+ */
+ public static final int STATUS_UNKNOWN_ERROR = 491;
+
+ /**
+ * This download couldn't be completed because of a storage issue.
+ * Typically, that's because the filesystem is missing or full.
+ * Use the more specific {@link #STATUS_INSUFFICIENT_SPACE_ERROR}
+ * and {@link #STATUS_DEVICE_NOT_FOUND_ERROR} when appropriate.
+ */
+ public static final int STATUS_FILE_ERROR = 492;
+
+ /**
+ * This download couldn't be completed because of an HTTP
+ * redirect response that the download manager couldn't
+ * handle.
+ */
+ public static final int STATUS_UNHANDLED_REDIRECT = 493;
+
+ /**
+ * This download couldn't be completed because of an
+ * unspecified unhandled HTTP code.
+ */
+ public static final int STATUS_UNHANDLED_HTTP_CODE = 494;
+
+ /**
+ * This download couldn't be completed because of an
+ * error receiving or processing data at the HTTP level.
+ */
+ public static final int STATUS_HTTP_DATA_ERROR = 495;
+
+ /**
+ * This download couldn't be completed because of an
+ * HttpException while setting up the request.
+ */
+ public static final int STATUS_HTTP_EXCEPTION = 496;
+
+ /**
+ * This download couldn't be completed because there were
+ * too many redirects.
+ */
+ public static final int STATUS_TOO_MANY_REDIRECTS = 497;
+
+ /**
+ * This download has failed because requesting application has been
+ * blocked by {@link NetworkPolicyManager}.
+ *
+ * @hide
+ * @deprecated since behavior now uses
+ * {@link #STATUS_WAITING_FOR_NETWORK}
+ */
+ @Deprecated
+ public static final int STATUS_BLOCKED = 498;
+
+ /** {@hide} */
+ public static String statusToString(int status) {
+ switch (status) {
+ case STATUS_PENDING: return "PENDING";
+ case STATUS_RUNNING: return "RUNNING";
+ case STATUS_PAUSED_BY_APP: return "PAUSED_BY_APP";
+ case STATUS_WAITING_TO_RETRY: return "WAITING_TO_RETRY";
+ case STATUS_WAITING_FOR_NETWORK: return "WAITING_FOR_NETWORK";
+ case STATUS_QUEUED_FOR_WIFI: return "QUEUED_FOR_WIFI";
+ case STATUS_INSUFFICIENT_SPACE_ERROR: return "INSUFFICIENT_SPACE_ERROR";
+ case STATUS_DEVICE_NOT_FOUND_ERROR: return "DEVICE_NOT_FOUND_ERROR";
+ case STATUS_SUCCESS: return "SUCCESS";
+ case STATUS_BAD_REQUEST: return "BAD_REQUEST";
+ case STATUS_NOT_ACCEPTABLE: return "NOT_ACCEPTABLE";
+ case STATUS_LENGTH_REQUIRED: return "LENGTH_REQUIRED";
+ case STATUS_PRECONDITION_FAILED: return "PRECONDITION_FAILED";
+ case STATUS_FILE_ALREADY_EXISTS_ERROR: return "FILE_ALREADY_EXISTS_ERROR";
+ case STATUS_CANNOT_RESUME: return "CANNOT_RESUME";
+ case STATUS_CANCELED: return "CANCELED";
+ case STATUS_UNKNOWN_ERROR: return "UNKNOWN_ERROR";
+ case STATUS_FILE_ERROR: return "FILE_ERROR";
+ case STATUS_UNHANDLED_REDIRECT: return "UNHANDLED_REDIRECT";
+ case STATUS_UNHANDLED_HTTP_CODE: return "UNHANDLED_HTTP_CODE";
+ case STATUS_HTTP_DATA_ERROR: return "HTTP_DATA_ERROR";
+ case STATUS_HTTP_EXCEPTION: return "HTTP_EXCEPTION";
+ case STATUS_TOO_MANY_REDIRECTS: return "TOO_MANY_REDIRECTS";
+ case STATUS_BLOCKED: return "BLOCKED";
+ default: return Integer.toString(status);
+ }
+ }
+
+ /**
+ * This download is visible but only shows in the notifications
+ * while it's in progress.
+ */
+ public static final int VISIBILITY_VISIBLE = DownloadManager.Request.VISIBILITY_VISIBLE;
+
+ /**
+ * This download is visible and shows in the notifications while
+ * in progress and after completion.
+ */
+ public static final int VISIBILITY_VISIBLE_NOTIFY_COMPLETED =
+ DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED;
+
+ /**
+ * This download doesn't show in the UI or in the notifications.
+ */
+ public static final int VISIBILITY_HIDDEN = DownloadManager.Request.VISIBILITY_HIDDEN;
+
+ /**
+ * Constants related to HTTP request headers associated with each download.
+ */
+ public static class RequestHeaders {
+ public static final String HEADERS_DB_TABLE = "request_headers";
+ public static final String COLUMN_DOWNLOAD_ID = "download_id";
+ public static final String COLUMN_HEADER = "header";
+ public static final String COLUMN_VALUE = "value";
+
+ /**
+ * Path segment to add to a download URI to retrieve request headers
+ */
+ public static final String URI_SEGMENT = "headers";
+
+ /**
+ * Prefix for ContentValues keys that contain HTTP header lines, to be passed to
+ * DownloadProvider.insert().
+ */
+ public static final String INSERT_KEY_PREFIX = "http_header_";
+ }
+ }
+
+ /**
+ * Query where clause for general querying.
+ */
+ private static final String QUERY_WHERE_CLAUSE = Impl.COLUMN_NOTIFICATION_PACKAGE + "=? AND "
+ + Impl.COLUMN_NOTIFICATION_CLASS + "=?";
+
+ /**
+ * Delete all the downloads for a package/class pair.
+ */
+ public static final void removeAllDownloadsByPackage(
+ Context context, String notificationPackage, String notificationClass) {
+ context.getContentResolver().delete(Impl.CONTENT_URI, QUERY_WHERE_CLAUSE,
+ new String[] { notificationPackage, notificationClass });
+ }
+}
diff --git a/src/com/android/messaging/mmslib/InvalidHeaderValueException.java b/src/com/android/messaging/mmslib/InvalidHeaderValueException.java
new file mode 100644
index 0000000..c141591
--- /dev/null
+++ b/src/com/android/messaging/mmslib/InvalidHeaderValueException.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib;
+
+/**
+ * Thrown when an invalid header value was set.
+ */
+public class InvalidHeaderValueException extends MmsException {
+ private static final long serialVersionUID = -2053384496042052262L;
+
+ /**
+ * Constructs an InvalidHeaderValueException with no detailed message.
+ */
+ public InvalidHeaderValueException() {
+ super();
+ }
+
+ /**
+ * Constructs an InvalidHeaderValueException with the specified detailed message.
+ *
+ * @param message the detailed message.
+ */
+ public InvalidHeaderValueException(String message) {
+ super(message);
+ }
+}
diff --git a/src/com/android/messaging/mmslib/MmsException.java b/src/com/android/messaging/mmslib/MmsException.java
new file mode 100644
index 0000000..173511d
--- /dev/null
+++ b/src/com/android/messaging/mmslib/MmsException.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib;
+
+/**
+ * A generic exception that is thrown by the Mms client.
+ */
+public class MmsException extends Exception {
+ private static final long serialVersionUID = -7323249827281485390L;
+
+ /**
+ * Creates a new MmsException.
+ */
+ public MmsException() {
+ super();
+ }
+
+ /**
+ * Creates a new MmsException with the specified detail message.
+ *
+ * @param message the detail message.
+ */
+ public MmsException(String message) {
+ super(message);
+ }
+
+ /**
+ * Creates a new MmsException with the specified cause.
+ *
+ * @param cause the cause.
+ */
+ public MmsException(Throwable cause) {
+ super(cause);
+ }
+
+ /**
+ * Creates a new MmsException with the specified detail message and cause.
+ *
+ * @param message the detail message.
+ * @param cause the cause.
+ */
+ public MmsException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/com/android/messaging/mmslib/SqliteWrapper.java b/src/com/android/messaging/mmslib/SqliteWrapper.java
new file mode 100644
index 0000000..8ef0e6f
--- /dev/null
+++ b/src/com/android/messaging/mmslib/SqliteWrapper.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2008 Esmertec AG.
+ * Copyright (C) 2008 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.messaging.mmslib;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteException;
+import android.net.Uri;
+
+import com.android.messaging.util.LogUtil;
+
+// Wrapper around content resolver methods to catch exceptions
+public final class SqliteWrapper {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private SqliteWrapper() {
+ // Forbidden being instantiated.
+ }
+
+ public static Cursor query(Context context, ContentResolver resolver, Uri uri,
+ String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ try {
+ return resolver.query(uri, projection, selection, selectionArgs, sortOrder);
+ } catch (SQLiteException e) {
+ LogUtil.e(TAG, "SqliteWrapper: catch an exception when query", e);
+ return null;
+ } catch (IllegalArgumentException e) {
+ LogUtil.e(TAG, "SqliteWrapper: catch an exception when query", e);
+ return null;
+ }
+ }
+
+ public static int update(Context context, ContentResolver resolver, Uri uri,
+ ContentValues values, String where, String[] selectionArgs) {
+ try {
+ return resolver.update(uri, values, where, selectionArgs);
+ } catch (SQLiteException e) {
+ LogUtil.e(TAG, "SqliteWrapper: catch an exception when update", e);
+ return -1;
+ } catch (IllegalArgumentException e) {
+ LogUtil.e(TAG, "SqliteWrapper: catch an exception when update", e);
+ return -1;
+ }
+ }
+
+ public static int delete(Context context, ContentResolver resolver, Uri uri,
+ String where, String[] selectionArgs) {
+ try {
+ return resolver.delete(uri, where, selectionArgs);
+ } catch (SQLiteException e) {
+ LogUtil.e(TAG, "SqliteWrapper: catch an exception when delete", e);
+ return -1;
+ } catch (IllegalArgumentException e) {
+ LogUtil.e(TAG, "SqliteWrapper: catch an exception when delete", e);
+ return -1;
+ }
+ }
+
+ public static Uri insert(Context context, ContentResolver resolver,
+ Uri uri, ContentValues values) {
+ try {
+ return resolver.insert(uri, values);
+ } catch (SQLiteException e) {
+ LogUtil.e(TAG, "SqliteWrapper: catch an exception when insert", e);
+ return null;
+ } catch (IllegalArgumentException e) {
+ LogUtil.e(TAG, "SqliteWrapper: catch an exception when insert", e);
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/AcknowledgeInd.java b/src/com/android/messaging/mmslib/pdu/AcknowledgeInd.java
new file mode 100644
index 0000000..05494c0
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/AcknowledgeInd.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+/**
+ * M-Acknowledge.ind PDU.
+ */
+public class AcknowledgeInd extends GenericPdu {
+ /**
+ * Constructor, used when composing a M-Acknowledge.ind pdu.
+ *
+ * @param mmsVersion current viersion of mms
+ * @param transactionId the transaction-id value
+ * @throws InvalidHeaderValueException if parameters are invalid.
+ * @throws NullPointerException if transactionId is null.
+ */
+ public AcknowledgeInd(int mmsVersion, byte[] transactionId)
+ throws InvalidHeaderValueException {
+ super();
+
+ setMessageType(PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND);
+ setMmsVersion(mmsVersion);
+ setTransactionId(transactionId);
+ }
+
+ /**
+ * Constructor with given headers.
+ *
+ * @param headers Headers for this PDU.
+ */
+ AcknowledgeInd(PduHeaders headers) {
+ super(headers);
+ }
+
+ /**
+ * Get X-Mms-Report-Allowed field value.
+ *
+ * @return the X-Mms-Report-Allowed value
+ */
+ public int getReportAllowed() {
+ return mPduHeaders.getOctet(PduHeaders.REPORT_ALLOWED);
+ }
+
+ /**
+ * Set X-Mms-Report-Allowed field value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ public void setReportAllowed(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.REPORT_ALLOWED);
+ }
+
+ /**
+ * Get X-Mms-Transaction-Id field value.
+ *
+ * @return the X-Mms-Report-Allowed value
+ */
+ public byte[] getTransactionId() {
+ return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID);
+ }
+
+ /**
+ * Set X-Mms-Transaction-Id field value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setTransactionId(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID);
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/Base64.java b/src/com/android/messaging/mmslib/pdu/Base64.java
new file mode 100644
index 0000000..2f27117
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/Base64.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+public class Base64 {
+ /**
+ * Used to get the number of Quadruples.
+ */
+ static final int FOURBYTE = 4;
+
+ /**
+ * Byte used to pad output.
+ */
+ static final byte PAD = (byte) '=';
+
+ /**
+ * The base length.
+ */
+ static final int BASELENGTH = 255;
+
+ // Create arrays to hold the base64 characters
+ private static byte[] base64Alphabet = new byte[BASELENGTH];
+
+ // Populating the character arrays
+ static {
+ for (int i = 0; i < BASELENGTH; i++) {
+ base64Alphabet[i] = (byte) -1;
+ }
+ for (int i = 'Z'; i >= 'A'; i--) {
+ base64Alphabet[i] = (byte) (i - 'A');
+ }
+ for (int i = 'z'; i >= 'a'; i--) {
+ base64Alphabet[i] = (byte) (i - 'a' + 26);
+ }
+ for (int i = '9'; i >= '0'; i--) {
+ base64Alphabet[i] = (byte) (i - '0' + 52);
+ }
+
+ base64Alphabet['+'] = 62;
+ base64Alphabet['/'] = 63;
+ }
+
+ /**
+ * Decodes Base64 data into octects
+ *
+ * @param base64Data Byte array containing Base64 data
+ * @return Array containing decoded data.
+ */
+ public static byte[] decodeBase64(byte[] base64Data) {
+ // RFC 2045 requires that we discard ALL non-Base64 characters
+ base64Data = discardNonBase64(base64Data);
+
+ // handle the edge case, so we don't have to worry about it later
+ if (base64Data.length == 0) {
+ return new byte[0];
+ }
+
+ int numberQuadruple = base64Data.length / FOURBYTE;
+ byte decodedData[] = null;
+ byte b1 = 0, b2 = 0, b3 = 0, b4 = 0, marker0 = 0, marker1 = 0;
+
+ // Throw away anything not in base64Data
+
+ int encodedIndex = 0;
+ int dataIndex = 0;
+ {
+ // this sizes the output array properly - rlw
+ int lastData = base64Data.length;
+ // ignore the '=' padding
+ while (base64Data[lastData - 1] == PAD) {
+ if (--lastData == 0) {
+ return new byte[0];
+ }
+ }
+ decodedData = new byte[lastData - numberQuadruple];
+ }
+
+ for (int i = 0; i < numberQuadruple; i++) {
+ dataIndex = i * 4;
+ marker0 = base64Data[dataIndex + 2];
+ marker1 = base64Data[dataIndex + 3];
+
+ b1 = base64Alphabet[base64Data[dataIndex]];
+ b2 = base64Alphabet[base64Data[dataIndex + 1]];
+
+ if (marker0 != PAD && marker1 != PAD) {
+ //No PAD e.g 3cQl
+ b3 = base64Alphabet[marker0];
+ b4 = base64Alphabet[marker1];
+
+ decodedData[encodedIndex] = (byte) (b1 << 2 | b2 >> 4);
+ decodedData[encodedIndex + 1] =
+ (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf));
+ decodedData[encodedIndex + 2] = (byte) (b3 << 6 | b4);
+ } else if (marker0 == PAD) {
+ //Two PAD e.g. 3c[Pad][Pad]
+ decodedData[encodedIndex] = (byte) (b1 << 2 | b2 >> 4);
+ } else if (marker1 == PAD) {
+ //One PAD e.g. 3cQ[Pad]
+ b3 = base64Alphabet[marker0];
+
+ decodedData[encodedIndex] = (byte) (b1 << 2 | b2 >> 4);
+ decodedData[encodedIndex + 1] =
+ (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf));
+ }
+ encodedIndex += 3;
+ }
+ return decodedData;
+ }
+
+ /**
+ * Check octect wheter it is a base64 encoding.
+ *
+ * @param octect to be checked byte
+ * @return ture if it is base64 encoding, false otherwise.
+ */
+ private static boolean isBase64(byte octect) {
+ if (octect == PAD) {
+ return true;
+ } else if (base64Alphabet[octect] == -1) {
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Discards any characters outside of the base64 alphabet, per
+ * the requirements on page 25 of RFC 2045 - "Any characters
+ * outside of the base64 alphabet are to be ignored in base64
+ * encoded data."
+ *
+ * @param data The base-64 encoded data to groom
+ * @return The data, less non-base64 characters (see RFC 2045).
+ */
+ static byte[] discardNonBase64(byte[] data) {
+ byte groomedData[] = new byte[data.length];
+ int bytesCopied = 0;
+
+ for (int i = 0; i < data.length; i++) {
+ if (isBase64(data[i])) {
+ groomedData[bytesCopied++] = data[i];
+ }
+ }
+
+ byte packedData[] = new byte[bytesCopied];
+
+ System.arraycopy(groomedData, 0, packedData, 0, bytesCopied);
+
+ return packedData;
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/CharacterSets.java b/src/com/android/messaging/mmslib/pdu/CharacterSets.java
new file mode 100644
index 0000000..0a9099b
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/CharacterSets.java
@@ -0,0 +1,452 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import android.support.v4.util.SimpleArrayMap;
+import android.util.SparseArray;
+
+import java.io.UnsupportedEncodingException;
+
+public class CharacterSets {
+ /**
+ * IANA assigned MIB enum numbers.
+ *
+ * From wap-230-wsp-20010705-a.pdf
+ * Any-charset = <Octet 128>
+ * Equivalent to the special RFC2616 charset value "*"
+ *
+ * Link to all the charsets: http://www.iana.org/assignments/character-sets/character-sets.xhtml
+ * Use Charset.availableCharsets() to see if a potential charset is supported in java:
+ * private void dumpCharsets() {
+ * logvv("dumpCharsets");
+ * SortedMap<String, Charset> charsets = Charset.availableCharsets();
+ * for (Entry<String, Charset> entry : charsets.entrySet()) {
+ * String key = entry.getKey();
+ * Charset value = entry.getValue();
+ * logvv("charset key: " + key + " value: " + value);
+ * }
+ * }
+ *
+ * As of March 21, 2014, here is the list from dumpCharsets:
+ * Adobe-Standard-Encoding value: java.nio.charset.CharsetICU[Adobe-Standard-Encoding]
+ * Big5 value: java.nio.charset.CharsetICU[Big5]
+ * Big5-HKSCS value: java.nio.charset.CharsetICU[Big5-HKSCS]
+ * BOCU-1 value: java.nio.charset.CharsetICU[BOCU-1]
+ * CESU-8 value: java.nio.charset.CharsetICU[CESU-8]
+ * cp1363 value: java.nio.charset.CharsetICU[cp1363]
+ * cp851 value: java.nio.charset.CharsetICU[cp851]
+ * cp864 value: java.nio.charset.CharsetICU[cp864]
+ * EUC-JP value: java.nio.charset.CharsetICU[EUC-JP]
+ * EUC-KR value: java.nio.charset.CharsetICU[EUC-KR]
+ * GB18030 value: java.nio.charset.CharsetICU[GB18030]
+ * GBK value: java.nio.charset.CharsetICU[GBK]
+ * hp-roman8 value: java.nio.charset.CharsetICU[hp-roman8]
+ * HZ-GB-2312 value: java.nio.charset.CharsetICU[HZ-GB-2312]
+ * IBM-Thai value: java.nio.charset.CharsetICU[IBM-Thai]
+ * IBM00858 value: java.nio.charset.CharsetICU[IBM00858]
+ * IBM01140 value: java.nio.charset.CharsetICU[IBM01140]
+ * IBM01141 value: java.nio.charset.CharsetICU[IBM01141]
+ * IBM01142 value: java.nio.charset.CharsetICU[IBM01142]
+ * IBM01143 value: java.nio.charset.CharsetICU[IBM01143]
+ * IBM01144 value: java.nio.charset.CharsetICU[IBM01144]
+ * IBM01145 value: java.nio.charset.CharsetICU[IBM01145]
+ * IBM01146 value: java.nio.charset.CharsetICU[IBM01146]
+ * IBM01147 value: java.nio.charset.CharsetICU[IBM01147]
+ * IBM01148 value: java.nio.charset.CharsetICU[IBM01148]
+ * IBM01149 value: java.nio.charset.CharsetICU[IBM01149]
+ * IBM037 value: java.nio.charset.CharsetICU[IBM037]
+ * IBM1026 value: java.nio.charset.CharsetICU[IBM1026]
+ * IBM1047 value: java.nio.charset.CharsetICU[IBM1047]
+ * IBM273 value: java.nio.charset.CharsetICU[IBM273]
+ * IBM277 value: java.nio.charset.CharsetICU[IBM277]
+ * IBM278 value: java.nio.charset.CharsetICU[IBM278]
+ * IBM280 value: java.nio.charset.CharsetICU[IBM280]
+ * IBM284 value: java.nio.charset.CharsetICU[IBM284]
+ * IBM285 value: java.nio.charset.CharsetICU[IBM285]
+ * IBM290 value: java.nio.charset.CharsetICU[IBM290]
+ * IBM297 value: java.nio.charset.CharsetICU[IBM297]
+ * IBM420 value: java.nio.charset.CharsetICU[IBM420]
+ * IBM424 value: java.nio.charset.CharsetICU[IBM424]
+ * IBM437 value: java.nio.charset.CharsetICU[IBM437]
+ * IBM500 value: java.nio.charset.CharsetICU[IBM500]
+ * IBM775 value: java.nio.charset.CharsetICU[IBM775]
+ * IBM850 value: java.nio.charset.CharsetICU[IBM850]
+ * IBM852 value: java.nio.charset.CharsetICU[IBM852]
+ * IBM855 value: java.nio.charset.CharsetICU[IBM855]
+ * IBM857 value: java.nio.charset.CharsetICU[IBM857]
+ * IBM860 value: java.nio.charset.CharsetICU[IBM860]
+ * IBM861 value: java.nio.charset.CharsetICU[IBM861]
+ * IBM862 value: java.nio.charset.CharsetICU[IBM862]
+ * IBM863 value: java.nio.charset.CharsetICU[IBM863]
+ * IBM865 value: java.nio.charset.CharsetICU[IBM865]
+ * IBM866 value: java.nio.charset.CharsetICU[IBM866]
+ * IBM868 value: java.nio.charset.CharsetICU[IBM868]
+ * IBM869 value: java.nio.charset.CharsetICU[IBM869]
+ * IBM870 value: java.nio.charset.CharsetICU[IBM870]
+ * IBM871 value: java.nio.charset.CharsetICU[IBM871]
+ * IBM918 value: java.nio.charset.CharsetICU[IBM918]
+ * ISO-2022-CN value: java.nio.charset.CharsetICU[ISO-2022-CN]
+ * ISO-2022-CN-EXT value: java.nio.charset.CharsetICU[ISO-2022-CN-EXT]
+ * ISO-2022-JP value: java.nio.charset.CharsetICU[ISO-2022-JP]
+ * ISO-2022-JP-1 value: java.nio.charset.CharsetICU[ISO-2022-JP-1]
+ * ISO-2022-JP-2 value: java.nio.charset.CharsetICU[ISO-2022-JP-2]
+ * ISO-2022-KR value: java.nio.charset.CharsetICU[ISO-2022-KR]
+ * ISO-8859-1 value: java.nio.charset.CharsetICU[ISO-8859-1]
+ * ISO-8859-10 value: java.nio.charset.CharsetICU[ISO-8859-10]
+ * ISO-8859-13 value: java.nio.charset.CharsetICU[ISO-8859-13]
+ * ISO-8859-14 value: java.nio.charset.CharsetICU[ISO-8859-14]
+ * ISO-8859-15 value: java.nio.charset.CharsetICU[ISO-8859-15]
+ * ISO-8859-2 value: java.nio.charset.CharsetICU[ISO-8859-2]
+ * ISO-8859-3 value: java.nio.charset.CharsetICU[ISO-8859-3]
+ * ISO-8859-4 value: java.nio.charset.CharsetICU[ISO-8859-4]
+ * ISO-8859-5 value: java.nio.charset.CharsetICU[ISO-8859-5]
+ * ISO-8859-6 value: java.nio.charset.CharsetICU[ISO-8859-6]
+ * ISO-8859-7 value: java.nio.charset.CharsetICU[ISO-8859-7]
+ * ISO-8859-8 value: java.nio.charset.CharsetICU[ISO-8859-8]
+ * ISO-8859-9 value: java.nio.charset.CharsetICU[ISO-8859-9]
+ * KOI8-R value: java.nio.charset.CharsetICU[KOI8-R]
+ * KOI8-U value: java.nio.charset.CharsetICU[KOI8-U]
+ * macintosh value: java.nio.charset.CharsetICU[macintosh]
+ * SCSU value: java.nio.charset.CharsetICU[SCSU]
+ * Shift_JIS value: java.nio.charset.CharsetICU[Shift_JIS]
+ * TIS-620 value: java.nio.charset.CharsetICU[TIS-620]
+ * US-ASCII value: java.nio.charset.CharsetICU[US-ASCII]
+ * UTF-16 value: java.nio.charset.CharsetICU[UTF-16]
+ * UTF-16BE value: java.nio.charset.CharsetICU[UTF-16BE]
+ * UTF-16LE value: java.nio.charset.CharsetICU[UTF-16LE]
+ * UTF-32 value: java.nio.charset.CharsetICU[UTF-32]
+ * UTF-32BE value: java.nio.charset.CharsetICU[UTF-32BE]
+ * UTF-32LE value: java.nio.charset.CharsetICU[UTF-32LE]
+ * UTF-7 value: java.nio.charset.CharsetICU[UTF-7]
+ * UTF-8 value: java.nio.charset.CharsetICU[UTF-8]
+ * windows-1250 value: java.nio.charset.CharsetICU[windows-1250]
+ * windows-1251 value: java.nio.charset.CharsetICU[windows-1251]
+ * windows-1252 value: java.nio.charset.CharsetICU[windows-1252]
+ * windows-1253 value: java.nio.charset.CharsetICU[windows-1253]
+ * windows-1254 value: java.nio.charset.CharsetICU[windows-1254]
+ * windows-1255 value: java.nio.charset.CharsetICU[windows-1255]
+ * windows-1256 value: java.nio.charset.CharsetICU[windows-1256]
+ * windows-1257 value: java.nio.charset.CharsetICU[windows-1257]
+ * windows-1258 value: java.nio.charset.CharsetICU[windows-1258]
+ * x-compound-text value: java.nio.charset.CharsetICU[x-compound-text]
+ * x-ebcdic-xml-us value: java.nio.charset.CharsetICU[x-ebcdic-xml-us]
+ * x-gsm-03.38-2000 value: java.nio.charset.CharsetICU[x-gsm-03.38-2000]
+ * x-ibm-1047-s390 value: java.nio.charset.CharsetICU[x-ibm-1047-s390]
+ * x-ibm-1125_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1125_P100-1997]
+ * x-ibm-1129_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1129_P100-1997]
+ * x-ibm-1130_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1130_P100-1997]
+ * x-ibm-1131_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1131_P100-1997]
+ * x-ibm-1132_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-1132_P100-1998]
+ * x-ibm-1133_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1133_P100-1997]
+ * x-ibm-1137_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1137_P100-1999]
+ * x-ibm-1140-s390 value: java.nio.charset.CharsetICU[x-ibm-1140-s390]
+ * x-ibm-1141-s390 value: java.nio.charset.CharsetICU[x-ibm-1141-s390]
+ * x-ibm-1142-s390 value: java.nio.charset.CharsetICU[x-ibm-1142-s390]
+ * x-ibm-1143-s390 value: java.nio.charset.CharsetICU[x-ibm-1143-s390]
+ * x-ibm-1144-s390 value: java.nio.charset.CharsetICU[x-ibm-1144-s390]
+ * x-ibm-1145-s390 value: java.nio.charset.CharsetICU[x-ibm-1145-s390]
+ * x-ibm-1146-s390 value: java.nio.charset.CharsetICU[x-ibm-1146-s390]
+ * x-ibm-1147-s390 value: java.nio.charset.CharsetICU[x-ibm-1147-s390]
+ * x-ibm-1148-s390 value: java.nio.charset.CharsetICU[x-ibm-1148-s390]
+ * x-ibm-1149-s390 value: java.nio.charset.CharsetICU[x-ibm-1149-s390]
+ * x-ibm-1153-s390 value: java.nio.charset.CharsetICU[x-ibm-1153-s390]
+ * x-ibm-1154_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1154_P100-1999]
+ * x-ibm-1155_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1155_P100-1999]
+ * x-ibm-1156_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1156_P100-1999]
+ * x-ibm-1157_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1157_P100-1999]
+ * x-ibm-1158_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1158_P100-1999]
+ * x-ibm-1160_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1160_P100-1999]
+ * x-ibm-1162_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1162_P100-1999]
+ * x-ibm-1164_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1164_P100-1999]
+ * x-ibm-1250_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1250_P100-1995]
+ * x-ibm-1251_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1251_P100-1995]
+ * x-ibm-1252_P100-2000 value: java.nio.charset.CharsetICU[x-ibm-1252_P100-2000]
+ * x-ibm-1253_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1253_P100-1995]
+ * x-ibm-1254_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1254_P100-1995]
+ * x-ibm-1255_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1255_P100-1995]
+ * x-ibm-1256_P110-1997 value: java.nio.charset.CharsetICU[x-ibm-1256_P110-1997]
+ * x-ibm-1257_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1257_P100-1995]
+ * x-ibm-1258_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1258_P100-1997]
+ * x-ibm-12712-s390 value: java.nio.charset.CharsetICU[x-ibm-12712-s390]
+ * x-ibm-12712_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-12712_P100-1998]
+ * x-ibm-1373_P100-2002 value: java.nio.charset.CharsetICU[x-ibm-1373_P100-2002]
+ * x-ibm-1383_P110-1999 value: java.nio.charset.CharsetICU[x-ibm-1383_P110-1999]
+ * x-ibm-1386_P100-2001 value: java.nio.charset.CharsetICU[x-ibm-1386_P100-2001]
+ * x-ibm-16684_P110-2003 value: java.nio.charset.CharsetICU[x-ibm-16684_P110-2003]
+ * x-ibm-16804-s390 value: java.nio.charset.CharsetICU[x-ibm-16804-s390]
+ * x-ibm-16804_X110-1999 value: java.nio.charset.CharsetICU[x-ibm-16804_X110-1999]
+ * x-ibm-25546 value: java.nio.charset.CharsetICU[x-ibm-25546]
+ * x-ibm-33722_P12A_P12A-2009_U2 value:
+ * java.nio.charset.CharsetICU[x-ibm-33722_P12A_P12A-2009_U2]
+ * x-ibm-37-s390 value: java.nio.charset.CharsetICU[x-ibm-37-s390]
+ * x-ibm-4517_P100-2005 value: java.nio.charset.CharsetICU[x-ibm-4517_P100-2005]
+ * x-ibm-4899_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-4899_P100-1998]
+ * x-ibm-4909_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-4909_P100-1999]
+ * x-ibm-4971_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-4971_P100-1999]
+ * x-ibm-5123_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-5123_P100-1999]
+ * x-ibm-5351_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-5351_P100-1998]
+ * x-ibm-5352_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-5352_P100-1998]
+ * x-ibm-5353_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-5353_P100-1998]
+ * x-ibm-5478_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-5478_P100-1995]
+ * x-ibm-803_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-803_P100-1999]
+ * x-ibm-813_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-813_P100-1995]
+ * x-ibm-8482_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-8482_P100-1999]
+ * x-ibm-901_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-901_P100-1999]
+ * x-ibm-902_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-902_P100-1999]
+ * x-ibm-9067_X100-2005 value: java.nio.charset.CharsetICU[x-ibm-9067_X100-2005]
+ * x-ibm-916_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-916_P100-1995]
+ * x-IBM1006 value: java.nio.charset.CharsetICU[x-IBM1006]
+ * x-IBM1025 value: java.nio.charset.CharsetICU[x-IBM1025]
+ * x-IBM1097 value: java.nio.charset.CharsetICU[x-IBM1097]
+ * x-IBM1098 value: java.nio.charset.CharsetICU[x-IBM1098]
+ * x-IBM1112 value: java.nio.charset.CharsetICU[x-IBM1112]
+ * x-IBM1122 value: java.nio.charset.CharsetICU[x-IBM1122]
+ * x-IBM1123 value: java.nio.charset.CharsetICU[x-IBM1123]
+ * x-IBM1124 value: java.nio.charset.CharsetICU[x-IBM1124]
+ * x-IBM1153 value: java.nio.charset.CharsetICU[x-IBM1153]
+ * x-IBM1363 value: java.nio.charset.CharsetICU[x-IBM1363]
+ * x-IBM1364 value: java.nio.charset.CharsetICU[x-IBM1364]
+ * x-IBM1371 value: java.nio.charset.CharsetICU[x-IBM1371]
+ * x-IBM1388 value: java.nio.charset.CharsetICU[x-IBM1388]
+ * x-IBM1390 value: java.nio.charset.CharsetICU[x-IBM1390]
+ * x-IBM1399 value: java.nio.charset.CharsetICU[x-IBM1399]
+ * x-IBM33722 value: java.nio.charset.CharsetICU[x-IBM33722]
+ * x-IBM720 value: java.nio.charset.CharsetICU[x-IBM720]
+ * x-IBM737 value: java.nio.charset.CharsetICU[x-IBM737]
+ * x-IBM856 value: java.nio.charset.CharsetICU[x-IBM856]
+ * x-IBM867 value: java.nio.charset.CharsetICU[x-IBM867]
+ * x-IBM875 value: java.nio.charset.CharsetICU[x-IBM875]
+ * x-IBM922 value: java.nio.charset.CharsetICU[x-IBM922]
+ * x-IBM930 value: java.nio.charset.CharsetICU[x-IBM930]
+ * x-IBM933 value: java.nio.charset.CharsetICU[x-IBM933]
+ * x-IBM935 value: java.nio.charset.CharsetICU[x-IBM935]
+ * x-IBM937 value: java.nio.charset.CharsetICU[x-IBM937]
+ * x-IBM939 value: java.nio.charset.CharsetICU[x-IBM939]
+ * x-IBM942 value: java.nio.charset.CharsetICU[x-IBM942]
+ * x-IBM943 value: java.nio.charset.CharsetICU[x-IBM943]
+ * x-IBM949 value: java.nio.charset.CharsetICU[x-IBM949]
+ * x-IBM949C value: java.nio.charset.CharsetICU[x-IBM949C]
+ * x-IBM950 value: java.nio.charset.CharsetICU[x-IBM950]
+ * x-IBM954 value: java.nio.charset.CharsetICU[x-IBM954]
+ * x-IBM964 value: java.nio.charset.CharsetICU[x-IBM964]
+ * x-IBM970 value: java.nio.charset.CharsetICU[x-IBM970]
+ * x-IBM971 value: java.nio.charset.CharsetICU[x-IBM971]
+ * x-IMAP-mailbox-name value: java.nio.charset.CharsetICU[x-IMAP-mailbox-name]
+ * x-iscii-be value: java.nio.charset.CharsetICU[x-iscii-be]
+ * x-iscii-gu value: java.nio.charset.CharsetICU[x-iscii-gu]
+ * x-iscii-ka value: java.nio.charset.CharsetICU[x-iscii-ka]
+ * x-iscii-ma value: java.nio.charset.CharsetICU[x-iscii-ma]
+ * x-iscii-or value: java.nio.charset.CharsetICU[x-iscii-or]
+ * x-iscii-pa value: java.nio.charset.CharsetICU[x-iscii-pa]
+ * x-iscii-ta value: java.nio.charset.CharsetICU[x-iscii-ta]
+ * x-iscii-te value: java.nio.charset.CharsetICU[x-iscii-te]
+ * x-ISCII91 value: java.nio.charset.CharsetICU[x-ISCII91]
+ * x-ISO-2022-CN-CNS value: java.nio.charset.CharsetICU[x-ISO-2022-CN-CNS]
+ * x-iso-8859-11 value: java.nio.charset.CharsetICU[x-iso-8859-11]
+ * x-JavaUnicode value: java.nio.charset.CharsetICU[x-JavaUnicode]
+ * x-JavaUnicode2 value: java.nio.charset.CharsetICU[x-JavaUnicode2]
+ * x-JIS7 value: java.nio.charset.CharsetICU[x-JIS7]
+ * x-JIS8 value: java.nio.charset.CharsetICU[x-JIS8]
+ * x-LMBCS-1 value: java.nio.charset.CharsetICU[x-LMBCS-1]
+ * x-mac-centraleurroman value: java.nio.charset.CharsetICU[x-mac-centraleurroman]
+ * x-mac-cyrillic value: java.nio.charset.CharsetICU[x-mac-cyrillic]
+ * x-mac-greek value: java.nio.charset.CharsetICU[x-mac-greek]
+ * x-mac-turkish value: java.nio.charset.CharsetICU[x-mac-turkish]
+ * x-MS950-HKSCS value: java.nio.charset.CharsetICU[x-MS950-HKSCS]
+ * x-UnicodeBig value: java.nio.charset.CharsetICU[x-UnicodeBig]
+ * x-UTF-16LE-BOM value: java.nio.charset.CharsetICU[x-UTF-16LE-BOM]
+ * x-UTF16_OppositeEndian value: java.nio.charset.CharsetICU[x-UTF16_OppositeEndian]
+ * x-UTF16_PlatformEndian value: java.nio.charset.CharsetICU[x-UTF16_PlatformEndian]
+ * x-UTF32_OppositeEndian value: java.nio.charset.CharsetICU[x-UTF32_OppositeEndian]
+ * x-UTF32_PlatformEndian value: java.nio.charset.CharsetICU[x-UTF32_PlatformEndian]
+ *
+ */
+ public static final int ANY_CHARSET = 0x00;
+ public static final int US_ASCII = 0x03;
+ public static final int ISO_8859_1 = 0x04;
+ public static final int ISO_8859_2 = 0x05;
+ public static final int ISO_8859_3 = 0x06;
+ public static final int ISO_8859_4 = 0x07;
+ public static final int ISO_8859_5 = 0x08;
+ public static final int ISO_8859_6 = 0x09;
+ public static final int ISO_8859_7 = 0x0A;
+ public static final int ISO_8859_8 = 0x0B;
+ public static final int ISO_8859_9 = 0x0C;
+ public static final int SHIFT_JIS = 0x11;
+ public static final int EUC_JP = 0x12;
+ public static final int EUC_KR = 0x26;
+ public static final int ISO_2022_JP = 0x27;
+ public static final int ISO_2022_JP_2 = 0x28;
+ public static final int UTF_8 = 0x6A;
+ public static final int GBK = 0x71;
+ public static final int GB18030 = 0x72;
+ public static final int GB2312 = 0x07E9;
+ public static final int BIG5 = 0x07EA;
+ public static final int UCS2 = 0x03E8;
+ public static final int UTF_16 = 0x03F7;
+ public static final int HZ_GB_2312 = 0x0825;
+
+ /**
+ * If the encoding of given data is unsupported, use UTF_8 to decode it.
+ */
+ public static final int DEFAULT_CHARSET = UTF_8;
+
+ /**
+ * Array of MIB enum numbers.
+ */
+ private static final int[] MIBENUM_NUMBERS = {
+ ANY_CHARSET,
+ US_ASCII,
+ ISO_8859_1,
+ ISO_8859_2,
+ ISO_8859_3,
+ ISO_8859_4,
+ ISO_8859_5,
+ ISO_8859_6,
+ ISO_8859_7,
+ ISO_8859_8,
+ ISO_8859_9,
+ SHIFT_JIS,
+ EUC_JP,
+ EUC_KR,
+ ISO_2022_JP,
+ ISO_2022_JP_2,
+ UTF_8,
+ GBK,
+ GB18030,
+ GB2312,
+ BIG5,
+ UCS2,
+ UTF_16,
+ HZ_GB_2312,
+ };
+
+ /**
+ * The Well-known-charset Mime name.
+ */
+ public static final String MIMENAME_ANY_CHARSET = "*";
+ public static final String MIMENAME_US_ASCII = "us-ascii";
+ public static final String MIMENAME_ISO_8859_1 = "iso-8859-1";
+ public static final String MIMENAME_ISO_8859_2 = "iso-8859-2";
+ public static final String MIMENAME_ISO_8859_3 = "iso-8859-3";
+ public static final String MIMENAME_ISO_8859_4 = "iso-8859-4";
+ public static final String MIMENAME_ISO_8859_5 = "iso-8859-5";
+ public static final String MIMENAME_ISO_8859_6 = "iso-8859-6";
+ public static final String MIMENAME_ISO_8859_7 = "iso-8859-7";
+ public static final String MIMENAME_ISO_8859_8 = "iso-8859-8";
+ public static final String MIMENAME_ISO_8859_9 = "iso-8859-9";
+ public static final String MIMENAME_SHIFT_JIS = "shift_JIS";
+ public static final String MIMENAME_EUC_JP = "euc-jp";
+ public static final String MIMENAME_EUC_KR = "euc-kr";
+ public static final String MIMENAME_ISO_2022_JP = "iso-2022-jp";
+ public static final String MIMENAME_ISO_2022_JP_2 = "iso-2022-jp-2";
+ public static final String MIMENAME_UTF_8 = "utf-8";
+ public static final String MIMENAME_GBK = "gbk";
+ public static final String MIMENAME_GB18030 = "gb18030";
+ public static final String MIMENAME_GB2312 = "gb2312";
+ public static final String MIMENAME_BIG5 = "big5";
+ public static final String MIMENAME_UCS2 = "iso-10646-ucs-2";
+ public static final String MIMENAME_UTF_16 = "utf-16";
+ public static final String MIMENAME_HZ_GB_2312 = "hz-gb-2312";
+
+ public static final String DEFAULT_CHARSET_NAME = MIMENAME_UTF_8;
+
+ /**
+ * Array of the names of character sets.
+ */
+ private static final String[] MIME_NAMES = {
+ MIMENAME_ANY_CHARSET,
+ MIMENAME_US_ASCII,
+ MIMENAME_ISO_8859_1,
+ MIMENAME_ISO_8859_2,
+ MIMENAME_ISO_8859_3,
+ MIMENAME_ISO_8859_4,
+ MIMENAME_ISO_8859_5,
+ MIMENAME_ISO_8859_6,
+ MIMENAME_ISO_8859_7,
+ MIMENAME_ISO_8859_8,
+ MIMENAME_ISO_8859_9,
+ MIMENAME_SHIFT_JIS,
+ MIMENAME_EUC_JP,
+ MIMENAME_EUC_KR,
+ MIMENAME_ISO_2022_JP,
+ MIMENAME_ISO_2022_JP_2,
+ MIMENAME_UTF_8,
+ MIMENAME_GBK,
+ MIMENAME_GB18030,
+ MIMENAME_GB2312,
+ MIMENAME_BIG5,
+ MIMENAME_UCS2,
+ MIMENAME_UTF_16,
+ MIMENAME_HZ_GB_2312,
+ };
+
+ private static final SparseArray<String> MIBENUM_TO_NAME_MAP;
+
+ private static final SimpleArrayMap<String, Integer> NAME_TO_MIBENUM_MAP;
+
+ static {
+ // Create the HashMaps.
+ MIBENUM_TO_NAME_MAP = new SparseArray<String>();
+ NAME_TO_MIBENUM_MAP = new SimpleArrayMap<String, Integer>();
+ assert (MIBENUM_NUMBERS.length == MIME_NAMES.length);
+ final int count = MIBENUM_NUMBERS.length - 1;
+ for (int i = 0; i <= count; i++) {
+ MIBENUM_TO_NAME_MAP.put(MIBENUM_NUMBERS[i], MIME_NAMES[i]);
+ NAME_TO_MIBENUM_MAP.put(MIME_NAMES[i], MIBENUM_NUMBERS[i]);
+ }
+ }
+
+ private CharacterSets() {
+ } // Non-instantiatable
+
+ /**
+ * Map an MIBEnum number to the name of the charset which this number
+ * is assigned to by IANA.
+ *
+ * @param mibEnumValue An IANA assigned MIBEnum number.
+ * @return The name string of the charset.
+ */
+ public static String getMimeName(final int mibEnumValue)
+ throws UnsupportedEncodingException {
+ final String name = MIBENUM_TO_NAME_MAP.get(mibEnumValue);
+ if (name == null) {
+ throw new UnsupportedEncodingException();
+ }
+ return name;
+ }
+
+ /**
+ * Map a well-known charset name to its assigned MIBEnum number.
+ *
+ * @param mimeName The charset name.
+ * @return The MIBEnum number assigned by IANA for this charset.
+ */
+ public static int getMibEnumValue(final String mimeName)
+ throws UnsupportedEncodingException {
+ if (null == mimeName) {
+ return -1;
+ }
+
+ final Integer mibEnumValue = NAME_TO_MIBENUM_MAP.get(mimeName);
+ if (mibEnumValue == null) {
+ throw new UnsupportedEncodingException();
+ }
+ return mibEnumValue;
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/DeliveryInd.java b/src/com/android/messaging/mmslib/pdu/DeliveryInd.java
new file mode 100644
index 0000000..5e1d873
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/DeliveryInd.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+/**
+ * M-Delivery.Ind Pdu.
+ */
+public class DeliveryInd extends GenericPdu {
+ /**
+ * Empty constructor.
+ * Since the Pdu corresponding to this class is constructed
+ * by the Proxy-Relay server, this class is only instantiated
+ * by the Pdu Parser.
+ *
+ * @throws InvalidHeaderValueException if error occurs.
+ */
+ public DeliveryInd() throws InvalidHeaderValueException {
+ super();
+ setMessageType(PduHeaders.MESSAGE_TYPE_DELIVERY_IND);
+ }
+
+ /**
+ * Constructor with given headers.
+ *
+ * @param headers Headers for this PDU.
+ */
+ DeliveryInd(PduHeaders headers) {
+ super(headers);
+ }
+
+ /**
+ * Get Date value.
+ *
+ * @return the value
+ */
+ public long getDate() {
+ return mPduHeaders.getLongInteger(PduHeaders.DATE);
+ }
+
+ /**
+ * Set Date value.
+ *
+ * @param value the value
+ */
+ public void setDate(long value) {
+ mPduHeaders.setLongInteger(value, PduHeaders.DATE);
+ }
+
+ /**
+ * Get Message-ID value.
+ *
+ * @return the value
+ */
+ public byte[] getMessageId() {
+ return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID);
+ }
+
+ /**
+ * Set Message-ID value.
+ *
+ * @param value the value, should not be null
+ * @throws NullPointerException if the value is null.
+ */
+ public void setMessageId(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID);
+ }
+
+ /**
+ * Get Status value.
+ *
+ * @return the value
+ */
+ public int getStatus() {
+ return mPduHeaders.getOctet(PduHeaders.STATUS);
+ }
+
+ /**
+ * Set Status value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ public void setStatus(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.STATUS);
+ }
+
+ /**
+ * Get To value.
+ *
+ * @return the value
+ */
+ public EncodedStringValue[] getTo() {
+ return mPduHeaders.getEncodedStringValues(PduHeaders.TO);
+ }
+
+ /**
+ * set To value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setTo(EncodedStringValue[] value) {
+ mPduHeaders.setEncodedStringValues(value, PduHeaders.TO);
+ }
+
+ /*
+ * Optional, not supported header fields:
+ *
+ * public byte[] getApplicId() {return null;}
+ * public void setApplicId(byte[] value) {}
+ *
+ * public byte[] getAuxApplicId() {return null;}
+ * public void getAuxApplicId(byte[] value) {}
+ *
+ * public byte[] getReplyApplicId() {return 0x00;}
+ * public void setReplyApplicId(byte[] value) {}
+ *
+ * public EncodedStringValue getStatusText() {return null;}
+ * public void setStatusText(EncodedStringValue value) {}
+ */
+}
diff --git a/src/com/android/messaging/mmslib/pdu/EncodedStringValue.java b/src/com/android/messaging/mmslib/pdu/EncodedStringValue.java
new file mode 100644
index 0000000..1d3c9ea
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/EncodedStringValue.java
@@ -0,0 +1,298 @@
+/*
+ * Copyright (C) 2007-2008 Esmertec AG.
+ * Copyright (C) 2007-2008 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.messaging.mmslib.pdu;
+
+import android.util.Log;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+
+/**
+ * Encoded-string-value = Text-string | Value-length Char-set Text-string
+ */
+public class EncodedStringValue implements Cloneable {
+ private static final String TAG = "EncodedStringValue";
+ private static final boolean DEBUG = false;
+ private static final boolean LOCAL_LOGV = false;
+
+ /**
+ * The Char-set value.
+ */
+ private int mCharacterSet;
+
+ /**
+ * The Text-string value.
+ */
+ private byte[] mData;
+
+ /**
+ * Constructor.
+ *
+ * @param charset the Char-set value
+ * @param data the Text-string value
+ * @throws NullPointerException if Text-string value is null.
+ */
+ public EncodedStringValue(int charset, byte[] data) {
+ // TODO: CharSet needs to be validated against MIBEnum.
+ if (null == data) {
+ throw new NullPointerException("EncodedStringValue: Text-string is null.");
+ }
+
+ mCharacterSet = charset;
+ mData = new byte[data.length];
+ System.arraycopy(data, 0, mData, 0, data.length);
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param data the Text-string value
+ * @throws NullPointerException if Text-string value is null.
+ */
+ public EncodedStringValue(byte[] data) {
+ this(CharacterSets.DEFAULT_CHARSET, data);
+ }
+
+ public EncodedStringValue(String data) {
+ this(CharacterSets.DEFAULT_CHARSET, data);
+ }
+
+ /**
+ * Constructor
+ *
+ * @param charset
+ * @param data The text in Java String
+ * @throws NullPointerException if Text-string value is null.
+ */
+ public EncodedStringValue(int charset, String data) {
+ if (null == data) {
+ throw new NullPointerException("EncodedStringValue: Text-string is null");
+ }
+ mCharacterSet = charset;
+ try {
+ mData = data.getBytes(CharacterSets.getMimeName(charset));
+ } catch (UnsupportedEncodingException e) {
+ Log.e(TAG, "Input encoding " + charset + " must be supported.", e);
+ mData = data.getBytes();
+ }
+ }
+
+ /**
+ * Get Char-set value.
+ *
+ * @return the value
+ */
+ public int getCharacterSet() {
+ return mCharacterSet;
+ }
+
+ /**
+ * Set Char-set value.
+ *
+ * @param charset the Char-set value
+ */
+ public void setCharacterSet(int charset) {
+ // TODO: CharSet needs to be validated against MIBEnum.
+ mCharacterSet = charset;
+ }
+
+ /**
+ * Get Text-string value.
+ *
+ * @return the value
+ */
+ public byte[] getTextString() {
+ byte[] byteArray = new byte[mData.length];
+
+ System.arraycopy(mData, 0, byteArray, 0, mData.length);
+ return byteArray;
+ }
+
+ /**
+ * Set Text-string value.
+ *
+ * @param textString the Text-string value
+ * @throws NullPointerException if Text-string value is null.
+ */
+ public void setTextString(byte[] textString) {
+ if (null == textString) {
+ throw new NullPointerException("EncodedStringValue: Text-string is null.");
+ }
+
+ mData = new byte[textString.length];
+ System.arraycopy(textString, 0, mData, 0, textString.length);
+ }
+
+ /**
+ * Convert this object to a {@link java.lang.String}. If the encoding of
+ * the EncodedStringValue is null or unsupported, it will be
+ * treated as iso-8859-1 encoding.
+ *
+ * @return The decoded String.
+ */
+ public String getString() {
+ if (CharacterSets.ANY_CHARSET == mCharacterSet) {
+ return new String(mData); // system default encoding.
+ } else {
+ try {
+ String name = CharacterSets.getMimeName(mCharacterSet);
+ return new String(mData, name);
+ } catch (UnsupportedEncodingException e) {
+ if (LOCAL_LOGV) {
+ Log.v(TAG, e.getMessage(), e);
+ }
+ try {
+ return new String(mData, CharacterSets.MIMENAME_ISO_8859_1);
+ } catch (UnsupportedEncodingException exception) {
+ return new String(mData); // system default encoding.
+ }
+ }
+ }
+ }
+
+ /**
+ * Append to Text-string.
+ *
+ * @param textString the textString to append
+ * @throws NullPointerException if the text String is null
+ * or an IOException occured.
+ */
+ public void appendTextString(byte[] textString) {
+ if (null == textString) {
+ throw new NullPointerException("Text-string is null.");
+ }
+
+ if (null == mData) {
+ mData = new byte[textString.length];
+ System.arraycopy(textString, 0, mData, 0, textString.length);
+ } else {
+ ByteArrayOutputStream newTextString = new ByteArrayOutputStream();
+ try {
+ newTextString.write(mData);
+ newTextString.write(textString);
+ } catch (IOException e) {
+ e.printStackTrace();
+ throw new NullPointerException(
+ "appendTextString: failed when write a new Text-string");
+ }
+
+ mData = newTextString.toByteArray();
+ }
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see java.lang.Object#clone()
+ */
+ @Override
+ public Object clone() throws CloneNotSupportedException {
+ super.clone();
+ int len = mData.length;
+ byte[] dstBytes = new byte[len];
+ System.arraycopy(mData, 0, dstBytes, 0, len);
+
+ try {
+ return new EncodedStringValue(mCharacterSet, dstBytes);
+ } catch (Exception e) {
+ Log.e(TAG, "failed to clone an EncodedStringValue: " + this);
+ e.printStackTrace();
+ throw new CloneNotSupportedException(e.getMessage());
+ }
+ }
+
+ /**
+ * Split this encoded string around matches of the given pattern.
+ *
+ * @param pattern the delimiting pattern
+ * @return the array of encoded strings computed by splitting this encoded
+ * string around matches of the given pattern
+ */
+ public EncodedStringValue[] split(String pattern) {
+ String[] temp = getString().split(pattern);
+ EncodedStringValue[] ret = new EncodedStringValue[temp.length];
+ for (int i = 0; i < ret.length; ++i) {
+ try {
+ ret[i] = new EncodedStringValue(mCharacterSet,
+ temp[i].getBytes());
+ } catch (NullPointerException exception) {
+ // Can't arrive here
+ return null;
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Extract an EncodedStringValue[] from a given String.
+ */
+ public static EncodedStringValue[] extract(String src) {
+ String[] values = src.split(";");
+
+ ArrayList<EncodedStringValue> list = new ArrayList<EncodedStringValue>();
+ for (int i = 0; i < values.length; i++) {
+ if (values[i].length() > 0) {
+ list.add(new EncodedStringValue(values[i]));
+ }
+ }
+
+ int len = list.size();
+ if (len > 0) {
+ return list.toArray(new EncodedStringValue[len]);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Concatenate an EncodedStringValue[] into a single String.
+ */
+ public static String concat(EncodedStringValue[] addr) {
+ StringBuilder sb = new StringBuilder();
+ int maxIndex = addr.length - 1;
+ for (int i = 0; i <= maxIndex; i++) {
+ sb.append(addr[i].getString());
+ if (i < maxIndex) {
+ sb.append(";");
+ }
+ }
+
+ return sb.toString();
+ }
+
+ public static EncodedStringValue copy(EncodedStringValue value) {
+ if (value == null) {
+ return null;
+ }
+
+ return new EncodedStringValue(value.mCharacterSet, value.mData);
+ }
+
+ public static EncodedStringValue[] encodeStrings(String[] array) {
+ int count = array.length;
+ if (count > 0) {
+ EncodedStringValue[] encodedArray = new EncodedStringValue[count];
+ for (int i = 0; i < count; i++) {
+ encodedArray[i] = new EncodedStringValue(array[i]);
+ }
+ return encodedArray;
+ }
+ return null;
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/GenericPdu.java b/src/com/android/messaging/mmslib/pdu/GenericPdu.java
new file mode 100644
index 0000000..178dc5a
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/GenericPdu.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+public class GenericPdu {
+ /**
+ * The headers of pdu.
+ */
+ PduHeaders mPduHeaders = null;
+
+ /**
+ * Constructor.
+ */
+ public GenericPdu() {
+ mPduHeaders = new PduHeaders();
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param headers Headers for this PDU.
+ */
+ GenericPdu(PduHeaders headers) {
+ mPduHeaders = headers;
+ }
+
+ /**
+ * Get the headers of this PDU.
+ *
+ * @return A PduHeaders of this PDU.
+ */
+ PduHeaders getPduHeaders() {
+ return mPduHeaders;
+ }
+
+ /**
+ * Get X-Mms-Message-Type field value.
+ *
+ * @return the X-Mms-Report-Allowed value
+ */
+ public int getMessageType() {
+ return mPduHeaders.getOctet(PduHeaders.MESSAGE_TYPE);
+ }
+
+ /**
+ * Set X-Mms-Message-Type field value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ * @throws RuntimeException if field's value is not Octet.
+ */
+ public void setMessageType(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.MESSAGE_TYPE);
+ }
+
+ /**
+ * Get X-Mms-MMS-Version field value.
+ *
+ * @return the X-Mms-MMS-Version value
+ */
+ public int getMmsVersion() {
+ return mPduHeaders.getOctet(PduHeaders.MMS_VERSION);
+ }
+
+ /**
+ * Set X-Mms-MMS-Version field value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ * @throws RuntimeException if field's value is not Octet.
+ */
+ public void setMmsVersion(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.MMS_VERSION);
+ }
+
+ /**
+ * Get From value.
+ * From-value = Value-length
+ * (Address-present-token Encoded-string-value | Insert-address-token)
+ *
+ * @return the value
+ */
+ public EncodedStringValue getFrom() {
+ return mPduHeaders.getEncodedStringValue(PduHeaders.FROM);
+ }
+
+ /**
+ * Set From value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setFrom(EncodedStringValue value) {
+ mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM);
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/MultimediaMessagePdu.java b/src/com/android/messaging/mmslib/pdu/MultimediaMessagePdu.java
new file mode 100644
index 0000000..cb8c8c7
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/MultimediaMessagePdu.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+/**
+ * Multimedia message PDU.
+ */
+public class MultimediaMessagePdu extends GenericPdu {
+ /**
+ * The body.
+ */
+ private PduBody mMessageBody;
+
+ /**
+ * Constructor.
+ */
+ public MultimediaMessagePdu() {
+ super();
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param header the header of this PDU
+ * @param body the body of this PDU
+ */
+ public MultimediaMessagePdu(PduHeaders header, PduBody body) {
+ super(header);
+ mMessageBody = body;
+ }
+
+ /**
+ * Constructor with given headers.
+ *
+ * @param headers Headers for this PDU.
+ */
+ MultimediaMessagePdu(PduHeaders headers) {
+ super(headers);
+ }
+
+ /**
+ * Get body of the PDU.
+ *
+ * @return the body
+ */
+ public PduBody getBody() {
+ return mMessageBody;
+ }
+
+ /**
+ * Set body of the PDU.
+ *
+ * @param body the body
+ */
+ public void setBody(PduBody body) {
+ mMessageBody = body;
+ }
+
+ /**
+ * Get subject.
+ *
+ * @return the value
+ */
+ public EncodedStringValue getSubject() {
+ return mPduHeaders.getEncodedStringValue(PduHeaders.SUBJECT);
+ }
+
+ /**
+ * Set subject.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setSubject(EncodedStringValue value) {
+ mPduHeaders.setEncodedStringValue(value, PduHeaders.SUBJECT);
+ }
+
+ /**
+ * Get To value.
+ *
+ * @return the value
+ */
+ public EncodedStringValue[] getTo() {
+ return mPduHeaders.getEncodedStringValues(PduHeaders.TO);
+ }
+
+ /**
+ * Add a "To" value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void addTo(EncodedStringValue value) {
+ mPduHeaders.appendEncodedStringValue(value, PduHeaders.TO);
+ }
+
+ /**
+ * Get X-Mms-Priority value.
+ *
+ * @return the value
+ */
+ public int getPriority() {
+ return mPduHeaders.getOctet(PduHeaders.PRIORITY);
+ }
+
+ /**
+ * Set X-Mms-Priority value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ public void setPriority(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.PRIORITY);
+ }
+
+ /**
+ * Get Date value.
+ *
+ * @return the value
+ */
+ public long getDate() {
+ return mPduHeaders.getLongInteger(PduHeaders.DATE);
+ }
+
+ /**
+ * Set Date value in seconds.
+ *
+ * @param value the value
+ */
+ public void setDate(long value) {
+ mPduHeaders.setLongInteger(value, PduHeaders.DATE);
+ }
+
+ /**
+ * Get the message size
+ *
+ * @return the size of the message
+ */
+ public long getMessageSize() {
+ return mPduHeaders.getLongInteger(PduHeaders.MESSAGE_SIZE);
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/NotificationInd.java b/src/com/android/messaging/mmslib/pdu/NotificationInd.java
new file mode 100644
index 0000000..4cbbd30
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/NotificationInd.java
@@ -0,0 +1,285 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+/**
+ * M-Notification.ind PDU.
+ */
+public class NotificationInd extends GenericPdu {
+ /**
+ * Empty constructor.
+ * Since the Pdu corresponding to this class is constructed
+ * by the Proxy-Relay server, this class is only instantiated
+ * by the Pdu Parser.
+ *
+ * @throws InvalidHeaderValueException if error occurs.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public NotificationInd() throws InvalidHeaderValueException {
+ super();
+ setMessageType(PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND);
+ }
+
+ /**
+ * Constructor with given headers.
+ *
+ * @param headers Headers for this PDU.
+ */
+ NotificationInd(PduHeaders headers) {
+ super(headers);
+ }
+
+ /**
+ * Get X-Mms-Content-Class Value.
+ *
+ * @return the value
+ */
+ public int getContentClass() {
+ return mPduHeaders.getOctet(PduHeaders.CONTENT_CLASS);
+ }
+
+ /**
+ * Set X-Mms-Content-Class Value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setContentClass(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.CONTENT_CLASS);
+ }
+
+ /**
+ * Get X-Mms-Content-Location value.
+ * When used in a PDU other than M-Mbox-Delete.conf and M-Delete.conf:
+ * Content-location-value = Uri-value
+ *
+ * @return the value
+ */
+ public byte[] getContentLocation() {
+ return mPduHeaders.getTextString(PduHeaders.CONTENT_LOCATION);
+ }
+
+ /**
+ * Set X-Mms-Content-Location value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setContentLocation(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.CONTENT_LOCATION);
+ }
+
+ /**
+ * Get X-Mms-Expiry value.
+ *
+ * Expiry-value = Value-length
+ * (Absolute-token Date-value | Relative-token Delta-seconds-value)
+ *
+ * @return the value
+ */
+ public long getExpiry() {
+ return mPduHeaders.getLongInteger(PduHeaders.EXPIRY);
+ }
+
+ /**
+ * Set X-Mms-Expiry value.
+ *
+ * @param value the value
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setExpiry(long value) {
+ mPduHeaders.setLongInteger(value, PduHeaders.EXPIRY);
+ }
+
+ /**
+ * Get From value.
+ * From-value = Value-length
+ * (Address-present-token Encoded-string-value | Insert-address-token)
+ *
+ * @return the value
+ */
+ public EncodedStringValue getFrom() {
+ return mPduHeaders.getEncodedStringValue(PduHeaders.FROM);
+ }
+
+ /**
+ * Set From value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setFrom(EncodedStringValue value) {
+ mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM);
+ }
+
+ /**
+ * Get X-Mms-Message-Class value.
+ * Message-class-value = Class-identifier | Token-text
+ * Class-identifier = Personal | Advertisement | Informational | Auto
+ *
+ * @return the value
+ */
+ public byte[] getMessageClass() {
+ return mPduHeaders.getTextString(PduHeaders.MESSAGE_CLASS);
+ }
+
+ /**
+ * Set X-Mms-Message-Class value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setMessageClass(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.MESSAGE_CLASS);
+ }
+
+ /**
+ * Get X-Mms-Message-Size value.
+ * Message-size-value = Long-integer
+ *
+ * @return the value
+ */
+ public long getMessageSize() {
+ return mPduHeaders.getLongInteger(PduHeaders.MESSAGE_SIZE);
+ }
+
+ /**
+ * Set X-Mms-Message-Size value.
+ *
+ * @param value the value
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setMessageSize(long value) {
+ mPduHeaders.setLongInteger(value, PduHeaders.MESSAGE_SIZE);
+ }
+
+ /**
+ * Get subject.
+ *
+ * @return the value
+ */
+ public EncodedStringValue getSubject() {
+ return mPduHeaders.getEncodedStringValue(PduHeaders.SUBJECT);
+ }
+
+ /**
+ * Set subject.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setSubject(EncodedStringValue value) {
+ mPduHeaders.setEncodedStringValue(value, PduHeaders.SUBJECT);
+ }
+
+ /**
+ * Get X-Mms-Transaction-Id.
+ *
+ * @return the value
+ */
+ public byte[] getTransactionId() {
+ return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID);
+ }
+
+ /**
+ * Set X-Mms-Transaction-Id.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setTransactionId(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID);
+ }
+
+ /**
+ * Get X-Mms-Delivery-Report Value.
+ *
+ * @return the value
+ */
+ public int getDeliveryReport() {
+ return mPduHeaders.getOctet(PduHeaders.DELIVERY_REPORT);
+ }
+
+ /**
+ * Set X-Mms-Delivery-Report Value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setDeliveryReport(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.DELIVERY_REPORT);
+ }
+
+ /*
+ * Optional, not supported header fields:
+ *
+ * public byte[] getApplicId() {return null;}
+ * public void setApplicId(byte[] value) {}
+ *
+ * public byte[] getAuxApplicId() {return null;}
+ * public void getAuxApplicId(byte[] value) {}
+ *
+ * public byte getDrmContent() {return 0x00;}
+ * public void setDrmContent(byte value) {}
+ *
+ * public byte getDistributionIndicator() {return 0x00;}
+ * public void setDistributionIndicator(byte value) {}
+ *
+ * public ElementDescriptorValue getElementDescriptor() {return null;}
+ * public void getElementDescriptor(ElementDescriptorValue value) {}
+ *
+ * public byte getPriority() {return 0x00;}
+ * public void setPriority(byte value) {}
+ *
+ * public byte getRecommendedRetrievalMode() {return 0x00;}
+ * public void setRecommendedRetrievalMode(byte value) {}
+ *
+ * public byte getRecommendedRetrievalModeText() {return 0x00;}
+ * public void setRecommendedRetrievalModeText(byte value) {}
+ *
+ * public byte[] getReplaceId() {return 0x00;}
+ * public void setReplaceId(byte[] value) {}
+ *
+ * public byte[] getReplyApplicId() {return 0x00;}
+ * public void setReplyApplicId(byte[] value) {}
+ *
+ * public byte getReplyCharging() {return 0x00;}
+ * public void setReplyCharging(byte value) {}
+ *
+ * public byte getReplyChargingDeadline() {return 0x00;}
+ * public void setReplyChargingDeadline(byte value) {}
+ *
+ * public byte[] getReplyChargingId() {return 0x00;}
+ * public void setReplyChargingId(byte[] value) {}
+ *
+ * public long getReplyChargingSize() {return 0;}
+ * public void setReplyChargingSize(long value) {}
+ *
+ * public byte getStored() {return 0x00;}
+ * public void setStored(byte value) {}
+ */
+}
diff --git a/src/com/android/messaging/mmslib/pdu/NotifyRespInd.java b/src/com/android/messaging/mmslib/pdu/NotifyRespInd.java
new file mode 100644
index 0000000..7dabd89
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/NotifyRespInd.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+/**
+ * M-NofifyResp.ind PDU.
+ */
+public class NotifyRespInd extends GenericPdu {
+ /**
+ * Constructor, used when composing a M-NotifyResp.ind pdu.
+ *
+ * @param mmsVersion current version of mms
+ * @param transactionId the transaction-id value
+ * @param status the status value
+ * @throws InvalidHeaderValueException if parameters are invalid.
+ * @throws NullPointerException if transactionId is null.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public NotifyRespInd(int mmsVersion,
+ byte[] transactionId,
+ int status) throws InvalidHeaderValueException {
+ super();
+ setMessageType(PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND);
+ setMmsVersion(mmsVersion);
+ setTransactionId(transactionId);
+ setStatus(status);
+ }
+
+ /**
+ * Constructor with given headers.
+ *
+ * @param headers Headers for this PDU.
+ */
+ NotifyRespInd(PduHeaders headers) {
+ super(headers);
+ }
+
+ /**
+ * Get X-Mms-Report-Allowed field value.
+ *
+ * @return the X-Mms-Report-Allowed value
+ */
+ public int getReportAllowed() {
+ return mPduHeaders.getOctet(PduHeaders.REPORT_ALLOWED);
+ }
+
+ /**
+ * Set X-Mms-Report-Allowed field value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setReportAllowed(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.REPORT_ALLOWED);
+ }
+
+ /**
+ * Set X-Mms-Status field value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setStatus(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.STATUS);
+ }
+
+ /**
+ * GetX-Mms-Status field value.
+ *
+ * @return the X-Mms-Status value
+ */
+ public int getStatus() {
+ return mPduHeaders.getOctet(PduHeaders.STATUS);
+ }
+
+ /**
+ * Get X-Mms-Transaction-Id field value.
+ *
+ * @return the X-Mms-Report-Allowed value
+ */
+ public byte[] getTransactionId() {
+ return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID);
+ }
+
+ /**
+ * Set X-Mms-Transaction-Id field value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ * @throws RuntimeException if an undeclared error occurs.
+ */
+ public void setTransactionId(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID);
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/PduBody.java b/src/com/android/messaging/mmslib/pdu/PduBody.java
new file mode 100644
index 0000000..8cbb2e6
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/PduBody.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import java.util.Vector;
+
+public class PduBody {
+ private Vector<PduPart> mParts = null;
+
+ /**
+ * Constructor.
+ */
+ public PduBody() {
+ mParts = new Vector<PduPart>();
+ }
+
+ /**
+ * Appends the specified part to the end of this body.
+ *
+ * @param part part to be appended
+ * @return true when success, false when fail
+ * @throws NullPointerException when part is null
+ */
+ public boolean addPart(PduPart part) {
+ if (null == part) {
+ throw new NullPointerException();
+ }
+
+ return mParts.add(part);
+ }
+
+ /**
+ * Inserts the specified part at the specified position.
+ *
+ * @param index index at which the specified part is to be inserted
+ * @param part part to be inserted
+ * @throws NullPointerException when part is null
+ */
+ public void addPart(int index, PduPart part) {
+ if (null == part) {
+ throw new NullPointerException();
+ }
+
+ mParts.add(index, part);
+ }
+
+ /**
+ * Remove all of the parts.
+ */
+ public void removeAll() {
+ mParts.clear();
+ }
+
+ /**
+ * Get the part at the specified position.
+ *
+ * @param index index of the part to return
+ * @return part at the specified index
+ */
+ public PduPart getPart(int index) {
+ return mParts.get(index);
+ }
+
+ /**
+ * Get the number of parts.
+ *
+ * @return the number of parts
+ */
+ public int getPartsNum() {
+ return mParts.size();
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/PduComposer.java b/src/com/android/messaging/mmslib/pdu/PduComposer.java
new file mode 100644
index 0000000..d05a198
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/PduComposer.java
@@ -0,0 +1,1260 @@
+/*
+ * Copyright (C) 2007-2008 Esmertec AG.
+ * Copyright (C) 2007-2008 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.messaging.mmslib.pdu;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.support.v4.util.SimpleArrayMap;
+import android.text.TextUtils;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+
+public class PduComposer {
+ /**
+ * Address type.
+ */
+ private static final int PDU_PHONE_NUMBER_ADDRESS_TYPE = 1;
+ private static final int PDU_EMAIL_ADDRESS_TYPE = 2;
+ private static final int PDU_IPV4_ADDRESS_TYPE = 3;
+ private static final int PDU_IPV6_ADDRESS_TYPE = 4;
+ private static final int PDU_UNKNOWN_ADDRESS_TYPE = 5;
+
+ /**
+ * Address regular expression string.
+ */
+ static final String REGEXP_PHONE_NUMBER_ADDRESS_TYPE = "\\+?[0-9|\\.|\\-]+";
+
+ static final String REGEXP_EMAIL_ADDRESS_TYPE = "[a-zA-Z| ]*\\<{0,1}[a-zA-Z| ]+@{1}" +
+ "[a-zA-Z| ]+\\.{1}[a-zA-Z| ]+\\>{0,1}";
+
+ static final String REGEXP_IPV6_ADDRESS_TYPE =
+ "[a-fA-F]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}" +
+ "[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}" +
+ "[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}";
+
+ static final String REGEXP_IPV4_ADDRESS_TYPE = "[0-9]{1,3}\\.{1}[0-9]{1,3}\\.{1}" +
+ "[0-9]{1,3}\\.{1}[0-9]{1,3}";
+
+ /**
+ * The postfix strings of address.
+ */
+ static final String STRING_PHONE_NUMBER_ADDRESS_TYPE = "/TYPE=PLMN";
+ static final String STRING_IPV4_ADDRESS_TYPE = "/TYPE=IPV4";
+ static final String STRING_IPV6_ADDRESS_TYPE = "/TYPE=IPV6";
+
+ /**
+ * Error values.
+ */
+ private static final int PDU_COMPOSE_SUCCESS = 0;
+ private static final int PDU_COMPOSE_CONTENT_ERROR = 1;
+ private static final int PDU_COMPOSE_FIELD_NOT_SET = 2;
+ private static final int PDU_COMPOSE_FIELD_NOT_SUPPORTED = 3;
+
+ /**
+ * WAP values defined in WSP spec.
+ */
+ private static final int QUOTED_STRING_FLAG = 34;
+ private static final int END_STRING_FLAG = 0;
+ private static final int LENGTH_QUOTE = 31;
+ private static final int TEXT_MAX = 127;
+ private static final int SHORT_INTEGER_MAX = 127;
+ private static final int LONG_INTEGER_LENGTH_MAX = 8;
+
+ /**
+ * Block size when read data from InputStream.
+ */
+ private static final int PDU_COMPOSER_BLOCK_SIZE = 1024;
+
+ /**
+ * The output message.
+ */
+ protected ByteArrayOutputStream mMessage = null;
+
+ /**
+ * The PDU.
+ */
+ private GenericPdu mPdu = null;
+
+ /**
+ * Current visiting position of the mMessage.
+ */
+ protected int mPosition = 0;
+
+ /**
+ * Message compose buffer stack.
+ */
+ private BufferStack mStack = null;
+
+ /**
+ * Content resolver.
+ */
+ private final ContentResolver mResolver;
+
+ /**
+ * Header of this pdu.
+ */
+ private PduHeaders mPduHeader = null;
+
+ /**
+ * Map of all content type
+ */
+ private static SimpleArrayMap<String, Integer> mContentTypeMap = null;
+
+ static {
+ mContentTypeMap = new SimpleArrayMap<String, Integer>();
+
+ int i;
+ for (i = 0; i < PduContentTypes.contentTypes.length; i++) {
+ mContentTypeMap.put(PduContentTypes.contentTypes[i], i);
+ }
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param context the context
+ * @param pdu the pdu to be composed
+ */
+ public PduComposer(final Context context, final GenericPdu pdu) {
+ mPdu = pdu;
+ mResolver = context.getContentResolver();
+ mPduHeader = pdu.getPduHeaders();
+ mStack = new BufferStack();
+ mMessage = new ByteArrayOutputStream();
+ mPosition = 0;
+ }
+
+ /**
+ * Make the message. No need to check whether mandatory fields are set,
+ * because the constructors of outgoing pdus are taking care of this.
+ *
+ * @return OutputStream of maked message. Return null if
+ * the PDU is invalid.
+ */
+ public byte[] make() {
+ // Get Message-type.
+ final int type = mPdu.getMessageType();
+
+ /* make the message */
+ switch (type) {
+ case PduHeaders.MESSAGE_TYPE_SEND_REQ:
+ if (makeSendReqPdu() != PDU_COMPOSE_SUCCESS) {
+ return null;
+ }
+ break;
+ case PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND:
+ if (makeNotifyResp() != PDU_COMPOSE_SUCCESS) {
+ return null;
+ }
+ break;
+ case PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND:
+ if (makeAckInd() != PDU_COMPOSE_SUCCESS) {
+ return null;
+ }
+ break;
+ case PduHeaders.MESSAGE_TYPE_READ_REC_IND:
+ if (makeReadRecInd() != PDU_COMPOSE_SUCCESS) {
+ return null;
+ }
+ break;
+ case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND:
+ if (makeNotificationInd() != PDU_COMPOSE_SUCCESS) {
+ return null;
+ }
+ break;
+ default:
+ return null;
+ }
+
+ return mMessage.toByteArray();
+ }
+
+ /**
+ * Copy buf to mMessage.
+ */
+ protected void arraycopy(final byte[] buf, final int pos, final int length) {
+ mMessage.write(buf, pos, length);
+ mPosition = mPosition + length;
+ }
+
+ /**
+ * Append a byte to mMessage.
+ */
+ protected void append(final int value) {
+ mMessage.write(value);
+ mPosition++;
+ }
+
+ /**
+ * Append short integer value to mMessage.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendShortInteger(final int value) {
+ /*
+ * From WAP-230-WSP-20010705-a:
+ * Short-integer = OCTET
+ * ; Integers in range 0-127 shall be encoded as a one octet value
+ * ; with the most significant bit set to one (1xxx xxxx) and with
+ * ; the value in the remaining least significant bits.
+ * In our implementation, only low 7 bits are stored and otherwise
+ * bits are ignored.
+ */
+ append((value | 0x80) & 0xff);
+ }
+
+ /**
+ * Append an octet number between 128 and 255 into mMessage.
+ * NOTE:
+ * A value between 0 and 127 should be appended by using appendShortInteger.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendOctet(final int number) {
+ append(number);
+ }
+
+ /**
+ * Append a short length into mMessage.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendShortLength(final int value) {
+ /*
+ * From WAP-230-WSP-20010705-a:
+ * Short-length = <Any octet 0-30>
+ */
+ append(value);
+ }
+
+ /**
+ * Append long integer into mMessage. it's used for really long integers.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendLongInteger(final long longInt) {
+ /*
+ * From WAP-230-WSP-20010705-a:
+ * Long-integer = Short-length Multi-octet-integer
+ * ; The Short-length indicates the length of the Multi-octet-integer
+ * Multi-octet-integer = 1*30 OCTET
+ * ; The content octets shall be an unsigned integer value with the
+ * ; most significant octet encoded first (big-endian representation).
+ * ; The minimum number of octets must be used to encode the value.
+ */
+ int size;
+ long temp = longInt;
+
+ // Count the length of the long integer.
+ for (size = 0; (temp != 0) && (size < LONG_INTEGER_LENGTH_MAX); size++) {
+ temp = (temp >>> 8);
+ }
+
+ // Set Length.
+ appendShortLength(size);
+
+ // Count and set the long integer.
+ int i;
+ int shift = (size - 1) * 8;
+
+ for (i = 0; i < size; i++) {
+ append((int) ((longInt >>> shift) & 0xff));
+ shift = shift - 8;
+ }
+ }
+
+ /**
+ * Append text string into mMessage.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendTextString(final byte[] text) {
+ /*
+ * From WAP-230-WSP-20010705-a:
+ * Text-string = [Quote] *TEXT End-of-string
+ * ; If the first character in the TEXT is in the range of 128-255,
+ * ; a Quote character must precede it. Otherwise the Quote character
+ * ;must be omitted. The Quote is not part of the contents.
+ */
+ if (((text[0]) & 0xff) > TEXT_MAX) { // No need to check for <= 255
+ append(TEXT_MAX);
+ }
+
+ arraycopy(text, 0, text.length);
+ append(0);
+ }
+
+ /**
+ * Append text string into mMessage.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendTextString(final String str) {
+ /*
+ * From WAP-230-WSP-20010705-a:
+ * Text-string = [Quote] *TEXT End-of-string
+ * ; If the first character in the TEXT is in the range of 128-255,
+ * ; a Quote character must precede it. Otherwise the Quote character
+ * ;must be omitted. The Quote is not part of the contents.
+ */
+ appendTextString(str.getBytes());
+ }
+
+ /**
+ * Append encoded string value to mMessage.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendEncodedString(final EncodedStringValue enStr) {
+ /*
+ * From OMA-TS-MMS-ENC-V1_3-20050927-C:
+ * Encoded-string-value = Text-string | Value-length Char-set Text-string
+ */
+ assert (enStr != null);
+
+ final int charset = enStr.getCharacterSet();
+ final byte[] textString = enStr.getTextString();
+ if (null == textString) {
+ return;
+ }
+
+ /*
+ * In the implementation of EncodedStringValue, the charset field will
+ * never be 0. It will always be composed as
+ * Encoded-string-value = Value-length Char-set Text-string
+ */
+ mStack.newbuf();
+ final PositionMarker start = mStack.mark();
+
+ appendShortInteger(charset);
+ appendTextString(textString);
+
+ final int len = start.getLength();
+ mStack.pop();
+ appendValueLength(len);
+ mStack.copy();
+ }
+
+ /**
+ * Append uintvar integer into mMessage.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendUintvarInteger(final long value) {
+ /*
+ * From WAP-230-WSP-20010705-a:
+ * To encode a large unsigned integer, split it into 7-bit fragments
+ * and place them in the payloads of multiple octets. The most significant
+ * bits are placed in the first octets with the least significant bits
+ * ending up in the last octet. All octets MUST set the Continue bit to 1
+ * except the last octet, which MUST set the Continue bit to 0.
+ */
+ int i;
+ long max = SHORT_INTEGER_MAX;
+
+ for (i = 0; i < 5; i++) {
+ if (value < max) {
+ break;
+ }
+
+ max = (max << 7) | 0x7fL;
+ }
+
+ while (i > 0) {
+ long temp = value >>> (i * 7);
+ temp = temp & 0x7f;
+
+ append((int) ((temp | 0x80) & 0xff));
+
+ i--;
+ }
+
+ append((int) (value & 0x7f));
+ }
+
+ /**
+ * Append date value into mMessage.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendDateValue(final long date) {
+ /*
+ * From OMA-TS-MMS-ENC-V1_3-20050927-C:
+ * Date-value = Long-integer
+ */
+ appendLongInteger(date);
+ }
+
+ /**
+ * Append value length to mMessage.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendValueLength(final long value) {
+ /*
+ * From WAP-230-WSP-20010705-a:
+ * Value-length = Short-length | (Length-quote Length)
+ * ; Value length is used to indicate the length of the value to follow
+ * Short-length = <Any octet 0-30>
+ * Length-quote = <Octet 31>
+ * Length = Uintvar-integer
+ */
+ if (value < LENGTH_QUOTE) {
+ appendShortLength((int) value);
+ return;
+ }
+
+ append(LENGTH_QUOTE);
+ appendUintvarInteger(value);
+ }
+
+ /**
+ * Append quoted string to mMessage.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendQuotedString(final byte[] text) {
+ /*
+ * From WAP-230-WSP-20010705-a:
+ * Quoted-string = <Octet 34> *TEXT End-of-string
+ * ;The TEXT encodes an RFC2616 Quoted-string with the enclosing
+ * ;quotation-marks <"> removed.
+ */
+ append(QUOTED_STRING_FLAG);
+ arraycopy(text, 0, text.length);
+ append(END_STRING_FLAG);
+ }
+
+ /**
+ * Append quoted string to mMessage.
+ * This implementation doesn't check the validity of parameter, since it
+ * assumes that the values are validated in the GenericPdu setter methods.
+ */
+ protected void appendQuotedString(final String str) {
+ /*
+ * From WAP-230-WSP-20010705-a:
+ * Quoted-string = <Octet 34> *TEXT End-of-string
+ * ;The TEXT encodes an RFC2616 Quoted-string with the enclosing
+ * ;quotation-marks <"> removed.
+ */
+ appendQuotedString(str.getBytes());
+ }
+
+ private EncodedStringValue appendAddressType(final EncodedStringValue address) {
+ EncodedStringValue temp = null;
+
+ try {
+ final int addressType = checkAddressType(address.getString());
+ temp = EncodedStringValue.copy(address);
+ if (PDU_PHONE_NUMBER_ADDRESS_TYPE == addressType) {
+ // Phone number.
+ temp.appendTextString(STRING_PHONE_NUMBER_ADDRESS_TYPE.getBytes());
+ } else if (PDU_IPV4_ADDRESS_TYPE == addressType) {
+ // Ipv4 address.
+ temp.appendTextString(STRING_IPV4_ADDRESS_TYPE.getBytes());
+ } else if (PDU_IPV6_ADDRESS_TYPE == addressType) {
+ // Ipv6 address.
+ temp.appendTextString(STRING_IPV6_ADDRESS_TYPE.getBytes());
+ }
+ } catch (final NullPointerException e) {
+ return null;
+ }
+
+ return temp;
+ }
+
+ /**
+ * Append header to mMessage.
+ */
+ private int appendHeader(final int field) {
+ switch (field) {
+ case PduHeaders.MMS_VERSION:
+ appendOctet(field);
+
+ final int version = mPduHeader.getOctet(field);
+ if (0 == version) {
+ appendShortInteger(PduHeaders.CURRENT_MMS_VERSION);
+ } else {
+ appendShortInteger(version);
+ }
+
+ break;
+
+ case PduHeaders.MESSAGE_ID:
+ case PduHeaders.TRANSACTION_ID:
+ case PduHeaders.CONTENT_LOCATION:
+ final byte[] textString = mPduHeader.getTextString(field);
+ if (null == textString) {
+ return PDU_COMPOSE_FIELD_NOT_SET;
+ }
+
+ appendOctet(field);
+ appendTextString(textString);
+ break;
+
+ case PduHeaders.TO:
+ case PduHeaders.BCC:
+ case PduHeaders.CC:
+ final EncodedStringValue[] addr = mPduHeader.getEncodedStringValues(field);
+
+ if (null == addr) {
+ return PDU_COMPOSE_FIELD_NOT_SET;
+ }
+
+ EncodedStringValue temp;
+ for (int i = 0; i < addr.length; i++) {
+ temp = appendAddressType(addr[i]);
+ if (temp == null) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ appendOctet(field);
+ appendEncodedString(temp);
+ }
+ break;
+
+ case PduHeaders.FROM:
+ // Value-length (Address-present-token Encoded-string-value | Insert-address-token)
+ appendOctet(field);
+
+ final EncodedStringValue from = mPduHeader.getEncodedStringValue(field);
+ if ((from == null)
+ || TextUtils.isEmpty(from.getString())
+ || new String(from.getTextString()).equals(
+ PduHeaders.FROM_INSERT_ADDRESS_TOKEN_STR)) {
+ // Length of from = 1
+ append(1);
+ // Insert-address-token = <Octet 129>
+ append(PduHeaders.FROM_INSERT_ADDRESS_TOKEN);
+ } else {
+ mStack.newbuf();
+ final PositionMarker fstart = mStack.mark();
+
+ // Address-present-token = <Octet 128>
+ append(PduHeaders.FROM_ADDRESS_PRESENT_TOKEN);
+
+ temp = appendAddressType(from);
+ if (temp == null) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ appendEncodedString(temp);
+
+ final int flen = fstart.getLength();
+ mStack.pop();
+ appendValueLength(flen);
+ mStack.copy();
+ }
+ break;
+
+ case PduHeaders.READ_STATUS:
+ case PduHeaders.STATUS:
+ case PduHeaders.REPORT_ALLOWED:
+ case PduHeaders.PRIORITY:
+ case PduHeaders.DELIVERY_REPORT:
+ case PduHeaders.READ_REPORT:
+ final int octet = mPduHeader.getOctet(field);
+ if (0 == octet) {
+ return PDU_COMPOSE_FIELD_NOT_SET;
+ }
+
+ appendOctet(field);
+ appendOctet(octet);
+ break;
+
+ case PduHeaders.DATE:
+ final long date = mPduHeader.getLongInteger(field);
+ if (-1 == date) {
+ return PDU_COMPOSE_FIELD_NOT_SET;
+ }
+
+ appendOctet(field);
+ appendDateValue(date);
+ break;
+
+ case PduHeaders.SUBJECT:
+ final EncodedStringValue enString =
+ mPduHeader.getEncodedStringValue(field);
+ if (null == enString) {
+ return PDU_COMPOSE_FIELD_NOT_SET;
+ }
+
+ appendOctet(field);
+ appendEncodedString(enString);
+ break;
+
+ case PduHeaders.MESSAGE_CLASS:
+ final byte[] messageClass = mPduHeader.getTextString(field);
+ if (null == messageClass) {
+ return PDU_COMPOSE_FIELD_NOT_SET;
+ }
+
+ appendOctet(field);
+ if (Arrays.equals(messageClass,
+ PduHeaders.MESSAGE_CLASS_ADVERTISEMENT_STR.getBytes())) {
+ appendOctet(PduHeaders.MESSAGE_CLASS_ADVERTISEMENT);
+ } else if (Arrays.equals(messageClass,
+ PduHeaders.MESSAGE_CLASS_AUTO_STR.getBytes())) {
+ appendOctet(PduHeaders.MESSAGE_CLASS_AUTO);
+ } else if (Arrays.equals(messageClass,
+ PduHeaders.MESSAGE_CLASS_PERSONAL_STR.getBytes())) {
+ appendOctet(PduHeaders.MESSAGE_CLASS_PERSONAL);
+ } else if (Arrays.equals(messageClass,
+ PduHeaders.MESSAGE_CLASS_INFORMATIONAL_STR.getBytes())) {
+ appendOctet(PduHeaders.MESSAGE_CLASS_INFORMATIONAL);
+ } else {
+ appendTextString(messageClass);
+ }
+ break;
+
+ case PduHeaders.EXPIRY:
+ case PduHeaders.MESSAGE_SIZE:
+ final long value = mPduHeader.getLongInteger(field);
+ if (-1 == value) {
+ return PDU_COMPOSE_FIELD_NOT_SET;
+ }
+
+ appendOctet(field);
+
+ mStack.newbuf();
+ final PositionMarker valueStart = mStack.mark();
+
+ append(PduHeaders.VALUE_RELATIVE_TOKEN);
+ appendLongInteger(value);
+
+ final int valueLength = valueStart.getLength();
+ mStack.pop();
+ appendValueLength(valueLength);
+ mStack.copy();
+ break;
+
+ default:
+ return PDU_COMPOSE_FIELD_NOT_SUPPORTED;
+ }
+
+ return PDU_COMPOSE_SUCCESS;
+ }
+
+ /**
+ * Make ReadRec.Ind.
+ */
+ private int makeReadRecInd() {
+ if (mMessage == null) {
+ mMessage = new ByteArrayOutputStream();
+ mPosition = 0;
+ }
+
+ // X-Mms-Message-Type
+ appendOctet(PduHeaders.MESSAGE_TYPE);
+ appendOctet(PduHeaders.MESSAGE_TYPE_READ_REC_IND);
+
+ // X-Mms-MMS-Version
+ if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // Message-ID
+ if (appendHeader(PduHeaders.MESSAGE_ID) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // To
+ if (appendHeader(PduHeaders.TO) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // From
+ if (appendHeader(PduHeaders.FROM) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // Date Optional
+ appendHeader(PduHeaders.DATE);
+
+ // X-Mms-Read-Status
+ if (appendHeader(PduHeaders.READ_STATUS) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // X-Mms-Applic-ID Optional(not support)
+ // X-Mms-Reply-Applic-ID Optional(not support)
+ // X-Mms-Aux-Applic-Info Optional(not support)
+
+ return PDU_COMPOSE_SUCCESS;
+ }
+
+ /**
+ * Make NotifyResp.Ind.
+ */
+ private int makeNotifyResp() {
+ if (mMessage == null) {
+ mMessage = new ByteArrayOutputStream();
+ mPosition = 0;
+ }
+
+ // X-Mms-Message-Type
+ appendOctet(PduHeaders.MESSAGE_TYPE);
+ appendOctet(PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND);
+
+ // X-Mms-Transaction-ID
+ if (appendHeader(PduHeaders.TRANSACTION_ID) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // X-Mms-MMS-Version
+ if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // X-Mms-Status
+ if (appendHeader(PduHeaders.STATUS) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // X-Mms-Report-Allowed Optional (not support)
+ return PDU_COMPOSE_SUCCESS;
+ }
+
+ /**
+ * Make Acknowledge.Ind.
+ */
+ private int makeAckInd() {
+ if (mMessage == null) {
+ mMessage = new ByteArrayOutputStream();
+ mPosition = 0;
+ }
+
+ // X-Mms-Message-Type
+ appendOctet(PduHeaders.MESSAGE_TYPE);
+ appendOctet(PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND);
+
+ // X-Mms-Transaction-ID
+ if (appendHeader(PduHeaders.TRANSACTION_ID) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // X-Mms-MMS-Version
+ if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // X-Mms-Report-Allowed Optional
+ appendHeader(PduHeaders.REPORT_ALLOWED);
+
+ return PDU_COMPOSE_SUCCESS;
+ }
+
+ /**
+ * Make Acknowledge.Ind.
+ */
+ private int makeNotificationInd() {
+ if (mMessage == null) {
+ mMessage = new ByteArrayOutputStream();
+ mPosition = 0;
+ }
+
+ // X-Mms-Message-Type
+ appendOctet(PduHeaders.MESSAGE_TYPE);
+ appendOctet(PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND);
+
+ // X-Mms-Transaction-ID
+ if (appendHeader(PduHeaders.TRANSACTION_ID) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // X-Mms-MMS-Version
+ if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // From
+ if (appendHeader(PduHeaders.FROM) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // Subject Optional
+ appendHeader(PduHeaders.SUBJECT);
+
+ // Expiry
+ if (appendHeader(PduHeaders.MESSAGE_CLASS) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // Expiry
+ if (appendHeader(PduHeaders.MESSAGE_SIZE) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // Expiry
+ if (appendHeader(PduHeaders.EXPIRY) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // X-Mms-Content-Location
+ if (appendHeader(PduHeaders.CONTENT_LOCATION) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ return PDU_COMPOSE_SUCCESS;
+ }
+
+ /**
+ * Make Send.req.
+ */
+ private int makeSendReqPdu() {
+ if (mMessage == null) {
+ mMessage = new ByteArrayOutputStream();
+ mPosition = 0;
+ }
+
+ // X-Mms-Message-Type
+ appendOctet(PduHeaders.MESSAGE_TYPE);
+ appendOctet(PduHeaders.MESSAGE_TYPE_SEND_REQ);
+
+ // X-Mms-Transaction-ID
+ appendOctet(PduHeaders.TRANSACTION_ID);
+
+ final byte[] trid = mPduHeader.getTextString(PduHeaders.TRANSACTION_ID);
+ if (trid == null) {
+ // Transaction-ID should be set(by Transaction) before make().
+ throw new IllegalArgumentException("Transaction-ID is null.");
+ }
+ appendTextString(trid);
+
+ // X-Mms-MMS-Version
+ if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // Date Date-value Optional.
+ appendHeader(PduHeaders.DATE);
+
+ // From
+ if (appendHeader(PduHeaders.FROM) != PDU_COMPOSE_SUCCESS) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ boolean recipient = false;
+
+ // To
+ if (appendHeader(PduHeaders.TO) != PDU_COMPOSE_CONTENT_ERROR) {
+ recipient = true;
+ }
+
+ // Cc
+ if (appendHeader(PduHeaders.CC) != PDU_COMPOSE_CONTENT_ERROR) {
+ recipient = true;
+ }
+
+ // Bcc
+ if (appendHeader(PduHeaders.BCC) != PDU_COMPOSE_CONTENT_ERROR) {
+ recipient = true;
+ }
+
+ // Need at least one of "cc", "bcc" and "to".
+ if (false == recipient) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // Subject Optional
+ appendHeader(PduHeaders.SUBJECT);
+
+ // X-Mms-Message-Class Optional
+ // Message-class-value = Class-identifier | Token-text
+ appendHeader(PduHeaders.MESSAGE_CLASS);
+
+ // X-Mms-Expiry Optional
+ appendHeader(PduHeaders.EXPIRY);
+
+ // X-Mms-Priority Optional
+ appendHeader(PduHeaders.PRIORITY);
+
+ // X-Mms-Delivery-Report Optional
+ appendHeader(PduHeaders.DELIVERY_REPORT);
+
+ // X-Mms-Read-Report Optional
+ appendHeader(PduHeaders.READ_REPORT);
+
+ // Content-Type
+ appendOctet(PduHeaders.CONTENT_TYPE);
+
+ // Message body
+ return makeMessageBody();
+ }
+
+ /**
+ * Make message body.
+ */
+ private int makeMessageBody() {
+ // 1. add body informations
+ mStack.newbuf(); // Switching buffer because we need to
+
+ final PositionMarker ctStart = mStack.mark();
+
+ // This contentTypeIdentifier should be used for type of attachment...
+ final String contentType = new String(mPduHeader.getTextString(PduHeaders.CONTENT_TYPE));
+ final Integer contentTypeIdentifier = mContentTypeMap.get(contentType);
+ if (contentTypeIdentifier == null) {
+ // content type is mandatory
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ appendShortInteger(contentTypeIdentifier.intValue());
+
+ // content-type parameter: start
+ final PduBody body = ((SendReq) mPdu).getBody();
+ if (null == body || body.getPartsNum() == 0) {
+ // empty message
+ appendUintvarInteger(0);
+ mStack.pop();
+ mStack.copy();
+ return PDU_COMPOSE_SUCCESS;
+ }
+
+ PduPart part;
+ try {
+ part = body.getPart(0);
+
+ final byte[] start = part.getContentId();
+ if (start != null) {
+ appendOctet(PduPart.P_DEP_START);
+ if (('<' == start[0]) && ('>' == start[start.length - 1])) {
+ appendTextString(start);
+ } else {
+ appendTextString("<" + new String(start) + ">");
+ }
+ }
+
+ // content-type parameter: type
+ appendOctet(PduPart.P_CT_MR_TYPE);
+ appendTextString(part.getContentType());
+ } catch (final ArrayIndexOutOfBoundsException e) {
+ e.printStackTrace();
+ }
+
+ final int ctLength = ctStart.getLength();
+ mStack.pop();
+ appendValueLength(ctLength);
+ mStack.copy();
+
+ // 3. add content
+ final int partNum = body.getPartsNum();
+ appendUintvarInteger(partNum);
+ for (int i = 0; i < partNum; i++) {
+ part = body.getPart(i);
+ mStack.newbuf(); // Leaving space for header lengh and data length
+ final PositionMarker attachment = mStack.mark();
+
+ mStack.newbuf(); // Leaving space for Content-Type length
+ final PositionMarker contentTypeBegin = mStack.mark();
+
+ final byte[] partContentType = part.getContentType();
+
+ if (partContentType == null) {
+ // content type is mandatory
+ return PDU_COMPOSE_CONTENT_ERROR;
+ }
+
+ // content-type value
+ final Integer partContentTypeIdentifier =
+ mContentTypeMap.get(new String(partContentType));
+ if (partContentTypeIdentifier == null) {
+ appendTextString(partContentType);
+ } else {
+ appendShortInteger(partContentTypeIdentifier.intValue());
+ }
+
+ /* Content-type parameter : name.
+ * The value of name, filename, content-location is the same.
+ * Just one of them is enough for this PDU.
+ */
+ byte[] name = part.getName();
+
+ if (null == name) {
+ name = part.getFilename();
+
+ if (null == name) {
+ name = part.getContentLocation();
+
+ if (null == name) {
+ /* at lease one of name, filename, Content-location
+ * should be available.
+ */
+ // I found that an mms received from tmomail.net will include a SMIL part
+ // that has no name. That would cause the code here to return
+ // PDU_COMPOSE_CONTENT_ERROR when a user tried to forward the message. The
+ // message would never send and the user would be stuck in a retry
+ // situation. Simply jam in any old name here to fix the problem.
+ name = "smil.xml".getBytes();
+ }
+ }
+ }
+ appendOctet(PduPart.P_DEP_NAME);
+ appendTextString(name);
+
+ // content-type parameter : charset
+ final int charset = part.getCharset();
+ if (charset != 0) {
+ appendOctet(PduPart.P_CHARSET);
+ appendShortInteger(charset);
+ }
+
+ final int contentTypeLength = contentTypeBegin.getLength();
+ mStack.pop();
+ appendValueLength(contentTypeLength);
+ mStack.copy();
+
+ // content id
+ final byte[] contentId = part.getContentId();
+
+ if (null != contentId) {
+ appendOctet(PduPart.P_CONTENT_ID);
+ if (('<' == contentId[0]) && ('>' == contentId[contentId.length - 1])) {
+ appendQuotedString(contentId);
+ } else {
+ appendQuotedString("<" + new String(contentId) + ">");
+ }
+ }
+
+ // content-location
+ final byte[] contentLocation = part.getContentLocation();
+ if (null != contentLocation) {
+ appendOctet(PduPart.P_CONTENT_LOCATION);
+ appendTextString(contentLocation);
+ }
+
+ // content
+ final int headerLength = attachment.getLength();
+
+ int dataLength = 0; // Just for safety...
+ final byte[] partData = part.getData();
+
+ if (partData != null) {
+ arraycopy(partData, 0, partData.length);
+ dataLength = partData.length;
+ } else {
+ InputStream cr = null;
+ try {
+ final byte[] buffer = new byte[PDU_COMPOSER_BLOCK_SIZE];
+ cr = mResolver.openInputStream(part.getDataUri());
+ int len = 0;
+ while ((len = cr.read(buffer)) != -1) {
+ mMessage.write(buffer, 0, len);
+ mPosition += len;
+ dataLength += len;
+ }
+ } catch (final FileNotFoundException e) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ } catch (final IOException e) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ } catch (final RuntimeException e) {
+ return PDU_COMPOSE_CONTENT_ERROR;
+ } finally {
+ if (cr != null) {
+ try {
+ cr.close();
+ } catch (final IOException e) {
+ // Nothing to do
+ }
+ }
+ }
+ }
+
+ if (dataLength != (attachment.getLength() - headerLength)) {
+ throw new RuntimeException("BUG: Length sanity check failed");
+ }
+
+ mStack.pop();
+ appendUintvarInteger(headerLength);
+ appendUintvarInteger(dataLength);
+ mStack.copy();
+ }
+
+ return PDU_COMPOSE_SUCCESS;
+ }
+
+ /**
+ * Record current message informations.
+ */
+ private static class LengthRecordNode {
+
+ ByteArrayOutputStream currentMessage = null;
+
+ public int currentPosition = 0;
+
+ public LengthRecordNode next = null;
+ }
+
+ /**
+ * Mark current message position and stact size.
+ */
+ private class PositionMarker {
+
+ private int c_pos; // Current position
+
+ private int currentStackSize; // Current stack size
+
+ int getLength() {
+ // If these assert fails, likely that you are finding the
+ // size of buffer that is deep in BufferStack you can only
+ // find the length of the buffer that is on top
+ if (currentStackSize != mStack.stackSize) {
+ throw new RuntimeException("BUG: Invalid call to getLength()");
+ }
+
+ return mPosition - c_pos;
+ }
+ }
+
+ /**
+ * This implementation can be OPTIMIZED to use only
+ * 2 buffers. This optimization involves changing BufferStack
+ * only... Its usage (interface) will not change.
+ */
+ private class BufferStack {
+
+ private LengthRecordNode stack = null;
+
+ private LengthRecordNode toCopy = null;
+
+ int stackSize = 0;
+
+ /**
+ * Create a new message buffer and push it into the stack.
+ */
+ void newbuf() {
+ // You can't create a new buff when toCopy != null
+ // That is after calling pop() and before calling copy()
+ // If you do, it is a bug
+ if (toCopy != null) {
+ throw new RuntimeException("BUG: Invalid newbuf() before copy()");
+ }
+
+ final LengthRecordNode temp = new LengthRecordNode();
+
+ temp.currentMessage = mMessage;
+ temp.currentPosition = mPosition;
+
+ temp.next = stack;
+ stack = temp;
+
+ stackSize = stackSize + 1;
+
+ mMessage = new ByteArrayOutputStream();
+ mPosition = 0;
+ }
+
+ /**
+ * Pop the message before and record current message in the stack.
+ */
+ void pop() {
+ final ByteArrayOutputStream currentMessage = mMessage;
+ final int currentPosition = mPosition;
+
+ mMessage = stack.currentMessage;
+ mPosition = stack.currentPosition;
+
+ toCopy = stack;
+ // Re using the top element of the stack to avoid memory allocation
+
+ stack = stack.next;
+ stackSize = stackSize - 1;
+
+ toCopy.currentMessage = currentMessage;
+ toCopy.currentPosition = currentPosition;
+ }
+
+ /**
+ * Append current message to the message before.
+ */
+ void copy() {
+ arraycopy(toCopy.currentMessage.toByteArray(), 0,
+ toCopy.currentPosition);
+
+ toCopy = null;
+ }
+
+ /**
+ * Mark current message position
+ */
+ PositionMarker mark() {
+ final PositionMarker m = new PositionMarker();
+
+ m.c_pos = mPosition;
+ m.currentStackSize = stackSize;
+
+ return m;
+ }
+ }
+
+ /**
+ * Check address type.
+ *
+ * @param address address string without the postfix stinng type,
+ * such as "/TYPE=PLMN", "/TYPE=IPv6" and "/TYPE=IPv4"
+ * @return PDU_PHONE_NUMBER_ADDRESS_TYPE if it is phone number,
+ * PDU_EMAIL_ADDRESS_TYPE if it is email address,
+ * PDU_IPV4_ADDRESS_TYPE if it is ipv4 address,
+ * PDU_IPV6_ADDRESS_TYPE if it is ipv6 address,
+ * PDU_UNKNOWN_ADDRESS_TYPE if it is unknown.
+ */
+ protected static int checkAddressType(final String address) {
+ /**
+ * From OMA-TS-MMS-ENC-V1_3-20050927-C.pdf, section 8.
+ * address = ( e-mail / device-address / alphanum-shortcode / num-shortcode)
+ * e-mail = mailbox; to the definition of mailbox as described in
+ * section 3.4 of [RFC2822], but excluding the
+ * obsolete definitions as indicated by the "obs-" prefix.
+ * device-address = ( global-phone-number "/TYPE=PLMN" )
+ * / ( ipv4 "/TYPE=IPv4" ) / ( ipv6 "/TYPE=IPv6" )
+ * / ( escaped-value "/TYPE=" address-type )
+ *
+ * global-phone-number = ["+"] 1*( DIGIT / written-sep )
+ * written-sep =("-"/".")
+ *
+ * ipv4 = 1*3DIGIT 3( "." 1*3DIGIT ) ; IPv4 address value
+ *
+ * ipv6 = 4HEXDIG 7( ":" 4HEXDIG ) ; IPv6 address per RFC 2373
+ */
+
+ if (null == address) {
+ return PDU_UNKNOWN_ADDRESS_TYPE;
+ }
+
+ if (address.matches(REGEXP_IPV4_ADDRESS_TYPE)) {
+ // Ipv4 address.
+ return PDU_IPV4_ADDRESS_TYPE;
+ } else if (address.matches(REGEXP_PHONE_NUMBER_ADDRESS_TYPE)) {
+ // Phone number.
+ return PDU_PHONE_NUMBER_ADDRESS_TYPE;
+ } else if (address.matches(REGEXP_EMAIL_ADDRESS_TYPE)) {
+ // Email address.
+ return PDU_EMAIL_ADDRESS_TYPE;
+ } else if (address.matches(REGEXP_IPV6_ADDRESS_TYPE)) {
+ // Ipv6 address.
+ return PDU_IPV6_ADDRESS_TYPE;
+ } else {
+ // Unknown address.
+ return PDU_UNKNOWN_ADDRESS_TYPE;
+ }
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/PduContentTypes.java b/src/com/android/messaging/mmslib/pdu/PduContentTypes.java
new file mode 100644
index 0000000..f2cebf1
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/PduContentTypes.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+public class PduContentTypes {
+ /**
+ * All content types. From:
+ * http://www.openmobilealliance.org/tech/omna/omna-wsp-content-type.htm
+ */
+ static final String[] contentTypes = {
+ "*/*", /* 0x00 */
+ "text/*", /* 0x01 */
+ "text/html", /* 0x02 */
+ "text/plain", /* 0x03 */
+ "text/x-hdml", /* 0x04 */
+ "text/x-ttml", /* 0x05 */
+ "text/x-vCalendar", /* 0x06 */
+ "text/x-vCard", /* 0x07 */
+ "text/vnd.wap.wml", /* 0x08 */
+ "text/vnd.wap.wmlscript", /* 0x09 */
+ "text/vnd.wap.wta-event", /* 0x0A */
+ "multipart/*", /* 0x0B */
+ "multipart/mixed", /* 0x0C */
+ "multipart/form-data", /* 0x0D */
+ "multipart/byterantes", /* 0x0E */
+ "multipart/alternative", /* 0x0F */
+ "application/*", /* 0x10 */
+ "application/java-vm", /* 0x11 */
+ "application/x-www-form-urlencoded", /* 0x12 */
+ "application/x-hdmlc", /* 0x13 */
+ "application/vnd.wap.wmlc", /* 0x14 */
+ "application/vnd.wap.wmlscriptc", /* 0x15 */
+ "application/vnd.wap.wta-eventc", /* 0x16 */
+ "application/vnd.wap.uaprof", /* 0x17 */
+ "application/vnd.wap.wtls-ca-certificate", /* 0x18 */
+ "application/vnd.wap.wtls-user-certificate", /* 0x19 */
+ "application/x-x509-ca-cert", /* 0x1A */
+ "application/x-x509-user-cert", /* 0x1B */
+ "image/*", /* 0x1C */
+ "image/gif", /* 0x1D */
+ "image/jpeg", /* 0x1E */
+ "image/tiff", /* 0x1F */
+ "image/png", /* 0x20 */
+ "image/vnd.wap.wbmp", /* 0x21 */
+ "application/vnd.wap.multipart.*", /* 0x22 */
+ "application/vnd.wap.multipart.mixed", /* 0x23 */
+ "application/vnd.wap.multipart.form-data", /* 0x24 */
+ "application/vnd.wap.multipart.byteranges", /* 0x25 */
+ "application/vnd.wap.multipart.alternative", /* 0x26 */
+ "application/xml", /* 0x27 */
+ "text/xml", /* 0x28 */
+ "application/vnd.wap.wbxml", /* 0x29 */
+ "application/x-x968-cross-cert", /* 0x2A */
+ "application/x-x968-ca-cert", /* 0x2B */
+ "application/x-x968-user-cert", /* 0x2C */
+ "text/vnd.wap.si", /* 0x2D */
+ "application/vnd.wap.sic", /* 0x2E */
+ "text/vnd.wap.sl", /* 0x2F */
+ "application/vnd.wap.slc", /* 0x30 */
+ "text/vnd.wap.co", /* 0x31 */
+ "application/vnd.wap.coc", /* 0x32 */
+ "application/vnd.wap.multipart.related", /* 0x33 */
+ "application/vnd.wap.sia", /* 0x34 */
+ "text/vnd.wap.connectivity-xml", /* 0x35 */
+ "application/vnd.wap.connectivity-wbxml", /* 0x36 */
+ "application/pkcs7-mime", /* 0x37 */
+ "application/vnd.wap.hashed-certificate", /* 0x38 */
+ "application/vnd.wap.signed-certificate", /* 0x39 */
+ "application/vnd.wap.cert-response", /* 0x3A */
+ "application/xhtml+xml", /* 0x3B */
+ "application/wml+xml", /* 0x3C */
+ "text/css", /* 0x3D */
+ "application/vnd.wap.mms-message", /* 0x3E */
+ "application/vnd.wap.rollover-certificate", /* 0x3F */
+ "application/vnd.wap.locc+wbxml", /* 0x40 */
+ "application/vnd.wap.loc+xml", /* 0x41 */
+ "application/vnd.syncml.dm+wbxml", /* 0x42 */
+ "application/vnd.syncml.dm+xml", /* 0x43 */
+ "application/vnd.syncml.notification", /* 0x44 */
+ "application/vnd.wap.xhtml+xml", /* 0x45 */
+ "application/vnd.wv.csp.cir", /* 0x46 */
+ "application/vnd.oma.dd+xml", /* 0x47 */
+ "application/vnd.oma.drm.message", /* 0x48 */
+ "application/vnd.oma.drm.content", /* 0x49 */
+ "application/vnd.oma.drm.rights+xml", /* 0x4A */
+ "application/vnd.oma.drm.rights+wbxml", /* 0x4B */
+ "application/vnd.wv.csp+xml", /* 0x4C */
+ "application/vnd.wv.csp+wbxml", /* 0x4D */
+ "application/vnd.syncml.ds.notification", /* 0x4E */
+ "audio/*", /* 0x4F */
+ "video/*", /* 0x50 */
+ "application/vnd.oma.dd2+xml", /* 0x51 */
+ "application/mikey" /* 0x52 */
+ };
+}
diff --git a/src/com/android/messaging/mmslib/pdu/PduHeaders.java b/src/com/android/messaging/mmslib/pdu/PduHeaders.java
new file mode 100644
index 0000000..96d18ee
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/PduHeaders.java
@@ -0,0 +1,743 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import android.util.SparseArray;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+import java.util.ArrayList;
+
+public class PduHeaders {
+ /**
+ * All pdu header fields.
+ */
+ public static final int BCC = 0x81;
+ public static final int CC = 0x82;
+ public static final int CONTENT_LOCATION = 0x83;
+ public static final int CONTENT_TYPE = 0x84;
+ public static final int DATE = 0x85;
+ public static final int DELIVERY_REPORT = 0x86;
+ public static final int DELIVERY_TIME = 0x87;
+ public static final int EXPIRY = 0x88;
+ public static final int FROM = 0x89;
+ public static final int MESSAGE_CLASS = 0x8A;
+ public static final int MESSAGE_ID = 0x8B;
+ public static final int MESSAGE_TYPE = 0x8C;
+ public static final int MMS_VERSION = 0x8D;
+ public static final int MESSAGE_SIZE = 0x8E;
+ public static final int PRIORITY = 0x8F;
+
+ public static final int READ_REPLY = 0x90;
+ public static final int READ_REPORT = 0x90;
+ public static final int REPORT_ALLOWED = 0x91;
+ public static final int RESPONSE_STATUS = 0x92;
+ public static final int RESPONSE_TEXT = 0x93;
+ public static final int SENDER_VISIBILITY = 0x94;
+ public static final int STATUS = 0x95;
+ public static final int SUBJECT = 0x96;
+ public static final int TO = 0x97;
+ public static final int TRANSACTION_ID = 0x98;
+ public static final int RETRIEVE_STATUS = 0x99;
+ public static final int RETRIEVE_TEXT = 0x9A;
+ public static final int READ_STATUS = 0x9B;
+ public static final int REPLY_CHARGING = 0x9C;
+ public static final int REPLY_CHARGING_DEADLINE = 0x9D;
+ public static final int REPLY_CHARGING_ID = 0x9E;
+ public static final int REPLY_CHARGING_SIZE = 0x9F;
+
+ public static final int PREVIOUSLY_SENT_BY = 0xA0;
+ public static final int PREVIOUSLY_SENT_DATE = 0xA1;
+ public static final int STORE = 0xA2;
+ public static final int MM_STATE = 0xA3;
+ public static final int MM_FLAGS = 0xA4;
+ public static final int STORE_STATUS = 0xA5;
+ public static final int STORE_STATUS_TEXT = 0xA6;
+ public static final int STORED = 0xA7;
+ public static final int ATTRIBUTES = 0xA8;
+ public static final int TOTALS = 0xA9;
+ public static final int MBOX_TOTALS = 0xAA;
+ public static final int QUOTAS = 0xAB;
+ public static final int MBOX_QUOTAS = 0xAC;
+ public static final int MESSAGE_COUNT = 0xAD;
+ public static final int CONTENT = 0xAE;
+ public static final int START = 0xAF;
+
+ public static final int ADDITIONAL_HEADERS = 0xB0;
+ public static final int DISTRIBUTION_INDICATOR = 0xB1;
+ public static final int ELEMENT_DESCRIPTOR = 0xB2;
+ public static final int LIMIT = 0xB3;
+ public static final int RECOMMENDED_RETRIEVAL_MODE = 0xB4;
+ public static final int RECOMMENDED_RETRIEVAL_MODE_TEXT = 0xB5;
+ public static final int STATUS_TEXT = 0xB6;
+ public static final int APPLIC_ID = 0xB7;
+ public static final int REPLY_APPLIC_ID = 0xB8;
+ public static final int AUX_APPLIC_ID = 0xB9;
+ public static final int CONTENT_CLASS = 0xBA;
+ public static final int DRM_CONTENT = 0xBB;
+ public static final int ADAPTATION_ALLOWED = 0xBC;
+ public static final int REPLACE_ID = 0xBD;
+ public static final int CANCEL_ID = 0xBE;
+ public static final int CANCEL_STATUS = 0xBF;
+
+ /**
+ * X-Mms-Message-Type field types.
+ */
+ public static final int MESSAGE_TYPE_SEND_REQ = 0x80;
+ public static final int MESSAGE_TYPE_SEND_CONF = 0x81;
+ public static final int MESSAGE_TYPE_NOTIFICATION_IND = 0x82;
+ public static final int MESSAGE_TYPE_NOTIFYRESP_IND = 0x83;
+ public static final int MESSAGE_TYPE_RETRIEVE_CONF = 0x84;
+ public static final int MESSAGE_TYPE_ACKNOWLEDGE_IND = 0x85;
+ public static final int MESSAGE_TYPE_DELIVERY_IND = 0x86;
+ public static final int MESSAGE_TYPE_READ_REC_IND = 0x87;
+ public static final int MESSAGE_TYPE_READ_ORIG_IND = 0x88;
+ public static final int MESSAGE_TYPE_FORWARD_REQ = 0x89;
+ public static final int MESSAGE_TYPE_FORWARD_CONF = 0x8A;
+ public static final int MESSAGE_TYPE_MBOX_STORE_REQ = 0x8B;
+ public static final int MESSAGE_TYPE_MBOX_STORE_CONF = 0x8C;
+ public static final int MESSAGE_TYPE_MBOX_VIEW_REQ = 0x8D;
+ public static final int MESSAGE_TYPE_MBOX_VIEW_CONF = 0x8E;
+ public static final int MESSAGE_TYPE_MBOX_UPLOAD_REQ = 0x8F;
+ public static final int MESSAGE_TYPE_MBOX_UPLOAD_CONF = 0x90;
+ public static final int MESSAGE_TYPE_MBOX_DELETE_REQ = 0x91;
+ public static final int MESSAGE_TYPE_MBOX_DELETE_CONF = 0x92;
+ public static final int MESSAGE_TYPE_MBOX_DESCR = 0x93;
+ public static final int MESSAGE_TYPE_DELETE_REQ = 0x94;
+ public static final int MESSAGE_TYPE_DELETE_CONF = 0x95;
+ public static final int MESSAGE_TYPE_CANCEL_REQ = 0x96;
+ public static final int MESSAGE_TYPE_CANCEL_CONF = 0x97;
+
+ /**
+ * X-Mms-Delivery-Report |
+ * X-Mms-Read-Report |
+ * X-Mms-Report-Allowed |
+ * X-Mms-Sender-Visibility |
+ * X-Mms-Store |
+ * X-Mms-Stored |
+ * X-Mms-Totals |
+ * X-Mms-Quotas |
+ * X-Mms-Distribution-Indicator |
+ * X-Mms-DRM-Content |
+ * X-Mms-Adaptation-Allowed |
+ * field types.
+ */
+ public static final int VALUE_YES = 0x80;
+ public static final int VALUE_NO = 0x81;
+
+ /**
+ * Delivery-Time |
+ * Expiry and Reply-Charging-Deadline |
+ * field type components.
+ */
+ public static final int VALUE_ABSOLUTE_TOKEN = 0x80;
+ public static final int VALUE_RELATIVE_TOKEN = 0x81;
+
+ /**
+ * X-Mms-MMS-Version field types.
+ */
+ public static final int MMS_VERSION_1_3 = ((1 << 4) | 3);
+ public static final int MMS_VERSION_1_2 = ((1 << 4) | 2);
+ public static final int MMS_VERSION_1_1 = ((1 << 4) | 1);
+ public static final int MMS_VERSION_1_0 = ((1 << 4) | 0);
+
+ // Current version is 1.2.
+ public static final int CURRENT_MMS_VERSION = MMS_VERSION_1_2;
+
+ /**
+ * From field type components.
+ */
+ public static final int FROM_ADDRESS_PRESENT_TOKEN = 0x80;
+ public static final int FROM_INSERT_ADDRESS_TOKEN = 0x81;
+
+ public static final String FROM_ADDRESS_PRESENT_TOKEN_STR = "address-present-token";
+
+ public static final String FROM_INSERT_ADDRESS_TOKEN_STR = "insert-address-token";
+
+ /**
+ * X-Mms-Status Field.
+ */
+ public static final int STATUS_EXPIRED = 0x80;
+ public static final int STATUS_RETRIEVED = 0x81;
+ public static final int STATUS_REJECTED = 0x82;
+ public static final int STATUS_DEFERRED = 0x83;
+ public static final int STATUS_UNRECOGNIZED = 0x84;
+ public static final int STATUS_INDETERMINATE = 0x85;
+ public static final int STATUS_FORWARDED = 0x86;
+ public static final int STATUS_UNREACHABLE = 0x87;
+
+ /**
+ * MM-Flags field type components.
+ */
+ public static final int MM_FLAGS_ADD_TOKEN = 0x80;
+ public static final int MM_FLAGS_REMOVE_TOKEN = 0x81;
+ public static final int MM_FLAGS_FILTER_TOKEN = 0x82;
+
+ /**
+ * X-Mms-Message-Class field types.
+ */
+ public static final int MESSAGE_CLASS_PERSONAL = 0x80;
+ public static final int MESSAGE_CLASS_ADVERTISEMENT = 0x81;
+ public static final int MESSAGE_CLASS_INFORMATIONAL = 0x82;
+ public static final int MESSAGE_CLASS_AUTO = 0x83;
+
+ public static final String MESSAGE_CLASS_PERSONAL_STR = "personal";
+
+ public static final String MESSAGE_CLASS_ADVERTISEMENT_STR = "advertisement";
+
+ public static final String MESSAGE_CLASS_INFORMATIONAL_STR = "informational";
+
+ public static final String MESSAGE_CLASS_AUTO_STR = "auto";
+
+ /**
+ * X-Mms-Priority field types.
+ */
+ public static final int PRIORITY_LOW = 0x80;
+ public static final int PRIORITY_NORMAL = 0x81;
+ public static final int PRIORITY_HIGH = 0x82;
+
+ /**
+ * X-Mms-Response-Status field types.
+ */
+ public static final int RESPONSE_STATUS_OK = 0x80;
+ public static final int RESPONSE_STATUS_ERROR_UNSPECIFIED = 0x81;
+ public static final int RESPONSE_STATUS_ERROR_SERVICE_DENIED = 0x82;
+
+ public static final int RESPONSE_STATUS_ERROR_MESSAGE_FORMAT_CORRUPT = 0x83;
+ public static final int RESPONSE_STATUS_ERROR_SENDING_ADDRESS_UNRESOLVED = 0x84;
+
+ public static final int RESPONSE_STATUS_ERROR_MESSAGE_NOT_FOUND = 0x85;
+ public static final int RESPONSE_STATUS_ERROR_NETWORK_PROBLEM = 0x86;
+ public static final int RESPONSE_STATUS_ERROR_CONTENT_NOT_ACCEPTED = 0x87;
+ public static final int RESPONSE_STATUS_ERROR_UNSUPPORTED_MESSAGE = 0x88;
+ public static final int RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0;
+
+ public static final int RESPONSE_STATUS_ERROR_TRANSIENT_SENDNG_ADDRESS_UNRESOLVED = 0xC1;
+ public static final int RESPONSE_STATUS_ERROR_TRANSIENT_MESSAGE_NOT_FOUND = 0xC2;
+ public static final int RESPONSE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC3;
+ public static final int RESPONSE_STATUS_ERROR_TRANSIENT_PARTIAL_SUCCESS = 0xC4;
+
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_FAILURE
+ = 0xE0;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_SERVICE_DENIED
+ = 0xE1;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_MESSAGE_FORMAT_CORRUPT
+ = 0xE2;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_SENDING_ADDRESS_UNRESOLVED
+ = 0xE3;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND
+ = 0xE4;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_CONTENT_NOT_ACCEPTED
+ = 0xE5;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_LIMITATIONS_NOT_MET
+ = 0xE6;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_REQUEST_NOT_ACCEPTED
+ = 0xE7;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_FORWARDING_DENIED
+ = 0xE8;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_NOT_SUPPORTED
+ = 0xE9;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_ADDRESS_HIDING_NOT_SUPPORTED
+ = 0xEA;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_LACK_OF_PREPAID
+ = 0xEB;
+ public static final int RESPONSE_STATUS_ERROR_PERMANENT_END
+ = 0xFF;
+
+ /**
+ * X-Mms-Retrieve-Status field types.
+ */
+ public static final int RETRIEVE_STATUS_OK = 0x80;
+ public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0;
+ public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_MESSAGE_NOT_FOUND = 0xC1;
+ public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC2;
+ public static final int RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE = 0xE0;
+ public static final int RETRIEVE_STATUS_ERROR_PERMANENT_SERVICE_DENIED = 0xE1;
+ public static final int RETRIEVE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND = 0xE2;
+ public static final int RETRIEVE_STATUS_ERROR_PERMANENT_CONTENT_UNSUPPORTED = 0xE3;
+ public static final int RETRIEVE_STATUS_ERROR_END = 0xFF;
+
+ /**
+ * X-Mms-Sender-Visibility field types.
+ */
+ public static final int SENDER_VISIBILITY_HIDE = 0x80;
+ public static final int SENDER_VISIBILITY_SHOW = 0x81;
+
+ /**
+ * X-Mms-Read-Status field types.
+ */
+ public static final int READ_STATUS_READ = 0x80;
+ public static final int READ_STATUS__DELETED_WITHOUT_BEING_READ = 0x81;
+
+ /**
+ * X-Mms-Cancel-Status field types.
+ */
+ public static final int CANCEL_STATUS_REQUEST_SUCCESSFULLY_RECEIVED = 0x80;
+ public static final int CANCEL_STATUS_REQUEST_CORRUPTED = 0x81;
+
+ /**
+ * X-Mms-Reply-Charging field types.
+ */
+ public static final int REPLY_CHARGING_REQUESTED = 0x80;
+ public static final int REPLY_CHARGING_REQUESTED_TEXT_ONLY = 0x81;
+ public static final int REPLY_CHARGING_ACCEPTED = 0x82;
+ public static final int REPLY_CHARGING_ACCEPTED_TEXT_ONLY = 0x83;
+
+ /**
+ * X-Mms-MM-State field types.
+ */
+ public static final int MM_STATE_DRAFT = 0x80;
+ public static final int MM_STATE_SENT = 0x81;
+ public static final int MM_STATE_NEW = 0x82;
+ public static final int MM_STATE_RETRIEVED = 0x83;
+ public static final int MM_STATE_FORWARDED = 0x84;
+
+ /**
+ * X-Mms-Recommended-Retrieval-Mode field types.
+ */
+ public static final int RECOMMENDED_RETRIEVAL_MODE_MANUAL = 0x80;
+
+ /**
+ * X-Mms-Content-Class field types.
+ */
+ public static final int CONTENT_CLASS_TEXT = 0x80;
+ public static final int CONTENT_CLASS_IMAGE_BASIC = 0x81;
+ public static final int CONTENT_CLASS_IMAGE_RICH = 0x82;
+ public static final int CONTENT_CLASS_VIDEO_BASIC = 0x83;
+ public static final int CONTENT_CLASS_VIDEO_RICH = 0x84;
+ public static final int CONTENT_CLASS_MEGAPIXEL = 0x85;
+ public static final int CONTENT_CLASS_CONTENT_BASIC = 0x86;
+ public static final int CONTENT_CLASS_CONTENT_RICH = 0x87;
+
+ /**
+ * X-Mms-Store-Status field types.
+ */
+ public static final int STORE_STATUS_SUCCESS = 0x80;
+ public static final int STORE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0;
+ public static final int STORE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC1;
+ public static final int STORE_STATUS_ERROR_PERMANENT_FAILURE = 0xE0;
+ public static final int STORE_STATUS_ERROR_PERMANENT_SERVICE_DENIED = 0xE1;
+ public static final int STORE_STATUS_ERROR_PERMANENT_MESSAGE_FORMAT_CORRUPT = 0xE2;
+ public static final int STORE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND = 0xE3;
+ public static final int STORE_STATUS_ERROR_PERMANENT_MMBOX_FULL = 0xE4;
+ public static final int STORE_STATUS_ERROR_END = 0xFF;
+
+ /**
+ * The map contains the value of all headers.
+ */
+ private SparseArray<Object> mHeaderMap = null;
+
+ /**
+ * Constructor of PduHeaders.
+ */
+ public PduHeaders() {
+ mHeaderMap = new SparseArray<Object>();
+ }
+
+ /**
+ * Get octet value by header field.
+ *
+ * @param field the field
+ * @return the octet value of the pdu header
+ * with specified header field. Return 0 if
+ * the value is not set.
+ */
+ protected int getOctet(int field) {
+ Integer octet = (Integer) mHeaderMap.get(field);
+ if (null == octet) {
+ return 0;
+ }
+
+ return octet;
+ }
+
+ /**
+ * Set octet value to pdu header by header field.
+ *
+ * @param value the value
+ * @param field the field
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ protected void setOctet(int value, int field)
+ throws InvalidHeaderValueException {
+ /**
+ * Check whether this field can be set for specific
+ * header and check validity of the field.
+ */
+ switch (field) {
+ case REPORT_ALLOWED:
+ case ADAPTATION_ALLOWED:
+ case DELIVERY_REPORT:
+ case DRM_CONTENT:
+ case DISTRIBUTION_INDICATOR:
+ case QUOTAS:
+ case READ_REPORT:
+ case STORE:
+ case STORED:
+ case TOTALS:
+ case SENDER_VISIBILITY:
+ if ((VALUE_YES != value) && (VALUE_NO != value)) {
+ // Invalid value.
+ throw new InvalidHeaderValueException("Invalid Octet value!");
+ }
+ break;
+ case READ_STATUS:
+ if ((READ_STATUS_READ != value) &&
+ (READ_STATUS__DELETED_WITHOUT_BEING_READ != value)) {
+ // Invalid value.
+ throw new InvalidHeaderValueException("Invalid Octet value!");
+ }
+ break;
+ case CANCEL_STATUS:
+ if ((CANCEL_STATUS_REQUEST_SUCCESSFULLY_RECEIVED != value) &&
+ (CANCEL_STATUS_REQUEST_CORRUPTED != value)) {
+ // Invalid value.
+ throw new InvalidHeaderValueException("Invalid Octet value!");
+ }
+ break;
+ case PRIORITY:
+ if ((value < PRIORITY_LOW) || (value > PRIORITY_HIGH)) {
+ // Invalid value.
+ throw new InvalidHeaderValueException("Invalid Octet value!");
+ }
+ break;
+ case STATUS:
+ if ((value < STATUS_EXPIRED) || (value > STATUS_UNREACHABLE)) {
+ // Invalid value.
+ throw new InvalidHeaderValueException("Invalid Octet value!");
+ }
+ break;
+ case REPLY_CHARGING:
+ if ((value < REPLY_CHARGING_REQUESTED)
+ || (value > REPLY_CHARGING_ACCEPTED_TEXT_ONLY)) {
+ // Invalid value.
+ throw new InvalidHeaderValueException("Invalid Octet value!");
+ }
+ break;
+ case MM_STATE:
+ if ((value < MM_STATE_DRAFT) || (value > MM_STATE_FORWARDED)) {
+ // Invalid value.
+ throw new InvalidHeaderValueException("Invalid Octet value!");
+ }
+ break;
+ case RECOMMENDED_RETRIEVAL_MODE:
+ if (RECOMMENDED_RETRIEVAL_MODE_MANUAL != value) {
+ // Invalid value.
+ throw new InvalidHeaderValueException("Invalid Octet value!");
+ }
+ break;
+ case CONTENT_CLASS:
+ if ((value < CONTENT_CLASS_TEXT)
+ || (value > CONTENT_CLASS_CONTENT_RICH)) {
+ // Invalid value.
+ throw new InvalidHeaderValueException("Invalid Octet value!");
+ }
+ break;
+ case RETRIEVE_STATUS:
+ // According to oma-ts-mms-enc-v1_3, section 7.3.50, we modify the invalid value.
+ if ((value > RETRIEVE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM) &&
+ (value < RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE)) {
+ value = RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE;
+ } else if ((value > RETRIEVE_STATUS_ERROR_PERMANENT_CONTENT_UNSUPPORTED) &&
+ (value <= RETRIEVE_STATUS_ERROR_END)) {
+ value = RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE;
+ } else if ((value < RETRIEVE_STATUS_OK) ||
+ ((value > RETRIEVE_STATUS_OK) &&
+ (value < RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE)) ||
+ (value > RETRIEVE_STATUS_ERROR_END)) {
+ value = RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE;
+ }
+ break;
+ case STORE_STATUS:
+ // According to oma-ts-mms-enc-v1_3, section 7.3.58, we modify the invalid value.
+ if ((value > STORE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM) &&
+ (value < STORE_STATUS_ERROR_PERMANENT_FAILURE)) {
+ value = STORE_STATUS_ERROR_TRANSIENT_FAILURE;
+ } else if ((value > STORE_STATUS_ERROR_PERMANENT_MMBOX_FULL) &&
+ (value <= STORE_STATUS_ERROR_END)) {
+ value = STORE_STATUS_ERROR_PERMANENT_FAILURE;
+ } else if ((value < STORE_STATUS_SUCCESS) ||
+ ((value > STORE_STATUS_SUCCESS) &&
+ (value < STORE_STATUS_ERROR_TRANSIENT_FAILURE)) ||
+ (value > STORE_STATUS_ERROR_END)) {
+ value = STORE_STATUS_ERROR_PERMANENT_FAILURE;
+ }
+ break;
+ case RESPONSE_STATUS:
+ // According to oma-ts-mms-enc-v1_3, section 7.3.48, we modify the invalid value.
+ if ((value > RESPONSE_STATUS_ERROR_TRANSIENT_PARTIAL_SUCCESS) &&
+ (value < RESPONSE_STATUS_ERROR_PERMANENT_FAILURE)) {
+ value = RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE;
+ } else if (((value > RESPONSE_STATUS_ERROR_PERMANENT_LACK_OF_PREPAID) &&
+ (value <= RESPONSE_STATUS_ERROR_PERMANENT_END)) ||
+ (value < RESPONSE_STATUS_OK) ||
+ ((value > RESPONSE_STATUS_ERROR_UNSUPPORTED_MESSAGE) &&
+ (value < RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE)) ||
+ (value > RESPONSE_STATUS_ERROR_PERMANENT_END)) {
+ value = RESPONSE_STATUS_ERROR_PERMANENT_FAILURE;
+ }
+ break;
+ case MMS_VERSION:
+ if ((value < MMS_VERSION_1_0) || (value > MMS_VERSION_1_3)) {
+ value = CURRENT_MMS_VERSION; // Current version is the default value.
+ }
+ break;
+ case MESSAGE_TYPE:
+ if ((value < MESSAGE_TYPE_SEND_REQ) || (value > MESSAGE_TYPE_CANCEL_CONF)) {
+ // Invalid value.
+ throw new InvalidHeaderValueException("Invalid Octet value!");
+ }
+ break;
+ default:
+ // This header value should not be Octect.
+ throw new RuntimeException("Invalid header field!");
+ }
+ mHeaderMap.put(field, value);
+ }
+
+ /**
+ * Get TextString value by header field.
+ *
+ * @param field the field
+ * @return the TextString value of the pdu header
+ * with specified header field
+ */
+ protected byte[] getTextString(int field) {
+ return (byte[]) mHeaderMap.get(field);
+ }
+
+ /**
+ * Set TextString value to pdu header by header field.
+ *
+ * @param value the value
+ * @param field the field
+ * @return the TextString value of the pdu header
+ * with specified header field
+ * @throws NullPointerException if the value is null.
+ */
+ protected void setTextString(byte[] value, int field) {
+ /**
+ * Check whether this field can be set for specific
+ * header and check validity of the field.
+ */
+ if (null == value) {
+ throw new NullPointerException();
+ }
+
+ switch (field) {
+ case TRANSACTION_ID:
+ case REPLY_CHARGING_ID:
+ case AUX_APPLIC_ID:
+ case APPLIC_ID:
+ case REPLY_APPLIC_ID:
+ case MESSAGE_ID:
+ case REPLACE_ID:
+ case CANCEL_ID:
+ case CONTENT_LOCATION:
+ case MESSAGE_CLASS:
+ case CONTENT_TYPE:
+ break;
+ default:
+ // This header value should not be Text-String.
+ throw new RuntimeException("Invalid header field!");
+ }
+ mHeaderMap.put(field, value);
+ }
+
+ /**
+ * Get EncodedStringValue value by header field.
+ *
+ * @param field the field
+ * @return the EncodedStringValue value of the pdu header
+ * with specified header field
+ */
+ protected EncodedStringValue getEncodedStringValue(int field) {
+ return (EncodedStringValue) mHeaderMap.get(field);
+ }
+
+ /**
+ * Get TO, CC or BCC header value.
+ *
+ * @param field the field
+ * @return the EncodeStringValue array of the pdu header
+ * with specified header field
+ */
+ protected EncodedStringValue[] getEncodedStringValues(int field) {
+ ArrayList<EncodedStringValue> list =
+ (ArrayList<EncodedStringValue>) mHeaderMap.get(field);
+ if (null == list) {
+ return null;
+ }
+ EncodedStringValue[] values = new EncodedStringValue[list.size()];
+ return list.toArray(values);
+ }
+
+ /**
+ * Set EncodedStringValue value to pdu header by header field.
+ *
+ * @param value the value
+ * @param field the field
+ * @return the EncodedStringValue value of the pdu header
+ * with specified header field
+ * @throws NullPointerException if the value is null.
+ */
+ protected void setEncodedStringValue(EncodedStringValue value, int field) {
+ /**
+ * Check whether this field can be set for specific
+ * header and check validity of the field.
+ */
+ if (null == value) {
+ throw new NullPointerException();
+ }
+
+ switch (field) {
+ case SUBJECT:
+ case RECOMMENDED_RETRIEVAL_MODE_TEXT:
+ case RETRIEVE_TEXT:
+ case STATUS_TEXT:
+ case STORE_STATUS_TEXT:
+ case RESPONSE_TEXT:
+ case FROM:
+ case PREVIOUSLY_SENT_BY:
+ case MM_FLAGS:
+ break;
+ default:
+ // This header value should not be Encoded-String-Value.
+ throw new RuntimeException("Invalid header field!");
+ }
+
+ mHeaderMap.put(field, value);
+ }
+
+ /**
+ * Set TO, CC or BCC header value.
+ *
+ * @param value the value
+ * @param field the field
+ * @return the EncodedStringValue value array of the pdu header
+ * with specified header field
+ * @throws NullPointerException if the value is null.
+ */
+ protected void setEncodedStringValues(EncodedStringValue[] value, int field) {
+ /**
+ * Check whether this field can be set for specific
+ * header and check validity of the field.
+ */
+ if (null == value) {
+ throw new NullPointerException();
+ }
+
+ switch (field) {
+ case BCC:
+ case CC:
+ case TO:
+ break;
+ default:
+ // This header value should not be Encoded-String-Value.
+ throw new RuntimeException("Invalid header field!");
+ }
+
+ ArrayList<EncodedStringValue> list = new ArrayList<EncodedStringValue>();
+ for (int i = 0; i < value.length; i++) {
+ list.add(value[i]);
+ }
+ mHeaderMap.put(field, list);
+ }
+
+ /**
+ * Append one EncodedStringValue to another.
+ *
+ * @param value the EncodedStringValue to append
+ * @param field the field
+ * @throws NullPointerException if the value is null.
+ */
+ protected void appendEncodedStringValue(EncodedStringValue value,
+ int field) {
+ if (null == value) {
+ throw new NullPointerException();
+ }
+
+ switch (field) {
+ case BCC:
+ case CC:
+ case TO:
+ break;
+ default:
+ throw new RuntimeException("Invalid header field!");
+ }
+
+ ArrayList<EncodedStringValue> list =
+ (ArrayList<EncodedStringValue>) mHeaderMap.get(field);
+ if (null == list) {
+ list = new ArrayList<EncodedStringValue>();
+ }
+ list.add(value);
+ mHeaderMap.put(field, list);
+ }
+
+ /**
+ * Get LongInteger value by header field.
+ *
+ * @param field the field
+ * @return the LongInteger value of the pdu header
+ * with specified header field. if return -1, the
+ * field is not existed in pdu header.
+ */
+ protected long getLongInteger(int field) {
+ Long longInteger = (Long) mHeaderMap.get(field);
+ if (null == longInteger) {
+ return -1;
+ }
+
+ return longInteger.longValue();
+ }
+
+ /**
+ * Set LongInteger value to pdu header by header field.
+ *
+ * @param value the value
+ * @param field the field
+ */
+ protected void setLongInteger(long value, int field) {
+ /**
+ * Check whether this field can be set for specific
+ * header and check validity of the field.
+ */
+ switch (field) {
+ case DATE:
+ case REPLY_CHARGING_SIZE:
+ case MESSAGE_SIZE:
+ case MESSAGE_COUNT:
+ case START:
+ case LIMIT:
+ case DELIVERY_TIME:
+ case EXPIRY:
+ case REPLY_CHARGING_DEADLINE:
+ case PREVIOUSLY_SENT_DATE:
+ break;
+ default:
+ // This header value should not be LongInteger.
+ throw new RuntimeException("Invalid header field!");
+ }
+ mHeaderMap.put(field, value);
+ }
+
+ public boolean hasHeader(int field) {
+ return mHeaderMap.get(field, null) != null;
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/PduParser.java b/src/com/android/messaging/mmslib/pdu/PduParser.java
new file mode 100755
index 0000000..e392fb5
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/PduParser.java
@@ -0,0 +1,2044 @@
+/*
+ * Copyright (C) 2007-2008 Esmertec AG.
+ * Copyright (C) 2007-2008 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.messaging.mmslib.pdu;
+
+import android.util.Log;
+import android.util.SparseArray;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+import com.android.messaging.util.ContentType;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+
+public class PduParser {
+ /**
+ * The log tag.
+ */
+ private static final String LOG_TAG = "PduParser";
+
+ private static final boolean LOCAL_LOGV = false;
+
+ /**
+ * The next are WAP values defined in WSP specification.
+ */
+ private static final int QUOTE = 127;
+
+ private static final int LENGTH_QUOTE = 31;
+
+ private static final int TEXT_MIN = 32;
+
+ private static final int TEXT_MAX = 127;
+
+ private static final int SHORT_INTEGER_MAX = 127;
+
+ private static final int SHORT_LENGTH_MAX = 30;
+
+ private static final int LONG_INTEGER_LENGTH_MAX = 8;
+
+ private static final int QUOTED_STRING_FLAG = 34;
+
+ private static final int END_STRING_FLAG = 0x00;
+
+ //The next two are used by the interface "parseWapString" to
+ //distinguish Text-String and Quoted-String.
+ private static final int TYPE_TEXT_STRING = 0;
+
+ private static final int TYPE_QUOTED_STRING = 1;
+
+ private static final int TYPE_TOKEN_STRING = 2;
+
+ /**
+ * Specify the part position.
+ */
+ private static final int THE_FIRST_PART = 0;
+
+ private static final int THE_LAST_PART = 1;
+
+ /**
+ * The pdu data.
+ */
+ private ByteArrayInputStream mPduDataStream = null;
+
+ /**
+ * Store pdu headers
+ */
+ private PduHeaders mHeaders = null;
+
+ /**
+ * Store pdu parts.
+ */
+ private PduBody mBody = null;
+
+ /**
+ * Store the "type" parameter in "Content-Type" header field.
+ */
+ private static byte[] mTypeParam = null;
+
+ /**
+ * Store the "start" parameter in "Content-Type" header field.
+ */
+ private static byte[] mStartParam = null;
+
+ /**
+ * Whether to parse content-disposition part header
+ */
+ private final boolean mParseContentDisposition;
+
+ /**
+ * Constructor.
+ *
+ * @param pduDataStream pdu data to be parsed
+ * @param parseContentDisposition whether to parse the Content-Disposition part header
+ */
+ public PduParser(byte[] pduDataStream, boolean parseContentDisposition) {
+ mPduDataStream = new ByteArrayInputStream(pduDataStream);
+ mParseContentDisposition = parseContentDisposition;
+ }
+
+ /**
+ * Parse the pdu.
+ *
+ * @return the pdu structure if parsing successfully.
+ * null if parsing error happened or mandatory fields are not set.
+ */
+ public GenericPdu parse() {
+ if (mPduDataStream == null) {
+ return null;
+ }
+
+ /* parse headers */
+ mHeaders = parseHeaders(mPduDataStream);
+ if (null == mHeaders) {
+ // Parse headers failed.
+ return null;
+ }
+
+ /* get the message type */
+ int messageType = mHeaders.getOctet(PduHeaders.MESSAGE_TYPE);
+
+ /* check mandatory header fields */
+ if (false == checkMandatoryHeader(mHeaders)) {
+ log("check mandatory headers failed!");
+ return null;
+ }
+ /*
+ * Get retrieve status. If the header is not there, assuming it is OK status.
+ * Some carriers may choose to not send this header.
+ */
+ int retrieveStatus = mHeaders.hasHeader(PduHeaders.RETRIEVE_STATUS) ?
+ mHeaders.getOctet(PduHeaders.RETRIEVE_STATUS) : PduHeaders.RETRIEVE_STATUS_OK;
+
+ if ((PduHeaders.MESSAGE_TYPE_SEND_REQ == messageType) ||
+ (PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF == messageType &&
+ retrieveStatus == PduHeaders.RETRIEVE_STATUS_OK)) {
+ /* need to parse the parts */
+ mBody = parseParts(mPduDataStream);
+ if (null == mBody) {
+ // Parse parts failed.
+ return null;
+ }
+ }
+
+ switch (messageType) {
+ case PduHeaders.MESSAGE_TYPE_SEND_REQ:
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parse: MESSAGE_TYPE_SEND_REQ");
+ }
+ SendReq sendReq = new SendReq(mHeaders, mBody);
+ return sendReq;
+ case PduHeaders.MESSAGE_TYPE_SEND_CONF:
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parse: MESSAGE_TYPE_SEND_CONF");
+ }
+ SendConf sendConf = new SendConf(mHeaders);
+ return sendConf;
+ case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND:
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parse: MESSAGE_TYPE_NOTIFICATION_IND");
+ }
+ NotificationInd notificationInd =
+ new NotificationInd(mHeaders);
+ return notificationInd;
+ case PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND:
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parse: MESSAGE_TYPE_NOTIFYRESP_IND");
+ }
+ NotifyRespInd notifyRespInd =
+ new NotifyRespInd(mHeaders);
+ return notifyRespInd;
+ case PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF:
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parse: MESSAGE_TYPE_RETRIEVE_CONF");
+ }
+ RetrieveConf retrieveConf =
+ new RetrieveConf(mHeaders, mBody);
+ if (retrieveStatus != PduHeaders.RETRIEVE_STATUS_OK) {
+ // For failure only no need to check content type
+ return retrieveConf;
+ }
+ byte[] contentType = retrieveConf.getContentType();
+ if (null == contentType) {
+ return null;
+ }
+ String ctTypeStr = new String(contentType);
+ if (ctTypeStr.equals(ContentType.MMS_MULTIPART_MIXED)
+ || ctTypeStr.equals(ContentType.MMS_MULTIPART_RELATED)
+ || ctTypeStr.equals(ContentType.MMS_MULTIPART_ALTERNATIVE)) {
+ // The MMS content type must be "application/vnd.wap.multipart.mixed"
+ // or "application/vnd.wap.multipart.related"
+ // or "application/vnd.wap.multipart.alternative"
+ return retrieveConf;
+ } else if (ctTypeStr.equals(ContentType.MMS_MULTIPART_ALTERNATIVE)) {
+ // "application/vnd.wap.multipart.alternative"
+ // should take only the first part.
+ PduPart firstPart = mBody.getPart(0);
+ mBody.removeAll();
+ mBody.addPart(0, firstPart);
+ return retrieveConf;
+ }
+ return null;
+ case PduHeaders.MESSAGE_TYPE_DELIVERY_IND:
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parse: MESSAGE_TYPE_DELIVERY_IND");
+ }
+ DeliveryInd deliveryInd =
+ new DeliveryInd(mHeaders);
+ return deliveryInd;
+ case PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND:
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parse: MESSAGE_TYPE_ACKNOWLEDGE_IND");
+ }
+ AcknowledgeInd acknowledgeInd =
+ new AcknowledgeInd(mHeaders);
+ return acknowledgeInd;
+ case PduHeaders.MESSAGE_TYPE_READ_ORIG_IND:
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parse: MESSAGE_TYPE_READ_ORIG_IND");
+ }
+ ReadOrigInd readOrigInd =
+ new ReadOrigInd(mHeaders);
+ return readOrigInd;
+ case PduHeaders.MESSAGE_TYPE_READ_REC_IND:
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parse: MESSAGE_TYPE_READ_REC_IND");
+ }
+ ReadRecInd readRecInd =
+ new ReadRecInd(mHeaders);
+ return readRecInd;
+ default:
+ log("Parser doesn't support this message type in this version!");
+ return null;
+ }
+ }
+
+ /**
+ * Parse pdu headers.
+ *
+ * @param pduDataStream pdu data input stream
+ * @return headers in PduHeaders structure, null when parse fail
+ */
+ protected PduHeaders parseHeaders(ByteArrayInputStream pduDataStream) {
+ if (pduDataStream == null) {
+ return null;
+ }
+ boolean keepParsing = true;
+ PduHeaders headers = new PduHeaders();
+
+ while (keepParsing && (pduDataStream.available() > 0)) {
+ pduDataStream.mark(1);
+ int headerField = extractByteValue(pduDataStream);
+ /* parse custom text header */
+ if ((headerField >= TEXT_MIN) && (headerField <= TEXT_MAX)) {
+ pduDataStream.reset();
+ byte[] bVal = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "TextHeader: " + new String(bVal));
+ }
+ /* we should ignore it at the moment */
+ continue;
+ }
+ switch (headerField) {
+ case PduHeaders.MESSAGE_TYPE: {
+ int messageType = extractByteValue(pduDataStream);
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: messageType: " + messageType +
+ " (" + Integer.toHexString(headerField) + ")");
+ }
+ switch (messageType) {
+ // We don't support these kind of messages now.
+ case PduHeaders.MESSAGE_TYPE_FORWARD_REQ:
+ case PduHeaders.MESSAGE_TYPE_FORWARD_CONF:
+ case PduHeaders.MESSAGE_TYPE_MBOX_STORE_REQ:
+ case PduHeaders.MESSAGE_TYPE_MBOX_STORE_CONF:
+ case PduHeaders.MESSAGE_TYPE_MBOX_VIEW_REQ:
+ case PduHeaders.MESSAGE_TYPE_MBOX_VIEW_CONF:
+ case PduHeaders.MESSAGE_TYPE_MBOX_UPLOAD_REQ:
+ case PduHeaders.MESSAGE_TYPE_MBOX_UPLOAD_CONF:
+ case PduHeaders.MESSAGE_TYPE_MBOX_DELETE_REQ:
+ case PduHeaders.MESSAGE_TYPE_MBOX_DELETE_CONF:
+ case PduHeaders.MESSAGE_TYPE_MBOX_DESCR:
+ case PduHeaders.MESSAGE_TYPE_DELETE_REQ:
+ case PduHeaders.MESSAGE_TYPE_DELETE_CONF:
+ case PduHeaders.MESSAGE_TYPE_CANCEL_REQ:
+ case PduHeaders.MESSAGE_TYPE_CANCEL_CONF:
+ return null;
+ }
+ try {
+ headers.setOctet(messageType, headerField);
+ } catch (InvalidHeaderValueException e) {
+ log("Set invalid Octet value: " + messageType +
+ " into the header filed: " + headerField);
+ return null;
+ } catch (RuntimeException e) {
+ log(headerField + "is not Octet header field!");
+ return null;
+ }
+ break;
+ }
+ /* Octect value */
+ case PduHeaders.REPORT_ALLOWED:
+ case PduHeaders.ADAPTATION_ALLOWED:
+ case PduHeaders.DELIVERY_REPORT:
+ case PduHeaders.DRM_CONTENT:
+ case PduHeaders.DISTRIBUTION_INDICATOR:
+ case PduHeaders.QUOTAS:
+ case PduHeaders.READ_REPORT:
+ case PduHeaders.STORE:
+ case PduHeaders.STORED:
+ case PduHeaders.TOTALS:
+ case PduHeaders.SENDER_VISIBILITY:
+ case PduHeaders.READ_STATUS:
+ case PduHeaders.CANCEL_STATUS:
+ case PduHeaders.PRIORITY:
+ case PduHeaders.STATUS:
+ case PduHeaders.REPLY_CHARGING:
+ case PduHeaders.MM_STATE:
+ case PduHeaders.RECOMMENDED_RETRIEVAL_MODE:
+ case PduHeaders.CONTENT_CLASS:
+ case PduHeaders.RETRIEVE_STATUS:
+ case PduHeaders.STORE_STATUS:
+ /**
+ * The following field has a different value when
+ * used in the M-Mbox-Delete.conf and M-Delete.conf PDU.
+ * For now we ignore this fact, since we do not support these PDUs
+ */
+ case PduHeaders.RESPONSE_STATUS: {
+ int value = extractByteValue(pduDataStream);
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") Octect value: " +
+ value);
+ }
+
+ try {
+ headers.setOctet(value, headerField);
+ } catch (InvalidHeaderValueException e) {
+ log("Set invalid Octet value: " + value +
+ " into the header filed: " + headerField);
+ return null;
+ } catch (RuntimeException e) {
+ log(headerField + "is not Octet header field!");
+ return null;
+ }
+ break;
+ }
+
+ /* Long-Integer */
+ case PduHeaders.DATE:
+ case PduHeaders.REPLY_CHARGING_SIZE:
+ case PduHeaders.MESSAGE_SIZE: {
+ try {
+ long value = parseLongInteger(pduDataStream);
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") longint value: " +
+ value);
+ }
+ headers.setLongInteger(value, headerField);
+ } catch (RuntimeException e) {
+ log(headerField + "is not Long-Integer header field!");
+ return null;
+ }
+ break;
+ }
+
+ /* Integer-Value */
+ case PduHeaders.MESSAGE_COUNT:
+ case PduHeaders.START:
+ case PduHeaders.LIMIT: {
+ try {
+ long value = parseIntegerValue(pduDataStream);
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") integer value: " +
+ value);
+ }
+ headers.setLongInteger(value, headerField);
+ } catch (RuntimeException e) {
+ log(headerField + "is not Long-Integer header field!");
+ return null;
+ }
+ break;
+ }
+
+ /* Text-String */
+ case PduHeaders.TRANSACTION_ID:
+ case PduHeaders.REPLY_CHARGING_ID:
+ case PduHeaders.AUX_APPLIC_ID:
+ case PduHeaders.APPLIC_ID:
+ case PduHeaders.REPLY_APPLIC_ID:
+ /**
+ * The next three header fields are email addresses
+ * as defined in RFC2822,
+ * not including the characters "<" and ">"
+ */
+ case PduHeaders.MESSAGE_ID:
+ case PduHeaders.REPLACE_ID:
+ case PduHeaders.CANCEL_ID:
+ /**
+ * The following field has a different value when
+ * used in the M-Mbox-Delete.conf and M-Delete.conf PDU.
+ * For now we ignore this fact, since we do not support these PDUs
+ */
+ case PduHeaders.CONTENT_LOCATION: {
+ byte[] value = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ if (null != value) {
+ try {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") string value: "
+ +
+ new String(value));
+ }
+ headers.setTextString(value, headerField);
+ } catch (NullPointerException e) {
+ log("null pointer error!");
+ } catch (RuntimeException e) {
+ log(headerField + "is not Text-String header field!");
+ return null;
+ }
+ }
+ break;
+ }
+
+ /* Encoded-string-value */
+ case PduHeaders.SUBJECT:
+ case PduHeaders.RECOMMENDED_RETRIEVAL_MODE_TEXT:
+ case PduHeaders.RETRIEVE_TEXT:
+ case PduHeaders.STATUS_TEXT:
+ case PduHeaders.STORE_STATUS_TEXT:
+ /* the next one is not support
+ * M-Mbox-Delete.conf and M-Delete.conf now */
+ case PduHeaders.RESPONSE_TEXT: {
+ EncodedStringValue value =
+ parseEncodedStringValue(pduDataStream);
+ if (null != value) {
+ try {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField)
+ + ") encoded string: " +
+ value.getString());
+ }
+ headers.setEncodedStringValue(value, headerField);
+ } catch (NullPointerException e) {
+ log("null pointer error!");
+ } catch (RuntimeException e) {
+ log(headerField + "is not Encoded-String-Value header field!");
+ return null;
+ }
+ }
+ break;
+ }
+
+ /* Addressing model */
+ case PduHeaders.BCC:
+ case PduHeaders.CC:
+ case PduHeaders.TO: {
+ EncodedStringValue value =
+ parseEncodedStringValue(pduDataStream);
+ if (null != value) {
+ byte[] address = value.getTextString();
+ if (null != address) {
+ String str = new String(address);
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: (to/cc/bcc) address: " + headerField
+ + " value: " + str);
+ }
+ int endIndex = str.indexOf("/");
+ if (endIndex > 0) {
+ str = str.substring(0, endIndex);
+ }
+ try {
+ value.setTextString(str.getBytes());
+ } catch (NullPointerException e) {
+ log("null pointer error!");
+ return null;
+ }
+ }
+
+ try {
+ headers.appendEncodedStringValue(value, headerField);
+ } catch (NullPointerException e) {
+ log("null pointer error!");
+ } catch (RuntimeException e) {
+ log(headerField + "is not Encoded-String-Value header field!");
+ return null;
+ }
+ }
+ break;
+ }
+
+ /* Value-length
+ * (Absolute-token Date-value | Relative-token Delta-seconds-value) */
+ case PduHeaders.DELIVERY_TIME:
+ case PduHeaders.EXPIRY:
+ case PduHeaders.REPLY_CHARGING_DEADLINE: {
+ /* parse Value-length */
+ parseValueLength(pduDataStream);
+
+ /* Absolute-token or Relative-token */
+ int token = extractByteValue(pduDataStream);
+
+ /* Date-value or Delta-seconds-value */
+ long timeValue;
+ try {
+ timeValue = parseLongInteger(pduDataStream);
+ } catch (RuntimeException e) {
+ log(headerField + "is not Long-Integer header field!");
+ return null;
+ }
+ if (PduHeaders.VALUE_RELATIVE_TOKEN == token) {
+ /* need to convert the Delta-seconds-value
+ * into Date-value */
+ timeValue = System.currentTimeMillis() / 1000 + timeValue;
+ }
+
+ try {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") time value: " +
+ timeValue);
+ }
+ headers.setLongInteger(timeValue, headerField);
+ } catch (RuntimeException e) {
+ log(headerField + "is not Long-Integer header field!");
+ return null;
+ }
+ break;
+ }
+
+ case PduHeaders.FROM: {
+ /* From-value =
+ * Value-length
+ * (Address-present-token Encoded-string-value | Insert-address-token)
+ */
+ EncodedStringValue from = null;
+ parseValueLength(pduDataStream); /* parse value-length */
+
+ /* Address-present-token or Insert-address-token */
+ int fromToken = extractByteValue(pduDataStream);
+
+ /* Address-present-token or Insert-address-token */
+ if (PduHeaders.FROM_ADDRESS_PRESENT_TOKEN == fromToken) {
+ /* Encoded-string-value */
+ from = parseEncodedStringValue(pduDataStream);
+ if (null != from) {
+ byte[] address = from.getTextString();
+ if (null != address) {
+ String str = new String(address);
+ int endIndex = str.indexOf("/");
+ if (endIndex > 0) {
+ str = str.substring(0, endIndex);
+ }
+ try {
+ from.setTextString(str.getBytes());
+ } catch (NullPointerException e) {
+ log("null pointer error!");
+ return null;
+ }
+ }
+ }
+ } else {
+ try {
+ from = new EncodedStringValue(
+ PduHeaders.FROM_INSERT_ADDRESS_TOKEN_STR.getBytes());
+ } catch (NullPointerException e) {
+ log(headerField + "is not Encoded-String-Value header field!");
+ return null;
+ }
+ }
+
+ try {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") from address: " +
+ from.getString());
+ }
+ headers.setEncodedStringValue(from, PduHeaders.FROM);
+ } catch (NullPointerException e) {
+ log("null pointer error!");
+ } catch (RuntimeException e) {
+ log(headerField + "is not Encoded-String-Value header field!");
+ return null;
+ }
+ break;
+ }
+
+ case PduHeaders.MESSAGE_CLASS: {
+ /* Message-class-value = Class-identifier | Token-text */
+ pduDataStream.mark(1);
+ int messageClass = extractByteValue(pduDataStream);
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") MESSAGE_CLASS: " +
+ messageClass);
+ }
+
+ if (messageClass >= PduHeaders.MESSAGE_CLASS_PERSONAL) {
+ /* Class-identifier */
+ try {
+ if (PduHeaders.MESSAGE_CLASS_PERSONAL == messageClass) {
+ headers.setTextString(
+ PduHeaders.MESSAGE_CLASS_PERSONAL_STR.getBytes(),
+ PduHeaders.MESSAGE_CLASS);
+ } else if (PduHeaders.MESSAGE_CLASS_ADVERTISEMENT == messageClass) {
+ headers.setTextString(
+ PduHeaders.MESSAGE_CLASS_ADVERTISEMENT_STR.getBytes(),
+ PduHeaders.MESSAGE_CLASS);
+ } else if (PduHeaders.MESSAGE_CLASS_INFORMATIONAL == messageClass) {
+ headers.setTextString(
+ PduHeaders.MESSAGE_CLASS_INFORMATIONAL_STR.getBytes(),
+ PduHeaders.MESSAGE_CLASS);
+ } else if (PduHeaders.MESSAGE_CLASS_AUTO == messageClass) {
+ headers.setTextString(
+ PduHeaders.MESSAGE_CLASS_AUTO_STR.getBytes(),
+ PduHeaders.MESSAGE_CLASS);
+ }
+ } catch (NullPointerException e) {
+ log("null pointer error!");
+ } catch (RuntimeException e) {
+ log(headerField + "is not Text-String header field!");
+ return null;
+ }
+ } else {
+ /* Token-text */
+ pduDataStream.reset();
+ byte[] messageClassString = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ if (null != messageClassString) {
+ try {
+ headers.setTextString(messageClassString, PduHeaders.MESSAGE_CLASS);
+ } catch (NullPointerException e) {
+ log("null pointer error!");
+ } catch (RuntimeException e) {
+ log(headerField + "is not Text-String header field!");
+ return null;
+ }
+ }
+ }
+ break;
+ }
+
+ case PduHeaders.MMS_VERSION: {
+ int version = parseShortInteger(pduDataStream);
+
+ try {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") MMS_VERSION: " +
+ version);
+ }
+ headers.setOctet(version, PduHeaders.MMS_VERSION);
+ } catch (InvalidHeaderValueException e) {
+ log("Set invalid Octet value: " + version +
+ " into the header filed: " + headerField);
+ return null;
+ } catch (RuntimeException e) {
+ log(headerField + "is not Octet header field!");
+ return null;
+ }
+ break;
+ }
+
+ case PduHeaders.PREVIOUSLY_SENT_BY: {
+ /* Previously-sent-by-value =
+ * Value-length Forwarded-count-value Encoded-string-value */
+ /* parse value-length */
+ parseValueLength(pduDataStream);
+
+ /* parse Forwarded-count-value */
+ try {
+ parseIntegerValue(pduDataStream);
+ } catch (RuntimeException e) {
+ log(headerField + " is not Integer-Value");
+ return null;
+ }
+
+ /* parse Encoded-string-value */
+ EncodedStringValue previouslySentBy =
+ parseEncodedStringValue(pduDataStream);
+ if (null != previouslySentBy) {
+ try {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) +
+ ") PREVIOUSLY_SENT_BY: " + previouslySentBy.getString());
+ }
+ headers.setEncodedStringValue(previouslySentBy,
+ PduHeaders.PREVIOUSLY_SENT_BY);
+ } catch (NullPointerException e) {
+ log("null pointer error!");
+ } catch (RuntimeException e) {
+ log(headerField + "is not Encoded-String-Value header field!");
+ return null;
+ }
+ }
+ break;
+ }
+
+ case PduHeaders.PREVIOUSLY_SENT_DATE: {
+ /* Previously-sent-date-value =
+ * Value-length Forwarded-count-value Date-value */
+ /* parse value-length */
+ parseValueLength(pduDataStream);
+
+ /* parse Forwarded-count-value */
+ try {
+ parseIntegerValue(pduDataStream);
+ } catch (RuntimeException e) {
+ log(headerField + " is not Integer-Value");
+ return null;
+ }
+
+ /* Date-value */
+ try {
+ long previouslySentDate = parseLongInteger(pduDataStream);
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) +
+ ") PREVIOUSLY_SENT_DATE: " + previouslySentDate);
+ }
+ headers.setLongInteger(previouslySentDate,
+ PduHeaders.PREVIOUSLY_SENT_DATE);
+ } catch (RuntimeException e) {
+ log(headerField + "is not Long-Integer header field!");
+ return null;
+ }
+ break;
+ }
+
+ case PduHeaders.MM_FLAGS: {
+ /* MM-flags-value =
+ * Value-length
+ * ( Add-token | Remove-token | Filter-token )
+ * Encoded-string-value
+ */
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") MM_FLAGS: " +
+ " NOT REALLY SUPPORTED");
+ }
+
+ /* parse Value-length */
+ parseValueLength(pduDataStream);
+
+ /* Add-token | Remove-token | Filter-token */
+ extractByteValue(pduDataStream);
+
+ /* Encoded-string-value */
+ parseEncodedStringValue(pduDataStream);
+
+ /* not store this header filed in "headers",
+ * because now PduHeaders doesn't support it */
+ break;
+ }
+
+ /* Value-length
+ * (Message-total-token | Size-total-token) Integer-Value */
+ case PduHeaders.MBOX_TOTALS:
+ case PduHeaders.MBOX_QUOTAS: {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") MBOX_");
+ }
+ /* Value-length */
+ parseValueLength(pduDataStream);
+
+ /* Message-total-token | Size-total-token */
+ extractByteValue(pduDataStream);
+
+ /*Integer-Value*/
+ try {
+ parseIntegerValue(pduDataStream);
+ } catch (RuntimeException e) {
+ log(headerField + " is not Integer-Value");
+ return null;
+ }
+
+ /* not store these headers filed in "headers",
+ because now PduHeaders doesn't support them */
+ break;
+ }
+
+ case PduHeaders.ELEMENT_DESCRIPTOR: {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") ELEMENT_DESCRIPTOR");
+ }
+ parseContentType(pduDataStream, null);
+
+ /* not store this header filed in "headers",
+ because now PduHeaders doesn't support it */
+ break;
+ }
+
+ case PduHeaders.CONTENT_TYPE: {
+ SparseArray<Object> map = new SparseArray<Object>();
+ byte[] contentType =
+ parseContentType(pduDataStream, map);
+
+ if (null != contentType) {
+ try {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField +
+ " (" + Integer.toHexString(headerField) + ") CONTENT_TYPE: "
+ + Arrays.toString(contentType));
+ }
+ headers.setTextString(contentType, PduHeaders.CONTENT_TYPE);
+ } catch (NullPointerException e) {
+ log("null pointer error!");
+ } catch (RuntimeException e) {
+ log(headerField + "is not Text-String header field!");
+ return null;
+ }
+ }
+
+ /* get start parameter */
+ mStartParam = (byte[]) map.get(PduPart.P_START);
+
+ /* get charset parameter */
+ mTypeParam = (byte[]) map.get(PduPart.P_TYPE);
+
+ keepParsing = false;
+ break;
+ }
+
+ case PduHeaders.CONTENT:
+ case PduHeaders.ADDITIONAL_HEADERS:
+ case PduHeaders.ATTRIBUTES:
+ default: {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "parseHeaders: Unknown header: " + headerField +
+ " (" + Integer.toHexString(headerField) + ")");
+ }
+ log("Unknown header");
+ }
+ }
+ }
+
+ return headers;
+ }
+
+ /**
+ * Parse pdu parts.
+ *
+ * @param pduDataStream pdu data input stream
+ * @return parts in PduBody structure
+ */
+ protected PduBody parseParts(ByteArrayInputStream pduDataStream) {
+ if (pduDataStream == null) {
+ return null;
+ }
+
+ int count = parseUnsignedInt(pduDataStream); // get the number of parts
+ PduBody body = new PduBody();
+
+ for (int i = 0; i < count; i++) {
+ int headerLength = parseUnsignedInt(pduDataStream);
+ int dataLength = parseUnsignedInt(pduDataStream);
+ PduPart part = new PduPart();
+ int startPos = pduDataStream.available();
+ if (startPos <= 0) {
+ // Invalid part.
+ return null;
+ }
+
+ /* parse part's content-type */
+ SparseArray<Object> map = new SparseArray<Object>();
+ byte[] contentType = parseContentType(pduDataStream, map);
+ if (null != contentType) {
+ part.setContentType(contentType);
+ } else {
+ part.setContentType((PduContentTypes.contentTypes[0]).getBytes()); //"*/*"
+ }
+
+ /* get name parameter */
+ byte[] name = (byte[]) map.get(PduPart.P_NAME);
+ if (null != name) {
+ part.setName(name);
+ }
+
+ /* get charset parameter */
+ Integer charset = (Integer) map.get(PduPart.P_CHARSET);
+ if (null != charset) {
+ part.setCharset(charset);
+ }
+
+ /* parse part's headers */
+ int endPos = pduDataStream.available();
+ int partHeaderLen = headerLength - (startPos - endPos);
+ if (partHeaderLen > 0) {
+ if (false == parsePartHeaders(pduDataStream, part, partHeaderLen)) {
+ // Parse part header faild.
+ return null;
+ }
+ } else if (partHeaderLen < 0) {
+ // Invalid length of content-type.
+ return null;
+ }
+
+ /* TODO: check content-id, name, filename and content location,
+ * if not set anyone of them, generate a default content-location
+ */
+ if ((null == part.getContentLocation())
+ && (null == part.getName())
+ && (null == part.getFilename())
+ && (null == part.getContentId())) {
+ part.setContentLocation(Long.toOctalString(
+ System.currentTimeMillis()).getBytes());
+ }
+
+ /* get part's data */
+ if (dataLength > 0) {
+ byte[] partData = new byte[dataLength];
+ String partContentType = new String(part.getContentType());
+ pduDataStream.read(partData, 0, dataLength);
+ if (partContentType.equalsIgnoreCase(ContentType.MMS_MULTIPART_ALTERNATIVE)) {
+ // parse "multipart/vnd.wap.multipart.alternative".
+ PduBody childBody = parseParts(new ByteArrayInputStream(partData));
+ // take the first part of children.
+ part = childBody.getPart(0);
+ } else {
+ // Check Content-Transfer-Encoding.
+ byte[] partDataEncoding = part.getContentTransferEncoding();
+ if (null != partDataEncoding) {
+ String encoding = new String(partDataEncoding);
+ if (encoding.equalsIgnoreCase(PduPart.P_BASE64)) {
+ // Decode "base64" into "binary".
+ partData = Base64.decodeBase64(partData);
+ } else if (encoding.equalsIgnoreCase(PduPart.P_QUOTED_PRINTABLE)) {
+ // Decode "quoted-printable" into "binary".
+ partData = QuotedPrintable.decodeQuotedPrintable(partData);
+ } else {
+ // "binary" is the default encoding.
+ }
+ }
+ if (null == partData) {
+ log("Decode part data error!");
+ return null;
+ }
+ part.setData(partData);
+ }
+ }
+
+ /* add this part to body */
+ if (THE_FIRST_PART == checkPartPosition(part)) {
+ /* this is the first part */
+ body.addPart(0, part);
+ } else {
+ /* add the part to the end */
+ body.addPart(part);
+ }
+ }
+
+ return body;
+ }
+
+ /**
+ * Log status.
+ *
+ * @param text log information
+ */
+ private static void log(String text) {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, text);
+ }
+ }
+
+ /**
+ * Parse unsigned integer.
+ *
+ * @param pduDataStream pdu data input stream
+ * @return the integer, -1 when failed
+ */
+ protected static int parseUnsignedInt(ByteArrayInputStream pduDataStream) {
+ /**
+ * From wap-230-wsp-20010705-a.pdf
+ * The maximum size of a uintvar is 32 bits.
+ * So it will be encoded in no more than 5 octets.
+ */
+ assert (null != pduDataStream);
+ int result = 0;
+ int temp = pduDataStream.read();
+ if (temp == -1) {
+ return temp;
+ }
+
+ while ((temp & 0x80) != 0) {
+ result = result << 7;
+ result |= temp & 0x7F;
+ temp = pduDataStream.read();
+ if (temp == -1) {
+ return temp;
+ }
+ }
+
+ result = result << 7;
+ result |= temp & 0x7F;
+
+ return result;
+ }
+
+ /**
+ * Parse value length.
+ *
+ * @param pduDataStream pdu data input stream
+ * @return the integer
+ */
+ protected static int parseValueLength(ByteArrayInputStream pduDataStream) {
+ /**
+ * From wap-230-wsp-20010705-a.pdf
+ * Value-length = Short-length | (Length-quote Length)
+ * Short-length = <Any octet 0-30>
+ * Length-quote = <Octet 31>
+ * Length = Uintvar-integer
+ * Uintvar-integer = 1*5 OCTET
+ */
+ assert (null != pduDataStream);
+ int temp = pduDataStream.read();
+ assert (-1 != temp);
+ int first = temp & 0xFF;
+
+ if (first <= SHORT_LENGTH_MAX) {
+ return first;
+ } else if (first == LENGTH_QUOTE) {
+ return parseUnsignedInt(pduDataStream);
+ }
+
+ throw new RuntimeException("Value length > LENGTH_QUOTE!");
+ }
+
+ /**
+ * Parse encoded string value.
+ *
+ * @param pduDataStream pdu data input stream
+ * @return the EncodedStringValue
+ */
+ protected static EncodedStringValue parseEncodedStringValue(
+ ByteArrayInputStream pduDataStream) {
+ /**
+ * From OMA-TS-MMS-ENC-V1_3-20050927-C.pdf
+ * Encoded-string-value = Text-string | Value-length Char-set Text-string
+ */
+ assert (null != pduDataStream);
+ pduDataStream.mark(1);
+ EncodedStringValue returnValue = null;
+ int charset = 0;
+ int temp = pduDataStream.read();
+ assert (-1 != temp);
+ int first = temp & 0xFF;
+ if (first == 0) {
+ return null; // Blank subject, bail.
+ }
+
+ pduDataStream.reset();
+ if (first < TEXT_MIN) {
+ parseValueLength(pduDataStream);
+
+ charset = parseShortInteger(pduDataStream); //get the "Charset"
+ }
+
+ byte[] textString = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+
+ try {
+ if (0 != charset) {
+ returnValue = new EncodedStringValue(charset, textString);
+ } else {
+ returnValue = new EncodedStringValue(textString);
+ }
+ } catch (Exception e) {
+ return null;
+ }
+
+ return returnValue;
+ }
+
+ /**
+ * Parse Text-String or Quoted-String.
+ *
+ * @param pduDataStream pdu data input stream
+ * @param stringType TYPE_TEXT_STRING or TYPE_QUOTED_STRING
+ * @return the string without End-of-string in byte array
+ */
+ protected static byte[] parseWapString(ByteArrayInputStream pduDataStream,
+ int stringType) {
+ assert (null != pduDataStream);
+ /**
+ * From wap-230-wsp-20010705-a.pdf
+ * Text-string = [Quote] *TEXT End-of-string
+ * If the first character in the TEXT is in the range of 128-255,
+ * a Quote character must precede it.
+ * Otherwise the Quote character must be omitted.
+ * The Quote is not part of the contents.
+ * Quote = <Octet 127>
+ * End-of-string = <Octet 0>
+ *
+ * Quoted-string = <Octet 34> *TEXT End-of-string
+ *
+ * Token-text = Token End-of-string
+ */
+
+ // Mark supposed beginning of Text-string
+ // We will have to mark again if first char is QUOTE or QUOTED_STRING_FLAG
+ pduDataStream.mark(1);
+
+ // Check first char
+ int temp = pduDataStream.read();
+ assert (-1 != temp);
+ if ((TYPE_QUOTED_STRING == stringType) &&
+ (QUOTED_STRING_FLAG == temp)) {
+ // Mark again if QUOTED_STRING_FLAG and ignore it
+ pduDataStream.mark(1);
+ } else if ((TYPE_TEXT_STRING == stringType) &&
+ (QUOTE == temp)) {
+ // Mark again if QUOTE and ignore it
+ pduDataStream.mark(1);
+ } else {
+ // Otherwise go back to origin
+ pduDataStream.reset();
+ }
+
+ // We are now definitely at the beginning of string
+ /**
+ * Return *TOKEN or *TEXT (Text-String without QUOTE,
+ * Quoted-String without QUOTED_STRING_FLAG and without End-of-string)
+ */
+ return getWapString(pduDataStream, stringType);
+ }
+
+ /**
+ * Check TOKEN data defined in RFC2616.
+ *
+ * @param ch checking data
+ * @return true when ch is TOKEN, false when ch is not TOKEN
+ */
+ protected static boolean isTokenCharacter(int ch) {
+ /**
+ * Token = 1*<any CHAR except CTLs or separators>
+ * separators = "("(40) | ")"(41) | "<"(60) | ">"(62) | "@"(64)
+ * | ","(44) | ";"(59) | ":"(58) | "\"(92) | <">(34)
+ * | "/"(47) | "["(91) | "]"(93) | "?"(63) | "="(61)
+ * | "{"(123) | "}"(125) | SP(32) | HT(9)
+ * CHAR = <any US-ASCII character (octets 0 - 127)>
+ * CTL = <any US-ASCII control character
+ * (octets 0 - 31) and DEL (127)>
+ * SP = <US-ASCII SP, space (32)>
+ * HT = <US-ASCII HT, horizontal-tab (9)>
+ */
+ if ((ch < 33) || (ch > 126)) {
+ return false;
+ }
+
+ switch (ch) {
+ case '"': /* '"' */
+ case '(': /* '(' */
+ case ')': /* ')' */
+ case ',': /* ',' */
+ case '/': /* '/' */
+ case ':': /* ':' */
+ case ';': /* ';' */
+ case '<': /* '<' */
+ case '=': /* '=' */
+ case '>': /* '>' */
+ case '?': /* '?' */
+ case '@': /* '@' */
+ case '[': /* '[' */
+ case '\\': /* '\' */
+ case ']': /* ']' */
+ case '{': /* '{' */
+ case '}': /* '}' */
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Check TEXT data defined in RFC2616.
+ *
+ * @param ch checking data
+ * @return true when ch is TEXT, false when ch is not TEXT
+ */
+ protected static boolean isText(int ch) {
+ /**
+ * TEXT = <any OCTET except CTLs,
+ * but including LWS>
+ * CTL = <any US-ASCII control character
+ * (octets 0 - 31) and DEL (127)>
+ * LWS = [CRLF] 1*( SP | HT )
+ * CRLF = CR LF
+ * CR = <US-ASCII CR, carriage return (13)>
+ * LF = <US-ASCII LF, linefeed (10)>
+ */
+ if (((ch >= 32) && (ch <= 126)) || ((ch >= 128) && (ch <= 255))) {
+ return true;
+ }
+
+ switch (ch) {
+ case '\t': /* '\t' */
+ case '\n': /* '\n' */
+ case '\r': /* '\r' */
+ return true;
+ }
+
+ return false;
+ }
+
+ protected static byte[] getWapString(ByteArrayInputStream pduDataStream,
+ int stringType) {
+ assert (null != pduDataStream);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ int temp = pduDataStream.read();
+ assert (-1 != temp);
+ while ((-1 != temp) && ('\0' != temp)) {
+ // check each of the character
+ if (stringType == TYPE_TOKEN_STRING) {
+ if (isTokenCharacter(temp)) {
+ out.write(temp);
+ }
+ } else {
+ if (isText(temp)) {
+ out.write(temp);
+ }
+ }
+
+ temp = pduDataStream.read();
+ assert (-1 != temp);
+ }
+
+ if (out.size() > 0) {
+ return out.toByteArray();
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract a byte value from the input stream.
+ *
+ * @param pduDataStream pdu data input stream
+ * @return the byte
+ */
+ protected static int extractByteValue(ByteArrayInputStream pduDataStream) {
+ assert (null != pduDataStream);
+ int temp = pduDataStream.read();
+ assert (-1 != temp);
+ return temp & 0xFF;
+ }
+
+ /**
+ * Parse Short-Integer.
+ *
+ * @param pduDataStream pdu data input stream
+ * @return the byte
+ */
+ protected static int parseShortInteger(ByteArrayInputStream pduDataStream) {
+ /**
+ * From wap-230-wsp-20010705-a.pdf
+ * Short-integer = OCTET
+ * Integers in range 0-127 shall be encoded as a one
+ * octet value with the most significant bit set to one (1xxx xxxx)
+ * and with the value in the remaining least significant bits.
+ */
+ assert (null != pduDataStream);
+ int temp = pduDataStream.read();
+ assert (-1 != temp);
+ return temp & 0x7F;
+ }
+
+ /**
+ * Parse Long-Integer.
+ *
+ * @param pduDataStream pdu data input stream
+ * @return long integer
+ */
+ protected static long parseLongInteger(ByteArrayInputStream pduDataStream) {
+ /**
+ * From wap-230-wsp-20010705-a.pdf
+ * Long-integer = Short-length Multi-octet-integer
+ * The Short-length indicates the length of the Multi-octet-integer
+ * Multi-octet-integer = 1*30 OCTET
+ * The content octets shall be an unsigned integer value
+ * with the most significant octet encoded first (big-endian representation).
+ * The minimum number of octets must be used to encode the value.
+ * Short-length = <Any octet 0-30>
+ */
+ assert (null != pduDataStream);
+ int temp = pduDataStream.read();
+ assert (-1 != temp);
+ int count = temp & 0xFF;
+
+ if (count > LONG_INTEGER_LENGTH_MAX) {
+ throw new RuntimeException("Octet count greater than 8 and I can't represent that!");
+ }
+
+ long result = 0;
+
+ for (int i = 0; i < count; i++) {
+ temp = pduDataStream.read();
+ assert (-1 != temp);
+ result <<= 8;
+ result += (temp & 0xFF);
+ }
+
+ return result;
+ }
+
+ /**
+ * Parse Integer-Value.
+ *
+ * @param pduDataStream pdu data input stream
+ * @return long integer
+ */
+ protected static long parseIntegerValue(ByteArrayInputStream pduDataStream) {
+ /**
+ * From wap-230-wsp-20010705-a.pdf
+ * Integer-Value = Short-integer | Long-integer
+ */
+ assert (null != pduDataStream);
+ pduDataStream.mark(1);
+ int temp = pduDataStream.read();
+ assert (-1 != temp);
+ pduDataStream.reset();
+ if (temp > SHORT_INTEGER_MAX) {
+ return parseShortInteger(pduDataStream);
+ } else {
+ return parseLongInteger(pduDataStream);
+ }
+ }
+
+ /**
+ * To skip length of the wap value.
+ *
+ * @param pduDataStream pdu data input stream
+ * @param length area size
+ * @return the values in this area
+ */
+ protected static int skipWapValue(ByteArrayInputStream pduDataStream, int length) {
+ assert (null != pduDataStream);
+ byte[] area = new byte[length];
+ int readLen = pduDataStream.read(area, 0, length);
+ if (readLen < length) { //The actually read length is lower than the length
+ return -1;
+ } else {
+ return readLen;
+ }
+ }
+
+ /**
+ * Parse content type parameters. For now we just support
+ * four parameters used in mms: "type", "start", "name", "charset".
+ *
+ * @param pduDataStream pdu data input stream
+ * @param map to store parameters of Content-Type field
+ * @param length length of all the parameters
+ */
+ protected static void parseContentTypeParams(ByteArrayInputStream pduDataStream,
+ SparseArray<Object> map, Integer length) {
+ /**
+ * From wap-230-wsp-20010705-a.pdf
+ * Parameter = Typed-parameter | Untyped-parameter
+ * Typed-parameter = Well-known-parameter-token Typed-value
+ * the actual expected type of the value is implied by the well-known parameter
+ * Well-known-parameter-token = Integer-value
+ * the code values used for parameters are specified in the Assigned Numbers appendix
+ * Typed-value = Compact-value | Text-value
+ * In addition to the expected type, there may be no value.
+ * If the value cannot be encoded using the expected type, it shall be encoded as text.
+ * Compact-value = Integer-value |
+ * Date-value | Delta-seconds-value | Q-value | Version-value |
+ * Uri-value
+ * Untyped-parameter = Token-text Untyped-value
+ * the type of the value is unknown, but it shall be encoded as an integer,
+ * if that is possible.
+ * Untyped-value = Integer-value | Text-value
+ */
+ assert (null != pduDataStream);
+ assert (length > 0);
+
+ int startPos = pduDataStream.available();
+ int tempPos = 0;
+ int lastLen = length;
+ while (0 < lastLen) {
+ int param = pduDataStream.read();
+ assert (-1 != param);
+ lastLen--;
+
+ switch (param) {
+ /**
+ * From rfc2387, chapter 3.1
+ * The type parameter must be specified and its value is the MIME media
+ * type of the "root" body part. It permits a MIME user agent to
+ * determine the content-type without reference to the enclosed body
+ * part. If the value of the type parameter and the root body part's
+ * content-type differ then the User Agent's behavior is undefined.
+ *
+ * From wap-230-wsp-20010705-a.pdf
+ * type = Constrained-encoding
+ * Constrained-encoding = Extension-Media | Short-integer
+ * Extension-media = *TEXT End-of-string
+ */
+ case PduPart.P_TYPE:
+ case PduPart.P_CT_MR_TYPE:
+ pduDataStream.mark(1);
+ int first = extractByteValue(pduDataStream);
+ pduDataStream.reset();
+ if (first > TEXT_MAX) {
+ // Short-integer (well-known type)
+ int index = parseShortInteger(pduDataStream);
+
+ if (index < PduContentTypes.contentTypes.length) {
+ byte[] type = (PduContentTypes.contentTypes[index]).getBytes();
+ map.put(PduPart.P_TYPE, type);
+ } else {
+ //not support this type, ignore it.
+ }
+ } else {
+ // Text-String (extension-media)
+ byte[] type = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ if ((null != type) && (null != map)) {
+ map.put(PduPart.P_TYPE, type);
+ }
+ }
+
+ tempPos = pduDataStream.available();
+ lastLen = length - (startPos - tempPos);
+ break;
+
+ /**
+ * From oma-ts-mms-conf-v1_3.pdf, chapter 10.2.3.
+ * Start Parameter Referring to Presentation
+ *
+ * From rfc2387, chapter 3.2
+ * The start parameter, if given, is the content-ID of the compound
+ * object's "root". If not present the "root" is the first body part in
+ * the Multipart/Related entity. The "root" is the element the
+ * applications processes first.
+ *
+ * From wap-230-wsp-20010705-a.pdf
+ * start = Text-String
+ */
+ case PduPart.P_START:
+ case PduPart.P_DEP_START:
+ byte[] start = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ if ((null != start) && (null != map)) {
+ map.put(PduPart.P_START, start);
+ }
+
+ tempPos = pduDataStream.available();
+ lastLen = length - (startPos - tempPos);
+ break;
+
+ /**
+ * From oma-ts-mms-conf-v1_3.pdf
+ * In creation, the character set SHALL be either us-ascii
+ * (IANA MIBenum 3) or utf-8 (IANA MIBenum 106)[Unicode].
+ * In retrieval, both us-ascii and utf-8 SHALL be supported.
+ *
+ * From wap-230-wsp-20010705-a.pdf
+ * charset = Well-known-charset|Text-String
+ * Well-known-charset = Any-charset | Integer-value
+ * Both are encoded using values from Character Set
+ * Assignments table in Assigned Numbers
+ * Any-charset = <Octet 128>
+ * Equivalent to the special RFC2616 charset value "*"
+ */
+ case PduPart.P_CHARSET:
+ pduDataStream.mark(1);
+ int firstValue = extractByteValue(pduDataStream);
+ pduDataStream.reset();
+ //Check first char
+ if (((firstValue > TEXT_MIN) && (firstValue < TEXT_MAX)) ||
+ (END_STRING_FLAG == firstValue)) {
+ //Text-String (extension-charset)
+ byte[] charsetStr = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ try {
+ int charsetInt = CharacterSets.getMibEnumValue(
+ new String(charsetStr));
+ map.put(PduPart.P_CHARSET, charsetInt);
+ } catch (UnsupportedEncodingException e) {
+ // Not a well-known charset, use "*".
+ Log.e(LOG_TAG, Arrays.toString(charsetStr), e);
+ map.put(PduPart.P_CHARSET, CharacterSets.ANY_CHARSET);
+ }
+ } else {
+ //Well-known-charset
+ int charset = (int) parseIntegerValue(pduDataStream);
+ if (map != null) {
+ map.put(PduPart.P_CHARSET, charset);
+ }
+ }
+
+ tempPos = pduDataStream.available();
+ lastLen = length - (startPos - tempPos);
+ break;
+
+ /**
+ * From oma-ts-mms-conf-v1_3.pdf
+ * A name for multipart object SHALL be encoded using name-parameter
+ * for Content-Type header in WSP multipart headers.
+ *
+ * From wap-230-wsp-20010705-a.pdf
+ * name = Text-String
+ */
+ case PduPart.P_DEP_NAME:
+ case PduPart.P_NAME:
+ byte[] name = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ if ((null != name) && (null != map)) {
+ map.put(PduPart.P_NAME, name);
+ }
+
+ tempPos = pduDataStream.available();
+ lastLen = length - (startPos - tempPos);
+ break;
+ default:
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "Not supported Content-Type parameter");
+ }
+ if (-1 == skipWapValue(pduDataStream, lastLen)) {
+ Log.e(LOG_TAG, "Corrupt Content-Type");
+ } else {
+ lastLen = 0;
+ }
+ break;
+ }
+ }
+
+ if (0 != lastLen) {
+ Log.e(LOG_TAG, "Corrupt Content-Type");
+ }
+ }
+
+ /**
+ * Parse content type.
+ *
+ * @param pduDataStream pdu data input stream
+ * @param map to store parameters in Content-Type header field
+ * @return Content-Type value
+ */
+ protected static byte[] parseContentType(ByteArrayInputStream pduDataStream,
+ SparseArray<Object> map) {
+ /**
+ * From wap-230-wsp-20010705-a.pdf
+ * Content-type-value = Constrained-media | Content-general-form
+ * Content-general-form = Value-length Media-type
+ * Media-type = (Well-known-media | Extension-Media) *(Parameter)
+ */
+ assert (null != pduDataStream);
+
+ byte[] contentType = null;
+ pduDataStream.mark(1);
+ int temp = pduDataStream.read();
+ assert (-1 != temp);
+ pduDataStream.reset();
+
+ int cur = (temp & 0xFF);
+
+ if (cur < TEXT_MIN) {
+ int length = parseValueLength(pduDataStream);
+ int startPos = pduDataStream.available();
+ pduDataStream.mark(1);
+ temp = pduDataStream.read();
+ assert (-1 != temp);
+ pduDataStream.reset();
+ int first = (temp & 0xFF);
+
+ if ((first >= TEXT_MIN) && (first <= TEXT_MAX)) {
+ contentType = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ } else if (first > TEXT_MAX) {
+ int index = parseShortInteger(pduDataStream);
+
+ if (index < PduContentTypes.contentTypes.length) { //well-known type
+ contentType = (PduContentTypes.contentTypes[index]).getBytes();
+ } else {
+ pduDataStream.reset();
+ contentType = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ }
+ } else {
+ Log.e(LOG_TAG, "Corrupt content-type");
+ return (PduContentTypes.contentTypes[0]).getBytes(); //"*/*"
+ }
+
+ int endPos = pduDataStream.available();
+ int parameterLen = length - (startPos - endPos);
+ if (parameterLen > 0) {//have parameters
+ parseContentTypeParams(pduDataStream, map, parameterLen);
+ }
+
+ if (parameterLen < 0) {
+ Log.e(LOG_TAG, "Corrupt MMS message");
+ return (PduContentTypes.contentTypes[0]).getBytes(); //"*/*"
+ }
+ } else if (cur <= TEXT_MAX) {
+ contentType = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ } else {
+ contentType =
+ (PduContentTypes.contentTypes[parseShortInteger(pduDataStream)]).getBytes();
+ }
+
+ return contentType;
+ }
+
+ /**
+ * Parse part's headers.
+ *
+ * @param pduDataStream pdu data input stream
+ * @param part to store the header informations of the part
+ * @param length length of the headers
+ * @return true if parse successfully, false otherwise
+ */
+ protected boolean parsePartHeaders(ByteArrayInputStream pduDataStream,
+ PduPart part, int length) {
+ assert (null != pduDataStream);
+ assert (null != part);
+ assert (length > 0);
+
+ /**
+ * From oma-ts-mms-conf-v1_3.pdf, chapter 10.2.
+ * A name for multipart object SHALL be encoded using name-parameter
+ * for Content-Type header in WSP multipart headers.
+ * In decoding, name-parameter of Content-Type SHALL be used if available.
+ * If name-parameter of Content-Type is not available,
+ * filename parameter of Content-Disposition header SHALL be used if available.
+ * If neither name-parameter of Content-Type header nor filename parameter
+ * of Content-Disposition header is available,
+ * Content-Location header SHALL be used if available.
+ *
+ * Within SMIL part the reference to the media object parts SHALL use
+ * either Content-ID or Content-Location mechanism [RFC2557]
+ * and the corresponding WSP part headers in media object parts
+ * contain the corresponding definitions.
+ */
+ int startPos = pduDataStream.available();
+ int tempPos = 0;
+ int lastLen = length;
+ while (0 < lastLen) {
+ int header = pduDataStream.read();
+ assert (-1 != header);
+ lastLen--;
+
+ if (header > TEXT_MAX) {
+ // Number assigned headers.
+ switch (header) {
+ case PduPart.P_CONTENT_LOCATION:
+ /**
+ * From wap-230-wsp-20010705-a.pdf, chapter 8.4.2.21
+ * Content-location-value = Uri-value
+ */
+ byte[] contentLocation = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ if (null != contentLocation) {
+ part.setContentLocation(contentLocation);
+ }
+
+ tempPos = pduDataStream.available();
+ lastLen = length - (startPos - tempPos);
+ break;
+ case PduPart.P_CONTENT_ID:
+ /**
+ * From wap-230-wsp-20010705-a.pdf, chapter 8.4.2.21
+ * Content-ID-value = Quoted-string
+ */
+ byte[] contentId = parseWapString(pduDataStream, TYPE_QUOTED_STRING);
+ if (null != contentId) {
+ part.setContentId(contentId);
+ }
+
+ tempPos = pduDataStream.available();
+ lastLen = length - (startPos - tempPos);
+ break;
+ case PduPart.P_DEP_CONTENT_DISPOSITION:
+ case PduPart.P_CONTENT_DISPOSITION:
+ /*
+ * From wap-230-wsp-20010705-a.pdf, chapter 8.4.2.21
+ * Content-disposition-value = Value-length Disposition *(Parameter)
+ * Disposition = Form-data | Attachment | Inline | Token-text
+ * Form-data = <Octet 128>
+ * Attachment = <Octet 129>
+ * Inline = <Octet 130>
+ *
+ * some carrier mmsc servers do not support content_disposition
+ * field correctly
+ */
+ if (mParseContentDisposition) {
+ int len = parseValueLength(pduDataStream);
+ pduDataStream.mark(1);
+ int thisStartPos = pduDataStream.available();
+ int thisEndPos = 0;
+ int value = pduDataStream.read();
+
+ if (value == PduPart.P_DISPOSITION_FROM_DATA) {
+ part.setContentDisposition(PduPart.DISPOSITION_FROM_DATA);
+ } else if (value == PduPart.P_DISPOSITION_ATTACHMENT) {
+ part.setContentDisposition(PduPart.DISPOSITION_ATTACHMENT);
+ } else if (value == PduPart.P_DISPOSITION_INLINE) {
+ part.setContentDisposition(PduPart.DISPOSITION_INLINE);
+ } else {
+ pduDataStream.reset();
+ /* Token-text */
+ part.setContentDisposition(parseWapString(pduDataStream
+ , TYPE_TEXT_STRING));
+ }
+
+ /* get filename parameter and skip other parameters */
+ thisEndPos = pduDataStream.available();
+ if (thisStartPos - thisEndPos < len) {
+ value = pduDataStream.read();
+ if (value == PduPart.P_FILENAME) { //filename is text-string
+ part.setFilename(parseWapString(pduDataStream
+ , TYPE_TEXT_STRING));
+ }
+
+ /* skip other parameters */
+ thisEndPos = pduDataStream.available();
+ if (thisStartPos - thisEndPos < len) {
+ int last = len - (thisStartPos - thisEndPos);
+ byte[] temp = new byte[last];
+ pduDataStream.read(temp, 0, last);
+ }
+ }
+
+ tempPos = pduDataStream.available();
+ lastLen = length - (startPos - tempPos);
+ }
+ break;
+ default:
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "Not supported Part headers: " + header);
+ }
+ if (-1 == skipWapValue(pduDataStream, lastLen)) {
+ Log.e(LOG_TAG, "Corrupt Part headers");
+ return false;
+ }
+ lastLen = 0;
+ break;
+ }
+ } else if ((header >= TEXT_MIN) && (header <= TEXT_MAX)) {
+ // Not assigned header.
+ byte[] tempHeader = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+ byte[] tempValue = parseWapString(pduDataStream, TYPE_TEXT_STRING);
+
+ // Check the header whether it is "Content-Transfer-Encoding".
+ if (true ==
+ PduPart.CONTENT_TRANSFER_ENCODING
+ .equalsIgnoreCase(new String(tempHeader))) {
+ part.setContentTransferEncoding(tempValue);
+ }
+
+ tempPos = pduDataStream.available();
+ lastLen = length - (startPos - tempPos);
+ } else {
+ if (LOCAL_LOGV) {
+ Log.v(LOG_TAG, "Not supported Part headers: " + header);
+ }
+ // Skip all headers of this part.
+ if (-1 == skipWapValue(pduDataStream, lastLen)) {
+ Log.e(LOG_TAG, "Corrupt Part headers");
+ return false;
+ }
+ lastLen = 0;
+ }
+ }
+
+ if (0 != lastLen) {
+ Log.e(LOG_TAG, "Corrupt Part headers");
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Check the position of a specified part.
+ *
+ * @param part the part to be checked
+ * @return part position, THE_FIRST_PART when it's the
+ * first one, THE_LAST_PART when it's the last one.
+ */
+ private static int checkPartPosition(PduPart part) {
+ assert (null != part);
+ if ((null == mTypeParam) &&
+ (null == mStartParam)) {
+ return THE_LAST_PART;
+ }
+
+ /* check part's content-id */
+ if (null != mStartParam) {
+ byte[] contentId = part.getContentId();
+ if (null != contentId) {
+ if (true == Arrays.equals(mStartParam, contentId)) {
+ return THE_FIRST_PART;
+ }
+ }
+ // This is not the first part, so append to end (keeping the original order)
+ // Check b/19607294 for details of this change
+ return THE_LAST_PART;
+ }
+
+ /* check part's content-type */
+ if (null != mTypeParam) {
+ byte[] contentType = part.getContentType();
+ if (null != contentType) {
+ if (true == Arrays.equals(mTypeParam, contentType)) {
+ return THE_FIRST_PART;
+ }
+ }
+ }
+
+ return THE_LAST_PART;
+ }
+
+ /**
+ * Check mandatory headers of a pdu.
+ *
+ * @param headers pdu headers
+ * @return true if the pdu has all of the mandatory headers, false otherwise.
+ */
+ protected static boolean checkMandatoryHeader(PduHeaders headers) {
+ if (null == headers) {
+ return false;
+ }
+
+ /* get message type */
+ int messageType = headers.getOctet(PduHeaders.MESSAGE_TYPE);
+
+ /* check Mms-Version field */
+ int mmsVersion = headers.getOctet(PduHeaders.MMS_VERSION);
+ if (0 == mmsVersion) {
+ // Every message should have Mms-Version field.
+ return false;
+ }
+
+ /* check mandatory header fields */
+ switch (messageType) {
+ case PduHeaders.MESSAGE_TYPE_SEND_REQ:
+ // Content-Type field.
+ byte[] srContentType = headers.getTextString(PduHeaders.CONTENT_TYPE);
+ if (null == srContentType) {
+ return false;
+ }
+
+ // From field.
+ EncodedStringValue srFrom = headers.getEncodedStringValue(PduHeaders.FROM);
+ if (null == srFrom) {
+ return false;
+ }
+
+ // Transaction-Id field.
+ byte[] srTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID);
+ if (null == srTransactionId) {
+ return false;
+ }
+
+ break;
+ case PduHeaders.MESSAGE_TYPE_SEND_CONF:
+ // Response-Status field.
+ int scResponseStatus = headers.getOctet(PduHeaders.RESPONSE_STATUS);
+ if (0 == scResponseStatus) {
+ return false;
+ }
+
+ // Transaction-Id field.
+ byte[] scTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID);
+ if (null == scTransactionId) {
+ return false;
+ }
+
+ break;
+ case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND:
+ // Content-Location field.
+ byte[] niContentLocation = headers.getTextString(PduHeaders.CONTENT_LOCATION);
+ if (null == niContentLocation) {
+ return false;
+ }
+
+ // Expiry field.
+ long niExpiry = headers.getLongInteger(PduHeaders.EXPIRY);
+ if (-1 == niExpiry) {
+ return false;
+ }
+
+ // Message-Class field.
+ byte[] niMessageClass = headers.getTextString(PduHeaders.MESSAGE_CLASS);
+ if (null == niMessageClass) {
+ return false;
+ }
+
+ // Message-Size field.
+ long niMessageSize = headers.getLongInteger(PduHeaders.MESSAGE_SIZE);
+ if (-1 == niMessageSize) {
+ return false;
+ }
+
+ // Transaction-Id field.
+ byte[] niTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID);
+ if (null == niTransactionId) {
+ return false;
+ }
+
+ break;
+ case PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND:
+ // Status field.
+ int nriStatus = headers.getOctet(PduHeaders.STATUS);
+ if (0 == nriStatus) {
+ return false;
+ }
+
+ // Transaction-Id field.
+ byte[] nriTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID);
+ if (null == nriTransactionId) {
+ return false;
+ }
+
+ break;
+ case PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF:
+ // Content-Type field.
+ byte[] rcContentType = headers.getTextString(PduHeaders.CONTENT_TYPE);
+ if (null == rcContentType) {
+ return false;
+ }
+
+ // Date field.
+ long rcDate = headers.getLongInteger(PduHeaders.DATE);
+ if (-1 == rcDate) {
+ return false;
+ }
+
+ break;
+ case PduHeaders.MESSAGE_TYPE_DELIVERY_IND:
+ // Date field.
+ long diDate = headers.getLongInteger(PduHeaders.DATE);
+ if (-1 == diDate) {
+ return false;
+ }
+
+ // Message-Id field.
+ byte[] diMessageId = headers.getTextString(PduHeaders.MESSAGE_ID);
+ if (null == diMessageId) {
+ return false;
+ }
+
+ // Status field.
+ int diStatus = headers.getOctet(PduHeaders.STATUS);
+ if (0 == diStatus) {
+ return false;
+ }
+
+ // To field.
+ EncodedStringValue[] diTo = headers.getEncodedStringValues(PduHeaders.TO);
+ if (null == diTo) {
+ return false;
+ }
+
+ break;
+ case PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND:
+ // Transaction-Id field.
+ byte[] aiTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID);
+ if (null == aiTransactionId) {
+ return false;
+ }
+
+ break;
+ case PduHeaders.MESSAGE_TYPE_READ_ORIG_IND:
+ // Date field.
+ long roDate = headers.getLongInteger(PduHeaders.DATE);
+ if (-1 == roDate) {
+ return false;
+ }
+
+ // From field.
+ EncodedStringValue roFrom = headers.getEncodedStringValue(PduHeaders.FROM);
+ if (null == roFrom) {
+ return false;
+ }
+
+ // Message-Id field.
+ byte[] roMessageId = headers.getTextString(PduHeaders.MESSAGE_ID);
+ if (null == roMessageId) {
+ return false;
+ }
+
+ // Read-Status field.
+ int roReadStatus = headers.getOctet(PduHeaders.READ_STATUS);
+ if (0 == roReadStatus) {
+ return false;
+ }
+
+ // To field.
+ EncodedStringValue[] roTo = headers.getEncodedStringValues(PduHeaders.TO);
+ if (null == roTo) {
+ return false;
+ }
+
+ break;
+ case PduHeaders.MESSAGE_TYPE_READ_REC_IND:
+ // From field.
+ EncodedStringValue rrFrom = headers.getEncodedStringValue(PduHeaders.FROM);
+ if (null == rrFrom) {
+ return false;
+ }
+
+ // Message-Id field.
+ byte[] rrMessageId = headers.getTextString(PduHeaders.MESSAGE_ID);
+ if (null == rrMessageId) {
+ return false;
+ }
+
+ // Read-Status field.
+ int rrReadStatus = headers.getOctet(PduHeaders.READ_STATUS);
+ if (0 == rrReadStatus) {
+ return false;
+ }
+
+ // To field.
+ EncodedStringValue[] rrTo = headers.getEncodedStringValues(PduHeaders.TO);
+ if (null == rrTo) {
+ return false;
+ }
+
+ break;
+ default:
+ // Parser doesn't support this message type in this version.
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/PduPart.java b/src/com/android/messaging/mmslib/pdu/PduPart.java
new file mode 100644
index 0000000..dcdb7a6
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/PduPart.java
@@ -0,0 +1,389 @@
+/*
+ * Copyright (C) 2007-2008 Esmertec AG.
+ * Copyright (C) 2007-2008 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.messaging.mmslib.pdu;
+
+import android.net.Uri;
+import android.util.SparseArray;
+
+/**
+ * The pdu part.
+ */
+public class PduPart {
+ /**
+ * Well-Known Parameters.
+ */
+ public static final int P_Q = 0x80;
+ public static final int P_CHARSET = 0x81;
+ public static final int P_LEVEL = 0x82;
+ public static final int P_TYPE = 0x83;
+ public static final int P_DEP_NAME = 0x85;
+ public static final int P_DEP_FILENAME = 0x86;
+ public static final int P_DIFFERENCES = 0x87;
+ public static final int P_PADDING = 0x88;
+ // This value of "TYPE" s used with Content-Type: multipart/related
+ public static final int P_CT_MR_TYPE = 0x89;
+ public static final int P_DEP_START = 0x8A;
+ public static final int P_DEP_START_INFO = 0x8B;
+ public static final int P_DEP_COMMENT = 0x8C;
+ public static final int P_DEP_DOMAIN = 0x8D;
+ public static final int P_MAX_AGE = 0x8E;
+ public static final int P_DEP_PATH = 0x8F;
+ public static final int P_SECURE = 0x90;
+ public static final int P_SEC = 0x91;
+ public static final int P_MAC = 0x92;
+ public static final int P_CREATION_DATE = 0x93;
+ public static final int P_MODIFICATION_DATE = 0x94;
+ public static final int P_READ_DATE = 0x95;
+ public static final int P_SIZE = 0x96;
+ public static final int P_NAME = 0x97;
+ public static final int P_FILENAME = 0x98;
+ public static final int P_START = 0x99;
+ public static final int P_START_INFO = 0x9A;
+ public static final int P_COMMENT = 0x9B;
+ public static final int P_DOMAIN = 0x9C;
+ public static final int P_PATH = 0x9D;
+
+ /**
+ * Header field names.
+ */
+ public static final int P_CONTENT_TYPE = 0x91;
+ public static final int P_CONTENT_LOCATION = 0x8E;
+ public static final int P_CONTENT_ID = 0xC0;
+ public static final int P_DEP_CONTENT_DISPOSITION = 0xAE;
+ public static final int P_CONTENT_DISPOSITION = 0xC5;
+ // The next header is unassigned header, use reserved header(0x48) value.
+ public static final int P_CONTENT_TRANSFER_ENCODING = 0xC8;
+
+ /**
+ * Content=Transfer-Encoding string.
+ */
+ public static final String CONTENT_TRANSFER_ENCODING =
+ "Content-Transfer-Encoding";
+
+ /**
+ * Value of Content-Transfer-Encoding.
+ */
+ public static final String P_BINARY = "binary";
+ public static final String P_7BIT = "7bit";
+ public static final String P_8BIT = "8bit";
+ public static final String P_BASE64 = "base64";
+ public static final String P_QUOTED_PRINTABLE = "quoted-printable";
+
+ /**
+ * Value of disposition can be set to PduPart when the value is octet in
+ * the PDU.
+ * "from-data" instead of Form-data<Octet 128>.
+ * "attachment" instead of Attachment<Octet 129>.
+ * "inline" instead of Inline<Octet 130>.
+ */
+ static final byte[] DISPOSITION_FROM_DATA = "from-data".getBytes();
+ static final byte[] DISPOSITION_ATTACHMENT = "attachment".getBytes();
+ static final byte[] DISPOSITION_INLINE = "inline".getBytes();
+
+ /**
+ * Content-Disposition value.
+ */
+ public static final int P_DISPOSITION_FROM_DATA = 0x80;
+ public static final int P_DISPOSITION_ATTACHMENT = 0x81;
+ public static final int P_DISPOSITION_INLINE = 0x82;
+
+ /**
+ * Header of part.
+ */
+ private SparseArray<Object> mPartHeader = null;
+
+ /**
+ * Data uri.
+ */
+ private Uri mUri = null;
+
+ /**
+ * Part data.
+ */
+ private byte[] mPartData = null;
+
+ private static final String TAG = "PduPart";
+
+ /**
+ * Empty Constructor.
+ */
+ public PduPart() {
+ mPartHeader = new SparseArray<Object>();
+ }
+
+ /**
+ * Set part data. The data are stored as byte array.
+ *
+ * @param data the data
+ */
+ public void setData(final byte[] data) {
+ mPartData = data;
+ }
+
+ /**
+ * @return The part data or null if the data wasn't set or
+ * the data is stored as Uri.
+ * @see #getDataUri
+ */
+ public byte[] getData() {
+ return mPartData;
+ }
+
+ /**
+ * Set data uri. The data are stored as Uri.
+ *
+ * @param uri the uri
+ */
+ public void setDataUri(final Uri uri) {
+ mUri = uri;
+ }
+
+ /**
+ * @return The Uri of the part data or null if the data wasn't set or
+ * the data is stored as byte array.
+ * @see #getData
+ */
+ public Uri getDataUri() {
+ return mUri;
+ }
+
+ /**
+ * Set Content-id value
+ *
+ * @param contentId the content-id value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setContentId(final byte[] contentId) {
+ if ((contentId == null) || (contentId.length == 0)) {
+ throw new IllegalArgumentException(
+ "Content-Id may not be null or empty.");
+ }
+
+ if ((contentId.length > 1)
+ && ((char) contentId[0] == '<')
+ && ((char) contentId[contentId.length - 1] == '>')) {
+ mPartHeader.put(P_CONTENT_ID, contentId);
+ return;
+ }
+
+ // Insert beginning '<' and trailing '>' for Content-Id.
+ final byte[] buffer = new byte[contentId.length + 2];
+ buffer[0] = (byte) (0xff & '<');
+ buffer[buffer.length - 1] = (byte) (0xff & '>');
+ System.arraycopy(contentId, 0, buffer, 1, contentId.length);
+ mPartHeader.put(P_CONTENT_ID, buffer);
+ }
+
+ /**
+ * Get Content-id value.
+ *
+ * @return the value
+ */
+ public byte[] getContentId() {
+ return (byte[]) mPartHeader.get(P_CONTENT_ID);
+ }
+
+ /**
+ * Set Char-set value.
+ *
+ * @param charset the value
+ */
+ public void setCharset(final int charset) {
+ mPartHeader.put(P_CHARSET, charset);
+ }
+
+ /**
+ * Get Char-set value
+ *
+ * @return the charset value. Return 0 if charset was not set.
+ */
+ public int getCharset() {
+ final Integer charset = (Integer) mPartHeader.get(P_CHARSET);
+ if (charset == null) {
+ return 0;
+ } else {
+ return charset.intValue();
+ }
+ }
+
+ /**
+ * Set Content-Location value.
+ *
+ * @param contentLocation the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setContentLocation(final byte[] contentLocation) {
+ if (contentLocation == null) {
+ throw new NullPointerException("null content-location");
+ }
+
+ mPartHeader.put(P_CONTENT_LOCATION, contentLocation);
+ }
+
+ /**
+ * Get Content-Location value.
+ *
+ * @return the value
+ * return PduPart.disposition[0] instead of <Octet 128> (Form-data).
+ * return PduPart.disposition[1] instead of <Octet 129> (Attachment).
+ * return PduPart.disposition[2] instead of <Octet 130> (Inline).
+ */
+ public byte[] getContentLocation() {
+ return (byte[]) mPartHeader.get(P_CONTENT_LOCATION);
+ }
+
+ /**
+ * Set Content-Disposition value.
+ * Use PduPart.disposition[0] instead of <Octet 128> (Form-data).
+ * Use PduPart.disposition[1] instead of <Octet 129> (Attachment).
+ * Use PduPart.disposition[2] instead of <Octet 130> (Inline).
+ *
+ * @param contentDisposition the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setContentDisposition(final byte[] contentDisposition) {
+ if (contentDisposition == null) {
+ throw new NullPointerException("null content-disposition");
+ }
+
+ mPartHeader.put(P_CONTENT_DISPOSITION, contentDisposition);
+ }
+
+ /**
+ * Get Content-Disposition value.
+ *
+ * @return the value
+ */
+ public byte[] getContentDisposition() {
+ return (byte[]) mPartHeader.get(P_CONTENT_DISPOSITION);
+ }
+
+ /**
+ * Set Content-Type value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setContentType(final byte[] contentType) {
+ if (contentType == null) {
+ throw new NullPointerException("null content-type");
+ }
+
+ mPartHeader.put(P_CONTENT_TYPE, contentType);
+ }
+
+ /**
+ * Get Content-Type value of part.
+ *
+ * @return the value
+ */
+ public byte[] getContentType() {
+ return (byte[]) mPartHeader.get(P_CONTENT_TYPE);
+ }
+
+ /**
+ * Set Content-Transfer-Encoding value
+ *
+ * @param contentId the content-id value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setContentTransferEncoding(final byte[] contentTransferEncoding) {
+ if (contentTransferEncoding == null) {
+ throw new NullPointerException("null content-transfer-encoding");
+ }
+
+ mPartHeader.put(P_CONTENT_TRANSFER_ENCODING, contentTransferEncoding);
+ }
+
+ /**
+ * Get Content-Transfer-Encoding value.
+ *
+ * @return the value
+ */
+ public byte[] getContentTransferEncoding() {
+ return (byte[]) mPartHeader.get(P_CONTENT_TRANSFER_ENCODING);
+ }
+
+ /**
+ * Set Content-type parameter: name.
+ *
+ * @param name the name value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setName(final byte[] name) {
+ if (null == name) {
+ throw new NullPointerException("null content-id");
+ }
+
+ mPartHeader.put(P_NAME, name);
+ }
+
+ /**
+ * Get content-type parameter: name.
+ *
+ * @return the name
+ */
+ public byte[] getName() {
+ return (byte[]) mPartHeader.get(P_NAME);
+ }
+
+ /**
+ * Get Content-disposition parameter: filename
+ *
+ * @param fileName the filename value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setFilename(final byte[] fileName) {
+ if (null == fileName) {
+ throw new NullPointerException("null content-id");
+ }
+
+ mPartHeader.put(P_FILENAME, fileName);
+ }
+
+ /**
+ * Set Content-disposition parameter: filename
+ *
+ * @return the filename
+ */
+ public byte[] getFilename() {
+ return (byte[]) mPartHeader.get(P_FILENAME);
+ }
+
+ public String generateLocation() {
+ // Assumption: At least one of the content-location / name / filename
+ // or content-id should be set. This is guaranteed by the PduParser
+ // for incoming messages and by MM composer for outgoing messages.
+ byte[] location = (byte[]) mPartHeader.get(P_NAME);
+ if (null == location) {
+ location = (byte[]) mPartHeader.get(P_FILENAME);
+
+ if (null == location) {
+ location = (byte[]) mPartHeader.get(P_CONTENT_LOCATION);
+ }
+ }
+
+ if (null == location) {
+ final byte[] contentId = (byte[]) mPartHeader.get(P_CONTENT_ID);
+ return "cid:" + new String(contentId);
+ } else {
+ return new String(location);
+ }
+ }
+}
+
diff --git a/src/com/android/messaging/mmslib/pdu/PduPersister.java b/src/com/android/messaging/mmslib/pdu/PduPersister.java
new file mode 100644
index 0000000..ceb6a85
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/PduPersister.java
@@ -0,0 +1,1683 @@
+/*
+ * Copyright (C) 2007-2008 Esmertec AG.
+ * Copyright (C) 2007-2008 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.messaging.mmslib.pdu;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteException;
+import android.net.Uri;
+import android.provider.MediaStore;
+import android.provider.Telephony.Mms;
+import android.provider.Telephony.Mms.Addr;
+import android.provider.Telephony.Mms.Part;
+import android.provider.Telephony.MmsSms;
+import android.provider.Telephony.MmsSms.PendingMessages;
+import android.support.v4.util.ArrayMap;
+import android.support.v4.util.SimpleArrayMap;
+import android.telephony.PhoneNumberUtils;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.SparseArray;
+import android.util.SparseIntArray;
+
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+import com.android.messaging.mmslib.MmsException;
+import com.android.messaging.mmslib.SqliteWrapper;
+import com.android.messaging.mmslib.util.DownloadDrmHelper;
+import com.android.messaging.mmslib.util.DrmConvertSession;
+import com.android.messaging.mmslib.util.PduCache;
+import com.android.messaging.mmslib.util.PduCacheEntry;
+import com.android.messaging.sms.MmsSmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Map;
+
+/**
+ * This class is the high-level manager of PDU storage.
+ */
+public class PduPersister {
+ private static final String TAG = "PduPersister";
+ private static final boolean LOCAL_LOGV = false;
+
+ /**
+ * The uri of temporary drm objects.
+ */
+ public static final String TEMPORARY_DRM_OBJECT_URI =
+ "content://mms/" + Long.MAX_VALUE + "/part";
+
+ /**
+ * Indicate that we transiently failed to process a MM.
+ */
+ public static final int PROC_STATUS_TRANSIENT_FAILURE = 1;
+
+ /**
+ * Indicate that we permanently failed to process a MM.
+ */
+ public static final int PROC_STATUS_PERMANENTLY_FAILURE = 2;
+
+ /**
+ * Indicate that we have successfully processed a MM.
+ */
+ public static final int PROC_STATUS_COMPLETED = 3;
+
+ public static final String BEGIN_VCARD = "BEGIN:VCARD";
+
+ private static PduPersister sPersister;
+
+ private static final PduCache PDU_CACHE_INSTANCE;
+
+ private static final int[] ADDRESS_FIELDS = new int[]{
+ PduHeaders.BCC,
+ PduHeaders.CC,
+ PduHeaders.FROM,
+ PduHeaders.TO
+ };
+
+ public static final String[] PDU_PROJECTION = new String[]{
+ Mms._ID,
+ Mms.MESSAGE_BOX,
+ Mms.THREAD_ID,
+ Mms.RETRIEVE_TEXT,
+ Mms.SUBJECT,
+ Mms.CONTENT_LOCATION,
+ Mms.CONTENT_TYPE,
+ Mms.MESSAGE_CLASS,
+ Mms.MESSAGE_ID,
+ Mms.RESPONSE_TEXT,
+ Mms.TRANSACTION_ID,
+ Mms.CONTENT_CLASS,
+ Mms.DELIVERY_REPORT,
+ Mms.MESSAGE_TYPE,
+ Mms.MMS_VERSION,
+ Mms.PRIORITY,
+ Mms.READ_REPORT,
+ Mms.READ_STATUS,
+ Mms.REPORT_ALLOWED,
+ Mms.RETRIEVE_STATUS,
+ Mms.STATUS,
+ Mms.DATE,
+ Mms.DELIVERY_TIME,
+ Mms.EXPIRY,
+ Mms.MESSAGE_SIZE,
+ Mms.SUBJECT_CHARSET,
+ Mms.RETRIEVE_TEXT_CHARSET,
+ Mms.READ,
+ Mms.SEEN,
+ };
+
+ public static final int PDU_COLUMN_ID = 0;
+ public static final int PDU_COLUMN_MESSAGE_BOX = 1;
+ public static final int PDU_COLUMN_THREAD_ID = 2;
+ public static final int PDU_COLUMN_RETRIEVE_TEXT = 3;
+ public static final int PDU_COLUMN_SUBJECT = 4;
+ public static final int PDU_COLUMN_CONTENT_LOCATION = 5;
+ public static final int PDU_COLUMN_CONTENT_TYPE = 6;
+ public static final int PDU_COLUMN_MESSAGE_CLASS = 7;
+ public static final int PDU_COLUMN_MESSAGE_ID = 8;
+ public static final int PDU_COLUMN_RESPONSE_TEXT = 9;
+ public static final int PDU_COLUMN_TRANSACTION_ID = 10;
+ public static final int PDU_COLUMN_CONTENT_CLASS = 11;
+ public static final int PDU_COLUMN_DELIVERY_REPORT = 12;
+ public static final int PDU_COLUMN_MESSAGE_TYPE = 13;
+ public static final int PDU_COLUMN_MMS_VERSION = 14;
+ public static final int PDU_COLUMN_PRIORITY = 15;
+ public static final int PDU_COLUMN_READ_REPORT = 16;
+ public static final int PDU_COLUMN_READ_STATUS = 17;
+ public static final int PDU_COLUMN_REPORT_ALLOWED = 18;
+ public static final int PDU_COLUMN_RETRIEVE_STATUS = 19;
+ public static final int PDU_COLUMN_STATUS = 20;
+ public static final int PDU_COLUMN_DATE = 21;
+ public static final int PDU_COLUMN_DELIVERY_TIME = 22;
+ public static final int PDU_COLUMN_EXPIRY = 23;
+ public static final int PDU_COLUMN_MESSAGE_SIZE = 24;
+ public static final int PDU_COLUMN_SUBJECT_CHARSET = 25;
+ public static final int PDU_COLUMN_RETRIEVE_TEXT_CHARSET = 26;
+ public static final int PDU_COLUMN_READ = 27;
+ public static final int PDU_COLUMN_SEEN = 28;
+
+ private static final String[] PART_PROJECTION = new String[] {
+ Part._ID,
+ Part.CHARSET,
+ Part.CONTENT_DISPOSITION,
+ Part.CONTENT_ID,
+ Part.CONTENT_LOCATION,
+ Part.CONTENT_TYPE,
+ Part.FILENAME,
+ Part.NAME,
+ Part.TEXT
+ };
+
+ private static final int PART_COLUMN_ID = 0;
+ private static final int PART_COLUMN_CHARSET = 1;
+ private static final int PART_COLUMN_CONTENT_DISPOSITION = 2;
+ private static final int PART_COLUMN_CONTENT_ID = 3;
+ private static final int PART_COLUMN_CONTENT_LOCATION = 4;
+ private static final int PART_COLUMN_CONTENT_TYPE = 5;
+ private static final int PART_COLUMN_FILENAME = 6;
+ private static final int PART_COLUMN_NAME = 7;
+ private static final int PART_COLUMN_TEXT = 8;
+
+ private static final SimpleArrayMap<Uri, Integer> MESSAGE_BOX_MAP;
+
+ // These map are used for convenience in persist() and load().
+ private static final SparseIntArray CHARSET_COLUMN_INDEX_MAP;
+
+ private static final SparseIntArray ENCODED_STRING_COLUMN_INDEX_MAP;
+
+ private static final SparseIntArray TEXT_STRING_COLUMN_INDEX_MAP;
+
+ private static final SparseIntArray OCTET_COLUMN_INDEX_MAP;
+
+ private static final SparseIntArray LONG_COLUMN_INDEX_MAP;
+
+ private static final SparseArray<String> CHARSET_COLUMN_NAME_MAP;
+
+ private static final SparseArray<String> ENCODED_STRING_COLUMN_NAME_MAP;
+
+ private static final SparseArray<String> TEXT_STRING_COLUMN_NAME_MAP;
+
+ private static final SparseArray<String> OCTET_COLUMN_NAME_MAP;
+
+ private static final SparseArray<String> LONG_COLUMN_NAME_MAP;
+
+ static {
+ MESSAGE_BOX_MAP = new SimpleArrayMap<Uri, Integer>();
+ MESSAGE_BOX_MAP.put(Mms.Inbox.CONTENT_URI, Mms.MESSAGE_BOX_INBOX);
+ MESSAGE_BOX_MAP.put(Mms.Sent.CONTENT_URI, Mms.MESSAGE_BOX_SENT);
+ MESSAGE_BOX_MAP.put(Mms.Draft.CONTENT_URI, Mms.MESSAGE_BOX_DRAFTS);
+ MESSAGE_BOX_MAP.put(Mms.Outbox.CONTENT_URI, Mms.MESSAGE_BOX_OUTBOX);
+
+ CHARSET_COLUMN_INDEX_MAP = new SparseIntArray();
+ CHARSET_COLUMN_INDEX_MAP.put(PduHeaders.SUBJECT, PDU_COLUMN_SUBJECT_CHARSET);
+ CHARSET_COLUMN_INDEX_MAP.put(PduHeaders.RETRIEVE_TEXT, PDU_COLUMN_RETRIEVE_TEXT_CHARSET);
+
+ CHARSET_COLUMN_NAME_MAP = new SparseArray<String>();
+ CHARSET_COLUMN_NAME_MAP.put(PduHeaders.SUBJECT, Mms.SUBJECT_CHARSET);
+ CHARSET_COLUMN_NAME_MAP.put(PduHeaders.RETRIEVE_TEXT, Mms.RETRIEVE_TEXT_CHARSET);
+
+ // Encoded string field code -> column index/name map.
+ ENCODED_STRING_COLUMN_INDEX_MAP = new SparseIntArray();
+ ENCODED_STRING_COLUMN_INDEX_MAP.put(PduHeaders.RETRIEVE_TEXT, PDU_COLUMN_RETRIEVE_TEXT);
+ ENCODED_STRING_COLUMN_INDEX_MAP.put(PduHeaders.SUBJECT, PDU_COLUMN_SUBJECT);
+
+ ENCODED_STRING_COLUMN_NAME_MAP = new SparseArray<String>();
+ ENCODED_STRING_COLUMN_NAME_MAP.put(PduHeaders.RETRIEVE_TEXT, Mms.RETRIEVE_TEXT);
+ ENCODED_STRING_COLUMN_NAME_MAP.put(PduHeaders.SUBJECT, Mms.SUBJECT);
+
+ // Text string field code -> column index/name map.
+ TEXT_STRING_COLUMN_INDEX_MAP = new SparseIntArray();
+ TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.CONTENT_LOCATION, PDU_COLUMN_CONTENT_LOCATION);
+ TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.CONTENT_TYPE, PDU_COLUMN_CONTENT_TYPE);
+ TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.MESSAGE_CLASS, PDU_COLUMN_MESSAGE_CLASS);
+ TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.MESSAGE_ID, PDU_COLUMN_MESSAGE_ID);
+ TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.RESPONSE_TEXT, PDU_COLUMN_RESPONSE_TEXT);
+ TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.TRANSACTION_ID, PDU_COLUMN_TRANSACTION_ID);
+
+ TEXT_STRING_COLUMN_NAME_MAP = new SparseArray<String>();
+ TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.CONTENT_LOCATION, Mms.CONTENT_LOCATION);
+ TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.CONTENT_TYPE, Mms.CONTENT_TYPE);
+ TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.MESSAGE_CLASS, Mms.MESSAGE_CLASS);
+ TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.MESSAGE_ID, Mms.MESSAGE_ID);
+ TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.RESPONSE_TEXT, Mms.RESPONSE_TEXT);
+ TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.TRANSACTION_ID, Mms.TRANSACTION_ID);
+
+ // Octet field code -> column index/name map.
+ OCTET_COLUMN_INDEX_MAP = new SparseIntArray();
+ OCTET_COLUMN_INDEX_MAP.put(PduHeaders.CONTENT_CLASS, PDU_COLUMN_CONTENT_CLASS);
+ OCTET_COLUMN_INDEX_MAP.put(PduHeaders.DELIVERY_REPORT, PDU_COLUMN_DELIVERY_REPORT);
+ OCTET_COLUMN_INDEX_MAP.put(PduHeaders.MESSAGE_TYPE, PDU_COLUMN_MESSAGE_TYPE);
+ OCTET_COLUMN_INDEX_MAP.put(PduHeaders.MMS_VERSION, PDU_COLUMN_MMS_VERSION);
+ OCTET_COLUMN_INDEX_MAP.put(PduHeaders.PRIORITY, PDU_COLUMN_PRIORITY);
+ OCTET_COLUMN_INDEX_MAP.put(PduHeaders.READ_REPORT, PDU_COLUMN_READ_REPORT);
+ OCTET_COLUMN_INDEX_MAP.put(PduHeaders.READ_STATUS, PDU_COLUMN_READ_STATUS);
+ OCTET_COLUMN_INDEX_MAP.put(PduHeaders.REPORT_ALLOWED, PDU_COLUMN_REPORT_ALLOWED);
+ OCTET_COLUMN_INDEX_MAP.put(PduHeaders.RETRIEVE_STATUS, PDU_COLUMN_RETRIEVE_STATUS);
+ OCTET_COLUMN_INDEX_MAP.put(PduHeaders.STATUS, PDU_COLUMN_STATUS);
+
+ OCTET_COLUMN_NAME_MAP = new SparseArray<String>();
+ OCTET_COLUMN_NAME_MAP.put(PduHeaders.CONTENT_CLASS, Mms.CONTENT_CLASS);
+ OCTET_COLUMN_NAME_MAP.put(PduHeaders.DELIVERY_REPORT, Mms.DELIVERY_REPORT);
+ OCTET_COLUMN_NAME_MAP.put(PduHeaders.MESSAGE_TYPE, Mms.MESSAGE_TYPE);
+ OCTET_COLUMN_NAME_MAP.put(PduHeaders.MMS_VERSION, Mms.MMS_VERSION);
+ OCTET_COLUMN_NAME_MAP.put(PduHeaders.PRIORITY, Mms.PRIORITY);
+ OCTET_COLUMN_NAME_MAP.put(PduHeaders.READ_REPORT, Mms.READ_REPORT);
+ OCTET_COLUMN_NAME_MAP.put(PduHeaders.READ_STATUS, Mms.READ_STATUS);
+ OCTET_COLUMN_NAME_MAP.put(PduHeaders.REPORT_ALLOWED, Mms.REPORT_ALLOWED);
+ OCTET_COLUMN_NAME_MAP.put(PduHeaders.RETRIEVE_STATUS, Mms.RETRIEVE_STATUS);
+ OCTET_COLUMN_NAME_MAP.put(PduHeaders.STATUS, Mms.STATUS);
+
+ // Long field code -> column index/name map.
+ LONG_COLUMN_INDEX_MAP = new SparseIntArray();
+ LONG_COLUMN_INDEX_MAP.put(PduHeaders.DATE, PDU_COLUMN_DATE);
+ LONG_COLUMN_INDEX_MAP.put(PduHeaders.DELIVERY_TIME, PDU_COLUMN_DELIVERY_TIME);
+ LONG_COLUMN_INDEX_MAP.put(PduHeaders.EXPIRY, PDU_COLUMN_EXPIRY);
+ LONG_COLUMN_INDEX_MAP.put(PduHeaders.MESSAGE_SIZE, PDU_COLUMN_MESSAGE_SIZE);
+
+ LONG_COLUMN_NAME_MAP = new SparseArray<String>();
+ LONG_COLUMN_NAME_MAP.put(PduHeaders.DATE, Mms.DATE);
+ LONG_COLUMN_NAME_MAP.put(PduHeaders.DELIVERY_TIME, Mms.DELIVERY_TIME);
+ LONG_COLUMN_NAME_MAP.put(PduHeaders.EXPIRY, Mms.EXPIRY);
+ LONG_COLUMN_NAME_MAP.put(PduHeaders.MESSAGE_SIZE, Mms.MESSAGE_SIZE);
+
+ PDU_CACHE_INSTANCE = PduCache.getInstance();
+ }
+
+ private final Context mContext;
+
+ private final ContentResolver mContentResolver;
+
+ private PduPersister(final Context context) {
+ mContext = context;
+ mContentResolver = context.getContentResolver();
+ }
+
+ /** Get(or create if not exist) an instance of PduPersister */
+ public static PduPersister getPduPersister(final Context context) {
+ if ((sPersister == null) || !context.equals(sPersister.mContext)) {
+ sPersister = new PduPersister(context);
+ }
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "PduPersister getPduPersister");
+ }
+
+ return sPersister;
+ }
+
+ private void setEncodedStringValueToHeaders(
+ final Cursor c, final int columnIndex,
+ final PduHeaders headers, final int mapColumn) {
+ final String s = c.getString(columnIndex);
+ if ((s != null) && (s.length() > 0)) {
+ final int charsetColumnIndex = CHARSET_COLUMN_INDEX_MAP.get(mapColumn);
+ final int charset = c.getInt(charsetColumnIndex);
+ final EncodedStringValue value = new EncodedStringValue(
+ charset, getBytes(s));
+ headers.setEncodedStringValue(value, mapColumn);
+ }
+ }
+
+ private void setTextStringToHeaders(
+ final Cursor c, final int columnIndex,
+ final PduHeaders headers, final int mapColumn) {
+ final String s = c.getString(columnIndex);
+ if (s != null) {
+ headers.setTextString(getBytes(s), mapColumn);
+ }
+ }
+
+ private void setOctetToHeaders(
+ final Cursor c, final int columnIndex,
+ final PduHeaders headers, final int mapColumn) throws InvalidHeaderValueException {
+ if (!c.isNull(columnIndex)) {
+ final int b = c.getInt(columnIndex);
+ headers.setOctet(b, mapColumn);
+ }
+ }
+
+ private void setLongToHeaders(
+ final Cursor c, final int columnIndex,
+ final PduHeaders headers, final int mapColumn) {
+ if (!c.isNull(columnIndex)) {
+ final long l = c.getLong(columnIndex);
+ headers.setLongInteger(l, mapColumn);
+ }
+ }
+
+ private Integer getIntegerFromPartColumn(final Cursor c, final int columnIndex) {
+ if (!c.isNull(columnIndex)) {
+ return c.getInt(columnIndex);
+ }
+ return null;
+ }
+
+ private byte[] getByteArrayFromPartColumn(final Cursor c, final int columnIndex) {
+ if (!c.isNull(columnIndex)) {
+ return getBytes(c.getString(columnIndex));
+ }
+ return null;
+ }
+
+ private PduPart[] loadParts(final long msgId) throws MmsException {
+ final Cursor c = SqliteWrapper.query(mContext, mContentResolver,
+ Uri.parse("content://mms/" + msgId + "/part"),
+ PART_PROJECTION, null, null, null);
+
+ PduPart[] parts = null;
+
+ try {
+ if ((c == null) || (c.getCount() == 0)) {
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "loadParts(" + msgId + "): no part to load.");
+ }
+ return null;
+ }
+
+ final int partCount = c.getCount();
+ int partIdx = 0;
+ parts = new PduPart[partCount];
+ while (c.moveToNext()) {
+ final PduPart part = new PduPart();
+ final Integer charset = getIntegerFromPartColumn(
+ c, PART_COLUMN_CHARSET);
+ if (charset != null) {
+ part.setCharset(charset);
+ }
+
+ final byte[] contentDisposition = getByteArrayFromPartColumn(
+ c, PART_COLUMN_CONTENT_DISPOSITION);
+ if (contentDisposition != null) {
+ part.setContentDisposition(contentDisposition);
+ }
+
+ final byte[] contentId = getByteArrayFromPartColumn(
+ c, PART_COLUMN_CONTENT_ID);
+ if (contentId != null) {
+ part.setContentId(contentId);
+ }
+
+ final byte[] contentLocation = getByteArrayFromPartColumn(
+ c, PART_COLUMN_CONTENT_LOCATION);
+ if (contentLocation != null) {
+ part.setContentLocation(contentLocation);
+ }
+
+ final byte[] contentType = getByteArrayFromPartColumn(
+ c, PART_COLUMN_CONTENT_TYPE);
+ if (contentType != null) {
+ part.setContentType(contentType);
+ } else {
+ throw new MmsException("Content-Type must be set.");
+ }
+
+ final byte[] fileName = getByteArrayFromPartColumn(
+ c, PART_COLUMN_FILENAME);
+ if (fileName != null) {
+ part.setFilename(fileName);
+ }
+
+ final byte[] name = getByteArrayFromPartColumn(
+ c, PART_COLUMN_NAME);
+ if (name != null) {
+ part.setName(name);
+ }
+
+ // Construct a Uri for this part.
+ final long partId = c.getLong(PART_COLUMN_ID);
+ final Uri partURI = Uri.parse("content://mms/part/" + partId);
+ part.setDataUri(partURI);
+
+ // For images/audio/video, we won't keep their data in Part
+ // because their renderer accept Uri as source.
+ final String type = toIsoString(contentType);
+ if (!ContentType.isImageType(type)
+ && !ContentType.isAudioType(type)
+ && !ContentType.isVideoType(type)) {
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ InputStream is = null;
+
+ // Store simple string values directly in the database instead of an
+ // external file. This makes the text searchable and retrieval slightly
+ // faster.
+ if (ContentType.TEXT_PLAIN.equals(type) || ContentType.APP_SMIL.equals(type)
+ || ContentType.TEXT_HTML.equals(type)) {
+ final String text = c.getString(PART_COLUMN_TEXT);
+ final byte[] blob = new EncodedStringValue(
+ charset != null ? charset : CharacterSets.DEFAULT_CHARSET,
+ text != null ? text : "")
+ .getTextString();
+ baos.write(blob, 0, blob.length);
+ } else {
+
+ try {
+ is = mContentResolver.openInputStream(partURI);
+
+ final byte[] buffer = new byte[256];
+ int len = is.read(buffer);
+ while (len >= 0) {
+ baos.write(buffer, 0, len);
+ len = is.read(buffer);
+ }
+ } catch (final IOException e) {
+ Log.e(TAG, "Failed to load part data", e);
+ c.close();
+ throw new MmsException(e);
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (final IOException e) {
+ Log.e(TAG, "Failed to close stream", e);
+ } // Ignore
+ }
+ }
+ }
+ part.setData(baos.toByteArray());
+ }
+ parts[partIdx++] = part;
+ }
+ } finally {
+ if (c != null) {
+ c.close();
+ }
+ }
+
+ return parts;
+ }
+
+ private void loadAddress(final long msgId, final PduHeaders headers) {
+ final Cursor c = SqliteWrapper.query(mContext, mContentResolver,
+ Uri.parse("content://mms/" + msgId + "/addr"),
+ new String[]{Addr.ADDRESS, Addr.CHARSET, Addr.TYPE},
+ null, null, null);
+
+ if (c != null) {
+ try {
+ while (c.moveToNext()) {
+ final String addr = c.getString(0);
+ if (!TextUtils.isEmpty(addr)) {
+ final int addrType = c.getInt(2);
+ switch (addrType) {
+ case PduHeaders.FROM:
+ headers.setEncodedStringValue(
+ new EncodedStringValue(c.getInt(1), getBytes(addr)),
+ addrType);
+ break;
+ case PduHeaders.TO:
+ case PduHeaders.CC:
+ case PduHeaders.BCC:
+ headers.appendEncodedStringValue(
+ new EncodedStringValue(c.getInt(1), getBytes(addr)),
+ addrType);
+ break;
+ default:
+ Log.e(TAG, "Unknown address type: " + addrType);
+ break;
+ }
+ }
+ }
+ } finally {
+ c.close();
+ }
+ }
+ }
+
+ /**
+ * Load a PDU from a given cursor
+ *
+ * @param c The cursor
+ * @return A parsed PDU from the database row
+ */
+ public GenericPdu load(final Cursor c) throws MmsException {
+ final PduHeaders headers = new PduHeaders();
+ final long msgId = c.getLong(PDU_COLUMN_ID);
+ // Fill in the headers from the PDU columns
+ loadHeadersFromCursor(c, headers);
+ // Load address information of the MM.
+ loadAddress(msgId, headers);
+ // Load parts for the PDU body
+ final int msgType = headers.getOctet(PduHeaders.MESSAGE_TYPE);
+ final PduBody body = loadBody(msgId, msgType);
+ return createPdu(msgType, headers, body);
+ }
+
+ /**
+ * Load a PDU from storage by given Uri.
+ *
+ * @param uri The Uri of the PDU to be loaded.
+ * @return A generic PDU object, it may be cast to dedicated PDU.
+ * @throws MmsException Failed to load some fields of a PDU.
+ */
+ public GenericPdu load(final Uri uri) throws MmsException {
+ GenericPdu pdu = null;
+ PduCacheEntry cacheEntry = null;
+ int msgBox = 0;
+ final long threadId = -1;
+ try {
+ synchronized (PDU_CACHE_INSTANCE) {
+ if (PDU_CACHE_INSTANCE.isUpdating(uri)) {
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "load: " + uri + " blocked by isUpdating()");
+ }
+ try {
+ PDU_CACHE_INSTANCE.wait();
+ } catch (final InterruptedException e) {
+ Log.e(TAG, "load: ", e);
+ }
+ }
+
+ // Check if the pdu is already loaded
+ cacheEntry = PDU_CACHE_INSTANCE.get(uri);
+ if (cacheEntry != null) {
+ return cacheEntry.getPdu();
+ }
+
+ // Tell the cache to indicate to other callers that this item
+ // is currently being updated.
+ PDU_CACHE_INSTANCE.setUpdating(uri, true);
+ }
+
+ final Cursor c = SqliteWrapper.query(mContext, mContentResolver, uri,
+ PDU_PROJECTION, null, null, null);
+ final PduHeaders headers = new PduHeaders();
+ final long msgId = ContentUris.parseId(uri);
+
+ try {
+ if ((c == null) || (c.getCount() != 1) || !c.moveToFirst()) {
+ return null; // MMS not found
+ }
+
+ msgBox = c.getInt(PDU_COLUMN_MESSAGE_BOX);
+ //threadId = c.getLong(PDU_COLUMN_THREAD_ID);
+ loadHeadersFromCursor(c, headers);
+ } finally {
+ if (c != null) {
+ c.close();
+ }
+ }
+
+ // Check whether 'msgId' has been assigned a valid value.
+ if (msgId == -1L) {
+ throw new MmsException("Error! ID of the message: -1.");
+ }
+
+ // Load address information of the MM.
+ loadAddress(msgId, headers);
+
+ final int msgType = headers.getOctet(PduHeaders.MESSAGE_TYPE);
+ final PduBody body = loadBody(msgId, msgType);
+ pdu = createPdu(msgType, headers, body);
+ } finally {
+ synchronized (PDU_CACHE_INSTANCE) {
+ if (pdu != null) {
+ Assert.isNull(PDU_CACHE_INSTANCE.get(uri), "Pdu exists for " + uri);
+ // Update the cache entry with the real info
+ cacheEntry = new PduCacheEntry(pdu, msgBox, threadId);
+ PDU_CACHE_INSTANCE.put(uri, cacheEntry);
+ }
+ PDU_CACHE_INSTANCE.setUpdating(uri, false);
+ PDU_CACHE_INSTANCE.notifyAll(); // tell anybody waiting on this entry to go ahead
+ }
+ }
+ return pdu;
+ }
+
+ private void loadHeadersFromCursor(final Cursor c, final PduHeaders headers)
+ throws InvalidHeaderValueException {
+ for (int i = ENCODED_STRING_COLUMN_INDEX_MAP.size(); --i >= 0; ) {
+ setEncodedStringValueToHeaders(
+ c, ENCODED_STRING_COLUMN_INDEX_MAP.valueAt(i), headers,
+ ENCODED_STRING_COLUMN_INDEX_MAP.keyAt(i));
+ }
+ for (int i = TEXT_STRING_COLUMN_INDEX_MAP.size(); --i >= 0; ) {
+ setTextStringToHeaders(
+ c, TEXT_STRING_COLUMN_INDEX_MAP.valueAt(i), headers,
+ TEXT_STRING_COLUMN_INDEX_MAP.keyAt(i));
+ }
+ for (int i = OCTET_COLUMN_INDEX_MAP.size(); --i >= 0; ) {
+ setOctetToHeaders(
+ c, OCTET_COLUMN_INDEX_MAP.valueAt(i), headers,
+ OCTET_COLUMN_INDEX_MAP.keyAt(i));
+ }
+ for (int i = LONG_COLUMN_INDEX_MAP.size(); --i >= 0; ) {
+ setLongToHeaders(
+ c, LONG_COLUMN_INDEX_MAP.valueAt(i), headers,
+ LONG_COLUMN_INDEX_MAP.keyAt(i));
+ }
+ }
+
+ private GenericPdu createPdu(final int msgType, final PduHeaders headers, final PduBody body)
+ throws MmsException {
+ switch (msgType) {
+ case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND:
+ return new NotificationInd(headers);
+ case PduHeaders.MESSAGE_TYPE_DELIVERY_IND:
+ return new DeliveryInd(headers);
+ case PduHeaders.MESSAGE_TYPE_READ_ORIG_IND:
+ return new ReadOrigInd(headers);
+ case PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF:
+ return new RetrieveConf(headers, body);
+ case PduHeaders.MESSAGE_TYPE_SEND_REQ:
+ return new SendReq(headers, body);
+ case PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND:
+ return new AcknowledgeInd(headers);
+ case PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND:
+ return new NotifyRespInd(headers);
+ case PduHeaders.MESSAGE_TYPE_READ_REC_IND:
+ return new ReadRecInd(headers);
+ case PduHeaders.MESSAGE_TYPE_SEND_CONF:
+ case PduHeaders.MESSAGE_TYPE_FORWARD_REQ:
+ case PduHeaders.MESSAGE_TYPE_FORWARD_CONF:
+ case PduHeaders.MESSAGE_TYPE_MBOX_STORE_REQ:
+ case PduHeaders.MESSAGE_TYPE_MBOX_STORE_CONF:
+ case PduHeaders.MESSAGE_TYPE_MBOX_VIEW_REQ:
+ case PduHeaders.MESSAGE_TYPE_MBOX_VIEW_CONF:
+ case PduHeaders.MESSAGE_TYPE_MBOX_UPLOAD_REQ:
+ case PduHeaders.MESSAGE_TYPE_MBOX_UPLOAD_CONF:
+ case PduHeaders.MESSAGE_TYPE_MBOX_DELETE_REQ:
+ case PduHeaders.MESSAGE_TYPE_MBOX_DELETE_CONF:
+ case PduHeaders.MESSAGE_TYPE_MBOX_DESCR:
+ case PduHeaders.MESSAGE_TYPE_DELETE_REQ:
+ case PduHeaders.MESSAGE_TYPE_DELETE_CONF:
+ case PduHeaders.MESSAGE_TYPE_CANCEL_REQ:
+ case PduHeaders.MESSAGE_TYPE_CANCEL_CONF:
+ throw new MmsException(
+ "Unsupported PDU type: " + Integer.toHexString(msgType));
+
+ default:
+ throw new MmsException(
+ "Unrecognized PDU type: " + Integer.toHexString(msgType));
+ }
+ }
+
+ private PduBody loadBody(final long msgId, final int msgType) throws MmsException {
+ final PduBody body = new PduBody();
+
+ // For PDU which type is M_retrieve.conf or Send.req, we should
+ // load multiparts and put them into the body of the PDU.
+ if ((msgType == PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF)
+ || (msgType == PduHeaders.MESSAGE_TYPE_SEND_REQ)) {
+ final PduPart[] parts = loadParts(msgId);
+ if (parts != null) {
+ final int partsNum = parts.length;
+ for (int i = 0; i < partsNum; i++) {
+ body.addPart(parts[i]);
+ }
+ }
+ }
+
+ return body;
+ }
+
+ private void persistAddress(
+ final long msgId, final int type, final EncodedStringValue[] array) {
+ final ContentValues values = new ContentValues(3);
+
+ for (final EncodedStringValue addr : array) {
+ values.clear(); // Clear all values first.
+ values.put(Addr.ADDRESS, toIsoString(addr.getTextString()));
+ values.put(Addr.CHARSET, addr.getCharacterSet());
+ values.put(Addr.TYPE, type);
+
+ final Uri uri = Uri.parse("content://mms/" + msgId + "/addr");
+ SqliteWrapper.insert(mContext, mContentResolver, uri, values);
+ }
+ }
+
+ private static String getPartContentType(final PduPart part) {
+ return part.getContentType() == null ? null : toIsoString(part.getContentType());
+ }
+
+ private static void getValues(final PduPart part, final ContentValues values) {
+ byte[] bytes = part.getFilename();
+ if (bytes != null) {
+ values.put(Part.FILENAME, new String(bytes));
+ }
+
+ bytes = part.getName();
+ if (bytes != null) {
+ values.put(Part.NAME, new String(bytes));
+ }
+
+ bytes = part.getContentDisposition();
+ if (bytes != null) {
+ values.put(Part.CONTENT_DISPOSITION, toIsoString(bytes));
+ }
+
+ bytes = part.getContentId();
+ if (bytes != null) {
+ values.put(Part.CONTENT_ID, toIsoString(bytes));
+ }
+
+ bytes = part.getContentLocation();
+ if (bytes != null) {
+ values.put(Part.CONTENT_LOCATION, toIsoString(bytes));
+ }
+ }
+
+ public Uri persistPart(final PduPart part, final long msgId,
+ final Map<Uri, InputStream> preOpenedFiles) throws MmsException {
+ final Uri uri = Uri.parse("content://mms/" + msgId + "/part");
+ final ContentValues values = new ContentValues(8);
+
+ final int charset = part.getCharset();
+ if (charset != 0) {
+ values.put(Part.CHARSET, charset);
+ }
+
+ String contentType = getPartContentType(part);
+ final byte[] data = part.getData();
+
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "PduPersister.persistPart part: " + uri + " contentType: " +
+ contentType);
+ }
+
+ if (contentType != null) {
+ // There is no "image/jpg" in Android (and it's an invalid mimetype).
+ // Change it to "image/jpeg"
+ if (ContentType.IMAGE_JPG.equals(contentType)) {
+ contentType = ContentType.IMAGE_JPEG;
+ }
+
+ // On somes phones, a vcard comes in as text/plain instead of text/v-card.
+ // Fix it if necessary.
+ if (ContentType.TEXT_PLAIN.equals(contentType) && data != null) {
+ // There might be a more efficient way to just check the beginning of the string
+ // without encoding the whole thing, but we're concerned that with various
+ // characters sets, just comparing the byte data to BEGIN_VCARD would not be
+ // reliable.
+ final String encodedDataString = new EncodedStringValue(charset, data).getString();
+ if (encodedDataString != null && encodedDataString.startsWith(BEGIN_VCARD)) {
+ contentType = ContentType.TEXT_VCARD;
+ part.setContentType(contentType.getBytes());
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "PduPersister.persistPart part: " + uri + " contentType: " +
+ contentType + " changing to vcard");
+ }
+ }
+ }
+
+ values.put(Part.CONTENT_TYPE, contentType);
+ // To ensure the SMIL part is always the first part.
+ if (ContentType.APP_SMIL.equals(contentType)) {
+ values.put(Part.SEQ, -1);
+ }
+ } else {
+ throw new MmsException("MIME type of the part must be set.");
+ }
+
+ getValues(part, values);
+
+ Uri res = null;
+
+ try {
+ res = SqliteWrapper.insert(mContext, mContentResolver, uri, values);
+ } catch (IllegalStateException e) {
+ // Currently the MMS provider throws an IllegalStateException when it's out of space
+ LogUtil.e(TAG, "SqliteWrapper.insert threw: ", e);
+ }
+
+ if (res == null) {
+ throw new MmsException("Failed to persist part, return null.");
+ }
+
+ persistData(part, res, contentType, preOpenedFiles);
+ // After successfully store the data, we should update
+ // the dataUri of the part.
+ part.setDataUri(res);
+
+ return res;
+ }
+
+ /**
+ * Save data of the part into storage. The source data may be given
+ * by a byte[] or a Uri. If it's a byte[], directly save it
+ * into storage, otherwise load source data from the dataUri and then
+ * save it. If the data is an image, we may scale down it according
+ * to user preference.
+ *
+ * @param part The PDU part which contains data to be saved.
+ * @param uri The URI of the part.
+ * @param contentType The MIME type of the part.
+ * @param preOpenedFiles if not null, a map of preopened InputStreams for the parts.
+ * @throws MmsException Cannot find source data or error occurred
+ * while saving the data.
+ */
+ private void persistData(final PduPart part, final Uri uri,
+ final String contentType, final Map<Uri, InputStream> preOpenedFiles)
+ throws MmsException {
+ OutputStream os = null;
+ InputStream is = null;
+ DrmConvertSession drmConvertSession = null;
+ Uri dataUri = null;
+ String path = null;
+
+ try {
+ final byte[] data = part.getData();
+ final int charset = part.getCharset();
+ if (ContentType.TEXT_PLAIN.equals(contentType)
+ || ContentType.APP_SMIL.equals(contentType)
+ || ContentType.TEXT_HTML.equals(contentType)) {
+ // Some phone could send MMS with a text part having empty data
+ // Let's just skip those parts.
+ // EncodedStringValue() throws NPE if data is empty
+ if (data != null) {
+ final ContentValues cv = new ContentValues();
+ cv.put(Mms.Part.TEXT, new EncodedStringValue(charset, data).getString());
+ if (mContentResolver.update(uri, cv, null, null) != 1) {
+ throw new MmsException("unable to update " + uri.toString());
+ }
+ }
+ } else {
+ final boolean isDrm = DownloadDrmHelper.isDrmConvertNeeded(contentType);
+ if (isDrm) {
+ if (uri != null) {
+ try {
+ path = convertUriToPath(mContext, uri);
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "drm uri: " + uri + " path: " + path);
+ }
+ final File f = new File(path);
+ final long len = f.length();
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "drm path: " + path + " len: " + len);
+ }
+ if (len > 0) {
+ // we're not going to re-persist and re-encrypt an already
+ // converted drm file
+ return;
+ }
+ } catch (final Exception e) {
+ Log.e(TAG, "Can't get file info for: " + part.getDataUri(), e);
+ }
+ }
+ // We haven't converted the file yet, start the conversion
+ drmConvertSession = DrmConvertSession.open(mContext, contentType);
+ if (drmConvertSession == null) {
+ throw new MmsException("Mimetype " + contentType +
+ " can not be converted.");
+ }
+ }
+ // uri can look like:
+ // content://mms/part/98
+ os = mContentResolver.openOutputStream(uri);
+ if (os == null) {
+ throw new MmsException("Failed to create output stream on " + uri);
+ }
+ if (data == null) {
+ dataUri = part.getDataUri();
+ if ((dataUri == null) || (dataUri == uri)) {
+ Log.w(TAG, "Can't find data for this part.");
+ return;
+ }
+ // dataUri can look like:
+ // content://com.google.android.gallery3d.provider/picasa/item/5720646660183715
+ if (preOpenedFiles != null && preOpenedFiles.containsKey(dataUri)) {
+ is = preOpenedFiles.get(dataUri);
+ }
+ if (is == null) {
+ is = mContentResolver.openInputStream(dataUri);
+ }
+ if (is == null) {
+ throw new MmsException("Failed to create input stream on " + dataUri);
+ }
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "Saving data to: " + uri);
+ }
+
+ final byte[] buffer = new byte[8192];
+ for (int len = 0; (len = is.read(buffer)) != -1; ) {
+ if (!isDrm) {
+ os.write(buffer, 0, len);
+ } else {
+ final byte[] convertedData = drmConvertSession.convert(buffer, len);
+ if (convertedData != null) {
+ os.write(convertedData, 0, convertedData.length);
+ } else {
+ throw new MmsException("Error converting drm data.");
+ }
+ }
+ }
+ } else {
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "Saving data to: " + uri);
+ }
+ if (!isDrm) {
+ os.write(data);
+ } else {
+ dataUri = uri;
+ final byte[] convertedData = drmConvertSession.convert(data, data.length);
+ if (convertedData != null) {
+ os.write(convertedData, 0, convertedData.length);
+ } else {
+ throw new MmsException("Error converting drm data.");
+ }
+ }
+ }
+ }
+ } catch (final SQLiteException e) {
+ Log.e(TAG, "Failed with SQLiteException.", e);
+ throw new MmsException(e);
+ } catch (final FileNotFoundException e) {
+ Log.e(TAG, "Failed to open Input/Output stream.", e);
+ throw new MmsException(e);
+ } catch (final IOException e) {
+ Log.e(TAG, "Failed to read/write data.", e);
+ throw new MmsException(e);
+ } finally {
+ if (os != null) {
+ try {
+ os.close();
+ } catch (final IOException e) {
+ Log.e(TAG, "IOException while closing: " + os, e);
+ } // Ignore
+ }
+ if (is != null) {
+ try {
+ is.close();
+ } catch (final IOException e) {
+ Log.e(TAG, "IOException while closing: " + is, e);
+ } // Ignore
+ }
+ if (drmConvertSession != null) {
+ drmConvertSession.close(path);
+
+ // Reset the permissions on the encrypted part file so everyone has only read
+ // permission.
+ final File f = new File(path);
+ final ContentValues values = new ContentValues(0);
+ SqliteWrapper.update(mContext, mContentResolver,
+ Uri.parse("content://mms/resetFilePerm/" + f.getName()),
+ values, null, null);
+ }
+ }
+ }
+
+ /**
+ * This method expects uri in the following format
+ * content://media/<table_name>/<row_index> (or)
+ * file://sdcard/test.mp4
+ * http://test.com/test.mp4
+ *
+ * Here <table_name> shall be "video" or "audio" or "images"
+ * <row_index> the index of the content in given table
+ */
+ public static String convertUriToPath(final Context context, final Uri uri) {
+ String path = null;
+ if (null != uri) {
+ final String scheme = uri.getScheme();
+ if (null == scheme || scheme.equals("") ||
+ scheme.equals(ContentResolver.SCHEME_FILE)) {
+ path = uri.getPath();
+
+ } else if (scheme.equals("http")) {
+ path = uri.toString();
+
+ } else if (scheme.equals(ContentResolver.SCHEME_CONTENT)) {
+ final String[] projection = new String[] {MediaStore.MediaColumns.DATA};
+ Cursor cursor = null;
+ try {
+ cursor = context.getContentResolver().query(uri, projection, null,
+ null, null);
+ if (null == cursor || 0 == cursor.getCount() || !cursor.moveToFirst()) {
+ throw new IllegalArgumentException("Given Uri could not be found" +
+ " in media store");
+ }
+ final int pathIndex =
+ cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA);
+ path = cursor.getString(pathIndex);
+ } catch (final SQLiteException e) {
+ throw new IllegalArgumentException("Given Uri is not formatted in a way " +
+ "so that it can be found in media store.");
+ } finally {
+ if (null != cursor) {
+ cursor.close();
+ }
+ }
+ } else {
+ throw new IllegalArgumentException("Given Uri scheme is not supported");
+ }
+ }
+ return path;
+ }
+
+ private void updateAddress(
+ final long msgId, final int type, final EncodedStringValue[] array) {
+ // Delete old address information and then insert new ones.
+ SqliteWrapper.delete(mContext, mContentResolver,
+ Uri.parse("content://mms/" + msgId + "/addr"),
+ Addr.TYPE + "=" + type, null);
+
+ persistAddress(msgId, type, array);
+ }
+
+ /**
+ * Update headers of a SendReq.
+ *
+ * @param uri The PDU which need to be updated.
+ * @param pdu New headers.
+ * @throws MmsException Bad URI or updating failed.
+ */
+ public void updateHeaders(final Uri uri, final SendReq sendReq) {
+ synchronized (PDU_CACHE_INSTANCE) {
+ // If the cache item is getting updated, wait until it's done updating before
+ // purging it.
+ if (PDU_CACHE_INSTANCE.isUpdating(uri)) {
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "updateHeaders: " + uri + " blocked by isUpdating()");
+ }
+ try {
+ PDU_CACHE_INSTANCE.wait();
+ } catch (final InterruptedException e) {
+ Log.e(TAG, "updateHeaders: ", e);
+ }
+ }
+ }
+ PDU_CACHE_INSTANCE.purge(uri);
+
+ final ContentValues values = new ContentValues(10);
+ final byte[] contentType = sendReq.getContentType();
+ if (contentType != null) {
+ values.put(Mms.CONTENT_TYPE, toIsoString(contentType));
+ }
+
+ final long date = sendReq.getDate();
+ if (date != -1) {
+ values.put(Mms.DATE, date);
+ }
+
+ final int deliveryReport = sendReq.getDeliveryReport();
+ if (deliveryReport != 0) {
+ values.put(Mms.DELIVERY_REPORT, deliveryReport);
+ }
+
+ final long expiry = sendReq.getExpiry();
+ if (expiry != -1) {
+ values.put(Mms.EXPIRY, expiry);
+ }
+
+ final byte[] msgClass = sendReq.getMessageClass();
+ if (msgClass != null) {
+ values.put(Mms.MESSAGE_CLASS, toIsoString(msgClass));
+ }
+
+ final int priority = sendReq.getPriority();
+ if (priority != 0) {
+ values.put(Mms.PRIORITY, priority);
+ }
+
+ final int readReport = sendReq.getReadReport();
+ if (readReport != 0) {
+ values.put(Mms.READ_REPORT, readReport);
+ }
+
+ final byte[] transId = sendReq.getTransactionId();
+ if (transId != null) {
+ values.put(Mms.TRANSACTION_ID, toIsoString(transId));
+ }
+
+ final EncodedStringValue subject = sendReq.getSubject();
+ if (subject != null) {
+ values.put(Mms.SUBJECT, toIsoString(subject.getTextString()));
+ values.put(Mms.SUBJECT_CHARSET, subject.getCharacterSet());
+ } else {
+ values.put(Mms.SUBJECT, "");
+ }
+
+ final long messageSize = sendReq.getMessageSize();
+ if (messageSize > 0) {
+ values.put(Mms.MESSAGE_SIZE, messageSize);
+ }
+
+ final PduHeaders headers = sendReq.getPduHeaders();
+ final HashSet<String> recipients = new HashSet<String>();
+ for (final int addrType : ADDRESS_FIELDS) {
+ EncodedStringValue[] array = null;
+ if (addrType == PduHeaders.FROM) {
+ final EncodedStringValue v = headers.getEncodedStringValue(addrType);
+ if (v != null) {
+ array = new EncodedStringValue[1];
+ array[0] = v;
+ }
+ } else {
+ array = headers.getEncodedStringValues(addrType);
+ }
+
+ if (array != null) {
+ final long msgId = ContentUris.parseId(uri);
+ updateAddress(msgId, addrType, array);
+ if (addrType == PduHeaders.TO) {
+ for (final EncodedStringValue v : array) {
+ if (v != null) {
+ recipients.add(v.getString());
+ }
+ }
+ }
+ }
+ }
+ if (!recipients.isEmpty()) {
+ final long threadId = MmsSmsUtils.Threads.getOrCreateThreadId(mContext, recipients);
+ values.put(Mms.THREAD_ID, threadId);
+ }
+
+ SqliteWrapper.update(mContext, mContentResolver, uri, values, null, null);
+ }
+
+
+ private void updatePart(final Uri uri, final PduPart part,
+ final Map<Uri, InputStream> preOpenedFiles)
+ throws MmsException {
+ final ContentValues values = new ContentValues(7);
+
+ final int charset = part.getCharset();
+ if (charset != 0) {
+ values.put(Part.CHARSET, charset);
+ }
+
+ String contentType = null;
+ if (part.getContentType() != null) {
+ contentType = toIsoString(part.getContentType());
+ values.put(Part.CONTENT_TYPE, contentType);
+ } else {
+ throw new MmsException("MIME type of the part must be set.");
+ }
+
+ getValues(part, values);
+
+ SqliteWrapper.update(mContext, mContentResolver, uri, values, null, null);
+
+ // Only update the data when:
+ // 1. New binary data supplied or
+ // 2. The Uri of the part is different from the current one.
+ if ((part.getData() != null)
+ || (uri != part.getDataUri())) {
+ persistData(part, uri, contentType, preOpenedFiles);
+ }
+ }
+
+ /**
+ * Update all parts of a PDU.
+ *
+ * @param uri The PDU which need to be updated.
+ * @param body New message body of the PDU.
+ * @param preOpenedFiles if not null, a map of preopened InputStreams for the parts.
+ * @throws MmsException Bad URI or updating failed.
+ */
+ public void updateParts(final Uri uri, final PduBody body,
+ final Map<Uri, InputStream> preOpenedFiles)
+ throws MmsException {
+ try {
+ PduCacheEntry cacheEntry;
+ synchronized (PDU_CACHE_INSTANCE) {
+ if (PDU_CACHE_INSTANCE.isUpdating(uri)) {
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "updateParts: " + uri + " blocked by isUpdating()");
+ }
+ try {
+ PDU_CACHE_INSTANCE.wait();
+ } catch (final InterruptedException e) {
+ Log.e(TAG, "updateParts: ", e);
+ }
+ cacheEntry = PDU_CACHE_INSTANCE.get(uri);
+ if (cacheEntry != null) {
+ ((MultimediaMessagePdu) cacheEntry.getPdu()).setBody(body);
+ }
+ }
+ // Tell the cache to indicate to other callers that this item
+ // is currently being updated.
+ PDU_CACHE_INSTANCE.setUpdating(uri, true);
+ }
+
+ final ArrayList<PduPart> toBeCreated = new ArrayList<PduPart>();
+ final ArrayMap<Uri, PduPart> toBeUpdated = new ArrayMap<Uri, PduPart>();
+
+ final int partsNum = body.getPartsNum();
+ final StringBuilder filter = new StringBuilder().append('(');
+ for (int i = 0; i < partsNum; i++) {
+ final PduPart part = body.getPart(i);
+ final Uri partUri = part.getDataUri();
+ if ((partUri == null) || !partUri.getAuthority().startsWith("mms")) {
+ toBeCreated.add(part);
+ } else {
+ toBeUpdated.put(partUri, part);
+
+ // Don't use 'i > 0' to determine whether we should append
+ // 'AND' since 'i = 0' may be skipped in another branch.
+ if (filter.length() > 1) {
+ filter.append(" AND ");
+ }
+
+ filter.append(Part._ID);
+ filter.append("!=");
+ DatabaseUtils.appendEscapedSQLString(filter, partUri.getLastPathSegment());
+ }
+ }
+ filter.append(')');
+
+ final long msgId = ContentUris.parseId(uri);
+
+ // Remove the parts which doesn't exist anymore.
+ SqliteWrapper.delete(mContext, mContentResolver,
+ Uri.parse(Mms.CONTENT_URI + "/" + msgId + "/part"),
+ filter.length() > 2 ? filter.toString() : null, null);
+
+ // Create new parts which didn't exist before.
+ for (final PduPart part : toBeCreated) {
+ persistPart(part, msgId, preOpenedFiles);
+ }
+
+ // Update the modified parts.
+ for (final Map.Entry<Uri, PduPart> e : toBeUpdated.entrySet()) {
+ updatePart(e.getKey(), e.getValue(), preOpenedFiles);
+ }
+ } finally {
+ synchronized (PDU_CACHE_INSTANCE) {
+ PDU_CACHE_INSTANCE.setUpdating(uri, false);
+ PDU_CACHE_INSTANCE.notifyAll();
+ }
+ }
+ }
+
+ /**
+ * Persist a PDU object to specific location in the storage.
+ *
+ * @param pdu The PDU object to be stored.
+ * @param uri Where to store the given PDU object.
+ * @param subId Subscription id associated with this message.
+ * @param subPhoneNumber TODO
+ * @param preOpenedFiles if not null, a map of preopened InputStreams for the parts.
+ * @return A Uri which can be used to access the stored PDU.
+ */
+ public Uri persist(final GenericPdu pdu, final Uri uri, final int subId,
+ final String subPhoneNumber, final Map<Uri, InputStream> preOpenedFiles)
+ throws MmsException {
+ if (uri == null) {
+ throw new MmsException("Uri may not be null.");
+ }
+ long msgId = -1;
+ try {
+ msgId = ContentUris.parseId(uri);
+ } catch (final NumberFormatException e) {
+ // the uri ends with "inbox" or something else like that
+ }
+ final boolean existingUri = msgId != -1;
+
+ if (!existingUri && MESSAGE_BOX_MAP.get(uri) == null) {
+ throw new MmsException(
+ "Bad destination, must be one of "
+ + "content://mms/inbox, content://mms/sent, "
+ + "content://mms/drafts, content://mms/outbox, "
+ + "content://mms/temp."
+ );
+ }
+ synchronized (PDU_CACHE_INSTANCE) {
+ // If the cache item is getting updated, wait until it's done updating before
+ // purging it.
+ if (PDU_CACHE_INSTANCE.isUpdating(uri)) {
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "persist: " + uri + " blocked by isUpdating()");
+ }
+ try {
+ PDU_CACHE_INSTANCE.wait();
+ } catch (final InterruptedException e) {
+ Log.e(TAG, "persist1: ", e);
+ }
+ }
+ }
+ PDU_CACHE_INSTANCE.purge(uri);
+
+ final PduHeaders header = pdu.getPduHeaders();
+ PduBody body = null;
+ ContentValues values = new ContentValues();
+
+ // Mark new messages as seen in the telephony database so that we don't have to
+ // do a global "set all messages as seen" since that occasionally seems to be
+ // problematic (i.e. very slow). See bug 18189471.
+ values.put(Mms.SEEN, 1);
+
+ //Set<Entry<Integer, String>> set;
+
+ for (int i = ENCODED_STRING_COLUMN_NAME_MAP.size(); --i >= 0; ) {
+ final int field = ENCODED_STRING_COLUMN_NAME_MAP.keyAt(i);
+ final EncodedStringValue encodedString = header.getEncodedStringValue(field);
+ if (encodedString != null) {
+ final String charsetColumn = CHARSET_COLUMN_NAME_MAP.get(field);
+ values.put(ENCODED_STRING_COLUMN_NAME_MAP.valueAt(i),
+ toIsoString(encodedString.getTextString()));
+ values.put(charsetColumn, encodedString.getCharacterSet());
+ }
+ }
+
+ for (int i = TEXT_STRING_COLUMN_NAME_MAP.size(); --i >= 0; ) {
+ final byte[] text = header.getTextString(TEXT_STRING_COLUMN_NAME_MAP.keyAt(i));
+ if (text != null) {
+ values.put(TEXT_STRING_COLUMN_NAME_MAP.valueAt(i), toIsoString(text));
+ }
+ }
+
+ for (int i = OCTET_COLUMN_NAME_MAP.size(); --i >= 0; ) {
+ final int b = header.getOctet(OCTET_COLUMN_NAME_MAP.keyAt(i));
+ if (b != 0) {
+ values.put(OCTET_COLUMN_NAME_MAP.valueAt(i), b);
+ }
+ }
+
+ for (int i = LONG_COLUMN_NAME_MAP.size(); --i >= 0; ) {
+ final long l = header.getLongInteger(LONG_COLUMN_NAME_MAP.keyAt(i));
+ if (l != -1L) {
+ values.put(LONG_COLUMN_NAME_MAP.valueAt(i), l);
+ }
+ }
+
+ final SparseArray<EncodedStringValue[]> addressMap =
+ new SparseArray<EncodedStringValue[]>(ADDRESS_FIELDS.length);
+ // Save address information.
+ for (final int addrType : ADDRESS_FIELDS) {
+ EncodedStringValue[] array = null;
+ if (addrType == PduHeaders.FROM) {
+ final EncodedStringValue v = header.getEncodedStringValue(addrType);
+ if (v != null) {
+ array = new EncodedStringValue[1];
+ array[0] = v;
+ }
+ } else {
+ array = header.getEncodedStringValues(addrType);
+ }
+ addressMap.put(addrType, array);
+ }
+
+ final HashSet<String> recipients = new HashSet<String>();
+ final int msgType = pdu.getMessageType();
+ // Here we only allocate thread ID for M-Notification.ind,
+ // M-Retrieve.conf and M-Send.req.
+ // Some of other PDU types may be allocated a thread ID outside
+ // this scope.
+ if ((msgType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND)
+ || (msgType == PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF)
+ || (msgType == PduHeaders.MESSAGE_TYPE_SEND_REQ)) {
+ switch (msgType) {
+ case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND:
+ case PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF:
+ loadRecipients(PduHeaders.FROM, recipients, addressMap);
+
+ // For received messages (whether group MMS is enabled or not) we want to
+ // associate this message with the thread composed of all the recipients
+ // EXCLUDING our own number. This includes the person who sent the
+ // message (the FROM field above) in addition to the other people the message
+ // was addressed TO (or CC fields to address group messaging compatibility
+ // issues with devices that place numbers in this field). Typically our own
+ // number is in the TO/CC field so we have to remove it in loadRecipients.
+ checkAndLoadToCcRecipients(recipients, addressMap, subPhoneNumber);
+ break;
+ case PduHeaders.MESSAGE_TYPE_SEND_REQ:
+ loadRecipients(PduHeaders.TO, recipients, addressMap);
+ break;
+ }
+ long threadId = -1L;
+ if (!recipients.isEmpty()) {
+ // Given all the recipients associated with this message, find (or create) the
+ // correct thread.
+ threadId = MmsSmsUtils.Threads.getOrCreateThreadId(mContext, recipients);
+ } else {
+ LogUtil.w(TAG, "PduPersister.persist No recipients; persisting PDU to thread: "
+ + threadId);
+ }
+ values.put(Mms.THREAD_ID, threadId);
+ }
+
+ // Save parts first to avoid inconsistent message is loaded
+ // while saving the parts.
+ final long dummyId = System.currentTimeMillis(); // Dummy ID of the msg.
+
+ // Figure out if this PDU is a text-only message
+ boolean textOnly = true;
+
+ // Get body if the PDU is a RetrieveConf or SendReq.
+ if (pdu instanceof MultimediaMessagePdu) {
+ body = ((MultimediaMessagePdu) pdu).getBody();
+ // Start saving parts if necessary.
+ if (body != null) {
+ final int partsNum = body.getPartsNum();
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "PduPersister.persist partsNum: " + partsNum);
+ }
+ if (partsNum > 2) {
+ // For a text-only message there will be two parts: 1-the SMIL, 2-the text.
+ // Down a few lines below we're checking to make sure we've only got SMIL or
+ // text. We also have to check then we don't have more than two parts.
+ // Otherwise, a slideshow with two text slides would be marked as textOnly.
+ textOnly = false;
+ }
+ for (int i = 0; i < partsNum; i++) {
+ final PduPart part = body.getPart(i);
+ persistPart(part, dummyId, preOpenedFiles);
+
+ // If we've got anything besides text/plain or SMIL part, then we've got
+ // an mms message with some other type of attachment.
+ final String contentType = getPartContentType(part);
+ if (LOCAL_LOGV) {
+ LogUtil.v(TAG, "PduPersister.persist part: " + i + " contentType: " +
+ contentType);
+ }
+ if (contentType != null && !ContentType.APP_SMIL.equals(contentType)
+ && !ContentType.TEXT_PLAIN.equals(contentType)) {
+ textOnly = false;
+ }
+ }
+ }
+ }
+ // Record whether this mms message is a simple plain text or not. This is a hint for the
+ // UI.
+ if (OsUtil.isAtLeastJB_MR1()) {
+ values.put(Mms.TEXT_ONLY, textOnly ? 1 : 0);
+ }
+
+ if (OsUtil.isAtLeastL_MR1()) {
+ values.put(Mms.SUBSCRIPTION_ID, subId);
+ } else {
+ Assert.equals(ParticipantData.DEFAULT_SELF_SUB_ID, subId);
+ }
+
+ Uri res = null;
+ if (existingUri) {
+ res = uri;
+ SqliteWrapper.update(mContext, mContentResolver, res, values, null, null);
+ } else {
+ res = SqliteWrapper.insert(mContext, mContentResolver, uri, values);
+ if (res == null) {
+ throw new MmsException("persist() failed: return null.");
+ }
+ // Get the real ID of the PDU and update all parts which were
+ // saved with the dummy ID.
+ msgId = ContentUris.parseId(res);
+ }
+
+ values = new ContentValues(1);
+ values.put(Part.MSG_ID, msgId);
+ SqliteWrapper.update(mContext, mContentResolver,
+ Uri.parse("content://mms/" + dummyId + "/part"),
+ values, null, null);
+ // We should return the longest URI of the persisted PDU, for
+ // example, if input URI is "content://mms/inbox" and the _ID of
+ // persisted PDU is '8', we should return "content://mms/inbox/8"
+ // instead of "content://mms/8".
+ // TODO: Should the MmsProvider be responsible for this???
+ if (!existingUri) {
+ res = Uri.parse(uri + "/" + msgId);
+ }
+
+ // Save address information.
+ for (final int addrType : ADDRESS_FIELDS) {
+ final EncodedStringValue[] array = addressMap.get(addrType);
+ if (array != null) {
+ persistAddress(msgId, addrType, array);
+ }
+ }
+
+ return res;
+ }
+
+ /**
+ * For a given address type, extract the recipients from the headers.
+ *
+ * @param addressType can be PduHeaders.FROM or PduHeaders.TO
+ * @param recipients a HashSet that is loaded with the recipients from the FROM or TO
+ * headers
+ * @param addressMap a HashMap of the addresses from the ADDRESS_FIELDS header
+ */
+ private void loadRecipients(final int addressType, final HashSet<String> recipients,
+ final SparseArray<EncodedStringValue[]> addressMap) {
+ final EncodedStringValue[] array = addressMap.get(addressType);
+ if (array == null) {
+ return;
+ }
+ for (final EncodedStringValue v : array) {
+ if (v != null) {
+ final String number = v.getString();
+ if (!recipients.contains(number)) {
+ // Only add numbers which aren't already included.
+ recipients.add(number);
+ }
+ }
+ }
+ }
+
+ /**
+ * For a given address type, extract the recipients from the headers.
+ *
+ * @param recipients a HashSet that is loaded with the recipients from the FROM or TO
+ * headers
+ * @param addressMap a HashMap of the addresses from the ADDRESS_FIELDS header
+ * @param selfNumber self phone number
+ */
+ private void checkAndLoadToCcRecipients(final HashSet<String> recipients,
+ final SparseArray<EncodedStringValue[]> addressMap, final String selfNumber) {
+ final EncodedStringValue[] arrayTo = addressMap.get(PduHeaders.TO);
+ final EncodedStringValue[] arrayCc = addressMap.get(PduHeaders.CC);
+ final ArrayList<String> numbers = new ArrayList<String>();
+ if (arrayTo != null) {
+ for (final EncodedStringValue v : arrayTo) {
+ if (v != null) {
+ numbers.add(v.getString());
+ }
+ }
+ }
+ if (arrayCc != null) {
+ for (final EncodedStringValue v : arrayCc) {
+ if (v != null) {
+ numbers.add(v.getString());
+ }
+ }
+ }
+ for (final String number : numbers) {
+ // Only add numbers which aren't my own number.
+ if (TextUtils.isEmpty(selfNumber) || !PhoneNumberUtils.compare(number, selfNumber)) {
+ if (!recipients.contains(number)) {
+ // Only add numbers which aren't already included.
+ recipients.add(number);
+ }
+ }
+ }
+ }
+
+ /**
+ * Move a PDU object from one location to another.
+ *
+ * @param from Specify the PDU object to be moved.
+ * @param to The destination location, should be one of the following:
+ * "content://mms/inbox", "content://mms/sent",
+ * "content://mms/drafts", "content://mms/outbox",
+ * "content://mms/trash".
+ * @return New Uri of the moved PDU.
+ * @throws MmsException Error occurred while moving the message.
+ */
+ public Uri move(final Uri from, final Uri to) throws MmsException {
+ // Check whether the 'msgId' has been assigned a valid value.
+ final long msgId = ContentUris.parseId(from);
+ if (msgId == -1L) {
+ throw new MmsException("Error! ID of the message: -1.");
+ }
+
+ // Get corresponding int value of destination box.
+ final Integer msgBox = MESSAGE_BOX_MAP.get(to);
+ if (msgBox == null) {
+ throw new MmsException(
+ "Bad destination, must be one of "
+ + "content://mms/inbox, content://mms/sent, "
+ + "content://mms/drafts, content://mms/outbox, "
+ + "content://mms/temp."
+ );
+ }
+
+ final ContentValues values = new ContentValues(1);
+ values.put(Mms.MESSAGE_BOX, msgBox);
+ SqliteWrapper.update(mContext, mContentResolver, from, values, null, null);
+ return ContentUris.withAppendedId(to, msgId);
+ }
+
+ /**
+ * Wrap a byte[] into a String.
+ */
+ public static String toIsoString(final byte[] bytes) {
+ try {
+ return new String(bytes, CharacterSets.MIMENAME_ISO_8859_1);
+ } catch (final UnsupportedEncodingException e) {
+ // Impossible to reach here!
+ Log.e(TAG, "ISO_8859_1 must be supported!", e);
+ return "";
+ }
+ }
+
+ /**
+ * Unpack a given String into a byte[].
+ */
+ public static byte[] getBytes(final String data) {
+ try {
+ return data.getBytes(CharacterSets.MIMENAME_ISO_8859_1);
+ } catch (final UnsupportedEncodingException e) {
+ // Impossible to reach here!
+ Log.e(TAG, "ISO_8859_1 must be supported!", e);
+ return new byte[0];
+ }
+ }
+
+ /**
+ * Remove all objects in the temporary path.
+ */
+ public void release() {
+ final Uri uri = Uri.parse(TEMPORARY_DRM_OBJECT_URI);
+ SqliteWrapper.delete(mContext, mContentResolver, uri, null, null);
+ }
+
+ /**
+ * Find all messages to be sent or downloaded before certain time.
+ */
+ public Cursor getPendingMessages(final long dueTime) {
+ final Uri.Builder uriBuilder = PendingMessages.CONTENT_URI.buildUpon();
+ uriBuilder.appendQueryParameter("protocol", "mms");
+
+ final String selection = PendingMessages.ERROR_TYPE + " < ?"
+ + " AND " + PendingMessages.DUE_TIME + " <= ?";
+
+ final String[] selectionArgs = new String[] {
+ String.valueOf(MmsSms.ERR_TYPE_GENERIC_PERMANENT),
+ String.valueOf(dueTime)
+ };
+
+ return SqliteWrapper.query(mContext, mContentResolver,
+ uriBuilder.build(), null, selection, selectionArgs,
+ PendingMessages.DUE_TIME);
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/QuotedPrintable.java b/src/com/android/messaging/mmslib/pdu/QuotedPrintable.java
new file mode 100644
index 0000000..1ce9dc1
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/QuotedPrintable.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import java.io.ByteArrayOutputStream;
+
+public class QuotedPrintable {
+ private static byte ESCAPE_CHAR = '=';
+
+ /**
+ * Decodes an array quoted-printable characters into an array of original bytes.
+ * Escaped characters are converted back to their original representation.
+ *
+ * <p>
+ * This function implements a subset of
+ * quoted-printable encoding specification (rule #1 and rule #2)
+ * as defined in RFC 1521.
+ * </p>
+ *
+ * @param bytes array of quoted-printable characters
+ * @return array of original bytes,
+ * null if quoted-printable decoding is unsuccessful.
+ */
+ public static final byte[] decodeQuotedPrintable(byte[] bytes) {
+ if (bytes == null) {
+ return null;
+ }
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ for (int i = 0; i < bytes.length; i++) {
+ int b = bytes[i];
+ if (b == ESCAPE_CHAR) {
+ try {
+ if ('\r' == (char) bytes[i + 1] &&
+ '\n' == (char) bytes[i + 2]) {
+ i += 2;
+ continue;
+ }
+ int u = Character.digit((char) bytes[++i], 16);
+ int l = Character.digit((char) bytes[++i], 16);
+ if (u == -1 || l == -1) {
+ return null;
+ }
+ buffer.write((char) ((u << 4) + l));
+ } catch (ArrayIndexOutOfBoundsException e) {
+ return null;
+ }
+ } else {
+ buffer.write(b);
+ }
+ }
+ return buffer.toByteArray();
+ }
+}
diff --git a/src/com/android/messaging/mmslib/pdu/ReadOrigInd.java b/src/com/android/messaging/mmslib/pdu/ReadOrigInd.java
new file mode 100644
index 0000000..198e4c3
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/ReadOrigInd.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+public class ReadOrigInd extends GenericPdu {
+ /**
+ * Empty constructor.
+ * Since the Pdu corresponding to this class is constructed
+ * by the Proxy-Relay server, this class is only instantiated
+ * by the Pdu Parser.
+ *
+ * @throws InvalidHeaderValueException if error occurs.
+ */
+ public ReadOrigInd() throws InvalidHeaderValueException {
+ super();
+ setMessageType(PduHeaders.MESSAGE_TYPE_READ_ORIG_IND);
+ }
+
+ /**
+ * Constructor with given headers.
+ *
+ * @param headers Headers for this PDU.
+ */
+ ReadOrigInd(PduHeaders headers) {
+ super(headers);
+ }
+
+ /**
+ * Get Date value.
+ *
+ * @return the value
+ */
+ public long getDate() {
+ return mPduHeaders.getLongInteger(PduHeaders.DATE);
+ }
+
+ /**
+ * Set Date value.
+ *
+ * @param value the value
+ */
+ public void setDate(long value) {
+ mPduHeaders.setLongInteger(value, PduHeaders.DATE);
+ }
+
+ /**
+ * Get From value.
+ * From-value = Value-length
+ * (Address-present-token Encoded-string-value | Insert-address-token)
+ *
+ * @return the value
+ */
+ public EncodedStringValue getFrom() {
+ return mPduHeaders.getEncodedStringValue(PduHeaders.FROM);
+ }
+
+ /**
+ * Set From value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setFrom(EncodedStringValue value) {
+ mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM);
+ }
+
+ /**
+ * Get Message-ID value.
+ *
+ * @return the value
+ */
+ public byte[] getMessageId() {
+ return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID);
+ }
+
+ /**
+ * Set Message-ID value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setMessageId(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID);
+ }
+
+ /**
+ * Get X-MMS-Read-status value.
+ *
+ * @return the value
+ */
+ public int getReadStatus() {
+ return mPduHeaders.getOctet(PduHeaders.READ_STATUS);
+ }
+
+ /**
+ * Set X-MMS-Read-status value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ public void setReadStatus(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.READ_STATUS);
+ }
+
+ /**
+ * Get To value.
+ *
+ * @return the value
+ */
+ public EncodedStringValue[] getTo() {
+ return mPduHeaders.getEncodedStringValues(PduHeaders.TO);
+ }
+
+ /**
+ * Set To value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setTo(EncodedStringValue[] value) {
+ mPduHeaders.setEncodedStringValues(value, PduHeaders.TO);
+ }
+
+ /*
+ * Optional, not supported header fields:
+ *
+ * public byte[] getApplicId() {return null;}
+ * public void setApplicId(byte[] value) {}
+ *
+ * public byte[] getAuxApplicId() {return null;}
+ * public void getAuxApplicId(byte[] value) {}
+ *
+ * public byte[] getReplyApplicId() {return 0x00;}
+ * public void setReplyApplicId(byte[] value) {}
+ */
+}
diff --git a/src/com/android/messaging/mmslib/pdu/ReadRecInd.java b/src/com/android/messaging/mmslib/pdu/ReadRecInd.java
new file mode 100644
index 0000000..c1a9ae4
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/ReadRecInd.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+public class ReadRecInd extends GenericPdu {
+ /**
+ * Constructor, used when composing a M-ReadRec.ind pdu.
+ *
+ * @param from the from value
+ * @param messageId the message ID value
+ * @param mmsVersion current viersion of mms
+ * @param readStatus the read status value
+ * @param to the to value
+ * @throws InvalidHeaderValueException if parameters are invalid.
+ * @throws NullPointerException if messageId or to is null.
+ */
+ public ReadRecInd(EncodedStringValue from,
+ byte[] messageId,
+ int mmsVersion,
+ int readStatus,
+ EncodedStringValue[] to) throws InvalidHeaderValueException {
+ super();
+ setMessageType(PduHeaders.MESSAGE_TYPE_READ_REC_IND);
+ setFrom(from);
+ setMessageId(messageId);
+ setMmsVersion(mmsVersion);
+ setTo(to);
+ setReadStatus(readStatus);
+ }
+
+ /**
+ * Constructor with given headers.
+ *
+ * @param headers Headers for this PDU.
+ */
+ ReadRecInd(PduHeaders headers) {
+ super(headers);
+ }
+
+ /**
+ * Get Date value.
+ *
+ * @return the value
+ */
+ public long getDate() {
+ return mPduHeaders.getLongInteger(PduHeaders.DATE);
+ }
+
+ /**
+ * Set Date value.
+ *
+ * @param value the value
+ */
+ public void setDate(long value) {
+ mPduHeaders.setLongInteger(value, PduHeaders.DATE);
+ }
+
+ /**
+ * Get Message-ID value.
+ *
+ * @return the value
+ */
+ public byte[] getMessageId() {
+ return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID);
+ }
+
+ /**
+ * Set Message-ID value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setMessageId(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID);
+ }
+
+ /**
+ * Get To value.
+ *
+ * @return the value
+ */
+ public EncodedStringValue[] getTo() {
+ return mPduHeaders.getEncodedStringValues(PduHeaders.TO);
+ }
+
+ /**
+ * Set To value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setTo(EncodedStringValue[] value) {
+ mPduHeaders.setEncodedStringValues(value, PduHeaders.TO);
+ }
+
+ /**
+ * Get X-MMS-Read-status value.
+ *
+ * @return the value
+ */
+ public int getReadStatus() {
+ return mPduHeaders.getOctet(PduHeaders.READ_STATUS);
+ }
+
+ /**
+ * Set X-MMS-Read-status value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ public void setReadStatus(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.READ_STATUS);
+ }
+
+ /*
+ * Optional, not supported header fields:
+ *
+ * public byte[] getApplicId() {return null;}
+ * public void setApplicId(byte[] value) {}
+ *
+ * public byte[] getAuxApplicId() {return null;}
+ * public void getAuxApplicId(byte[] value) {}
+ *
+ * public byte[] getReplyApplicId() {return 0x00;}
+ * public void setReplyApplicId(byte[] value) {}
+ */
+}
diff --git a/src/com/android/messaging/mmslib/pdu/RetrieveConf.java b/src/com/android/messaging/mmslib/pdu/RetrieveConf.java
new file mode 100644
index 0000000..9e0faed
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/RetrieveConf.java
@@ -0,0 +1,305 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+/**
+ * M-Retrive.conf Pdu.
+ */
+public class RetrieveConf extends MultimediaMessagePdu {
+ /**
+ * Empty constructor.
+ * Since the Pdu corresponding to this class is constructed
+ * by the Proxy-Relay server, this class is only instantiated
+ * by the Pdu Parser.
+ *
+ * @throws InvalidHeaderValueException if error occurs.
+ */
+ public RetrieveConf() throws InvalidHeaderValueException {
+ super();
+ setMessageType(PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF);
+ }
+
+ /**
+ * Constructor with given headers.
+ *
+ * @param headers Headers for this PDU.
+ */
+ RetrieveConf(PduHeaders headers) {
+ super(headers);
+ }
+
+ /**
+ * Constructor with given headers and body
+ *
+ * @param headers Headers for this PDU.
+ * @param body Body of this PDu.
+ */
+ RetrieveConf(PduHeaders headers, PduBody body) {
+ super(headers, body);
+ }
+
+ /**
+ * Get CC value.
+ *
+ * @return the value
+ */
+ public EncodedStringValue[] getCc() {
+ return mPduHeaders.getEncodedStringValues(PduHeaders.CC);
+ }
+
+ /**
+ * Add a "CC" value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void addCc(EncodedStringValue value) {
+ mPduHeaders.appendEncodedStringValue(value, PduHeaders.CC);
+ }
+
+ /**
+ * Get Content-type value.
+ *
+ * @return the value
+ */
+ public byte[] getContentType() {
+ return mPduHeaders.getTextString(PduHeaders.CONTENT_TYPE);
+ }
+
+ /**
+ * Set Content-type value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setContentType(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.CONTENT_TYPE);
+ }
+
+ /**
+ * Get X-Mms-Delivery-Report value.
+ *
+ * @return the value
+ */
+ public int getDeliveryReport() {
+ return mPduHeaders.getOctet(PduHeaders.DELIVERY_REPORT);
+ }
+
+ /**
+ * Set X-Mms-Delivery-Report value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ public void setDeliveryReport(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.DELIVERY_REPORT);
+ }
+
+ /**
+ * Get From value.
+ * From-value = Value-length
+ * (Address-present-token Encoded-string-value | Insert-address-token)
+ *
+ * @return the value
+ */
+ public EncodedStringValue getFrom() {
+ return mPduHeaders.getEncodedStringValue(PduHeaders.FROM);
+ }
+
+ /**
+ * Set From value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setFrom(EncodedStringValue value) {
+ mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM);
+ }
+
+ /**
+ * Get X-Mms-Message-Class value.
+ * Message-class-value = Class-identifier | Token-text
+ * Class-identifier = Personal | Advertisement | Informational | Auto
+ *
+ * @return the value
+ */
+ public byte[] getMessageClass() {
+ return mPduHeaders.getTextString(PduHeaders.MESSAGE_CLASS);
+ }
+
+ /**
+ * Set X-Mms-Message-Class value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setMessageClass(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.MESSAGE_CLASS);
+ }
+
+ /**
+ * Get Message-ID value.
+ *
+ * @return the value
+ */
+ public byte[] getMessageId() {
+ return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID);
+ }
+
+ /**
+ * Set Message-ID value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setMessageId(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID);
+ }
+
+ /**
+ * Get X-Mms-Read-Report value.
+ *
+ * @return the value
+ */
+ public int getReadReport() {
+ return mPduHeaders.getOctet(PduHeaders.READ_REPORT);
+ }
+
+ /**
+ * Set X-Mms-Read-Report value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ public void setReadReport(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.READ_REPORT);
+ }
+
+ /**
+ * Get X-Mms-Retrieve-Status value.
+ *
+ * @return the value
+ */
+ public int getRetrieveStatus() {
+ /*
+ * If the header is not there, assuming it is OK status.
+ * Some carriers may choose to not send this header.
+ */
+ return mPduHeaders.hasHeader(PduHeaders.RETRIEVE_STATUS) ?
+ mPduHeaders.getOctet(PduHeaders.RETRIEVE_STATUS) : PduHeaders.RETRIEVE_STATUS_OK;
+ }
+
+ /**
+ * Set X-Mms-Retrieve-Status value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ public void setRetrieveStatus(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.RETRIEVE_STATUS);
+ }
+
+ /**
+ * Get X-Mms-Retrieve-Text value.
+ *
+ * @return the value
+ */
+ public EncodedStringValue getRetrieveText() {
+ return mPduHeaders.getEncodedStringValue(PduHeaders.RETRIEVE_TEXT);
+ }
+
+ /**
+ * Set X-Mms-Retrieve-Text value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setRetrieveText(EncodedStringValue value) {
+ mPduHeaders.setEncodedStringValue(value, PduHeaders.RETRIEVE_TEXT);
+ }
+
+ /**
+ * Get X-Mms-Transaction-Id.
+ *
+ * @return the value
+ */
+ public byte[] getTransactionId() {
+ return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID);
+ }
+
+ /**
+ * Set X-Mms-Transaction-Id.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setTransactionId(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID);
+ }
+
+ /*
+ * Optional, not supported header fields:
+ *
+ * public byte[] getApplicId() {return null;}
+ * public void setApplicId(byte[] value) {}
+ *
+ * public byte[] getAuxApplicId() {return null;}
+ * public void getAuxApplicId(byte[] value) {}
+ *
+ * public byte getContentClass() {return 0x00;}
+ * public void setApplicId(byte value) {}
+ *
+ * public byte getDrmContent() {return 0x00;}
+ * public void setDrmContent(byte value) {}
+ *
+ * public byte getDistributionIndicator() {return 0x00;}
+ * public void setDistributionIndicator(byte value) {}
+ *
+ * public PreviouslySentByValue getPreviouslySentBy() {return null;}
+ * public void setPreviouslySentBy(PreviouslySentByValue value) {}
+ *
+ * public PreviouslySentDateValue getPreviouslySentDate() {}
+ * public void setPreviouslySentDate(PreviouslySentDateValue value) {}
+ *
+ * public MmFlagsValue getMmFlags() {return null;}
+ * public void setMmFlags(MmFlagsValue value) {}
+ *
+ * public MmStateValue getMmState() {return null;}
+ * public void getMmState(MmStateValue value) {}
+ *
+ * public byte[] getReplaceId() {return 0x00;}
+ * public void setReplaceId(byte[] value) {}
+ *
+ * public byte[] getReplyApplicId() {return 0x00;}
+ * public void setReplyApplicId(byte[] value) {}
+ *
+ * public byte getReplyCharging() {return 0x00;}
+ * public void setReplyCharging(byte value) {}
+ *
+ * public byte getReplyChargingDeadline() {return 0x00;}
+ * public void setReplyChargingDeadline(byte value) {}
+ *
+ * public byte[] getReplyChargingId() {return 0x00;}
+ * public void setReplyChargingId(byte[] value) {}
+ *
+ * public long getReplyChargingSize() {return 0;}
+ * public void setReplyChargingSize(long value) {}
+ */
+}
diff --git a/src/com/android/messaging/mmslib/pdu/SendConf.java b/src/com/android/messaging/mmslib/pdu/SendConf.java
new file mode 100644
index 0000000..cf1399e
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/SendConf.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2007 Esmertec AG.
+ * Copyright (C) 2007 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.messaging.mmslib.pdu;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+public class SendConf extends GenericPdu {
+ /**
+ * Empty constructor.
+ * Since the Pdu corresponding to this class is constructed
+ * by the Proxy-Relay server, this class is only instantiated
+ * by the Pdu Parser.
+ *
+ * @throws InvalidHeaderValueException if error occurs.
+ */
+ public SendConf() throws InvalidHeaderValueException {
+ super();
+ setMessageType(PduHeaders.MESSAGE_TYPE_SEND_CONF);
+ }
+
+ /**
+ * Constructor with given headers.
+ *
+ * @param headers Headers for this PDU.
+ */
+ SendConf(PduHeaders headers) {
+ super(headers);
+ }
+
+ /**
+ * Get Message-ID value.
+ *
+ * @return the value
+ */
+ public byte[] getMessageId() {
+ return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID);
+ }
+
+ /**
+ * Set Message-ID value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setMessageId(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID);
+ }
+
+ /**
+ * Get X-Mms-Response-Status.
+ *
+ * @return the value
+ */
+ public int getResponseStatus() {
+ return mPduHeaders.getOctet(PduHeaders.RESPONSE_STATUS);
+ }
+
+ /**
+ * Set X-Mms-Response-Status.
+ *
+ * @param value the values
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ public void setResponseStatus(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.RESPONSE_STATUS);
+ }
+
+ /**
+ * Get X-Mms-Transaction-Id field value.
+ *
+ * @return the X-Mms-Report-Allowed value
+ */
+ public byte[] getTransactionId() {
+ return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID);
+ }
+
+ /**
+ * Set X-Mms-Transaction-Id field value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setTransactionId(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID);
+ }
+
+ /*
+ * Optional, not supported header fields:
+ *
+ * public byte[] getContentLocation() {return null;}
+ * public void setContentLocation(byte[] value) {}
+ *
+ * public EncodedStringValue getResponseText() {return null;}
+ * public void setResponseText(EncodedStringValue value) {}
+ *
+ * public byte getStoreStatus() {return 0x00;}
+ * public void setStoreStatus(byte value) {}
+ *
+ * public byte[] getStoreStatusText() {return null;}
+ * public void setStoreStatusText(byte[] value) {}
+ */
+}
diff --git a/src/com/android/messaging/mmslib/pdu/SendReq.java b/src/com/android/messaging/mmslib/pdu/SendReq.java
new file mode 100644
index 0000000..d173c0b
--- /dev/null
+++ b/src/com/android/messaging/mmslib/pdu/SendReq.java
@@ -0,0 +1,346 @@
+/*
+ * Copyright (C) 2007-2008 Esmertec AG.
+ * Copyright (C) 2007-2008 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.messaging.mmslib.pdu;
+
+import android.util.Log;
+
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+
+public class SendReq extends MultimediaMessagePdu {
+ private static final String TAG = "SendReq";
+
+ public SendReq() {
+ super();
+
+ try {
+ setMessageType(PduHeaders.MESSAGE_TYPE_SEND_REQ);
+ setMmsVersion(PduHeaders.CURRENT_MMS_VERSION);
+ // TODO: Content-type must be decided according to whether
+ // SMIL part present.
+ setContentType("application/vnd.wap.multipart.related".getBytes());
+ setFrom(new EncodedStringValue(PduHeaders.FROM_INSERT_ADDRESS_TOKEN_STR.getBytes()));
+ setTransactionId(generateTransactionId());
+ } catch (InvalidHeaderValueException e) {
+ // Impossible to reach here since all headers we set above are valid.
+ Log.e(TAG, "Unexpected InvalidHeaderValueException.", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ private byte[] generateTransactionId() {
+ String transactionId = "T" + Long.toHexString(System.currentTimeMillis());
+ return transactionId.getBytes();
+ }
+
+ /**
+ * Constructor, used when composing a M-Send.req pdu.
+ *
+ * @param contentType the content type value
+ * @param from the from value
+ * @param mmsVersion current viersion of mms
+ * @param transactionId the transaction-id value
+ * @throws InvalidHeaderValueException if parameters are invalid.
+ * NullPointerException if contentType, form or
+ * transactionId is null.
+ */
+ public SendReq(byte[] contentType,
+ EncodedStringValue from,
+ int mmsVersion,
+ byte[] transactionId) throws InvalidHeaderValueException {
+ super();
+ setMessageType(PduHeaders.MESSAGE_TYPE_SEND_REQ);
+ setContentType(contentType);
+ setFrom(from);
+ setMmsVersion(mmsVersion);
+ setTransactionId(transactionId);
+ }
+
+ /**
+ * Constructor with given headers.
+ *
+ * @param headers Headers for this PDU.
+ */
+ SendReq(PduHeaders headers) {
+ super(headers);
+ }
+
+ /**
+ * Constructor with given headers and body
+ *
+ * @param headers Headers for this PDU.
+ * @param body Body of this PDu.
+ */
+ SendReq(PduHeaders headers, PduBody body) {
+ super(headers, body);
+ }
+
+ /**
+ * Get Bcc value.
+ *
+ * @return the value
+ */
+ public EncodedStringValue[] getBcc() {
+ return mPduHeaders.getEncodedStringValues(PduHeaders.BCC);
+ }
+
+ /**
+ * Add a "BCC" value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void addBcc(EncodedStringValue value) {
+ mPduHeaders.appendEncodedStringValue(value, PduHeaders.BCC);
+ }
+
+ /**
+ * Set "BCC" value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setBcc(EncodedStringValue[] value) {
+ mPduHeaders.setEncodedStringValues(value, PduHeaders.BCC);
+ }
+
+ /**
+ * Get CC value.
+ *
+ * @return the value
+ */
+ public EncodedStringValue[] getCc() {
+ return mPduHeaders.getEncodedStringValues(PduHeaders.CC);
+ }
+
+ /**
+ * Add a "CC" value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void addCc(EncodedStringValue value) {
+ mPduHeaders.appendEncodedStringValue(value, PduHeaders.CC);
+ }
+
+ /**
+ * Set "CC" value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setCc(EncodedStringValue[] value) {
+ mPduHeaders.setEncodedStringValues(value, PduHeaders.CC);
+ }
+
+ /**
+ * Get Content-type value.
+ *
+ * @return the value
+ */
+ public byte[] getContentType() {
+ return mPduHeaders.getTextString(PduHeaders.CONTENT_TYPE);
+ }
+
+ /**
+ * Set Content-type value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setContentType(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.CONTENT_TYPE);
+ }
+
+ /**
+ * Get X-Mms-Delivery-Report value.
+ *
+ * @return the value
+ */
+ public int getDeliveryReport() {
+ return mPduHeaders.getOctet(PduHeaders.DELIVERY_REPORT);
+ }
+
+ /**
+ * Set X-Mms-Delivery-Report value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ public void setDeliveryReport(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.DELIVERY_REPORT);
+ }
+
+ /**
+ * Get X-Mms-Expiry value.
+ *
+ * Expiry-value = Value-length
+ * (Absolute-token Date-value | Relative-token Delta-seconds-value)
+ *
+ * @return the value
+ */
+ public long getExpiry() {
+ return mPduHeaders.getLongInteger(PduHeaders.EXPIRY);
+ }
+
+ /**
+ * Set X-Mms-Expiry value.
+ *
+ * @param value the value
+ */
+ public void setExpiry(long value) {
+ mPduHeaders.setLongInteger(value, PduHeaders.EXPIRY);
+ }
+
+ /**
+ * Get X-Mms-MessageSize value.
+ *
+ * Expiry-value = size of message
+ *
+ * @return the value
+ */
+ public long getMessageSize() {
+ return mPduHeaders.getLongInteger(PduHeaders.MESSAGE_SIZE);
+ }
+
+ /**
+ * Set X-Mms-MessageSize value.
+ *
+ * @param value the value
+ */
+ public void setMessageSize(long value) {
+ mPduHeaders.setLongInteger(value, PduHeaders.MESSAGE_SIZE);
+ }
+
+ /**
+ * Get X-Mms-Message-Class value.
+ * Message-class-value = Class-identifier | Token-text
+ * Class-identifier = Personal | Advertisement | Informational | Auto
+ *
+ * @return the value
+ */
+ public byte[] getMessageClass() {
+ return mPduHeaders.getTextString(PduHeaders.MESSAGE_CLASS);
+ }
+
+ /**
+ * Set X-Mms-Message-Class value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setMessageClass(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.MESSAGE_CLASS);
+ }
+
+ /**
+ * Get X-Mms-Read-Report value.
+ *
+ * @return the value
+ */
+ public int getReadReport() {
+ return mPduHeaders.getOctet(PduHeaders.READ_REPORT);
+ }
+
+ /**
+ * Set X-Mms-Read-Report value.
+ *
+ * @param value the value
+ * @throws InvalidHeaderValueException if the value is invalid.
+ */
+ public void setReadReport(int value) throws InvalidHeaderValueException {
+ mPduHeaders.setOctet(value, PduHeaders.READ_REPORT);
+ }
+
+ /**
+ * Set "To" value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setTo(EncodedStringValue[] value) {
+ mPduHeaders.setEncodedStringValues(value, PduHeaders.TO);
+ }
+
+ /**
+ * Get X-Mms-Transaction-Id field value.
+ *
+ * @return the X-Mms-Report-Allowed value
+ */
+ public byte[] getTransactionId() {
+ return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID);
+ }
+
+ /**
+ * Set X-Mms-Transaction-Id field value.
+ *
+ * @param value the value
+ * @throws NullPointerException if the value is null.
+ */
+ public void setTransactionId(byte[] value) {
+ mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID);
+ }
+
+ /*
+ * Optional, not supported header fields:
+ *
+ * public byte getAdaptationAllowed() {return 0};
+ * public void setAdaptationAllowed(btye value) {};
+ *
+ * public byte[] getApplicId() {return null;}
+ * public void setApplicId(byte[] value) {}
+ *
+ * public byte[] getAuxApplicId() {return null;}
+ * public void getAuxApplicId(byte[] value) {}
+ *
+ * public byte getContentClass() {return 0x00;}
+ * public void setApplicId(byte value) {}
+ *
+ * public long getDeliveryTime() {return 0};
+ * public void setDeliveryTime(long value) {};
+ *
+ * public byte getDrmContent() {return 0x00;}
+ * public void setDrmContent(byte value) {}
+ *
+ * public MmFlagsValue getMmFlags() {return null;}
+ * public void setMmFlags(MmFlagsValue value) {}
+ *
+ * public MmStateValue getMmState() {return null;}
+ * public void getMmState(MmStateValue value) {}
+ *
+ * public byte[] getReplyApplicId() {return 0x00;}
+ * public void setReplyApplicId(byte[] value) {}
+ *
+ * public byte getReplyCharging() {return 0x00;}
+ * public void setReplyCharging(byte value) {}
+ *
+ * public byte getReplyChargingDeadline() {return 0x00;}
+ * public void setReplyChargingDeadline(byte value) {}
+ *
+ * public byte[] getReplyChargingId() {return 0x00;}
+ * public void setReplyChargingId(byte[] value) {}
+ *
+ * public long getReplyChargingSize() {return 0;}
+ * public void setReplyChargingSize(long value) {}
+ *
+ * public byte[] getReplyApplicId() {return 0x00;}
+ * public void setReplyApplicId(byte[] value) {}
+ *
+ * public byte getStore() {return 0x00;}
+ * public void setStore(byte value) {}
+ */
+}
diff --git a/src/com/android/messaging/mmslib/util/AbstractCache.java b/src/com/android/messaging/mmslib/util/AbstractCache.java
new file mode 100644
index 0000000..db98bb9
--- /dev/null
+++ b/src/com/android/messaging/mmslib/util/AbstractCache.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright (C) 2008 Esmertec AG.
+ * Copyright (C) 2008 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.messaging.mmslib.util;
+
+import android.support.v4.util.SimpleArrayMap;
+import android.util.Log;
+
+public abstract class AbstractCache<K, V> {
+ private static final String TAG = "AbstractCache";
+ private static final boolean LOCAL_LOGV = false;
+
+ private static final int MAX_CACHED_ITEMS = 500;
+
+ private final SimpleArrayMap<K, CacheEntry<V>> mCacheMap;
+
+ protected AbstractCache() {
+ mCacheMap = new SimpleArrayMap<K, CacheEntry<V>>();
+ }
+
+ public boolean put(K key, V value) {
+ if (LOCAL_LOGV) {
+ Log.v(TAG, "Trying to put " + key + " into cache.");
+ }
+
+ if (mCacheMap.size() >= MAX_CACHED_ITEMS) {
+ // TODO: Should remove the oldest or least hit cached entry
+ // and then cache the new one.
+ if (LOCAL_LOGV) {
+ Log.v(TAG, "Failed! size limitation reached.");
+ }
+ return false;
+ }
+
+ if (key != null) {
+ CacheEntry<V> cacheEntry = new CacheEntry<V>();
+ cacheEntry.value = value;
+ mCacheMap.put(key, cacheEntry);
+
+ if (LOCAL_LOGV) {
+ Log.v(TAG, key + " cached, " + mCacheMap.size() + " items total.");
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public V get(K key) {
+ if (LOCAL_LOGV) {
+ Log.v(TAG, "Trying to get " + key + " from cache.");
+ }
+
+ if (key != null) {
+ CacheEntry<V> cacheEntry = mCacheMap.get(key);
+ if (cacheEntry != null) {
+ cacheEntry.hit++;
+ if (LOCAL_LOGV) {
+ Log.v(TAG, key + " hit " + cacheEntry.hit + " times.");
+ }
+ return cacheEntry.value;
+ }
+ }
+ return null;
+ }
+
+ public V purge(K key) {
+ if (LOCAL_LOGV) {
+ Log.v(TAG, "Trying to purge " + key);
+ }
+
+ CacheEntry<V> v = mCacheMap.remove(key);
+
+ if (LOCAL_LOGV) {
+ Log.v(TAG, mCacheMap.size() + " items cached.");
+ }
+
+ return v != null ? v.value : null;
+ }
+
+ public void purgeAll() {
+ if (LOCAL_LOGV) {
+ Log.v(TAG, "Purging cache, " + mCacheMap.size()
+ + " items dropped.");
+ }
+ mCacheMap.clear();
+ }
+
+ public int size() {
+ return mCacheMap.size();
+ }
+
+ private static class CacheEntry<V> {
+
+ int hit;
+
+ V value;
+ }
+}
diff --git a/src/com/android/messaging/mmslib/util/DownloadDrmHelper.java b/src/com/android/messaging/mmslib/util/DownloadDrmHelper.java
new file mode 100644
index 0000000..c38b179
--- /dev/null
+++ b/src/com/android/messaging/mmslib/util/DownloadDrmHelper.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.messaging.mmslib.util;
+
+import android.content.Context;
+import android.drm.DrmManagerClient;
+import android.util.Log;
+
+public class DownloadDrmHelper {
+ private static final String TAG = "DownloadDrmHelper";
+
+ /** The MIME type of special DRM files */
+ public static final String MIMETYPE_DRM_MESSAGE = "application/vnd.oma.drm.message";
+
+ /** The extensions of special DRM files */
+ public static final String EXTENSION_DRM_MESSAGE = ".dm";
+
+ public static final String EXTENSION_INTERNAL_FWDL = ".fl";
+
+ /**
+ * Checks if the Media Type is a DRM Media Type
+ *
+ * @param drmManagerClient A DrmManagerClient
+ * @param mimetype Media Type to check
+ * @return True if the Media Type is DRM else false
+ */
+ public static boolean isDrmMimeType(Context context, String mimetype) {
+ boolean result = false;
+ if (context != null) {
+ try {
+ DrmManagerClient drmClient = new DrmManagerClient(context);
+ if (drmClient != null && mimetype != null && mimetype.length() > 0) {
+ result = drmClient.canHandle("", mimetype);
+ }
+ } catch (IllegalArgumentException e) {
+ Log.w(TAG,
+ "DrmManagerClient instance could not be created, context is Illegal.");
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "DrmManagerClient didn't initialize properly.");
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Checks if the Media Type needs to be DRM converted
+ *
+ * @param mimetype Media type of the content
+ * @return True if convert is needed else false
+ */
+ public static boolean isDrmConvertNeeded(String mimetype) {
+ return MIMETYPE_DRM_MESSAGE.equals(mimetype);
+ }
+
+ /**
+ * Modifies the file extension for a DRM Forward Lock file NOTE: This
+ * function shouldn't be called if the file shouldn't be DRM converted
+ */
+ public static String modifyDrmFwLockFileExtension(String filename) {
+ if (filename != null) {
+ int extensionIndex;
+ extensionIndex = filename.lastIndexOf(".");
+ if (extensionIndex != -1) {
+ filename = filename.substring(0, extensionIndex);
+ }
+ filename = filename.concat(EXTENSION_INTERNAL_FWDL);
+ }
+ return filename;
+ }
+
+ /**
+ * Gets the original mime type of DRM protected content.
+ *
+ * @param context The context
+ * @param path Path to the file
+ * @param containingMime The current mime type of of the file i.e. the
+ * containing mime type
+ * @return The original mime type of the file if DRM protected else the
+ * currentMime
+ */
+ public static String getOriginalMimeType(Context context, String path, String containingMime) {
+ String result = containingMime;
+ DrmManagerClient drmClient = new DrmManagerClient(context);
+ try {
+ if (drmClient.canHandle(path, null)) {
+ result = drmClient.getOriginalMimeType(path);
+ }
+ } catch (IllegalArgumentException ex) {
+ Log.w(TAG,
+ "Can't get original mime type since path is null or empty string.");
+ } catch (IllegalStateException ex) {
+ Log.w(TAG, "DrmManagerClient didn't initialize properly.");
+ }
+ return result;
+ }
+}
diff --git a/src/com/android/messaging/mmslib/util/DrmConvertSession.java b/src/com/android/messaging/mmslib/util/DrmConvertSession.java
new file mode 100644
index 0000000..604e391
--- /dev/null
+++ b/src/com/android/messaging/mmslib/util/DrmConvertSession.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.android.messaging.mmslib.util;
+
+import android.content.Context;
+import android.drm.DrmConvertedStatus;
+import android.drm.DrmManagerClient;
+import android.util.Log;
+
+import com.android.messaging.mmslib.Downloads;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+
+
+public class DrmConvertSession {
+ private DrmManagerClient mDrmClient;
+ private int mConvertSessionId;
+ private static final String TAG = "DrmConvertSession";
+
+ private DrmConvertSession(DrmManagerClient drmClient, int convertSessionId) {
+ mDrmClient = drmClient;
+ mConvertSessionId = convertSessionId;
+ }
+
+ /**
+ * Start of converting a file.
+ *
+ * @param context The context of the application running the convert session.
+ * @param mimeType Mimetype of content that shall be converted.
+ * @return A convert session or null in case an error occurs.
+ */
+ public static DrmConvertSession open(Context context, String mimeType) {
+ DrmManagerClient drmClient = null;
+ int convertSessionId = -1;
+ if (context != null && mimeType != null && !mimeType.equals("")) {
+ try {
+ drmClient = new DrmManagerClient(context);
+ try {
+ convertSessionId = drmClient.openConvertSession(mimeType);
+ } catch (IllegalArgumentException e) {
+ Log.w(TAG, "Conversion of Mimetype: " + mimeType
+ + " is not supported.", e);
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "Could not access Open DrmFramework.", e);
+ }
+ } catch (IllegalArgumentException e) {
+ Log.w(TAG,
+ "DrmManagerClient instance could not be created, context is Illegal.");
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "DrmManagerClient didn't initialize properly.");
+ }
+ }
+
+ if (drmClient == null || convertSessionId < 0) {
+ return null;
+ } else {
+ return new DrmConvertSession(drmClient, convertSessionId);
+ }
+ }
+
+ /**
+ * Convert a buffer of data to protected format.
+ *
+ * @param buffer Buffer filled with data to convert.
+ * @param size The number of bytes that shall be converted.
+ * @return A Buffer filled with converted data, if execution is ok, in all
+ * other case null.
+ */
+ public byte[] convert(byte[] inBuffer, int size) {
+ byte[] result = null;
+ if (inBuffer != null) {
+ DrmConvertedStatus convertedStatus = null;
+ try {
+ if (size != inBuffer.length) {
+ byte[] buf = new byte[size];
+ System.arraycopy(inBuffer, 0, buf, 0, size);
+ convertedStatus = mDrmClient.convertData(mConvertSessionId, buf);
+ } else {
+ convertedStatus = mDrmClient.convertData(mConvertSessionId, inBuffer);
+ }
+
+ if (convertedStatus != null &&
+ convertedStatus.statusCode == DrmConvertedStatus.STATUS_OK &&
+ convertedStatus.convertedData != null) {
+ result = convertedStatus.convertedData;
+ }
+ } catch (IllegalArgumentException e) {
+ Log.w(TAG, "Buffer with data to convert is illegal. Convertsession: "
+ + mConvertSessionId, e);
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "Could not convert data. Convertsession: " +
+ mConvertSessionId, e);
+ }
+ } else {
+ throw new IllegalArgumentException("Parameter inBuffer is null");
+ }
+ return result;
+ }
+
+ /**
+ * Ends a conversion session of a file.
+ *
+ * @param fileName The filename of the converted file.
+ * @return Downloads.Impl.STATUS_SUCCESS if execution is ok.
+ * Downloads.Impl.STATUS_FILE_ERROR in case converted file can not
+ * be accessed. Downloads.Impl.STATUS_NOT_ACCEPTABLE if a problem
+ * occurs when accessing drm framework.
+ * Downloads.Impl.STATUS_UNKNOWN_ERROR if a general error occurred.
+ */
+ public int close(String filename) {
+ DrmConvertedStatus convertedStatus = null;
+ int result = Downloads.Impl.STATUS_UNKNOWN_ERROR;
+ if (mDrmClient != null && mConvertSessionId >= 0) {
+ try {
+ convertedStatus = mDrmClient.closeConvertSession(mConvertSessionId);
+ if (convertedStatus == null ||
+ convertedStatus.statusCode != DrmConvertedStatus.STATUS_OK ||
+ convertedStatus.convertedData == null) {
+ result = Downloads.Impl.STATUS_NOT_ACCEPTABLE;
+ } else {
+ RandomAccessFile rndAccessFile = null;
+ try {
+ rndAccessFile = new RandomAccessFile(filename, "rw");
+ rndAccessFile.seek(convertedStatus.offset);
+ rndAccessFile.write(convertedStatus.convertedData);
+ result = Downloads.Impl.STATUS_SUCCESS;
+ } catch (FileNotFoundException e) {
+ result = Downloads.Impl.STATUS_FILE_ERROR;
+ Log.w(TAG, "File: " + filename + " could not be found.", e);
+ } catch (IOException e) {
+ result = Downloads.Impl.STATUS_FILE_ERROR;
+ Log.w(TAG, "Could not access File: " + filename + " .", e);
+ } catch (IllegalArgumentException e) {
+ result = Downloads.Impl.STATUS_FILE_ERROR;
+ Log.w(TAG, "Could not open file in mode: rw", e);
+ } catch (SecurityException e) {
+ Log.w(TAG, "Access to File: " + filename +
+ " was denied denied by SecurityManager.", e);
+ } finally {
+ if (rndAccessFile != null) {
+ try {
+ rndAccessFile.close();
+ } catch (IOException e) {
+ result = Downloads.Impl.STATUS_FILE_ERROR;
+ Log.w(TAG, "Failed to close File:" + filename
+ + ".", e);
+ }
+ }
+ }
+ }
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "Could not close convertsession. Convertsession: " +
+ mConvertSessionId, e);
+ }
+ }
+ return result;
+ }
+}
diff --git a/src/com/android/messaging/mmslib/util/PduCache.java b/src/com/android/messaging/mmslib/util/PduCache.java
new file mode 100644
index 0000000..9a400c0
--- /dev/null
+++ b/src/com/android/messaging/mmslib/util/PduCache.java
@@ -0,0 +1,262 @@
+/*
+ * Copyright (C) 2008 Esmertec AG.
+ * Copyright (C) 2008 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.messaging.mmslib.util;
+
+import android.content.ContentUris;
+import android.content.UriMatcher;
+import android.net.Uri;
+import android.provider.Telephony.Mms;
+import android.support.v4.util.SimpleArrayMap;
+import android.util.Log;
+import android.util.SparseArray;
+
+import java.util.HashSet;
+
+public final class PduCache extends AbstractCache<Uri, PduCacheEntry> {
+ private static final String TAG = "PduCache";
+ private static final boolean LOCAL_LOGV = false;
+
+ private static final int MMS_ALL = 0;
+ private static final int MMS_ALL_ID = 1;
+ private static final int MMS_INBOX = 2;
+ private static final int MMS_INBOX_ID = 3;
+ private static final int MMS_SENT = 4;
+ private static final int MMS_SENT_ID = 5;
+ private static final int MMS_DRAFTS = 6;
+ private static final int MMS_DRAFTS_ID = 7;
+ private static final int MMS_OUTBOX = 8;
+ private static final int MMS_OUTBOX_ID = 9;
+ private static final int MMS_CONVERSATION = 10;
+ private static final int MMS_CONVERSATION_ID = 11;
+
+ private static final UriMatcher URI_MATCHER;
+ private static final SparseArray<Integer> MATCH_TO_MSGBOX_ID_MAP;
+
+ private static PduCache sInstance;
+
+ static {
+ URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+ URI_MATCHER.addURI("mms", null, MMS_ALL);
+ URI_MATCHER.addURI("mms", "#", MMS_ALL_ID);
+ URI_MATCHER.addURI("mms", "inbox", MMS_INBOX);
+ URI_MATCHER.addURI("mms", "inbox/#", MMS_INBOX_ID);
+ URI_MATCHER.addURI("mms", "sent", MMS_SENT);
+ URI_MATCHER.addURI("mms", "sent/#", MMS_SENT_ID);
+ URI_MATCHER.addURI("mms", "drafts", MMS_DRAFTS);
+ URI_MATCHER.addURI("mms", "drafts/#", MMS_DRAFTS_ID);
+ URI_MATCHER.addURI("mms", "outbox", MMS_OUTBOX);
+ URI_MATCHER.addURI("mms", "outbox/#", MMS_OUTBOX_ID);
+ URI_MATCHER.addURI("mms-sms", "conversations", MMS_CONVERSATION);
+ URI_MATCHER.addURI("mms-sms", "conversations/#", MMS_CONVERSATION_ID);
+
+ MATCH_TO_MSGBOX_ID_MAP = new SparseArray<Integer>();
+ MATCH_TO_MSGBOX_ID_MAP.put(MMS_INBOX, Mms.MESSAGE_BOX_INBOX);
+ MATCH_TO_MSGBOX_ID_MAP.put(MMS_SENT, Mms.MESSAGE_BOX_SENT);
+ MATCH_TO_MSGBOX_ID_MAP.put(MMS_DRAFTS, Mms.MESSAGE_BOX_DRAFTS);
+ MATCH_TO_MSGBOX_ID_MAP.put(MMS_OUTBOX, Mms.MESSAGE_BOX_OUTBOX);
+ }
+
+ private final SparseArray<HashSet<Uri>> mMessageBoxes;
+ private final SimpleArrayMap<Long, HashSet<Uri>> mThreads;
+ private final HashSet<Uri> mUpdating;
+
+ private PduCache() {
+ mMessageBoxes = new SparseArray<HashSet<Uri>>();
+ mThreads = new SimpleArrayMap<Long, HashSet<Uri>>();
+ mUpdating = new HashSet<Uri>();
+ }
+
+ public static final synchronized PduCache getInstance() {
+ if (sInstance == null) {
+ if (LOCAL_LOGV) {
+ Log.v(TAG, "Constructing new PduCache instance.");
+ }
+ sInstance = new PduCache();
+ }
+ return sInstance;
+ }
+
+ @Override
+ public synchronized boolean put(Uri uri, PduCacheEntry entry) {
+ int msgBoxId = entry.getMessageBox();
+ HashSet<Uri> msgBox = mMessageBoxes.get(msgBoxId);
+ if (msgBox == null) {
+ msgBox = new HashSet<Uri>();
+ mMessageBoxes.put(msgBoxId, msgBox);
+ }
+
+ long threadId = entry.getThreadId();
+ HashSet<Uri> thread = mThreads.get(threadId);
+ if (thread == null) {
+ thread = new HashSet<Uri>();
+ mThreads.put(threadId, thread);
+ }
+
+ Uri finalKey = normalizeKey(uri);
+ boolean result = super.put(finalKey, entry);
+ if (result) {
+ msgBox.add(finalKey);
+ thread.add(finalKey);
+ }
+ setUpdating(uri, false);
+ return result;
+ }
+
+ public synchronized void setUpdating(Uri uri, boolean updating) {
+ if (updating) {
+ mUpdating.add(uri);
+ } else {
+ mUpdating.remove(uri);
+ }
+ }
+
+ public synchronized boolean isUpdating(Uri uri) {
+ return mUpdating.contains(uri);
+ }
+
+ @Override
+ public synchronized PduCacheEntry purge(Uri uri) {
+ int match = URI_MATCHER.match(uri);
+ switch (match) {
+ case MMS_ALL_ID:
+ return purgeSingleEntry(uri);
+ case MMS_INBOX_ID:
+ case MMS_SENT_ID:
+ case MMS_DRAFTS_ID:
+ case MMS_OUTBOX_ID:
+ String msgId = uri.getLastPathSegment();
+ return purgeSingleEntry(Uri.withAppendedPath(Mms.CONTENT_URI, msgId));
+ // Implicit batch of purge, return null.
+ case MMS_ALL:
+ case MMS_CONVERSATION:
+ purgeAll();
+ return null;
+ case MMS_INBOX:
+ case MMS_SENT:
+ case MMS_DRAFTS:
+ case MMS_OUTBOX:
+ purgeByMessageBox(MATCH_TO_MSGBOX_ID_MAP.get(match));
+ return null;
+ case MMS_CONVERSATION_ID:
+ purgeByThreadId(ContentUris.parseId(uri));
+ return null;
+ default:
+ return null;
+ }
+ }
+
+ private PduCacheEntry purgeSingleEntry(Uri key) {
+ mUpdating.remove(key);
+ PduCacheEntry entry = super.purge(key);
+ if (entry != null) {
+ removeFromThreads(key, entry);
+ removeFromMessageBoxes(key, entry);
+ return entry;
+ }
+ return null;
+ }
+
+ @Override
+ public synchronized void purgeAll() {
+ super.purgeAll();
+
+ mMessageBoxes.clear();
+ mThreads.clear();
+ mUpdating.clear();
+ }
+
+ /**
+ * @param uri The Uri to be normalized.
+ * @return Uri The normalized key of cached entry.
+ */
+ private Uri normalizeKey(Uri uri) {
+ int match = URI_MATCHER.match(uri);
+ Uri normalizedKey = null;
+
+ switch (match) {
+ case MMS_ALL_ID:
+ normalizedKey = uri;
+ break;
+ case MMS_INBOX_ID:
+ case MMS_SENT_ID:
+ case MMS_DRAFTS_ID:
+ case MMS_OUTBOX_ID:
+ String msgId = uri.getLastPathSegment();
+ normalizedKey = Uri.withAppendedPath(Mms.CONTENT_URI, msgId);
+ break;
+ default:
+ return null;
+ }
+
+ if (LOCAL_LOGV) {
+ Log.v(TAG, uri + " -> " + normalizedKey);
+ }
+ return normalizedKey;
+ }
+
+ private void purgeByMessageBox(Integer msgBoxId) {
+ if (LOCAL_LOGV) {
+ Log.v(TAG, "Purge cache in message box: " + msgBoxId);
+ }
+
+ if (msgBoxId != null) {
+ HashSet<Uri> msgBox = mMessageBoxes.get(msgBoxId);
+ mMessageBoxes.remove(msgBoxId);
+ if (msgBox != null) {
+ for (Uri key : msgBox) {
+ mUpdating.remove(key);
+ PduCacheEntry entry = super.purge(key);
+ if (entry != null) {
+ removeFromThreads(key, entry);
+ }
+ }
+ }
+ }
+ }
+
+ private void removeFromThreads(Uri key, PduCacheEntry entry) {
+ HashSet<Uri> thread = mThreads.get(entry.getThreadId());
+ if (thread != null) {
+ thread.remove(key);
+ }
+ }
+
+ private void purgeByThreadId(long threadId) {
+ if (LOCAL_LOGV) {
+ Log.v(TAG, "Purge cache in thread: " + threadId);
+ }
+
+ HashSet<Uri> thread = mThreads.remove(threadId);
+ if (thread != null) {
+ for (Uri key : thread) {
+ mUpdating.remove(key);
+ PduCacheEntry entry = super.purge(key);
+ if (entry != null) {
+ removeFromMessageBoxes(key, entry);
+ }
+ }
+ }
+ }
+
+ private void removeFromMessageBoxes(Uri key, PduCacheEntry entry) {
+ HashSet<Uri> msgBox = mThreads.get(Long.valueOf(entry.getMessageBox()));
+ if (msgBox != null) {
+ msgBox.remove(key);
+ }
+ }
+}
diff --git a/src/com/android/messaging/mmslib/util/PduCacheEntry.java b/src/com/android/messaging/mmslib/util/PduCacheEntry.java
new file mode 100644
index 0000000..b287f00
--- /dev/null
+++ b/src/com/android/messaging/mmslib/util/PduCacheEntry.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2008 Esmertec AG.
+ * Copyright (C) 2008 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.messaging.mmslib.util;
+
+import com.android.messaging.mmslib.pdu.GenericPdu;
+
+public final class PduCacheEntry {
+ private final GenericPdu mPdu;
+ private final int mMessageBox;
+ private final long mThreadId;
+
+ public PduCacheEntry(GenericPdu pdu, int msgBox, long threadId) {
+ mPdu = pdu;
+ mMessageBox = msgBox;
+ mThreadId = threadId;
+ }
+
+ public GenericPdu getPdu() {
+ return mPdu;
+ }
+
+ public int getMessageBox() {
+ return mMessageBox;
+ }
+
+ public long getThreadId() {
+ return mThreadId;
+ }
+}
diff --git a/src/com/android/messaging/receiver/AbortMmsWapPushReceiver.java b/src/com/android/messaging/receiver/AbortMmsWapPushReceiver.java
new file mode 100644
index 0000000..9e09a2a
--- /dev/null
+++ b/src/com/android/messaging/receiver/AbortMmsWapPushReceiver.java
@@ -0,0 +1,43 @@
+/*
+ * 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.messaging.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.provider.Telephony;
+
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+/**
+ * This receiver is used to abort MMS WAP broadcasts pre-KLP when SMS is enabled.
+ */
+public class AbortMmsWapPushReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (Telephony.Sms.Intents.WAP_PUSH_RECEIVED_ACTION.equals(intent.getAction())
+ && ContentType.MMS_MESSAGE.equals(intent.getType())) {
+ // If we are enabled, it's our job to stop the broadcast from continuing. This
+ // receiver is not used on KLP but we do an extra check here just to make sure.
+ if (!OsUtil.isAtLeastKLP() && PhoneUtils.getDefault().isSmsEnabled()) {
+ abortBroadcast();
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/receiver/AbortSmsReceiver.java b/src/com/android/messaging/receiver/AbortSmsReceiver.java
new file mode 100644
index 0000000..f4491d8
--- /dev/null
+++ b/src/com/android/messaging/receiver/AbortSmsReceiver.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+/**
+ * This receiver is used to abort SMS broadcasts pre-KLP when SMS is enabled.
+ */
+public final class AbortSmsReceiver extends BroadcastReceiver {
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ // If we are enabled, it's our job to stop the broadcast from continuing. This
+ // receiver is not used on KLP but we do an extra check here just to make sure.
+ if (!OsUtil.isAtLeastKLP() && PhoneUtils.getDefault().isSmsEnabled()) {
+ if (!SmsReceiver.shouldIgnoreMessage(intent)) {
+ abortBroadcast();
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/receiver/BootAndPackageReplacedReceiver.java b/src/com/android/messaging/receiver/BootAndPackageReplacedReceiver.java
new file mode 100644
index 0000000..be0d296
--- /dev/null
+++ b/src/com/android/messaging/receiver/BootAndPackageReplacedReceiver.java
@@ -0,0 +1,49 @@
+/*
+ * 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.messaging.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import com.android.messaging.BugleApplication;
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.action.UpdateMessageNotificationAction;
+import com.android.messaging.util.BuglePrefsKeys;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Receives notification of boot completion and package replacement
+ */
+public class BootAndPackageReplacedReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())
+ || Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())) {
+ // Repost unseen notifications
+ Factory.get().getApplicationPrefs().putLong(
+ BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP, Long.MIN_VALUE);
+ UpdateMessageNotificationAction.updateMessageNotification();
+
+ BugleApplication.updateAppConfig(context);
+ } else {
+ LogUtil.i(LogUtil.BUGLE_TAG, "BootAndPackageReplacedReceiver got unexpected action: "
+ + intent.getAction());
+ }
+ }
+}
+
diff --git a/src/com/android/messaging/receiver/DefaultSmsSubscriptionChangeReceiver.java b/src/com/android/messaging/receiver/DefaultSmsSubscriptionChangeReceiver.java
new file mode 100644
index 0000000..d5153a0
--- /dev/null
+++ b/src/com/android/messaging/receiver/DefaultSmsSubscriptionChangeReceiver.java
@@ -0,0 +1,32 @@
+/*
+ * 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.messaging.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import com.android.messaging.datamodel.ParticipantRefresh;
+
+/**
+ * Responds to default SMS subscription selection changes from system Settings.
+ */
+public class DefaultSmsSubscriptionChangeReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ ParticipantRefresh.refreshSelfParticipants();
+ }
+}
diff --git a/src/com/android/messaging/receiver/MmsWapPushDeliverReceiver.java b/src/com/android/messaging/receiver/MmsWapPushDeliverReceiver.java
new file mode 100644
index 0000000..a5c247c
--- /dev/null
+++ b/src/com/android/messaging/receiver/MmsWapPushDeliverReceiver.java
@@ -0,0 +1,43 @@
+/*
+ * 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.messaging.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.provider.Telephony;
+
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.PhoneUtils;
+
+/**
+ * Class that handles MMS WAP push intent from telephony on KLP+ Devices.
+ */
+public class MmsWapPushDeliverReceiver extends BroadcastReceiver {
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (Telephony.Sms.Intents.WAP_PUSH_DELIVER_ACTION.equals(intent.getAction())
+ && ContentType.MMS_MESSAGE.equals(intent.getType())) {
+ // Always convert negative subIds into -1
+ int subId = PhoneUtils.getDefault().getEffectiveIncomingSubIdFromSystem(
+ intent, MmsWapPushReceiver.EXTRA_SUBSCRIPTION);
+ byte[] data = intent.getByteArrayExtra(MmsWapPushReceiver.EXTRA_DATA);
+ MmsWapPushReceiver.mmsReceived(subId, data);
+ }
+ }
+}
diff --git a/src/com/android/messaging/receiver/MmsWapPushReceiver.java b/src/com/android/messaging/receiver/MmsWapPushReceiver.java
new file mode 100644
index 0000000..29cf0db
--- /dev/null
+++ b/src/com/android/messaging/receiver/MmsWapPushReceiver.java
@@ -0,0 +1,58 @@
+/*
+ * 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.messaging.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.provider.Telephony;
+
+import com.android.messaging.datamodel.action.ReceiveMmsMessageAction;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.PhoneUtils;
+
+/**
+ * Class that handles MMS WAP push intent from telephony on pre-KLP Devices.
+ */
+public class MmsWapPushReceiver extends BroadcastReceiver {
+ static final String EXTRA_SUBSCRIPTION = "subscription";
+ static final String EXTRA_DATA = "data";
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (Telephony.Sms.Intents.WAP_PUSH_RECEIVED_ACTION.equals(intent.getAction())
+ && ContentType.MMS_MESSAGE.equals(intent.getType())) {
+ if (PhoneUtils.getDefault().isSmsEnabled()) {
+ // Always convert negative subIds into -1
+ final int subId = PhoneUtils.getDefault().getEffectiveIncomingSubIdFromSystem(
+ intent, MmsWapPushReceiver.EXTRA_SUBSCRIPTION);
+ final byte[] data = intent.getByteArrayExtra(MmsWapPushReceiver.EXTRA_DATA);
+ mmsReceived(subId, data);
+ }
+ }
+ }
+
+ static void mmsReceived(final int subId, final byte[] data) {
+ if (!PhoneUtils.getDefault().isSmsEnabled()) {
+ return;
+ }
+
+ final ReceiveMmsMessageAction action = new ReceiveMmsMessageAction(subId, data);
+ action.start();
+ }
+}
+
diff --git a/src/com/android/messaging/receiver/NotificationReceiver.java b/src/com/android/messaging/receiver/NotificationReceiver.java
new file mode 100644
index 0000000..bbb847d
--- /dev/null
+++ b/src/com/android/messaging/receiver/NotificationReceiver.java
@@ -0,0 +1,57 @@
+/*
+ * 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.messaging.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.action.MarkAsSeenAction;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.ConversationIdSet;
+import com.android.messaging.util.LogUtil;
+
+// NotificationReceiver is used to handle delete intents from notifications. When a user
+// clears all notifications or swipes a bugle notification away, the intent we pass in as
+// the delete intent will get handled here.
+public class NotificationReceiver extends BroadcastReceiver {
+ // Logging
+ public static final String TAG = LogUtil.BUGLE_TAG;
+ public static final boolean VERBOSE = false;
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (VERBOSE) {
+ LogUtil.v(TAG, "NotificationReceiver.onReceive: intent " + intent);
+ }
+ if (intent.getAction().equals(UIIntents.ACTION_RESET_NOTIFICATIONS)) {
+ final String conversationIdSetString =
+ intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID_SET);
+ final int notificationTargets = intent.getIntExtra(
+ UIIntents.UI_INTENT_EXTRA_NOTIFICATIONS_UPDATE, BugleNotifications.UPDATE_ALL);
+ if (conversationIdSetString == null) {
+ BugleNotifications.markAllMessagesAsSeen();
+ } else {
+ for (final String conversationId :
+ ConversationIdSet.createSet(conversationIdSetString)) {
+ MarkAsSeenAction.markAsSeen(conversationId);
+ BugleNotifications.resetLastMessageDing(conversationId);
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/receiver/SendStatusReceiver.java b/src/com/android/messaging/receiver/SendStatusReceiver.java
new file mode 100644
index 0000000..fc0e8c9
--- /dev/null
+++ b/src/com/android/messaging/receiver/SendStatusReceiver.java
@@ -0,0 +1,96 @@
+/*
+ * 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.messaging.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.telephony.SmsMessage;
+
+import com.android.messaging.datamodel.action.ProcessDeliveryReportAction;
+import com.android.messaging.datamodel.action.ProcessDownloadedMmsAction;
+import com.android.messaging.datamodel.action.ProcessSentMessageAction;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.sms.SmsSender;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * The SMS sent and delivery intent receiver.
+ *
+ * This class just simply forwards the intents to proper recipients for actual handling.
+ */
+public class SendStatusReceiver extends BroadcastReceiver {
+ public static final String MESSAGE_SENT_ACTION =
+ "com.android.messaging.receiver.SendStatusReceiver.MESSAGE_SENT";
+ public static final String MESSAGE_DELIVERED_ACTION =
+ "com.android.messaging.receiver.SendStatusReceiver.MESSAGE_DELIVERED";
+ public static final String MMS_SENT_ACTION =
+ "com.android.messaging.receiver.SendStatusReceiver.MMS_SENT";
+ public static final String MMS_DOWNLOADED_ACTION =
+ "com.android.messaging.receiver.SendStatusReceiver.MMS_DOWNLOADED";
+
+ // Defined by platform, but no constant provided. See docs for SmsManager.sendTextMessage.
+ public static final String EXTRA_ERROR_CODE = "errorCode";
+
+ public static final String EXTRA_PART_ID = "partId";
+ public static final String EXTRA_SUB_ID = "subId";
+
+ public static final int NO_ERROR_CODE = 0;
+ public static final int NO_PART_ID = -1;
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ // This will be called on the main thread (so it should exit quickly)
+ final String action = intent.getAction();
+ final int resultCode = getResultCode();
+ if (MESSAGE_SENT_ACTION.equals(action)) {
+ final Uri requestId = intent.getData();
+ SmsSender.setResult(
+ requestId,
+ resultCode,
+ intent.getIntExtra(EXTRA_ERROR_CODE, NO_ERROR_CODE),
+ intent.getIntExtra(EXTRA_PART_ID, NO_PART_ID),
+ intent.getIntExtra(EXTRA_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID));
+ } else if (MMS_SENT_ACTION.equals(action)) {
+ final Uri messageUri = intent.getData();
+ ProcessSentMessageAction.processMmsSent(resultCode, messageUri,
+ intent.getExtras());
+ } else if (MMS_DOWNLOADED_ACTION.equals(action)) {
+ ProcessDownloadedMmsAction.processMessageDownloaded(resultCode,
+ intent.getExtras());
+ } else if (MESSAGE_DELIVERED_ACTION.equals(action)) {
+ final SmsMessage smsMessage = MmsUtils.getSmsMessageFromDeliveryReport(intent);
+ final Uri smsMessageUri = intent.getData();
+ if (smsMessage == null) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "SendStatusReceiver: empty report message");
+ return;
+ }
+ int status = 0;
+ try {
+ status = smsMessage.getStatus();
+ } catch (final NullPointerException e) {
+ // Sometimes, SmsMessage.mWrappedSmsMessage is null causing NPE when we access
+ // the methods on it although the SmsMessage itself is not null.
+ LogUtil.e(LogUtil.BUGLE_TAG, "SendStatusReceiver: NPE inside SmsMessage");
+ return;
+ }
+ ProcessDeliveryReportAction.deliveryReportReceived(smsMessageUri, status);
+ }
+ }
+}
diff --git a/src/com/android/messaging/receiver/SmsDeliverReceiver.java b/src/com/android/messaging/receiver/SmsDeliverReceiver.java
new file mode 100644
index 0000000..6a9b66c
--- /dev/null
+++ b/src/com/android/messaging/receiver/SmsDeliverReceiver.java
@@ -0,0 +1,31 @@
+/*
+ * 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.messaging.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * Class that receives incoming SMS messages on KLP+ Devices.
+ */
+public final class SmsDeliverReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ SmsReceiver.deliverSmsIntent(context, intent);
+ }
+}
diff --git a/src/com/android/messaging/receiver/SmsReceiver.java b/src/com/android/messaging/receiver/SmsReceiver.java
new file mode 100644
index 0000000..db9b4bb
--- /dev/null
+++ b/src/com/android/messaging/receiver/SmsReceiver.java
@@ -0,0 +1,375 @@
+/*
+ * 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.messaging.receiver;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.provider.Telephony;
+import android.provider.Telephony.Sms;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationCompat.Builder;
+import android.support.v4.app.NotificationCompat.Style;
+import android.support.v4.app.NotificationManagerCompat;
+
+import java.util.ArrayList;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.BugleNotifications;
+import com.android.messaging.datamodel.MessageNotificationState;
+import com.android.messaging.datamodel.NoConfirmationSmsSendService;
+import com.android.messaging.datamodel.action.ReceiveSmsMessageAction;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PendingIntentConstants;
+import com.android.messaging.util.PhoneUtils;
+
+/**
+ * Class that receives incoming SMS messages through android.provider.Telephony.SMS_RECEIVED
+ *
+ * This class serves two purposes:
+ * - Process phone verification SMS messages
+ * - Handle SMS messages when the user has enabled us to be the default SMS app (Pre-KLP)
+ */
+public final class SmsReceiver extends BroadcastReceiver {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private static ArrayList<Pattern> sIgnoreSmsPatterns;
+
+ /**
+ * Enable or disable the SmsReceiver as appropriate. Pre-KLP we use this receiver for
+ * receiving incoming SMS messages. For KLP+ this receiver is not used when running as the
+ * primary user and the SmsDeliverReceiver is used for receiving incoming SMS messages.
+ * When running as a secondary user, this receiver is still used to trigger the incoming
+ * notification.
+ */
+ public static void updateSmsReceiveHandler(final Context context) {
+ boolean smsReceiverEnabled;
+ boolean mmsWapPushReceiverEnabled;
+ boolean respondViaMessageEnabled;
+ boolean broadcastAbortEnabled;
+
+ if (OsUtil.isAtLeastKLP()) {
+ // When we're running as the secondary user, we don't get the new SMS_DELIVER intent,
+ // only the primary user receives that. As secondary, we need to go old-school and
+ // listen for the SMS_RECEIVED intent. For the secondary user, use this SmsReceiver
+ // for both sms and mms notification. For the primary user on KLP (and above), we don't
+ // use the SmsReceiver.
+ smsReceiverEnabled = OsUtil.isSecondaryUser();
+ // On KLP use the new deliver event for mms
+ mmsWapPushReceiverEnabled = false;
+ // On KLP we need to always enable this handler to show in the list of sms apps
+ respondViaMessageEnabled = true;
+ // On KLP we don't need to abort the broadcast
+ broadcastAbortEnabled = false;
+ } else {
+ // On JB we use the sms receiver for both sms/mms delivery
+ final boolean carrierSmsEnabled = PhoneUtils.getDefault().isSmsEnabled();
+ smsReceiverEnabled = carrierSmsEnabled;
+
+ // On JB we use the mms receiver when sms/mms is enabled
+ mmsWapPushReceiverEnabled = carrierSmsEnabled;
+ // On JB this is dynamic to make sure we don't show in dialer if sms is disabled
+ respondViaMessageEnabled = carrierSmsEnabled;
+ // On JB we need to abort broadcasts if SMS is enabled
+ broadcastAbortEnabled = carrierSmsEnabled;
+ }
+
+ final PackageManager packageManager = context.getPackageManager();
+ final boolean logv = LogUtil.isLoggable(TAG, LogUtil.VERBOSE);
+ if (smsReceiverEnabled) {
+ if (logv) {
+ LogUtil.v(TAG, "Enabling SMS message receiving");
+ }
+ packageManager.setComponentEnabledSetting(
+ new ComponentName(context, SmsReceiver.class),
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
+
+ } else {
+ if (logv) {
+ LogUtil.v(TAG, "Disabling SMS message receiving");
+ }
+ packageManager.setComponentEnabledSetting(
+ new ComponentName(context, SmsReceiver.class),
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
+ }
+ if (mmsWapPushReceiverEnabled) {
+ if (logv) {
+ LogUtil.v(TAG, "Enabling MMS message receiving");
+ }
+ packageManager.setComponentEnabledSetting(
+ new ComponentName(context, MmsWapPushReceiver.class),
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
+ } else {
+ if (logv) {
+ LogUtil.v(TAG, "Disabling MMS message receiving");
+ }
+ packageManager.setComponentEnabledSetting(
+ new ComponentName(context, MmsWapPushReceiver.class),
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
+ }
+ if (broadcastAbortEnabled) {
+ if (logv) {
+ LogUtil.v(TAG, "Enabling SMS/MMS broadcast abort");
+ }
+ packageManager.setComponentEnabledSetting(
+ new ComponentName(context, AbortSmsReceiver.class),
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
+ packageManager.setComponentEnabledSetting(
+ new ComponentName(context, AbortMmsWapPushReceiver.class),
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
+ } else {
+ if (logv) {
+ LogUtil.v(TAG, "Disabling SMS/MMS broadcast abort");
+ }
+ packageManager.setComponentEnabledSetting(
+ new ComponentName(context, AbortSmsReceiver.class),
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
+ packageManager.setComponentEnabledSetting(
+ new ComponentName(context, AbortMmsWapPushReceiver.class),
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
+ }
+ if (respondViaMessageEnabled) {
+ if (logv) {
+ LogUtil.v(TAG, "Enabling respond via message intent");
+ }
+ packageManager.setComponentEnabledSetting(
+ new ComponentName(context, NoConfirmationSmsSendService.class),
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
+ } else {
+ if (logv) {
+ LogUtil.v(TAG, "Disabling respond via message intent");
+ }
+ packageManager.setComponentEnabledSetting(
+ new ComponentName(context, NoConfirmationSmsSendService.class),
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
+ }
+ }
+
+ private static final String EXTRA_ERROR_CODE = "errorCode";
+ private static final String EXTRA_SUB_ID = "subscription";
+
+ public static void deliverSmsIntent(final Context context, final Intent intent) {
+ final android.telephony.SmsMessage[] messages = getMessagesFromIntent(intent);
+
+ // Check messages for validity
+ if (messages == null || messages.length < 1) {
+ LogUtil.e(TAG, "processReceivedSms: null or zero or ignored message");
+ return;
+ }
+
+ final int errorCode = intent.getIntExtra(EXTRA_ERROR_CODE, 0);
+ // Always convert negative subIds into -1
+ int subId = PhoneUtils.getDefault().getEffectiveIncomingSubIdFromSystem(
+ intent, EXTRA_SUB_ID);
+ deliverSmsMessages(context, subId, errorCode, messages);
+ if (MmsUtils.isDumpSmsEnabled()) {
+ final String format = null;
+ DebugUtils.dumpSms(messages[0].getTimestampMillis(), messages, format);
+ }
+ }
+
+ public static void deliverSmsMessages(final Context context, final int subId,
+ final int errorCode, final android.telephony.SmsMessage[] messages) {
+ final ContentValues messageValues =
+ MmsUtils.parseReceivedSmsMessage(context, messages, errorCode);
+
+ LogUtil.v(TAG, "SmsReceiver.deliverSmsMessages");
+
+ final long nowInMillis = System.currentTimeMillis();
+ final long receivedTimestampMs = MmsUtils.getMessageDate(messages[0], nowInMillis);
+
+ messageValues.put(Sms.Inbox.DATE, receivedTimestampMs);
+ // Default to unread and unseen for us but ReceiveSmsMessageAction will override
+ // seen for the telephony db.
+ messageValues.put(Sms.Inbox.READ, 0);
+ messageValues.put(Sms.Inbox.SEEN, 0);
+ if (OsUtil.isAtLeastL_MR1()) {
+ messageValues.put(Sms.SUBSCRIPTION_ID, subId);
+ }
+
+ if (messages[0].getMessageClass() == android.telephony.SmsMessage.MessageClass.CLASS_0 ||
+ DebugUtils.debugClassZeroSmsEnabled()) {
+ Factory.get().getUIIntents().launchClassZeroActivity(context, messageValues);
+ } else {
+ final ReceiveSmsMessageAction action = new ReceiveSmsMessageAction(messageValues);
+ action.start();
+ }
+ }
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ LogUtil.v(TAG, "SmsReceiver.onReceive " + intent);
+ // On KLP+ we only take delivery of SMS messages in SmsDeliverReceiver.
+ if (PhoneUtils.getDefault().isSmsEnabled()) {
+ final String action = intent.getAction();
+ if (OsUtil.isSecondaryUser() &&
+ (Telephony.Sms.Intents.SMS_RECEIVED_ACTION.equals(action) ||
+ // TODO: update this with the actual constant from Telephony
+ "android.provider.Telephony.MMS_DOWNLOADED".equals(action))) {
+ postNewMessageSecondaryUserNotification();
+ } else if (!OsUtil.isAtLeastKLP()) {
+ deliverSmsIntent(context, intent);
+ }
+ }
+ }
+
+ private static class SecondaryUserNotificationState extends MessageNotificationState {
+ SecondaryUserNotificationState() {
+ super(null);
+ }
+
+ @Override
+ protected Style build(Builder builder) {
+ return null;
+ }
+
+ @Override
+ public boolean getNotificationVibrate() {
+ return true;
+ }
+ }
+
+ public static void postNewMessageSecondaryUserNotification() {
+ final Context context = Factory.get().getApplicationContext();
+ final Resources resources = context.getResources();
+ final PendingIntent pendingIntent = UIIntents.get()
+ .getPendingIntentForSecondaryUserNewMessageNotification(context);
+
+ final NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
+ builder.setContentTitle(resources.getString(R.string.secondary_user_new_message_title))
+ .setTicker(resources.getString(R.string.secondary_user_new_message_ticker))
+ .setSmallIcon(R.drawable.ic_sms_light)
+ // Returning PRIORITY_HIGH causes L to put up a HUD notification. Without it, the ticker
+ // isn't displayed.
+ .setPriority(Notification.PRIORITY_HIGH)
+ .setContentIntent(pendingIntent);
+
+ final NotificationCompat.BigTextStyle bigTextStyle =
+ new NotificationCompat.BigTextStyle(builder);
+ bigTextStyle.bigText(resources.getString(R.string.secondary_user_new_message_title));
+ final Notification notification = bigTextStyle.build();
+
+ final NotificationManagerCompat notificationManager =
+ NotificationManagerCompat.from(Factory.get().getApplicationContext());
+
+ int defaults = Notification.DEFAULT_LIGHTS;
+ if (BugleNotifications.shouldVibrate(new SecondaryUserNotificationState())) {
+ defaults |= Notification.DEFAULT_VIBRATE;
+ }
+ notification.defaults = defaults;
+
+ notificationManager.notify(getNotificationTag(),
+ PendingIntentConstants.SMS_SECONDARY_USER_NOTIFICATION_ID, notification);
+ }
+
+ /**
+ * Cancel the notification
+ */
+ public static void cancelSecondaryUserNotification() {
+ final NotificationManagerCompat notificationManager =
+ NotificationManagerCompat.from(Factory.get().getApplicationContext());
+ notificationManager.cancel(getNotificationTag(),
+ PendingIntentConstants.SMS_SECONDARY_USER_NOTIFICATION_ID);
+ }
+
+ private static String getNotificationTag() {
+ return Factory.get().getApplicationContext().getPackageName() + ":secondaryuser";
+ }
+
+ /**
+ * Compile all of the patterns we check for to ignore system SMS messages.
+ */
+ private static void compileIgnoreSmsPatterns() {
+ // Get the pattern set from GServices
+ final String smsIgnoreRegex = BugleGservices.get().getString(
+ BugleGservicesKeys.SMS_IGNORE_MESSAGE_REGEX,
+ BugleGservicesKeys.SMS_IGNORE_MESSAGE_REGEX_DEFAULT);
+ if (smsIgnoreRegex != null) {
+ final String[] ignoreSmsExpressions = smsIgnoreRegex.split("\n");
+ if (ignoreSmsExpressions.length != 0) {
+ sIgnoreSmsPatterns = new ArrayList<Pattern>();
+ for (int i = 0; i < ignoreSmsExpressions.length; i++) {
+ try {
+ sIgnoreSmsPatterns.add(Pattern.compile(ignoreSmsExpressions[i]));
+ } catch (PatternSyntaxException e) {
+ LogUtil.e(TAG, "compileIgnoreSmsPatterns: Skipping bad expression: " +
+ ignoreSmsExpressions[i]);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Get the SMS messages from the specified SMS intent.
+ * @return the messages. If there is an error or the message should be ignored, return null.
+ */
+ public static android.telephony.SmsMessage[] getMessagesFromIntent(Intent intent) {
+ final android.telephony.SmsMessage[] messages = Sms.Intents.getMessagesFromIntent(intent);
+
+ // Check messages for validity
+ if (messages == null || messages.length < 1) {
+ return null;
+ }
+ // Sometimes, SmsMessage.mWrappedSmsMessage is null causing NPE when we access
+ // the methods on it although the SmsMessage itself is not null. So do this check
+ // before we do anything on the parsed SmsMessages.
+ try {
+ final String messageBody = messages[0].getDisplayMessageBody();
+ if (messageBody != null) {
+ // Compile patterns if necessary
+ if (sIgnoreSmsPatterns == null) {
+ compileIgnoreSmsPatterns();
+ }
+ // Check against filters
+ for (final Pattern pattern : sIgnoreSmsPatterns) {
+ if (pattern.matcher(messageBody).matches()) {
+ return null;
+ }
+ }
+ }
+ } catch (final NullPointerException e) {
+ LogUtil.e(TAG, "shouldIgnoreMessage: NPE inside SmsMessage");
+ return null;
+ }
+ return messages;
+ }
+
+
+ /**
+ * Check the specified SMS intent to see if the message should be ignored
+ * @return true if the message should be ignored
+ */
+ public static boolean shouldIgnoreMessage(Intent intent) {
+ return getMessagesFromIntent(intent) == null;
+ }
+}
diff --git a/src/com/android/messaging/receiver/StorageStatusReceiver.java b/src/com/android/messaging/receiver/StorageStatusReceiver.java
new file mode 100644
index 0000000..bee899c
--- /dev/null
+++ b/src/com/android/messaging/receiver/StorageStatusReceiver.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import com.android.messaging.sms.SmsStorageStatusManager;
+
+/**
+ * Receiver that listens on storage status changes
+ */
+public class StorageStatusReceiver extends BroadcastReceiver {
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (Intent.ACTION_DEVICE_STORAGE_LOW.equals(intent.getAction())) {
+ SmsStorageStatusManager.handleStorageLow();
+ } else if (Intent.ACTION_DEVICE_STORAGE_OK.equals(intent.getAction())) {
+ SmsStorageStatusManager.handleStorageOk();
+ }
+ }
+}
diff --git a/src/com/android/messaging/sms/ApnDatabase.java b/src/com/android/messaging/sms/ApnDatabase.java
new file mode 100644
index 0000000..a8d0d0c
--- /dev/null
+++ b/src/com/android/messaging/sms/ApnDatabase.java
@@ -0,0 +1,374 @@
+/*
+ * 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.messaging.sms;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.XmlResourceParser;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.provider.Telephony;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.google.common.collect.Lists;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+/*
+ * Database helper class for looking up APNs. This database has a single table
+ * which stores the APNs that are initially created from an xml file.
+ */
+public class ApnDatabase extends SQLiteOpenHelper {
+ private static final int DB_VERSION = 3; // added sub_id columns
+
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private static final boolean DEBUG = false;
+
+ private static Context sContext;
+ private static ApnDatabase sApnDatabase;
+
+ private static final String APN_DATABASE_NAME = "apn.db";
+
+ /** table for carrier APN's */
+ public static final String APN_TABLE = "apn";
+
+ // APN table
+ private static final String APN_TABLE_SQL =
+ "CREATE TABLE " + APN_TABLE +
+ "(_id INTEGER PRIMARY KEY," +
+ Telephony.Carriers.NAME + " TEXT," +
+ Telephony.Carriers.NUMERIC + " TEXT," +
+ Telephony.Carriers.MCC + " TEXT," +
+ Telephony.Carriers.MNC + " TEXT," +
+ Telephony.Carriers.APN + " TEXT," +
+ Telephony.Carriers.USER + " TEXT," +
+ Telephony.Carriers.SERVER + " TEXT," +
+ Telephony.Carriers.PASSWORD + " TEXT," +
+ Telephony.Carriers.PROXY + " TEXT," +
+ Telephony.Carriers.PORT + " TEXT," +
+ Telephony.Carriers.MMSPROXY + " TEXT," +
+ Telephony.Carriers.MMSPORT + " TEXT," +
+ Telephony.Carriers.MMSC + " TEXT," +
+ Telephony.Carriers.AUTH_TYPE + " INTEGER," +
+ Telephony.Carriers.TYPE + " TEXT," +
+ Telephony.Carriers.CURRENT + " INTEGER," +
+ Telephony.Carriers.PROTOCOL + " TEXT," +
+ Telephony.Carriers.ROAMING_PROTOCOL + " TEXT," +
+ Telephony.Carriers.CARRIER_ENABLED + " BOOLEAN," +
+ Telephony.Carriers.BEARER + " INTEGER," +
+ Telephony.Carriers.MVNO_TYPE + " TEXT," +
+ Telephony.Carriers.MVNO_MATCH_DATA + " TEXT," +
+ Telephony.Carriers.SUBSCRIPTION_ID + " INTEGER DEFAULT " +
+ ParticipantData.DEFAULT_SELF_SUB_ID + ");";
+
+ public static final String[] APN_PROJECTION = {
+ Telephony.Carriers.TYPE, // 0
+ Telephony.Carriers.MMSC, // 1
+ Telephony.Carriers.MMSPROXY, // 2
+ Telephony.Carriers.MMSPORT, // 3
+ Telephony.Carriers._ID, // 4
+ Telephony.Carriers.CURRENT, // 5
+ Telephony.Carriers.NUMERIC, // 6
+ Telephony.Carriers.NAME, // 7
+ Telephony.Carriers.MCC, // 8
+ Telephony.Carriers.MNC, // 9
+ Telephony.Carriers.APN, // 10
+ Telephony.Carriers.SUBSCRIPTION_ID // 11
+ };
+
+ public static final int COLUMN_TYPE = 0;
+ public static final int COLUMN_MMSC = 1;
+ public static final int COLUMN_MMSPROXY = 2;
+ public static final int COLUMN_MMSPORT = 3;
+ public static final int COLUMN_ID = 4;
+ public static final int COLUMN_CURRENT = 5;
+ public static final int COLUMN_NUMERIC = 6;
+ public static final int COLUMN_NAME = 7;
+ public static final int COLUMN_MCC = 8;
+ public static final int COLUMN_MNC = 9;
+ public static final int COLUMN_APN = 10;
+ public static final int COLUMN_SUB_ID = 11;
+
+ public static final String[] APN_FULL_PROJECTION = {
+ Telephony.Carriers.NAME,
+ Telephony.Carriers.MCC,
+ Telephony.Carriers.MNC,
+ Telephony.Carriers.APN,
+ Telephony.Carriers.USER,
+ Telephony.Carriers.SERVER,
+ Telephony.Carriers.PASSWORD,
+ Telephony.Carriers.PROXY,
+ Telephony.Carriers.PORT,
+ Telephony.Carriers.MMSC,
+ Telephony.Carriers.MMSPROXY,
+ Telephony.Carriers.MMSPORT,
+ Telephony.Carriers.AUTH_TYPE,
+ Telephony.Carriers.TYPE,
+ Telephony.Carriers.PROTOCOL,
+ Telephony.Carriers.ROAMING_PROTOCOL,
+ Telephony.Carriers.CARRIER_ENABLED,
+ Telephony.Carriers.BEARER,
+ Telephony.Carriers.MVNO_TYPE,
+ Telephony.Carriers.MVNO_MATCH_DATA,
+ Telephony.Carriers.CURRENT,
+ Telephony.Carriers.SUBSCRIPTION_ID,
+ };
+
+ private static final String CURRENT_SELECTION = Telephony.Carriers.CURRENT + " NOT NULL";
+
+ /**
+ * ApnDatabase is initialized asynchronously from the application.onCreate
+ * To ensure that it works in a testing environment it needs to never access the factory context
+ */
+ public static void initializeAppContext(final Context context) {
+ sContext = context;
+ }
+
+ private ApnDatabase() {
+ super(sContext, APN_DATABASE_NAME, null, DB_VERSION);
+ if (DEBUG) {
+ LogUtil.d(TAG, "ApnDatabase constructor");
+ }
+ }
+
+ public static ApnDatabase getApnDatabase() {
+ if (sApnDatabase == null) {
+ sApnDatabase = new ApnDatabase();
+ }
+ return sApnDatabase;
+ }
+
+ public static boolean doesDatabaseExist() {
+ final File dbFile = sContext.getDatabasePath(APN_DATABASE_NAME);
+ return dbFile.exists();
+ }
+
+ @Override
+ public void onCreate(final SQLiteDatabase db) {
+ if (DEBUG) {
+ LogUtil.d(TAG, "ApnDatabase onCreate");
+ }
+ // Build the table using defaults (apn info bundled with the app)
+ rebuildTables(db);
+ }
+
+ /**
+ * Get a copy of user changes in the old table
+ *
+ * @return The list of user changed apns
+ */
+ public static List<ContentValues> loadUserDataFromOldTable(final SQLiteDatabase db) {
+ Cursor cursor = null;
+ try {
+ cursor = db.query(APN_TABLE,
+ APN_FULL_PROJECTION, CURRENT_SELECTION,
+ null/*selectionArgs*/,
+ null/*groupBy*/, null/*having*/, null/*orderBy*/);
+ if (cursor != null) {
+ final List<ContentValues> result = Lists.newArrayList();
+ while (cursor.moveToNext()) {
+ final ContentValues row = cursorToValues(cursor);
+ if (row != null) {
+ result.add(row);
+ }
+ }
+ return result;
+ }
+ } catch (final SQLiteException e) {
+ LogUtil.w(TAG, "ApnDatabase.loadUserDataFromOldTable: no old user data: " + e, e);
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+
+ private static final String[] ID_PROJECTION = new String[]{Telephony.Carriers._ID};
+
+ private static final String ID_SELECTION = Telephony.Carriers._ID + "=?";
+
+ /**
+ * Store use changes of old table into the new apn table
+ *
+ * @param data The user changes
+ */
+ public static void saveUserDataFromOldTable(
+ final SQLiteDatabase db, final List<ContentValues> data) {
+ if (data == null || data.size() < 1) {
+ return;
+ }
+ for (final ContentValues row : data) {
+ // Build query from the row data. It is an exact match, column by column,
+ // except the CURRENT column
+ final StringBuilder selectionBuilder = new StringBuilder();
+ final ArrayList<String> selectionArgs = Lists.newArrayList();
+ for (final String key : row.keySet()) {
+ if (!Telephony.Carriers.CURRENT.equals(key)) {
+ if (selectionBuilder.length() > 0) {
+ selectionBuilder.append(" AND ");
+ }
+ final String value = row.getAsString(key);
+ if (TextUtils.isEmpty(value)) {
+ selectionBuilder.append(key).append(" IS NULL");
+ } else {
+ selectionBuilder.append(key).append("=?");
+ selectionArgs.add(value);
+ }
+ }
+ }
+ Cursor cursor = null;
+ try {
+ cursor = db.query(APN_TABLE,
+ ID_PROJECTION,
+ selectionBuilder.toString(),
+ selectionArgs.toArray(new String[0]),
+ null/*groupBy*/, null/*having*/, null/*orderBy*/);
+ if (cursor != null && cursor.moveToFirst()) {
+ db.update(APN_TABLE, row, ID_SELECTION, new String[]{cursor.getString(0)});
+ } else {
+ // User APN does not exist, insert into the new table
+ row.put(Telephony.Carriers.NUMERIC,
+ PhoneUtils.canonicalizeMccMnc(
+ row.getAsString(Telephony.Carriers.MCC),
+ row.getAsString(Telephony.Carriers.MNC))
+ );
+ db.insert(APN_TABLE, null/*nullColumnHack*/, row);
+ }
+ } catch (final SQLiteException e) {
+ LogUtil.e(TAG, "ApnDatabase.saveUserDataFromOldTable: query error " + e, e);
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+ }
+
+ // Convert Cursor to ContentValues
+ private static ContentValues cursorToValues(final Cursor cursor) {
+ final int columnCount = cursor.getColumnCount();
+ if (columnCount > 0) {
+ final ContentValues result = new ContentValues();
+ for (int i = 0; i < columnCount; i++) {
+ final String name = cursor.getColumnName(i);
+ final String value = cursor.getString(i);
+ result.put(name, value);
+ }
+ return result;
+ }
+ return null;
+ }
+
+ @Override
+ public void onOpen(final SQLiteDatabase db) {
+ super.onOpen(db);
+ if (DEBUG) {
+ LogUtil.d(TAG, "ApnDatabase onOpen");
+ }
+ }
+
+ @Override
+ public void close() {
+ super.close();
+ if (DEBUG) {
+ LogUtil.d(TAG, "ApnDatabase close");
+ }
+ }
+
+ private void rebuildTables(final SQLiteDatabase db) {
+ if (DEBUG) {
+ LogUtil.d(TAG, "ApnDatabase rebuildTables");
+ }
+ db.execSQL("DROP TABLE IF EXISTS " + APN_TABLE + ";");
+ db.execSQL(APN_TABLE_SQL);
+ loadApnTable(db);
+ }
+
+ @Override
+ public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
+ if (DEBUG) {
+ LogUtil.d(TAG, "ApnDatabase onUpgrade");
+ }
+ rebuildTables(db);
+ }
+
+ @Override
+ public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
+ if (DEBUG) {
+ LogUtil.d(TAG, "ApnDatabase onDowngrade");
+ }
+ rebuildTables(db);
+ }
+
+ /**
+ * Load APN table from app resources
+ */
+ private static void loadApnTable(final SQLiteDatabase db) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "ApnDatabase loadApnTable");
+ }
+ final Resources r = sContext.getResources();
+ final XmlResourceParser parser = r.getXml(R.xml.apns);
+ final ApnsXmlProcessor processor = ApnsXmlProcessor.get(parser);
+ processor.setApnHandler(new ApnsXmlProcessor.ApnHandler() {
+ @Override
+ public void process(final ContentValues apnValues) {
+ db.insert(APN_TABLE, null/*nullColumnHack*/, apnValues);
+ }
+ });
+ try {
+ processor.process();
+ } catch (final Exception e) {
+ Log.e(TAG, "Got exception while loading APN database.", e);
+ } finally {
+ parser.close();
+ }
+ }
+
+ public static void forceBuildAndLoadApnTables() {
+ final SQLiteDatabase db = getApnDatabase().getWritableDatabase();
+ db.execSQL("DROP TABLE IF EXISTS " + APN_TABLE);
+ // Table(s) always need for JB MR1 for APN support for MMS because JB MR1 throws
+ // a SecurityException when trying to access the carriers table (which holds the
+ // APNs). Some JB MR2 devices also throw the security exception, so we're building
+ // the table for JB MR2, too.
+ db.execSQL(APN_TABLE_SQL);
+
+ loadApnTable(db);
+ }
+
+ /**
+ * Clear all tables
+ */
+ public static void clearTables() {
+ final SQLiteDatabase db = getApnDatabase().getWritableDatabase();
+ db.execSQL("DROP TABLE IF EXISTS " + APN_TABLE);
+ db.execSQL(APN_TABLE_SQL);
+ }
+}
diff --git a/src/com/android/messaging/sms/ApnsXmlProcessor.java b/src/com/android/messaging/sms/ApnsXmlProcessor.java
new file mode 100644
index 0000000..976896c
--- /dev/null
+++ b/src/com/android/messaging/sms/ApnsXmlProcessor.java
@@ -0,0 +1,329 @@
+/*
+ * 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.messaging.sms;
+
+import android.content.ContentValues;
+import android.provider.Telephony;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.google.common.collect.Maps;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.IOException;
+import java.util.Map;
+
+/*
+ * XML processor for the following files:
+ * 1. res/xml/apns.xml
+ * 2. res/xml/mms_config.xml (or related overlay files)
+ */
+class ApnsXmlProcessor {
+ public interface ApnHandler {
+ public void process(ContentValues apnValues);
+ }
+
+ public interface MmsConfigHandler {
+ public void process(String mccMnc, String key, String value, String type);
+ }
+
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private static final Map<String, String> APN_ATTRIBUTE_MAP = Maps.newHashMap();
+ static {
+ APN_ATTRIBUTE_MAP.put("mcc", Telephony.Carriers.MCC);
+ APN_ATTRIBUTE_MAP.put("mnc", Telephony.Carriers.MNC);
+ APN_ATTRIBUTE_MAP.put("carrier", Telephony.Carriers.NAME);
+ APN_ATTRIBUTE_MAP.put("apn", Telephony.Carriers.APN);
+ APN_ATTRIBUTE_MAP.put("mmsc", Telephony.Carriers.MMSC);
+ APN_ATTRIBUTE_MAP.put("mmsproxy", Telephony.Carriers.MMSPROXY);
+ APN_ATTRIBUTE_MAP.put("mmsport", Telephony.Carriers.MMSPORT);
+ APN_ATTRIBUTE_MAP.put("type", Telephony.Carriers.TYPE);
+ APN_ATTRIBUTE_MAP.put("user", Telephony.Carriers.USER);
+ APN_ATTRIBUTE_MAP.put("password", Telephony.Carriers.PASSWORD);
+ APN_ATTRIBUTE_MAP.put("authtype", Telephony.Carriers.AUTH_TYPE);
+ APN_ATTRIBUTE_MAP.put("mvno_match_data", Telephony.Carriers.MVNO_MATCH_DATA);
+ APN_ATTRIBUTE_MAP.put("mvno_type", Telephony.Carriers.MVNO_TYPE);
+ APN_ATTRIBUTE_MAP.put("protocol", Telephony.Carriers.PROTOCOL);
+ APN_ATTRIBUTE_MAP.put("bearer", Telephony.Carriers.BEARER);
+ APN_ATTRIBUTE_MAP.put("server", Telephony.Carriers.SERVER);
+ APN_ATTRIBUTE_MAP.put("roaming_protocol", Telephony.Carriers.ROAMING_PROTOCOL);
+ APN_ATTRIBUTE_MAP.put("proxy", Telephony.Carriers.PROXY);
+ APN_ATTRIBUTE_MAP.put("port", Telephony.Carriers.PORT);
+ APN_ATTRIBUTE_MAP.put("carrier_enabled", Telephony.Carriers.CARRIER_ENABLED);
+ }
+
+ private static final String TAG_APNS = "apns";
+ private static final String TAG_APN = "apn";
+ private static final String TAG_MMS_CONFIG = "mms_config";
+
+ // Handler to process one apn
+ private ApnHandler mApnHandler;
+ // Handler to process one mms_config key/value pair
+ private MmsConfigHandler mMmsConfigHandler;
+
+ private final StringBuilder mLogStringBuilder = new StringBuilder();
+
+ private final XmlPullParser mInputParser;
+
+ private ApnsXmlProcessor(XmlPullParser parser) {
+ mInputParser = parser;
+ mApnHandler = null;
+ mMmsConfigHandler = null;
+ }
+
+ public static ApnsXmlProcessor get(XmlPullParser parser) {
+ Assert.notNull(parser);
+ return new ApnsXmlProcessor(parser);
+ }
+
+ public ApnsXmlProcessor setApnHandler(ApnHandler handler) {
+ mApnHandler = handler;
+ return this;
+ }
+
+ public ApnsXmlProcessor setMmsConfigHandler(MmsConfigHandler handler) {
+ mMmsConfigHandler = handler;
+ return this;
+ }
+
+ /**
+ * Move XML parser forward to next event type or the end of doc
+ *
+ * @param eventType
+ * @return The final event type we meet
+ * @throws XmlPullParserException
+ * @throws IOException
+ */
+ private int advanceToNextEvent(int eventType) throws XmlPullParserException, IOException {
+ for (;;) {
+ int nextEvent = mInputParser.next();
+ if (nextEvent == eventType
+ || nextEvent == XmlPullParser.END_DOCUMENT) {
+ return nextEvent;
+ }
+ }
+ }
+
+ public void process() {
+ try {
+ // Find the first element
+ if (advanceToNextEvent(XmlPullParser.START_TAG) != XmlPullParser.START_TAG) {
+ throw new XmlPullParserException("ApnsXmlProcessor: expecting start tag @"
+ + xmlParserDebugContext());
+ }
+ // A single ContentValues object for holding the parsing result of
+ // an apn element
+ final ContentValues values = new ContentValues();
+ String tagName = mInputParser.getName();
+ // Top level tag can be "apns" (apns.xml)
+ // or "mms_config" (mms_config.xml)
+ if (TAG_APNS.equals(tagName)) {
+ // For "apns", there could be "apn" or both "apn" and "mms_config"
+ for (;;) {
+ if (advanceToNextEvent(XmlPullParser.START_TAG) != XmlPullParser.START_TAG) {
+ break;
+ }
+ tagName = mInputParser.getName();
+ if (TAG_APN.equals(tagName)) {
+ processApn(values);
+ } else if (TAG_MMS_CONFIG.equals(tagName)) {
+ processMmsConfig();
+ }
+ }
+ } else if (TAG_MMS_CONFIG.equals(tagName)) {
+ // mms_config.xml resource
+ processMmsConfig();
+ }
+ } catch (IOException e) {
+ LogUtil.e(TAG, "ApnsXmlProcessor: I/O failure " + e, e);
+ } catch (XmlPullParserException e) {
+ LogUtil.e(TAG, "ApnsXmlProcessor: parsing failure " + e, e);
+ }
+ }
+
+ private Integer parseInt(String text, Integer defaultValue, String logHint) {
+ Integer value = defaultValue;
+ try {
+ value = Integer.parseInt(text);
+ } catch (Exception e) {
+ LogUtil.e(TAG,
+ "Invalid value " + text + "for" + logHint + " @" + xmlParserDebugContext());
+ }
+ return value;
+ }
+
+ private Boolean parseBoolean(String text, Boolean defaultValue, String logHint) {
+ Boolean value = defaultValue;
+ try {
+ value = Boolean.parseBoolean(text);
+ } catch (Exception e) {
+ LogUtil.e(TAG,
+ "Invalid value " + text + "for" + logHint + " @" + xmlParserDebugContext());
+ }
+ return value;
+ }
+
+ private static String xmlParserEventString(int event) {
+ switch (event) {
+ case XmlPullParser.START_DOCUMENT: return "START_DOCUMENT";
+ case XmlPullParser.END_DOCUMENT: return "END_DOCUMENT";
+ case XmlPullParser.START_TAG: return "START_TAG";
+ case XmlPullParser.END_TAG: return "END_TAG";
+ case XmlPullParser.TEXT: return "TEXT";
+ }
+ return Integer.toString(event);
+ }
+
+ /**
+ * @return The debugging information of the parser's current position
+ */
+ private String xmlParserDebugContext() {
+ mLogStringBuilder.setLength(0);
+ if (mInputParser != null) {
+ try {
+ final int eventType = mInputParser.getEventType();
+ mLogStringBuilder.append(xmlParserEventString(eventType));
+ if (eventType == XmlPullParser.START_TAG
+ || eventType == XmlPullParser.END_TAG
+ || eventType == XmlPullParser.TEXT) {
+ mLogStringBuilder.append('<').append(mInputParser.getName());
+ for (int i = 0; i < mInputParser.getAttributeCount(); i++) {
+ mLogStringBuilder.append(' ')
+ .append(mInputParser.getAttributeName(i))
+ .append('=')
+ .append(mInputParser.getAttributeValue(i));
+ }
+ mLogStringBuilder.append("/>");
+ }
+ return mLogStringBuilder.toString();
+ } catch (XmlPullParserException e) {
+ LogUtil.e(TAG, "xmlParserDebugContext: " + e, e);
+ }
+ }
+ return "Unknown";
+ }
+
+ /**
+ * Process one apn
+ *
+ * @param apnValues Where we store the parsed apn
+ * @throws IOException
+ * @throws XmlPullParserException
+ */
+ private void processApn(ContentValues apnValues) throws IOException, XmlPullParserException {
+ Assert.notNull(apnValues);
+ apnValues.clear();
+ // Collect all the attributes
+ for (int i = 0; i < mInputParser.getAttributeCount(); i++) {
+ final String key = APN_ATTRIBUTE_MAP.get(mInputParser.getAttributeName(i));
+ if (key != null) {
+ apnValues.put(key, mInputParser.getAttributeValue(i));
+ }
+ }
+ // Set numeric to be canonicalized mcc/mnc like "310120", always 6 digits
+ final String canonicalMccMnc = PhoneUtils.canonicalizeMccMnc(
+ apnValues.getAsString(Telephony.Carriers.MCC),
+ apnValues.getAsString(Telephony.Carriers.MNC));
+ apnValues.put(Telephony.Carriers.NUMERIC, canonicalMccMnc);
+ // Some of the values should not be string type, converting them to desired types
+ final String authType = apnValues.getAsString(Telephony.Carriers.AUTH_TYPE);
+ if (authType != null) {
+ apnValues.put(Telephony.Carriers.AUTH_TYPE, parseInt(authType, -1, "apn authtype"));
+ }
+ final String carrierEnabled = apnValues.getAsString(Telephony.Carriers.CARRIER_ENABLED);
+ if (carrierEnabled != null) {
+ apnValues.put(Telephony.Carriers.CARRIER_ENABLED,
+ parseBoolean(carrierEnabled, null, "apn carrierEnabled"));
+ }
+ final String bearer = apnValues.getAsString(Telephony.Carriers.BEARER);
+ if (bearer != null) {
+ apnValues.put(Telephony.Carriers.BEARER, parseInt(bearer, 0, "apn bearer"));
+ }
+ // We are at the end tag
+ if (mInputParser.next() != XmlPullParser.END_TAG) {
+ throw new XmlPullParserException("Apn: expecting end tag @"
+ + xmlParserDebugContext());
+ }
+ // We are done parsing one APN, call the handler
+ if (mApnHandler != null) {
+ mApnHandler.process(apnValues);
+ }
+ }
+
+ /**
+ * Process one mms_config.
+ *
+ * @throws IOException
+ * @throws XmlPullParserException
+ */
+ private void processMmsConfig()
+ throws IOException, XmlPullParserException {
+ // Get the mcc and mnc attributes
+ final String canonicalMccMnc = PhoneUtils.canonicalizeMccMnc(
+ mInputParser.getAttributeValue(null, "mcc"),
+ mInputParser.getAttributeValue(null, "mnc"));
+ // We are at the start tag
+ for (;;) {
+ int nextEvent;
+ // Skipping spaces
+ while ((nextEvent = mInputParser.next()) == XmlPullParser.TEXT) {
+ }
+ if (nextEvent == XmlPullParser.START_TAG) {
+ // Parse one mms config key/value
+ processMmsConfigKeyValue(canonicalMccMnc);
+ } else if (nextEvent == XmlPullParser.END_TAG) {
+ break;
+ } else {
+ throw new XmlPullParserException("MmsConfig: expecting start or end tag @"
+ + xmlParserDebugContext());
+ }
+ }
+ }
+
+ /**
+ * Process one mms_config key/value pair
+ *
+ * @param mccMnc The mcc and mnc of this mms_config
+ * @throws IOException
+ * @throws XmlPullParserException
+ */
+ private void processMmsConfigKeyValue(String mccMnc)
+ throws IOException, XmlPullParserException {
+ final String key = mInputParser.getAttributeValue(null, "name");
+ // We are at the start tag, the name of the tag is the type
+ // e.g. <int name="key">value</int>
+ final String type = mInputParser.getName();
+ int nextEvent = mInputParser.next();
+ String value = null;
+ if (nextEvent == XmlPullParser.TEXT) {
+ value = mInputParser.getText();
+ nextEvent = mInputParser.next();
+ }
+ if (nextEvent != XmlPullParser.END_TAG) {
+ throw new XmlPullParserException("ApnsXmlProcessor: expecting end tag @"
+ + xmlParserDebugContext());
+ }
+ // We are done parsing one mms_config key/value, call the handler
+ if (mMmsConfigHandler != null) {
+ mMmsConfigHandler.process(mccMnc, key, value, type);
+ }
+ }
+}
diff --git a/src/com/android/messaging/sms/BugleApnSettingsLoader.java b/src/com/android/messaging/sms/BugleApnSettingsLoader.java
new file mode 100644
index 0000000..43c95ac
--- /dev/null
+++ b/src/com/android/messaging/sms/BugleApnSettingsLoader.java
@@ -0,0 +1,646 @@
+/*
+ * 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.messaging.sms;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.net.Uri;
+import android.provider.Telephony;
+import android.support.v7.mms.ApnSettingsLoader;
+import android.support.v7.mms.MmsManager;
+import android.text.TextUtils;
+import android.util.SparseArray;
+
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.mmslib.SqliteWrapper;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * APN loader for default SMS SIM
+ *
+ * This loader tries to load APNs from 3 sources in order:
+ * 1. Gservices setting
+ * 2. System APN table
+ * 3. Local APN table
+ */
+public class BugleApnSettingsLoader implements ApnSettingsLoader {
+ /**
+ * The base implementation of an APN
+ */
+ private static class BaseApn implements Apn {
+ /**
+ * Create a base APN from parameters
+ *
+ * @param typesIn the APN type field
+ * @param mmscIn the APN mmsc field
+ * @param proxyIn the APN mmsproxy field
+ * @param portIn the APN mmsport field
+ * @return an instance of base APN, or null if any of the parameter is invalid
+ */
+ public static BaseApn from(final String typesIn, final String mmscIn, final String proxyIn,
+ final String portIn) {
+ if (!isValidApnType(trimWithNullCheck(typesIn), APN_TYPE_MMS)) {
+ return null;
+ }
+ String mmsc = trimWithNullCheck(mmscIn);
+ if (TextUtils.isEmpty(mmsc)) {
+ return null;
+ }
+ mmsc = trimV4AddrZeros(mmsc);
+ try {
+ new URI(mmsc);
+ } catch (final URISyntaxException e) {
+ return null;
+ }
+ String mmsProxy = trimWithNullCheck(proxyIn);
+ int mmsProxyPort = 80;
+ if (!TextUtils.isEmpty(mmsProxy)) {
+ mmsProxy = trimV4AddrZeros(mmsProxy);
+ final String portString = trimWithNullCheck(portIn);
+ if (portString != null) {
+ try {
+ mmsProxyPort = Integer.parseInt(portString);
+ } catch (final NumberFormatException e) {
+ // Ignore, just use 80 to try
+ }
+ }
+ }
+ return new BaseApn(mmsc, mmsProxy, mmsProxyPort);
+ }
+
+ private final String mMmsc;
+ private final String mMmsProxy;
+ private final int mMmsProxyPort;
+
+ public BaseApn(final String mmsc, final String proxy, final int port) {
+ mMmsc = mmsc;
+ mMmsProxy = proxy;
+ mMmsProxyPort = port;
+ }
+
+ @Override
+ public String getMmsc() {
+ return mMmsc;
+ }
+
+ @Override
+ public String getMmsProxy() {
+ return mMmsProxy;
+ }
+
+ @Override
+ public int getMmsProxyPort() {
+ return mMmsProxyPort;
+ }
+
+ @Override
+ public void setSuccess() {
+ // Do nothing
+ }
+
+ public boolean equals(final BaseApn other) {
+ return TextUtils.equals(mMmsc, other.getMmsc()) &&
+ TextUtils.equals(mMmsProxy, other.getMmsProxy()) &&
+ mMmsProxyPort == other.getMmsProxyPort();
+ }
+ }
+
+ /**
+ * The APN represented by the local APN table row
+ */
+ private static class DatabaseApn implements Apn {
+ private static final ContentValues CURRENT_NULL_VALUE;
+ private static final ContentValues CURRENT_SET_VALUE;
+ static {
+ CURRENT_NULL_VALUE = new ContentValues(1);
+ CURRENT_NULL_VALUE.putNull(Telephony.Carriers.CURRENT);
+ CURRENT_SET_VALUE = new ContentValues(1);
+ CURRENT_SET_VALUE.put(Telephony.Carriers.CURRENT, "1"); // 1 for auto selected APN
+ }
+ private static final String CLEAR_UPDATE_SELECTION = Telephony.Carriers.CURRENT + " =?";
+ private static final String[] CLEAR_UPDATE_SELECTION_ARGS = new String[] { "1" };
+ private static final String SET_UPDATE_SELECTION = Telephony.Carriers._ID + " =?";
+
+ /**
+ * Create an APN loaded from local database
+ *
+ * @param apns the in-memory APN list
+ * @param typesIn the APN type field
+ * @param mmscIn the APN mmsc field
+ * @param proxyIn the APN mmsproxy field
+ * @param portIn the APN mmsport field
+ * @param rowId the APN's row ID in database
+ * @param current the value of CURRENT column in database
+ * @return an in-memory APN instance for database APN row, null if parameter invalid
+ */
+ public static DatabaseApn from(final List<Apn> apns, final String typesIn,
+ final String mmscIn, final String proxyIn, final String portIn,
+ final long rowId, final int current) {
+ if (apns == null) {
+ return null;
+ }
+ final BaseApn base = BaseApn.from(typesIn, mmscIn, proxyIn, portIn);
+ if (base == null) {
+ return null;
+ }
+ for (final ApnSettingsLoader.Apn apn : apns) {
+ if (apn instanceof DatabaseApn && ((DatabaseApn) apn).equals(base)) {
+ return null;
+ }
+ }
+ return new DatabaseApn(apns, base, rowId, current);
+ }
+
+ private final List<Apn> mApns;
+ private final BaseApn mBase;
+ private final long mRowId;
+ private int mCurrent;
+
+ public DatabaseApn(final List<Apn> apns, final BaseApn base, final long rowId,
+ final int current) {
+ mApns = apns;
+ mBase = base;
+ mRowId = rowId;
+ mCurrent = current;
+ }
+
+ @Override
+ public String getMmsc() {
+ return mBase.getMmsc();
+ }
+
+ @Override
+ public String getMmsProxy() {
+ return mBase.getMmsProxy();
+ }
+
+ @Override
+ public int getMmsProxyPort() {
+ return mBase.getMmsProxyPort();
+ }
+
+ @Override
+ public void setSuccess() {
+ moveToListHead();
+ setCurrentInDatabase();
+ }
+
+ /**
+ * Try to move this APN to the head of in-memory list
+ */
+ private void moveToListHead() {
+ // If this is being marked as a successful APN, move it to the top of the list so
+ // next time it will be tried first
+ boolean moved = false;
+ synchronized (mApns) {
+ if (mApns.get(0) != this) {
+ mApns.remove(this);
+ mApns.add(0, this);
+ moved = true;
+ }
+ }
+ if (moved) {
+ LogUtil.d(LogUtil.BUGLE_TAG, "Set APN ["
+ + "MMSC=" + getMmsc() + ", "
+ + "PROXY=" + getMmsProxy() + ", "
+ + "PORT=" + getMmsProxyPort() + "] to be first");
+ }
+ }
+
+ /**
+ * Try to set the APN to be CURRENT in its database table
+ */
+ private void setCurrentInDatabase() {
+ synchronized (this) {
+ if (mCurrent > 0) {
+ // Already current
+ return;
+ }
+ mCurrent = 1;
+ }
+ LogUtil.d(LogUtil.BUGLE_TAG, "Set APN @" + mRowId + " to be CURRENT in local db");
+ final SQLiteDatabase database = ApnDatabase.getApnDatabase().getWritableDatabase();
+ database.beginTransaction();
+ try {
+ // clear the previous current=1 apn
+ // we don't clear current=2 apn since it is manually selected by user
+ // and we should not override it.
+ database.update(ApnDatabase.APN_TABLE, CURRENT_NULL_VALUE,
+ CLEAR_UPDATE_SELECTION, CLEAR_UPDATE_SELECTION_ARGS);
+ // set this one to be current (1)
+ database.update(ApnDatabase.APN_TABLE, CURRENT_SET_VALUE, SET_UPDATE_SELECTION,
+ new String[] { Long.toString(mRowId) });
+ database.setTransactionSuccessful();
+ } finally {
+ database.endTransaction();
+ }
+ }
+
+ public boolean equals(final BaseApn other) {
+ if (other == null) {
+ return false;
+ }
+ return mBase.equals(other);
+ }
+ }
+
+ /**
+ * APN_TYPE_ALL is a special type to indicate that this APN entry can
+ * service all data connections.
+ */
+ public static final String APN_TYPE_ALL = "*";
+ /** APN type for MMS traffic */
+ public static final String APN_TYPE_MMS = "mms";
+
+ private static final String[] APN_PROJECTION_SYSTEM = {
+ Telephony.Carriers.TYPE,
+ Telephony.Carriers.MMSC,
+ Telephony.Carriers.MMSPROXY,
+ Telephony.Carriers.MMSPORT,
+ };
+ private static final String[] APN_PROJECTION_LOCAL = {
+ Telephony.Carriers.TYPE,
+ Telephony.Carriers.MMSC,
+ Telephony.Carriers.MMSPROXY,
+ Telephony.Carriers.MMSPORT,
+ Telephony.Carriers.CURRENT,
+ Telephony.Carriers._ID,
+ };
+ private static final int COLUMN_TYPE = 0;
+ private static final int COLUMN_MMSC = 1;
+ private static final int COLUMN_MMSPROXY = 2;
+ private static final int COLUMN_MMSPORT = 3;
+ private static final int COLUMN_CURRENT = 4;
+ private static final int COLUMN_ID = 5;
+
+ private static final String SELECTION_APN = Telephony.Carriers.APN + "=?";
+ private static final String SELECTION_CURRENT = Telephony.Carriers.CURRENT + " IS NOT NULL";
+ private static final String SELECTION_NUMERIC = Telephony.Carriers.NUMERIC + "=?";
+ private static final String ORDER_BY = Telephony.Carriers.CURRENT + " DESC";
+
+ private final Context mContext;
+
+ // Cached APNs for subIds
+ private final SparseArray<List<ApnSettingsLoader.Apn>> mApnsCache;
+
+ public BugleApnSettingsLoader(final Context context) {
+ mContext = context;
+ mApnsCache = new SparseArray<>();
+ }
+
+ @Override
+ public List<ApnSettingsLoader.Apn> get(final String apnName) {
+ final int subId = PhoneUtils.getDefault().getEffectiveSubId(
+ ParticipantData.DEFAULT_SELF_SUB_ID);
+ List<ApnSettingsLoader.Apn> apns;
+ boolean didLoad = false;
+ synchronized (this) {
+ apns = mApnsCache.get(subId);
+ if (apns == null) {
+ apns = new ArrayList<>();
+ mApnsCache.put(subId, apns);
+ loadLocked(subId, apnName, apns);
+ didLoad = true;
+ }
+ }
+ if (didLoad) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "Loaded " + apns.size() + " APNs");
+ }
+ return apns;
+ }
+
+ private void loadLocked(final int subId, final String apnName, final List<Apn> apns) {
+ // Try Gservices first
+ loadFromGservices(apns);
+ if (apns.size() > 0) {
+ return;
+ }
+ // Try system APN table
+ loadFromSystem(subId, apnName, apns);
+ if (apns.size() > 0) {
+ return;
+ }
+ // Try local APN table
+ loadFromLocalDatabase(apnName, apns);
+ if (apns.size() <= 0) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Failed to load any APN");
+ }
+ }
+
+ /**
+ * Load from Gservices if APN setting is set in Gservices
+ *
+ * @param apns the list used to return results
+ */
+ private void loadFromGservices(final List<Apn> apns) {
+ final BugleGservices gservices = BugleGservices.get();
+ final String mmsc = gservices.getString(BugleGservicesKeys.MMS_MMSC, null);
+ if (TextUtils.isEmpty(mmsc)) {
+ return;
+ }
+ LogUtil.i(LogUtil.BUGLE_TAG, "Loading APNs from gservices");
+ final String proxy = gservices.getString(BugleGservicesKeys.MMS_PROXY_ADDRESS, null);
+ final int port = gservices.getInt(BugleGservicesKeys.MMS_PROXY_PORT, -1);
+ final Apn apn = BaseApn.from("mms", mmsc, proxy, Integer.toString(port));
+ if (apn != null) {
+ apns.add(apn);
+ }
+ }
+
+ /**
+ * Load matching APNs from telephony provider.
+ * We try different combinations of the query to work around some platform quirks.
+ *
+ * @param subId the SIM subId
+ * @param apnName the APN name to match
+ * @param apns the list used to return results
+ */
+ private void loadFromSystem(final int subId, final String apnName, final List<Apn> apns) {
+ Uri uri;
+ if (OsUtil.isAtLeastL_MR1() && subId != MmsManager.DEFAULT_SUB_ID) {
+ uri = Uri.withAppendedPath(Telephony.Carriers.CONTENT_URI, "/subId/" + subId);
+ } else {
+ uri = Telephony.Carriers.CONTENT_URI;
+ }
+ Cursor cursor = null;
+ try {
+ for (; ; ) {
+ // Try different combinations of queries. Some would work on some platforms.
+ // So we query each combination until we find one returns non-empty result.
+ cursor = querySystem(uri, true/*checkCurrent*/, apnName);
+ if (cursor != null) {
+ break;
+ }
+ cursor = querySystem(uri, false/*checkCurrent*/, apnName);
+ if (cursor != null) {
+ break;
+ }
+ cursor = querySystem(uri, true/*checkCurrent*/, null/*apnName*/);
+ if (cursor != null) {
+ break;
+ }
+ cursor = querySystem(uri, false/*checkCurrent*/, null/*apnName*/);
+ break;
+ }
+ } catch (final SecurityException e) {
+ // Can't access platform APN table, return directly
+ return;
+ }
+ if (cursor == null) {
+ return;
+ }
+ try {
+ if (cursor.moveToFirst()) {
+ final ApnSettingsLoader.Apn apn = BaseApn.from(
+ cursor.getString(COLUMN_TYPE),
+ cursor.getString(COLUMN_MMSC),
+ cursor.getString(COLUMN_MMSPROXY),
+ cursor.getString(COLUMN_MMSPORT));
+ if (apn != null) {
+ apns.add(apn);
+ }
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Query system APN table
+ *
+ * @param uri The APN query URL to use
+ * @param checkCurrent If add "CURRENT IS NOT NULL" condition
+ * @param apnName The optional APN name for query condition
+ * @return A cursor of the query result. If a cursor is returned as not null, it is
+ * guaranteed to contain at least one row.
+ */
+ private Cursor querySystem(final Uri uri, final boolean checkCurrent, String apnName) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "Loading APNs from system, "
+ + "checkCurrent=" + checkCurrent + " apnName=" + apnName);
+ final StringBuilder selectionBuilder = new StringBuilder();
+ String[] selectionArgs = null;
+ if (checkCurrent) {
+ selectionBuilder.append(SELECTION_CURRENT);
+ }
+ apnName = trimWithNullCheck(apnName);
+ if (!TextUtils.isEmpty(apnName)) {
+ if (selectionBuilder.length() > 0) {
+ selectionBuilder.append(" AND ");
+ }
+ selectionBuilder.append(SELECTION_APN);
+ selectionArgs = new String[] { apnName };
+ }
+ try {
+ final Cursor cursor = SqliteWrapper.query(
+ mContext,
+ mContext.getContentResolver(),
+ uri,
+ APN_PROJECTION_SYSTEM,
+ selectionBuilder.toString(),
+ selectionArgs,
+ null/*sortOrder*/);
+ if (cursor == null || cursor.getCount() < 1) {
+ if (cursor != null) {
+ cursor.close();
+ }
+ LogUtil.w(LogUtil.BUGLE_TAG, "Query " + uri + " with apn " + apnName + " and "
+ + (checkCurrent ? "checking CURRENT" : "not checking CURRENT")
+ + " returned empty");
+ return null;
+ }
+ return cursor;
+ } catch (final SQLiteException e) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "APN table query exception: " + e);
+ } catch (final SecurityException e) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Platform restricts APN table access: " + e);
+ throw e;
+ }
+ return null;
+ }
+
+ /**
+ * Load matching APNs from local APN table.
+ * We try both using the APN name and not using the APN name.
+ *
+ * @param apnName the APN name
+ * @param apns the list of results to return
+ */
+ private void loadFromLocalDatabase(final String apnName, final List<Apn> apns) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "Loading APNs from local APN table");
+ final SQLiteDatabase database = ApnDatabase.getApnDatabase().getWritableDatabase();
+ final String mccMnc = PhoneUtils.getMccMncString(PhoneUtils.getDefault().getMccMnc());
+ Cursor cursor = null;
+ cursor = queryLocalDatabase(database, mccMnc, apnName);
+ if (cursor == null) {
+ cursor = queryLocalDatabase(database, mccMnc, null/*apnName*/);
+ }
+ if (cursor == null) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Could not find any APN in local table");
+ return;
+ }
+ try {
+ while (cursor.moveToNext()) {
+ final Apn apn = DatabaseApn.from(apns,
+ cursor.getString(COLUMN_TYPE),
+ cursor.getString(COLUMN_MMSC),
+ cursor.getString(COLUMN_MMSPROXY),
+ cursor.getString(COLUMN_MMSPORT),
+ cursor.getLong(COLUMN_ID),
+ cursor.getInt(COLUMN_CURRENT));
+ if (apn != null) {
+ apns.add(apn);
+ }
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Make a query of local APN table based on MCC/MNC and APN name, sorted by CURRENT
+ * column in descending order
+ *
+ * @param db the local database
+ * @param numeric the MCC/MNC string
+ * @param apnName the optional APN name to match
+ * @return the cursor of the query, null if no result
+ */
+ private static Cursor queryLocalDatabase(final SQLiteDatabase db, final String numeric,
+ final String apnName) {
+ final String selection;
+ final String[] selectionArgs;
+ if (TextUtils.isEmpty(apnName)) {
+ selection = SELECTION_NUMERIC;
+ selectionArgs = new String[] { numeric };
+ } else {
+ selection = SELECTION_NUMERIC + " AND " + SELECTION_APN;
+ selectionArgs = new String[] { numeric, apnName };
+ }
+ Cursor cursor = null;
+ try {
+ cursor = db.query(ApnDatabase.APN_TABLE, APN_PROJECTION_LOCAL, selection, selectionArgs,
+ null/*groupBy*/, null/*having*/, ORDER_BY, null/*limit*/);
+ } catch (final SQLiteException e) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Local APN table does not exist. Try rebuilding.", e);
+ ApnDatabase.forceBuildAndLoadApnTables();
+ cursor = db.query(ApnDatabase.APN_TABLE, APN_PROJECTION_LOCAL, selection, selectionArgs,
+ null/*groupBy*/, null/*having*/, ORDER_BY, null/*limit*/);
+ }
+ if (cursor == null || cursor.getCount() < 1) {
+ if (cursor != null) {
+ cursor.close();
+ }
+ LogUtil.w(LogUtil.BUGLE_TAG, "Query local APNs with apn " + apnName
+ + " returned empty");
+ return null;
+ }
+ return cursor;
+ }
+
+ private static String trimWithNullCheck(final String value) {
+ return value != null ? value.trim() : null;
+ }
+
+ /**
+ * Trim leading zeros from IPv4 address strings
+ * Our base libraries will interpret that as octel..
+ * Must leave non v4 addresses and host names alone.
+ * For example, 192.168.000.010 -> 192.168.0.10
+ *
+ * @param addr a string representing an ip addr
+ * @return a string propertly trimmed
+ */
+ private static String trimV4AddrZeros(final String addr) {
+ if (addr == null) {
+ return null;
+ }
+ final String[] octets = addr.split("\\.");
+ if (octets.length != 4) {
+ return addr;
+ }
+ final StringBuilder builder = new StringBuilder(16);
+ String result = null;
+ for (int i = 0; i < 4; i++) {
+ try {
+ if (octets[i].length() > 3) {
+ return addr;
+ }
+ builder.append(Integer.parseInt(octets[i]));
+ } catch (final NumberFormatException e) {
+ return addr;
+ }
+ if (i < 3) {
+ builder.append('.');
+ }
+ }
+ result = builder.toString();
+ return result;
+ }
+
+ /**
+ * Check if the APN contains the APN type we want
+ *
+ * @param types The string encodes a list of supported types
+ * @param requestType The type we want
+ * @return true if the input types string contains the requestType
+ */
+ public static boolean isValidApnType(final String types, final String requestType) {
+ // If APN type is unspecified, assume APN_TYPE_ALL.
+ if (TextUtils.isEmpty(types)) {
+ return true;
+ }
+ for (final String t : types.split(",")) {
+ if (t.equals(requestType) || t.equals(APN_TYPE_ALL)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get the ID of first APN to try
+ */
+ public static String getFirstTryApn(final SQLiteDatabase database, final String mccMnc) {
+ String key = null;
+ Cursor cursor = null;
+ try {
+ cursor = queryLocalDatabase(database, mccMnc, null/*apnName*/);
+ if (cursor.moveToFirst()) {
+ key = cursor.getString(ApnDatabase.COLUMN_ID);
+ }
+ } catch (final Exception e) {
+ // Nothing to do
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return key;
+ }
+}
diff --git a/src/com/android/messaging/sms/BugleCarrierConfigValuesLoader.java b/src/com/android/messaging/sms/BugleCarrierConfigValuesLoader.java
new file mode 100644
index 0000000..ac6c7e4
--- /dev/null
+++ b/src/com/android/messaging/sms/BugleCarrierConfigValuesLoader.java
@@ -0,0 +1,201 @@
+/*
+ * 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.messaging.sms;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.content.res.XmlResourceParser;
+import android.os.Bundle;
+import android.support.v7.mms.CarrierConfigValuesLoader;
+import android.util.SparseArray;
+
+import com.android.messaging.R;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+/**
+ * Carrier configuration loader
+ *
+ * Loader tries to load from resources. If there is MMS API available, also
+ * load from system.
+ */
+public class BugleCarrierConfigValuesLoader implements CarrierConfigValuesLoader {
+ /*
+ * Key types
+ */
+ public static final String KEY_TYPE_INT = "int";
+ public static final String KEY_TYPE_BOOL = "bool";
+ public static final String KEY_TYPE_STRING = "string";
+
+ private final Context mContext;
+
+ // Cached values for subIds
+ private final SparseArray<Bundle> mValuesCache;
+
+ public BugleCarrierConfigValuesLoader(final Context context) {
+ mContext = context;
+ mValuesCache = new SparseArray<>();
+ }
+
+ @Override
+ public Bundle get(int subId) {
+ subId = PhoneUtils.getDefault().getEffectiveSubId(subId);
+ Bundle values;
+ String loadSource = null;
+ synchronized (this) {
+ values = mValuesCache.get(subId);
+ if (values == null) {
+ values = new Bundle();
+ mValuesCache.put(subId, values);
+ loadSource = loadLocked(subId, values);
+ }
+ }
+ if (loadSource != null) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "Carrier configs loaded: " + values
+ + " from " + loadSource + " for subId=" + subId);
+ }
+ return values;
+ }
+
+ /**
+ * Clear the cache for reloading
+ */
+ public void reset() {
+ synchronized (this) {
+ mValuesCache.clear();
+ }
+ }
+
+ /**
+ * Loading carrier config values
+ *
+ * @param subId which SIM to load for
+ * @param values the result to add to
+ * @return the source of the config, could be "resources" or "resources+system"
+ */
+ private String loadLocked(final int subId, final Bundle values) {
+ // Load from resources in earlier platform
+ loadFromResources(subId, values);
+ if (OsUtil.isAtLeastL()) {
+ // Load from system to override if system API exists
+ loadFromSystem(subId, values);
+ return "resources+system";
+ }
+ return "resources";
+ }
+
+ /**
+ * Load from system, using MMS API
+ *
+ * @param subId which SIM to load for
+ * @param values the result to add to
+ */
+ private static void loadFromSystem(final int subId, final Bundle values) {
+ try {
+ final Bundle systemValues =
+ PhoneUtils.get(subId).getSmsManager().getCarrierConfigValues();
+ if (systemValues != null) {
+ values.putAll(systemValues);
+ }
+ } catch (final Exception e) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Calling system getCarrierConfigValues exception", e);
+ }
+ }
+
+ /**
+ * Load from SIM-dependent resources
+ *
+ * @param subId which SIM to load for
+ * @param values the result to add to
+ */
+ private void loadFromResources(final int subId, final Bundle values) {
+ // Get a subscription-dependent context for loading the mms_config.xml
+ final Context subContext = getSubDepContext(mContext, subId);
+ // Load and parse the XML
+ XmlResourceParser parser = null;
+ try {
+ parser = subContext.getResources().getXml(R.xml.mms_config);
+ final ApnsXmlProcessor processor = ApnsXmlProcessor.get(parser);
+ processor.setMmsConfigHandler(new ApnsXmlProcessor.MmsConfigHandler() {
+ @Override
+ public void process(final String mccMnc, final String key, final String value,
+ final String type) {
+ update(values, type, key, value);
+ }
+ });
+ processor.process();
+ } catch (final Resources.NotFoundException e) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Can not find mms_config.xml");
+ } finally {
+ if (parser != null) {
+ parser.close();
+ }
+ }
+ }
+
+ /**
+ * Get a subscription's Context so we can load resources from it
+ *
+ * @param context the sub-independent Context
+ * @param subId the SIM's subId
+ * @return the sub-dependent Context
+ */
+ private static Context getSubDepContext(final Context context, final int subId) {
+ if (!OsUtil.isAtLeastL_MR1()) {
+ return context;
+ }
+ final int[] mccMnc = PhoneUtils.get(subId).getMccMnc();
+ final int mcc = mccMnc[0];
+ final int mnc = mccMnc[1];
+ final Configuration subConfig = new Configuration();
+ if (mcc == 0 && mnc == 0) {
+ Configuration config = context.getResources().getConfiguration();
+ subConfig.mcc = config.mcc;
+ subConfig.mnc = config.mnc;
+ } else {
+ subConfig.mcc = mcc;
+ subConfig.mnc = mnc;
+ }
+ return context.createConfigurationContext(subConfig);
+ }
+
+ /**
+ * Add or update a carrier config key/value pair to the Bundle
+ *
+ * @param values the result Bundle to add to
+ * @param type the value type
+ * @param key the key
+ * @param value the value
+ */
+ public static void update(final Bundle values, final String type, final String key,
+ final String value) {
+ try {
+ if (KEY_TYPE_INT.equals(type)) {
+ values.putInt(key, Integer.parseInt(value));
+ } else if (KEY_TYPE_BOOL.equals(type)) {
+ values.putBoolean(key, Boolean.parseBoolean(value));
+ } else if (KEY_TYPE_STRING.equals(type)){
+ values.putString(key, value);
+ }
+ } catch (final NumberFormatException e) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Add carrier values: "
+ + "invalid " + key + "," + value + "," + type);
+ }
+ }
+}
diff --git a/src/com/android/messaging/sms/BugleUserAgentInfoLoader.java b/src/com/android/messaging/sms/BugleUserAgentInfoLoader.java
new file mode 100644
index 0000000..a8dc5c4
--- /dev/null
+++ b/src/com/android/messaging/sms/BugleUserAgentInfoLoader.java
@@ -0,0 +1,96 @@
+/*
+ * 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.messaging.sms;
+
+import android.content.Context;
+import android.support.v7.mms.UserAgentInfoLoader;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.VersionUtil;
+
+/**
+ * User agent and UA profile URL loader
+ */
+public class BugleUserAgentInfoLoader implements UserAgentInfoLoader {
+ private static final String DEFAULT_USER_AGENT_PREFIX = "Bugle/";
+
+ private Context mContext;
+ private boolean mLoaded;
+
+ private String mUserAgent;
+ private String mUAProfUrl;
+
+ public BugleUserAgentInfoLoader(final Context context) {
+ mContext = context;
+ }
+
+ @Override
+ public String getUserAgent() {
+ load();
+ return mUserAgent;
+ }
+
+ @Override
+ public String getUAProfUrl() {
+ load();
+ return mUAProfUrl;
+ }
+
+ private void load() {
+ if (mLoaded) {
+ return;
+ }
+ boolean didLoad = false;
+ synchronized (this) {
+ if (!mLoaded) {
+ loadLocked();
+ mLoaded = true;
+ didLoad = true;
+ }
+ }
+ if (didLoad) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "Loaded user agent info: "
+ + "UA=" + mUserAgent + ", UAProfUrl=" + mUAProfUrl);
+ }
+ }
+
+ private void loadLocked() {
+ if (OsUtil.isAtLeastKLP()) {
+ // load the MMS User agent and UaProfUrl from TelephonyManager APIs
+ final TelephonyManager telephonyManager = (TelephonyManager) mContext.getSystemService(
+ Context.TELEPHONY_SERVICE);
+ mUserAgent = telephonyManager.getMmsUserAgent();
+ mUAProfUrl = telephonyManager.getMmsUAProfUrl();
+ }
+ // if user agent string isn't set, use the format "Bugle/<app_version>".
+ if (TextUtils.isEmpty(mUserAgent)) {
+ final String simpleVersionName = VersionUtil.getInstance(mContext).getSimpleName();
+ mUserAgent = DEFAULT_USER_AGENT_PREFIX + simpleVersionName;
+ }
+ // if the UAProfUrl isn't set, get it from Gservices
+ if (TextUtils.isEmpty(mUAProfUrl)) {
+ mUAProfUrl = BugleGservices.get().getString(
+ BugleGservicesKeys.MMS_UA_PROFILE_URL,
+ BugleGservicesKeys.MMS_UA_PROFILE_URL_DEFAULT);
+ }
+ }
+}
diff --git a/src/com/android/messaging/sms/DatabaseMessages.java b/src/com/android/messaging/sms/DatabaseMessages.java
new file mode 100644
index 0000000..0f662e5
--- /dev/null
+++ b/src/com/android/messaging/sms/DatabaseMessages.java
@@ -0,0 +1,1006 @@
+/*
+ * 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.messaging.sms;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.res.AssetFileDescriptor;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.Telephony.Mms;
+import android.provider.Telephony.Sms;
+import android.text.TextUtils;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.media.VideoThumbnailRequest;
+import com.android.messaging.mmslib.pdu.CharacterSets;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.MediaMetadataRetrieverWrapper;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.google.common.collect.Lists;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Class contains various SMS/MMS database entities from telephony provider
+ */
+public class DatabaseMessages {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ public abstract static class DatabaseMessage {
+ public abstract int getProtocol();
+ public abstract String getUri();
+ public abstract long getTimestampInMillis();
+
+ @Override
+ public boolean equals(final Object other) {
+ if (other == null || !(other instanceof DatabaseMessage)) {
+ return false;
+ }
+ final DatabaseMessage otherDbMsg = (DatabaseMessage) other;
+ // No need to check timestamp since we only need this when we compare
+ // messages at the same timestamp
+ return TextUtils.equals(getUri(), otherDbMsg.getUri());
+ }
+
+ @Override
+ public int hashCode() {
+ // No need to check timestamp since we only need this when we compare
+ // messages at the same timestamp
+ return getUri().hashCode();
+ }
+ }
+
+ /**
+ * SMS message
+ */
+ public static class SmsMessage extends DatabaseMessage implements Parcelable {
+ private static int sIota = 0;
+ public static final int INDEX_ID = sIota++;
+ public static final int INDEX_TYPE = sIota++;
+ public static final int INDEX_ADDRESS = sIota++;
+ public static final int INDEX_BODY = sIota++;
+ public static final int INDEX_DATE = sIota++;
+ public static final int INDEX_THREAD_ID = sIota++;
+ public static final int INDEX_STATUS = sIota++;
+ public static final int INDEX_READ = sIota++;
+ public static final int INDEX_SEEN = sIota++;
+ public static final int INDEX_DATE_SENT = sIota++;
+ public static final int INDEX_SUB_ID = sIota++;
+
+ private static String[] sProjection;
+
+ public static String[] getProjection() {
+ if (sProjection == null) {
+ String[] projection = new String[] {
+ Sms._ID,
+ Sms.TYPE,
+ Sms.ADDRESS,
+ Sms.BODY,
+ Sms.DATE,
+ Sms.THREAD_ID,
+ Sms.STATUS,
+ Sms.READ,
+ Sms.SEEN,
+ Sms.DATE_SENT,
+ Sms.SUBSCRIPTION_ID,
+ };
+ if (!MmsUtils.hasSmsDateSentColumn()) {
+ projection[INDEX_DATE_SENT] = Sms.DATE;
+ }
+ if (!OsUtil.isAtLeastL_MR1()) {
+ Assert.equals(INDEX_SUB_ID, projection.length - 1);
+ String[] withoutSubId = new String[projection.length - 1];
+ System.arraycopy(projection, 0, withoutSubId, 0, withoutSubId.length);
+ projection = withoutSubId;
+ }
+
+ sProjection = projection;
+ }
+
+ return sProjection;
+ }
+
+ public String mUri;
+ public String mAddress;
+ public String mBody;
+ private long mRowId;
+ public long mTimestampInMillis;
+ public long mTimestampSentInMillis;
+ public int mType;
+ public long mThreadId;
+ public int mStatus;
+ public boolean mRead;
+ public boolean mSeen;
+ public int mSubId;
+
+ private SmsMessage() {
+ }
+
+ /**
+ * Load from a cursor of a query that returns the SMS to import
+ *
+ * @param cursor
+ */
+ private void load(final Cursor cursor) {
+ mRowId = cursor.getLong(INDEX_ID);
+ mAddress = cursor.getString(INDEX_ADDRESS);
+ mBody = cursor.getString(INDEX_BODY);
+ mTimestampInMillis = cursor.getLong(INDEX_DATE);
+ // Before ICS, there is no "date_sent" so use copy of "date" value
+ mTimestampSentInMillis = cursor.getLong(INDEX_DATE_SENT);
+ mType = cursor.getInt(INDEX_TYPE);
+ mThreadId = cursor.getLong(INDEX_THREAD_ID);
+ mStatus = cursor.getInt(INDEX_STATUS);
+ mRead = cursor.getInt(INDEX_READ) == 0 ? false : true;
+ mSeen = cursor.getInt(INDEX_SEEN) == 0 ? false : true;
+ mUri = ContentUris.withAppendedId(Sms.CONTENT_URI, mRowId).toString();
+ mSubId = PhoneUtils.getDefault().getSubIdFromTelephony(cursor, INDEX_SUB_ID);
+ }
+
+ /**
+ * Get a new SmsMessage by loading from the cursor of a query
+ * that returns the SMS to import
+ *
+ * @param cursor
+ * @return
+ */
+ public static SmsMessage get(final Cursor cursor) {
+ final SmsMessage msg = new SmsMessage();
+ msg.load(cursor);
+ return msg;
+ }
+
+ @Override
+ public String getUri() {
+ return mUri;
+ }
+
+ public int getSubId() {
+ return mSubId;
+ }
+
+ @Override
+ public int getProtocol() {
+ return MessageData.PROTOCOL_SMS;
+ }
+
+ @Override
+ public long getTimestampInMillis() {
+ return mTimestampInMillis;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ private SmsMessage(final Parcel in) {
+ mUri = in.readString();
+ mRowId = in.readLong();
+ mTimestampInMillis = in.readLong();
+ mTimestampSentInMillis = in.readLong();
+ mType = in.readInt();
+ mThreadId = in.readLong();
+ mStatus = in.readInt();
+ mRead = in.readInt() != 0;
+ mSeen = in.readInt() != 0;
+ mSubId = in.readInt();
+
+ // SMS specific
+ mAddress = in.readString();
+ mBody = in.readString();
+ }
+
+ public static final Parcelable.Creator<SmsMessage> CREATOR
+ = new Parcelable.Creator<SmsMessage>() {
+ @Override
+ public SmsMessage createFromParcel(final Parcel in) {
+ return new SmsMessage(in);
+ }
+
+ @Override
+ public SmsMessage[] newArray(final int size) {
+ return new SmsMessage[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeString(mUri);
+ out.writeLong(mRowId);
+ out.writeLong(mTimestampInMillis);
+ out.writeLong(mTimestampSentInMillis);
+ out.writeInt(mType);
+ out.writeLong(mThreadId);
+ out.writeInt(mStatus);
+ out.writeInt(mRead ? 1 : 0);
+ out.writeInt(mSeen ? 1 : 0);
+ out.writeInt(mSubId);
+
+ // SMS specific
+ out.writeString(mAddress);
+ out.writeString(mBody);
+ }
+ }
+
+ /**
+ * MMS message
+ */
+ public static class MmsMessage extends DatabaseMessage implements Parcelable {
+ private static int sIota = 0;
+ public static final int INDEX_ID = sIota++;
+ public static final int INDEX_MESSAGE_BOX = sIota++;
+ public static final int INDEX_SUBJECT = sIota++;
+ public static final int INDEX_SUBJECT_CHARSET = sIota++;
+ public static final int INDEX_MESSAGE_SIZE = sIota++;
+ public static final int INDEX_DATE = sIota++;
+ public static final int INDEX_DATE_SENT = sIota++;
+ public static final int INDEX_THREAD_ID = sIota++;
+ public static final int INDEX_PRIORITY = sIota++;
+ public static final int INDEX_STATUS = sIota++;
+ public static final int INDEX_READ = sIota++;
+ public static final int INDEX_SEEN = sIota++;
+ public static final int INDEX_CONTENT_LOCATION = sIota++;
+ public static final int INDEX_TRANSACTION_ID = sIota++;
+ public static final int INDEX_MESSAGE_TYPE = sIota++;
+ public static final int INDEX_EXPIRY = sIota++;
+ public static final int INDEX_RESPONSE_STATUS = sIota++;
+ public static final int INDEX_RETRIEVE_STATUS = sIota++;
+ public static final int INDEX_SUB_ID = sIota++;
+
+ private static String[] sProjection;
+
+ public static String[] getProjection() {
+ if (sProjection == null) {
+ String[] projection = new String[] {
+ Mms._ID,
+ Mms.MESSAGE_BOX,
+ Mms.SUBJECT,
+ Mms.SUBJECT_CHARSET,
+ Mms.MESSAGE_SIZE,
+ Mms.DATE,
+ Mms.DATE_SENT,
+ Mms.THREAD_ID,
+ Mms.PRIORITY,
+ Mms.STATUS,
+ Mms.READ,
+ Mms.SEEN,
+ Mms.CONTENT_LOCATION,
+ Mms.TRANSACTION_ID,
+ Mms.MESSAGE_TYPE,
+ Mms.EXPIRY,
+ Mms.RESPONSE_STATUS,
+ Mms.RETRIEVE_STATUS,
+ Mms.SUBSCRIPTION_ID,
+ };
+
+ if (!OsUtil.isAtLeastL_MR1()) {
+ Assert.equals(INDEX_SUB_ID, projection.length - 1);
+ String[] withoutSubId = new String[projection.length - 1];
+ System.arraycopy(projection, 0, withoutSubId, 0, withoutSubId.length);
+ projection = withoutSubId;
+ }
+
+ sProjection = projection;
+ }
+
+ return sProjection;
+ }
+
+ public String mUri;
+ private long mRowId;
+ public int mType;
+ public String mSubject;
+ public int mSubjectCharset;
+ private long mSize;
+ public long mTimestampInMillis;
+ public long mSentTimestampInMillis;
+ public long mThreadId;
+ public int mPriority;
+ public int mStatus;
+ public boolean mRead;
+ public boolean mSeen;
+ public String mContentLocation;
+ public String mTransactionId;
+ public int mMmsMessageType;
+ public long mExpiryInMillis;
+ public int mSubId;
+ public String mSender;
+ public int mResponseStatus;
+ public int mRetrieveStatus;
+
+ public List<MmsPart> mParts = Lists.newArrayList();
+ private boolean mPartsProcessed = false;
+
+ private MmsMessage() {
+ }
+
+ /**
+ * Load from a cursor of a query that returns the MMS to import
+ *
+ * @param cursor
+ */
+ public void load(final Cursor cursor) {
+ mRowId = cursor.getLong(INDEX_ID);
+ mType = cursor.getInt(INDEX_MESSAGE_BOX);
+ mSubject = cursor.getString(INDEX_SUBJECT);
+ mSubjectCharset = cursor.getInt(INDEX_SUBJECT_CHARSET);
+ if (!TextUtils.isEmpty(mSubject)) {
+ // PduPersister stores the subject using ISO_8859_1
+ // Let's load it using that encoding and convert it back to its original
+ // See PduPersister.persist and PduPersister.toIsoString
+ // (Refer to bug b/11162476)
+ mSubject = getDecodedString(
+ getStringBytes(mSubject, CharacterSets.ISO_8859_1), mSubjectCharset);
+ }
+ mSize = cursor.getLong(INDEX_MESSAGE_SIZE);
+ // MMS db times are in seconds
+ mTimestampInMillis = cursor.getLong(INDEX_DATE) * 1000;
+ mSentTimestampInMillis = cursor.getLong(INDEX_DATE_SENT) * 1000;
+ mThreadId = cursor.getLong(INDEX_THREAD_ID);
+ mPriority = cursor.getInt(INDEX_PRIORITY);
+ mStatus = cursor.getInt(INDEX_STATUS);
+ mRead = cursor.getInt(INDEX_READ) == 0 ? false : true;
+ mSeen = cursor.getInt(INDEX_SEEN) == 0 ? false : true;
+ mContentLocation = cursor.getString(INDEX_CONTENT_LOCATION);
+ mTransactionId = cursor.getString(INDEX_TRANSACTION_ID);
+ mMmsMessageType = cursor.getInt(INDEX_MESSAGE_TYPE);
+ mExpiryInMillis = cursor.getLong(INDEX_EXPIRY) * 1000;
+ mResponseStatus = cursor.getInt(INDEX_RESPONSE_STATUS);
+ mRetrieveStatus = cursor.getInt(INDEX_RETRIEVE_STATUS);
+ // Clear all parts in case we reuse this object
+ mParts.clear();
+ mPartsProcessed = false;
+ mUri = ContentUris.withAppendedId(Mms.CONTENT_URI, mRowId).toString();
+ mSubId = PhoneUtils.getDefault().getSubIdFromTelephony(cursor, INDEX_SUB_ID);
+ }
+
+ /**
+ * Get a new MmsMessage by loading from the cursor of a query
+ * that returns the MMS to import
+ *
+ * @param cursor
+ * @return
+ */
+ public static MmsMessage get(final Cursor cursor) {
+ final MmsMessage msg = new MmsMessage();
+ msg.load(cursor);
+ return msg;
+ }
+ /**
+ * Add a loaded MMS part
+ *
+ * @param part
+ */
+ public void addPart(final MmsPart part) {
+ mParts.add(part);
+ }
+
+ public List<MmsPart> getParts() {
+ return mParts;
+ }
+
+ public long getSize() {
+ if (!mPartsProcessed) {
+ processParts();
+ }
+ return mSize;
+ }
+
+ /**
+ * Process loaded MMS parts to obtain the combined text, the combined attachment url,
+ * the combined content type and the combined size.
+ */
+ private void processParts() {
+ if (mPartsProcessed) {
+ return;
+ }
+ mPartsProcessed = true;
+ // Remember the width and height of the first media part
+ // These are needed when building attachment list
+ long sizeOfParts = 0L;
+ for (final MmsPart part : mParts) {
+ sizeOfParts += part.mSize;
+ }
+ if (mSize <= 0) {
+ mSize = mSubject != null ? mSubject.getBytes().length : 0L;
+ mSize += sizeOfParts;
+ }
+ }
+
+ @Override
+ public String getUri() {
+ return mUri;
+ }
+
+ public long getId() {
+ return mRowId;
+ }
+
+ public int getSubId() {
+ return mSubId;
+ }
+
+ @Override
+ public int getProtocol() {
+ return MessageData.PROTOCOL_MMS;
+ }
+
+ @Override
+ public long getTimestampInMillis() {
+ return mTimestampInMillis;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public void setSender(final String sender) {
+ mSender = sender;
+ }
+
+ private MmsMessage(final Parcel in) {
+ mUri = in.readString();
+ mRowId = in.readLong();
+ mTimestampInMillis = in.readLong();
+ mSentTimestampInMillis = in.readLong();
+ mType = in.readInt();
+ mThreadId = in.readLong();
+ mStatus = in.readInt();
+ mRead = in.readInt() != 0;
+ mSeen = in.readInt() != 0;
+ mSubId = in.readInt();
+
+ // MMS specific
+ mSubject = in.readString();
+ mContentLocation = in.readString();
+ mTransactionId = in.readString();
+ mSender = in.readString();
+
+ mSize = in.readLong();
+ mExpiryInMillis = in.readLong();
+
+ mSubjectCharset = in.readInt();
+ mPriority = in.readInt();
+ mMmsMessageType = in.readInt();
+ mResponseStatus = in.readInt();
+ mRetrieveStatus = in.readInt();
+
+ final int nParts = in.readInt();
+ mParts = new ArrayList<MmsPart>();
+ mPartsProcessed = false;
+ for (int i = 0; i < nParts; i++) {
+ mParts.add((MmsPart) in.readParcelable(getClass().getClassLoader()));
+ }
+ }
+
+ public static final Parcelable.Creator<MmsMessage> CREATOR
+ = new Parcelable.Creator<MmsMessage>() {
+ @Override
+ public MmsMessage createFromParcel(final Parcel in) {
+ return new MmsMessage(in);
+ }
+
+ @Override
+ public MmsMessage[] newArray(final int size) {
+ return new MmsMessage[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeString(mUri);
+ out.writeLong(mRowId);
+ out.writeLong(mTimestampInMillis);
+ out.writeLong(mSentTimestampInMillis);
+ out.writeInt(mType);
+ out.writeLong(mThreadId);
+ out.writeInt(mStatus);
+ out.writeInt(mRead ? 1 : 0);
+ out.writeInt(mSeen ? 1 : 0);
+ out.writeInt(mSubId);
+
+ out.writeString(mSubject);
+ out.writeString(mContentLocation);
+ out.writeString(mTransactionId);
+ out.writeString(mSender);
+
+ out.writeLong(mSize);
+ out.writeLong(mExpiryInMillis);
+
+ out.writeInt(mSubjectCharset);
+ out.writeInt(mPriority);
+ out.writeInt(mMmsMessageType);
+ out.writeInt(mResponseStatus);
+ out.writeInt(mRetrieveStatus);
+
+ out.writeInt(mParts.size());
+ for (final MmsPart part : mParts) {
+ out.writeParcelable(part, 0);
+ }
+ }
+ }
+
+ /**
+ * Part of an MMS message
+ */
+ public static class MmsPart implements Parcelable {
+ public static final String[] PROJECTION = new String[] {
+ Mms.Part._ID,
+ Mms.Part.MSG_ID,
+ Mms.Part.CHARSET,
+ Mms.Part.CONTENT_TYPE,
+ Mms.Part.TEXT,
+ };
+ private static int sIota = 0;
+ public static final int INDEX_ID = sIota++;
+ public static final int INDEX_MSG_ID = sIota++;
+ public static final int INDEX_CHARSET = sIota++;
+ public static final int INDEX_CONTENT_TYPE = sIota++;
+ public static final int INDEX_TEXT = sIota++;
+
+ public String mUri;
+ public long mRowId;
+ public long mMessageId;
+ public String mContentType;
+ public String mText;
+ public int mCharset;
+ private int mWidth;
+ private int mHeight;
+ public long mSize;
+
+ private MmsPart() {
+ }
+
+ /**
+ * Load from a cursor of a query that returns the MMS part to import
+ *
+ * @param cursor
+ */
+ public void load(final Cursor cursor, final boolean loadMedia) {
+ mRowId = cursor.getLong(INDEX_ID);
+ mMessageId = cursor.getLong(INDEX_MSG_ID);
+ mContentType = cursor.getString(INDEX_CONTENT_TYPE);
+ mText = cursor.getString(INDEX_TEXT);
+ mCharset = cursor.getInt(INDEX_CHARSET);
+ mWidth = 0;
+ mHeight = 0;
+ mSize = 0;
+ if (isMedia()) {
+ // For importing we don't load media since performance is critical
+ // For loading when we receive mms, we do load media to get enough
+ // information of the media file
+ if (loadMedia) {
+ if (ContentType.isImageType(mContentType)) {
+ loadImage();
+ } else if (ContentType.isVideoType(mContentType)) {
+ loadVideo();
+ } // No need to load audio for parsing
+ mSize = MmsUtils.getMediaFileSize(getDataUri());
+ }
+ } else {
+ // Load text if not media type
+ loadText();
+ }
+ mUri = Uri.withAppendedPath(Mms.CONTENT_URI, cursor.getString(INDEX_ID)).toString();
+ }
+
+ /**
+ * Get content type from file extension
+ */
+ private static String extractContentType(final Context context, final Uri uri) {
+ final String path = uri.getPath();
+ final MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
+ String extension = MimeTypeMap.getFileExtensionFromUrl(path);
+ if (TextUtils.isEmpty(extension)) {
+ // getMimeTypeFromExtension() doesn't handle spaces in filenames nor can it handle
+ // urlEncoded strings. Let's try one last time at finding the extension.
+ final int dotPos = path.lastIndexOf('.');
+ if (0 <= dotPos) {
+ extension = path.substring(dotPos + 1);
+ }
+ }
+ return mimeTypeMap.getMimeTypeFromExtension(extension);
+ }
+
+ /**
+ * Get text of a text part
+ */
+ private void loadText() {
+ byte[] data = null;
+ if (isEmbeddedTextType()) {
+ // Embedded text, get from the "text" column
+ if (!TextUtils.isEmpty(mText)) {
+ data = getStringBytes(mText, mCharset);
+ }
+ } else {
+ // Not embedded, load from disk
+ final ContentResolver resolver =
+ Factory.get().getApplicationContext().getContentResolver();
+ final Uri uri = getDataUri();
+ InputStream is = null;
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try {
+ is = resolver.openInputStream(uri);
+ final byte[] buffer = new byte[256];
+ int len = is.read(buffer);
+ while (len >= 0) {
+ baos.write(buffer, 0, len);
+ len = is.read(buffer);
+ }
+ } catch (final IOException e) {
+ LogUtil.e(TAG,
+ "DatabaseMessages.MmsPart: loading text from file failed: " + e, e);
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (final IOException e) {
+ LogUtil.e(TAG, "DatabaseMessages.MmsPart: close file failed: " + e, e);
+ }
+ }
+ }
+ data = baos.toByteArray();
+ }
+ if (data != null && data.length > 0) {
+ mSize = data.length;
+ mText = getDecodedString(data, mCharset);
+ }
+ }
+
+ /**
+ * Load image file of an image part and parse the dimensions and type
+ */
+ private void loadImage() {
+ final Context context = Factory.get().getApplicationContext();
+ final ContentResolver resolver = context.getContentResolver();
+ final Uri uri = getDataUri();
+ // We have to get the width and height of the image -- they're needed when adding
+ // an attachment in bugle.
+ InputStream is = null;
+ try {
+ is = resolver.openInputStream(uri);
+ final BitmapFactory.Options opt = new BitmapFactory.Options();
+ opt.inJustDecodeBounds = true;
+ BitmapFactory.decodeStream(is, null, opt);
+ mContentType = opt.outMimeType;
+ mWidth = opt.outWidth;
+ mHeight = opt.outHeight;
+ if (TextUtils.isEmpty(mContentType)) {
+ // BitmapFactory couldn't figure out the image type. That's got to be a bad
+ // sign, but see if we can figure it out from the file extension.
+ mContentType = extractContentType(context, uri);
+ }
+ } catch (final FileNotFoundException e) {
+ LogUtil.e(TAG, "DatabaseMessages.MmsPart.loadImage: file not found", e);
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (final IOException e) {
+ Log.e(TAG, "IOException caught while closing stream", e);
+ }
+ }
+ }
+ }
+
+ /**
+ * Load video file of a video part and parse the dimensions and type
+ */
+ private void loadVideo() {
+ // This is a coarse check, and should not be applied to outgoing messages. However,
+ // currently, this does not cause any problems.
+ if (!VideoThumbnailRequest.shouldShowIncomingVideoThumbnails()) {
+ return;
+ }
+ final Uri uri = getDataUri();
+ final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper();
+ try {
+ retriever.setDataSource(uri);
+ // FLAG: This inadvertently fixes a problem with phone receiving audio
+ // messages on some carrier. We should handle this in a less accidental way so that
+ // we don't break it again. (The carrier changes the content type in the wrapper
+ // in-transit from audio/mp4 to video/3gpp without changing the data)
+ // Also note: There is a bug in some OEM device where mmr returns
+ // video/ffmpeg for image files. That shouldn't happen here but be aware.
+ mContentType =
+ retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE);
+ final Bitmap bitmap = retriever.getFrameAtTime(-1);
+ if (bitmap != null) {
+ mWidth = bitmap.getWidth();
+ mHeight = bitmap.getHeight();
+ } else {
+ // Get here if it's not actually video (see above)
+ LogUtil.i(LogUtil.BUGLE_TAG, "loadVideo: Got null bitmap from " + uri);
+ }
+ } catch (IOException e) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "Error extracting metadata from " + uri, e);
+ } finally {
+ retriever.release();
+ }
+ }
+
+ /**
+ * Get media file size
+ */
+ private long getMediaFileSize() {
+ final Context context = Factory.get().getApplicationContext();
+ final Uri uri = getDataUri();
+ AssetFileDescriptor fd = null;
+ try {
+ fd = context.getContentResolver().openAssetFileDescriptor(uri, "r");
+ if (fd != null) {
+ return fd.getParcelFileDescriptor().getStatSize();
+ }
+ } catch (final FileNotFoundException e) {
+ LogUtil.e(TAG, "DatabaseMessages.MmsPart: cound not find media file: " + e, e);
+ } finally {
+ if (fd != null) {
+ try {
+ fd.close();
+ } catch (final IOException e) {
+ LogUtil.e(TAG, "DatabaseMessages.MmsPart: failed to close " + e, e);
+ }
+ }
+ }
+ return 0L;
+ }
+
+ /**
+ * @return If the type is a text type that stores text embedded (i.e. in db table)
+ */
+ private boolean isEmbeddedTextType() {
+ return ContentType.TEXT_PLAIN.equals(mContentType)
+ || ContentType.APP_SMIL.equals(mContentType)
+ || ContentType.TEXT_HTML.equals(mContentType);
+ }
+
+ /**
+ * Get an instance of the MMS part from the part table cursor
+ *
+ * @param cursor
+ * @param loadMedia Whether to load the media file of the part
+ * @return
+ */
+ public static MmsPart get(final Cursor cursor, final boolean loadMedia) {
+ final MmsPart part = new MmsPart();
+ part.load(cursor, loadMedia);
+ return part;
+ }
+
+ public boolean isText() {
+ return ContentType.TEXT_PLAIN.equals(mContentType)
+ || ContentType.TEXT_HTML.equals(mContentType)
+ || ContentType.APP_WAP_XHTML.equals(mContentType);
+ }
+
+ public boolean isMedia() {
+ return ContentType.isImageType(mContentType)
+ || ContentType.isVideoType(mContentType)
+ || ContentType.isAudioType(mContentType)
+ || ContentType.isVCardType(mContentType);
+ }
+
+ public boolean isImage() {
+ return ContentType.isImageType(mContentType);
+ }
+
+ public Uri getDataUri() {
+ return Uri.parse("content://mms/part/" + mRowId);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ private MmsPart(final Parcel in) {
+ mUri = in.readString();
+ mRowId = in.readLong();
+ mMessageId = in.readLong();
+ mContentType = in.readString();
+ mText = in.readString();
+ mCharset = in.readInt();
+ mWidth = in.readInt();
+ mHeight = in.readInt();
+ mSize = in.readLong();
+ }
+
+ public static final Parcelable.Creator<MmsPart> CREATOR
+ = new Parcelable.Creator<MmsPart>() {
+ @Override
+ public MmsPart createFromParcel(final Parcel in) {
+ return new MmsPart(in);
+ }
+
+ @Override
+ public MmsPart[] newArray(final int size) {
+ return new MmsPart[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeString(mUri);
+ out.writeLong(mRowId);
+ out.writeLong(mMessageId);
+ out.writeString(mContentType);
+ out.writeString(mText);
+ out.writeInt(mCharset);
+ out.writeInt(mWidth);
+ out.writeInt(mHeight);
+ out.writeLong(mSize);
+ }
+ }
+
+ /**
+ * This class provides the same DatabaseMessage interface over a local SMS db message
+ */
+ public static class LocalDatabaseMessage extends DatabaseMessage implements Parcelable {
+ private final int mProtocol;
+ private final String mUri;
+ private final long mTimestamp;
+ private final long mLocalId;
+ private final String mConversationId;
+
+ public LocalDatabaseMessage(final long localId, final int protocol, final String uri,
+ final long timestamp, final String conversationId) {
+ mLocalId = localId;
+ mProtocol = protocol;
+ mUri = uri;
+ mTimestamp = timestamp;
+ mConversationId = conversationId;
+ }
+
+ @Override
+ public int getProtocol() {
+ return mProtocol;
+ }
+
+ @Override
+ public long getTimestampInMillis() {
+ return mTimestamp;
+ }
+
+ @Override
+ public String getUri() {
+ return mUri;
+ }
+
+ public long getLocalId() {
+ return mLocalId;
+ }
+
+ public String getConversationId() {
+ return mConversationId;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ private LocalDatabaseMessage(final Parcel in) {
+ mUri = in.readString();
+ mConversationId = in.readString();
+ mLocalId = in.readLong();
+ mTimestamp = in.readLong();
+ mProtocol = in.readInt();
+ }
+
+ public static final Parcelable.Creator<LocalDatabaseMessage> CREATOR
+ = new Parcelable.Creator<LocalDatabaseMessage>() {
+ @Override
+ public LocalDatabaseMessage createFromParcel(final Parcel in) {
+ return new LocalDatabaseMessage(in);
+ }
+
+ @Override
+ public LocalDatabaseMessage[] newArray(final int size) {
+ return new LocalDatabaseMessage[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeString(mUri);
+ out.writeString(mConversationId);
+ out.writeLong(mLocalId);
+ out.writeLong(mTimestamp);
+ out.writeInt(mProtocol);
+ }
+ }
+
+ /**
+ * Address for MMS message
+ */
+ public static class MmsAddr {
+ public static final String[] PROJECTION = new String[] {
+ Mms.Addr.ADDRESS,
+ Mms.Addr.CHARSET,
+ };
+ private static int sIota = 0;
+ public static final int INDEX_ADDRESS = sIota++;
+ public static final int INDEX_CHARSET = sIota++;
+
+ public static String get(final Cursor cursor) {
+ final int charset = cursor.getInt(INDEX_CHARSET);
+ // PduPersister stores the addresses using ISO_8859_1
+ // Let's load it using that encoding and convert it back to its original
+ // See PduPersister.persistAddress
+ return getDecodedString(
+ getStringBytes(cursor.getString(INDEX_ADDRESS), CharacterSets.ISO_8859_1),
+ charset);
+ }
+ }
+
+ /**
+ * Decoded string by character set
+ */
+ public static String getDecodedString(final byte[] data, final int charset) {
+ if (CharacterSets.ANY_CHARSET == charset) {
+ return new String(data); // system default encoding.
+ } else {
+ try {
+ final String name = CharacterSets.getMimeName(charset);
+ return new String(data, name);
+ } catch (final UnsupportedEncodingException e) {
+ try {
+ return new String(data, CharacterSets.MIMENAME_ISO_8859_1);
+ } catch (final UnsupportedEncodingException exception) {
+ return new String(data); // system default encoding.
+ }
+ }
+ }
+ }
+
+ /**
+ * Unpack a given String into a byte[].
+ */
+ public static byte[] getStringBytes(final String data, final int charset) {
+ if (CharacterSets.ANY_CHARSET == charset) {
+ return data.getBytes();
+ } else {
+ try {
+ final String name = CharacterSets.getMimeName(charset);
+ return data.getBytes(name);
+ } catch (final UnsupportedEncodingException e) {
+ return data.getBytes();
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/sms/MmsConfig.java b/src/com/android/messaging/sms/MmsConfig.java
new file mode 100755
index 0000000..f13d785
--- /dev/null
+++ b/src/com/android/messaging/sms/MmsConfig.java
@@ -0,0 +1,309 @@
+/*
+ * 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.messaging.sms;
+
+import android.os.Bundle;
+import android.support.v7.mms.CarrierConfigValuesLoader;
+import android.telephony.SubscriptionInfo;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.SafeAsyncTask;
+import com.google.common.collect.Maps;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * MMS configuration.
+ *
+ * This is now a wrapper around the BugleCarrierConfigValuesLoader, which does
+ * the actual loading and stores the values in a Bundle. This class provides getter
+ * methods for values used in the app, which is easier to use than the raw loader
+ * class.
+ */
+public class MmsConfig {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private static final int DEFAULT_MAX_TEXT_LENGTH = 2000;
+
+ /*
+ * Key types
+ */
+ public static final String KEY_TYPE_INT = "int";
+ public static final String KEY_TYPE_BOOL = "bool";
+ public static final String KEY_TYPE_STRING = "string";
+
+ private static final Map<String, String> sKeyTypeMap = Maps.newHashMap();
+ static {
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLED_MMS, KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLED_TRANS_ID, KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLED_NOTIFY_WAP_MMSC, KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ALIAS_ENABLED, KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ALLOW_ATTACH_AUDIO, KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLE_MULTIPART_SMS, KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLE_SMS_DELIVERY_REPORTS,
+ KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLE_GROUP_MMS, KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION,
+ KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_CELL_BROADCAST_APP_LINKS, KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_SEND_MULTIPART_SMS_AS_SEPARATE_MESSAGES,
+ KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLE_MMS_READ_REPORTS, KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLE_MMS_DELIVERY_REPORTS,
+ KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_SUPPORT_HTTP_CHARSET_HEADER,
+ KEY_TYPE_BOOL);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_SIZE, KEY_TYPE_INT);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_HEIGHT, KEY_TYPE_INT);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_WIDTH, KEY_TYPE_INT);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_RECIPIENT_LIMIT, KEY_TYPE_INT);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_HTTP_SOCKET_TIMEOUT, KEY_TYPE_INT);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ALIAS_MIN_CHARS, KEY_TYPE_INT);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ALIAS_MAX_CHARS, KEY_TYPE_INT);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_THRESHOLD, KEY_TYPE_INT);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_LENGTH_THRESHOLD,
+ KEY_TYPE_INT);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_TEXT_SIZE, KEY_TYPE_INT);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_MAX_SUBJECT_LENGTH, KEY_TYPE_INT);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_UA_PROF_TAG_NAME, KEY_TYPE_STRING);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_HTTP_PARAMS, KEY_TYPE_STRING);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_EMAIL_GATEWAY_NUMBER, KEY_TYPE_STRING);
+ sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_NAI_SUFFIX, KEY_TYPE_STRING);
+ }
+
+ // A map that stores all MmsConfigs, one per active subscription. For pre-LMSim, this will
+ // contain just one entry with the default self sub id; for LMSim and above, this will contain
+ // all active sub ids but the default subscription id - the default subscription id will be
+ // resolved to an active sub id during runtime.
+ private static final Map<Integer, MmsConfig> sSubIdToMmsConfigMap = Maps.newHashMap();
+ // The fallback values
+ private static final MmsConfig sFallback =
+ new MmsConfig(ParticipantData.DEFAULT_SELF_SUB_ID, new Bundle());
+
+ // Per-subscription configuration values.
+ private final Bundle mValues;
+ private final int mSubId;
+
+ /**
+ * Retrieves the MmsConfig instance associated with the given {@code subId}
+ */
+ public static MmsConfig get(final int subId) {
+ final int realSubId = PhoneUtils.getDefault().getEffectiveSubId(subId);
+ synchronized (sSubIdToMmsConfigMap) {
+ final MmsConfig mmsConfig = sSubIdToMmsConfigMap.get(realSubId);
+ if (mmsConfig == null) {
+ // The subId is no longer valid. Fall back to the default config.
+ LogUtil.e(LogUtil.BUGLE_TAG, "Get mms config failed: invalid subId. subId=" + subId
+ + ", real subId=" + realSubId
+ + ", map=" + sSubIdToMmsConfigMap.keySet());
+ return sFallback;
+ }
+ return mmsConfig;
+ }
+ }
+
+ private MmsConfig(final int subId, final Bundle values) {
+ mSubId = subId;
+ mValues = values;
+ }
+
+ /**
+ * Same as load() but doing it using an async thread from SafeAsyncTask thread pool.
+ */
+ public static void loadAsync() {
+ SafeAsyncTask.executeOnThreadPool(new Runnable() {
+ @Override
+ public void run() {
+ load();
+ }
+ });
+ }
+
+ /**
+ * Reload the device and per-subscription settings.
+ */
+ public static synchronized void load() {
+ final BugleCarrierConfigValuesLoader loader = Factory.get().getCarrierConfigValuesLoader();
+ // Rebuild the entire MmsConfig map.
+ sSubIdToMmsConfigMap.clear();
+ loader.reset();
+ if (OsUtil.isAtLeastL_MR1()) {
+ final List<SubscriptionInfo> subInfoRecords =
+ PhoneUtils.getDefault().toLMr1().getActiveSubscriptionInfoList();
+ if (subInfoRecords == null) {
+ LogUtil.w(TAG, "Loading mms config failed: no active SIM");
+ return;
+ }
+ for (SubscriptionInfo subInfoRecord : subInfoRecords) {
+ final int subId = subInfoRecord.getSubscriptionId();
+ final Bundle values = loader.get(subId);
+ addMmsConfig(new MmsConfig(subId, values));
+ }
+ } else {
+ final Bundle values = loader.get(ParticipantData.DEFAULT_SELF_SUB_ID);
+ addMmsConfig(new MmsConfig(ParticipantData.DEFAULT_SELF_SUB_ID, values));
+ }
+ }
+
+ private static void addMmsConfig(MmsConfig mmsConfig) {
+ Assert.isTrue(OsUtil.isAtLeastL_MR1() !=
+ (mmsConfig.mSubId == ParticipantData.DEFAULT_SELF_SUB_ID));
+ sSubIdToMmsConfigMap.put(mmsConfig.mSubId, mmsConfig);
+ }
+
+ public int getSmsToMmsTextThreshold() {
+ return mValues.getInt(CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_THRESHOLD,
+ CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_THRESHOLD_DEFAULT);
+ }
+
+ public int getSmsToMmsTextLengthThreshold() {
+ return mValues.getInt(CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_LENGTH_THRESHOLD,
+ CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_LENGTH_THRESHOLD_DEFAULT);
+ }
+
+ public int getMaxMessageSize() {
+ return mValues.getInt(CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_SIZE,
+ CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_SIZE_DEFAULT);
+ }
+
+ /**
+ * Return the largest MaxMessageSize for any subid
+ */
+ public static int getMaxMaxMessageSize() {
+ int maxMax = 0;
+ for (MmsConfig config : sSubIdToMmsConfigMap.values()) {
+ maxMax = Math.max(maxMax, config.getMaxMessageSize());
+ }
+ return maxMax > 0 ? maxMax : sFallback.getMaxMessageSize();
+ }
+
+ public boolean getTransIdEnabled() {
+ return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ENABLED_TRANS_ID,
+ CarrierConfigValuesLoader.CONFIG_ENABLED_TRANS_ID_DEFAULT);
+ }
+
+ public String getEmailGateway() {
+ return mValues.getString(CarrierConfigValuesLoader.CONFIG_EMAIL_GATEWAY_NUMBER,
+ CarrierConfigValuesLoader.CONFIG_EMAIL_GATEWAY_NUMBER_DEFAULT);
+ }
+
+ public int getMaxImageHeight() {
+ return mValues.getInt(CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_HEIGHT,
+ CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_HEIGHT_DEFAULT);
+ }
+
+ public int getMaxImageWidth() {
+ return mValues.getInt(CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_WIDTH,
+ CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_WIDTH_DEFAULT);
+ }
+
+ public int getRecipientLimit() {
+ final int limit = mValues.getInt(CarrierConfigValuesLoader.CONFIG_RECIPIENT_LIMIT,
+ CarrierConfigValuesLoader.CONFIG_RECIPIENT_LIMIT_DEFAULT);
+ return limit < 0 ? Integer.MAX_VALUE : limit;
+ }
+
+ public int getMaxTextLimit() {
+ final int max = mValues.getInt(CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_TEXT_SIZE,
+ CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_TEXT_SIZE_DEFAULT);
+ return max > -1 ? max : DEFAULT_MAX_TEXT_LENGTH;
+ }
+
+ public boolean getMultipartSmsEnabled() {
+ return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ENABLE_MULTIPART_SMS,
+ CarrierConfigValuesLoader.CONFIG_ENABLE_MULTIPART_SMS_DEFAULT);
+ }
+
+ public boolean getSendMultipartSmsAsSeparateMessages() {
+ return mValues.getBoolean(
+ CarrierConfigValuesLoader.CONFIG_SEND_MULTIPART_SMS_AS_SEPARATE_MESSAGES,
+ CarrierConfigValuesLoader.CONFIG_SEND_MULTIPART_SMS_AS_SEPARATE_MESSAGES_DEFAULT);
+ }
+
+ public boolean getSMSDeliveryReportsEnabled() {
+ return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ENABLE_SMS_DELIVERY_REPORTS,
+ CarrierConfigValuesLoader.CONFIG_ENABLE_SMS_DELIVERY_REPORTS_DEFAULT);
+ }
+
+ public boolean getNotifyWapMMSC() {
+ return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ENABLED_NOTIFY_WAP_MMSC,
+ CarrierConfigValuesLoader.CONFIG_ENABLED_NOTIFY_WAP_MMSC_DEFAULT);
+ }
+
+ public boolean isAliasEnabled() {
+ return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ALIAS_ENABLED,
+ CarrierConfigValuesLoader.CONFIG_ALIAS_ENABLED_DEFAULT);
+ }
+
+ public int getAliasMinChars() {
+ return mValues.getInt(CarrierConfigValuesLoader.CONFIG_ALIAS_MIN_CHARS,
+ CarrierConfigValuesLoader.CONFIG_ALIAS_MIN_CHARS_DEFAULT);
+ }
+
+ public int getAliasMaxChars() {
+ return mValues.getInt(CarrierConfigValuesLoader.CONFIG_ALIAS_MAX_CHARS,
+ CarrierConfigValuesLoader.CONFIG_ALIAS_MAX_CHARS_DEFAULT);
+ }
+
+ public boolean getAllowAttachAudio() {
+ return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ALLOW_ATTACH_AUDIO,
+ CarrierConfigValuesLoader.CONFIG_ALLOW_ATTACH_AUDIO_DEFAULT);
+ }
+
+ public int getMaxSubjectLength() {
+ return mValues.getInt(CarrierConfigValuesLoader.CONFIG_MAX_SUBJECT_LENGTH,
+ CarrierConfigValuesLoader.CONFIG_MAX_SUBJECT_LENGTH_DEFAULT);
+ }
+
+ public boolean getGroupMmsEnabled() {
+ return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ENABLE_GROUP_MMS,
+ CarrierConfigValuesLoader.CONFIG_ENABLE_GROUP_MMS_DEFAULT);
+ }
+
+ public boolean getSupportMmsContentDisposition() {
+ return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION,
+ CarrierConfigValuesLoader.CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION_DEFAULT);
+ }
+
+ public boolean getShowCellBroadcast() {
+ return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_CELL_BROADCAST_APP_LINKS,
+ CarrierConfigValuesLoader.CONFIG_CELL_BROADCAST_APP_LINKS_DEFAULT);
+ }
+
+ public Object getValue(final String key) {
+ return mValues.get(key);
+ }
+
+ public Set<String> keySet() {
+ return mValues.keySet();
+ }
+
+ public static String getKeyType(final String key) {
+ return sKeyTypeMap.get(key);
+ }
+
+ public void update(final String type, final String key, final String value) {
+ BugleCarrierConfigValuesLoader.update(mValues, type, key, value);
+ }
+}
diff --git a/src/com/android/messaging/sms/MmsFailureException.java b/src/com/android/messaging/sms/MmsFailureException.java
new file mode 100644
index 0000000..dd702ee
--- /dev/null
+++ b/src/com/android/messaging/sms/MmsFailureException.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.messaging.sms;
+
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.util.Assert;
+
+/**
+ * Exception for MMS failures
+ */
+public class MmsFailureException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Hint of how we should retry in case of failure. Take values defined in MmsUtils.
+ */
+ public final int retryHint;
+
+ /**
+ * If set, provides a more detailed reason for the failure.
+ */
+ public final int rawStatus;
+
+ private void checkRetryHint() {
+ Assert.isTrue(retryHint == MmsUtils.MMS_REQUEST_AUTO_RETRY
+ || retryHint == MmsUtils.MMS_REQUEST_MANUAL_RETRY
+ || retryHint == MmsUtils.MMS_REQUEST_NO_RETRY);
+ }
+ /**
+ * Creates a new MmsFailureException.
+ *
+ * @param retryHint Hint for how to retry
+ */
+ public MmsFailureException(final int retryHint) {
+ super();
+ this.retryHint = retryHint;
+ checkRetryHint();
+ this.rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED;
+ }
+
+ public MmsFailureException(final int retryHint, final int rawStatus) {
+ super();
+ this.retryHint = retryHint;
+ checkRetryHint();
+ this.rawStatus = rawStatus;
+ }
+
+ /**
+ * Creates a new MmsFailureException with the specified detail message.
+ *
+ * @param retryHint Hint for how to retry
+ * @param message the detail message.
+ */
+ public MmsFailureException(final int retryHint, String message) {
+ super(message);
+ this.retryHint = retryHint;
+ checkRetryHint();
+ this.rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED;
+ }
+
+ /**
+ * Creates a new MmsFailureException with the specified cause.
+ *
+ * @param retryHint Hint for how to retry
+ * @param cause the cause.
+ */
+ public MmsFailureException(final int retryHint, Throwable cause) {
+ super(cause);
+ this.retryHint = retryHint;
+ checkRetryHint();
+ this.rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED;
+ }
+
+ /**
+ * Creates a new MmsFailureException
+ * with the specified detail message and cause.
+ *
+ * @param retryHint Hint for how to retry
+ * @param message the detail message.
+ * @param cause the cause.
+ */
+ public MmsFailureException(final int retryHint, String message, Throwable cause) {
+ super(message, cause);
+ this.retryHint = retryHint;
+ checkRetryHint();
+ this.rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED;
+ }
+}
diff --git a/src/com/android/messaging/sms/MmsSender.java b/src/com/android/messaging/sms/MmsSender.java
new file mode 100644
index 0000000..6dfa81a
--- /dev/null
+++ b/src/com/android/messaging/sms/MmsSender.java
@@ -0,0 +1,312 @@
+/*
+ * 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.messaging.sms;
+
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v7.mms.MmsManager;
+import android.telephony.SmsManager;
+
+import com.android.messaging.datamodel.MmsFileProvider;
+import com.android.messaging.datamodel.action.SendMessageAction;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+import com.android.messaging.mmslib.pdu.AcknowledgeInd;
+import com.android.messaging.mmslib.pdu.EncodedStringValue;
+import com.android.messaging.mmslib.pdu.GenericPdu;
+import com.android.messaging.mmslib.pdu.NotifyRespInd;
+import com.android.messaging.mmslib.pdu.PduComposer;
+import com.android.messaging.mmslib.pdu.PduHeaders;
+import com.android.messaging.mmslib.pdu.PduParser;
+import com.android.messaging.mmslib.pdu.RetrieveConf;
+import com.android.messaging.mmslib.pdu.SendConf;
+import com.android.messaging.mmslib.pdu.SendReq;
+import com.android.messaging.receiver.SendStatusReceiver;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.PhoneUtils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+/**
+ * Class that sends chat message via MMS.
+ *
+ * The interface emulates a blocking send similar to making an HTTP request.
+ */
+public class MmsSender {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ /**
+ * Send an MMS message.
+ *
+ * @param context Context
+ * @param messageUri The unique URI of the message for identifying it during sending
+ * @param sendReq The SendReq PDU of the message
+ * @throws MmsFailureException
+ */
+ public static void sendMms(final Context context, final int subId, final Uri messageUri,
+ final SendReq sendReq, final Bundle sentIntentExras) throws MmsFailureException {
+ sendMms(context,
+ subId,
+ messageUri,
+ null /* locationUrl */,
+ sendReq,
+ true /* responseImportant */,
+ sentIntentExras);
+ }
+
+ /**
+ * Send NotifyRespInd (response to mms auto download).
+ *
+ * @param context Context
+ * @param subId subscription to use to send the response
+ * @param transactionId The transaction id of the MMS message
+ * @param contentLocation The url of the MMS message
+ * @param status The status to send with the NotifyRespInd
+ * @throws MmsFailureException
+ * @throws InvalidHeaderValueException
+ */
+ public static void sendNotifyResponseForMmsDownload(final Context context, final int subId,
+ final byte[] transactionId, final String contentLocation, final int status)
+ throws MmsFailureException, InvalidHeaderValueException {
+ // Create the M-NotifyResp.ind
+ final NotifyRespInd notifyRespInd = new NotifyRespInd(
+ PduHeaders.CURRENT_MMS_VERSION, transactionId, status);
+ final Uri messageUri = Uri.parse(contentLocation);
+ // Pack M-NotifyResp.ind and send it
+ sendMms(context,
+ subId,
+ messageUri,
+ MmsConfig.get(subId).getNotifyWapMMSC() ? contentLocation : null,
+ notifyRespInd,
+ false /* responseImportant */,
+ null /* sentIntentExtras */);
+ }
+
+ /**
+ * Send AcknowledgeInd (response to mms manual download). Ignore failures.
+ *
+ * @param context Context
+ * @param subId The SIM's subId we are currently using
+ * @param transactionId The transaction id of the MMS message
+ * @param contentLocation The url of the MMS message
+ * @throws MmsFailureException
+ * @throws InvalidHeaderValueException
+ */
+ public static void sendAcknowledgeForMmsDownload(final Context context, final int subId,
+ final byte[] transactionId, final String contentLocation)
+ throws MmsFailureException, InvalidHeaderValueException {
+ final String selfNumber = PhoneUtils.get(subId).getCanonicalForSelf(true/*allowOverride*/);
+ // Create the M-Acknowledge.ind
+ final AcknowledgeInd acknowledgeInd = new AcknowledgeInd(PduHeaders.CURRENT_MMS_VERSION,
+ transactionId);
+ acknowledgeInd.setFrom(new EncodedStringValue(selfNumber));
+ final Uri messageUri = Uri.parse(contentLocation);
+ // Sending
+ sendMms(context,
+ subId,
+ messageUri,
+ MmsConfig.get(subId).getNotifyWapMMSC() ? contentLocation : null,
+ acknowledgeInd,
+ false /*responseImportant*/,
+ null /* sentIntentExtras */);
+ }
+
+ /**
+ * Send a generic PDU.
+ *
+ * @param context Context
+ * @param messageUri The unique URI of the message for identifying it during sending
+ * @param locationUrl The optional URL to send to
+ * @param pdu The PDU to send
+ * @param responseImportant If the sending response is important. Responses to the
+ * Sending of AcknowledgeInd and NotifyRespInd are not important.
+ * @throws MmsFailureException
+ */
+ private static void sendMms(final Context context, final int subId, final Uri messageUri,
+ final String locationUrl, final GenericPdu pdu, final boolean responseImportant,
+ final Bundle sentIntentExtras) throws MmsFailureException {
+ // Write PDU to temporary file to send to platform
+ final Uri contentUri = writePduToTempFile(context, pdu, subId);
+
+ // Construct PendingIntent that will notify us when message sending is complete
+ final Intent sentIntent = new Intent(SendStatusReceiver.MMS_SENT_ACTION,
+ messageUri,
+ context,
+ SendStatusReceiver.class);
+ sentIntent.putExtra(SendMessageAction.EXTRA_CONTENT_URI, contentUri);
+ sentIntent.putExtra(SendMessageAction.EXTRA_RESPONSE_IMPORTANT, responseImportant);
+ if (sentIntentExtras != null) {
+ sentIntent.putExtras(sentIntentExtras);
+ }
+ final PendingIntent sentPendingIntent = PendingIntent.getBroadcast(
+ context,
+ 0 /*request code*/,
+ sentIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
+
+ // Send the message
+ MmsManager.sendMultimediaMessage(subId, context, contentUri, locationUrl,
+ sentPendingIntent);
+ }
+
+ private static Uri writePduToTempFile(final Context context, final GenericPdu pdu, int subId)
+ throws MmsFailureException {
+ final Uri contentUri = MmsFileProvider.buildRawMmsUri();
+ final File tempFile = MmsFileProvider.getFile(contentUri);
+ FileOutputStream writer = null;
+ try {
+ // Ensure rawmms directory exists
+ tempFile.getParentFile().mkdirs();
+ writer = new FileOutputStream(tempFile);
+ final byte[] pduBytes = new PduComposer(context, pdu).make();
+ if (pduBytes == null) {
+ throw new MmsFailureException(
+ MmsUtils.MMS_REQUEST_NO_RETRY, "Failed to compose PDU");
+ }
+ if (pduBytes.length > MmsConfig.get(subId).getMaxMessageSize()) {
+ throw new MmsFailureException(
+ MmsUtils.MMS_REQUEST_NO_RETRY,
+ MessageData.RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG);
+ }
+ writer.write(pduBytes);
+ } catch (final IOException e) {
+ if (tempFile != null) {
+ tempFile.delete();
+ }
+ LogUtil.e(TAG, "Cannot create temporary file " + tempFile.getAbsolutePath(), e);
+ throw new MmsFailureException(
+ MmsUtils.MMS_REQUEST_AUTO_RETRY, "Cannot create raw mms file");
+ } catch (final OutOfMemoryError e) {
+ if (tempFile != null) {
+ tempFile.delete();
+ }
+ LogUtil.e(TAG, "Out of memory in composing PDU", e);
+ throw new MmsFailureException(
+ MmsUtils.MMS_REQUEST_MANUAL_RETRY,
+ MessageData.RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG);
+ } finally {
+ if (writer != null) {
+ try {
+ writer.close();
+ } catch (final IOException e) {
+ // no action we can take here
+ }
+ }
+ }
+ return contentUri;
+ }
+
+ public static SendConf parseSendConf(byte[] response, int subId) {
+ if (response != null) {
+ final GenericPdu respPdu = new PduParser(
+ response, MmsConfig.get(subId).getSupportMmsContentDisposition()).parse();
+ if (respPdu != null) {
+ if (respPdu instanceof SendConf) {
+ return (SendConf) respPdu;
+ } else {
+ LogUtil.e(TAG, "MmsSender: send response not SendConf");
+ }
+ } else {
+ // Invalid PDU
+ LogUtil.e(TAG, "MmsSender: send invalid response");
+ }
+ }
+ // Empty or invalid response
+ return null;
+ }
+
+ /**
+ * Download an MMS message.
+ *
+ * @param context Context
+ * @param contentLocation The url of the MMS message
+ * @throws MmsFailureException
+ * @throws InvalidHeaderValueException
+ */
+ public static void downloadMms(final Context context, final int subId,
+ final String contentLocation, Bundle extras) throws MmsFailureException,
+ InvalidHeaderValueException {
+ final Uri requestUri = Uri.parse(contentLocation);
+ final Uri contentUri = MmsFileProvider.buildRawMmsUri();
+
+ final Intent downloadedIntent = new Intent(SendStatusReceiver.MMS_DOWNLOADED_ACTION,
+ requestUri,
+ context,
+ SendStatusReceiver.class);
+ downloadedIntent.putExtra(SendMessageAction.EXTRA_CONTENT_URI, contentUri);
+ if (extras != null) {
+ downloadedIntent.putExtras(extras);
+ }
+ final PendingIntent downloadedPendingIntent = PendingIntent.getBroadcast(
+ context,
+ 0 /*request code*/,
+ downloadedIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
+
+ MmsManager.downloadMultimediaMessage(subId, context, contentLocation, contentUri,
+ downloadedPendingIntent);
+ }
+
+ public static RetrieveConf parseRetrieveConf(byte[] data, int subId) {
+ if (data != null) {
+ final GenericPdu pdu = new PduParser(
+ data, MmsConfig.get(subId).getSupportMmsContentDisposition()).parse();
+ if (pdu != null) {
+ if (pdu instanceof RetrieveConf) {
+ return (RetrieveConf) pdu;
+ } else {
+ LogUtil.e(TAG, "MmsSender: downloaded pdu not RetrieveConf: "
+ + pdu.getClass().getName());
+ }
+ } else {
+ LogUtil.e(TAG, "MmsSender: downloaded pdu could not be parsed (invalid)");
+ }
+ }
+ LogUtil.e(TAG, "MmsSender: downloaded pdu is empty");
+ return null;
+ }
+
+ // Process different result code from platform MMS service
+ public static int getErrorResultStatus(int resultCode, int httpStatusCode) {
+ Assert.isFalse(resultCode == Activity.RESULT_OK);
+ switch (resultCode) {
+ case SmsManager.MMS_ERROR_UNABLE_CONNECT_MMS:
+ case SmsManager.MMS_ERROR_IO_ERROR:
+ return MmsUtils.MMS_REQUEST_AUTO_RETRY;
+ case SmsManager.MMS_ERROR_INVALID_APN:
+ case SmsManager.MMS_ERROR_CONFIGURATION_ERROR:
+ case SmsManager.MMS_ERROR_NO_DATA_NETWORK:
+ case SmsManager.MMS_ERROR_UNSPECIFIED:
+ return MmsUtils.MMS_REQUEST_MANUAL_RETRY;
+ case SmsManager.MMS_ERROR_HTTP_FAILURE:
+ if (httpStatusCode == 404) {
+ return MmsUtils.MMS_REQUEST_NO_RETRY;
+ } else {
+ return MmsUtils.MMS_REQUEST_AUTO_RETRY;
+ }
+ default:
+ return MmsUtils.MMS_REQUEST_MANUAL_RETRY;
+ }
+ }
+}
diff --git a/src/com/android/messaging/sms/MmsSmsUtils.java b/src/com/android/messaging/sms/MmsSmsUtils.java
new file mode 100644
index 0000000..1a0ef99
--- /dev/null
+++ b/src/com/android/messaging/sms/MmsSmsUtils.java
@@ -0,0 +1,204 @@
+/*
+ * 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.messaging.sms;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.BaseColumns;
+import android.text.TextUtils;
+import android.util.Patterns;
+
+import com.android.messaging.mmslib.SqliteWrapper;
+import com.android.messaging.util.LogUtil;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Utility functions for the Messaging Service
+ */
+public class MmsSmsUtils {
+ private MmsSmsUtils() {
+ // Forbidden being instantiated.
+ }
+
+ // An alias (or commonly called "nickname") is:
+ // Nickname must begin with a letter.
+ // Only letters a-z, numbers 0-9, or . are allowed in Nickname field.
+ public static boolean isAlias(final String string, final int subId) {
+ if (!MmsConfig.get(subId).isAliasEnabled()) {
+ return false;
+ }
+
+ final int len = string == null ? 0 : string.length();
+
+ if (len < MmsConfig.get(subId).getAliasMinChars() ||
+ len > MmsConfig.get(subId).getAliasMaxChars()) {
+ return false;
+ }
+
+ if (!Character.isLetter(string.charAt(0))) { // Nickname begins with a letter
+ return false;
+ }
+ for (int i = 1; i < len; i++) {
+ final char c = string.charAt(i);
+ if (!(Character.isLetterOrDigit(c) || c == '.')) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * mailbox = name-addr
+ * name-addr = [display-name] angle-addr
+ * angle-addr = [CFWS] "<" addr-spec ">" [CFWS]
+ */
+ public static final Pattern NAME_ADDR_EMAIL_PATTERN =
+ Pattern.compile("\\s*(\"[^\"]*\"|[^<>\"]+)\\s*<([^<>]+)>\\s*");
+
+ public static String extractAddrSpec(final String address) {
+ final Matcher match = NAME_ADDR_EMAIL_PATTERN.matcher(address);
+
+ if (match.matches()) {
+ return match.group(2);
+ }
+ return address;
+ }
+
+ /**
+ * Returns true if the address is an email address
+ *
+ * @param address the input address to be tested
+ * @return true if address is an email address
+ */
+ public static boolean isEmailAddress(final String address) {
+ if (TextUtils.isEmpty(address)) {
+ return false;
+ }
+
+ final String s = extractAddrSpec(address);
+ final Matcher match = Patterns.EMAIL_ADDRESS.matcher(s);
+ return match.matches();
+ }
+
+ /**
+ * Returns true if the number is a Phone number
+ *
+ * @param number the input number to be tested
+ * @return true if number is a Phone number
+ */
+ public static boolean isPhoneNumber(final String number) {
+ if (TextUtils.isEmpty(number)) {
+ return false;
+ }
+
+ final Matcher match = Patterns.PHONE.matcher(number);
+ return match.matches();
+ }
+
+ /**
+ * Check if MMS is required when sending to email address
+ *
+ * @param destinationHasEmailAddress destination includes an email address
+ * @return true if MMS is required.
+ */
+ public static boolean getRequireMmsForEmailAddress(final boolean destinationHasEmailAddress,
+ final int subId) {
+ if (!TextUtils.isEmpty(MmsConfig.get(subId).getEmailGateway())) {
+ return false;
+ } else {
+ return destinationHasEmailAddress;
+ }
+ }
+
+ /**
+ * Helper functions for the "threads" table used by MMS and SMS.
+ */
+ public static final class Threads implements android.provider.Telephony.ThreadsColumns {
+ private static final String[] ID_PROJECTION = { BaseColumns._ID };
+ private static final Uri THREAD_ID_CONTENT_URI = Uri.parse(
+ "content://mms-sms/threadID");
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(
+ android.provider.Telephony.MmsSms.CONTENT_URI, "conversations");
+
+ // No one should construct an instance of this class.
+ private Threads() {
+ }
+
+ /**
+ * This is a single-recipient version of
+ * getOrCreateThreadId. It's convenient for use with SMS
+ * messages.
+ */
+ public static long getOrCreateThreadId(final Context context, final String recipient) {
+ final Set<String> recipients = new HashSet<String>();
+
+ recipients.add(recipient);
+ return getOrCreateThreadId(context, recipients);
+ }
+
+ /**
+ * Given the recipients list and subject of an unsaved message,
+ * return its thread ID. If the message starts a new thread,
+ * allocate a new thread ID. Otherwise, use the appropriate
+ * existing thread ID.
+ *
+ * Find the thread ID of the same set of recipients (in
+ * any order, without any additions). If one
+ * is found, return it. Otherwise, return a unique thread ID.
+ */
+ public static long getOrCreateThreadId(
+ final Context context, final Set<String> recipients) {
+ final Uri.Builder uriBuilder = THREAD_ID_CONTENT_URI.buildUpon();
+
+ for (String recipient : recipients) {
+ if (isEmailAddress(recipient)) {
+ recipient = extractAddrSpec(recipient);
+ }
+
+ uriBuilder.appendQueryParameter("recipient", recipient);
+ }
+
+ final Uri uri = uriBuilder.build();
+ //if (DEBUG) Rlog.v(TAG, "getOrCreateThreadId uri: " + uri);
+
+ final Cursor cursor = SqliteWrapper.query(context, context.getContentResolver(),
+ uri, ID_PROJECTION, null, null, null);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ return cursor.getLong(0);
+ } else {
+ LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG,
+ "getOrCreateThreadId returned no rows!");
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+ LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "getOrCreateThreadId failed with "
+ + LogUtil.sanitizePII(recipients.toString()));
+ throw new IllegalArgumentException("Unable to find or allocate a thread ID.");
+ }
+ }
+}
diff --git a/src/com/android/messaging/sms/MmsUtils.java b/src/com/android/messaging/sms/MmsUtils.java
new file mode 100644
index 0000000..913e9a6
--- /dev/null
+++ b/src/com/android/messaging/sms/MmsUtils.java
@@ -0,0 +1,2747 @@
+/*
+ * 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.messaging.sms;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.AssetFileDescriptor;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.media.MediaMetadataRetriever;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.provider.Telephony;
+import android.provider.Telephony.Mms;
+import android.provider.Telephony.Sms;
+import android.provider.Telephony.Threads;
+import android.telephony.SmsManager;
+import android.telephony.SmsMessage;
+import android.text.TextUtils;
+import android.text.util.Rfc822Token;
+import android.text.util.Rfc822Tokenizer;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.MediaScratchFileProvider;
+import com.android.messaging.datamodel.action.DownloadMmsAction;
+import com.android.messaging.datamodel.action.SendMessageAction;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.mmslib.InvalidHeaderValueException;
+import com.android.messaging.mmslib.MmsException;
+import com.android.messaging.mmslib.SqliteWrapper;
+import com.android.messaging.mmslib.pdu.CharacterSets;
+import com.android.messaging.mmslib.pdu.EncodedStringValue;
+import com.android.messaging.mmslib.pdu.GenericPdu;
+import com.android.messaging.mmslib.pdu.NotificationInd;
+import com.android.messaging.mmslib.pdu.PduBody;
+import com.android.messaging.mmslib.pdu.PduComposer;
+import com.android.messaging.mmslib.pdu.PduHeaders;
+import com.android.messaging.mmslib.pdu.PduParser;
+import com.android.messaging.mmslib.pdu.PduPart;
+import com.android.messaging.mmslib.pdu.PduPersister;
+import com.android.messaging.mmslib.pdu.RetrieveConf;
+import com.android.messaging.mmslib.pdu.SendConf;
+import com.android.messaging.mmslib.pdu.SendReq;
+import com.android.messaging.sms.SmsSender.SendResult;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.EmailAddress;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.ImageUtils.ImageResizer;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.MediaMetadataRetrieverWrapper;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.google.common.base.Joiner;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Utils for sending sms/mms messages.
+ */
+public class MmsUtils {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ public static final boolean DEFAULT_DELIVERY_REPORT_MODE = false;
+ public static final boolean DEFAULT_READ_REPORT_MODE = false;
+ public static final long DEFAULT_EXPIRY_TIME_IN_SECONDS = 7 * 24 * 60 * 60;
+ public static final int DEFAULT_PRIORITY = PduHeaders.PRIORITY_NORMAL;
+
+ public static final int MAX_SMS_RETRY = 3;
+
+ /**
+ * MMS request succeeded
+ */
+ public static final int MMS_REQUEST_SUCCEEDED = 0;
+ /**
+ * MMS request failed with a transient error and can be retried automatically
+ */
+ public static final int MMS_REQUEST_AUTO_RETRY = 1;
+ /**
+ * MMS request failed with an error and can be retried manually
+ */
+ public static final int MMS_REQUEST_MANUAL_RETRY = 2;
+ /**
+ * MMS request failed with a specific error and should not be retried
+ */
+ public static final int MMS_REQUEST_NO_RETRY = 3;
+
+ public static final String getRequestStatusDescription(final int status) {
+ switch (status) {
+ case MMS_REQUEST_SUCCEEDED:
+ return "SUCCEEDED";
+ case MMS_REQUEST_AUTO_RETRY:
+ return "AUTO_RETRY";
+ case MMS_REQUEST_MANUAL_RETRY:
+ return "MANUAL_RETRY";
+ case MMS_REQUEST_NO_RETRY:
+ return "NO_RETRY";
+ default:
+ return String.valueOf(status) + " (check MmsUtils)";
+ }
+ }
+
+ public static final int PDU_HEADER_VALUE_UNDEFINED = 0;
+
+ private static final int DEFAULT_DURATION = 5000; //ms
+
+ // amount of space to leave in a MMS for text and overhead.
+ private static final int MMS_MAX_SIZE_SLOP = 1024;
+ public static final long INVALID_TIMESTAMP = 0L;
+ private static String[] sNoSubjectStrings;
+
+ public static class MmsInfo {
+ public Uri mUri;
+ public int mMessageSize;
+ public PduBody mPduBody;
+ }
+
+ // Sync all remote messages apart from drafts
+ private static final String REMOTE_SMS_SELECTION = String.format(
+ Locale.US,
+ "(%s IN (%d, %d, %d, %d, %d))",
+ Sms.TYPE,
+ Sms.MESSAGE_TYPE_INBOX,
+ Sms.MESSAGE_TYPE_OUTBOX,
+ Sms.MESSAGE_TYPE_QUEUED,
+ Sms.MESSAGE_TYPE_FAILED,
+ Sms.MESSAGE_TYPE_SENT);
+
+ private static final String REMOTE_MMS_SELECTION = String.format(
+ Locale.US,
+ "((%s IN (%d, %d, %d, %d)) AND (%s IN (%d, %d, %d)))",
+ Mms.MESSAGE_BOX,
+ Mms.MESSAGE_BOX_INBOX,
+ Mms.MESSAGE_BOX_OUTBOX,
+ Mms.MESSAGE_BOX_SENT,
+ Mms.MESSAGE_BOX_FAILED,
+ Mms.MESSAGE_TYPE,
+ PduHeaders.MESSAGE_TYPE_SEND_REQ,
+ PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND,
+ PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF);
+
+ /**
+ * Type selection for importing sms messages.
+ *
+ * @return The SQL selection for importing sms messages
+ */
+ public static String getSmsTypeSelectionSql() {
+ return REMOTE_SMS_SELECTION;
+ }
+
+ /**
+ * Type selection for importing mms messages.
+ *
+ * @return The SQL selection for importing mms messages. This selects the message type,
+ * not including the selection on timestamp.
+ */
+ public static String getMmsTypeSelectionSql() {
+ return REMOTE_MMS_SELECTION;
+ }
+
+ // SMIL spec: http://www.w3.org/TR/SMIL3
+
+ private static final String sSmilImagePart =
+ "<par dur=\"" + DEFAULT_DURATION + "ms\">" +
+ "<img src=\"%s\" region=\"Image\" />" +
+ "</par>";
+
+ private static final String sSmilVideoPart =
+ "<par dur=\"%2$dms\">" +
+ "<video src=\"%1$s\" dur=\"%2$dms\" region=\"Image\" />" +
+ "</par>";
+
+ private static final String sSmilAudioPart =
+ "<par dur=\"%2$dms\">" +
+ "<audio src=\"%1$s\" dur=\"%2$dms\" />" +
+ "</par>";
+
+ private static final String sSmilTextPart =
+ "<par dur=\"" + DEFAULT_DURATION + "ms\">" +
+ "<text src=\"%s\" region=\"Text\" />" +
+ "</par>";
+
+ private static final String sSmilPart =
+ "<par dur=\"" + DEFAULT_DURATION + "ms\">" +
+ "<ref src=\"%s\" />" +
+ "</par>";
+
+ private static final String sSmilTextOnly =
+ "<smil>" +
+ "<head>" +
+ "<layout>" +
+ "<root-layout/>" +
+ "<region id=\"Text\" top=\"0\" left=\"0\" "
+ + "height=\"100%%\" width=\"100%%\"/>" +
+ "</layout>" +
+ "</head>" +
+ "<body>" +
+ "%s" + // constructed body goes here
+ "</body>" +
+ "</smil>";
+
+ private static final String sSmilVisualAttachmentsOnly =
+ "<smil>" +
+ "<head>" +
+ "<layout>" +
+ "<root-layout/>" +
+ "<region id=\"Image\" fit=\"meet\" top=\"0\" left=\"0\" "
+ + "height=\"100%%\" width=\"100%%\"/>" +
+ "</layout>" +
+ "</head>" +
+ "<body>" +
+ "%s" + // constructed body goes here
+ "</body>" +
+ "</smil>";
+
+ private static final String sSmilVisualAttachmentsWithText =
+ "<smil>" +
+ "<head>" +
+ "<layout>" +
+ "<root-layout/>" +
+ "<region id=\"Image\" fit=\"meet\" top=\"0\" left=\"0\" "
+ + "height=\"80%%\" width=\"100%%\"/>" +
+ "<region id=\"Text\" top=\"80%%\" left=\"0\" height=\"20%%\" "
+ + "width=\"100%%\"/>" +
+ "</layout>" +
+ "</head>" +
+ "<body>" +
+ "%s" + // constructed body goes here
+ "</body>" +
+ "</smil>";
+
+ private static final String sSmilNonVisualAttachmentsOnly =
+ "<smil>" +
+ "<head>" +
+ "<layout>" +
+ "<root-layout/>" +
+ "</layout>" +
+ "</head>" +
+ "<body>" +
+ "%s" + // constructed body goes here
+ "</body>" +
+ "</smil>";
+
+ private static final String sSmilNonVisualAttachmentsWithText = sSmilTextOnly;
+
+ public static final String MMS_DUMP_PREFIX = "mmsdump-";
+ public static final String SMS_DUMP_PREFIX = "smsdump-";
+
+ public static final int MIN_VIDEO_BYTES_PER_SECOND = 4 * 1024;
+ public static final int MIN_IMAGE_BYTE_SIZE = 16 * 1024;
+ public static final int MAX_VIDEO_ATTACHMENT_COUNT = 1;
+
+ public static MmsInfo makePduBody(final Context context, final MessageData message,
+ final int subId) {
+ final PduBody pb = new PduBody();
+
+ // Compute data size requirements for this message: count up images and total size of
+ // non-image attachments.
+ int totalLength = 0;
+ int countImage = 0;
+ for (final MessagePartData part : message.getParts()) {
+ if (part.isAttachment()) {
+ final String contentType = part.getContentType();
+ if (ContentType.isImageType(contentType)) {
+ countImage++;
+ } else if (ContentType.isVCardType(contentType)) {
+ totalLength += getDataLength(context, part.getContentUri());
+ } else {
+ totalLength += getMediaFileSize(part.getContentUri());
+ }
+ }
+ }
+ final long minSize = countImage * MIN_IMAGE_BYTE_SIZE;
+ final int byteBudget = MmsConfig.get(subId).getMaxMessageSize() - totalLength
+ - MMS_MAX_SIZE_SLOP;
+ final double budgetFactor =
+ minSize > 0 ? Math.max(1.0, byteBudget / ((double) minSize)) : 1;
+ final int bytesPerImage = (int) (budgetFactor * MIN_IMAGE_BYTE_SIZE);
+ final int widthLimit = MmsConfig.get(subId).getMaxImageWidth();
+ final int heightLimit = MmsConfig.get(subId).getMaxImageHeight();
+
+ // Actually add the attachments, shrinking images appropriately.
+ int index = 0;
+ totalLength = 0;
+ boolean hasVisualAttachment = false;
+ boolean hasNonVisualAttachment = false;
+ boolean hasText = false;
+ final StringBuilder smilBody = new StringBuilder();
+ for (final MessagePartData part : message.getParts()) {
+ String srcName;
+ if (part.isAttachment()) {
+ String contentType = part.getContentType();
+ if (ContentType.isImageType(contentType)) {
+ // There's a good chance that if we selected the image from our media picker the
+ // content type is image/*. Fix the content type here for gifs so that we only
+ // need to open the input stream once. All other gif vs static image checks will
+ // only have to do a string comparison which is much cheaper.
+ final boolean isGif = ImageUtils.isGif(contentType, part.getContentUri());
+ contentType = isGif ? ContentType.IMAGE_GIF : contentType;
+ srcName = String.format(isGif ? "image%06d.gif" : "image%06d.jpg", index);
+ smilBody.append(String.format(sSmilImagePart, srcName));
+ totalLength += addPicturePart(context, pb, index, part,
+ widthLimit, heightLimit, bytesPerImage, srcName, contentType);
+ hasVisualAttachment = true;
+ } else if (ContentType.isVideoType(contentType)) {
+ srcName = String.format("video%06d.mp4", index);
+ final int length = addVideoPart(context, pb, part, srcName);
+ totalLength += length;
+ smilBody.append(String.format(sSmilVideoPart, srcName,
+ getMediaDurationMs(context, part, DEFAULT_DURATION)));
+ hasVisualAttachment = true;
+ } else if (ContentType.isVCardType(contentType)) {
+ srcName = String.format("contact%06d.vcf", index);
+ totalLength += addVCardPart(context, pb, part, srcName);
+ smilBody.append(String.format(sSmilPart, srcName));
+ hasNonVisualAttachment = true;
+ } else if (ContentType.isAudioType(contentType)) {
+ srcName = String.format("recording%06d.amr", index);
+ totalLength += addOtherPart(context, pb, part, srcName);
+ final int duration = getMediaDurationMs(context, part, -1);
+ Assert.isTrue(duration != -1);
+ smilBody.append(String.format(sSmilAudioPart, srcName, duration));
+ hasNonVisualAttachment = true;
+ } else {
+ srcName = String.format("other%06d.dat", index);
+ totalLength += addOtherPart(context, pb, part, srcName);
+ smilBody.append(String.format(sSmilPart, srcName));
+ }
+ index++;
+ }
+ if (!TextUtils.isEmpty(part.getText())) {
+ hasText = true;
+ }
+ }
+
+ if (hasText) {
+ final String srcName = String.format("text.%06d.txt", index);
+ final String text = message.getMessageText();
+ totalLength += addTextPart(context, pb, text, srcName);
+
+ // Append appropriate SMIL to the body.
+ smilBody.append(String.format(sSmilTextPart, srcName));
+ }
+
+ final String smilTemplate = getSmilTemplate(hasVisualAttachment,
+ hasNonVisualAttachment, hasText);
+ addSmilPart(pb, smilTemplate, smilBody.toString());
+
+ final MmsInfo mmsInfo = new MmsInfo();
+ mmsInfo.mPduBody = pb;
+ mmsInfo.mMessageSize = totalLength;
+
+ return mmsInfo;
+ }
+
+ private static int getMediaDurationMs(final Context context, final MessagePartData part,
+ final int defaultDurationMs) {
+ Assert.notNull(context);
+ Assert.notNull(part);
+ Assert.isTrue(ContentType.isAudioType(part.getContentType()) ||
+ ContentType.isVideoType(part.getContentType()));
+
+ final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper();
+ try {
+ retriever.setDataSource(part.getContentUri());
+ return retriever.extractInteger(
+ MediaMetadataRetriever.METADATA_KEY_DURATION, defaultDurationMs);
+ } catch (final IOException e) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "Error extracting duration from " + part.getContentUri(), e);
+ return defaultDurationMs;
+ } finally {
+ retriever.release();
+ }
+ }
+
+ private static void setPartContentLocationAndId(final PduPart part, final String srcName) {
+ // Set Content-Location.
+ part.setContentLocation(srcName.getBytes());
+
+ // Set Content-Id.
+ final int index = srcName.lastIndexOf(".");
+ final String contentId = (index == -1) ? srcName : srcName.substring(0, index);
+ part.setContentId(contentId.getBytes());
+ }
+
+ private static int addTextPart(final Context context, final PduBody pb,
+ final String text, final String srcName) {
+ final PduPart part = new PduPart();
+
+ // Set Charset if it's a text media.
+ part.setCharset(CharacterSets.UTF_8);
+
+ // Set Content-Type.
+ part.setContentType(ContentType.TEXT_PLAIN.getBytes());
+
+ // Set Content-Location.
+ setPartContentLocationAndId(part, srcName);
+
+ part.setData(text.getBytes());
+
+ pb.addPart(part);
+
+ return part.getData().length;
+ }
+
+ private static int addPicturePart(final Context context, final PduBody pb, final int index,
+ final MessagePartData messagePart, int widthLimit, int heightLimit,
+ final int maxPartSize, final String srcName, final String contentType) {
+ final Uri imageUri = messagePart.getContentUri();
+ final int width = messagePart.getWidth();
+ final int height = messagePart.getHeight();
+
+ // Swap the width and height limits to match the orientation of the image so we scale the
+ // picture as little as possible.
+ if ((height > width) != (heightLimit > widthLimit)) {
+ final int temp = widthLimit;
+ widthLimit = heightLimit;
+ heightLimit = temp;
+ }
+
+ final int orientation = ImageUtils.getOrientation(context, imageUri);
+ int imageSize = getDataLength(context, imageUri);
+ if (imageSize <= 0) {
+ LogUtil.e(TAG, "Can't get image", new Exception());
+ return 0;
+ }
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "addPicturePart size: " + imageSize + " width: "
+ + width + " widthLimit: " + widthLimit
+ + " height: " + height
+ + " heightLimit: " + heightLimit);
+ }
+
+ PduPart part;
+ // Check if we're already within the limits - in which case we don't need to resize.
+ // The size can be zero here, even when the media has content. See the comment in
+ // MediaModel.initMediaSize. Sometimes it'll compute zero and it's costly to read the
+ // whole stream to compute the size. When we call getResizedImageAsPart(), we'll correctly
+ // set the size.
+ if (imageSize <= maxPartSize &&
+ width <= widthLimit &&
+ height <= heightLimit &&
+ (orientation == android.media.ExifInterface.ORIENTATION_UNDEFINED ||
+ orientation == android.media.ExifInterface.ORIENTATION_NORMAL)) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "addPicturePart - already sized");
+ }
+ part = new PduPart();
+ part.setDataUri(imageUri);
+ part.setContentType(contentType.getBytes());
+ } else {
+ part = getResizedImageAsPart(widthLimit, heightLimit, maxPartSize,
+ width, height, orientation, imageUri, context, contentType);
+ if (part == null) {
+ final OutOfMemoryError e = new OutOfMemoryError();
+ LogUtil.e(TAG, "Can't resize image: not enough memory?", e);
+ throw e;
+ }
+ imageSize = part.getData().length;
+ }
+
+ setPartContentLocationAndId(part, srcName);
+
+ pb.addPart(index, part);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "addPicturePart size: " + imageSize);
+ }
+
+ return imageSize;
+ }
+
+ private static void addPartForUri(final Context context, final PduBody pb,
+ final String srcName, final Uri uri, final String contentType) {
+ final PduPart part = new PduPart();
+ part.setDataUri(uri);
+ part.setContentType(contentType.getBytes());
+
+ setPartContentLocationAndId(part, srcName);
+
+ pb.addPart(part);
+ }
+
+ private static int addVCardPart(final Context context, final PduBody pb,
+ final MessagePartData messagePart, final String srcName) {
+ final Uri vcardUri = messagePart.getContentUri();
+ final String contentType = messagePart.getContentType();
+ final int vcardSize = getDataLength(context, vcardUri);
+ if (vcardSize <= 0) {
+ LogUtil.e(TAG, "Can't get vcard", new Exception());
+ return 0;
+ }
+
+ addPartForUri(context, pb, srcName, vcardUri, contentType);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "addVCardPart size: " + vcardSize);
+ }
+
+ return vcardSize;
+ }
+
+ /**
+ * Add video part recompressing video if necessary. If recompression fails, part is not
+ * added.
+ */
+ private static int addVideoPart(final Context context, final PduBody pb,
+ final MessagePartData messagePart, final String srcName) {
+ final Uri attachmentUri = messagePart.getContentUri();
+ String contentType = messagePart.getContentType();
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "addPart attachmentUrl: " + attachmentUri.toString());
+ }
+
+ if (TextUtils.isEmpty(contentType)) {
+ contentType = ContentType.VIDEO_3G2;
+ }
+
+ addPartForUri(context, pb, srcName, attachmentUri, contentType);
+ return (int) getMediaFileSize(attachmentUri);
+ }
+
+ private static int addOtherPart(final Context context, final PduBody pb,
+ final MessagePartData messagePart, final String srcName) {
+ final Uri attachmentUri = messagePart.getContentUri();
+ final String contentType = messagePart.getContentType();
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "addPart attachmentUrl: " + attachmentUri.toString());
+ }
+
+ final int dataSize = (int) getMediaFileSize(attachmentUri);
+
+ addPartForUri(context, pb, srcName, attachmentUri, contentType);
+
+ return dataSize;
+ }
+
+ private static void addSmilPart(final PduBody pb, final String smilTemplate,
+ final String smilBody) {
+ final PduPart smilPart = new PduPart();
+ smilPart.setContentId("smil".getBytes());
+ smilPart.setContentLocation("smil.xml".getBytes());
+ smilPart.setContentType(ContentType.APP_SMIL.getBytes());
+ final String smil = String.format(smilTemplate, smilBody);
+ smilPart.setData(smil.getBytes());
+ pb.addPart(0, smilPart);
+ }
+
+ private static String getSmilTemplate(final boolean hasVisualAttachments,
+ final boolean hasNonVisualAttachments, final boolean hasText) {
+ if (hasVisualAttachments) {
+ return hasText ? sSmilVisualAttachmentsWithText : sSmilVisualAttachmentsOnly;
+ }
+ if (hasNonVisualAttachments) {
+ return hasText ? sSmilNonVisualAttachmentsWithText : sSmilNonVisualAttachmentsOnly;
+ }
+ return sSmilTextOnly;
+ }
+
+ private static int getDataLength(final Context context, final Uri uri) {
+ InputStream is = null;
+ try {
+ is = context.getContentResolver().openInputStream(uri);
+ try {
+ return is == null ? 0 : is.available();
+ } catch (final IOException e) {
+ LogUtil.e(TAG, "getDataLength couldn't stream: " + uri, e);
+ }
+ } catch (final FileNotFoundException e) {
+ LogUtil.e(TAG, "getDataLength couldn't open: " + uri, e);
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (final IOException e) {
+ LogUtil.e(TAG, "getDataLength couldn't close: " + uri, e);
+ }
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Returns {@code true} if group mms is turned on,
+ * {@code false} otherwise.
+ *
+ * For the group mms feature to be enabled, the following must be true:
+ * 1. the feature is enabled in mms_config.xml (currently on by default)
+ * 2. the feature is enabled in the SMS settings page
+ *
+ * @return true if group mms is supported
+ */
+ public static boolean groupMmsEnabled(final int subId) {
+ final Context context = Factory.get().getApplicationContext();
+ final Resources resources = context.getResources();
+ final BuglePrefs prefs = BuglePrefs.getSubscriptionPrefs(subId);
+ final String groupMmsKey = resources.getString(R.string.group_mms_pref_key);
+ final boolean groupMmsEnabledDefault = resources.getBoolean(R.bool.group_mms_pref_default);
+ final boolean groupMmsPrefOn = prefs.getBoolean(groupMmsKey, groupMmsEnabledDefault);
+ return MmsConfig.get(subId).getGroupMmsEnabled() && groupMmsPrefOn;
+ }
+
+ /**
+ * Get a version of this image resized to fit the given dimension and byte-size limits. Note
+ * that the content type of the resulting PduPart may not be the same as the content type of
+ * this UriImage; always call {@link PduPart#getContentType()} to get the new content type.
+ *
+ * @param widthLimit The width limit, in pixels
+ * @param heightLimit The height limit, in pixels
+ * @param byteLimit The binary size limit, in bytes
+ * @param width The image width, in pixels
+ * @param height The image height, in pixels
+ * @param orientation Orientation constant from ExifInterface for rotating or flipping the
+ * image
+ * @param imageUri Uri to the image data
+ * @param context Needed to open the image
+ * @return A new PduPart containing the resized image data
+ */
+ private static PduPart getResizedImageAsPart(final int widthLimit,
+ final int heightLimit, final int byteLimit, final int width, final int height,
+ final int orientation, final Uri imageUri, final Context context, final String contentType) {
+ final PduPart part = new PduPart();
+
+ final byte[] data = ImageResizer.getResizedImageData(width, height, orientation,
+ widthLimit, heightLimit, byteLimit, imageUri, context, contentType);
+ if (data == null) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Resize image failed.");
+ }
+ return null;
+ }
+
+ part.setData(data);
+ // Any static images will be compressed into a jpeg
+ final String contentTypeOfResizedImage = ImageUtils.isGif(contentType, imageUri)
+ ? ContentType.IMAGE_GIF : ContentType.IMAGE_JPEG;
+ part.setContentType(contentTypeOfResizedImage.getBytes());
+
+ return part;
+ }
+
+ /**
+ * Get media file size
+ */
+ public static long getMediaFileSize(final Uri uri) {
+ final Context context = Factory.get().getApplicationContext();
+ AssetFileDescriptor fd = null;
+ try {
+ fd = context.getContentResolver().openAssetFileDescriptor(uri, "r");
+ if (fd != null) {
+ return fd.getParcelFileDescriptor().getStatSize();
+ }
+ } catch (final FileNotFoundException e) {
+ LogUtil.e(TAG, "MmsUtils.getMediaFileSize: cound not find media file: " + e, e);
+ } finally {
+ if (fd != null) {
+ try {
+ fd.close();
+ } catch (final IOException e) {
+ LogUtil.e(TAG, "MmsUtils.getMediaFileSize: failed to close " + e, e);
+ }
+ }
+ }
+ return 0L;
+ }
+
+ // Code for extracting the actual phone numbers for the participants in a conversation,
+ // given a thread id.
+
+ private static final Uri ALL_THREADS_URI =
+ Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build();
+
+ private static final String[] RECIPIENTS_PROJECTION = {
+ Threads._ID,
+ Threads.RECIPIENT_IDS
+ };
+
+ private static final int RECIPIENT_IDS = 1;
+
+ public static List<String> getRecipientsByThread(final long threadId) {
+ final String spaceSepIds = getRawRecipientIdsForThread(threadId);
+ if (!TextUtils.isEmpty(spaceSepIds)) {
+ final Context context = Factory.get().getApplicationContext();
+ return getAddresses(context, spaceSepIds);
+ }
+ return null;
+ }
+
+ // NOTE: There are phones on which you can't get the recipients from the thread id for SMS
+ // until you have a message in the conversation!
+ public static String getRawRecipientIdsForThread(final long threadId) {
+ if (threadId <= 0) {
+ return null;
+ }
+ final Context context = Factory.get().getApplicationContext();
+ final ContentResolver cr = context.getContentResolver();
+ final Cursor thread = cr.query(
+ ALL_THREADS_URI,
+ RECIPIENTS_PROJECTION, "_id=?", new String[] { String.valueOf(threadId) }, null);
+ if (thread != null) {
+ try {
+ if (thread.moveToFirst()) {
+ // recipientIds will be a space-separated list of ids into the
+ // canonical addresses table.
+ return thread.getString(RECIPIENT_IDS);
+ }
+ } finally {
+ thread.close();
+ }
+ }
+ return null;
+ }
+
+ private static final Uri SINGLE_CANONICAL_ADDRESS_URI =
+ Uri.parse("content://mms-sms/canonical-address");
+
+ private static List<String> getAddresses(final Context context, final String spaceSepIds) {
+ final List<String> numbers = new ArrayList<String>();
+ final String[] ids = spaceSepIds.split(" ");
+ for (final String id : ids) {
+ long longId;
+
+ try {
+ longId = Long.parseLong(id);
+ if (longId < 0) {
+ LogUtil.e(TAG, "MmsUtils.getAddresses: invalid id " + longId);
+ continue;
+ }
+ } catch (final NumberFormatException ex) {
+ LogUtil.e(TAG, "MmsUtils.getAddresses: invalid id. " + ex, ex);
+ // skip this id
+ continue;
+ }
+
+ // TODO: build a single query where we get all the addresses at once.
+ Cursor c = null;
+ try {
+ c = context.getContentResolver().query(
+ ContentUris.withAppendedId(SINGLE_CANONICAL_ADDRESS_URI, longId),
+ null, null, null, null);
+ } catch (final Exception e) {
+ LogUtil.e(TAG, "MmsUtils.getAddresses: query failed for id " + longId, e);
+ }
+ if (c != null) {
+ try {
+ if (c.moveToFirst()) {
+ final String number = c.getString(0);
+ if (!TextUtils.isEmpty(number)) {
+ numbers.add(number);
+ } else {
+ LogUtil.w(TAG, "Canonical MMS/SMS address is empty for id: " + longId);
+ }
+ }
+ } finally {
+ c.close();
+ }
+ }
+ }
+ if (numbers.isEmpty()) {
+ LogUtil.w(TAG, "No MMS addresses found from ids string [" + spaceSepIds + "]");
+ }
+ return numbers;
+ }
+
+ // Get telephony SMS thread ID
+ public static long getOrCreateSmsThreadId(final Context context, final String dest) {
+ // use destinations to determine threadId
+ final Set<String> recipients = new HashSet<String>();
+ recipients.add(dest);
+ try {
+ return MmsSmsUtils.Threads.getOrCreateThreadId(context, recipients);
+ } catch (final IllegalArgumentException e) {
+ LogUtil.e(TAG, "MmsUtils: getting thread id failed: " + e);
+ return -1;
+ }
+ }
+
+ // Get telephony SMS thread ID
+ public static long getOrCreateThreadId(final Context context, final List<String> dests) {
+ if (dests == null || dests.size() == 0) {
+ return -1;
+ }
+ // use destinations to determine threadId
+ final Set<String> recipients = new HashSet<String>(dests);
+ try {
+ return MmsSmsUtils.Threads.getOrCreateThreadId(context, recipients);
+ } catch (final IllegalArgumentException e) {
+ LogUtil.e(TAG, "MmsUtils: getting thread id failed: " + e);
+ return -1;
+ }
+ }
+
+ /**
+ * Add an SMS to the given URI with thread_id specified.
+ *
+ * @param resolver the content resolver to use
+ * @param uri the URI to add the message to
+ * @param subId subId for the receiving sim
+ * @param address the address of the sender
+ * @param body the body of the message
+ * @param subject the psuedo-subject of the message
+ * @param date the timestamp for the message
+ * @param read true if the message has been read, false if not
+ * @param threadId the thread_id of the message
+ * @return the URI for the new message
+ */
+ private static Uri addMessageToUri(final ContentResolver resolver,
+ final Uri uri, final int subId, final String address, final String body,
+ final String subject, final Long date, final boolean read, final boolean seen,
+ final int status, final int type, final long threadId) {
+ final ContentValues values = new ContentValues(7);
+
+ values.put(Telephony.Sms.ADDRESS, address);
+ if (date != null) {
+ values.put(Telephony.Sms.DATE, date);
+ }
+ values.put(Telephony.Sms.READ, read ? 1 : 0);
+ values.put(Telephony.Sms.SEEN, seen ? 1 : 0);
+ values.put(Telephony.Sms.SUBJECT, subject);
+ values.put(Telephony.Sms.BODY, body);
+ if (OsUtil.isAtLeastL_MR1()) {
+ values.put(Telephony.Sms.SUBSCRIPTION_ID, subId);
+ }
+ if (status != Telephony.Sms.STATUS_NONE) {
+ values.put(Telephony.Sms.STATUS, status);
+ }
+ if (type != Telephony.Sms.MESSAGE_TYPE_ALL) {
+ values.put(Telephony.Sms.TYPE, type);
+ }
+ if (threadId != -1L) {
+ values.put(Telephony.Sms.THREAD_ID, threadId);
+ }
+ return resolver.insert(uri, values);
+ }
+
+ // Insert an SMS message to telephony
+ public static Uri insertSmsMessage(final Context context, final Uri uri, final int subId,
+ final String dest, final String text, final long timestamp, final int status,
+ final int type, final long threadId) {
+ Uri response = null;
+ try {
+ response = addMessageToUri(context.getContentResolver(), uri, subId, dest,
+ text, null /* subject */, timestamp, true /* read */,
+ true /* seen */, status, type, threadId);
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "Mmsutils: Inserted SMS message into telephony (type = " + type + ")"
+ + ", uri: " + response);
+ }
+ } catch (final SQLiteException e) {
+ LogUtil.e(TAG, "MmsUtils: persist sms message failure " + e, e);
+ } catch (final IllegalArgumentException e) {
+ LogUtil.e(TAG, "MmsUtils: persist sms message failure " + e, e);
+ }
+ return response;
+ }
+
+ // Update SMS message type in telephony; returns true if it succeeded.
+ public static boolean updateSmsMessageSendingStatus(final Context context, final Uri uri,
+ final int type, final long date) {
+ try {
+ final ContentResolver resolver = context.getContentResolver();
+ final ContentValues values = new ContentValues(2);
+
+ values.put(Telephony.Sms.TYPE, type);
+ values.put(Telephony.Sms.DATE, date);
+ final int cnt = resolver.update(uri, values, null, null);
+ if (cnt == 1) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "Mmsutils: Updated sending SMS " + uri + "; type = " + type
+ + ", date = " + date + " (millis since epoch)");
+ }
+ return true;
+ }
+ } catch (final SQLiteException e) {
+ LogUtil.e(TAG, "MmsUtils: update sms message failure " + e, e);
+ } catch (final IllegalArgumentException e) {
+ LogUtil.e(TAG, "MmsUtils: update sms message failure " + e, e);
+ }
+ return false;
+ }
+
+ // Persist a sent MMS message in telephony
+ private static Uri insertSendReq(final Context context, final GenericPdu pdu, final int subId,
+ final String subPhoneNumber) {
+ final PduPersister persister = PduPersister.getPduPersister(context);
+ Uri uri = null;
+ try {
+ // Persist the PDU
+ uri = persister.persist(
+ pdu,
+ Mms.Sent.CONTENT_URI,
+ subId,
+ subPhoneNumber,
+ null/*preOpenedFiles*/);
+ // Update mms table to reflect sent messages are always seen and read
+ final ContentValues values = new ContentValues(1);
+ values.put(Mms.READ, 1);
+ values.put(Mms.SEEN, 1);
+ SqliteWrapper.update(context, context.getContentResolver(), uri, values, null, null);
+ } catch (final MmsException e) {
+ LogUtil.e(TAG, "MmsUtils: persist mms sent message failure " + e, e);
+ }
+ return uri;
+ }
+
+ // Persist a received MMS message in telephony
+ public static Uri insertReceivedMmsMessage(final Context context,
+ final RetrieveConf retrieveConf, final int subId, final String subPhoneNumber,
+ final long receivedTimestampInSeconds, final String contentLocation) {
+ final PduPersister persister = PduPersister.getPduPersister(context);
+ Uri uri = null;
+ try {
+ uri = persister.persist(
+ retrieveConf,
+ Mms.Inbox.CONTENT_URI,
+ subId,
+ subPhoneNumber,
+ null/*preOpenedFiles*/);
+
+ final ContentValues values = new ContentValues(2);
+ // Update mms table with local time instead of PDU time
+ values.put(Mms.DATE, receivedTimestampInSeconds);
+ // Also update the content location field from NotificationInd so that
+ // wap push dedup would work even after the wap push is deleted
+ values.put(Mms.CONTENT_LOCATION, contentLocation);
+ SqliteWrapper.update(context, context.getContentResolver(), uri, values, null, null);
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "MmsUtils: Inserted MMS message into telephony, uri: " + uri);
+ }
+ } catch (final MmsException e) {
+ LogUtil.e(TAG, "MmsUtils: persist mms received message failure " + e, e);
+ // Just returns empty uri to RetrieveMmsRequest, which triggers a permanent failure
+ } catch (final SQLiteException e) {
+ LogUtil.e(TAG, "MmsUtils: update mms received message failure " + e, e);
+ // Time update failure is ignored.
+ }
+ return uri;
+ }
+
+ // Update MMS message type in telephony; returns true if it succeeded.
+ public static boolean updateMmsMessageSendingStatus(final Context context, final Uri uri,
+ final int box, final long timestampInMillis) {
+ try {
+ final ContentResolver resolver = context.getContentResolver();
+ final ContentValues values = new ContentValues();
+
+ final long timestampInSeconds = timestampInMillis / 1000L;
+ values.put(Telephony.Mms.MESSAGE_BOX, box);
+ values.put(Telephony.Mms.DATE, timestampInSeconds);
+ final int cnt = resolver.update(uri, values, null, null);
+ if (cnt == 1) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "Mmsutils: Updated sending MMS " + uri + "; box = " + box
+ + ", date = " + timestampInSeconds + " (secs since epoch)");
+ }
+ return true;
+ }
+ } catch (final SQLiteException e) {
+ LogUtil.e(TAG, "MmsUtils: update mms message failure " + e, e);
+ } catch (final IllegalArgumentException e) {
+ LogUtil.e(TAG, "MmsUtils: update mms message failure " + e, e);
+ }
+ return false;
+ }
+
+ /**
+ * Parse values from a received sms message
+ *
+ * @param context
+ * @param msgs The received sms message content
+ * @param error The received sms error
+ * @return Parsed values from the message
+ */
+ public static ContentValues parseReceivedSmsMessage(
+ final Context context, final SmsMessage[] msgs, final int error) {
+ final SmsMessage sms = msgs[0];
+ final ContentValues values = new ContentValues();
+
+ values.put(Sms.ADDRESS, sms.getDisplayOriginatingAddress());
+ values.put(Sms.BODY, buildMessageBodyFromPdus(msgs));
+ if (MmsUtils.hasSmsDateSentColumn()) {
+ // TODO:: The boxing here seems unnecessary.
+ values.put(Sms.DATE_SENT, Long.valueOf(sms.getTimestampMillis()));
+ }
+ values.put(Sms.PROTOCOL, sms.getProtocolIdentifier());
+ if (sms.getPseudoSubject().length() > 0) {
+ values.put(Sms.SUBJECT, sms.getPseudoSubject());
+ }
+ values.put(Sms.REPLY_PATH_PRESENT, sms.isReplyPathPresent() ? 1 : 0);
+ values.put(Sms.SERVICE_CENTER, sms.getServiceCenterAddress());
+ // Error code
+ values.put(Sms.ERROR_CODE, error);
+
+ return values;
+ }
+
+ // Some providers send formfeeds in their messages. Convert those formfeeds to newlines.
+ private static String replaceFormFeeds(final String s) {
+ return s == null ? "" : s.replace('\f', '\n');
+ }
+
+ // Parse the message body from message PDUs
+ private static String buildMessageBodyFromPdus(final SmsMessage[] msgs) {
+ if (msgs.length == 1) {
+ // There is only one part, so grab the body directly.
+ return replaceFormFeeds(msgs[0].getDisplayMessageBody());
+ } else {
+ // Build up the body from the parts.
+ final StringBuilder body = new StringBuilder();
+ for (final SmsMessage msg : msgs) {
+ try {
+ // getDisplayMessageBody() can NPE if mWrappedMessage inside is null.
+ body.append(msg.getDisplayMessageBody());
+ } catch (final NullPointerException e) {
+ // Nothing to do
+ }
+ }
+ return replaceFormFeeds(body.toString());
+ }
+ }
+
+ // Parse the message date
+ public static Long getMessageDate(final SmsMessage sms, long now) {
+ // Use now for the timestamp to avoid confusion with clock
+ // drift between the handset and the SMSC.
+ // Check to make sure the system is giving us a non-bogus time.
+ final Calendar buildDate = new GregorianCalendar(2011, 8, 18); // 18 Sep 2011
+ final Calendar nowDate = new GregorianCalendar();
+ nowDate.setTimeInMillis(now);
+ if (nowDate.before(buildDate)) {
+ // It looks like our system clock isn't set yet because the current time right now
+ // is before an arbitrary time we made this build. Instead of inserting a bogus
+ // receive time in this case, use the timestamp of when the message was sent.
+ now = sms.getTimestampMillis();
+ }
+ return now;
+ }
+
+ /**
+ * cleanseMmsSubject will take a subject that's says, "<Subject: no subject>", and return
+ * a null string. Otherwise it will return the original subject string.
+ * @param resources So the function can grab string resources
+ * @param subject the raw subject
+ * @return
+ */
+ public static String cleanseMmsSubject(final Resources resources, final String subject) {
+ if (TextUtils.isEmpty(subject)) {
+ return null;
+ }
+ if (sNoSubjectStrings == null) {
+ sNoSubjectStrings =
+ resources.getStringArray(R.array.empty_subject_strings);
+ }
+ for (final String noSubjectString : sNoSubjectStrings) {
+ if (subject.equalsIgnoreCase(noSubjectString)) {
+ return null;
+ }
+ }
+ return subject;
+ }
+
+ // return a semicolon separated list of phone numbers from a smsto: uri.
+ public static String getSmsRecipients(final Uri uri) {
+ String recipients = uri.getSchemeSpecificPart();
+ final int pos = recipients.indexOf('?');
+ if (pos != -1) {
+ recipients = recipients.substring(0, pos);
+ }
+ recipients = replaceUnicodeDigits(recipients).replace(',', ';');
+ return recipients;
+ }
+
+ // This function was lifted from Telephony.PhoneNumberUtils because it was @hide
+ /**
+ * Replace arabic/unicode digits with decimal digits.
+ * @param number
+ * the number to be normalized.
+ * @return the replaced number.
+ */
+ private static String replaceUnicodeDigits(final String number) {
+ final StringBuilder normalizedDigits = new StringBuilder(number.length());
+ for (final char c : number.toCharArray()) {
+ final int digit = Character.digit(c, 10);
+ if (digit != -1) {
+ normalizedDigits.append(digit);
+ } else {
+ normalizedDigits.append(c);
+ }
+ }
+ return normalizedDigits.toString();
+ }
+
+ /**
+ * @return Whether the data roaming is enabled
+ */
+ private static boolean isDataRoamingEnabled() {
+ boolean dataRoamingEnabled = false;
+ final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
+ if (OsUtil.isAtLeastJB_MR1()) {
+ dataRoamingEnabled = (Settings.Global.getInt(cr, Settings.Global.DATA_ROAMING, 0) != 0);
+ } else {
+ dataRoamingEnabled = (Settings.System.getInt(cr, Settings.System.DATA_ROAMING, 0) != 0);
+ }
+ return dataRoamingEnabled;
+ }
+
+ /**
+ * @return Whether to auto retrieve MMS
+ */
+ public static boolean allowMmsAutoRetrieve(final int subId) {
+ final Context context = Factory.get().getApplicationContext();
+ final Resources resources = context.getResources();
+ final BuglePrefs prefs = BuglePrefs.getSubscriptionPrefs(subId);
+ final boolean autoRetrieve = prefs.getBoolean(
+ resources.getString(R.string.auto_retrieve_mms_pref_key),
+ resources.getBoolean(R.bool.auto_retrieve_mms_pref_default));
+ if (autoRetrieve) {
+ final boolean autoRetrieveInRoaming = prefs.getBoolean(
+ resources.getString(R.string.auto_retrieve_mms_when_roaming_pref_key),
+ resources.getBoolean(R.bool.auto_retrieve_mms_when_roaming_pref_default));
+ final PhoneUtils phoneUtils = PhoneUtils.get(subId);
+ if ((autoRetrieveInRoaming && phoneUtils.isDataRoamingEnabled())
+ || !phoneUtils.isRoaming()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Parse the message row id from a message Uri.
+ *
+ * @param messageUri The input Uri
+ * @return The message row id if valid, otherwise -1
+ */
+ public static long parseRowIdFromMessageUri(final Uri messageUri) {
+ try {
+ if (messageUri != null) {
+ return ContentUris.parseId(messageUri);
+ }
+ } catch (final UnsupportedOperationException e) {
+ // Nothing to do
+ } catch (final NumberFormatException e) {
+ // Nothing to do
+ }
+ return -1;
+ }
+
+ public static SmsMessage getSmsMessageFromDeliveryReport(final Intent intent) {
+ final byte[] pdu = intent.getByteArrayExtra("pdu");
+ return SmsMessage.createFromPdu(pdu);
+ }
+
+ /**
+ * Update the status and date_sent column of sms message in telephony provider
+ *
+ * @param smsMessageUri
+ * @param status
+ * @param timeSentInMillis
+ */
+ public static void updateSmsStatusAndDateSent(final Uri smsMessageUri, final int status,
+ final long timeSentInMillis) {
+ if (smsMessageUri == null) {
+ return;
+ }
+ final ContentValues values = new ContentValues();
+ values.put(Sms.STATUS, status);
+ if (MmsUtils.hasSmsDateSentColumn()) {
+ values.put(Sms.DATE_SENT, timeSentInMillis);
+ }
+ final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver();
+ resolver.update(smsMessageUri, values, null/*where*/, null/*selectionArgs*/);
+ }
+
+ /**
+ * Get the SQL selection statement for matching messages with media.
+ *
+ * Example for MMS part table:
+ * "((ct LIKE 'image/%')
+ * OR (ct LIKE 'video/%')
+ * OR (ct LIKE 'audio/%')
+ * OR (ct='application/ogg'))
+ *
+ * @param contentTypeColumn The content-type column name
+ * @return The SQL selection statement for matching media types: image, video, audio
+ */
+ public static String getMediaTypeSelectionSql(final String contentTypeColumn) {
+ return String.format(
+ Locale.US,
+ "((%s LIKE '%s') OR (%s LIKE '%s') OR (%s LIKE '%s') OR (%s='%s'))",
+ contentTypeColumn,
+ "image/%",
+ contentTypeColumn,
+ "video/%",
+ contentTypeColumn,
+ "audio/%",
+ contentTypeColumn,
+ ContentType.AUDIO_OGG);
+ }
+
+ // Max number of operands per SQL query for deleting SMS messages
+ public static final int MAX_IDS_PER_QUERY = 128;
+
+ /**
+ * Delete MMS messages with media parts.
+ *
+ * Because the telephony provider constraints, we can't use JOIN and delete messages in one
+ * shot. We have to do a query first and then batch delete the messages based on IDs.
+ *
+ * @return The count of messages deleted.
+ */
+ public static int deleteMediaMessages() {
+ // Do a query first
+ //
+ // The WHERE clause has two parts:
+ // The first part is to select the exact same types of MMS messages as when we import them
+ // (so that we don't delete messages that are not in local database)
+ // The second part is to select MMS with media parts, including image, video and audio
+ final String selection = String.format(
+ Locale.US,
+ "%s AND (%s IN (SELECT %s FROM part WHERE %s))",
+ getMmsTypeSelectionSql(),
+ Mms._ID,
+ Mms.Part.MSG_ID,
+ getMediaTypeSelectionSql(Mms.Part.CONTENT_TYPE));
+ final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver();
+ final Cursor cursor = resolver.query(Mms.CONTENT_URI,
+ new String[]{ Mms._ID },
+ selection,
+ null/*selectionArgs*/,
+ null/*sortOrder*/);
+ int deleted = 0;
+ if (cursor != null) {
+ final long[] messageIds = new long[cursor.getCount()];
+ try {
+ int i = 0;
+ while (cursor.moveToNext()) {
+ messageIds[i++] = cursor.getLong(0);
+ }
+ } finally {
+ cursor.close();
+ }
+ final int totalIds = messageIds.length;
+ if (totalIds > 0) {
+ // Batch delete the messages using IDs
+ // We don't want to send all IDs at once since there is a limit on SQL statement
+ for (int start = 0; start < totalIds; start += MAX_IDS_PER_QUERY) {
+ final int end = Math.min(start + MAX_IDS_PER_QUERY, totalIds); // excluding
+ final int count = end - start;
+ final String batchSelection = String.format(
+ Locale.US,
+ "%s IN %s",
+ Mms._ID,
+ getSqlInOperand(count));
+ final String[] batchSelectionArgs =
+ getSqlInOperandArgs(messageIds, start, count);
+ final int deletedForBatch = resolver.delete(
+ Mms.CONTENT_URI,
+ batchSelection,
+ batchSelectionArgs);
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "deleteMediaMessages: deleting IDs = "
+ + Joiner.on(',').skipNulls().join(batchSelectionArgs)
+ + ", deleted = " + deletedForBatch);
+ }
+ deleted += deletedForBatch;
+ }
+ }
+ }
+ return deleted;
+ }
+
+ /**
+ * Get the (?,?,...) thing for the SQL IN operator by a count
+ *
+ * @param count
+ * @return
+ */
+ public static String getSqlInOperand(final int count) {
+ if (count <= 0) {
+ return null;
+ }
+ final StringBuilder sb = new StringBuilder();
+ sb.append("(?");
+ for (int i = 0; i < count - 1; i++) {
+ sb.append(",?");
+ }
+ sb.append(")");
+ return sb.toString();
+ }
+
+ /**
+ * Get the args for SQL IN operator from a long ID array
+ *
+ * @param ids The original long id array
+ * @param start Start of the ids to fill the args
+ * @param count Number of ids to pack
+ * @return The long array with the id args
+ */
+ private static String[] getSqlInOperandArgs(
+ final long[] ids, final int start, final int count) {
+ if (count <= 0) {
+ return null;
+ }
+ final String[] args = new String[count];
+ for (int i = 0; i < count; i++) {
+ args[i] = Long.toString(ids[start + i]);
+ }
+ return args;
+ }
+
+ /**
+ * Delete SMS and MMS messages that are earlier than a specific timestamp
+ *
+ * @param cutOffTimestampInMillis The cut-off timestamp
+ * @return Total number of messages deleted.
+ */
+ public static int deleteMessagesOlderThan(final long cutOffTimestampInMillis) {
+ int deleted = 0;
+ final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver();
+ // Delete old SMS
+ final String smsSelection = String.format(
+ Locale.US,
+ "%s AND (%s<=%d)",
+ getSmsTypeSelectionSql(),
+ Sms.DATE,
+ cutOffTimestampInMillis);
+ deleted += resolver.delete(Sms.CONTENT_URI, smsSelection, null/*selectionArgs*/);
+ // Delete old MMS
+ final String mmsSelection = String.format(
+ Locale.US,
+ "%s AND (%s<=%d)",
+ getMmsTypeSelectionSql(),
+ Mms.DATE,
+ cutOffTimestampInMillis / 1000L);
+ deleted += resolver.delete(Mms.CONTENT_URI, mmsSelection, null/*selectionArgs*/);
+ return deleted;
+ }
+
+ /**
+ * Update the read status of SMS/MMS messages by thread and timestamp
+ *
+ * @param threadId The thread of sms/mms to change
+ * @param timestampInMillis Change the status before this timestamp
+ */
+ public static void updateSmsReadStatus(final long threadId, final long timestampInMillis) {
+ final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver();
+ final ContentValues values = new ContentValues();
+ values.put("read", 1);
+ values.put("seen", 1); /* If you read it you saw it */
+ final String smsSelection = String.format(
+ Locale.US,
+ "%s=%d AND %s<=%d AND %s=0",
+ Sms.THREAD_ID,
+ threadId,
+ Sms.DATE,
+ timestampInMillis,
+ Sms.READ);
+ resolver.update(
+ Sms.CONTENT_URI,
+ values,
+ smsSelection,
+ null/*selectionArgs*/);
+ final String mmsSelection = String.format(
+ Locale.US,
+ "%s=%d AND %s<=%d AND %s=0",
+ Mms.THREAD_ID,
+ threadId,
+ Mms.DATE,
+ timestampInMillis / 1000L,
+ Mms.READ);
+ resolver.update(
+ Mms.CONTENT_URI,
+ values,
+ mmsSelection,
+ null/*selectionArgs*/);
+ }
+
+ /**
+ * Update the read status of a single MMS message by its URI
+ *
+ * @param mmsUri
+ * @param read
+ */
+ public static void updateReadStatusForMmsMessage(final Uri mmsUri, final boolean read) {
+ final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver();
+ final ContentValues values = new ContentValues();
+ values.put(Mms.READ, read ? 1 : 0);
+ resolver.update(mmsUri, values, null/*where*/, null/*selectionArgs*/);
+ }
+
+ public static class AttachmentInfo {
+ public String mUrl;
+ public String mContentType;
+ public int mWidth;
+ public int mHeight;
+ }
+
+ /**
+ * Convert byte array to Java String using a charset name
+ *
+ * @param bytes
+ * @param charsetName
+ * @return
+ */
+ public static String bytesToString(final byte[] bytes, final String charsetName) {
+ if (bytes == null) {
+ return null;
+ }
+ try {
+ return new String(bytes, charsetName);
+ } catch (final UnsupportedEncodingException e) {
+ LogUtil.e(TAG, "MmsUtils.bytesToString: " + e, e);
+ return new String(bytes);
+ }
+ }
+
+ /**
+ * Convert a Java String to byte array using a charset name
+ *
+ * @param string
+ * @param charsetName
+ * @return
+ */
+ public static byte[] stringToBytes(final String string, final String charsetName) {
+ if (string == null) {
+ return null;
+ }
+ try {
+ return string.getBytes(charsetName);
+ } catch (final UnsupportedEncodingException e) {
+ LogUtil.e(TAG, "MmsUtils.stringToBytes: " + e, e);
+ return string.getBytes();
+ }
+ }
+
+ private static final String[] TEST_DATE_SENT_PROJECTION = new String[] { Sms.DATE_SENT };
+ private static Boolean sHasSmsDateSentColumn = null;
+ /**
+ * Check if date_sent column exists on ICS and above devices. We need to do a test
+ * query to figure that out since on some ICS+ devices, somehow the date_sent column does
+ * not exist. http://b/17629135 tracks the associated compliance test.
+ *
+ * @return Whether "date_sent" column exists in sms table
+ */
+ public static boolean hasSmsDateSentColumn() {
+ if (sHasSmsDateSentColumn == null) {
+ Cursor cursor = null;
+ try {
+ final Context context = Factory.get().getApplicationContext();
+ final ContentResolver resolver = context.getContentResolver();
+ cursor = SqliteWrapper.query(
+ context,
+ resolver,
+ Sms.CONTENT_URI,
+ TEST_DATE_SENT_PROJECTION,
+ null/*selection*/,
+ null/*selectionArgs*/,
+ Sms.DATE_SENT + " ASC LIMIT 1");
+ sHasSmsDateSentColumn = true;
+ } catch (final SQLiteException e) {
+ LogUtil.w(TAG, "date_sent in sms table does not exist", e);
+ sHasSmsDateSentColumn = false;
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+ return sHasSmsDateSentColumn;
+ }
+
+ private static final String[] TEST_CARRIERS_PROJECTION =
+ new String[] { Telephony.Carriers.MMSC };
+ private static Boolean sUseSystemApn = null;
+ /**
+ * Check if we can access the APN data in the Telephony provider. Access was restricted in
+ * JB MR1 (and some JB MR2) devices. If we can't access the APN, we have to fall back and use
+ * a private table in our own app.
+ *
+ * @return Whether we can access the system APN table
+ */
+ public static boolean useSystemApnTable() {
+ if (sUseSystemApn == null) {
+ Cursor cursor = null;
+ try {
+ final Context context = Factory.get().getApplicationContext();
+ final ContentResolver resolver = context.getContentResolver();
+ cursor = SqliteWrapper.query(
+ context,
+ resolver,
+ Telephony.Carriers.CONTENT_URI,
+ TEST_CARRIERS_PROJECTION,
+ null/*selection*/,
+ null/*selectionArgs*/,
+ null);
+ sUseSystemApn = true;
+ } catch (final SecurityException e) {
+ LogUtil.w(TAG, "Can't access system APN, using internal table", e);
+ sUseSystemApn = false;
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+ return sUseSystemApn;
+ }
+
+ // For the internal debugger only
+ public static void setUseSystemApnTable(final boolean turnOn) {
+ if (!turnOn) {
+ // We're not turning on to the system table. Instead, we're using our internal table.
+ final int osVersion = OsUtil.getApiVersion();
+ if (osVersion != android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ // We're turning on local APNs on a device where we wouldn't normally have the
+ // local APN table. Build it here.
+
+ final SQLiteDatabase database = ApnDatabase.getApnDatabase().getWritableDatabase();
+
+ // Do we already have the table?
+ Cursor cursor = null;
+ try {
+ cursor = database.query(ApnDatabase.APN_TABLE,
+ ApnDatabase.APN_PROJECTION,
+ null, null, null, null, null, null);
+ } catch (final Exception e) {
+ // Apparently there's no table, create it now.
+ ApnDatabase.forceBuildAndLoadApnTables();
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+ }
+ sUseSystemApn = turnOn;
+ }
+
+ /**
+ * Checks if we should dump sms, based on both the setting and the global debug
+ * flag
+ *
+ * @return if dump sms is enabled
+ */
+ public static boolean isDumpSmsEnabled() {
+ if (!DebugUtils.isDebugEnabled()) {
+ return false;
+ }
+ return getDumpSmsOrMmsPref(R.string.dump_sms_pref_key, R.bool.dump_sms_pref_default);
+ }
+
+ /**
+ * Checks if we should dump mms, based on both the setting and the global debug
+ * flag
+ *
+ * @return if dump mms is enabled
+ */
+ public static boolean isDumpMmsEnabled() {
+ if (!DebugUtils.isDebugEnabled()) {
+ return false;
+ }
+ return getDumpSmsOrMmsPref(R.string.dump_mms_pref_key, R.bool.dump_mms_pref_default);
+ }
+
+ /**
+ * Load the value of dump sms or mms setting preference
+ */
+ private static boolean getDumpSmsOrMmsPref(final int prefKeyRes, final int defaultKeyRes) {
+ final Context context = Factory.get().getApplicationContext();
+ final Resources resources = context.getResources();
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ final String key = resources.getString(prefKeyRes);
+ final boolean defaultValue = resources.getBoolean(defaultKeyRes);
+ return prefs.getBoolean(key, defaultValue);
+ }
+
+ public static final Uri MMS_PART_CONTENT_URI = Uri.parse("content://mms/part");
+
+ /**
+ * Load MMS from telephony
+ *
+ * @param mmsUri The MMS pdu Uri
+ * @return A memory copy of the MMS pdu including parts (but not addresses)
+ */
+ public static DatabaseMessages.MmsMessage loadMms(final Uri mmsUri) {
+ final Context context = Factory.get().getApplicationContext();
+ final ContentResolver resolver = context.getContentResolver();
+ DatabaseMessages.MmsMessage mms = null;
+ Cursor cursor = null;
+ // Load pdu first
+ try {
+ cursor = SqliteWrapper.query(context, resolver,
+ mmsUri,
+ DatabaseMessages.MmsMessage.getProjection(),
+ null/*selection*/, null/*selectionArgs*/, null/*sortOrder*/);
+ if (cursor != null && cursor.moveToFirst()) {
+ mms = DatabaseMessages.MmsMessage.get(cursor);
+ }
+ } catch (final SQLiteException e) {
+ LogUtil.e(TAG, "MmsLoader: query pdu failure: " + e, e);
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ if (mms == null) {
+ return null;
+ }
+ // Load parts except SMIL
+ // TODO: we may need to load SMIL part in the future.
+ final long rowId = MmsUtils.parseRowIdFromMessageUri(mmsUri);
+ final String selection = String.format(
+ Locale.US,
+ "%s != '%s' AND %s = ?",
+ Mms.Part.CONTENT_TYPE,
+ ContentType.APP_SMIL,
+ Mms.Part.MSG_ID);
+ cursor = null;
+ try {
+ cursor = SqliteWrapper.query(context, resolver,
+ MMS_PART_CONTENT_URI,
+ DatabaseMessages.MmsPart.PROJECTION,
+ selection,
+ new String[] { Long.toString(rowId) },
+ null/*sortOrder*/);
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ mms.addPart(DatabaseMessages.MmsPart.get(cursor, true/*loadMedia*/));
+ }
+ }
+ } catch (final SQLiteException e) {
+ LogUtil.e(TAG, "MmsLoader: query parts failure: " + e, e);
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return mms;
+ }
+
+ /**
+ * Get the sender of an MMS message
+ *
+ * @param recipients The recipient list of the message
+ * @param mmsUri The pdu uri of the MMS
+ * @return The sender phone number of the MMS
+ */
+ public static String getMmsSender(final List<String> recipients, final String mmsUri) {
+ final Context context = Factory.get().getApplicationContext();
+ // We try to avoid the database query.
+ // If this is a 1v1 conv., then the other party is the sender
+ if (recipients != null && recipients.size() == 1) {
+ return recipients.get(0);
+ }
+ // Otherwise, we have to query the MMS addr table for sender address
+ // This should only be done for a received group mms message
+ final Cursor cursor = SqliteWrapper.query(
+ context,
+ context.getContentResolver(),
+ Uri.withAppendedPath(Uri.parse(mmsUri), "addr"),
+ new String[] { Mms.Addr.ADDRESS, Mms.Addr.CHARSET },
+ Mms.Addr.TYPE + "=" + PduHeaders.FROM,
+ null/*selectionArgs*/,
+ null/*sortOrder*/);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ return DatabaseMessages.MmsAddr.get(cursor);
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+
+ public static int bugleStatusForMms(final boolean isOutgoing, final boolean isNotification,
+ final int messageBox) {
+ int bugleStatus = MessageData.BUGLE_STATUS_UNKNOWN;
+ // For a message we sync either
+ if (isOutgoing) {
+ if (messageBox == Mms.MESSAGE_BOX_OUTBOX || messageBox == Mms.MESSAGE_BOX_FAILED) {
+ // Not sent counts as failed and available for manual resend
+ bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_FAILED;
+ } else {
+ // Otherwise outgoing message is complete
+ bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE;
+ }
+ } else if (isNotification) {
+ // Incoming MMS notifications we sync count as failed and available for manual download
+ bugleStatus = MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD;
+ } else {
+ // Other incoming MMS messages are complete
+ bugleStatus = MessageData.BUGLE_STATUS_INCOMING_COMPLETE;
+ }
+ return bugleStatus;
+ }
+
+ public static MessageData createMmsMessage(final DatabaseMessages.MmsMessage mms,
+ final String conversationId, final String participantId, final String selfId,
+ final int bugleStatus) {
+ Assert.notNull(mms);
+ final boolean isNotification = (mms.mMmsMessageType ==
+ PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND);
+ final int rawMmsStatus = (bugleStatus < MessageData.BUGLE_STATUS_FIRST_INCOMING
+ ? mms.mRetrieveStatus : mms.mResponseStatus);
+
+ final MessageData message = MessageData.createMmsMessage(mms.getUri(),
+ participantId, selfId, conversationId, isNotification, bugleStatus,
+ mms.mContentLocation, mms.mTransactionId, mms.mPriority, mms.mSubject,
+ mms.mSeen, mms.mRead, mms.getSize(), rawMmsStatus,
+ mms.mExpiryInMillis, mms.mSentTimestampInMillis, mms.mTimestampInMillis);
+
+ for (final DatabaseMessages.MmsPart part : mms.mParts) {
+ final MessagePartData messagePart = MmsUtils.createMmsMessagePart(part);
+ // Import media and text parts (skip SMIL and others)
+ if (messagePart != null) {
+ message.addPart(messagePart);
+ }
+ }
+
+ if (!message.getParts().iterator().hasNext()) {
+ message.addPart(MessagePartData.createEmptyMessagePart());
+ }
+
+ return message;
+ }
+
+ public static MessagePartData createMmsMessagePart(final DatabaseMessages.MmsPart part) {
+ MessagePartData messagePart = null;
+ if (part.isText()) {
+ final int mmsTextLengthLimit =
+ BugleGservices.get().getInt(BugleGservicesKeys.MMS_TEXT_LIMIT,
+ BugleGservicesKeys.MMS_TEXT_LIMIT_DEFAULT);
+ String text = part.mText;
+ if (text != null && text.length() > mmsTextLengthLimit) {
+ // Limit the text to a reasonable value. We ran into a situation where a vcard
+ // with a photo was sent as plain text. The massive amount of text caused the
+ // app to hang, ANR, and eventually crash in native text code.
+ text = text.substring(0, mmsTextLengthLimit);
+ }
+ messagePart = MessagePartData.createTextMessagePart(text);
+ } else if (part.isMedia()) {
+ messagePart = MessagePartData.createMediaMessagePart(part.mContentType,
+ part.getDataUri(), MessagePartData.UNSPECIFIED_SIZE,
+ MessagePartData.UNSPECIFIED_SIZE);
+ }
+ return messagePart;
+ }
+
+ public static class StatusPlusUri {
+ // The request status to be as the result of the operation
+ // e.g. MMS_REQUEST_MANUAL_RETRY
+ public final int status;
+ // The raw telephony status
+ public final int rawStatus;
+ // The raw telephony URI
+ public final Uri uri;
+ // The operation result code from system api invocation (sent by system)
+ // or mapped from internal exception (sent by app)
+ public final int resultCode;
+
+ public StatusPlusUri(final int status, final int rawStatus, final Uri uri) {
+ this.status = status;
+ this.rawStatus = rawStatus;
+ this.uri = uri;
+ resultCode = MessageData.UNKNOWN_RESULT_CODE;
+ }
+
+ public StatusPlusUri(final int status, final int rawStatus, final Uri uri,
+ final int resultCode) {
+ this.status = status;
+ this.rawStatus = rawStatus;
+ this.uri = uri;
+ this.resultCode = resultCode;
+ }
+ }
+
+ public static class SendReqResp {
+ public SendReq mSendReq;
+ public SendConf mSendConf;
+
+ public SendReqResp(final SendReq sendReq, final SendConf sendConf) {
+ mSendReq = sendReq;
+ mSendConf = sendConf;
+ }
+ }
+
+ /**
+ * Returned when sending/downloading MMS via platform APIs. In that case, we have to wait to
+ * receive the pending intent to determine status.
+ */
+ public static final StatusPlusUri STATUS_PENDING = new StatusPlusUri(-1, -1, null);
+
+ public static StatusPlusUri downloadMmsMessage(final Context context, final Uri notificationUri,
+ final int subId, final String subPhoneNumber, final String transactionId,
+ final String contentLocation, final boolean autoDownload,
+ final long receivedTimestampInSeconds, Bundle extras) {
+ if (TextUtils.isEmpty(contentLocation)) {
+ LogUtil.e(TAG, "MmsUtils: Download from empty content location URL");
+ return new StatusPlusUri(
+ MMS_REQUEST_NO_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, null);
+ }
+ if (!isMmsDataAvailable(subId)) {
+ LogUtil.e(TAG,
+ "MmsUtils: failed to download message, no data available");
+ return new StatusPlusUri(MMS_REQUEST_MANUAL_RETRY,
+ MessageData.RAW_TELEPHONY_STATUS_UNDEFINED,
+ null,
+ SmsManager.MMS_ERROR_NO_DATA_NETWORK);
+ }
+ int status = MMS_REQUEST_MANUAL_RETRY;
+ try {
+ RetrieveConf retrieveConf = null;
+ if (DebugUtils.isDebugEnabled() &&
+ MediaScratchFileProvider
+ .isMediaScratchSpaceUri(Uri.parse(contentLocation))) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "MmsUtils: Reading MMS from dump file: " + contentLocation);
+ }
+ final String fileName = Uri.parse(contentLocation).getPathSegments().get(1);
+ final byte[] data = DebugUtils.receiveFromDumpFile(fileName);
+ retrieveConf = receiveFromDumpFile(data);
+ } else {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "MmsUtils: Downloading MMS via MMS lib API; notification "
+ + "message: " + notificationUri);
+ }
+ if (OsUtil.isAtLeastL_MR1()) {
+ if (subId < 0) {
+ LogUtil.e(TAG, "MmsUtils: Incoming MMS came from unknown SIM");
+ throw new MmsFailureException(MMS_REQUEST_NO_RETRY,
+ "Message from unknown SIM");
+ }
+ } else {
+ Assert.isTrue(subId == ParticipantData.DEFAULT_SELF_SUB_ID);
+ }
+ if (extras == null) {
+ extras = new Bundle();
+ }
+ extras.putParcelable(DownloadMmsAction.EXTRA_NOTIFICATION_URI, notificationUri);
+ extras.putInt(DownloadMmsAction.EXTRA_SUB_ID, subId);
+ extras.putString(DownloadMmsAction.EXTRA_SUB_PHONE_NUMBER, subPhoneNumber);
+ extras.putString(DownloadMmsAction.EXTRA_TRANSACTION_ID, transactionId);
+ extras.putString(DownloadMmsAction.EXTRA_CONTENT_LOCATION, contentLocation);
+ extras.putBoolean(DownloadMmsAction.EXTRA_AUTO_DOWNLOAD, autoDownload);
+ extras.putLong(DownloadMmsAction.EXTRA_RECEIVED_TIMESTAMP,
+ receivedTimestampInSeconds);
+
+ MmsSender.downloadMms(context, subId, contentLocation, extras);
+ return STATUS_PENDING; // Download happens asynchronously; no status to return
+ }
+ return insertDownloadedMessageAndSendResponse(context, notificationUri, subId,
+ subPhoneNumber, transactionId, contentLocation, autoDownload,
+ receivedTimestampInSeconds, retrieveConf);
+
+ } catch (final MmsFailureException e) {
+ LogUtil.e(TAG, "MmsUtils: failed to download message " + notificationUri, e);
+ status = e.retryHint;
+ } catch (final InvalidHeaderValueException e) {
+ LogUtil.e(TAG, "MmsUtils: failed to download message " + notificationUri, e);
+ }
+ return new StatusPlusUri(status, PDU_HEADER_VALUE_UNDEFINED, null);
+ }
+
+ public static StatusPlusUri insertDownloadedMessageAndSendResponse(final Context context,
+ final Uri notificationUri, final int subId, final String subPhoneNumber,
+ final String transactionId, final String contentLocation,
+ final boolean autoDownload, final long receivedTimestampInSeconds,
+ final RetrieveConf retrieveConf) {
+ final byte[] transactionIdBytes = stringToBytes(transactionId, "UTF-8");
+ Uri messageUri = null;
+ int status = MMS_REQUEST_MANUAL_RETRY;
+ int retrieveStatus = PDU_HEADER_VALUE_UNDEFINED;
+
+ retrieveStatus = retrieveConf.getRetrieveStatus();
+ if (retrieveStatus == PduHeaders.RETRIEVE_STATUS_OK) {
+ status = MMS_REQUEST_SUCCEEDED;
+ } else if (retrieveStatus >= PduHeaders.RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE &&
+ retrieveStatus < PduHeaders.RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE) {
+ status = MMS_REQUEST_AUTO_RETRY;
+ } else {
+ // else not meant to retry download
+ status = MMS_REQUEST_NO_RETRY;
+ LogUtil.e(TAG, "MmsUtils: failed to retrieve message; retrieveStatus: "
+ + retrieveStatus);
+ }
+ final ContentValues values = new ContentValues(1);
+ values.put(Mms.RETRIEVE_STATUS, retrieveConf.getRetrieveStatus());
+ SqliteWrapper.update(context, context.getContentResolver(),
+ notificationUri, values, null, null);
+
+ if (status == MMS_REQUEST_SUCCEEDED) {
+ // Send response of the notification
+ if (autoDownload) {
+ sendNotifyResponseForMmsDownload(context, subId, transactionIdBytes,
+ contentLocation, PduHeaders.STATUS_RETRIEVED);
+ } else {
+ sendAcknowledgeForMmsDownload(context, subId, transactionIdBytes, contentLocation);
+ }
+
+ // Insert downloaded message into telephony
+ final Uri inboxUri = MmsUtils.insertReceivedMmsMessage(context, retrieveConf, subId,
+ subPhoneNumber, receivedTimestampInSeconds, contentLocation);
+ messageUri = ContentUris.withAppendedId(Mms.CONTENT_URI, ContentUris.parseId(inboxUri));
+ } else if (status == MMS_REQUEST_AUTO_RETRY) {
+ // For a retry do nothing
+ } else if (status == MMS_REQUEST_MANUAL_RETRY && autoDownload) {
+ // Failure from autodownload - just treat like manual download
+ sendNotifyResponseForMmsDownload(context, subId, transactionIdBytes,
+ contentLocation, PduHeaders.STATUS_DEFERRED);
+ }
+ return new StatusPlusUri(status, retrieveStatus, messageUri);
+ }
+
+ /**
+ * Send response for MMS download - catches and ignores errors
+ */
+ public static void sendNotifyResponseForMmsDownload(final Context context, final int subId,
+ final byte[] transactionId, final String contentLocation, final int status) {
+ try {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "MmsUtils: Sending M-NotifyResp.ind for received MMS, status: "
+ + String.format("0x%X", status));
+ }
+ if (contentLocation == null) {
+ LogUtil.w(TAG, "MmsUtils: Can't send NotifyResp; contentLocation is null");
+ return;
+ }
+ if (transactionId == null) {
+ LogUtil.w(TAG, "MmsUtils: Can't send NotifyResp; transaction id is null");
+ return;
+ }
+ if (!isMmsDataAvailable(subId)) {
+ LogUtil.w(TAG, "MmsUtils: Can't send NotifyResp; no data available");
+ return;
+ }
+ MmsSender.sendNotifyResponseForMmsDownload(
+ context, subId, transactionId, contentLocation, status);
+ } catch (final MmsFailureException e) {
+ LogUtil.e(TAG, "sendNotifyResponseForMmsDownload: failed to retrieve message " + e, e);
+ } catch (final InvalidHeaderValueException e) {
+ LogUtil.e(TAG, "sendNotifyResponseForMmsDownload: failed to retrieve message " + e, e);
+ }
+ }
+
+ /**
+ * Send acknowledge for mms download - catched and ignores errors
+ */
+ public static void sendAcknowledgeForMmsDownload(final Context context, final int subId,
+ final byte[] transactionId, final String contentLocation) {
+ try {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "MmsUtils: Sending M-Acknowledge.ind for received MMS");
+ }
+ if (contentLocation == null) {
+ LogUtil.w(TAG, "MmsUtils: Can't send AckInd; contentLocation is null");
+ return;
+ }
+ if (transactionId == null) {
+ LogUtil.w(TAG, "MmsUtils: Can't send AckInd; transaction id is null");
+ return;
+ }
+ if (!isMmsDataAvailable(subId)) {
+ LogUtil.w(TAG, "MmsUtils: Can't send AckInd; no data available");
+ return;
+ }
+ MmsSender.sendAcknowledgeForMmsDownload(context, subId, transactionId, contentLocation);
+ } catch (final MmsFailureException e) {
+ LogUtil.e(TAG, "sendAcknowledgeForMmsDownload: failed to retrieve message " + e, e);
+ } catch (final InvalidHeaderValueException e) {
+ LogUtil.e(TAG, "sendAcknowledgeForMmsDownload: failed to retrieve message " + e, e);
+ }
+ }
+
+ /**
+ * Try parsing a PDU without knowing the carrier. This is useful for importing
+ * MMS or storing draft when carrier info is not available
+ *
+ * @param data The PDU data
+ * @return Parsed PDU, null if failed to parse
+ */
+ private static GenericPdu parsePduForAnyCarrier(final byte[] data) {
+ GenericPdu pdu = null;
+ try {
+ pdu = (new PduParser(data, true/*parseContentDisposition*/)).parse();
+ } catch (final RuntimeException e) {
+ LogUtil.d(TAG, "parsePduForAnyCarrier: Failed to parse PDU with content disposition",
+ e);
+ }
+ if (pdu == null) {
+ try {
+ pdu = (new PduParser(data, false/*parseContentDisposition*/)).parse();
+ } catch (final RuntimeException e) {
+ LogUtil.d(TAG,
+ "parsePduForAnyCarrier: Failed to parse PDU without content disposition",
+ e);
+ }
+ }
+ return pdu;
+ }
+
+ private static RetrieveConf receiveFromDumpFile(final byte[] data) throws MmsFailureException {
+ final GenericPdu pdu = parsePduForAnyCarrier(data);
+ if (pdu == null || !(pdu instanceof RetrieveConf)) {
+ LogUtil.e(TAG, "receiveFromDumpFile: Parsing retrieved PDU failure");
+ throw new MmsFailureException(MMS_REQUEST_MANUAL_RETRY, "Failed reading dump file");
+ }
+ return (RetrieveConf) pdu;
+ }
+
+ private static boolean isMmsDataAvailable(final int subId) {
+ if (OsUtil.isAtLeastL_MR1()) {
+ // L_MR1 above may support sending mms via wifi
+ return true;
+ }
+ final PhoneUtils phoneUtils = PhoneUtils.get(subId);
+ return !phoneUtils.isAirplaneModeOn() && phoneUtils.isMobileDataEnabled();
+ }
+
+ private static boolean isSmsDataAvailable(final int subId) {
+ if (OsUtil.isAtLeastL_MR1()) {
+ // L_MR1 above may support sending sms via wifi
+ return true;
+ }
+ final PhoneUtils phoneUtils = PhoneUtils.get(subId);
+ return !phoneUtils.isAirplaneModeOn();
+ }
+
+ public static boolean isMobileDataEnabled(final int subId) {
+ final PhoneUtils phoneUtils = PhoneUtils.get(subId);
+ return phoneUtils.isMobileDataEnabled();
+ }
+
+ public static boolean isAirplaneModeOn(final int subId) {
+ final PhoneUtils phoneUtils = PhoneUtils.get(subId);
+ return phoneUtils.isAirplaneModeOn();
+ }
+
+ public static StatusPlusUri sendMmsMessage(final Context context, final int subId,
+ final Uri messageUri, final Bundle extras) {
+ int status = MMS_REQUEST_MANUAL_RETRY;
+ int rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED;
+ if (!isMmsDataAvailable(subId)) {
+ LogUtil.w(TAG, "MmsUtils: failed to send message, no data available");
+ return new StatusPlusUri(MMS_REQUEST_MANUAL_RETRY,
+ MessageData.RAW_TELEPHONY_STATUS_UNDEFINED,
+ messageUri,
+ SmsManager.MMS_ERROR_NO_DATA_NETWORK);
+ }
+ final PduPersister persister = PduPersister.getPduPersister(context);
+ try {
+ final SendReq sendReq = (SendReq) persister.load(messageUri);
+ if (sendReq == null) {
+ LogUtil.w(TAG, "MmsUtils: Sending MMS was deleted; uri = " + messageUri);
+ return new StatusPlusUri(MMS_REQUEST_NO_RETRY,
+ MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, messageUri);
+ }
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, String.format("MmsUtils: Sending MMS, message uri: %s", messageUri));
+ }
+ extras.putInt(SendMessageAction.KEY_SUB_ID, subId);
+ MmsSender.sendMms(context, subId, messageUri, sendReq, extras);
+ return STATUS_PENDING;
+ } catch (final MmsFailureException e) {
+ status = e.retryHint;
+ rawStatus = e.rawStatus;
+ LogUtil.e(TAG, "MmsUtils: failed to send message " + e, e);
+ } catch (final InvalidHeaderValueException e) {
+ LogUtil.e(TAG, "MmsUtils: failed to send message " + e, e);
+ } catch (final IllegalArgumentException e) {
+ LogUtil.e(TAG, "MmsUtils: invalid message to send " + e, e);
+ } catch (final MmsException e) {
+ LogUtil.e(TAG, "MmsUtils: failed to send message " + e, e);
+ }
+ // If we get here, some exception occurred
+ return new StatusPlusUri(status, rawStatus, messageUri);
+ }
+
+ public static StatusPlusUri updateSentMmsMessageStatus(final Context context,
+ final Uri messageUri, final SendConf sendConf) {
+ int status = MMS_REQUEST_MANUAL_RETRY;
+ final int respStatus = sendConf.getResponseStatus();
+
+ final ContentValues values = new ContentValues(2);
+ values.put(Mms.RESPONSE_STATUS, respStatus);
+ final byte[] messageId = sendConf.getMessageId();
+ if (messageId != null && messageId.length > 0) {
+ values.put(Mms.MESSAGE_ID, PduPersister.toIsoString(messageId));
+ }
+ SqliteWrapper.update(context, context.getContentResolver(),
+ messageUri, values, null, null);
+ if (respStatus == PduHeaders.RESPONSE_STATUS_OK) {
+ status = MMS_REQUEST_SUCCEEDED;
+ } else if (respStatus == PduHeaders.RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE ||
+ respStatus == PduHeaders.RESPONSE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM ||
+ respStatus == PduHeaders.RESPONSE_STATUS_ERROR_TRANSIENT_PARTIAL_SUCCESS) {
+ status = MMS_REQUEST_AUTO_RETRY;
+ } else {
+ // else permanent failure
+ LogUtil.e(TAG, "MmsUtils: failed to send message; respStatus = "
+ + String.format("0x%X", respStatus));
+ }
+ return new StatusPlusUri(status, respStatus, messageUri);
+ }
+
+ public static void clearMmsStatus(final Context context, final Uri uri) {
+ // Messaging application can leave invalid values in STATUS field of M-Notification.ind
+ // messages. Take this opportunity to clear it.
+ // Downloading status just kept in local db and not reflected into telephony.
+ final ContentValues values = new ContentValues(1);
+ values.putNull(Mms.STATUS);
+ SqliteWrapper.update(context, context.getContentResolver(),
+ uri, values, null, null);
+ }
+
+ // Selection for new dedup algorithm:
+ // ((m_type<>130) OR (exp>NOW)) AND (date>NOW-7d) AND (date<NOW+7d) AND (ct_l=xxxxxx)
+ // i.e. If it is NotificationInd and not expired or not NotificationInd
+ // AND message is received with +/- 7 days from now
+ // AND content location is the input URL
+ private static final String DUP_NOTIFICATION_QUERY_SELECTION =
+ "((" + Mms.MESSAGE_TYPE + "<>?) OR (" + Mms.EXPIRY + ">?)) AND ("
+ + Mms.DATE + ">?) AND (" + Mms.DATE + "<?) AND (" + Mms.CONTENT_LOCATION +
+ "=?)";
+ // Selection for old behavior: only checks NotificationInd and its content location
+ private static final String DUP_NOTIFICATION_QUERY_SELECTION_OLD =
+ "(" + Mms.MESSAGE_TYPE + "=?) AND (" + Mms.CONTENT_LOCATION + "=?)";
+
+ private static final int MAX_RETURN = 32;
+ private static String[] getDupNotifications(final Context context, final NotificationInd nInd) {
+ final byte[] rawLocation = nInd.getContentLocation();
+ if (rawLocation != null) {
+ final String location = new String(rawLocation);
+ // We can not be sure if the content location of an MMS is globally and historically
+ // unique. So we limit the dedup time within the last 7 days
+ // (or configured by gservices remotely). If the same content location shows up after
+ // that, we will download regardless. Duplicated message is better than no message.
+ String selection;
+ String[] selectionArgs;
+ final long timeLimit = BugleGservices.get().getLong(
+ BugleGservicesKeys.MMS_WAP_PUSH_DEDUP_TIME_LIMIT_SECS,
+ BugleGservicesKeys.MMS_WAP_PUSH_DEDUP_TIME_LIMIT_SECS_DEFAULT);
+ if (timeLimit > 0) {
+ // New dedup algorithm
+ selection = DUP_NOTIFICATION_QUERY_SELECTION;
+ final long nowSecs = System.currentTimeMillis() / 1000;
+ final long timeLowerBoundSecs = nowSecs - timeLimit;
+ // Need upper bound to protect against clock change so that a message has a time
+ // stamp in the future
+ final long timeUpperBoundSecs = nowSecs + timeLimit;
+ selectionArgs = new String[] {
+ Integer.toString(PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND),
+ Long.toString(nowSecs),
+ Long.toString(timeLowerBoundSecs),
+ Long.toString(timeUpperBoundSecs),
+ location
+ };
+ } else {
+ // If time limit is 0, we revert back to old behavior in case the new
+ // dedup algorithm behaves badly
+ selection = DUP_NOTIFICATION_QUERY_SELECTION_OLD;
+ selectionArgs = new String[] {
+ Integer.toString(PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND),
+ location
+ };
+ }
+ Cursor cursor = null;
+ try {
+ cursor = SqliteWrapper.query(
+ context, context.getContentResolver(),
+ Mms.CONTENT_URI, new String[] { Mms._ID },
+ selection, selectionArgs, null);
+ final int dupCount = cursor.getCount();
+ if (dupCount > 0) {
+ // We already received the same notification before.
+ // Don't want to return too many dups. It is only for debugging.
+ final int returnCount = dupCount < MAX_RETURN ? dupCount : MAX_RETURN;
+ final String[] dups = new String[returnCount];
+ for (int i = 0; cursor.moveToNext() && i < returnCount; i++) {
+ dups[i] = cursor.getString(0);
+ }
+ return dups;
+ }
+ } catch (final SQLiteException e) {
+ LogUtil.e(TAG, "query failure: " + e, e);
+ } finally {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Try parse the address using RFC822 format. If it fails to parse, then return the
+ * original address
+ *
+ * @param address The MMS ind sender address to parse
+ * @return The real address. If in RFC822 format, returns the correct email.
+ */
+ private static String parsePotentialRfc822EmailAddress(final String address) {
+ if (address == null || !address.contains("@") || !address.contains("<")) {
+ return address;
+ }
+ final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);
+ if (tokens != null && tokens.length > 0) {
+ for (final Rfc822Token token : tokens) {
+ if (token != null && !TextUtils.isEmpty(token.getAddress())) {
+ return token.getAddress();
+ }
+ }
+ }
+ return address;
+ }
+
+ public static DatabaseMessages.MmsMessage processReceivedPdu(final Context context,
+ final byte[] pushData, final int subId, final String subPhoneNumber) {
+ // Parse data
+
+ // Insert placeholder row to telephony and local db
+ // Get raw PDU push-data from the message and parse it
+ final PduParser parser = new PduParser(pushData,
+ MmsConfig.get(subId).getSupportMmsContentDisposition());
+ final GenericPdu pdu = parser.parse();
+
+ if (null == pdu) {
+ LogUtil.e(TAG, "Invalid PUSH data");
+ return null;
+ }
+
+ final PduPersister p = PduPersister.getPduPersister(context);
+ final int type = pdu.getMessageType();
+
+ Uri messageUri = null;
+ switch (type) {
+ case PduHeaders.MESSAGE_TYPE_DELIVERY_IND:
+ case PduHeaders.MESSAGE_TYPE_READ_ORIG_IND: {
+ // TODO: Should this be commented out?
+// threadId = findThreadId(context, pdu, type);
+// if (threadId == -1) {
+// // The associated SendReq isn't found, therefore skip
+// // processing this PDU.
+// break;
+// }
+
+// Uri uri = p.persist(pdu, Inbox.CONTENT_URI, true,
+// MessagingPreferenceActivity.getIsGroupMmsEnabled(mContext), null);
+// // Update thread ID for ReadOrigInd & DeliveryInd.
+// ContentValues values = new ContentValues(1);
+// values.put(Mms.THREAD_ID, threadId);
+// SqliteWrapper.update(mContext, cr, uri, values, null, null);
+ LogUtil.w(TAG, "Received unsupported WAP Push, type=" + type);
+ break;
+ }
+ case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND: {
+ final NotificationInd nInd = (NotificationInd) pdu;
+
+ if (MmsConfig.get(subId).getTransIdEnabled()) {
+ final byte [] contentLocationTemp = nInd.getContentLocation();
+ if ('=' == contentLocationTemp[contentLocationTemp.length - 1]) {
+ final byte [] transactionIdTemp = nInd.getTransactionId();
+ final byte [] contentLocationWithId =
+ new byte [contentLocationTemp.length
+ + transactionIdTemp.length];
+ System.arraycopy(contentLocationTemp, 0, contentLocationWithId,
+ 0, contentLocationTemp.length);
+ System.arraycopy(transactionIdTemp, 0, contentLocationWithId,
+ contentLocationTemp.length, transactionIdTemp.length);
+ nInd.setContentLocation(contentLocationWithId);
+ }
+ }
+ final String[] dups = getDupNotifications(context, nInd);
+ if (dups == null) {
+ // TODO: Do we handle Rfc822 Email Addresses?
+ //final String contentLocation =
+ // MmsUtils.bytesToString(nInd.getContentLocation(), "UTF-8");
+ //final byte[] transactionId = nInd.getTransactionId();
+ //final long messageSize = nInd.getMessageSize();
+ //final long expiry = nInd.getExpiry();
+ //final String transactionIdString =
+ // MmsUtils.bytesToString(transactionId, "UTF-8");
+
+ //final EncodedStringValue fromEncoded = nInd.getFrom();
+ // An mms ind received from email address will have from address shown as
+ // "John Doe <johndoe@foobar.com>" but the actual received message will only
+ // have the email address. So let's try to parse the RFC822 format to get the
+ // real email. Otherwise we will create two conversations for the MMS
+ // notification and the actual MMS message if auto retrieve is disabled.
+ //final String from = parsePotentialRfc822EmailAddress(
+ // fromEncoded != null ? fromEncoded.getString() : null);
+
+ Uri inboxUri = null;
+ try {
+ inboxUri = p.persist(pdu, Mms.Inbox.CONTENT_URI, subId, subPhoneNumber,
+ null);
+ messageUri = ContentUris.withAppendedId(Mms.CONTENT_URI,
+ ContentUris.parseId(inboxUri));
+ } catch (final MmsException e) {
+ LogUtil.e(TAG, "Failed to save the data from PUSH: type=" + type, e);
+ }
+ } else {
+ LogUtil.w(TAG, "Received WAP Push is a dup: " + Joiner.on(',').join(dups));
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.w(TAG, "Dup WAP Push url=" + new String(nInd.getContentLocation()));
+ }
+ }
+ break;
+ }
+ default:
+ LogUtil.e(TAG, "Received unrecognized WAP Push, type=" + type);
+ }
+
+ DatabaseMessages.MmsMessage mms = null;
+ if (messageUri != null) {
+ mms = MmsUtils.loadMms(messageUri);
+ }
+ return mms;
+ }
+
+ public static Uri insertSendingMmsMessage(final Context context, final List<String> recipients,
+ final MessageData content, final int subId, final String subPhoneNumber,
+ final long timestamp) {
+ final SendReq sendReq = createMmsSendReq(
+ context, subId, recipients.toArray(new String[recipients.size()]), content,
+ DEFAULT_DELIVERY_REPORT_MODE,
+ DEFAULT_READ_REPORT_MODE,
+ DEFAULT_EXPIRY_TIME_IN_SECONDS,
+ DEFAULT_PRIORITY,
+ timestamp);
+ Uri messageUri = null;
+ if (sendReq != null) {
+ final Uri outboxUri = MmsUtils.insertSendReq(context, sendReq, subId, subPhoneNumber);
+ if (outboxUri != null) {
+ messageUri = ContentUris.withAppendedId(Telephony.Mms.CONTENT_URI,
+ ContentUris.parseId(outboxUri));
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "Mmsutils: Inserted sending MMS message into telephony, uri: "
+ + outboxUri);
+ }
+ } else {
+ LogUtil.e(TAG, "insertSendingMmsMessage: failed to persist message into telephony");
+ }
+ }
+ return messageUri;
+ }
+
+ public static MessageData readSendingMmsMessage(final Uri messageUri,
+ final String conversationId, final String participantId, final String selfId) {
+ MessageData message = null;
+ if (messageUri != null) {
+ final DatabaseMessages.MmsMessage mms = MmsUtils.loadMms(messageUri);
+
+ // Make sure that the message has not been deleted from the Telephony DB
+ if (mms != null) {
+ // Transform the message
+ message = MmsUtils.createMmsMessage(mms, conversationId, participantId, selfId,
+ MessageData.BUGLE_STATUS_OUTGOING_RESENDING);
+ }
+ }
+ return message;
+ }
+
+ /**
+ * Create an MMS message with subject, text and image
+ *
+ * @return Both the M-Send.req and the M-Send.conf for processing in the caller
+ * @throws MmsException
+ */
+ private static SendReq createMmsSendReq(final Context context, final int subId,
+ final String[] recipients, final MessageData message,
+ final boolean requireDeliveryReport, final boolean requireReadReport,
+ final long expiryTime, final int priority, final long timestampMillis) {
+ Assert.notNull(context);
+ if (recipients == null || recipients.length < 1) {
+ throw new IllegalArgumentException("MMS sendReq no recipient");
+ }
+
+ // Make a copy so we don't propagate changes to recipients to outside of this method
+ final String[] recipientsCopy = new String[recipients.length];
+ // Don't send phone number as is since some received phone number is malformed
+ // for sending. We need to strip the separators.
+ for (int i = 0; i < recipients.length; i++) {
+ final String recipient = recipients[i];
+ if (EmailAddress.isValidEmail(recipients[i])) {
+ // Don't do stripping for emails
+ recipientsCopy[i] = recipient;
+ } else {
+ recipientsCopy[i] = stripPhoneNumberSeparators(recipient);
+ }
+ }
+
+ SendReq sendReq = null;
+ try {
+ sendReq = createSendReq(context, subId, recipientsCopy,
+ message, requireDeliveryReport,
+ requireReadReport, expiryTime, priority, timestampMillis);
+ } catch (final InvalidHeaderValueException e) {
+ LogUtil.e(TAG, "InvalidHeaderValue creating sendReq PDU");
+ } catch (final OutOfMemoryError e) {
+ LogUtil.e(TAG, "Out of memory error creating sendReq PDU");
+ }
+ return sendReq;
+ }
+
+ /**
+ * Stripping out the invalid characters in a phone number before sending
+ * MMS. We only keep alphanumeric and '*', '#', '+'.
+ */
+ private static String stripPhoneNumberSeparators(final String phoneNumber) {
+ if (phoneNumber == null) {
+ return null;
+ }
+ final int len = phoneNumber.length();
+ final StringBuilder ret = new StringBuilder(len);
+ for (int i = 0; i < len; i++) {
+ final char c = phoneNumber.charAt(i);
+ if (Character.isLetterOrDigit(c) || c == '+' || c == '*' || c == '#') {
+ ret.append(c);
+ }
+ }
+ return ret.toString();
+ }
+
+ /**
+ * Create M-Send.req for the MMS message to be sent.
+ *
+ * @return the M-Send.req
+ * @throws InvalidHeaderValueException if there is any error in parsing the input
+ */
+ static SendReq createSendReq(final Context context, final int subId,
+ final String[] recipients, final MessageData message,
+ final boolean requireDeliveryReport,
+ final boolean requireReadReport, final long expiryTime, final int priority,
+ final long timestampMillis)
+ throws InvalidHeaderValueException {
+ final SendReq req = new SendReq();
+ // From, per spec
+ final String lineNumber = PhoneUtils.get(subId).getCanonicalForSelf(true/*allowOverride*/);
+ if (!TextUtils.isEmpty(lineNumber)) {
+ req.setFrom(new EncodedStringValue(lineNumber));
+ }
+ // To
+ final EncodedStringValue[] encodedNumbers = EncodedStringValue.encodeStrings(recipients);
+ if (encodedNumbers != null) {
+ req.setTo(encodedNumbers);
+ }
+ // Subject
+ if (!TextUtils.isEmpty(message.getMmsSubject())) {
+ req.setSubject(new EncodedStringValue(message.getMmsSubject()));
+ }
+ // Date
+ req.setDate(timestampMillis / 1000L);
+ // Body
+ final MmsInfo bodyInfo = MmsUtils.makePduBody(context, message, subId);
+ req.setBody(bodyInfo.mPduBody);
+ // Message size
+ req.setMessageSize(bodyInfo.mMessageSize);
+ // Message class
+ req.setMessageClass(PduHeaders.MESSAGE_CLASS_PERSONAL_STR.getBytes());
+ // Expiry
+ req.setExpiry(expiryTime);
+ // Priority
+ req.setPriority(priority);
+ // Delivery report
+ req.setDeliveryReport(requireDeliveryReport ? PduHeaders.VALUE_YES : PduHeaders.VALUE_NO);
+ // Read report
+ req.setReadReport(requireReadReport ? PduHeaders.VALUE_YES : PduHeaders.VALUE_NO);
+ return req;
+ }
+
+ public static boolean isDeliveryReportRequired(final int subId) {
+ if (!MmsConfig.get(subId).getSMSDeliveryReportsEnabled()) {
+ return false;
+ }
+ final Context context = Factory.get().getApplicationContext();
+ final Resources res = context.getResources();
+ final BuglePrefs prefs = BuglePrefs.getSubscriptionPrefs(subId);
+ final String deliveryReportKey = res.getString(R.string.delivery_reports_pref_key);
+ final boolean defaultValue = res.getBoolean(R.bool.delivery_reports_pref_default);
+ return prefs.getBoolean(deliveryReportKey, defaultValue);
+ }
+
+ public static int sendSmsMessage(final String recipient, final String messageText,
+ final Uri requestUri, final int subId,
+ final String smsServiceCenter, final boolean requireDeliveryReport) {
+ if (!isSmsDataAvailable(subId)) {
+ LogUtil.w(TAG, "MmsUtils: can't send SMS without radio");
+ return MMS_REQUEST_MANUAL_RETRY;
+ }
+ final Context context = Factory.get().getApplicationContext();
+ int status = MMS_REQUEST_MANUAL_RETRY;
+ try {
+ // Send a single message
+ final SendResult result = SmsSender.sendMessage(
+ context,
+ subId,
+ recipient,
+ messageText,
+ smsServiceCenter,
+ requireDeliveryReport,
+ requestUri);
+ if (!result.hasPending()) {
+ // not timed out, check failures
+ final int failureLevel = result.getHighestFailureLevel();
+ switch (failureLevel) {
+ case SendResult.FAILURE_LEVEL_NONE:
+ status = MMS_REQUEST_SUCCEEDED;
+ break;
+ case SendResult.FAILURE_LEVEL_TEMPORARY:
+ status = MMS_REQUEST_AUTO_RETRY;
+ LogUtil.e(TAG, "MmsUtils: SMS temporary failure");
+ break;
+ case SendResult.FAILURE_LEVEL_PERMANENT:
+ LogUtil.e(TAG, "MmsUtils: SMS permanent failure");
+ break;
+ }
+ } else {
+ // Timed out
+ LogUtil.e(TAG, "MmsUtils: sending SMS timed out");
+ }
+ } catch (final Exception e) {
+ LogUtil.e(TAG, "MmsUtils: failed to send SMS " + e, e);
+ }
+ return status;
+ }
+
+ /**
+ * Delete SMS and MMS messages in a particular thread
+ *
+ * @return the number of messages deleted
+ */
+ public static int deleteThread(final long threadId, final long cutOffTimestampInMillis) {
+ final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver();
+ final Uri threadUri = ContentUris.withAppendedId(Telephony.Threads.CONTENT_URI, threadId);
+ if (cutOffTimestampInMillis < Long.MAX_VALUE) {
+ return resolver.delete(threadUri, Sms.DATE + "<=?",
+ new String[] { Long.toString(cutOffTimestampInMillis) });
+ } else {
+ return resolver.delete(threadUri, null /* smsSelection */, null /* selectionArgs */);
+ }
+ }
+
+ /**
+ * Delete single SMS and MMS message
+ *
+ * @return number of rows deleted (should be 1 or 0)
+ */
+ public static int deleteMessage(final Uri messageUri) {
+ final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver();
+ return resolver.delete(messageUri, null /* selection */, null /* selectionArgs */);
+ }
+
+ public static byte[] createDebugNotificationInd(final String fileName) {
+ byte[] pduData = null;
+ try {
+ final Context context = Factory.get().getApplicationContext();
+ // Load the message file
+ final byte[] data = DebugUtils.receiveFromDumpFile(fileName);
+ final RetrieveConf retrieveConf = receiveFromDumpFile(data);
+ // Create the notification
+ final NotificationInd notification = new NotificationInd();
+ final long expiry = System.currentTimeMillis() / 1000 + 600;
+ notification.setTransactionId(fileName.getBytes());
+ notification.setMmsVersion(retrieveConf.getMmsVersion());
+ notification.setFrom(retrieveConf.getFrom());
+ notification.setSubject(retrieveConf.getSubject());
+ notification.setExpiry(expiry);
+ notification.setMessageSize(data.length);
+ notification.setMessageClass(retrieveConf.getMessageClass());
+
+ final Uri.Builder builder = MediaScratchFileProvider.getUriBuilder();
+ builder.appendPath(fileName);
+ final Uri contentLocation = builder.build();
+ notification.setContentLocation(contentLocation.toString().getBytes());
+
+ // Serialize
+ pduData = new PduComposer(context, notification).make();
+ if (pduData == null || pduData.length < 1) {
+ throw new IllegalArgumentException("Empty or zero length PDU data");
+ }
+ } catch (final MmsFailureException e) {
+ // Nothing to do
+ } catch (final InvalidHeaderValueException e) {
+ // Nothing to do
+ }
+ return pduData;
+ }
+
+ public static int mapRawStatusToErrorResourceId(final int bugleStatus, final int rawStatus) {
+ int stringResId = R.string.message_status_send_failed;
+ switch (rawStatus) {
+ case PduHeaders.RESPONSE_STATUS_ERROR_SERVICE_DENIED:
+ case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_SERVICE_DENIED:
+ //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_LIMITATIONS_NOT_MET:
+ //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_REQUEST_NOT_ACCEPTED:
+ //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_FORWARDING_DENIED:
+ //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_NOT_SUPPORTED:
+ //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_ADDRESS_HIDING_NOT_SUPPORTED:
+ //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_LACK_OF_PREPAID:
+ stringResId = R.string.mms_failure_outgoing_service;
+ break;
+ case PduHeaders.RESPONSE_STATUS_ERROR_SENDING_ADDRESS_UNRESOLVED:
+ case PduHeaders.RESPONSE_STATUS_ERROR_TRANSIENT_SENDNG_ADDRESS_UNRESOLVED:
+ case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_SENDING_ADDRESS_UNRESOLVED:
+ stringResId = R.string.mms_failure_outgoing_address;
+ break;
+ case PduHeaders.RESPONSE_STATUS_ERROR_MESSAGE_FORMAT_CORRUPT:
+ case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_MESSAGE_FORMAT_CORRUPT:
+ stringResId = R.string.mms_failure_outgoing_corrupt;
+ break;
+ case PduHeaders.RESPONSE_STATUS_ERROR_CONTENT_NOT_ACCEPTED:
+ case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_CONTENT_NOT_ACCEPTED:
+ stringResId = R.string.mms_failure_outgoing_content;
+ break;
+ case PduHeaders.RESPONSE_STATUS_ERROR_UNSUPPORTED_MESSAGE:
+ //case PduHeaders.RESPONSE_STATUS_ERROR_MESSAGE_NOT_FOUND:
+ //case PduHeaders.RESPONSE_STATUS_ERROR_TRANSIENT_MESSAGE_NOT_FOUND:
+ stringResId = R.string.mms_failure_outgoing_unsupported;
+ break;
+ case MessageData.RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG:
+ stringResId = R.string.mms_failure_outgoing_too_large;
+ break;
+ }
+ return stringResId;
+ }
+
+ /**
+ * The absence of a connection type.
+ */
+ public static final int TYPE_NONE = -1;
+
+ public static int getConnectivityEventNetworkType(final Context context, final Intent intent) {
+ final ConnectivityManager connMgr = (ConnectivityManager)
+ context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (OsUtil.isAtLeastJB_MR1()) {
+ return intent.getIntExtra(ConnectivityManager.EXTRA_NETWORK_TYPE, TYPE_NONE);
+ } else {
+ final NetworkInfo info = (NetworkInfo) intent.getParcelableExtra(
+ ConnectivityManager.EXTRA_NETWORK_INFO);
+ if (info != null) {
+ return info.getType();
+ }
+ }
+ return TYPE_NONE;
+ }
+
+ /**
+ * Dump the raw MMS data into a file
+ *
+ * @param rawPdu The raw pdu data
+ * @param pdu The parsed pdu, used to construct a dump file name
+ */
+ public static void dumpPdu(final byte[] rawPdu, final GenericPdu pdu) {
+ if (rawPdu == null || rawPdu.length < 1) {
+ return;
+ }
+ final String dumpFileName = MmsUtils.MMS_DUMP_PREFIX + getDumpFileId(pdu);
+ final File dumpFile = DebugUtils.getDebugFile(dumpFileName, true);
+ if (dumpFile != null) {
+ try {
+ final FileOutputStream fos = new FileOutputStream(dumpFile);
+ final BufferedOutputStream bos = new BufferedOutputStream(fos);
+ try {
+ bos.write(rawPdu);
+ bos.flush();
+ } finally {
+ bos.close();
+ }
+ DebugUtils.ensureReadable(dumpFile);
+ } catch (final IOException e) {
+ LogUtil.e(TAG, "dumpPdu: " + e, e);
+ }
+ }
+ }
+
+ /**
+ * Get the dump file id based on the parsed PDU
+ * 1. Use message id if not empty
+ * 2. Use transaction id if message id is empty
+ * 3. If all above is empty, use random UUID
+ *
+ * @param pdu the parsed PDU
+ * @return the id of the dump file
+ */
+ private static String getDumpFileId(final GenericPdu pdu) {
+ String fileId = null;
+ if (pdu != null && pdu instanceof RetrieveConf) {
+ final RetrieveConf retrieveConf = (RetrieveConf) pdu;
+ if (retrieveConf.getMessageId() != null) {
+ fileId = new String(retrieveConf.getMessageId());
+ } else if (retrieveConf.getTransactionId() != null) {
+ fileId = new String(retrieveConf.getTransactionId());
+ }
+ }
+ if (TextUtils.isEmpty(fileId)) {
+ fileId = UUID.randomUUID().toString();
+ }
+ return fileId;
+ }
+}
diff --git a/src/com/android/messaging/sms/SmsException.java b/src/com/android/messaging/sms/SmsException.java
new file mode 100644
index 0000000..728db8c
--- /dev/null
+++ b/src/com/android/messaging/sms/SmsException.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.messaging.sms;
+
+/**
+ * A generic Exception for errors in sending SMS
+ */
+class SmsException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates a new SmsException.
+ */
+ public SmsException() {
+ super();
+ }
+
+ /**
+ * Creates a new SmsException with the specified detail message.
+ *
+ * @param message the detail message.
+ */
+ public SmsException(String message) {
+ super(message);
+ }
+
+ /**
+ * Creates a new SmsException with the specified cause.
+ *
+ * @param cause the cause.
+ */
+ public SmsException(Throwable cause) {
+ super(cause);
+ }
+
+ /**
+ * Creates a new SmsException with the specified detail message and cause.
+ *
+ * @param message the detail message.
+ * @param cause the cause.
+ */
+ public SmsException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/com/android/messaging/sms/SmsReleaseStorage.java b/src/com/android/messaging/sms/SmsReleaseStorage.java
new file mode 100644
index 0000000..13a6284
--- /dev/null
+++ b/src/com/android/messaging/sms/SmsReleaseStorage.java
@@ -0,0 +1,166 @@
+/*
+ * 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.messaging.sms;
+
+import android.content.res.Resources;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.SyncManager;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.LogUtil;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Class handling message cleanup when storage is low
+ */
+public class SmsReleaseStorage {
+ /**
+ * Class representing a time duration specified by Gservices
+ */
+ public static class Duration {
+ // Time duration unit types
+ public static final int UNIT_WEEK = 'w';
+ public static final int UNIT_MONTH = 'm';
+ public static final int UNIT_YEAR = 'y';
+
+ // Number of units
+ public final int mCount;
+ // Unit type: week, month or year
+ public final int mUnit;
+
+ public Duration(final int count, final int unit) {
+ mCount = count;
+ mUnit = unit;
+ }
+ }
+
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private static final Duration DEFAULT_DURATION = new Duration(1, Duration.UNIT_MONTH);
+
+ private static final Pattern DURATION_PATTERN = Pattern.compile("([1-9]+\\d*)(w|m|y)");
+ /**
+ * Parse message retaining time duration specified by Gservices
+ *
+ * @return The parsed time duration from Gservices
+ */
+ public static Duration parseMessageRetainingDuration() {
+ final String smsAutoDeleteMessageRetainingDuration =
+ BugleGservices.get().getString(
+ BugleGservicesKeys.SMS_STORAGE_PURGING_MESSAGE_RETAINING_DURATION,
+ BugleGservicesKeys.SMS_STORAGE_PURGING_MESSAGE_RETAINING_DURATION_DEFAULT);
+ final Matcher matcher = DURATION_PATTERN.matcher(smsAutoDeleteMessageRetainingDuration);
+ try {
+ if (matcher.matches()) {
+ return new Duration(
+ Integer.parseInt(matcher.group(1)),
+ matcher.group(2).charAt(0));
+ }
+ } catch (final NumberFormatException e) {
+ // Nothing to do
+ }
+ LogUtil.e(TAG, "SmsAutoDelete: invalid duration " +
+ smsAutoDeleteMessageRetainingDuration);
+ return DEFAULT_DURATION;
+ }
+
+ /**
+ * Get string representation of the time duration
+ *
+ * @param duration
+ * @return
+ */
+ public static String getMessageRetainingDurationString(final Duration duration) {
+ final Resources resources = Factory.get().getApplicationContext().getResources();
+ switch (duration.mUnit) {
+ case Duration.UNIT_WEEK:
+ return resources.getQuantityString(
+ R.plurals.week_count, duration.mCount, duration.mCount);
+ case Duration.UNIT_MONTH:
+ return resources.getQuantityString(
+ R.plurals.month_count, duration.mCount, duration.mCount);
+ case Duration.UNIT_YEAR:
+ return resources.getQuantityString(
+ R.plurals.year_count, duration.mCount, duration.mCount);
+ }
+ throw new IllegalArgumentException(
+ "SmsAutoDelete: invalid duration unit " + duration.mUnit);
+ }
+
+ // Time conversations
+ private static final long WEEK_IN_MILLIS = 7 * 24 * 3600 * 1000L;
+ private static final long MONTH_IN_MILLIS = 30 * 24 * 3600 * 1000L;
+ private static final long YEAR_IN_MILLIS = 365 * 24 * 3600 * 1000L;
+
+ /**
+ * Convert time duration to time in milliseconds
+ *
+ * @param duration
+ * @return
+ */
+ public static long durationToTimeInMillis(final Duration duration) {
+ switch (duration.mUnit) {
+ case Duration.UNIT_WEEK:
+ return duration.mCount * WEEK_IN_MILLIS;
+ case Duration.UNIT_MONTH:
+ return duration.mCount * MONTH_IN_MILLIS;
+ case Duration.UNIT_YEAR:
+ return duration.mCount * YEAR_IN_MILLIS;
+ }
+ return -1L;
+ }
+
+ /**
+ * Delete message actions:
+ * 0: delete media messages
+ * 1: delete old messages
+ *
+ * @param actionIndex The index of the delete action to perform
+ * @param durationInMillis The time duration for retaining messages
+ */
+ public static void deleteMessages(final int actionIndex, final long durationInMillis) {
+ int deleted = 0;
+ switch (actionIndex) {
+ case 0: {
+ // Delete media
+ deleted = MmsUtils.deleteMediaMessages();
+ break;
+ }
+ case 1: {
+ // Delete old messages
+ final long now = System.currentTimeMillis();
+ final long cutOffTimestampInMillis = now - durationInMillis;
+ // Delete messages from telephony provider
+ deleted = MmsUtils.deleteMessagesOlderThan(cutOffTimestampInMillis);
+ break;
+ }
+ default: {
+ LogUtil.e(TAG, "SmsStorageStatusManager: invalid action " + actionIndex);
+ break;
+ }
+ }
+
+ if (deleted > 0) {
+ // Kick off a sync to update local db.
+ SyncManager.sync();
+ }
+ }
+}
diff --git a/src/com/android/messaging/sms/SmsSender.java b/src/com/android/messaging/sms/SmsSender.java
new file mode 100644
index 0000000..889973f
--- /dev/null
+++ b/src/com/android/messaging/sms/SmsSender.java
@@ -0,0 +1,315 @@
+/*
+ * 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.messaging.sms;
+
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.SystemClock;
+import android.telephony.PhoneNumberUtils;
+import android.telephony.SmsManager;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.receiver.SendStatusReceiver;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.UiUtils;
+
+import java.util.ArrayList;
+import java.util.Random;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Class that sends chat message via SMS.
+ *
+ * The interface emulates a blocking sending similar to making an HTTP request.
+ * It calls the SmsManager to send a (potentially multipart) message and waits
+ * on the sent status on each part. The waiting has a timeout so it won't wait
+ * forever. Once the sent status of all parts received, the call returns.
+ * A successful sending requires success status for all parts. Otherwise, we
+ * pick the highest level of failure as the error for the whole message, which
+ * is used to determine if we need to retry the sending.
+ */
+public class SmsSender {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ public static final String EXTRA_PART_ID = "part_id";
+
+ /*
+ * A map for pending sms messages. The key is the random request UUID.
+ */
+ private static ConcurrentHashMap<Uri, SendResult> sPendingMessageMap =
+ new ConcurrentHashMap<Uri, SendResult>();
+
+ private static final Random RANDOM = new Random();
+
+ // Whether we should send multipart SMS as separate messages
+ private static Boolean sSendMultipartSmsAsSeparateMessages = null;
+
+ /**
+ * Class that holds the sent status for all parts of a multipart message sending
+ */
+ public static class SendResult {
+ // Failure levels, used by the caller of the sender.
+ // For temporary failures, possibly we could retry the sending
+ // For permanent failures, we probably won't retry
+ public static final int FAILURE_LEVEL_NONE = 0;
+ public static final int FAILURE_LEVEL_TEMPORARY = 1;
+ public static final int FAILURE_LEVEL_PERMANENT = 2;
+
+ // Tracking the remaining pending parts in sending
+ private int mPendingParts;
+ // Tracking the highest level of failure among all parts
+ private int mHighestFailureLevel;
+
+ public SendResult(final int numOfParts) {
+ Assert.isTrue(numOfParts > 0);
+ mPendingParts = numOfParts;
+ mHighestFailureLevel = FAILURE_LEVEL_NONE;
+ }
+
+ // Update the sent status of one part
+ public void setPartResult(final int resultCode) {
+ mPendingParts--;
+ setHighestFailureLevel(resultCode);
+ }
+
+ public boolean hasPending() {
+ return mPendingParts > 0;
+ }
+
+ public int getHighestFailureLevel() {
+ return mHighestFailureLevel;
+ }
+
+ private int getFailureLevel(final int resultCode) {
+ switch (resultCode) {
+ case Activity.RESULT_OK:
+ return FAILURE_LEVEL_NONE;
+ case SmsManager.RESULT_ERROR_NO_SERVICE:
+ return FAILURE_LEVEL_TEMPORARY;
+ case SmsManager.RESULT_ERROR_RADIO_OFF:
+ return FAILURE_LEVEL_PERMANENT;
+ case SmsManager.RESULT_ERROR_GENERIC_FAILURE:
+ return FAILURE_LEVEL_PERMANENT;
+ default: {
+ LogUtil.e(TAG, "SmsSender: Unexpected sent intent resultCode = " + resultCode);
+ return FAILURE_LEVEL_PERMANENT;
+ }
+ }
+ }
+
+ private void setHighestFailureLevel(final int resultCode) {
+ final int level = getFailureLevel(resultCode);
+ if (level > mHighestFailureLevel) {
+ mHighestFailureLevel = level;
+ }
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder();
+ sb.append("SendResult:");
+ sb.append("Pending=").append(mPendingParts).append(",");
+ sb.append("HighestFailureLevel=").append(mHighestFailureLevel);
+ return sb.toString();
+ }
+ }
+
+ public static void setResult(final Uri requestId, final int resultCode,
+ final int errorCode, final int partId, int subId) {
+ if (resultCode != Activity.RESULT_OK) {
+ LogUtil.e(TAG, "SmsSender: failure in sending message part. "
+ + " requestId=" + requestId + " partId=" + partId
+ + " resultCode=" + resultCode + " errorCode=" + errorCode);
+ if (errorCode != SendStatusReceiver.NO_ERROR_CODE) {
+ final Context context = Factory.get().getApplicationContext();
+ UiUtils.showToastAtBottom(getSendErrorToastMessage(context, subId, errorCode));
+ }
+ } else {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SmsSender: received sent result. " + " requestId=" + requestId
+ + " partId=" + partId + " resultCode=" + resultCode);
+ }
+ }
+ if (requestId != null) {
+ final SendResult result = sPendingMessageMap.get(requestId);
+ if (result != null) {
+ synchronized (result) {
+ result.setPartResult(resultCode);
+ if (!result.hasPending()) {
+ result.notifyAll();
+ }
+ }
+ } else {
+ LogUtil.e(TAG, "SmsSender: ignoring sent result. " + " requestId=" + requestId
+ + " partId=" + partId + " resultCode=" + resultCode);
+ }
+ }
+ }
+
+ private static String getSendErrorToastMessage(final Context context, final int subId,
+ final int errorCode) {
+ final String carrierName = PhoneUtils.get(subId).getCarrierName();
+ if (TextUtils.isEmpty(carrierName)) {
+ return context.getString(R.string.carrier_send_error_unknown_carrier, errorCode);
+ } else {
+ return context.getString(R.string.carrier_send_error, carrierName, errorCode);
+ }
+ }
+
+ // This should be called from a RequestWriter queue thread
+ public static SendResult sendMessage(final Context context, final int subId, String dest,
+ String message, final String serviceCenter, final boolean requireDeliveryReport,
+ final Uri messageUri) throws SmsException {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SmsSender: sending message. " +
+ "dest=" + dest + " message=" + message +
+ " serviceCenter=" + serviceCenter +
+ " requireDeliveryReport=" + requireDeliveryReport +
+ " requestId=" + messageUri);
+ }
+ if (TextUtils.isEmpty(message)) {
+ throw new SmsException("SmsSender: empty text message");
+ }
+ // Get the real dest and message for email or alias if dest is email or alias
+ // Or sanitize the dest if dest is a number
+ if (!TextUtils.isEmpty(MmsConfig.get(subId).getEmailGateway()) &&
+ (MmsSmsUtils.isEmailAddress(dest) || MmsSmsUtils.isAlias(dest, subId))) {
+ // The original destination (email address) goes with the message
+ message = dest + " " + message;
+ // the new address is the email gateway #
+ dest = MmsConfig.get(subId).getEmailGateway();
+ } else {
+ // remove spaces and dashes from destination number
+ // (e.g. "801 555 1212" -> "8015551212")
+ // (e.g. "+8211-123-4567" -> "+82111234567")
+ dest = PhoneNumberUtils.stripSeparators(dest);
+ }
+ if (TextUtils.isEmpty(dest)) {
+ throw new SmsException("SmsSender: empty destination address");
+ }
+ // Divide the input message by SMS length limit
+ final SmsManager smsManager = PhoneUtils.get(subId).getSmsManager();
+ final ArrayList<String> messages = smsManager.divideMessage(message);
+ if (messages == null || messages.size() < 1) {
+ throw new SmsException("SmsSender: fails to divide message");
+ }
+ // Prepare the send result, which collects the send status for each part
+ final SendResult pendingResult = new SendResult(messages.size());
+ sPendingMessageMap.put(messageUri, pendingResult);
+ // Actually send the sms
+ sendInternal(
+ context, subId, dest, messages, serviceCenter, requireDeliveryReport, messageUri);
+ // Wait for pending intent to come back
+ synchronized (pendingResult) {
+ final long smsSendTimeoutInMillis = BugleGservices.get().getLong(
+ BugleGservicesKeys.SMS_SEND_TIMEOUT_IN_MILLIS,
+ BugleGservicesKeys.SMS_SEND_TIMEOUT_IN_MILLIS_DEFAULT);
+ final long beginTime = SystemClock.elapsedRealtime();
+ long waitTime = smsSendTimeoutInMillis;
+ // We could possibly be woken up while still pending
+ // so make sure we wait the full timeout period unless
+ // we have the send results of all parts.
+ while (pendingResult.hasPending() && waitTime > 0) {
+ try {
+ pendingResult.wait(waitTime);
+ } catch (final InterruptedException e) {
+ LogUtil.e(TAG, "SmsSender: sending wait interrupted");
+ }
+ waitTime = smsSendTimeoutInMillis - (SystemClock.elapsedRealtime() - beginTime);
+ }
+ }
+ // Either we timed out or have all the results (success or failure)
+ sPendingMessageMap.remove(messageUri);
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "SmsSender: sending completed. " +
+ "dest=" + dest + " message=" + message + " result=" + pendingResult);
+ }
+ return pendingResult;
+ }
+
+ // Actually sending the message using SmsManager
+ private static void sendInternal(final Context context, final int subId, String dest,
+ final ArrayList<String> messages, final String serviceCenter,
+ final boolean requireDeliveryReport, final Uri messageUri) throws SmsException {
+ Assert.notNull(context);
+ final SmsManager smsManager = PhoneUtils.get(subId).getSmsManager();
+ final int messageCount = messages.size();
+ final ArrayList<PendingIntent> deliveryIntents = new ArrayList<PendingIntent>(messageCount);
+ final ArrayList<PendingIntent> sentIntents = new ArrayList<PendingIntent>(messageCount);
+ for (int i = 0; i < messageCount; i++) {
+ // Make pending intents different for each message part
+ final int partId = (messageCount <= 1 ? 0 : i + 1);
+ if (requireDeliveryReport && (i == (messageCount - 1))) {
+ // TODO we only care about the delivery status of the last part
+ // Shall we have better tracking of delivery status of all parts?
+ deliveryIntents.add(PendingIntent.getBroadcast(
+ context,
+ partId,
+ getSendStatusIntent(context, SendStatusReceiver.MESSAGE_DELIVERED_ACTION,
+ messageUri, partId, subId),
+ 0/*flag*/));
+ } else {
+ deliveryIntents.add(null);
+ }
+ sentIntents.add(PendingIntent.getBroadcast(
+ context,
+ partId,
+ getSendStatusIntent(context, SendStatusReceiver.MESSAGE_SENT_ACTION,
+ messageUri, partId, subId),
+ 0/*flag*/));
+ }
+ if (sSendMultipartSmsAsSeparateMessages == null) {
+ sSendMultipartSmsAsSeparateMessages = MmsConfig.get(subId)
+ .getSendMultipartSmsAsSeparateMessages();
+ }
+ try {
+ if (sSendMultipartSmsAsSeparateMessages) {
+ // If multipart sms is not supported, send them as separate messages
+ for (int i = 0; i < messageCount; i++) {
+ smsManager.sendTextMessage(dest,
+ serviceCenter,
+ messages.get(i),
+ sentIntents.get(i),
+ deliveryIntents.get(i));
+ }
+ } else {
+ smsManager.sendMultipartTextMessage(
+ dest, serviceCenter, messages, sentIntents, deliveryIntents);
+ }
+ } catch (final Exception e) {
+ throw new SmsException("SmsSender: caught exception in sending " + e);
+ }
+ }
+
+ private static Intent getSendStatusIntent(final Context context, final String action,
+ final Uri requestUri, final int partId, final int subId) {
+ // Encode requestId in intent data
+ final Intent intent = new Intent(action, requestUri, context, SendStatusReceiver.class);
+ intent.putExtra(SendStatusReceiver.EXTRA_PART_ID, partId);
+ intent.putExtra(SendStatusReceiver.EXTRA_SUB_ID, subId);
+ return intent;
+ }
+}
diff --git a/src/com/android/messaging/sms/SmsStorageStatusManager.java b/src/com/android/messaging/sms/SmsStorageStatusManager.java
new file mode 100644
index 0000000..ff7b79d
--- /dev/null
+++ b/src/com/android/messaging/sms/SmsStorageStatusManager.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.messaging.sms;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.res.Resources;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationManagerCompat;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.PendingIntentConstants;
+import com.android.messaging.util.PhoneUtils;
+
+/**
+ * Class that handles SMS auto delete and notification when storage is low
+ */
+public class SmsStorageStatusManager {
+ /**
+ * Handles storage low signal for SMS
+ */
+ public static void handleStorageLow() {
+ if (!PhoneUtils.getDefault().isSmsEnabled()) {
+ return;
+ }
+
+ // TODO: Auto-delete messages, when that setting exists and is enabled
+
+ // Notify low storage for SMS
+ postStorageLowNotification();
+ }
+
+ /**
+ * Handles storage OK signal for SMS
+ */
+ public static void handleStorageOk() {
+ if (!PhoneUtils.getDefault().isSmsEnabled()) {
+ return;
+ }
+ cancelStorageLowNotification();
+ }
+
+ /**
+ * Post sms storage low notification
+ */
+ private static void postStorageLowNotification() {
+ final Context context = Factory.get().getApplicationContext();
+ final Resources resources = context.getResources();
+ final PendingIntent pendingIntent = UIIntents.get()
+ .getPendingIntentForLowStorageNotifications(context);
+
+ final NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
+ builder.setContentTitle(resources.getString(R.string.sms_storage_low_title))
+ .setTicker(resources.getString(R.string.sms_storage_low_notification_ticker))
+ .setSmallIcon(R.drawable.ic_failed_light)
+ .setPriority(Notification.PRIORITY_DEFAULT)
+ .setOngoing(true) // Can't be swiped off
+ .setAutoCancel(false) // Don't auto cancel
+ .setContentIntent(pendingIntent);
+
+ final NotificationCompat.BigTextStyle bigTextStyle =
+ new NotificationCompat.BigTextStyle(builder);
+ bigTextStyle.bigText(resources.getString(R.string.sms_storage_low_text));
+ final Notification notification = bigTextStyle.build();
+
+ final NotificationManagerCompat notificationManager =
+ NotificationManagerCompat.from(Factory.get().getApplicationContext());
+
+ notificationManager.notify(getNotificationTag(),
+ PendingIntentConstants.SMS_STORAGE_LOW_NOTIFICATION_ID, notification);
+ }
+
+ /**
+ * Cancel the notification
+ */
+ public static void cancelStorageLowNotification() {
+ final NotificationManagerCompat notificationManager =
+ NotificationManagerCompat.from(Factory.get().getApplicationContext());
+ notificationManager.cancel(getNotificationTag(),
+ PendingIntentConstants.SMS_STORAGE_LOW_NOTIFICATION_ID);
+ }
+
+ private static String getNotificationTag() {
+ return Factory.get().getApplicationContext().getPackageName() + ":smsstoragelow";
+ }
+}
diff --git a/src/com/android/messaging/sms/SystemProperties.java b/src/com/android/messaging/sms/SystemProperties.java
new file mode 100644
index 0000000..669e448
--- /dev/null
+++ b/src/com/android/messaging/sms/SystemProperties.java
@@ -0,0 +1,54 @@
+/*
+ * 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.messaging.sms;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Hacky way to call the hidden SystemProperties class API
+ */
+class SystemProperties {
+ private static Method sSystemPropertiesGetMethod = null;
+
+ public static String get(final String name) {
+ if (sSystemPropertiesGetMethod == null) {
+ try {
+ final Class systemPropertiesClass = Class.forName("android.os.SystemProperties");
+ if (systemPropertiesClass != null) {
+ sSystemPropertiesGetMethod =
+ systemPropertiesClass.getMethod("get", String.class);
+ }
+ } catch (final ClassNotFoundException e) {
+ // Nothing to do
+ } catch (final NoSuchMethodException e) {
+ // Nothing to do
+ }
+ }
+ if (sSystemPropertiesGetMethod != null) {
+ try {
+ return (String) sSystemPropertiesGetMethod.invoke(null, name);
+ } catch (final IllegalArgumentException e) {
+ // Nothing to do
+ } catch (final IllegalAccessException e) {
+ // Nothing to do
+ } catch (final InvocationTargetException e) {
+ // Nothing to do
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/com/android/messaging/ui/AsyncImageView.java b/src/com/android/messaging/ui/AsyncImageView.java
new file mode 100644
index 0000000..9aaf0b1
--- /dev/null
+++ b/src/com/android/messaging/ui/AsyncImageView.java
@@ -0,0 +1,457 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.Nullable;
+import android.support.rastermill.FrameSequenceDrawable;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.widget.ImageView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.media.BindableMediaRequest;
+import com.android.messaging.datamodel.media.GifImageResource;
+import com.android.messaging.datamodel.media.ImageRequest;
+import com.android.messaging.datamodel.media.ImageRequestDescriptor;
+import com.android.messaging.datamodel.media.ImageResource;
+import com.android.messaging.datamodel.media.MediaRequest;
+import com.android.messaging.datamodel.media.MediaResourceManager;
+import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.ThreadUtil;
+import com.android.messaging.util.UiUtils;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.HashSet;
+
+/**
+ * An ImageView used to asynchronously request an image from MediaResourceManager and render it.
+ */
+public class AsyncImageView extends ImageView implements MediaResourceLoadListener<ImageResource> {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+ // 100ms delay before disposing the image in case the AsyncImageView is re-added to the UI
+ private static final int DISPOSE_IMAGE_DELAY = 100;
+
+ // AsyncImageView has a 1-1 binding relationship with an ImageRequest instance that requests
+ // the image from the MediaResourceManager. Since the request is done asynchronously, we
+ // want to make sure the image view is always bound to the latest image request that it
+ // issues, so that when the image is loaded, the ImageRequest (which extends BindableData)
+ // will be able to figure out whether the binding is still valid and whether the loaded image
+ // should be delivered to the AsyncImageView via onMediaResourceLoaded() callback.
+ @VisibleForTesting
+ public final Binding<BindableMediaRequest<ImageResource>> mImageRequestBinding;
+
+ /** True if we want the image to fade in when it loads */
+ private boolean mFadeIn;
+
+ /** True if we want the image to reveal (scale) when it loads. When set to true, this
+ * will take precedence over {@link #mFadeIn} */
+ private final boolean mReveal;
+
+ // The corner radius for drawing rounded corners around bitmap. The default value is zero
+ // (no rounded corners)
+ private final int mCornerRadius;
+ private final Path mRoundedCornerClipPath;
+ private int mClipPathWidth;
+ private int mClipPathHeight;
+
+ // A placeholder drawable that takes the spot of the image when it's loading. The default
+ // setting is null (no placeholder).
+ private final Drawable mPlaceholderDrawable;
+ protected ImageResource mImageResource;
+ private final Runnable mDisposeRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (mImageRequestBinding.isBound()) {
+ mDetachedRequestDescriptor = (ImageRequestDescriptor)
+ mImageRequestBinding.getData().getDescriptor();
+ }
+ unbindView();
+ releaseImageResource();
+ }
+ };
+
+ private AsyncImageViewDelayLoader mDelayLoader;
+ private ImageRequestDescriptor mDetachedRequestDescriptor;
+
+ public AsyncImageView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mImageRequestBinding = BindingBase.createBinding(this);
+ final TypedArray attr = context.obtainStyledAttributes(attrs, R.styleable.AsyncImageView,
+ 0, 0);
+ mFadeIn = attr.getBoolean(R.styleable.AsyncImageView_fadeIn, true);
+ mReveal = attr.getBoolean(R.styleable.AsyncImageView_reveal, false);
+ mPlaceholderDrawable = attr.getDrawable(R.styleable.AsyncImageView_placeholderDrawable);
+ mCornerRadius = attr.getDimensionPixelSize(R.styleable.AsyncImageView_cornerRadius, 0);
+ mRoundedCornerClipPath = new Path();
+
+ attr.recycle();
+ }
+
+ /**
+ * The main entrypoint for AsyncImageView to load image resource given an ImageRequestDescriptor
+ * @param descriptor the request descriptor, or null if no image should be displayed
+ */
+ public void setImageResourceId(@Nullable final ImageRequestDescriptor descriptor) {
+ final String requestKey = (descriptor == null) ? null : descriptor.getKey();
+ if (mImageRequestBinding.isBound()) {
+ if (TextUtils.equals(mImageRequestBinding.getData().getKey(), requestKey)) {
+ // Don't re-request the bitmap if the new request is for the same resource.
+ return;
+ }
+ unbindView();
+ }
+ setImage(null);
+ resetTransientViewStates();
+ if (!TextUtils.isEmpty(requestKey)) {
+ maybeSetupPlaceholderDrawable(descriptor);
+ final BindableMediaRequest<ImageResource> imageRequest =
+ descriptor.buildAsyncMediaRequest(getContext(), this);
+ requestImage(imageRequest);
+ }
+ }
+
+ /**
+ * Sets a delay loader that centrally manages image request delay loading logic.
+ */
+ public void setDelayLoader(final AsyncImageViewDelayLoader delayLoader) {
+ Assert.isTrue(mDelayLoader == null);
+ mDelayLoader = delayLoader;
+ }
+
+ /**
+ * Called by the delay loader when we can resume image loading.
+ */
+ public void resumeLoading() {
+ Assert.notNull(mDelayLoader);
+ Assert.isTrue(mImageRequestBinding.isBound());
+ MediaResourceManager.get().requestMediaResourceAsync(mImageRequestBinding.getData());
+ }
+
+ /**
+ * Setup the placeholder drawable if:
+ * 1. There's an image to be loaded AND
+ * 2. We are given a placeholder drawable AND
+ * 3. The descriptor provided us with source width and height.
+ */
+ private void maybeSetupPlaceholderDrawable(final ImageRequestDescriptor descriptor) {
+ if (!TextUtils.isEmpty(descriptor.getKey()) && mPlaceholderDrawable != null) {
+ if (descriptor.sourceWidth != ImageRequest.UNSPECIFIED_SIZE &&
+ descriptor.sourceHeight != ImageRequest.UNSPECIFIED_SIZE) {
+ // Set a transparent inset drawable to the foreground so it will mimick the final
+ // size of the image, and use the background to show the actual placeholder
+ // drawable.
+ setImageDrawable(PlaceholderInsetDrawable.fromDrawable(
+ new ColorDrawable(Color.TRANSPARENT),
+ descriptor.sourceWidth, descriptor.sourceHeight));
+ }
+ setBackground(mPlaceholderDrawable);
+ }
+ }
+
+ protected void setImage(final ImageResource resource) {
+ setImage(resource, false /* isCached */);
+ }
+
+ protected void setImage(final ImageResource resource, final boolean isCached) {
+ // Switch reference to the new ImageResource. Make sure we release the current
+ // resource and addRef() on the new resource so that the underlying bitmaps don't
+ // get leaked or get recycled by the bitmap cache.
+ releaseImageResource();
+ // Ensure that any pending dispose runnables get removed.
+ ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable);
+ // The drawable may require work to get if its a static object so try to only make this call
+ // once.
+ final Drawable drawable = (resource != null) ? resource.getDrawable(getResources()) : null;
+ if (drawable != null) {
+ mImageResource = resource;
+ mImageResource.addRef();
+ setImageDrawable(drawable);
+ if (drawable instanceof FrameSequenceDrawable) {
+ ((FrameSequenceDrawable) drawable).start();
+ }
+
+ if (getVisibility() == VISIBLE) {
+ if (mReveal) {
+ setVisibility(INVISIBLE);
+ UiUtils.revealOrHideViewWithAnimation(this, VISIBLE, null);
+ } else if (mFadeIn && !isCached) {
+ // Hide initially to avoid flash.
+ setAlpha(0F);
+ animate().alpha(1F).start();
+ }
+ }
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ if (mImageResource instanceof GifImageResource) {
+ LogUtil.v(TAG, "setImage size unknown -- it's a GIF");
+ } else {
+ LogUtil.v(TAG, "setImage size: " + mImageResource.getMediaSize() +
+ " width: " + mImageResource.getBitmap().getWidth() +
+ " heigh: " + mImageResource.getBitmap().getHeight());
+ }
+ }
+ }
+ invalidate();
+ }
+
+ private void requestImage(final BindableMediaRequest<ImageResource> request) {
+ mImageRequestBinding.bind(request);
+ if (mDelayLoader == null || !mDelayLoader.isDelayLoadingImage()) {
+ MediaResourceManager.get().requestMediaResourceAsync(request);
+ } else {
+ mDelayLoader.registerView(this);
+ }
+ }
+
+ @Override
+ public void onMediaResourceLoaded(final MediaRequest<ImageResource> request,
+ final ImageResource resource, final boolean isCached) {
+ if (mImageResource != resource) {
+ setImage(resource, isCached);
+ }
+ }
+
+ @Override
+ public void onMediaResourceLoadError(
+ final MediaRequest<ImageResource> request, final Exception exception) {
+ // Media load failed, unbind and reset bitmap to default.
+ unbindView();
+ setImage(null);
+ }
+
+ private void releaseImageResource() {
+ final Drawable drawable = getDrawable();
+ if (drawable instanceof FrameSequenceDrawable) {
+ ((FrameSequenceDrawable) drawable).stop();
+ ((FrameSequenceDrawable) drawable).destroy();
+ }
+ if (mImageResource != null) {
+ mImageResource.release();
+ mImageResource = null;
+ }
+ setImageDrawable(null);
+ setBackground(null);
+ }
+
+ /**
+ * Resets transient view states (eg. alpha, animations) before rebinding/reusing the view.
+ */
+ private void resetTransientViewStates() {
+ clearAnimation();
+ setAlpha(1F);
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ // If it was recently removed, then cancel disposing, we're still using it.
+ ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable);
+
+ // When the image view gets detached and immediately re-attached, any fade-in animation
+ // will be terminated, leaving the view in a semi-transparent state. Make sure we restore
+ // alpha when the view is re-attached.
+ if (mFadeIn) {
+ setAlpha(1F);
+ }
+
+ // Check whether we are in a simple reuse scenario: detached from window, and reattached
+ // later without rebinding. This may be done by containers such as the RecyclerView to
+ // reuse the views. In this case, we would like to rebind the original image request.
+ if (!mImageRequestBinding.isBound() && mDetachedRequestDescriptor != null) {
+ setImageResourceId(mDetachedRequestDescriptor);
+ }
+ mDetachedRequestDescriptor = null;
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ // Dispose the bitmap, but if an AysncImageView is removed from the window, then quickly
+ // re-added, we shouldn't dispose, so wait a short time before disposing
+ ThreadUtil.getMainThreadHandler().postDelayed(mDisposeRunnable, DISPOSE_IMAGE_DELAY);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ // The base implementation does not honor the minimum sizes. We try to to honor it here.
+
+ final int measuredWidth = getMeasuredWidth();
+ final int measuredHeight = getMeasuredHeight();
+ if (measuredWidth >= getMinimumWidth() || measuredHeight >= getMinimumHeight()) {
+ // We are ok if either of the minimum sizes is honored. Note that satisfying both the
+ // sizes may not be possible, depending on the aspect ratio of the image and whether
+ // a maximum size has been specified. This implementation only tries to handle the case
+ // where both the minimum sizes are not being satisfied.
+ return;
+ }
+
+ if (!getAdjustViewBounds()) {
+ // The base implementation is reasonable in this case. If the view bounds cannot be
+ // changed, it is not possible to satisfy the minimum sizes anyway.
+ return;
+ }
+
+ final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
+ final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
+ if (widthSpecMode == MeasureSpec.EXACTLY && heightSpecMode == MeasureSpec.EXACTLY) {
+ // The base implementation is reasonable in this case.
+ return;
+ }
+
+ int width = measuredWidth;
+ int height = measuredHeight;
+ // Get the minimum sizes that will honor other constraints as well.
+ final int minimumWidth = resolveSize(
+ getMinimumWidth(), getMaxWidth(), widthMeasureSpec);
+ final int minimumHeight = resolveSize(
+ getMinimumHeight(), getMaxHeight(), heightMeasureSpec);
+ final float aspectRatio = measuredWidth / (float) measuredHeight;
+ if (aspectRatio == 0) {
+ // If the image is (close to) infinitely high, there is not much we can do.
+ return;
+ }
+
+ if (width < minimumWidth) {
+ height = resolveSize((int) (minimumWidth / aspectRatio),
+ getMaxHeight(), heightMeasureSpec);
+ width = (int) (height * aspectRatio);
+ }
+
+ if (height < minimumHeight) {
+ width = resolveSize((int) (minimumHeight * aspectRatio),
+ getMaxWidth(), widthMeasureSpec);
+ height = (int) (width / aspectRatio);
+ }
+
+ setMeasuredDimension(width, height);
+ }
+
+ private static int resolveSize(int desiredSize, int maxSize, int measureSpec) {
+ final int specMode = MeasureSpec.getMode(measureSpec);
+ final int specSize = MeasureSpec.getSize(measureSpec);
+ switch(specMode) {
+ case MeasureSpec.UNSPECIFIED:
+ return Math.min(desiredSize, maxSize);
+
+ case MeasureSpec.AT_MOST:
+ return Math.min(Math.min(desiredSize, specSize), maxSize);
+
+ default:
+ Assert.fail("Unreachable");
+ return specSize;
+ }
+ }
+
+ @Override
+ protected void onDraw(final Canvas canvas) {
+ if (mCornerRadius > 0) {
+ final int currentWidth = this.getWidth();
+ final int currentHeight = this.getHeight();
+ if (mClipPathWidth != currentWidth || mClipPathHeight != currentHeight) {
+ final RectF rect = new RectF(0, 0, currentWidth, currentHeight);
+ mRoundedCornerClipPath.reset();
+ mRoundedCornerClipPath.addRoundRect(rect, mCornerRadius, mCornerRadius,
+ Path.Direction.CW);
+ mClipPathWidth = currentWidth;
+ mClipPathHeight = currentHeight;
+ }
+
+ final int saveCount = canvas.getSaveCount();
+ canvas.save();
+ canvas.clipPath(mRoundedCornerClipPath);
+ super.onDraw(canvas);
+ canvas.restoreToCount(saveCount);
+ } else {
+ super.onDraw(canvas);
+ }
+ }
+
+ private void unbindView() {
+ if (mImageRequestBinding.isBound()) {
+ mImageRequestBinding.unbind();
+ if (mDelayLoader != null) {
+ mDelayLoader.unregisterView(this);
+ }
+ }
+ }
+
+ /**
+ * As a performance optimization, the consumer of the AsyncImageView may opt to delay loading
+ * the image when it's busy doing other things (such as when a list view is scrolling). In
+ * order to do this, the consumer can create a new AsyncImageViewDelayLoader instance to be
+ * shared among all relevant AsyncImageViews (through setDelayLoader() method), and call
+ * onStartDelayLoading() and onStopDelayLoading() to start and stop delay loading, respectively.
+ */
+ public static class AsyncImageViewDelayLoader {
+ private boolean mShouldDelayLoad;
+ private final HashSet<AsyncImageView> mAttachedViews;
+
+ public AsyncImageViewDelayLoader() {
+ mAttachedViews = new HashSet<AsyncImageView>();
+ }
+
+ private void registerView(final AsyncImageView view) {
+ mAttachedViews.add(view);
+ }
+
+ private void unregisterView(final AsyncImageView view) {
+ mAttachedViews.remove(view);
+ }
+
+ public boolean isDelayLoadingImage() {
+ return mShouldDelayLoad;
+ }
+
+ /**
+ * Called by the consumer of this view to delay loading images
+ */
+ public void onDelayLoading() {
+ // Don't need to explicitly tell the AsyncImageView to stop loading since
+ // ImageRequests are not cancellable.
+ mShouldDelayLoad = true;
+ }
+
+ /**
+ * Called by the consumer of this view to resume loading images
+ */
+ public void onResumeLoading() {
+ if (mShouldDelayLoad) {
+ mShouldDelayLoad = false;
+
+ // Notify all attached views to resume loading.
+ for (final AsyncImageView view : mAttachedViews) {
+ view.resumeLoading();
+ }
+ mAttachedViews.clear();
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/AttachmentPreview.java b/src/com/android/messaging/ui/AttachmentPreview.java
new file mode 100644
index 0000000..7eea14b
--- /dev/null
+++ b/src/com/android/messaging/ui/AttachmentPreview.java
@@ -0,0 +1,331 @@
+/*
+ * 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.messaging.ui;
+
+import android.animation.Animator;
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.graphics.Rect;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageButton;
+import android.widget.ScrollView;
+
+import com.android.messaging.R;
+import com.android.messaging.annotation.VisibleForAnimation;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.MediaPickerMessagePartData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.PendingAttachmentData;
+import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener;
+import com.android.messaging.ui.animation.PopupTransitionAnimation;
+import com.android.messaging.ui.conversation.ComposeMessageView;
+import com.android.messaging.ui.conversation.ConversationFragment;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ThreadUtil;
+import com.android.messaging.util.UiUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class AttachmentPreview extends ScrollView implements OnAttachmentClickListener {
+ private FrameLayout mAttachmentView;
+ private ComposeMessageView mComposeMessageView;
+ private ImageButton mCloseButton;
+ private int mAnimatedHeight = -1;
+ private Animator mCloseGapAnimator;
+ private boolean mPendingFirstUpdate;
+ private Handler mHandler;
+ private Runnable mHideRunnable;
+ private boolean mPendingHideCanceled;
+
+ private static final int CLOSE_BUTTON_REVEAL_STAGGER_MILLIS = 300;
+
+ public AttachmentPreview(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mHandler = new Handler(Looper.getMainLooper());
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mCloseButton = (ImageButton) findViewById(R.id.close_button);
+ mCloseButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ mComposeMessageView.clearAttachments();
+ }
+ });
+
+ mAttachmentView = (FrameLayout) findViewById(R.id.attachment_view);
+
+ // The attachment preview is a scroll view so that it can show the bottom portion of the
+ // attachment whenever the space is tight (e.g. when in landscape mode). Per design
+ // request we'd like to make the attachment view always scrolled to the bottom.
+ addOnLayoutChangeListener(new OnLayoutChangeListener() {
+ @Override
+ public void onLayoutChange(final View v, final int left, final int top, final int right,
+ final int bottom, final int oldLeft, final int oldTop, final int oldRight,
+ final int oldBottom) {
+ post(new Runnable() {
+ @Override
+ public void run() {
+ final int childCount = getChildCount();
+ if (childCount > 0) {
+ final View lastChild = getChildAt(childCount - 1);
+ scrollTo(getScrollX(), lastChild.getBottom() - getHeight());
+ }
+ }
+ });
+ }
+ });
+ mPendingFirstUpdate = true;
+ }
+
+ public void setComposeMessageView(final ComposeMessageView composeMessageView) {
+ mComposeMessageView = composeMessageView;
+ }
+
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ if (mAnimatedHeight >= 0) {
+ setMeasuredDimension(getMeasuredWidth(), mAnimatedHeight);
+ }
+ }
+
+ private void cancelPendingHide() {
+ mPendingHideCanceled = true;
+ }
+
+ public void hideAttachmentPreview() {
+ if (getVisibility() != GONE) {
+ UiUtils.revealOrHideViewWithAnimation(mCloseButton, GONE,
+ null /* onFinishRunnable */);
+ startCloseGapAnimationOnAttachmentClear();
+
+ if (mAttachmentView.getChildCount() > 0) {
+ mPendingHideCanceled = false;
+ final View viewToHide = mAttachmentView.getChildCount() > 1 ?
+ mAttachmentView : mAttachmentView.getChildAt(0);
+ UiUtils.revealOrHideViewWithAnimation(viewToHide, INVISIBLE,
+ new Runnable() {
+ @Override
+ public void run() {
+ // Only hide if we are didn't get overruled by showing
+ if (!mPendingHideCanceled) {
+ mAttachmentView.removeAllViews();
+ setVisibility(GONE);
+ }
+ }
+ });
+ } else {
+ mAttachmentView.removeAllViews();
+ setVisibility(GONE);
+ }
+ }
+ }
+
+ // returns true if we have attachments
+ public boolean onAttachmentsChanged(final DraftMessageData draftMessageData) {
+ final boolean isFirstUpdate = mPendingFirstUpdate;
+ final List<MessagePartData> attachments = draftMessageData.getReadOnlyAttachments();
+ final List<PendingAttachmentData> pendingAttachments =
+ draftMessageData.getReadOnlyPendingAttachments();
+
+ // Any change in attachments would invalidate the animated height animation.
+ cancelCloseGapAnimation();
+ mPendingFirstUpdate = false;
+
+ final int combinedAttachmentCount = attachments.size() + pendingAttachments.size();
+ mCloseButton.setContentDescription(getResources()
+ .getQuantityString(R.plurals.attachment_preview_close_content_description,
+ combinedAttachmentCount));
+ if (combinedAttachmentCount == 0) {
+ mHideRunnable = new Runnable() {
+ @Override
+ public void run() {
+ mHideRunnable = null;
+ // Only start the hiding if there are still no attachments
+ if (attachments.size() + pendingAttachments.size() == 0) {
+ hideAttachmentPreview();
+ }
+ }
+ };
+ if (draftMessageData.isSending()) {
+ // Wait to hide until the message is ready to start animating
+ // We'll execute immediately when the animation triggers
+ mHandler.postDelayed(mHideRunnable,
+ ConversationFragment.MESSAGE_ANIMATION_MAX_WAIT);
+ } else {
+ // Run immediately when clearing attachments
+ mHideRunnable.run();
+ }
+ return false;
+ }
+
+ cancelPendingHide(); // We're showing
+ if (getVisibility() != VISIBLE) {
+ setVisibility(VISIBLE);
+ mAttachmentView.setVisibility(VISIBLE);
+
+ // Don't animate in the close button if this is the first update after view creation.
+ // This is the initial draft load from database for pre-existing drafts.
+ if (!isFirstUpdate) {
+ // Reveal the close button after the view animates in.
+ mCloseButton.setVisibility(INVISIBLE);
+ ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ UiUtils.revealOrHideViewWithAnimation(mCloseButton, VISIBLE,
+ null /* onFinishRunnable */);
+ }
+ }, UiUtils.MEDIAPICKER_TRANSITION_DURATION + CLOSE_BUTTON_REVEAL_STAGGER_MILLIS);
+ }
+ }
+
+ // Merge the pending attachment list with real attachment. Design would prefer these be
+ // in LIFO order user can see added images past the 5th one but we also want them to be in
+ // order and we want it to be WYSIWYG.
+ final List<MessagePartData> combinedAttachments = new ArrayList<>();
+ combinedAttachments.addAll(attachments);
+ combinedAttachments.addAll(pendingAttachments);
+
+ final LayoutInflater layoutInflater = LayoutInflater.from(getContext());
+ if (combinedAttachmentCount > 1) {
+ MultiAttachmentLayout multiAttachmentLayout = null;
+ Rect transitionRect = null;
+ if (mAttachmentView.getChildCount() > 0) {
+ final View firstChild = mAttachmentView.getChildAt(0);
+ if (firstChild instanceof MultiAttachmentLayout) {
+ Assert.equals(1, mAttachmentView.getChildCount());
+ multiAttachmentLayout = (MultiAttachmentLayout) firstChild;
+ multiAttachmentLayout.bindAttachments(combinedAttachments,
+ null /* transitionRect */, combinedAttachmentCount);
+ } else {
+ transitionRect = new Rect(firstChild.getLeft(), firstChild.getTop(),
+ firstChild.getRight(), firstChild.getBottom());
+ }
+ }
+ if (multiAttachmentLayout == null) {
+ multiAttachmentLayout = AttachmentPreviewFactory.createMultiplePreview(
+ getContext(), this);
+ multiAttachmentLayout.bindAttachments(combinedAttachments, transitionRect,
+ combinedAttachmentCount);
+ mAttachmentView.removeAllViews();
+ mAttachmentView.addView(multiAttachmentLayout);
+ }
+ } else {
+ final MessagePartData attachment = combinedAttachments.get(0);
+ boolean shouldAnimate = true;
+ if (mAttachmentView.getChildCount() > 0) {
+ // If we are going from N->1 attachments, try to use the current bounds
+ // bounds as the starting rect.
+ shouldAnimate = false;
+ final View firstChild = mAttachmentView.getChildAt(0);
+ if (firstChild instanceof MultiAttachmentLayout &&
+ attachment instanceof MediaPickerMessagePartData) {
+ final View leftoverView = ((MultiAttachmentLayout) firstChild)
+ .findViewForAttachment(attachment);
+ if (leftoverView != null) {
+ final Rect currentRect = UiUtils.getMeasuredBoundsOnScreen(leftoverView);
+ if (!currentRect.isEmpty() &&
+ attachment instanceof MediaPickerMessagePartData) {
+ ((MediaPickerMessagePartData) attachment).setStartRect(currentRect);
+ shouldAnimate = true;
+ }
+ }
+ }
+ }
+ mAttachmentView.removeAllViews();
+ final View attachmentView = AttachmentPreviewFactory.createAttachmentPreview(
+ layoutInflater, attachment, mAttachmentView,
+ AttachmentPreviewFactory.TYPE_SINGLE, true /* startImageRequest */, this);
+ if (attachmentView != null) {
+ mAttachmentView.addView(attachmentView);
+ if (shouldAnimate) {
+ tryAnimateViewIn(attachment, attachmentView);
+ }
+ }
+ }
+ return true;
+ }
+
+ public void onMessageAnimationStart() {
+ if (mHideRunnable == null) {
+ return;
+ }
+
+ // Run the hide animation at the same time as the message animation
+ mHandler.removeCallbacks(mHideRunnable);
+ setVisibility(View.INVISIBLE);
+ mHideRunnable.run();
+ }
+
+ static void tryAnimateViewIn(final MessagePartData attachmentData, final View view) {
+ if (attachmentData instanceof MediaPickerMessagePartData) {
+ final Rect startRect = ((MediaPickerMessagePartData) attachmentData).getStartRect();
+ new PopupTransitionAnimation(startRect, view).startAfterLayoutComplete();
+ }
+ }
+
+ @VisibleForAnimation
+ public void setAnimatedHeight(final int animatedHeight) {
+ if (mAnimatedHeight != animatedHeight) {
+ mAnimatedHeight = animatedHeight;
+ requestLayout();
+ }
+ }
+
+ /**
+ * Kicks off an animation to animate the layout change for closing the gap between the
+ * message list and the compose message box when the attachments are cleared.
+ */
+ private void startCloseGapAnimationOnAttachmentClear() {
+ // Cancel existing animation.
+ cancelCloseGapAnimation();
+ mCloseGapAnimator = ObjectAnimator.ofInt(this, "animatedHeight", getHeight(), 0);
+ mCloseGapAnimator.start();
+ }
+
+ private void cancelCloseGapAnimation() {
+ if (mCloseGapAnimator != null) {
+ mCloseGapAnimator.cancel();
+ mCloseGapAnimator = null;
+ }
+ mAnimatedHeight = -1;
+ }
+
+ @Override
+ public boolean onAttachmentClick(final MessagePartData attachment,
+ final Rect viewBoundsOnScreen, final boolean longPress) {
+ if (longPress) {
+ mComposeMessageView.onAttachmentPreviewLongClicked();
+ return true;
+ }
+
+ if (!(attachment instanceof PendingAttachmentData) && attachment.isImage()) {
+ mComposeMessageView.displayPhoto(attachment.getContentUri(), viewBoundsOnScreen);
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/com/android/messaging/ui/AttachmentPreviewFactory.java b/src/com/android/messaging/ui/AttachmentPreviewFactory.java
new file mode 100644
index 0000000..ed5d4d7
--- /dev/null
+++ b/src/com/android/messaging/ui/AttachmentPreviewFactory.java
@@ -0,0 +1,299 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.View.OnLongClickListener;
+import android.view.ViewGroup;
+import android.widget.FrameLayout.LayoutParams;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.PendingAttachmentData;
+import com.android.messaging.datamodel.data.PersonItemData;
+import com.android.messaging.datamodel.data.VCardContactItemData;
+import com.android.messaging.datamodel.media.FileImageRequestDescriptor;
+import com.android.messaging.datamodel.media.ImageRequest;
+import com.android.messaging.datamodel.media.ImageRequestDescriptor;
+import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
+import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener;
+import com.android.messaging.ui.PersonItemView.PersonItemViewListener;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.UiUtils;
+import com.android.messaging.util.UriUtil;
+
+/**
+ * A view factory that creates previews for single/multiple attachments.
+ */
+public class AttachmentPreviewFactory {
+ /** Standalone attachment preview */
+ public static final int TYPE_SINGLE = 1;
+
+ /** Attachment preview displayed in a multi-attachment layout */
+ public static final int TYPE_MULTIPLE = 2;
+
+ /** Attachment preview displayed in the attachment chooser grid view */
+ public static final int TYPE_CHOOSER_GRID = 3;
+
+ public static View createAttachmentPreview(final LayoutInflater layoutInflater,
+ final MessagePartData attachmentData, final ViewGroup parent,
+ final int viewType, final boolean startImageRequest,
+ @Nullable final OnAttachmentClickListener clickListener) {
+ final String contentType = attachmentData.getContentType();
+ View attachmentView = null;
+ if (attachmentData instanceof PendingAttachmentData) {
+ attachmentView = createPendingAttachmentPreview(layoutInflater, parent,
+ (PendingAttachmentData) attachmentData);
+ } else if (ContentType.isImageType(contentType)) {
+ attachmentView = createImagePreview(layoutInflater, attachmentData, parent, viewType,
+ startImageRequest);
+ } else if (ContentType.isAudioType(contentType)) {
+ attachmentView = createAudioPreview(layoutInflater, attachmentData, parent, viewType);
+ } else if (ContentType.isVideoType(contentType)) {
+ attachmentView = createVideoPreview(layoutInflater, attachmentData, parent, viewType);
+ } else if (ContentType.isVCardType(contentType)) {
+ attachmentView = createVCardPreview(layoutInflater, attachmentData, parent, viewType);
+ } else {
+ Assert.fail("unsupported attachment type: " + contentType);
+ return null;
+ }
+
+ // Some views have a caption, set the text/visibility if one exists
+ final TextView captionView = (TextView) attachmentView.findViewById(R.id.caption);
+ if (captionView != null) {
+ final String caption = attachmentData.getText();
+ captionView.setVisibility(TextUtils.isEmpty(caption) ? View.GONE : View.VISIBLE);
+ captionView.setText(caption);
+ }
+
+ if (attachmentView != null && clickListener != null) {
+ attachmentView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view);
+ clickListener.onAttachmentClick(attachmentData, bounds,
+ false /* longPress */);
+ }
+ });
+ attachmentView.setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(final View view) {
+ final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view);
+ return clickListener.onAttachmentClick(attachmentData, bounds,
+ true /* longPress */);
+ }
+ });
+ }
+ return attachmentView;
+ }
+
+ public static MultiAttachmentLayout createMultiplePreview(final Context context,
+ final OnAttachmentClickListener listener) {
+ final MultiAttachmentLayout multiAttachmentLayout =
+ new MultiAttachmentLayout(context, null);
+ final ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(
+ LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+ multiAttachmentLayout.setLayoutParams(layoutParams);
+ multiAttachmentLayout.setOnAttachmentClickListener(listener);
+ return multiAttachmentLayout;
+ }
+
+ public static ImageRequestDescriptor getImageRequestDescriptorForAttachment(
+ final MessagePartData attachmentData, final int desiredWidth, final int desiredHeight) {
+ final Uri uri = attachmentData.getContentUri();
+ final String contentType = attachmentData.getContentType();
+ if (ContentType.isImageType(contentType)) {
+ final String filePath = UriUtil.getFilePathFromUri(uri);
+ if (filePath != null) {
+ return new FileImageRequestDescriptor(filePath, desiredWidth, desiredHeight,
+ attachmentData.getWidth(), attachmentData.getHeight(),
+ false /* canUseThumbnail */, true /* allowCompression */,
+ false /* isStatic */);
+ } else {
+ return new UriImageRequestDescriptor(uri, desiredWidth, desiredHeight,
+ attachmentData.getWidth(), attachmentData.getHeight(),
+ true /* allowCompression */, false /* isStatic */, false /*cropToCircle*/,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ }
+ }
+ return null;
+ }
+
+ private static View createImagePreview(final LayoutInflater layoutInflater,
+ final MessagePartData attachmentData, final ViewGroup parent,
+ final int viewType, final boolean startImageRequest) {
+ int layoutId = R.layout.attachment_single_image;
+ switch (viewType) {
+ case AttachmentPreviewFactory.TYPE_SINGLE:
+ layoutId = R.layout.attachment_single_image;
+ break;
+ case AttachmentPreviewFactory.TYPE_MULTIPLE:
+ layoutId = R.layout.attachment_multiple_image;
+ break;
+ case AttachmentPreviewFactory.TYPE_CHOOSER_GRID:
+ layoutId = R.layout.attachment_chooser_image;
+ break;
+ default:
+ Assert.fail("unsupported attachment view type!");
+ break;
+ }
+ final View view = layoutInflater.inflate(layoutId, parent, false /* attachToRoot */);
+ final AsyncImageView imageView = (AsyncImageView) view.findViewById(
+ R.id.attachment_image_view);
+ int maxWidth = imageView.getMaxWidth();
+ int maxHeight = imageView.getMaxHeight();
+ if (viewType == TYPE_CHOOSER_GRID) {
+ final Resources resources = layoutInflater.getContext().getResources();
+ maxWidth = maxHeight = resources.getDimensionPixelSize(
+ R.dimen.attachment_grid_image_cell_size);
+ }
+ if (maxWidth <= 0 || maxWidth == Integer.MAX_VALUE) {
+ maxWidth = ImageRequest.UNSPECIFIED_SIZE;
+ }
+ if (maxHeight <= 0 || maxHeight == Integer.MAX_VALUE) {
+ maxHeight = ImageRequest.UNSPECIFIED_SIZE;
+ }
+ if (startImageRequest) {
+ imageView.setImageResourceId(getImageRequestDescriptorForAttachment(attachmentData,
+ maxWidth, maxHeight));
+ }
+ imageView.setContentDescription(
+ parent.getResources().getString(R.string.message_image_content_description));
+ return view;
+ }
+
+ private static View createPendingAttachmentPreview(final LayoutInflater layoutInflater,
+ final ViewGroup parent, final PendingAttachmentData attachmentData) {
+ final View pendingItemView = layoutInflater.inflate(R.layout.attachment_pending_item,
+ parent, false);
+ final ImageView imageView = (ImageView)
+ pendingItemView.findViewById(R.id.pending_item_view);
+ final ViewGroup.LayoutParams layoutParams = imageView.getLayoutParams();
+ final int defaultSize = layoutInflater.getContext().getResources().getDimensionPixelSize(
+ R.dimen.pending_attachment_size);
+ layoutParams.width = attachmentData.getWidth() == MessagePartData.UNSPECIFIED_SIZE ?
+ defaultSize : attachmentData.getWidth();
+ layoutParams.height = attachmentData.getHeight() == MessagePartData.UNSPECIFIED_SIZE ?
+ defaultSize : attachmentData.getHeight();
+ return pendingItemView;
+ }
+
+ private static View createVCardPreview(final LayoutInflater layoutInflater,
+ final MessagePartData attachmentData, final ViewGroup parent,
+ final int viewType) {
+ int layoutId = R.layout.attachment_single_vcard;
+ switch (viewType) {
+ case AttachmentPreviewFactory.TYPE_SINGLE:
+ layoutId = R.layout.attachment_single_vcard;
+ break;
+ case AttachmentPreviewFactory.TYPE_MULTIPLE:
+ layoutId = R.layout.attachment_multiple_vcard;
+ break;
+ case AttachmentPreviewFactory.TYPE_CHOOSER_GRID:
+ layoutId = R.layout.attachment_chooser_vcard;
+ break;
+ default:
+ Assert.fail("unsupported attachment view type!");
+ break;
+ }
+ final View view = layoutInflater.inflate(layoutId, parent, false /* attachToRoot */);
+ final PersonItemView vcardPreview = (PersonItemView) view.findViewById(
+ R.id.vcard_attachment_view);
+ vcardPreview.setAvatarOnly(viewType != AttachmentPreviewFactory.TYPE_SINGLE);
+ vcardPreview.bind(DataModel.get().createVCardContactItemData(layoutInflater.getContext(),
+ attachmentData));
+ vcardPreview.setListener(new PersonItemViewListener() {
+ @Override
+ public void onPersonClicked(final PersonItemData data) {
+ Assert.isTrue(data instanceof VCardContactItemData);
+ final VCardContactItemData vCardData = (VCardContactItemData) data;
+ if (vCardData.hasValidVCard()) {
+ final Uri vCardUri = vCardData.getVCardUri();
+ UIIntents.get().launchVCardDetailActivity(vcardPreview.getContext(), vCardUri);
+ }
+ }
+
+ @Override
+ public boolean onPersonLongClicked(final PersonItemData data) {
+ return false;
+ }
+ });
+ return view;
+ }
+
+ private static View createAudioPreview(final LayoutInflater layoutInflater,
+ final MessagePartData attachmentData, final ViewGroup parent,
+ final int viewType) {
+ int layoutId = R.layout.attachment_single_audio;
+ switch (viewType) {
+ case AttachmentPreviewFactory.TYPE_SINGLE:
+ layoutId = R.layout.attachment_single_audio;
+ break;
+ case AttachmentPreviewFactory.TYPE_MULTIPLE:
+ layoutId = R.layout.attachment_multiple_audio;
+ break;
+ case AttachmentPreviewFactory.TYPE_CHOOSER_GRID:
+ layoutId = R.layout.attachment_chooser_audio;
+ break;
+ default:
+ Assert.fail("unsupported attachment view type!");
+ break;
+ }
+ final View view = layoutInflater.inflate(layoutId, parent, false /* attachToRoot */);
+ final AudioAttachmentView audioView = (AudioAttachmentView)
+ view.findViewById(R.id.audio_attachment_view);
+ audioView.bindMessagePartData(attachmentData, false /* incoming */);
+ return view;
+ }
+
+ private static View createVideoPreview(final LayoutInflater layoutInflater,
+ final MessagePartData attachmentData, final ViewGroup parent,
+ final int viewType) {
+ int layoutId = R.layout.attachment_single_video;
+ switch (viewType) {
+ case AttachmentPreviewFactory.TYPE_SINGLE:
+ layoutId = R.layout.attachment_single_video;
+ break;
+ case AttachmentPreviewFactory.TYPE_MULTIPLE:
+ layoutId = R.layout.attachment_multiple_video;
+ break;
+ case AttachmentPreviewFactory.TYPE_CHOOSER_GRID:
+ layoutId = R.layout.attachment_chooser_video;
+ break;
+ default:
+ Assert.fail("unsupported attachment view type!");
+ break;
+ }
+ final VideoThumbnailView videoThumbnail = (VideoThumbnailView) layoutInflater.inflate(
+ layoutId, parent, false /* attachToRoot */);
+ videoThumbnail.setSource(attachmentData, false /* incomingMessage */);
+ return videoThumbnail;
+ }
+}
diff --git a/src/com/android/messaging/ui/AudioAttachmentPlayPauseButton.java b/src/com/android/messaging/ui/AudioAttachmentPlayPauseButton.java
new file mode 100644
index 0000000..724c4fe
--- /dev/null
+++ b/src/com/android/messaging/ui/AudioAttachmentPlayPauseButton.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.messaging.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.ImageView;
+import android.widget.ViewSwitcher;
+
+import com.android.messaging.R;
+
+/**
+ * Shows a tinted play pause button.
+ */
+public class AudioAttachmentPlayPauseButton extends ViewSwitcher {
+ private ImageView mPlayButton;
+ private ImageView mPauseButton;
+
+ private boolean mShowAsIncoming;
+
+ public AudioAttachmentPlayPauseButton(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mPlayButton = (ImageView) findViewById(R.id.play_button);
+ mPauseButton = (ImageView) findViewById(R.id.pause_button);
+ updateAppearance();
+ }
+
+ public void setVisualStyle(final boolean showAsIncoming) {
+ if (mShowAsIncoming != showAsIncoming) {
+ mShowAsIncoming = showAsIncoming;
+ updateAppearance();
+ }
+ }
+
+ private void updateAppearance() {
+ mPlayButton.setImageDrawable(ConversationDrawables.get()
+ .getPlayButtonDrawable(mShowAsIncoming));
+ mPauseButton.setImageDrawable(ConversationDrawables.get()
+ .getPauseButtonDrawable(mShowAsIncoming));
+ }
+}
diff --git a/src/com/android/messaging/ui/AudioAttachmentView.java b/src/com/android/messaging/ui/AudioAttachmentView.java
new file mode 100644
index 0000000..bb649b0
--- /dev/null
+++ b/src/com/android/messaging/ui/AudioAttachmentView.java
@@ -0,0 +1,329 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.media.AudioManager;
+import android.media.MediaPlayer;
+import android.media.MediaPlayer.OnCompletionListener;
+import android.media.MediaPlayer.OnErrorListener;
+import android.media.MediaPlayer.OnPreparedListener;
+import android.net.Uri;
+import android.os.SystemClock;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.ui.mediapicker.PausableChronometer;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * A reusable widget that hosts an audio player for audio attachment playback. This widget is used
+ * by both the media picker and the conversation message view to show audio attachments.
+ */
+public class AudioAttachmentView extends LinearLayout {
+ /** The normal layout mode where we have the play button, timer and progress bar */
+ private static final int LAYOUT_MODE_NORMAL = 0;
+
+ /** The compact layout mode with only the play button and the timer beneath it. Suitable
+ * for displaying in limited space such as multi-attachment layout */
+ private static final int LAYOUT_MODE_COMPACT = 1;
+
+ /** The sub-compact layout mode with only the play button. */
+ private static final int LAYOUT_MODE_SUB_COMPACT = 2;
+
+ private static final int PLAY_BUTTON = 0;
+ private static final int PAUSE_BUTTON = 1;
+
+ private AudioAttachmentPlayPauseButton mPlayPauseButton;
+ private PausableChronometer mChronometer;
+ private AudioPlaybackProgressBar mProgressBar;
+ private MediaPlayer mMediaPlayer;
+
+ private Uri mDataSourceUri;
+
+ // The corner radius for drawing rounded corners. The default value is zero (no rounded corners)
+ private final int mCornerRadius;
+ private final Path mRoundedCornerClipPath;
+ private int mClipPathWidth;
+ private int mClipPathHeight;
+
+ // Indicates whether the attachment view is to be styled as a part of an incoming message.
+ private boolean mShowAsIncoming;
+
+ private boolean mPrepared;
+ private boolean mPlaybackFinished;
+ private final int mMode;
+
+ public AudioAttachmentView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ final TypedArray typedAttributes =
+ context.obtainStyledAttributes(attrs, R.styleable.AudioAttachmentView);
+ mMode = typedAttributes.getInt(R.styleable.AudioAttachmentView_layoutMode,
+ LAYOUT_MODE_NORMAL);
+ final LayoutInflater inflater = LayoutInflater.from(getContext());
+ inflater.inflate(R.layout.audio_attachment_view, this, true);
+ typedAttributes.recycle();
+
+ setWillNotDraw(mMode != LAYOUT_MODE_SUB_COMPACT);
+ mRoundedCornerClipPath = new Path();
+ mCornerRadius = context.getResources().getDimensionPixelSize(
+ R.dimen.conversation_list_image_preview_corner_radius);
+ setContentDescription(context.getString(R.string.audio_attachment_content_description));
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+
+ mPlayPauseButton = (AudioAttachmentPlayPauseButton) findViewById(R.id.play_pause_button);
+ mChronometer = (PausableChronometer) findViewById(R.id.timer);
+ mProgressBar = (AudioPlaybackProgressBar) findViewById(R.id.progress);
+ mPlayPauseButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ setupMediaPlayer();
+ if (mMediaPlayer != null && mPrepared) {
+ if (mMediaPlayer.isPlaying()) {
+ mMediaPlayer.pause();
+ mChronometer.pause();
+ mProgressBar.pause();
+ } else {
+ playAudio();
+ }
+ }
+ updatePlayPauseButtonState();
+ }
+ });
+ updatePlayPauseButtonState();
+ initializeViewsForMode();
+ }
+
+ /**
+ * Bind the audio attachment view with a MessagePartData.
+ * @param incoming indicates whether the attachment view is to be styled as a part of an
+ * incoming message.
+ */
+ public void bindMessagePartData(final MessagePartData messagePartData,
+ final boolean incoming) {
+ Assert.isTrue(messagePartData == null ||
+ ContentType.isAudioType(messagePartData.getContentType()));
+ final Uri contentUri = (messagePartData == null) ? null : messagePartData.getContentUri();
+ bind(contentUri, incoming);
+ }
+
+ public void bind(final Uri dataSourceUri, final boolean incoming) {
+ final String currentUriString = (mDataSourceUri == null) ? "" : mDataSourceUri.toString();
+ final String newUriString = (dataSourceUri == null) ? "" : dataSourceUri.toString();
+ mShowAsIncoming = incoming;
+ if (!TextUtils.equals(currentUriString, newUriString)) {
+ mDataSourceUri = dataSourceUri;
+ resetToZeroState();
+ }
+ }
+
+ private void playAudio() {
+ Assert.notNull(mMediaPlayer);
+ if (mPlaybackFinished) {
+ mMediaPlayer.seekTo(0);
+ mChronometer.restart();
+ mProgressBar.restart();
+ mPlaybackFinished = false;
+ } else {
+ mChronometer.resume();
+ mProgressBar.resume();
+ }
+ mMediaPlayer.start();
+ }
+
+ private void onAudioReplayError(final int what, final int extra, final Exception exception) {
+ if (exception == null) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "audio replay failed, what=" + what +
+ ", extra=" + extra);
+ } else {
+ LogUtil.e(LogUtil.BUGLE_TAG, "audio replay failed, exception=" + exception);
+ }
+ UiUtils.showToastAtBottom(R.string.audio_recording_replay_failed);
+ releaseMediaPlayer();
+ }
+
+ private void setupMediaPlayer() {
+ Assert.notNull(mDataSourceUri);
+ if (mMediaPlayer == null) {
+ Assert.isTrue(!mPrepared);
+ mMediaPlayer = new MediaPlayer();
+ try {
+ mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
+ mMediaPlayer.setDataSource(Factory.get().getApplicationContext(), mDataSourceUri);
+ mMediaPlayer.setOnCompletionListener(new OnCompletionListener() {
+ @Override
+ public void onCompletion(final MediaPlayer mp) {
+ updatePlayPauseButtonState();
+ mChronometer.reset();
+ mChronometer.setBase(SystemClock.elapsedRealtime() -
+ mMediaPlayer.getDuration());
+ mProgressBar.reset();
+
+ mPlaybackFinished = true;
+ }
+ });
+
+ mMediaPlayer.setOnPreparedListener(new OnPreparedListener() {
+ @Override
+ public void onPrepared(final MediaPlayer mp) {
+ // Set base on the chronometer so we can show the full length of the audio.
+ mChronometer.setBase(SystemClock.elapsedRealtime() -
+ mMediaPlayer.getDuration());
+ mProgressBar.setDuration(mMediaPlayer.getDuration());
+ mMediaPlayer.seekTo(0);
+ mPrepared = true;
+ }
+ });
+
+ mMediaPlayer.setOnErrorListener(new OnErrorListener() {
+ @Override
+ public boolean onError(final MediaPlayer mp, final int what, final int extra) {
+ onAudioReplayError(what, extra, null);
+ return true;
+ }
+ });
+ mMediaPlayer.prepareAsync();
+ } catch (final Exception exception) {
+ onAudioReplayError(0, 0, exception);
+ releaseMediaPlayer();
+ }
+ }
+ }
+
+ private void releaseMediaPlayer() {
+ if (mMediaPlayer != null) {
+ mMediaPlayer.release();
+ mMediaPlayer = null;
+ mPrepared = false;
+ mPlaybackFinished = false;
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ releaseMediaPlayer();
+ }
+
+ @Override
+ protected void onDraw(final Canvas canvas) {
+ if (mMode != LAYOUT_MODE_SUB_COMPACT) {
+ return;
+ }
+
+ final int currentWidth = this.getWidth();
+ final int currentHeight = this.getHeight();
+ if (mClipPathWidth != currentWidth || mClipPathHeight != currentHeight) {
+ final RectF rect = new RectF(0, 0, currentWidth, currentHeight);
+ mRoundedCornerClipPath.reset();
+ mRoundedCornerClipPath.addRoundRect(rect, mCornerRadius, mCornerRadius,
+ Path.Direction.CW);
+ mClipPathWidth = currentWidth;
+ mClipPathHeight = currentHeight;
+ }
+
+ canvas.clipPath(mRoundedCornerClipPath);
+ super.onDraw(canvas);
+ }
+
+ private void updatePlayPauseButtonState() {
+ if (mMediaPlayer == null || !mMediaPlayer.isPlaying()) {
+ mPlayPauseButton.setDisplayedChild(PLAY_BUTTON);
+ } else {
+ mPlayPauseButton.setDisplayedChild(PAUSE_BUTTON);
+ }
+ }
+
+ private void resetToZeroState() {
+ // Release the media player so it may be set up with the new audio source.
+ releaseMediaPlayer();
+ mChronometer.reset();
+ mProgressBar.reset();
+ updateVisualStyle();
+
+ if (mDataSourceUri != null) {
+ // Re-ensure the media player, so we can read the duration of the audio.
+ setupMediaPlayer();
+ }
+ }
+
+ private void updateVisualStyle() {
+ if (mMode == LAYOUT_MODE_SUB_COMPACT) {
+ // Sub-compact mode has static visual appearance already set up during initialization.
+ return;
+ }
+
+ if (mShowAsIncoming) {
+ mChronometer.setTextColor(getResources().getColor(R.color.message_text_color_incoming));
+ } else {
+ mChronometer.setTextColor(getResources().getColor(R.color.message_text_color_outgoing));
+ }
+ mProgressBar.setVisualStyle(mShowAsIncoming);
+ mPlayPauseButton.setVisualStyle(mShowAsIncoming);
+ updatePlayPauseButtonState();
+ }
+
+ private void initializeViewsForMode() {
+ switch (mMode) {
+ case LAYOUT_MODE_NORMAL:
+ setOrientation(HORIZONTAL);
+ mProgressBar.setVisibility(VISIBLE);
+ break;
+
+ case LAYOUT_MODE_COMPACT:
+ setOrientation(VERTICAL);
+ mProgressBar.setVisibility(GONE);
+ ((MarginLayoutParams) mPlayPauseButton.getLayoutParams()).setMargins(0, 0, 0, 0);
+ ((MarginLayoutParams) mChronometer.getLayoutParams()).setMargins(0, 0, 0, 0);
+ break;
+
+ case LAYOUT_MODE_SUB_COMPACT:
+ setOrientation(VERTICAL);
+ mProgressBar.setVisibility(GONE);
+ mChronometer.setVisibility(GONE);
+ ((MarginLayoutParams) mPlayPauseButton.getLayoutParams()).setMargins(0, 0, 0, 0);
+ final ImageView playButton = (ImageView) findViewById(R.id.play_button);
+ playButton.setImageDrawable(
+ getResources().getDrawable(R.drawable.ic_preview_play));
+ final ImageView pauseButton = (ImageView) findViewById(R.id.pause_button);
+ pauseButton.setImageDrawable(
+ getResources().getDrawable(R.drawable.ic_preview_pause));
+ break;
+
+ default:
+ Assert.fail("Unsupported mode for AudioAttachmentView!");
+ break;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/AudioPlaybackProgressBar.java b/src/com/android/messaging/ui/AudioPlaybackProgressBar.java
new file mode 100644
index 0000000..a5b3a57
--- /dev/null
+++ b/src/com/android/messaging/ui/AudioPlaybackProgressBar.java
@@ -0,0 +1,121 @@
+/*
+ * 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.messaging.ui;
+
+import android.animation.ObjectAnimator;
+import android.animation.TimeAnimator;
+import android.animation.TimeAnimator.TimeListener;
+import android.content.Context;
+import android.graphics.drawable.ClipDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.SystemClock;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.widget.ProgressBar;
+
+/**
+ * Shows a styled progress bar that is synchronized with the playback state of an audio attachment.
+ */
+public class AudioPlaybackProgressBar extends ProgressBar implements PlaybackStateView {
+ private long mDurationInMillis;
+ private final TimeAnimator mUpdateAnimator;
+ private long mCumulativeTime = 0;
+ private long mCurrentPlayStartTime = 0;
+ private boolean mIncoming = false;
+
+ public AudioPlaybackProgressBar(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ mUpdateAnimator = new TimeAnimator();
+ mUpdateAnimator.setRepeatCount(ObjectAnimator.INFINITE);
+ mUpdateAnimator.setTimeListener(new TimeListener() {
+ @Override
+ public void onTimeUpdate(final TimeAnimator animation, final long totalTime,
+ final long deltaTime) {
+ int progress = 0;
+ if (mDurationInMillis > 0) {
+ progress = (int) (((mCumulativeTime + SystemClock.elapsedRealtime() -
+ mCurrentPlayStartTime) * 1.0f / mDurationInMillis) * 100);
+ progress = Math.max(Math.min(progress, 100), 0);
+ }
+ setProgress(progress);
+ }
+ });
+ updateAppearance();
+ }
+
+ /**
+ * Sets the duration of the audio that's being played, in milliseconds.
+ */
+ public void setDuration(final long durationInMillis) {
+ mDurationInMillis = durationInMillis;
+ }
+
+ @Override
+ public void restart() {
+ reset();
+ resume();
+ }
+
+ @Override
+ public void reset() {
+ stopUpdateTicks();
+ setProgress(0);
+ mCumulativeTime = 0;
+ mCurrentPlayStartTime = 0;
+ }
+
+ @Override
+ public void resume() {
+ mCurrentPlayStartTime = SystemClock.elapsedRealtime();
+ startUpdateTicks();
+ }
+
+ @Override
+ public void pause() {
+ mCumulativeTime += SystemClock.elapsedRealtime() - mCurrentPlayStartTime;
+ stopUpdateTicks();
+ }
+
+ private void startUpdateTicks() {
+ if (!mUpdateAnimator.isStarted()) {
+ mUpdateAnimator.start();
+ }
+ }
+
+ private void stopUpdateTicks() {
+ if (mUpdateAnimator.isStarted()) {
+ mUpdateAnimator.end();
+ }
+ }
+
+ private void updateAppearance() {
+ final Drawable drawable =
+ ConversationDrawables.get().getAudioProgressDrawable(mIncoming);
+ final ClipDrawable clipDrawable = new ClipDrawable(drawable, Gravity.START,
+ ClipDrawable.HORIZONTAL);
+ setProgressDrawable(clipDrawable);
+ setBackground(ConversationDrawables.get()
+ .getAudioProgressBackgroundDrawable(mIncoming));
+ }
+
+ public void setVisualStyle(final boolean incoming) {
+ if (mIncoming != incoming) {
+ mIncoming = incoming;
+ updateAppearance();
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/BaseBugleActivity.java b/src/com/android/messaging/ui/BaseBugleActivity.java
new file mode 100644
index 0000000..1236282
--- /dev/null
+++ b/src/com/android/messaging/ui/BaseBugleActivity.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.messaging.ui;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+import com.android.messaging.util.BugleActivityUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * Base class for app activities that would normally derive from Activity. Responsible for
+ * ensuring app requirements are met during onResume()
+ */
+public class BaseBugleActivity extends Activity {
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (UiUtils.redirectToPermissionCheckIfNeeded(this)) {
+ return;
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onResume");
+ BugleActivityUtil.onActivityResume(this, BaseBugleActivity.this);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onPause");
+ }
+}
diff --git a/src/com/android/messaging/ui/BaseBugleFragmentActivity.java b/src/com/android/messaging/ui/BaseBugleFragmentActivity.java
new file mode 100644
index 0000000..947970f
--- /dev/null
+++ b/src/com/android/messaging/ui/BaseBugleFragmentActivity.java
@@ -0,0 +1,43 @@
+/*
+ * 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.messaging.ui;
+
+import android.app.Activity;
+
+import com.android.messaging.util.BugleActivityUtil;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Base class for app activities that would normally derive from FragmentActivity. Responsible for
+ * ensuring app requirements are met during onResume()
+ */
+public class BaseBugleFragmentActivity extends Activity {
+ @Override
+ protected void onResume() {
+ super.onResume();
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onResume");
+ // Ensure we have a sufficient version of Google Play Services, prompting for upgrade and
+ // disabling the data updates if we don't have the correct version.
+ BugleActivityUtil.onActivityResume(this, BaseBugleFragmentActivity.this);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onPause");
+ }
+}
diff --git a/src/com/android/messaging/ui/BasePagerViewHolder.java b/src/com/android/messaging/ui/BasePagerViewHolder.java
new file mode 100644
index 0000000..a82ecce
--- /dev/null
+++ b/src/com/android/messaging/ui/BasePagerViewHolder.java
@@ -0,0 +1,106 @@
+/*
+ * 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.messaging.ui;
+
+import android.os.Parcelable;
+import android.view.View;
+import android.view.ViewGroup;
+
+/**
+ * The base pager view holder implementation that handles basic view creation/destruction logic,
+ * as well as logic to save/restore instance state that's persisted not only for activity
+ * reconstruction (e.g. during a configuration change), but also cases where the individual
+ * page view gets destroyed and recreated.
+ *
+ * To opt into saving/restoring instance state behavior for a particular page view, let the
+ * PageView implement PersistentInstanceState to save and restore instance states to/from a
+ * Parcelable.
+ */
+public abstract class BasePagerViewHolder implements PagerViewHolder {
+ protected View mView;
+ protected Parcelable mSavedState;
+
+ /**
+ * This is called when the entire view pager is being torn down (due to configuration change
+ * for example) that will be restored later.
+ */
+ @Override
+ public Parcelable saveState() {
+ savePendingState();
+ return mSavedState;
+ }
+
+ /**
+ * This is called when the view pager is being restored.
+ */
+ @Override
+ public void restoreState(final Parcelable restoredState) {
+ if (restoredState != null) {
+ mSavedState = restoredState;
+ // If the view is already there, let it restore the state. Otherwise, it will be
+ // restored the next time the view gets created.
+ restorePendingState();
+ }
+ }
+
+ @Override
+ public void resetState() {
+ mSavedState = null;
+ if (mView != null && (mView instanceof PersistentInstanceState)) {
+ ((PersistentInstanceState) mView).resetState();
+ }
+ }
+
+ /**
+ * This is called when the view itself is being torn down. This may happen when the user
+ * has flipped to another page in the view pager, so we want to save the current state if
+ * possible.
+ */
+ @Override
+ public View destroyView() {
+ savePendingState();
+ final View retView = mView;
+ mView = null;
+ return retView;
+ }
+
+ @Override
+ public View getView(ViewGroup container) {
+ if (mView == null) {
+ mView = createView(container);
+ // When initially created, check if the view has any saved state. If so restore it.
+ restorePendingState();
+ }
+ return mView;
+ }
+
+ private void savePendingState() {
+ if (mView != null && (mView instanceof PersistentInstanceState)) {
+ mSavedState = ((PersistentInstanceState) mView).saveState();
+ }
+ }
+
+ private void restorePendingState() {
+ if (mView != null && (mView instanceof PersistentInstanceState) && (mSavedState != null)) {
+ ((PersistentInstanceState) mView).restoreState(mSavedState);
+ }
+ }
+
+ /**
+ * Create and initialize a new page view.
+ */
+ protected abstract View createView(ViewGroup container);
+}
diff --git a/src/com/android/messaging/ui/BlockedParticipantListItemView.java b/src/com/android/messaging/ui/BlockedParticipantListItemView.java
new file mode 100644
index 0000000..2ccdebf
--- /dev/null
+++ b/src/com/android/messaging/ui/BlockedParticipantListItemView.java
@@ -0,0 +1,64 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.support.v4.text.BidiFormatter;
+import android.support.v4.text.TextDirectionHeuristicsCompat;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ParticipantListItemData;
+
+/**
+ * View for individual participant in blocked participants list.
+ *
+ * Unblocks participant when clicked.
+ */
+public class BlockedParticipantListItemView extends LinearLayout {
+ private TextView mNameTextView;
+ private ContactIconView mContactIconView;
+ private ParticipantListItemData mData;
+
+ public BlockedParticipantListItemView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ mNameTextView = (TextView) findViewById(R.id.name);
+ mContactIconView = (ContactIconView) findViewById(R.id.contact_icon);
+ setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ mData.unblock(getContext());
+ }
+ });
+ }
+
+ public void bind(final ParticipantListItemData data) {
+ mData = data;
+ final BidiFormatter bidiFormatter = BidiFormatter.getInstance();
+ mNameTextView.setText(bidiFormatter.unicodeWrap(
+ data.getDisplayName(), TextDirectionHeuristicsCompat.LTR));
+ mContactIconView.setImageResourceUri(data.getAvatarUri(), data.getContactId(),
+ data.getLookupKey(), data.getNormalizedDestination());
+ mNameTextView.setText(data.getDisplayName());
+ }
+}
diff --git a/src/com/android/messaging/ui/BlockedParticipantsActivity.java b/src/com/android/messaging/ui/BlockedParticipantsActivity.java
new file mode 100644
index 0000000..b740264
--- /dev/null
+++ b/src/com/android/messaging/ui/BlockedParticipantsActivity.java
@@ -0,0 +1,57 @@
+/*
+ * 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.messaging.ui;
+
+import android.app.Fragment;
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import com.android.messaging.R;
+import com.android.messaging.util.Assert;
+
+/**
+ * Show a list of currently blocked participants.
+ */
+public class BlockedParticipantsActivity extends BugleActionBarActivity {
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.blocked_participants_activity);
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ @Override
+ public void onAttachFragment(final Fragment fragment) {
+ Assert.isTrue(fragment instanceof BlockedParticipantsFragment);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ // Treat the home press as back press so that when we go back to
+ // ConversationActivity, it doesn't lose its original intent (conversation id etc.)
+ onBackPressed();
+ return true;
+
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/BlockedParticipantsFragment.java b/src/com/android/messaging/ui/BlockedParticipantsFragment.java
new file mode 100644
index 0000000..3ab54ab
--- /dev/null
+++ b/src/com/android/messaging/ui/BlockedParticipantsFragment.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.messaging.ui;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.v4.widget.CursorAdapter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ListView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.BlockedParticipantsData;
+import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.util.Assert;
+
+/**
+ * Show a list of currently blocked participants.
+ */
+public class BlockedParticipantsFragment extends Fragment
+ implements BlockedParticipantsDataListener {
+ private ListView mListView;
+ private BlockedParticipantListAdapter mAdapter;
+ private final Binding<BlockedParticipantsData> mBinding =
+ BindingBase.createBinding(this);
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View view =
+ inflater.inflate(R.layout.blocked_participants_fragment, container, false);
+ mListView = (ListView) view.findViewById(android.R.id.list);
+ mAdapter = new BlockedParticipantListAdapter(getActivity(), null);
+ mListView.setAdapter(mAdapter);
+ mBinding.bind(DataModel.get().createBlockedParticipantsData(getActivity(), this));
+ mBinding.getData().init(getLoaderManager(), mBinding);
+ return view;
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mBinding.unbind();
+ }
+
+ /**
+ * An adapter to display ParticipantListItemView based on ParticipantData.
+ */
+ private class BlockedParticipantListAdapter extends CursorAdapter {
+ public BlockedParticipantListAdapter(final Context context, final Cursor cursor) {
+ super(context, cursor, 0);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return LayoutInflater.from(context)
+ .inflate(R.layout.blocked_participant_list_item_view, parent, false);
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ Assert.isTrue(view instanceof BlockedParticipantListItemView);
+ ((BlockedParticipantListItemView) view).bind(
+ mBinding.getData().createParticipantListItemData(cursor));
+ }
+ }
+
+ @Override
+ public void onBlockedParticipantsCursorUpdated(Cursor cursor) {
+ mAdapter.swapCursor(cursor);
+ }
+}
diff --git a/src/com/android/messaging/ui/BugleActionBarActivity.java b/src/com/android/messaging/ui/BugleActionBarActivity.java
new file mode 100644
index 0000000..80dabb8
--- /dev/null
+++ b/src/com/android/messaging/ui/BugleActionBarActivity.java
@@ -0,0 +1,356 @@
+/*
+ * 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.messaging.ui;
+
+import android.graphics.drawable.ColorDrawable;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.support.v7.app.ActionBarActivity;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+
+import com.android.messaging.R;
+import com.android.messaging.util.BugleActivityUtil;
+import com.android.messaging.util.ImeUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.UiUtils;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Base class for app activities that use an action bar. Responsible for logging/telemetry/other
+ * needs that will be common for all activities. We can break out the common code if/when we need
+ * a version that doesn't use an actionbar.
+ */
+public class BugleActionBarActivity extends ActionBarActivity implements ImeUtil.ImeStateHost {
+ // Tracks the list of observers opting in for IME state change.
+ private final Set<ImeUtil.ImeStateObserver> mImeStateObservers = new HashSet<>();
+
+ // Tracks the soft keyboard display state
+ private boolean mImeOpen;
+
+ // The ActionMode that represents the modal contextual action bar, using our own implementation
+ // rather than the built in contextual action bar to reduce jank
+ private CustomActionMode mActionMode;
+
+ // The menu for the action bar
+ private Menu mActionBarMenu;
+
+ // Used to determine if a onDisplayHeightChanged was due to the IME opening or rotation of the
+ // device
+ private int mLastScreenHeight;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (UiUtils.redirectToPermissionCheckIfNeeded(this)) {
+ return;
+ }
+
+ mLastScreenHeight = getResources().getDisplayMetrics().heightPixels;
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onCreate");
+ }
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onStart");
+ }
+ }
+
+ @Override
+ protected void onRestart() {
+ super.onStop();
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onRestart");
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onResume");
+ }
+ BugleActivityUtil.onActivityResume(this, BugleActionBarActivity.this);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onPause");
+ }
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onStop");
+ }
+ }
+
+ private boolean mDestroyed;
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ mDestroyed = true;
+ }
+
+ public boolean getIsDestroyed() {
+ return mDestroyed;
+ }
+
+ @Override
+ public void onDisplayHeightChanged(final int heightSpecification) {
+ int screenHeight = getResources().getDisplayMetrics().heightPixels;
+
+ if (screenHeight != mLastScreenHeight) {
+ // Appears to be an orientation change, don't fire ime updates
+ mLastScreenHeight = screenHeight;
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onDisplayHeightChanged " +
+ " screenHeight: " + screenHeight + " lastScreenHeight: " + mLastScreenHeight +
+ " Skipped, appears to be orientation change.");
+ return;
+ }
+ final ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null && actionBar.isShowing()) {
+ screenHeight -= actionBar.getHeight();
+ }
+ final int height = View.MeasureSpec.getSize(heightSpecification);
+
+ final boolean imeWasOpen = mImeOpen;
+ mImeOpen = screenHeight - height > 100;
+
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onDisplayHeightChanged " +
+ "imeWasOpen: " + imeWasOpen + " mImeOpen: " + mImeOpen + " screenHeight: " +
+ screenHeight + " height: " + height);
+ }
+
+ if (imeWasOpen != mImeOpen) {
+ for (final ImeUtil.ImeStateObserver observer : mImeStateObservers) {
+ observer.onImeStateChanged(mImeOpen);
+ }
+ }
+ }
+
+ @Override
+ public void registerImeStateObserver(final ImeUtil.ImeStateObserver observer) {
+ mImeStateObservers.add(observer);
+ }
+
+ @Override
+ public void unregisterImeStateObserver(final ImeUtil.ImeStateObserver observer) {
+ mImeStateObservers.remove(observer);
+ }
+
+ @Override
+ public boolean isImeOpen() {
+ return mImeOpen;
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ mActionBarMenu = menu;
+ if (mActionMode != null &&
+ mActionMode.getCallback().onCreateActionMode(mActionMode, menu)) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(final Menu menu) {
+ mActionBarMenu = menu;
+ if (mActionMode != null &&
+ mActionMode.getCallback().onPrepareActionMode(mActionMode, menu)) {
+ return true;
+ }
+ return super.onPrepareOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem menuItem) {
+ if (mActionMode != null &&
+ mActionMode.getCallback().onActionItemClicked(mActionMode, menuItem)) {
+ return true;
+ }
+
+ switch (menuItem.getItemId()) {
+ case android.R.id.home:
+ if (mActionMode != null) {
+ dismissActionMode();
+ return true;
+ }
+ }
+ return super.onOptionsItemSelected(menuItem);
+ }
+
+ @Override
+ public ActionMode startActionMode(final ActionMode.Callback callback) {
+ mActionMode = new CustomActionMode(callback);
+ supportInvalidateOptionsMenu();
+ invalidateActionBar();
+ return mActionMode;
+ }
+
+ public void dismissActionMode() {
+ if (mActionMode != null) {
+ mActionMode.finish();
+ mActionMode = null;
+ invalidateActionBar();
+ }
+ }
+
+ public ActionMode getActionMode() {
+ return mActionMode;
+ }
+
+ protected ActionMode.Callback getActionModeCallback() {
+ if (mActionMode == null) {
+ return null;
+ }
+
+ return mActionMode.getCallback();
+ }
+
+ /**
+ * Receives and handles action bar invalidation request from sub-components of this activity.
+ *
+ * <p>Normally actions have sole control over the action bar, but in order to support seamless
+ * transitions for components such as the full screen media picker, we have to let it take over
+ * the action bar and then restore its state afterwards</p>
+ *
+ * <p>If a fragment does anything that may change the action bar, it should call this method
+ * and then it is this method's responsibility to figure out which component "controls" the
+ * action bar and delegate the updating of the action bar to that component</p>
+ */
+ public final void invalidateActionBar() {
+ if (mActionMode != null) {
+ mActionMode.updateActionBar(getSupportActionBar());
+ } else {
+ updateActionBar(getSupportActionBar());
+ }
+ }
+
+ protected void updateActionBar(final ActionBar actionBar) {
+ actionBar.setHomeAsUpIndicator(null);
+ }
+
+ /**
+ * Custom ActionMode implementation which allows us to just replace the contents of the main
+ * action bar rather than overlay over it
+ */
+ private class CustomActionMode extends ActionMode {
+ private CharSequence mTitle;
+ private CharSequence mSubtitle;
+ private View mCustomView;
+ private final Callback mCallback;
+
+ public CustomActionMode(final Callback callback) {
+ mCallback = callback;
+ }
+
+ @Override
+ public void setTitle(final CharSequence title) {
+ mTitle = title;
+ }
+
+ @Override
+ public void setTitle(final int resId) {
+ mTitle = getResources().getString(resId);
+ }
+
+ @Override
+ public void setSubtitle(final CharSequence subtitle) {
+ mSubtitle = subtitle;
+ }
+
+ @Override
+ public void setSubtitle(final int resId) {
+ mSubtitle = getResources().getString(resId);
+ }
+
+ @Override
+ public void setCustomView(final View view) {
+ mCustomView = view;
+ }
+
+ @Override
+ public void invalidate() {
+ invalidateActionBar();
+ }
+
+ @Override
+ public void finish() {
+ mActionMode = null;
+ mCallback.onDestroyActionMode(this);
+ supportInvalidateOptionsMenu();
+ invalidateActionBar();
+ }
+
+ @Override
+ public Menu getMenu() {
+ return mActionBarMenu;
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return mTitle;
+ }
+
+ @Override
+ public CharSequence getSubtitle() {
+ return mSubtitle;
+ }
+
+ @Override
+ public View getCustomView() {
+ return mCustomView;
+ }
+
+ @Override
+ public MenuInflater getMenuInflater() {
+ return BugleActionBarActivity.this.getMenuInflater();
+ }
+
+ public Callback getCallback() {
+ return mCallback;
+ }
+
+ public void updateActionBar(final ActionBar actionBar) {
+ actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP);
+ actionBar.setDisplayShowTitleEnabled(false);
+ actionBar.setDisplayShowCustomEnabled(false);
+ mActionMode.getCallback().onPrepareActionMode(mActionMode, mActionBarMenu);
+ actionBar.setBackgroundDrawable(new ColorDrawable(
+ getResources().getColor(R.color.contextual_action_bar_background_color)));
+ actionBar.setHomeAsUpIndicator(R.drawable.ic_cancel_small_dark);
+ actionBar.show();
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/BugleAnimationTags.java b/src/com/android/messaging/ui/BugleAnimationTags.java
new file mode 100644
index 0000000..b141f5b
--- /dev/null
+++ b/src/com/android/messaging/ui/BugleAnimationTags.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.ui;
+
+
+/**
+ * Defines a list of animation tags used as android:transitionName properties used for L's
+ * hero transitions.
+ */
+public class BugleAnimationTags {
+ /**
+ * The tag for the FAB in the conversation list activity.
+ */
+ public static final String TAG_FABICON = "bugle:fabicon";
+
+ /**
+ * The tag for the content view of a conversation list item view.
+ */
+ public static final String TAG_CLIVCONTENT = "bugle:clivcontent";
+
+ /**
+ * The tag for the action bar.
+ */
+ public static final String TAG_ACTIONBAR = "bugle:actionbar";
+}
diff --git a/src/com/android/messaging/ui/ClassZeroActivity.java b/src/com/android/messaging/ui/ClassZeroActivity.java
new file mode 100644
index 0000000..129ec19
--- /dev/null
+++ b/src/com/android/messaging/ui/ClassZeroActivity.java
@@ -0,0 +1,205 @@
+/*
+ * 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.messaging.ui;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.ContentValues;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.os.SystemClock;
+import android.provider.Telephony.Sms;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.Window;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.action.ReceiveSmsMessageAction;
+import com.android.messaging.util.Assert;
+
+import java.util.ArrayList;
+
+/**
+ * Display a class-zero SMS message to the user. Wait for the user to dismiss
+ * it.
+ */
+public class ClassZeroActivity extends Activity {
+ private static final boolean VERBOSE = false;
+ private static final String TAG = "display_00";
+ private static final int ON_AUTO_SAVE = 1;
+
+ /** Default timer to dismiss the dialog. */
+ private static final long DEFAULT_TIMER = 5 * 60 * 1000;
+
+ /** To remember the exact time when the timer should fire. */
+ private static final String TIMER_FIRE = "timer_fire";
+
+ private ContentValues mMessageValues = null;
+
+ /** Is the message read. */
+ private boolean mRead = false;
+
+ /** The timer to dismiss the dialog automatically. */
+ private long mTimerSet = 0;
+ private AlertDialog mDialog = null;
+
+ private ArrayList<ContentValues> mMessageQueue = null;
+
+ private final Handler mHandler = new Handler() {
+ @Override
+ public void handleMessage(final Message msg) {
+ // Do not handle an invalid message.
+ if (msg.what == ON_AUTO_SAVE) {
+ mRead = false;
+ mDialog.dismiss();
+ saveMessage();
+ processNextMessage();
+ }
+ }
+ };
+
+ private boolean queueMsgFromIntent(final Intent msgIntent) {
+ final ContentValues messageValues =
+ msgIntent.getParcelableExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_VALUES);
+ // that takes the format argument is a hidden API right now.
+ final String message = messageValues.getAsString(Sms.BODY);
+ if (TextUtils.isEmpty(message)) {
+ if (mMessageQueue.size() == 0) {
+ finish();
+ }
+ return false;
+ }
+ mMessageQueue.add(messageValues);
+ return true;
+ }
+
+ private void processNextMessage() {
+ if (mMessageQueue.size() > 0) {
+ mMessageQueue.remove(0);
+ }
+ if (mMessageQueue.size() == 0) {
+ finish();
+ } else {
+ displayZeroMessage(mMessageQueue.get(0));
+ }
+ }
+
+ private void saveMessage() {
+ mMessageValues.put(Sms.Inbox.READ, mRead ? Integer.valueOf(1) : Integer.valueOf(0));
+ final ReceiveSmsMessageAction action = new ReceiveSmsMessageAction(mMessageValues);
+ action.start();
+ }
+
+ @Override
+ protected void onCreate(final Bundle icicle) {
+ super.onCreate(icicle);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+
+ if (mMessageQueue == null) {
+ mMessageQueue = new ArrayList<ContentValues>();
+ }
+ if (!queueMsgFromIntent(getIntent())) {
+ return;
+ }
+ Assert.isTrue(mMessageQueue.size() == 1);
+ if (mMessageQueue.size() == 1) {
+ displayZeroMessage(mMessageQueue.get(0));
+ }
+
+ if (icicle != null) {
+ mTimerSet = icicle.getLong(TIMER_FIRE, mTimerSet);
+ }
+ }
+
+ private void displayZeroMessage(final ContentValues messageValues) {
+ /* This'll be used by the save action */
+ mMessageValues = messageValues;
+ final String message = messageValues.getAsString(Sms.BODY);;
+
+ mDialog = new AlertDialog.Builder(this).setMessage(message)
+ .setPositiveButton(R.string.save, mSaveListener)
+ .setNegativeButton(android.R.string.cancel, mCancelListener)
+ .setTitle(R.string.class_0_message_activity)
+ .setCancelable(false).show();
+ final long now = SystemClock.uptimeMillis();
+ mTimerSet = now + DEFAULT_TIMER;
+ }
+
+ @Override
+ protected void onNewIntent(final Intent msgIntent) {
+ // Running with another visible message, queue this one
+ queueMsgFromIntent(msgIntent);
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ final long now = SystemClock.uptimeMillis();
+ if (mTimerSet <= now) {
+ // Save the message if the timer already expired.
+ mHandler.sendEmptyMessage(ON_AUTO_SAVE);
+ } else {
+ mHandler.sendEmptyMessageAtTime(ON_AUTO_SAVE, mTimerSet);
+ if (VERBOSE) {
+ Log.d(TAG, "onRestart time = " + Long.toString(mTimerSet) + " "
+ + this.toString());
+ }
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(final Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putLong(TIMER_FIRE, mTimerSet);
+ if (VERBOSE) {
+ Log.d(TAG, "onSaveInstanceState time = " + Long.toString(mTimerSet)
+ + " " + this.toString());
+ }
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ mHandler.removeMessages(ON_AUTO_SAVE);
+ if (VERBOSE) {
+ Log.d(TAG, "onStop time = " + Long.toString(mTimerSet)
+ + " " + this.toString());
+ }
+ }
+
+ private final OnClickListener mCancelListener = new OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int whichButton) {
+ dialog.dismiss();
+ processNextMessage();
+ }
+ };
+
+ private final OnClickListener mSaveListener = new OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int whichButton) {
+ mRead = true;
+ saveMessage();
+ dialog.dismiss();
+ processNextMessage();
+ }
+ };
+}
diff --git a/src/com/android/messaging/ui/CompositeAdapter.java b/src/com/android/messaging/ui/CompositeAdapter.java
new file mode 100644
index 0000000..620e511
--- /dev/null
+++ b/src/com/android/messaging/ui/CompositeAdapter.java
@@ -0,0 +1,288 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.database.DataSetObserver;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+
+/**
+ * A general purpose adapter that composes one or more other adapters. It
+ * appends them in the order they are added.
+ */
+public class CompositeAdapter extends BaseAdapter {
+
+ private static final int INITIAL_CAPACITY = 2;
+
+ public static class Partition {
+ boolean mShowIfEmpty;
+ boolean mHasHeader;
+ BaseAdapter mAdapter;
+
+ public Partition(final boolean showIfEmpty, final boolean hasHeader,
+ final BaseAdapter adapter) {
+ this.mShowIfEmpty = showIfEmpty;
+ this.mHasHeader = hasHeader;
+ this.mAdapter = adapter;
+ }
+
+ /**
+ * True if the directory should be shown even if no contacts are found.
+ */
+ public boolean showIfEmpty() {
+ return mShowIfEmpty;
+ }
+
+ public boolean hasHeader() {
+ return mHasHeader;
+ }
+
+ public int getCount() {
+ int count = mAdapter.getCount();
+ if (mHasHeader && (count != 0 || mShowIfEmpty)) {
+ count++;
+ }
+ return count;
+ }
+
+ public BaseAdapter getAdapter() {
+ return mAdapter;
+ }
+
+ public View getHeaderView(final View convertView, final ViewGroup parentView) {
+ return null;
+ }
+
+ public void close() {
+ // do nothing in base class.
+ }
+ }
+
+ private class Observer extends DataSetObserver {
+ @Override
+ public void onChanged() {
+ CompositeAdapter.this.notifyDataSetChanged();
+ }
+
+ @Override
+ public void onInvalidated() {
+ CompositeAdapter.this.notifyDataSetInvalidated();
+ }
+ };
+
+ protected final Context mContext;
+ private Partition[] mPartitions;
+ private int mSize = 0;
+ private int mCount = 0;
+ private boolean mCacheValid = true;
+ private final Observer mObserver;
+
+ public CompositeAdapter(final Context context) {
+ mContext = context;
+ mObserver = new Observer();
+ mPartitions = new Partition[INITIAL_CAPACITY];
+ }
+
+ public Context getContext() {
+ return mContext;
+ }
+
+ public void addPartition(final Partition partition) {
+ if (mSize >= mPartitions.length) {
+ final int newCapacity = mSize + 2;
+ final Partition[] newAdapters = new Partition[newCapacity];
+ System.arraycopy(mPartitions, 0, newAdapters, 0, mSize);
+ mPartitions = newAdapters;
+ }
+ mPartitions[mSize++] = partition;
+ partition.getAdapter().registerDataSetObserver(mObserver);
+ invalidate();
+ notifyDataSetChanged();
+ }
+
+ public void removePartition(final int index) {
+ final Partition partition = mPartitions[index];
+ partition.close();
+ System.arraycopy(mPartitions, index + 1, mPartitions, index,
+ mSize - index - 1);
+ mSize--;
+ partition.getAdapter().unregisterDataSetObserver(mObserver);
+ invalidate();
+ notifyDataSetChanged();
+ }
+
+ public void clearPartitions() {
+ for (int i = 0; i < mSize; i++) {
+ final Partition partition = mPartitions[i];
+ partition.close();
+ partition.getAdapter().unregisterDataSetObserver(mObserver);
+ }
+ invalidate();
+ notifyDataSetChanged();
+ }
+
+ public Partition getPartition(final int index) {
+ return mPartitions[index];
+ }
+
+ public int getPartitionAtPosition(final int position) {
+ ensureCacheValid();
+ int start = 0;
+ for (int i = 0; i < mSize; i++) {
+ final int end = start + mPartitions[i].getCount();
+ if (position >= start && position < end) {
+ int offset = position - start;
+ if (mPartitions[i].hasHeader() &&
+ (mPartitions[i].getCount() > 0 || mPartitions[i].showIfEmpty())) {
+ offset--;
+ }
+ if (offset == -1) {
+ return -1;
+ }
+ return i;
+ }
+ start = end;
+ }
+ return mSize - 1;
+ }
+
+ public int getPartitionCount() {
+ return mSize;
+ }
+
+ public void invalidate() {
+ mCacheValid = false;
+ }
+
+ private void ensureCacheValid() {
+ if (mCacheValid) {
+ return;
+ }
+ mCount = 0;
+ for (int i = 0; i < mSize; i++) {
+ mCount += mPartitions[i].getCount();
+ }
+ }
+
+ @Override
+ public int getCount() {
+ ensureCacheValid();
+ return mCount;
+ }
+
+ public int getCount(final int index) {
+ ensureCacheValid();
+ return mPartitions[index].getCount();
+ }
+
+ @Override
+ public Object getItem(final int position) {
+ ensureCacheValid();
+ int start = 0;
+ for (int i = 0; i < mSize; i++) {
+ final int end = start + mPartitions[i].getCount();
+ if (position >= start && position < end) {
+ final int offset = position - start;
+ final Partition partition = mPartitions[i];
+ if (partition.hasHeader() && offset == 0 &&
+ (partition.getCount() > 0 || partition.showIfEmpty())) {
+ // This is the header
+ return null;
+ }
+ return mPartitions[i].getAdapter().getItem(offset);
+ }
+ start = end;
+ }
+
+ return null;
+ }
+
+ @Override
+ public long getItemId(final int position) {
+ ensureCacheValid();
+ int start = 0;
+ for (int i = 0; i < mSize; i++) {
+ final int end = start + mPartitions[i].getCount();
+ if (position >= start && position < end) {
+ final int offset = position - start;
+ final Partition partition = mPartitions[i];
+ if (partition.hasHeader() && offset == 0 &&
+ (partition.getCount() > 0 || partition.showIfEmpty())) {
+ // Header
+ return 0;
+ }
+ return mPartitions[i].getAdapter().getItemId(offset);
+ }
+ start = end;
+ }
+
+ return 0;
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ ensureCacheValid();
+ int start = 0;
+ for (int i = 0; i < mSize; i++) {
+ final int end = start + mPartitions[i].getCount();
+ if (position >= start && position < end) {
+ final int offset = position - start;
+ final Partition partition = mPartitions[i];
+ if (partition.hasHeader() && offset == 0 &&
+ (partition.getCount() > 0 || partition.showIfEmpty())) {
+ // This is the header
+ return false;
+ }
+ return true;
+ }
+ start = end;
+ }
+ return true;
+ }
+
+ @Override
+ public View getView(final int position, final View convertView, final ViewGroup parentView) {
+ ensureCacheValid();
+ int start = 0;
+ for (int i = 0; i < mSize; i++) {
+ final Partition partition = mPartitions[i];
+ final int end = start + partition.getCount();
+ if (position >= start && position < end) {
+ int offset = position - start;
+ View view;
+ if (partition.hasHeader() &&
+ (partition.getCount() > 0 || partition.showIfEmpty())) {
+ offset = offset - 1;
+ }
+ if (offset == -1) {
+ view = partition.getHeaderView(convertView, parentView);
+ } else {
+ view = partition.getAdapter().getView(offset, convertView, parentView);
+ }
+ if (view == null) {
+ throw new NullPointerException("View should not be null, partition: " + i
+ + " position: " + offset);
+ }
+ return view;
+ }
+ start = end;
+ }
+
+ throw new ArrayIndexOutOfBoundsException(position);
+ }
+}
diff --git a/src/com/android/messaging/ui/ContactIconView.java b/src/com/android/messaging/ui/ContactIconView.java
new file mode 100644
index 0000000..44983ab
--- /dev/null
+++ b/src/com/android/messaging/ui/ContactIconView.java
@@ -0,0 +1,152 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.media.AvatarGroupRequestDescriptor;
+import com.android.messaging.datamodel.media.AvatarRequestDescriptor;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.ContactUtil;
+
+/**
+ * A view used to render contact icons. This class derives from AsyncImageView, so it loads contact
+ * icons from MediaResourceManager, and it handles more rendering logic than an AsyncImageView
+ * (draws a circular bitmap).
+ */
+public class ContactIconView extends AsyncImageView {
+ private static final int NORMAL_ICON_SIZE_ID = 0;
+ private static final int LARGE_ICON_SIZE_ID = 1;
+ private static final int SMALL_ICON_SIZE_ID = 2;
+
+ protected final int mIconSize;
+ private final int mColorPressedId;
+
+ private long mContactId;
+ private String mContactLookupKey;
+ private String mNormalizedDestination;
+ private Uri mAvatarUri;
+ private boolean mDisableClickHandler;
+
+ public ContactIconView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ final Resources resources = context.getResources();
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ContactIconView);
+
+ final int iconSizeId = a.getInt(R.styleable.ContactIconView_iconSize, 0);
+ switch (iconSizeId) {
+ case NORMAL_ICON_SIZE_ID:
+ mIconSize = (int) resources.getDimension(
+ R.dimen.contact_icon_view_normal_size);
+ break;
+ case LARGE_ICON_SIZE_ID:
+ mIconSize = (int) resources.getDimension(
+ R.dimen.contact_icon_view_large_size);
+ break;
+ case SMALL_ICON_SIZE_ID:
+ mIconSize = (int) resources.getDimension(
+ R.dimen.contact_icon_view_small_size);
+ break;
+ default:
+ // For the compiler, something has to be set even with the assert.
+ mIconSize = 0;
+ Assert.fail("Unsupported ContactIconView icon size attribute");
+ }
+ mColorPressedId = resources.getColor(R.color.contact_avatar_pressed_color);
+
+ setImage(null);
+ a.recycle();
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ setColorFilter(mColorPressedId);
+ } else {
+ clearColorFilter();
+ }
+ return super.onTouchEvent(event);
+ }
+
+ /**
+ * Method which allows the automatic hookup of a click handler when the Uri is changed
+ */
+ public void setImageClickHandlerDisabled(final boolean isHandlerDisabled) {
+ mDisableClickHandler = isHandlerDisabled;
+ setOnClickListener(null);
+ setClickable(false);
+ }
+
+ /**
+ * A convenience method that sets the URI of the contact icon by creating a new image request.
+ */
+ public void setImageResourceUri(final Uri uri) {
+ setImageResourceUri(uri, 0, null, null);
+ }
+
+ public void setImageResourceUri(final Uri uri, final long contactId,
+ final String contactLookupKey, final String normalizedDestination) {
+ if (uri == null) {
+ setImageResourceId(null);
+ } else {
+ final String avatarType = AvatarUriUtil.getAvatarType(uri);
+ if (AvatarUriUtil.TYPE_GROUP_URI.equals(avatarType)) {
+ setImageResourceId(new AvatarGroupRequestDescriptor(uri, mIconSize, mIconSize));
+ } else {
+ setImageResourceId(new AvatarRequestDescriptor(uri, mIconSize, mIconSize));
+ }
+ }
+
+ mContactId = contactId;
+ mContactLookupKey = contactLookupKey;
+ mNormalizedDestination = normalizedDestination;
+ mAvatarUri = uri;
+
+ maybeInitializeOnClickListener();
+ }
+
+ protected void maybeInitializeOnClickListener() {
+ if ((mContactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED
+ && !TextUtils.isEmpty(mContactLookupKey)) ||
+ !TextUtils.isEmpty(mNormalizedDestination)) {
+ if (!mDisableClickHandler) {
+ setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ ContactUtil.showOrAddContact(view, mContactId, mContactLookupKey,
+ mAvatarUri, mNormalizedDestination);
+ }
+ });
+ }
+ } else {
+ // This should happen when the phone number is not in the user's contacts or it is a
+ // group conversation, group conversations don't have contact phone numbers. If this
+ // is the case then absorb the click to prevent propagation.
+ setOnClickListener(null);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/ConversationDrawables.java b/src/com/android/messaging/ui/ConversationDrawables.java
new file mode 100644
index 0000000..cf858e2
--- /dev/null
+++ b/src/com/android/messaging/ui/ConversationDrawables.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.messaging.ui;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.util.ImageUtils;
+
+/**
+ * A singleton cache that holds tinted drawable resources for displaying messages, such as
+ * message bubbles, audio attachments etc.
+ */
+public class ConversationDrawables {
+ private static ConversationDrawables sInstance;
+
+ // Cache the color filtered bubble drawables so that we don't need to create a
+ // new one for each ConversationMessageView.
+ private Drawable mIncomingBubbleDrawable;
+ private Drawable mOutgoingBubbleDrawable;
+ private Drawable mIncomingErrorBubbleDrawable;
+ private Drawable mIncomingBubbleNoArrowDrawable;
+ private Drawable mOutgoingBubbleNoArrowDrawable;
+ private Drawable mAudioPlayButtonDrawable;
+ private Drawable mAudioPauseButtonDrawable;
+ private Drawable mIncomingAudioProgressBackgroundDrawable;
+ private Drawable mOutgoingAudioProgressBackgroundDrawable;
+ private Drawable mAudioProgressForegroundDrawable;
+ private Drawable mFastScrollThumbDrawable;
+ private Drawable mFastScrollThumbPressedDrawable;
+ private Drawable mFastScrollPreviewDrawableLeft;
+ private Drawable mFastScrollPreviewDrawableRight;
+ private final Context mContext;
+ private int mOutgoingBubbleColor;
+ private int mIncomingErrorBubbleColor;
+ private int mIncomingAudioButtonColor;
+ private int mSelectedBubbleColor;
+ private int mThemeColor;
+
+ public static ConversationDrawables get() {
+ if (sInstance == null) {
+ sInstance = new ConversationDrawables(Factory.get().getApplicationContext());
+ }
+ return sInstance;
+ }
+
+ private ConversationDrawables(final Context context) {
+ mContext = context;
+ // Pre-create all the drawables.
+ updateDrawables();
+ }
+
+ public int getConversationThemeColor() {
+ return mThemeColor;
+ }
+
+ public void updateDrawables() {
+ final Resources resources = mContext.getResources();
+
+ mIncomingBubbleDrawable = resources.getDrawable(R.drawable.msg_bubble_incoming);
+ mIncomingBubbleNoArrowDrawable =
+ resources.getDrawable(R.drawable.message_bubble_incoming_no_arrow);
+ mIncomingErrorBubbleDrawable = resources.getDrawable(R.drawable.msg_bubble_error);
+ mOutgoingBubbleDrawable = resources.getDrawable(R.drawable.msg_bubble_outgoing);
+ mOutgoingBubbleNoArrowDrawable =
+ resources.getDrawable(R.drawable.message_bubble_outgoing_no_arrow);
+ mAudioPlayButtonDrawable = resources.getDrawable(R.drawable.ic_audio_play);
+ mAudioPauseButtonDrawable = resources.getDrawable(R.drawable.ic_audio_pause);
+ mIncomingAudioProgressBackgroundDrawable =
+ resources.getDrawable(R.drawable.audio_progress_bar_background_incoming);
+ mOutgoingAudioProgressBackgroundDrawable =
+ resources.getDrawable(R.drawable.audio_progress_bar_background_outgoing);
+ mAudioProgressForegroundDrawable =
+ resources.getDrawable(R.drawable.audio_progress_bar_progress);
+ mFastScrollThumbDrawable = resources.getDrawable(R.drawable.fastscroll_thumb);
+ mFastScrollThumbPressedDrawable =
+ resources.getDrawable(R.drawable.fastscroll_thumb_pressed);
+ mFastScrollPreviewDrawableLeft =
+ resources.getDrawable(R.drawable.fastscroll_preview_left);
+ mFastScrollPreviewDrawableRight =
+ resources.getDrawable(R.drawable.fastscroll_preview_right);
+ mOutgoingBubbleColor = resources.getColor(R.color.message_bubble_color_outgoing);
+ mIncomingErrorBubbleColor =
+ resources.getColor(R.color.message_error_bubble_color_incoming);
+ mIncomingAudioButtonColor =
+ resources.getColor(R.color.message_audio_button_color_incoming);
+ mSelectedBubbleColor = resources.getColor(R.color.message_bubble_color_selected);
+ mThemeColor = resources.getColor(R.color.primary_color);
+ }
+
+ public Drawable getBubbleDrawable(final boolean selected, final boolean incoming,
+ final boolean needArrow, final boolean isError) {
+ final Drawable protoDrawable;
+ if (needArrow) {
+ if (incoming) {
+ protoDrawable = isError && !selected ?
+ mIncomingErrorBubbleDrawable : mIncomingBubbleDrawable;
+ } else {
+ protoDrawable = mOutgoingBubbleDrawable;
+ }
+ } else if (incoming) {
+ protoDrawable = mIncomingBubbleNoArrowDrawable;
+ } else {
+ protoDrawable = mOutgoingBubbleNoArrowDrawable;
+ }
+
+ int color;
+ if (selected) {
+ color = mSelectedBubbleColor;
+ } else if (incoming) {
+ if (isError) {
+ color = mIncomingErrorBubbleColor;
+ } else {
+ color = mThemeColor;
+ }
+ } else {
+ color = mOutgoingBubbleColor;
+ }
+
+ return ImageUtils.getTintedDrawable(mContext, protoDrawable, color);
+ }
+
+ private int getAudioButtonColor(final boolean incoming) {
+ return incoming ? mIncomingAudioButtonColor : mThemeColor;
+ }
+
+ public Drawable getPlayButtonDrawable(final boolean incoming) {
+ return ImageUtils.getTintedDrawable(
+ mContext, mAudioPlayButtonDrawable, getAudioButtonColor(incoming));
+ }
+
+ public Drawable getPauseButtonDrawable(final boolean incoming) {
+ return ImageUtils.getTintedDrawable(
+ mContext, mAudioPauseButtonDrawable, getAudioButtonColor(incoming));
+ }
+
+ public Drawable getAudioProgressDrawable(final boolean incoming) {
+ return ImageUtils.getTintedDrawable(
+ mContext, mAudioProgressForegroundDrawable, getAudioButtonColor(incoming));
+ }
+
+ public Drawable getAudioProgressBackgroundDrawable(final boolean incoming) {
+ return incoming ? mIncomingAudioProgressBackgroundDrawable :
+ mOutgoingAudioProgressBackgroundDrawable;
+ }
+
+ public Drawable getFastScrollThumbDrawable(final boolean pressed) {
+ if (pressed) {
+ return ImageUtils.getTintedDrawable(mContext, mFastScrollThumbPressedDrawable,
+ mThemeColor);
+ } else {
+ return mFastScrollThumbDrawable;
+ }
+ }
+
+ public Drawable getFastScrollPreviewDrawable(boolean positionRight) {
+ Drawable protoDrawable = positionRight ? mFastScrollPreviewDrawableRight :
+ mFastScrollPreviewDrawableLeft;
+ return ImageUtils.getTintedDrawable(mContext, protoDrawable, mThemeColor);
+ }
+}
diff --git a/src/com/android/messaging/ui/CursorRecyclerAdapter.java b/src/com/android/messaging/ui/CursorRecyclerAdapter.java
new file mode 100644
index 0000000..f1a7b7d
--- /dev/null
+++ b/src/com/android/messaging/ui/CursorRecyclerAdapter.java
@@ -0,0 +1,333 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.DataSetObserver;
+import android.os.Handler;
+import android.support.v7.widget.RecyclerView;
+import android.util.Log;
+import android.view.ViewGroup;
+import android.widget.FilterQueryProvider;
+
+/**
+ * Copy of CursorAdapter suited for RecyclerView.
+ *
+ * TODO: BUG 16327984. Replace this with a framework supported CursorAdapter for
+ * RecyclerView when one is available.
+ */
+public abstract class CursorRecyclerAdapter<VH extends RecyclerView.ViewHolder>
+ extends RecyclerView.Adapter<VH> {
+ /**
+ * This field should be made private, so it is hidden from the SDK.
+ * {@hide}
+ */
+ protected boolean mDataValid;
+ /**
+ * This field should be made private, so it is hidden from the SDK.
+ * {@hide}
+ */
+ protected boolean mAutoRequery;
+ /**
+ * This field should be made private, so it is hidden from the SDK.
+ * {@hide}
+ */
+ protected Cursor mCursor;
+ /**
+ * This field should be made private, so it is hidden from the SDK.
+ * {@hide}
+ */
+ protected Context mContext;
+ /**
+ * This field should be made private, so it is hidden from the SDK.
+ * {@hide}
+ */
+ protected int mRowIDColumn;
+ /**
+ * This field should be made private, so it is hidden from the SDK.
+ * {@hide}
+ */
+ protected ChangeObserver mChangeObserver;
+ /**
+ * This field should be made private, so it is hidden from the SDK.
+ * {@hide}
+ */
+ protected DataSetObserver mDataSetObserver;
+ /**
+ * This field should be made private, so it is hidden from the SDK.
+ * {@hide}
+ */
+ protected FilterQueryProvider mFilterQueryProvider;
+
+ /**
+ * If set the adapter will call requery() on the cursor whenever a content change
+ * notification is delivered. Implies {@link #FLAG_REGISTER_CONTENT_OBSERVER}.
+ *
+ * @deprecated This option is discouraged, as it results in Cursor queries
+ * being performed on the application's UI thread and thus can cause poor
+ * responsiveness or even Application Not Responding errors. As an alternative,
+ * use {@link android.app.LoaderManager} with a {@link android.content.CursorLoader}.
+ */
+ @Deprecated
+ public static final int FLAG_AUTO_REQUERY = 0x01;
+
+ /**
+ * If set the adapter will register a content observer on the cursor and will call
+ * {@link #onContentChanged()} when a notification comes in. Be careful when
+ * using this flag: you will need to unset the current Cursor from the adapter
+ * to avoid leaks due to its registered observers. This flag is not needed
+ * when using a CursorAdapter with a
+ * {@link android.content.CursorLoader}.
+ */
+ public static final int FLAG_REGISTER_CONTENT_OBSERVER = 0x02;
+
+ /**
+ * Recommended constructor.
+ *
+ * @param c The cursor from which to get the data.
+ * @param context The context
+ * @param flags Flags used to determine the behavior of the adapter; may
+ * be any combination of {@link #FLAG_AUTO_REQUERY} and
+ * {@link #FLAG_REGISTER_CONTENT_OBSERVER}.
+ */
+ public CursorRecyclerAdapter(final Context context, final Cursor c, final int flags) {
+ init(context, c, flags);
+ }
+
+ void init(final Context context, final Cursor c, int flags) {
+ if ((flags & FLAG_AUTO_REQUERY) == FLAG_AUTO_REQUERY) {
+ flags |= FLAG_REGISTER_CONTENT_OBSERVER;
+ mAutoRequery = true;
+ } else {
+ mAutoRequery = false;
+ }
+ final boolean cursorPresent = c != null;
+ mCursor = c;
+ mDataValid = cursorPresent;
+ mContext = context;
+ mRowIDColumn = cursorPresent ? c.getColumnIndexOrThrow("_id") : -1;
+ if ((flags & FLAG_REGISTER_CONTENT_OBSERVER) == FLAG_REGISTER_CONTENT_OBSERVER) {
+ mChangeObserver = new ChangeObserver();
+ mDataSetObserver = new MyDataSetObserver();
+ } else {
+ mChangeObserver = null;
+ mDataSetObserver = null;
+ }
+
+ if (cursorPresent) {
+ if (mChangeObserver != null) {
+ c.registerContentObserver(mChangeObserver);
+ }
+ if (mDataSetObserver != null) {
+ c.registerDataSetObserver(mDataSetObserver);
+ }
+ }
+ }
+
+ /**
+ * Returns the cursor.
+ * @return the cursor.
+ */
+ public Cursor getCursor() {
+ return mCursor;
+ }
+
+ @Override
+ public int getItemCount() {
+ if (mDataValid && mCursor != null) {
+ return mCursor.getCount();
+ } else {
+ return 0;
+ }
+ }
+
+ /**
+ * @see android.support.v7.widget.RecyclerView.Adapter#getItem(int)
+ */
+ public Object getItem(final int position) {
+ if (mDataValid && mCursor != null) {
+ mCursor.moveToPosition(position);
+ return mCursor;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @see android.support.v7.widget.RecyclerView.Adapter#getItemId(int)
+ */
+ @Override
+ public long getItemId(final int position) {
+ if (mDataValid && mCursor != null) {
+ if (mCursor.moveToPosition(position)) {
+ return mCursor.getLong(mRowIDColumn);
+ } else {
+ return 0;
+ }
+ } else {
+ return 0;
+ }
+ }
+
+ @Override
+ public VH onCreateViewHolder(final ViewGroup parent, final int viewType) {
+ return createViewHolder(mContext, parent, viewType);
+ }
+
+ @Override
+ public void onBindViewHolder(final VH holder, final int position) {
+ if (!mDataValid) {
+ throw new IllegalStateException("this should only be called when the cursor is valid");
+ }
+ if (!mCursor.moveToPosition(position)) {
+ throw new IllegalStateException("couldn't move cursor to position " + position);
+ }
+ bindViewHolder(holder, mContext, mCursor);
+ }
+ /**
+ * Bind an existing view to the data pointed to by cursor
+ * @param view Existing view, returned earlier by newView
+ * @param context Interface to application's global information
+ * @param cursor The cursor from which to get the data. The cursor is already
+ * moved to the correct position.
+ */
+ public abstract void bindViewHolder(VH holder, Context context, Cursor cursor);
+
+ /**
+ * @see android.support.v7.widget.RecyclerView.Adapter#createViewHolder(Context, ViewGroup, int)
+ */
+ public abstract VH createViewHolder(Context context, ViewGroup parent, int viewType);
+
+ /**
+ * Change the underlying cursor to a new cursor. If there is an existing cursor it will be
+ * closed.
+ *
+ * @param cursor The new cursor to be used
+ */
+ public void changeCursor(final Cursor cursor) {
+ final Cursor old = swapCursor(cursor);
+ if (old != null) {
+ old.close();
+ }
+ }
+
+ /**
+ * Swap in a new Cursor, returning the old Cursor. Unlike
+ * {@link #changeCursor(Cursor)}, the returned old Cursor is <em>not</em>
+ * closed.
+ *
+ * @param newCursor The new cursor to be used.
+ * @return Returns the previously set Cursor, or null if there wasa not one.
+ * If the given new Cursor is the same instance is the previously set
+ * Cursor, null is also returned.
+ */
+ public Cursor swapCursor(final Cursor newCursor) {
+ if (newCursor == mCursor) {
+ return null;
+ }
+ final Cursor oldCursor = mCursor;
+ if (oldCursor != null) {
+ if (mChangeObserver != null) {
+ oldCursor.unregisterContentObserver(mChangeObserver);
+ }
+ if (mDataSetObserver != null) {
+ oldCursor.unregisterDataSetObserver(mDataSetObserver);
+ }
+ }
+ mCursor = newCursor;
+ if (newCursor != null) {
+ if (mChangeObserver != null) {
+ newCursor.registerContentObserver(mChangeObserver);
+ }
+ if (mDataSetObserver != null) {
+ newCursor.registerDataSetObserver(mDataSetObserver);
+ }
+ mRowIDColumn = newCursor.getColumnIndexOrThrow("_id");
+ mDataValid = true;
+ // notify the observers about the new cursor
+ notifyDataSetChanged();
+ } else {
+ mRowIDColumn = -1;
+ mDataValid = false;
+ // notify the observers about the lack of a data set
+ notifyDataSetChanged();
+ }
+ return oldCursor;
+ }
+
+ /**
+ * <p>Converts the cursor into a CharSequence. Subclasses should override this
+ * method to convert their results. The default implementation returns an
+ * empty String for null values or the default String representation of
+ * the value.</p>
+ *
+ * @param cursor the cursor to convert to a CharSequence
+ * @return a CharSequence representing the value
+ */
+ public CharSequence convertToString(final Cursor cursor) {
+ return cursor == null ? "" : cursor.toString();
+ }
+
+ /**
+ * Called when the {@link ContentObserver} on the cursor receives a change notification.
+ * The default implementation provides the auto-requery logic, but may be overridden by
+ * sub classes.
+ *
+ * @see ContentObserver#onChange(boolean)
+ */
+ protected void onContentChanged() {
+ if (mAutoRequery && mCursor != null && !mCursor.isClosed()) {
+ if (false) {
+ Log.v("Cursor", "Auto requerying " + mCursor + " due to update");
+ }
+ mDataValid = mCursor.requery();
+ }
+ }
+
+ private class ChangeObserver extends ContentObserver {
+ public ChangeObserver() {
+ super(new Handler());
+ }
+
+ @Override
+ public boolean deliverSelfNotifications() {
+ return true;
+ }
+
+ @Override
+ public void onChange(final boolean selfChange) {
+ onContentChanged();
+ }
+ }
+
+ private class MyDataSetObserver extends DataSetObserver {
+ @Override
+ public void onChanged() {
+ mDataValid = true;
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void onInvalidated() {
+ mDataValid = false;
+ notifyDataSetChanged();
+ }
+ }
+
+}
diff --git a/src/com/android/messaging/ui/CustomHeaderPagerListViewHolder.java b/src/com/android/messaging/ui/CustomHeaderPagerListViewHolder.java
new file mode 100644
index 0000000..1268c53
--- /dev/null
+++ b/src/com/android/messaging/ui/CustomHeaderPagerListViewHolder.java
@@ -0,0 +1,136 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AbsListView;
+import android.widget.AbsListView.OnScrollListener;
+import android.widget.CursorAdapter;
+import android.widget.ListView;
+
+import com.android.messaging.util.ImeUtil;
+
+/**
+ * Produces and holds a list view and its tab header to be displayed in a ViewPager.
+ */
+public abstract class CustomHeaderPagerListViewHolder extends BasePagerViewHolder
+ implements CustomHeaderPagerViewHolder {
+ private final Context mContext;
+ private final CursorAdapter mListAdapter;
+ private boolean mListCursorInitialized;
+ private ListView mListView;
+
+ public CustomHeaderPagerListViewHolder(final Context context,
+ final CursorAdapter adapter) {
+ mContext = context;
+ mListAdapter = adapter;
+ }
+
+ @Override
+ protected View createView(ViewGroup container) {
+ final LayoutInflater inflater = (LayoutInflater)
+ mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ final View view = inflater.inflate(
+ getLayoutResId(),
+ null /* root */,
+ false /* attachToRoot */);
+ final ListView listView = (ListView) view.findViewById(getListViewResId());
+ listView.setAdapter(mListAdapter);
+ listView.setOnScrollListener(new OnScrollListener() {
+ @Override
+ public void onScrollStateChanged(final AbsListView view, final int scrollState) {
+ if (scrollState != SCROLL_STATE_IDLE) {
+ ImeUtil.get().hideImeKeyboard(mContext, view);
+ }
+ }
+
+ @Override
+ public void onScroll(final AbsListView view, final int firstVisibleItem,
+ final int visibleItemCount, final int totalItemCount) {
+ }
+ });
+ mListView = listView;
+ maybeSetEmptyView();
+ return view;
+ }
+
+ public void onContactsCursorUpdated(final Cursor data) {
+ mListAdapter.swapCursor(data);
+ if (!mListCursorInitialized) {
+ // We set the emptyView here instead of in create so that the initial load won't show
+ // the empty UI - the system handles this and doesn't do what we would like.
+ mListCursorInitialized = true;
+ maybeSetEmptyView();
+ }
+ }
+
+ /**
+ * We don't want to show the empty view hint until BOTH conditions are met:
+ * 1. The view has been created.
+ * 2. Cursor data has been loaded once.
+ * Due to timing when data is loaded, the view may not be ready (and vice versa). So we
+ * are calling this method from both onContactsCursorUpdated & createView.
+ */
+ private void maybeSetEmptyView() {
+ if (mView != null && mListCursorInitialized) {
+ final ListEmptyView emptyView = (ListEmptyView) mView.findViewById(getEmptyViewResId());
+ if (emptyView != null) {
+ emptyView.setTextHint(getEmptyViewTitleResId());
+ emptyView.setImageHint(getEmptyViewImageResId());
+ final ListView listView = (ListView) mView.findViewById(getListViewResId());
+ listView.setEmptyView(emptyView);
+ }
+ }
+ }
+
+ public void invalidateList() {
+ mListAdapter.notifyDataSetChanged();
+ }
+
+ /**
+ * In order for scene transition to work, we toggle the visibility for each individual list
+ * view items so that they can be properly tracked by the scene transition manager.
+ * @param show whether the pending transition is to show or hide the list.
+ */
+ public void toggleVisibilityForPendingTransition(final boolean show, final View epicenterView) {
+ if (mListView == null) {
+ return;
+ }
+ final int childCount = mListView.getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View childView = mListView.getChildAt(i);
+ if (childView != epicenterView) {
+ childView.setVisibility(show ? View.VISIBLE : View.INVISIBLE);
+ }
+ }
+ }
+
+ @Override
+ public CharSequence getPageTitle(Context context) {
+ return context.getString(getPageTitleResId());
+ }
+
+ protected abstract int getLayoutResId();
+ protected abstract int getPageTitleResId();
+ protected abstract int getEmptyViewResId();
+ protected abstract int getEmptyViewTitleResId();
+ protected abstract int getEmptyViewImageResId();
+ protected abstract int getListViewResId();
+}
diff --git a/src/com/android/messaging/ui/CustomHeaderPagerViewHolder.java b/src/com/android/messaging/ui/CustomHeaderPagerViewHolder.java
new file mode 100644
index 0000000..43802cd
--- /dev/null
+++ b/src/com/android/messaging/ui/CustomHeaderPagerViewHolder.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.messaging.ui;
+
+import android.content.Context;
+
+/**
+ * An extension on the standard PagerViewHolder to return a custom header view to be used by
+ * CustomHeaderViewPager
+ */
+public interface CustomHeaderPagerViewHolder extends PagerViewHolder {
+ CharSequence getPageTitle(Context context);
+}
diff --git a/src/com/android/messaging/ui/CustomHeaderViewPager.java b/src/com/android/messaging/ui/CustomHeaderViewPager.java
new file mode 100644
index 0000000..2e8df42
--- /dev/null
+++ b/src/com/android/messaging/ui/CustomHeaderViewPager.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.ui;
+
+import android.content.Context;
+import android.support.v4.view.PagerAdapter;
+import android.support.v4.view.ViewPager;
+import android.support.v4.view.ViewPager.OnPageChangeListener;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.widget.LinearLayout;
+
+import com.android.messaging.R;
+import com.android.messaging.util.Assert;
+
+/**
+ * A view that contains both a view pager and a tab strip wrapped in a linear layout.
+ */
+public class CustomHeaderViewPager extends LinearLayout {
+ public final static int DEFAULT_TAB_STRIP_SIZE = -1;
+ private final int mDefaultTabStripSize;
+ private ViewPager mViewPager;
+ private ViewPagerTabs mTabstrip;
+
+ public CustomHeaderViewPager(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ inflater.inflate(R.layout.custom_header_view_pager, this, true);
+ setOrientation(LinearLayout.VERTICAL);
+
+ mTabstrip = (ViewPagerTabs) findViewById(R.id.tab_strip);
+ mViewPager = (ViewPager) findViewById(R.id.pager);
+
+ TypedValue tv = new TypedValue();
+ context.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true);
+ mDefaultTabStripSize = context.getResources().getDimensionPixelSize(tv.resourceId);
+ }
+
+ public void setCurrentItem(final int position) {
+ mViewPager.setCurrentItem(position);
+ }
+
+ public void setViewPagerTabHeight(final int tabHeight) {
+ mTabstrip.getLayoutParams().height = tabHeight == DEFAULT_TAB_STRIP_SIZE ?
+ mDefaultTabStripSize : tabHeight;
+ }
+
+ public void setViewHolders(final CustomHeaderPagerViewHolder[] viewHolders) {
+ Assert.notNull(mViewPager);
+ final PagerAdapter adapter = new CustomHeaderViewPagerAdapter(viewHolders);
+ mViewPager.setAdapter(adapter);
+ mTabstrip.setViewPager(mViewPager);
+ mViewPager.setOnPageChangeListener(new OnPageChangeListener() {
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ mTabstrip.onPageScrollStateChanged(state);
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset,
+ int positionOffsetPixels) {
+ mTabstrip.onPageScrolled(position, positionOffset, positionOffsetPixels);
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ mTabstrip.onPageSelected(position);
+ }
+ });
+ }
+
+ public int getSelectedItemPosition() {
+ return mTabstrip.getSelectedItemPosition();
+ }
+}
diff --git a/src/com/android/messaging/ui/CustomHeaderViewPagerAdapter.java b/src/com/android/messaging/ui/CustomHeaderViewPagerAdapter.java
new file mode 100644
index 0000000..1a5cf6a
--- /dev/null
+++ b/src/com/android/messaging/ui/CustomHeaderViewPagerAdapter.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.messaging.ui;
+
+import com.android.messaging.Factory;
+
+public class CustomHeaderViewPagerAdapter extends
+ FixedViewPagerAdapter<CustomHeaderPagerViewHolder> {
+
+ public CustomHeaderViewPagerAdapter(final CustomHeaderPagerViewHolder[] viewHolders) {
+ super(viewHolders);
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ // The tab strip will handle RTL internally so we should use raw position.
+ return getViewHolder(position, false /* rtlAware */)
+ .getPageTitle(Factory.get().getApplicationContext());
+ }
+}
diff --git a/src/com/android/messaging/ui/FixedViewPagerAdapter.java b/src/com/android/messaging/ui/FixedViewPagerAdapter.java
new file mode 100644
index 0000000..bd73b71
--- /dev/null
+++ b/src/com/android/messaging/ui/FixedViewPagerAdapter.java
@@ -0,0 +1,132 @@
+/*
+ * 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.messaging.ui;
+
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.v4.view.PagerAdapter;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.messaging.Factory;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.UiUtils;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * A PagerAdapter that provides a fixed number of paged Views provided by a fixed set of
+ * {@link PagerViewHolder}'s. This allows us to put a fixed number of Views, instead of fragments,
+ * into a given ViewPager.
+ */
+public class FixedViewPagerAdapter<T extends PagerViewHolder> extends PagerAdapter {
+ private final T[] mViewHolders;
+
+ public FixedViewPagerAdapter(final T[] viewHolders) {
+ Assert.notNull(viewHolders);
+ mViewHolders = viewHolders;
+ }
+
+ @Override
+ public Object instantiateItem(final ViewGroup container, final int position) {
+ final PagerViewHolder viewHolder = getViewHolder(position);
+ final View view = viewHolder.getView(container);
+ if (view == null) {
+ return null;
+ }
+ view.setTag(viewHolder);
+ container.addView(view);
+ return viewHolder;
+ }
+
+ @Override
+ public void destroyItem(final ViewGroup container, final int position, final Object object) {
+ final PagerViewHolder viewHolder = getViewHolder(position);
+ final View destroyedView = viewHolder.destroyView();
+ if (destroyedView != null) {
+ container.removeView(destroyedView);
+ }
+ }
+
+ @Override
+ public int getCount() {
+ return mViewHolders.length;
+ }
+
+ @Override
+ public boolean isViewFromObject(final View view, final Object object) {
+ return view.getTag() == object;
+ }
+
+ public T getViewHolder(final int i) {
+ return getViewHolder(i, true /* rtlAware */);
+ }
+
+ @VisibleForTesting
+ public T getViewHolder(final int i, final boolean rtlAware) {
+ return mViewHolders[rtlAware ? getRtlPosition(i) : i];
+ }
+
+ @Override
+ public Parcelable saveState() {
+ // The paged views in the view pager gets created and destroyed as the user scrolls through
+ // them. By default, only the pages to the immediate left and right of the current visible
+ // page are realized. Moreover, if the activity gets destroyed and recreated, the pages are
+ // automatically destroyed. Therefore, in order to preserve transient page UI states that
+ // are not persisted in the DB we'd like to store them in a Bundle when views get
+ // destroyed. When the views get recreated, we rehydrate them by passing them the saved
+ // data. When the activity gets destroyed, it invokes saveState() on this adapter to
+ // add this saved Bundle to the overall saved instance state.
+ final Bundle savedViewHolderState = new Bundle(Factory.get().getApplicationContext()
+ .getClassLoader());
+ for (int i = 0; i < mViewHolders.length; i++) {
+ final Parcelable pageState = getViewHolder(i).saveState();
+ savedViewHolderState.putParcelable(getInstanceStateKeyForPage(i), pageState);
+ }
+ return savedViewHolderState;
+ }
+
+ @Override
+ public void restoreState(final Parcelable state, final ClassLoader loader) {
+ if (state instanceof Bundle) {
+ final Bundle restoredViewHolderState = (Bundle) state;
+ ((Bundle) state).setClassLoader(Factory.get().getApplicationContext().getClassLoader());
+ for (int i = 0; i < mViewHolders.length; i++) {
+ final Parcelable pageState = restoredViewHolderState
+ .getParcelable(getInstanceStateKeyForPage(i));
+ getViewHolder(i).restoreState(pageState);
+ }
+ } else {
+ super.restoreState(state, loader);
+ }
+ }
+
+ public void resetState() {
+ for (int i = 0; i < mViewHolders.length; i++) {
+ getViewHolder(i).resetState();
+ }
+ }
+
+ private String getInstanceStateKeyForPage(final int i) {
+ return getViewHolder(i).getClass().getCanonicalName() + "_savedstate_" + i;
+ }
+
+ protected int getRtlPosition(final int position) {
+ if (UiUtils.isRtlMode()) {
+ return mViewHolders.length - 1 - position;
+ }
+ return position;
+ }
+}
diff --git a/src/com/android/messaging/ui/ImeDetectFrameLayout.java b/src/com/android/messaging/ui/ImeDetectFrameLayout.java
new file mode 100644
index 0000000..32564ea
--- /dev/null
+++ b/src/com/android/messaging/ui/ImeDetectFrameLayout.java
@@ -0,0 +1,46 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.FrameLayout;
+
+import com.android.messaging.util.ImeUtil;
+import com.android.messaging.util.LogUtil;
+
+public class ImeDetectFrameLayout extends FrameLayout {
+ public ImeDetectFrameLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ final int measuredHeight = getMeasuredHeight();
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(LogUtil.BUGLE_TAG, "ImeDetectFrameLayout " +
+ "measuredHeight: " + measuredHeight + " getMeasuredHeight(): " +
+ getMeasuredHeight());
+ }
+
+ if (measuredHeight != getMeasuredHeight() && getContext() instanceof ImeUtil.ImeStateHost) {
+ ((ImeUtil.ImeStateHost) getContext()).onDisplayHeightChanged(heightMeasureSpec);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/LicenseActivity.java b/src/com/android/messaging/ui/LicenseActivity.java
new file mode 100644
index 0000000..a28da81
--- /dev/null
+++ b/src/com/android/messaging/ui/LicenseActivity.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.messaging.ui;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.webkit.WebView;
+
+import com.android.messaging.R;
+
+public class LicenseActivity extends Activity {
+ private final String LICENSE_URL = "file:///android_asset/licenses.html";
+
+ @Override
+ public void onCreate(final Bundle bundle) {
+ super.onCreate(bundle);
+ setContentView(R.layout.license_activity);
+ final WebView webView = (WebView) findViewById(R.id.content);
+ webView.loadUrl(LICENSE_URL);
+ }
+}
diff --git a/src/com/android/messaging/ui/LineWrapLayout.java b/src/com/android/messaging/ui/LineWrapLayout.java
new file mode 100644
index 0000000..5219811
--- /dev/null
+++ b/src/com/android/messaging/ui/LineWrapLayout.java
@@ -0,0 +1,232 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.UiUtils;
+
+import java.util.ArrayList;
+
+/**
+* A line-wrapping flow layout. Arranges children in horizontal flow, packing as many
+* child views as possible on each line. When the current line does not
+* have enough horizontal space, the layout continues on the next line.
+*/
+public class LineWrapLayout extends ViewGroup {
+ public LineWrapLayout(Context context) {
+ this(context, null);
+ }
+
+ public LineWrapLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ final int startPadding = UiUtils.getPaddingStart(this);
+ final int endPadding = UiUtils.getPaddingEnd(this);
+ final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
+ final int widthSize = MeasureSpec.getSize(widthMeasureSpec) - startPadding - endPadding;
+ final boolean isFixedSize = (widthMode == MeasureSpec.EXACTLY);
+
+ int height = 0;
+
+ int childCount = getChildCount();
+ int childWidthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.AT_MOST);
+
+ int x = startPadding;
+ int currLineWidth = 0;
+ int currLineHeight = 0;
+ int maxLineWidth = 0;
+
+ for (int i = 0; i < childCount; i++) {
+ View currChild = getChildAt(i);
+ if (currChild.getVisibility() == GONE) {
+ continue;
+ }
+ LayoutParams layoutParams = (LayoutParams) currChild.getLayoutParams();
+ int startMargin = layoutParams.getStartMargin();
+ int endMargin = layoutParams.getEndMargin();
+ currChild.measure(childWidthSpec, MeasureSpec.UNSPECIFIED);
+ int childMeasuredWidth = currChild.getMeasuredWidth() + startMargin + endMargin;
+ int childMeasuredHeight = currChild.getMeasuredHeight() + layoutParams.topMargin +
+ layoutParams.bottomMargin;
+
+ if ((x + childMeasuredWidth) > widthSize) {
+ // New line. Update the overall height and reset trackers.
+ height += currLineHeight;
+ currLineHeight = 0;
+ x = startPadding;
+ currLineWidth = 0;
+ startMargin = 0;
+ }
+
+ x += childMeasuredWidth;
+ currLineWidth += childMeasuredWidth;
+ currLineHeight = Math.max(currLineHeight, childMeasuredHeight);
+ maxLineWidth = Math.max(currLineWidth, maxLineWidth);
+ }
+ // And account for the height of the last line.
+ height += currLineHeight;
+
+ int width = isFixedSize ? widthSize : maxLineWidth;
+ setMeasuredDimension(width + startPadding + endPadding,
+ height + getPaddingTop() + getPaddingBottom());
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ final int startPadding = UiUtils.getPaddingStart(this);
+ final int endPadding = UiUtils.getPaddingEnd(this);
+ int width = getWidth() - startPadding - endPadding;
+ int y = getPaddingTop();
+ int x = startPadding;
+ int childCount = getChildCount();
+
+ int currLineHeight = 0;
+
+ // Do a dry-run first to get the line heights.
+ final ArrayList<Integer> lineHeights = new ArrayList<Integer>();
+ for (int i = 0; i < childCount; i++) {
+ View currChild = getChildAt(i);
+ if (currChild.getVisibility() == GONE) {
+ continue;
+ }
+ LayoutParams layoutParams = (LayoutParams) currChild.getLayoutParams();
+ int childWidth = currChild.getMeasuredWidth();
+ int childHeight = currChild.getMeasuredHeight();
+ int startMargin = layoutParams.getStartMargin();
+ int endMargin = layoutParams.getEndMargin();
+
+ if ((x + childWidth + startMargin + endMargin) > width) {
+ // new line
+ lineHeights.add(currLineHeight);
+ currLineHeight = 0;
+ x = startPadding;
+ startMargin = 0;
+ }
+ currLineHeight = Math.max(currLineHeight, childHeight + layoutParams.topMargin +
+ layoutParams.bottomMargin);
+ x += childWidth + startMargin + endMargin;
+ }
+ // Add the last line height.
+ lineHeights.add(currLineHeight);
+
+ // Now perform the actual layout.
+ x = startPadding;
+ currLineHeight = 0;
+ int lineIndex = 0;
+ for (int i = 0; i < childCount; i++) {
+ View currChild = getChildAt(i);
+ if (currChild.getVisibility() == GONE) {
+ continue;
+ }
+ LayoutParams layoutParams = (LayoutParams) currChild.getLayoutParams();
+ int childWidth = currChild.getMeasuredWidth();
+ int childHeight = currChild.getMeasuredHeight();
+ int startMargin = layoutParams.getStartMargin();
+ int endMargin = layoutParams.getEndMargin();
+
+ if ((x + childWidth + startMargin + endMargin) > width) {
+ // new line
+ y += currLineHeight;
+ currLineHeight = 0;
+ x = startPadding;
+ startMargin = 0;
+ lineIndex++;
+ }
+ final int startPositionX = x + startMargin;
+ int startPositionY = y + layoutParams.topMargin; // default to top gravity
+ final int majorGravity = layoutParams.gravity & Gravity.VERTICAL_GRAVITY_MASK;
+ if (majorGravity != Gravity.TOP && lineHeights.size() > lineIndex) {
+ final int lineHeight = lineHeights.get(lineIndex);
+ switch (majorGravity) {
+ case Gravity.BOTTOM:
+ startPositionY = y + lineHeight - childHeight - layoutParams.bottomMargin;
+ break;
+
+ case Gravity.CENTER_VERTICAL:
+ startPositionY = y + (lineHeight - childHeight) / 2;
+ break;
+ }
+ }
+
+ if (OsUtil.isAtLeastJB_MR2() && getResources().getConfiguration()
+ .getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
+ currChild.layout(width - startPositionX - childWidth, startPositionY,
+ width - startPositionX, startPositionY + childHeight);
+ } else {
+ currChild.layout(startPositionX, startPositionY, startPositionX + childWidth,
+ startPositionY + childHeight);
+ }
+ currLineHeight = Math.max(currLineHeight, childHeight + layoutParams.topMargin +
+ layoutParams.bottomMargin);
+ x += childWidth + startMargin + endMargin;
+ }
+ }
+
+ @Override
+ protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
+ return new LayoutParams(p);
+ }
+
+ @Override
+ public LayoutParams generateLayoutParams(AttributeSet attrs) {
+ return new LayoutParams(getContext(), attrs);
+ }
+
+ @Override
+ protected LayoutParams generateDefaultLayoutParams() {
+ return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+ }
+
+ public static final class LayoutParams extends FrameLayout.LayoutParams {
+ public LayoutParams(Context c, AttributeSet attrs) {
+ super(c, attrs);
+ }
+
+ public LayoutParams(int width, int height) {
+ super(width, height);
+ }
+
+ public LayoutParams(ViewGroup.LayoutParams source) {
+ super(source);
+ }
+
+ public int getStartMargin() {
+ if (OsUtil.isAtLeastJB_MR2()) {
+ return getMarginStart();
+ } else {
+ return leftMargin;
+ }
+ }
+
+ public int getEndMargin() {
+ if (OsUtil.isAtLeastJB_MR2()) {
+ return getMarginEnd();
+ } else {
+ return rightMargin;
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/ListEmptyView.java b/src/com/android/messaging/ui/ListEmptyView.java
new file mode 100644
index 0000000..8cf3049
--- /dev/null
+++ b/src/com/android/messaging/ui/ListEmptyView.java
@@ -0,0 +1,72 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.LinearLayout.LayoutParams;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+
+/**
+ * A common reusable view that shows a hint image and text for an empty list view.
+ */
+public class ListEmptyView extends LinearLayout {
+ private ImageView mEmptyImageHint;
+ private TextView mEmptyTextHint;
+
+ public ListEmptyView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+
+ mEmptyImageHint = (ImageView) findViewById(R.id.empty_image_hint);
+ mEmptyTextHint = (TextView) findViewById(R.id.empty_text_hint);
+ }
+
+ public void setImageHint(final int resId) {
+ mEmptyImageHint.setImageResource(resId);
+ }
+
+ public void setTextHint(final int resId) {
+ mEmptyTextHint.setText(getResources().getText(resId));
+ }
+
+ public void setTextHint(final CharSequence hintText) {
+ mEmptyTextHint.setText(hintText);
+ }
+
+ public void setIsImageVisible(final boolean isImageVisible) {
+ mEmptyImageHint.setVisibility(isImageVisible ? VISIBLE : GONE);
+ }
+
+ public void setIsVerticallyCentered(final boolean isVerticallyCentered) {
+ int gravity =
+ isVerticallyCentered ? Gravity.CENTER : Gravity.TOP | Gravity.CENTER_HORIZONTAL;
+ ((LinearLayout.LayoutParams) mEmptyImageHint.getLayoutParams()).gravity = gravity;
+ ((LinearLayout.LayoutParams) mEmptyTextHint.getLayoutParams()).gravity = gravity;
+ getLayoutParams().height =
+ isVerticallyCentered ? LayoutParams.WRAP_CONTENT : LayoutParams.MATCH_PARENT;
+ requestLayout();
+ }
+}
diff --git a/src/com/android/messaging/ui/MaxHeightScrollView.java b/src/com/android/messaging/ui/MaxHeightScrollView.java
new file mode 100644
index 0000000..a000cd1
--- /dev/null
+++ b/src/com/android/messaging/ui/MaxHeightScrollView.java
@@ -0,0 +1,50 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.widget.ScrollView;
+
+import com.android.messaging.R;
+
+/**
+ * A ScrollView that limits the maximum height that it can take. This is to work around android
+ * layout's limitation of not having android:maxHeight.
+ */
+public class MaxHeightScrollView extends ScrollView {
+ private static final int NO_MAX_HEIGHT = -1;
+
+ private final int mMaxHeight;
+
+ public MaxHeightScrollView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ final TypedArray attr = context.obtainStyledAttributes(attrs,
+ R.styleable.MaxHeightScrollView, 0, 0);
+ mMaxHeight = attr.getDimensionPixelSize(R.styleable.MaxHeightScrollView_android_maxHeight,
+ NO_MAX_HEIGHT);
+ attr.recycle();
+ }
+
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ if (mMaxHeight != NO_MAX_HEIGHT) {
+ setMeasuredDimension(getMeasuredWidth(), Math.min(getMeasuredHeight(), mMaxHeight));
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/ui/MultiAttachmentLayout.java b/src/com/android/messaging/ui/MultiAttachmentLayout.java
new file mode 100644
index 0000000..f620245
--- /dev/null
+++ b/src/com/android/messaging/ui/MultiAttachmentLayout.java
@@ -0,0 +1,424 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.animation.AnimationSet;
+import android.view.animation.ScaleAnimation;
+import android.view.animation.TranslateAnimation;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.MediaPickerMessagePartData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.PendingAttachmentData;
+import com.android.messaging.datamodel.media.ImageRequestDescriptor;
+import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.UiUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Holds and displays multiple attachments in a 4x2 grid. Each preview image "tile" can take
+ * one of three sizes - small (1x1), wide (2x1) and large (2x2). We have a number of predefined
+ * layout settings designed for holding 2, 3, 4+ attachments (these layout settings are
+ * tweakable by design request to allow for max flexibility). For a visual example, consider the
+ * following attachment layout:
+ *
+ * +---------------+----------------+
+ * | | |
+ * | | B |
+ * | | |
+ * | A |-------+--------|
+ * | | | |
+ * | | C | D |
+ * | | | |
+ * +---------------+-------+--------+
+ *
+ * In the above example, the layout consists of four tiles, A-D. A is a large tile, B is a
+ * wide tile and C & D are both small tiles. A starts at (0,0) and ends at (1,1), B starts at
+ * (2,0) and ends at (3,0), and so on. In our layout class we'd have these tiles in the order
+ * of A-D, so that we make sure the last tile is always the one where we can put the overflow
+ * indicator (e.g. "+2").
+ */
+public class MultiAttachmentLayout extends FrameLayout {
+
+ public interface OnAttachmentClickListener {
+ boolean onAttachmentClick(MessagePartData attachment, Rect viewBoundsOnScreen,
+ boolean longPress);
+ }
+
+ private static final int GRID_WIDTH = 4; // in # of cells
+ private static final int GRID_HEIGHT = 2; // in # of cells
+
+ /**
+ * Represents a preview image tile in the layout
+ */
+ private static class Tile {
+ public final int startX;
+ public final int startY;
+ public final int endX;
+ public final int endY;
+
+ private Tile(final int startX, final int startY, final int endX, final int endY) {
+ this.startX = startX;
+ this.startY = startY;
+ this.endX = endX;
+ this.endY = endY;
+ }
+
+ public int getWidthMeasureSpec(final int cellWidth, final int padding) {
+ return MeasureSpec.makeMeasureSpec((endX - startX + 1) * cellWidth - padding * 2,
+ MeasureSpec.EXACTLY);
+ }
+
+ public int getHeightMeasureSpec(final int cellHeight, final int padding) {
+ return MeasureSpec.makeMeasureSpec((endY - startY + 1) * cellHeight - padding * 2,
+ MeasureSpec.EXACTLY);
+ }
+
+ public static Tile large(final int startX, final int startY) {
+ return new Tile(startX, startY, startX + 1, startY + 1);
+ }
+
+ public static Tile wide(final int startX, final int startY) {
+ return new Tile(startX, startY, startX + 1, startY);
+ }
+
+ public static Tile small(final int startX, final int startY) {
+ return new Tile(startX, startY, startX, startY);
+ }
+ }
+
+ /**
+ * A layout simply contains a list of tiles, in the order of top-left -> bottom-right.
+ */
+ private static class Layout {
+ public final List<Tile> tiles;
+ public Layout(final Tile[] tilesArray) {
+ tiles = Arrays.asList(tilesArray);
+ }
+ }
+
+ /**
+ * List of predefined layout configurations w.r.t no. of attachments.
+ */
+ private static final Layout[] ATTACHMENT_LAYOUTS_BY_COUNT = {
+ null, // Doesn't support zero attachments.
+ null, // Doesn't support one attachment. Single attachment preview is used instead.
+ new Layout(new Tile[] { Tile.large(0, 0), Tile.large(2, 0) }), // 2 items
+ new Layout(new Tile[] { Tile.large(0, 0), Tile.wide(2, 0), Tile.wide(2, 1) }), // 3 items
+ new Layout(new Tile[] { Tile.large(0, 0), Tile.wide(2, 0), Tile.small(2, 1), // 4+ items
+ Tile.small(3, 1) }),
+ };
+
+ /**
+ * List of predefined RTL layout configurations w.r.t no. of attachments.
+ */
+ private static final Layout[] ATTACHMENT_RTL_LAYOUTS_BY_COUNT = {
+ null, // Doesn't support zero attachments.
+ null, // Doesn't support one attachment. Single attachment preview is used instead.
+ new Layout(new Tile[] { Tile.large(2, 0), Tile.large(0, 0)}), // 2 items
+ new Layout(new Tile[] { Tile.large(2, 0), Tile.wide(0, 0), Tile.wide(0, 1) }), // 3 items
+ new Layout(new Tile[] { Tile.large(2, 0), Tile.wide(0, 0), Tile.small(1, 1), // 4+ items
+ Tile.small(0, 1) }),
+ };
+
+ private Layout mCurrentLayout;
+ private ArrayList<ViewWrapper> mPreviewViews;
+ private int mPlusNumber;
+ private TextView mPlusTextView;
+ private OnAttachmentClickListener mAttachmentClickListener;
+ private AsyncImageViewDelayLoader mImageViewDelayLoader;
+
+ public MultiAttachmentLayout(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mPreviewViews = new ArrayList<ViewWrapper>();
+ }
+
+ public void bindAttachments(final Iterable<MessagePartData> attachments,
+ final Rect transitionRect, final int count) {
+ final ArrayList<ViewWrapper> previousViews = mPreviewViews;
+ mPreviewViews = new ArrayList<ViewWrapper>();
+ removeView(mPlusTextView);
+ mPlusTextView = null;
+
+ determineLayout(attachments, count);
+ buildViews(attachments, previousViews, transitionRect);
+
+ // Remove all previous views that couldn't be recycled.
+ for (final ViewWrapper viewWrapper : previousViews) {
+ removeView(viewWrapper.view);
+ }
+ requestLayout();
+ }
+
+ public OnAttachmentClickListener getOnAttachmentClickListener() {
+ return mAttachmentClickListener;
+ }
+
+ public void setOnAttachmentClickListener(final OnAttachmentClickListener listener) {
+ mAttachmentClickListener = listener;
+ }
+
+ public void setImageViewDelayLoader(final AsyncImageViewDelayLoader delayLoader) {
+ mImageViewDelayLoader = delayLoader;
+ }
+
+ public void setColorFilter(int color) {
+ for (ViewWrapper viewWrapper : mPreviewViews) {
+ if (viewWrapper.view instanceof AsyncImageView) {
+ ((AsyncImageView) viewWrapper.view).setColorFilter(color);
+ }
+ }
+ }
+
+ public void clearColorFilter() {
+ for (ViewWrapper viewWrapper : mPreviewViews) {
+ if (viewWrapper.view instanceof AsyncImageView) {
+ ((AsyncImageView) viewWrapper.view).clearColorFilter();
+ }
+ }
+ }
+
+ private void determineLayout(final Iterable<MessagePartData> attachments, final int count) {
+ Assert.isTrue(attachments != null);
+ final boolean isRtl = AccessibilityUtil.isLayoutRtl(getRootView());
+ if (isRtl) {
+ mCurrentLayout = ATTACHMENT_RTL_LAYOUTS_BY_COUNT[Math.min(count,
+ ATTACHMENT_RTL_LAYOUTS_BY_COUNT.length - 1)];
+ } else {
+ mCurrentLayout = ATTACHMENT_LAYOUTS_BY_COUNT[Math.min(count,
+ ATTACHMENT_LAYOUTS_BY_COUNT.length - 1)];
+ }
+
+ // We must have a valid layout for the current configuration.
+ Assert.notNull(mCurrentLayout);
+
+ mPlusNumber = count - mCurrentLayout.tiles.size();
+ Assert.isTrue(mPlusNumber >= 0);
+ }
+
+ private void buildViews(final Iterable<MessagePartData> attachments,
+ final ArrayList<ViewWrapper> previousViews, final Rect transitionRect) {
+ final LayoutInflater layoutInflater = LayoutInflater.from(getContext());
+ final int count = mCurrentLayout.tiles.size();
+ int i = 0;
+ final Iterator<MessagePartData> iterator = attachments.iterator();
+ while (iterator.hasNext() && i < count) {
+ final MessagePartData attachment = iterator.next();
+ ViewWrapper attachmentWrapper = null;
+ // Try to recycle a previous view first
+ for (int j = 0; j < previousViews.size(); j++) {
+ final ViewWrapper previousView = previousViews.get(j);
+ if (previousView.attachment.equals(attachment) &&
+ !(previousView.attachment instanceof PendingAttachmentData)) {
+ attachmentWrapper = previousView;
+ previousViews.remove(j);
+ break;
+ }
+ }
+
+ if (attachmentWrapper == null) {
+ final View view = AttachmentPreviewFactory.createAttachmentPreview(layoutInflater,
+ attachment, this, AttachmentPreviewFactory.TYPE_MULTIPLE,
+ false /* startImageRequest */, mAttachmentClickListener);
+
+ if (view == null) {
+ // createAttachmentPreview can return null if something goes wrong (e.g.
+ // attachment has unsupported contentType)
+ continue;
+ }
+ if (view instanceof AsyncImageView && mImageViewDelayLoader != null) {
+ AsyncImageView asyncImageView = (AsyncImageView) view;
+ asyncImageView.setDelayLoader(mImageViewDelayLoader);
+ }
+ addView(view);
+ attachmentWrapper = new ViewWrapper(view, attachment);
+ // Help animate from single to multi by copying over the prev location
+ if (count == 2 && i == 1 && transitionRect != null) {
+ attachmentWrapper.prevLeft = transitionRect.left;
+ attachmentWrapper.prevTop = transitionRect.top;
+ attachmentWrapper.prevWidth = transitionRect.width();
+ attachmentWrapper.prevHeight = transitionRect.height();
+ }
+ }
+ i++;
+ Assert.notNull(attachmentWrapper);
+ mPreviewViews.add(attachmentWrapper);
+
+ // The first view will animate in using PopupTransitionAnimation, but the remaining
+ // views will slide from their previous position to their new position within the
+ // layout
+ if (i == 0) {
+ AttachmentPreview.tryAnimateViewIn(attachment, attachmentWrapper.view);
+ }
+ attachmentWrapper.needsSlideAnimation = i > 0;
+ }
+
+ // Build the plus text view (e.g. "+2") for when there are more attachments than what
+ // this layout can display.
+ if (mPlusNumber > 0) {
+ mPlusTextView = (TextView) layoutInflater.inflate(R.layout.attachment_more_text_view,
+ null /* parent */);
+ mPlusTextView.setText(getResources().getString(R.string.attachment_more_items,
+ mPlusNumber));
+ addView(mPlusTextView);
+ }
+ }
+
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ final int maxWidth = getResources().getDimensionPixelSize(
+ R.dimen.multiple_attachment_preview_width);
+ final int maxHeight = getResources().getDimensionPixelSize(
+ R.dimen.multiple_attachment_preview_height);
+ final int width = Math.min(MeasureSpec.getSize(widthMeasureSpec), maxWidth);
+ final int height = maxHeight;
+ final int cellWidth = width / GRID_WIDTH;
+ final int cellHeight = height / GRID_HEIGHT;
+ final int count = mPreviewViews.size();
+ final int padding = getResources().getDimensionPixelOffset(
+ R.dimen.multiple_attachment_preview_padding);
+ for (int i = 0; i < count; i++) {
+ final View view = mPreviewViews.get(i).view;
+ final Tile imageTile = mCurrentLayout.tiles.get(i);
+ view.measure(imageTile.getWidthMeasureSpec(cellWidth, padding),
+ imageTile.getHeightMeasureSpec(cellHeight, padding));
+
+ // Now that we know the size, we can request an appropriately-sized image.
+ if (view instanceof AsyncImageView) {
+ final ImageRequestDescriptor imageRequest =
+ AttachmentPreviewFactory.getImageRequestDescriptorForAttachment(
+ mPreviewViews.get(i).attachment,
+ view.getMeasuredWidth(),
+ view.getMeasuredHeight());
+ ((AsyncImageView) view).setImageResourceId(imageRequest);
+ }
+
+ if (i == count - 1 && mPlusTextView != null) {
+ // The plus text view always covers the last attachment.
+ mPlusTextView.measure(imageTile.getWidthMeasureSpec(cellWidth, padding),
+ imageTile.getHeightMeasureSpec(cellHeight, padding));
+ }
+ }
+ setMeasuredDimension(width, height);
+ }
+
+ @Override
+ protected void onLayout(final boolean changed, final int left, final int top, final int right,
+ final int bottom) {
+ final int cellWidth = getMeasuredWidth() / GRID_WIDTH;
+ final int cellHeight = getMeasuredHeight() / GRID_HEIGHT;
+ final int padding = getResources().getDimensionPixelOffset(
+ R.dimen.multiple_attachment_preview_padding);
+ final int count = mPreviewViews.size();
+ for (int i = 0; i < count; i++) {
+ final ViewWrapper viewWrapper = mPreviewViews.get(i);
+ final View view = viewWrapper.view;
+ final Tile imageTile = mCurrentLayout.tiles.get(i);
+ final int tileLeft = imageTile.startX * cellWidth;
+ final int tileTop = imageTile.startY * cellHeight;
+ view.layout(tileLeft + padding, tileTop + padding,
+ tileLeft + view.getMeasuredWidth(),
+ tileTop + view.getMeasuredHeight());
+ if (viewWrapper.needsSlideAnimation) {
+ trySlideAttachmentView(viewWrapper);
+ viewWrapper.needsSlideAnimation = false;
+ } else {
+ viewWrapper.prevLeft = view.getLeft();
+ viewWrapper.prevTop = view.getTop();
+ viewWrapper.prevWidth = view.getWidth();
+ viewWrapper.prevHeight = view.getHeight();
+ }
+
+ if (i == count - 1 && mPlusTextView != null) {
+ // The plus text view always covers the last attachment.
+ mPlusTextView.layout(tileLeft + padding, tileTop + padding,
+ tileLeft + mPlusTextView.getMeasuredWidth(),
+ tileTop + mPlusTextView.getMeasuredHeight());
+ }
+ }
+ }
+
+ private void trySlideAttachmentView(final ViewWrapper viewWrapper) {
+ if (!(viewWrapper.attachment instanceof MediaPickerMessagePartData)) {
+ return;
+ }
+ final View view = viewWrapper.view;
+
+
+ final int xOffset = viewWrapper.prevLeft - view.getLeft();
+ final int yOffset = viewWrapper.prevTop - view.getTop();
+ final float scaleX = viewWrapper.prevWidth / (float) view.getWidth();
+ final float scaleY = viewWrapper.prevHeight / (float) view.getHeight();
+
+ if (xOffset == 0 && yOffset == 0 && scaleX == 1 && scaleY == 1) {
+ // Layout hasn't changed
+ return;
+ }
+
+ final AnimationSet animationSet = new AnimationSet(
+ true /* shareInterpolator */);
+ animationSet.addAnimation(new TranslateAnimation(xOffset, 0, yOffset, 0));
+ animationSet.addAnimation(new ScaleAnimation(scaleX, 1, scaleY, 1));
+ animationSet.setDuration(
+ UiUtils.MEDIAPICKER_TRANSITION_DURATION);
+ animationSet.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR);
+ view.startAnimation(animationSet);
+ view.invalidate();
+ viewWrapper.prevLeft = view.getLeft();
+ viewWrapper.prevTop = view.getTop();
+ viewWrapper.prevWidth = view.getWidth();
+ viewWrapper.prevHeight = view.getHeight();
+ }
+
+ public View findViewForAttachment(final MessagePartData attachment) {
+ for (ViewWrapper wrapper : mPreviewViews) {
+ if (wrapper.attachment.equals(attachment) &&
+ !(wrapper.attachment instanceof PendingAttachmentData)) {
+ return wrapper.view;
+ }
+ }
+ return null;
+ }
+
+ private static class ViewWrapper {
+ final View view;
+ final MessagePartData attachment;
+ boolean needsSlideAnimation;
+ int prevLeft;
+ int prevTop;
+ int prevWidth;
+ int prevHeight;
+
+ ViewWrapper(final View view, final MessagePartData attachment) {
+ this.view = view;
+ this.attachment = attachment;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/OrientedBitmapDrawable.java b/src/com/android/messaging/ui/OrientedBitmapDrawable.java
new file mode 100644
index 0000000..9242668
--- /dev/null
+++ b/src/com/android/messaging/ui/OrientedBitmapDrawable.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.messaging.ui;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.drawable.BitmapDrawable;
+import android.view.Gravity;
+
+import com.android.messaging.util.exif.ExifInterface;
+
+/**
+ * A drawable that draws a bitmap in a flipped or rotated orientation without having to adjust the
+ * bitmap
+ */
+public class OrientedBitmapDrawable extends BitmapDrawable {
+ private final ExifInterface.OrientationParams mOrientationParams;
+ private final Rect mDstRect;
+ private int mCenterX;
+ private int mCenterY;
+ private boolean mApplyGravity;
+
+ public static BitmapDrawable create(final int orientation, Resources res, Bitmap bitmap) {
+ if (orientation <= ExifInterface.Orientation.TOP_LEFT) {
+ // No need to adjust the bitmap, so just use a regular BitmapDrawable
+ return new BitmapDrawable(res, bitmap);
+ } else {
+ // Create an oriented bitmap drawable
+ return new OrientedBitmapDrawable(orientation, res, bitmap);
+ }
+ }
+
+ private OrientedBitmapDrawable(final int orientation, Resources res, Bitmap bitmap) {
+ super(res, bitmap);
+ mOrientationParams = ExifInterface.getOrientationParams(orientation);
+ mApplyGravity = true;
+ mDstRect = new Rect();
+ }
+
+ @Override
+ public int getIntrinsicWidth() {
+ if (mOrientationParams.invertDimensions) {
+ return super.getIntrinsicHeight();
+ }
+ return super.getIntrinsicWidth();
+ }
+
+ @Override
+ public int getIntrinsicHeight() {
+ if (mOrientationParams.invertDimensions) {
+ return super.getIntrinsicWidth();
+ }
+ return super.getIntrinsicHeight();
+ }
+
+ @Override
+ protected void onBoundsChange(Rect bounds) {
+ super.onBoundsChange(bounds);
+ mApplyGravity = true;
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ if (mApplyGravity) {
+ Gravity.apply(getGravity(), getIntrinsicWidth(), getIntrinsicHeight(), getBounds(),
+ mDstRect);
+ mCenterX = mDstRect.centerX();
+ mCenterY = mDstRect.centerY();
+ if (mOrientationParams.invertDimensions) {
+ final Matrix matrix = new Matrix();
+ matrix.setRotate(mOrientationParams.rotation, mCenterX, mCenterY);
+ final RectF rotatedRect = new RectF(mDstRect);
+ matrix.mapRect(rotatedRect);
+ mDstRect.set((int) rotatedRect.left, (int) rotatedRect.top, (int) rotatedRect.right,
+ (int) rotatedRect.bottom);
+ }
+
+ mApplyGravity = false;
+ }
+ canvas.save();
+ canvas.scale(mOrientationParams.scaleX, mOrientationParams.scaleY, mCenterX, mCenterY);
+ canvas.rotate(mOrientationParams.rotation, mCenterX, mCenterY);
+ canvas.drawBitmap(getBitmap(), (Rect) null, mDstRect, getPaint());
+ canvas.restore();
+ }
+}
diff --git a/src/com/android/messaging/ui/PagerViewHolder.java b/src/com/android/messaging/ui/PagerViewHolder.java
new file mode 100644
index 0000000..2f33a0f
--- /dev/null
+++ b/src/com/android/messaging/ui/PagerViewHolder.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.ui;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+/**
+ * Holds reusable View(s) for a {@link FixedViewPagerAdapter} to display a page. By using
+ * reusable Views inside ViewPagers this allows us to get rid of nested fragments and the messy
+ * activity lifecycle problems they entail.
+ */
+public interface PagerViewHolder extends PersistentInstanceState {
+ /** Instructs the pager to clean up any view related resources
+ * @return the destroyed View so that the adapter may remove it from the container, or
+ * null if no View has been created. */
+ View destroyView();
+
+ /** @return The view that presents the page view to the user */
+ View getView(ViewGroup container);
+}
diff --git a/src/com/android/messaging/ui/PagingAwareViewPager.java b/src/com/android/messaging/ui/PagingAwareViewPager.java
new file mode 100644
index 0000000..cc7b2cd
--- /dev/null
+++ b/src/com/android/messaging/ui/PagingAwareViewPager.java
@@ -0,0 +1,95 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.support.v4.view.PagerAdapter;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+
+import com.android.messaging.util.UiUtils;
+
+/**
+ * A simple extension on the standard ViewPager which lets you turn paging on/off.
+ */
+public class PagingAwareViewPager extends ViewPager {
+ private boolean mPagingEnabled = true;
+
+ public PagingAwareViewPager(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public void setCurrentItem(int item, boolean smoothScroll) {
+ super.setCurrentItem(getRtlPosition(item), smoothScroll);
+ }
+
+ @Override
+ public void setCurrentItem(int item) {
+ super.setCurrentItem(getRtlPosition(item));
+ }
+
+ @Override
+ public int getCurrentItem() {
+ int position = super.getCurrentItem();
+ return getRtlPosition(position);
+ }
+
+ /**
+ * Switches position in pager to be adjusted for if we are in RtL mode
+ *
+ * @param position
+ * @return position adjusted if in rtl mode
+ */
+ protected int getRtlPosition(final int position) {
+ final PagerAdapter adapter = getAdapter();
+ if (adapter != null && UiUtils.isRtlMode()) {
+ return adapter.getCount() - 1 - position;
+ }
+ return position;
+ }
+
+ @Override
+ public boolean onTouchEvent(final MotionEvent event) {
+ if (!mPagingEnabled) {
+ return false;
+ }
+ return super.onTouchEvent(event);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(final MotionEvent event) {
+ if (!mPagingEnabled) {
+ return false;
+ }
+ return super.onInterceptTouchEvent(event);
+ }
+
+ public void setPagingEnabled(final boolean enabled) {
+ this.mPagingEnabled = enabled;
+ }
+
+ /** This prevents touch-less scrolling eg. while doing accessibility navigation. */
+ @Override
+ public boolean canScrollHorizontally(int direction) {
+ if (mPagingEnabled) {
+ return super.canScrollHorizontally(direction);
+ } else {
+ return false;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/PermissionCheckActivity.java b/src/com/android/messaging/ui/PermissionCheckActivity.java
new file mode 100644
index 0000000..e992a10
--- /dev/null
+++ b/src/com/android/messaging/ui/PermissionCheckActivity.java
@@ -0,0 +1,141 @@
+/*
+ * 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.messaging.ui;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.provider.Settings;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.TextView;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * Activity to check if the user has required permissions. If not, it will try to prompt the user
+ * to grant permissions. However, the OS may not actually prompt the user if the user had
+ * previously checked the "Never ask again" checkbox while denying the required permissions.
+ */
+public class PermissionCheckActivity extends Activity {
+ private static final int REQUIRED_PERMISSIONS_REQUEST_CODE = 1;
+ private static final long AUTOMATED_RESULT_THRESHOLD_MILLLIS = 250;
+ private static final String PACKAGE_URI_PREFIX = "package:";
+ private long mRequestTimeMillis;
+ private TextView mNextView;
+ private TextView mSettingsView;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (redirectIfNeeded()) {
+ return;
+ }
+
+ setContentView(R.layout.permission_check_activity);
+ UiUtils.setStatusBarColor(this, getColor(R.color.permission_check_activity_background));
+
+ findViewById(R.id.exit).setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ finish();
+ }
+ });
+
+ mNextView = (TextView) findViewById(R.id.next);
+ mNextView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ tryRequestPermission();
+ }
+ });
+
+ mSettingsView = (TextView) findViewById(R.id.settings);
+ mSettingsView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.parse(PACKAGE_URI_PREFIX + getPackageName()));
+ startActivity(intent);
+ }
+ });
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ if (redirectIfNeeded()) {
+ return;
+ }
+ }
+
+ private void tryRequestPermission() {
+ final String[] missingPermissions = OsUtil.getMissingRequiredPermissions();
+ if (missingPermissions.length == 0) {
+ redirect();
+ return;
+ }
+
+ mRequestTimeMillis = SystemClock.elapsedRealtime();
+ requestPermissions(missingPermissions, REQUIRED_PERMISSIONS_REQUEST_CODE);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ final int requestCode, final String permissions[], final int[] grantResults) {
+ if (requestCode == REQUIRED_PERMISSIONS_REQUEST_CODE) {
+ // We do not use grantResults as some of the granted permissions might have been
+ // revoked while the permissions dialog box was being shown for the missing permissions.
+ if (OsUtil.hasRequiredPermissions()) {
+ Factory.get().onRequiredPermissionsAcquired();
+ redirect();
+ } else {
+ final long currentTimeMillis = SystemClock.elapsedRealtime();
+ // If the permission request completes very quickly, it must be because the system
+ // automatically denied. This can happen if the user had previously denied it
+ // and checked the "Never ask again" check box.
+ if ((currentTimeMillis - mRequestTimeMillis) < AUTOMATED_RESULT_THRESHOLD_MILLLIS) {
+ mNextView.setVisibility(View.GONE);
+
+ mSettingsView.setVisibility(View.VISIBLE);
+ findViewById(R.id.enable_permission_procedure).setVisibility(View.VISIBLE);
+ }
+ }
+ }
+ }
+
+ /** Returns true if the redirecting was performed */
+ private boolean redirectIfNeeded() {
+ if (!OsUtil.hasRequiredPermissions()) {
+ return false;
+ }
+
+ redirect();
+ return true;
+ }
+
+ private void redirect() {
+ UIIntents.get().launchConversationListActivity(this);
+ finish();
+ }
+}
diff --git a/src/com/android/messaging/ui/PersistentInstanceState.java b/src/com/android/messaging/ui/PersistentInstanceState.java
new file mode 100644
index 0000000..3cc1856
--- /dev/null
+++ b/src/com/android/messaging/ui/PersistentInstanceState.java
@@ -0,0 +1,39 @@
+/*
+ * 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.messaging.ui;
+
+import android.os.Parcelable;
+
+/**
+ * Wraps around functionality to save, restore and reset a particular UI component's state.
+ */
+public interface PersistentInstanceState {
+ /**
+ * Saves necessary information about the current state of the instance to later restore
+ * the instance to its original state.
+ */
+ Parcelable saveState();
+
+ /**
+ * Given a previously saved instance state, attempt to restore to its original view state.
+ */
+ void restoreState(Parcelable restoredState);
+
+ /**
+ * Called when any current/preserved state should be reset to zero state.
+ */
+ void resetState();
+}
diff --git a/src/com/android/messaging/ui/PersonItemView.java b/src/com/android/messaging/ui/PersonItemView.java
new file mode 100644
index 0000000..afd1a99
--- /dev/null
+++ b/src/com/android/messaging/ui/PersonItemView.java
@@ -0,0 +1,242 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.content.Intent;
+import android.support.v4.text.BidiFormatter;
+import android.support.v4.text.TextDirectionHeuristicsCompat;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnLayoutChangeListener;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.binding.DetachableBinding;
+import com.android.messaging.datamodel.data.PersonItemData;
+import com.android.messaging.datamodel.data.PersonItemData.PersonItemDataListener;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * Shows a view for a "person" - could be a contact or a participant. This always shows a
+ * contact icon on the left, and the person's display name on the right.
+ *
+ * This view is always bound to an abstract PersonItemData class, so to use it for a specific
+ * scenario, all you need to do is to create a concrete PersonItemData subclass that bridges
+ * between the underlying data (e.g. ParticipantData) and what the UI wants (e.g. display name).
+ */
+public class PersonItemView extends LinearLayout implements PersonItemDataListener,
+ OnLayoutChangeListener {
+ public interface PersonItemViewListener {
+ void onPersonClicked(PersonItemData data);
+ boolean onPersonLongClicked(PersonItemData data);
+ }
+
+ protected final DetachableBinding<PersonItemData> mBinding;
+ private TextView mNameTextView;
+ private TextView mDetailsTextView;
+ private ContactIconView mContactIconView;
+ private View mDetailsContainer;
+ private PersonItemViewListener mListener;
+ private boolean mAvatarOnly;
+
+ public PersonItemView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mBinding = BindingBase.createDetachableBinding(this);
+ LayoutInflater.from(getContext()).inflate(R.layout.person_item_view, this, true);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ mNameTextView = (TextView) findViewById(R.id.name);
+ mDetailsTextView = (TextView) findViewById(R.id.details);
+ mContactIconView = (ContactIconView) findViewById(R.id.contact_icon);
+ mDetailsContainer = findViewById(R.id.details_container);
+ mNameTextView.addOnLayoutChangeListener(this);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ if (mBinding.isBound()) {
+ mBinding.detach();
+ }
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ mBinding.reAttachIfPossible();
+ }
+
+ /**
+ * Binds to a person item data which will provide us info to be displayed.
+ * @param personData the PersonItemData to be bound to.
+ */
+ public void bind(final PersonItemData personData) {
+ if (mBinding.isBound()) {
+ if (mBinding.getData().equals(personData)) {
+ // Don't rebind if we are requesting the same data.
+ return;
+ }
+ mBinding.unbind();
+ }
+
+ if (personData != null) {
+ mBinding.bind(personData);
+ mBinding.getData().setListener(this);
+
+ // Accessibility reason : in case phone numbers are mixed in the display name,
+ // we need to vocalize it for talkback.
+ final String vocalizedDisplayName = AccessibilityUtil.getVocalizedPhoneNumber(
+ getResources(), getDisplayName());
+ mNameTextView.setContentDescription(vocalizedDisplayName);
+ }
+ updateViewAppearance();
+ }
+
+ /**
+ * @return Display name, possibly comma-ellipsized.
+ */
+ private String getDisplayName() {
+ final int width = mNameTextView.getMeasuredWidth();
+ final String displayName = mBinding.getData().getDisplayName();
+ if (width == 0 || TextUtils.isEmpty(displayName) || !displayName.contains(",")) {
+ return displayName;
+ }
+ final String plusOneString = getContext().getString(R.string.plus_one);
+ final String plusNString = getContext().getString(R.string.plus_n);
+ return BidiFormatter.getInstance().unicodeWrap(
+ UiUtils.commaEllipsize(
+ displayName,
+ mNameTextView.getPaint(),
+ width,
+ plusOneString,
+ plusNString).toString(),
+ TextDirectionHeuristicsCompat.LTR);
+ }
+
+ @Override
+ public void onLayoutChange(final View v, final int left, final int top, final int right,
+ final int bottom, final int oldLeft, final int oldTop, final int oldRight,
+ final int oldBottom) {
+ if (mBinding.isBound() && v == mNameTextView) {
+ setNameTextView();
+ }
+ }
+
+ /**
+ * When set to true, we display only the avatar of the person and hide everything else.
+ */
+ public void setAvatarOnly(final boolean avatarOnly) {
+ mAvatarOnly = avatarOnly;
+ mDetailsContainer.setVisibility(avatarOnly ? GONE : VISIBLE);
+ }
+
+ public boolean isAvatarOnly() {
+ return mAvatarOnly;
+ }
+
+ public void setNameTextColor(final int color) {
+ mNameTextView.setTextColor(color);
+ }
+
+ public void setDetailsTextColor(final int color) {
+ mDetailsTextView.setTextColor(color);
+ }
+
+ public void setListener(final PersonItemViewListener listener) {
+ mListener = listener;
+ if (mListener == null) {
+ return;
+ }
+ setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ if (mListener != null && mBinding.isBound()) {
+ mListener.onPersonClicked(mBinding.getData());
+ }
+ }
+ });
+ final OnLongClickListener onLongClickListener = new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ if (mListener != null && mBinding.isBound()) {
+ return mListener.onPersonLongClicked(mBinding.getData());
+ }
+ return false;
+ }
+ };
+ setOnLongClickListener(onLongClickListener);
+ mContactIconView.setOnLongClickListener(onLongClickListener);
+ }
+
+ public void performClickOnAvatar() {
+ mContactIconView.performClick();
+ }
+
+ protected void updateViewAppearance() {
+ if (mBinding.isBound()) {
+ setNameTextView();
+
+ final String details = mBinding.getData().getDetails();
+ if (TextUtils.isEmpty(details)) {
+ mDetailsTextView.setVisibility(GONE);
+ } else {
+ mDetailsTextView.setVisibility(VISIBLE);
+ mDetailsTextView.setText(details);
+ }
+
+ mContactIconView.setImageResourceUri(mBinding.getData().getAvatarUri(),
+ mBinding.getData().getContactId(), mBinding.getData().getLookupKey(),
+ mBinding.getData().getNormalizedDestination());
+ } else {
+ mNameTextView.setText("");
+ mContactIconView.setImageResourceUri(null);
+ }
+ }
+
+ private void setNameTextView() {
+ final String displayName = getDisplayName();
+ if (TextUtils.isEmpty(displayName)) {
+ mNameTextView.setVisibility(GONE);
+ } else {
+ mNameTextView.setVisibility(VISIBLE);
+ mNameTextView.setText(displayName);
+ }
+ }
+
+ @Override
+ public void onPersonDataUpdated(final PersonItemData data) {
+ mBinding.ensureBound(data);
+ updateViewAppearance();
+ }
+
+ @Override
+ public void onPersonDataFailed(final PersonItemData data, final Exception exception) {
+ mBinding.ensureBound(data);
+ updateViewAppearance();
+ }
+
+ public Intent getClickIntent() {
+ return mBinding.getData().getClickIntent();
+ }
+}
diff --git a/src/com/android/messaging/ui/PlaceholderInsetDrawable.java b/src/com/android/messaging/ui/PlaceholderInsetDrawable.java
new file mode 100644
index 0000000..dda2e7b
--- /dev/null
+++ b/src/com/android/messaging/ui/PlaceholderInsetDrawable.java
@@ -0,0 +1,72 @@
+/*
+ * 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.messaging.ui;
+
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.InsetDrawable;
+
+/**
+ * A "placeholder" drawable that has the same sizing properties as the real UI element it
+ * replaces.
+ *
+ * This is an InsetDrawable that takes a placeholder drawable (an animation list, or simply
+ * a color drawable) and place it in the center of the inset drawable that's sized to the
+ * requested source width and height of the image that it replaces. Unlike the base
+ * InsetDrawable, this implementation returns the true width and height of the real image
+ * that it's placeholding, instead of the intrinsic size of the contained drawable, so that
+ * when used in an ImageView, it may be positioned/scaled/cropped the same way the real
+ * image is.
+ */
+public class PlaceholderInsetDrawable extends InsetDrawable {
+ // The dimensions of the real image that this drawable is replacing.
+ private final int mSourceWidth;
+ private final int mSourceHeight;
+
+ /**
+ * Given a source drawable, wraps it around in this placeholder drawable by placing the
+ * drawable at the center of the container if possible (or fill the container if the
+ * drawable doesn't have intrinsic size such as color drawable).
+ */
+ public static PlaceholderInsetDrawable fromDrawable(final Drawable drawable,
+ final int sourceWidth, final int sourceHeight) {
+ final int drawableWidth = drawable.getIntrinsicWidth();
+ final int drawableHeight = drawable.getIntrinsicHeight();
+ final int insetHorizontal = drawableWidth < 0 || drawableWidth > sourceWidth ?
+ 0 : (sourceWidth - drawableWidth) / 2;
+ final int insetVertical = drawableHeight < 0 || drawableHeight > sourceHeight ?
+ 0 : (sourceHeight - drawableHeight) / 2;
+ return new PlaceholderInsetDrawable(drawable, insetHorizontal, insetVertical,
+ insetHorizontal, insetVertical, sourceWidth, sourceHeight);
+ }
+
+ private PlaceholderInsetDrawable(final Drawable drawable, final int insetLeft,
+ final int insetTop, final int insetRight, final int insetBottom,
+ final int sourceWidth, final int sourceHeight) {
+ super(drawable, insetLeft, insetTop, insetRight, insetBottom);
+ mSourceWidth = sourceWidth;
+ mSourceHeight = sourceHeight;
+ }
+
+ @Override
+ public int getIntrinsicWidth() {
+ return mSourceWidth;
+ }
+
+ @Override
+ public int getIntrinsicHeight() {
+ return mSourceHeight;
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/ui/PlainTextEditText.java b/src/com/android/messaging/ui/PlainTextEditText.java
new file mode 100644
index 0000000..8d6e784
--- /dev/null
+++ b/src/com/android/messaging/ui/PlainTextEditText.java
@@ -0,0 +1,85 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.EditText;
+
+/**
+ * We want the EditText used in Conversations to convert text to plain text on paste. This
+ * conversion would happen anyway on send, so without this class it could appear to the user
+ * that we would send e.g. bold or italic formatting, but in the sent message it would just be
+ * plain text.
+ */
+public class PlainTextEditText extends EditText {
+ private static final char OBJECT_UNICODE = '\uFFFC';
+
+ public PlainTextEditText(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ // Intercept and modify the paste event. Let everything else through unchanged.
+ @Override
+ public boolean onTextContextMenuItem(final int id) {
+ if (id == android.R.id.paste) {
+ // We can use this to know where the text position was originally before we pasted
+ final int selectionStartPrePaste = getSelectionStart();
+
+ // Let the EditText's normal paste routine fire, then modify the content after.
+ // This is simpler than re-implementing the paste logic, which we'd have to do
+ // if we want to get the text from the clipboard ourselves and then modify it.
+
+ final boolean result = super.onTextContextMenuItem(id);
+ CharSequence text = getText();
+ int selectionStart = getSelectionStart();
+ int selectionEnd = getSelectionEnd();
+
+ // There is an option in the Chrome mobile app to copy image; however, instead of the
+ // image in the form of the uri, Chrome gives us the html source for the image, which
+ // the platform paste code turns into the unicode object character. The below section
+ // of code looks for that edge case and replaces it with the url for the image.
+ final int startIndex = selectionStart - 1;
+ final int pasteStringLength = selectionStart - selectionStartPrePaste;
+ // Only going to handle the case where the pasted object is the image
+ if (pasteStringLength == 1 && text.charAt(startIndex) == OBJECT_UNICODE) {
+ final ClipboardManager clipboard =
+ (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ final ClipData clip = clipboard.getPrimaryClip();
+ if (clip != null) {
+ ClipData.Item item = clip.getItemAt(0);
+ StringBuilder sb = new StringBuilder(text);
+ final String url = item.getText().toString();
+ sb.replace(selectionStartPrePaste, selectionStart, url);
+ text = sb.toString();
+ selectionStart = selectionStartPrePaste + url.length();
+ selectionEnd = selectionStart;
+ }
+ }
+
+ // This removes the formatting due to the conversion to string.
+ setText(text.toString(), BufferType.EDITABLE);
+
+ // Restore the cursor selection state.
+ setSelection(selectionStart, selectionEnd);
+ return result;
+ } else {
+ return super.onTextContextMenuItem(id);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/PlaybackStateView.java b/src/com/android/messaging/ui/PlaybackStateView.java
new file mode 100644
index 0000000..8d9aac7
--- /dev/null
+++ b/src/com/android/messaging/ui/PlaybackStateView.java
@@ -0,0 +1,43 @@
+/*
+ * 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.messaging.ui;
+
+/**
+ * An interface for a UI element ("View") that reflects the playback state of a piece of media
+ * content. It needs to support the ability to take common playback commands (play, pause, stop,
+ * restart) and reflect the state in UI (through timer or progress bar etc.)
+ */
+public interface PlaybackStateView {
+ /**
+ * Restart the playback.
+ */
+ void restart();
+
+ /**
+ * Reset ("stop") the playback to the starting position.
+ */
+ void reset();
+
+ /**
+ * Resume the playback, or start it if it hasn't been started yet.
+ */
+ void resume();
+
+ /**
+ * Pause the playback.
+ */
+ void pause();
+}
diff --git a/src/com/android/messaging/ui/RemoteInputEntrypointActivity.java b/src/com/android/messaging/ui/RemoteInputEntrypointActivity.java
new file mode 100644
index 0000000..c731164
--- /dev/null
+++ b/src/com/android/messaging/ui/RemoteInputEntrypointActivity.java
@@ -0,0 +1,58 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.telephony.TelephonyManager;
+
+import com.android.messaging.datamodel.NoConfirmationSmsSendService;
+import com.android.messaging.util.LogUtil;
+
+public class RemoteInputEntrypointActivity extends BaseBugleActivity {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ Intent intent = getIntent();
+ if (intent == null) {
+ LogUtil.w(TAG, "No intent attached");
+ setResult(RESULT_CANCELED);
+ finish();
+ return;
+ }
+
+ // Perform some action depending on the intent
+ String action = intent.getAction();
+ if (Intent.ACTION_SENDTO.equals(action)) {
+ // Build and send the intent
+ final Intent sendIntent = new Intent(this, NoConfirmationSmsSendService.class);
+ sendIntent.setAction(TelephonyManager.ACTION_RESPOND_VIA_MESSAGE);
+ sendIntent.putExtras(intent);
+ // Wear apparently passes all of its extras via the clip data. Must pass it along.
+ sendIntent.setClipData(intent.getClipData());
+ startService(sendIntent);
+ setResult(RESULT_OK);
+ } else {
+ LogUtil.w(TAG, "Unrecognized intent action: " + action);
+ setResult(RESULT_CANCELED);
+ }
+ // This activity should never stick around after processing the intent
+ finish();
+ }
+}
diff --git a/src/com/android/messaging/ui/SmsStorageLowWarningActivity.java b/src/com/android/messaging/ui/SmsStorageLowWarningActivity.java
new file mode 100644
index 0000000..6b3e84b
--- /dev/null
+++ b/src/com/android/messaging/ui/SmsStorageLowWarningActivity.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.messaging.ui;
+
+import android.app.FragmentTransaction;
+import android.os.Bundle;
+
+/**
+ * Activity to contain the dialog of warning sms storage low.
+ */
+public class SmsStorageLowWarningActivity extends BaseBugleFragmentActivity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ SmsStorageLowWarningFragment fragment =
+ SmsStorageLowWarningFragment.newInstance();
+ ft.add(fragment, null/*tag*/);
+ ft.commit();
+ }
+}
diff --git a/src/com/android/messaging/ui/SmsStorageLowWarningFragment.java b/src/com/android/messaging/ui/SmsStorageLowWarningFragment.java
new file mode 100644
index 0000000..3ebfdcf
--- /dev/null
+++ b/src/com/android/messaging/ui/SmsStorageLowWarningFragment.java
@@ -0,0 +1,267 @@
+/*
+ * 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.messaging.ui;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.app.Fragment;
+import android.app.FragmentTransaction;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.action.HandleLowStorageAction;
+import com.android.messaging.sms.SmsReleaseStorage;
+import com.android.messaging.sms.SmsReleaseStorage.Duration;
+import com.android.messaging.sms.SmsStorageStatusManager;
+import com.android.messaging.util.Assert;
+import com.google.common.collect.Lists;
+
+import java.util.List;
+
+/**
+ * Dialog to show the sms storage low warning
+ */
+public class SmsStorageLowWarningFragment extends Fragment {
+ private SmsStorageLowWarningFragment() {
+ }
+
+ public static SmsStorageLowWarningFragment newInstance() {
+ return new SmsStorageLowWarningFragment();
+ }
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final FragmentTransaction ft = getFragmentManager().beginTransaction();
+ final ChooseActionDialogFragment dialog = ChooseActionDialogFragment.newInstance();
+ dialog.setTargetFragment(this, 0/*requestCode*/);
+ dialog.show(ft, null/*tag*/);
+ }
+
+ /**
+ * Perform confirm action for a specific action
+ *
+ * @param actionIndex
+ */
+ private void confirm(final int actionIndex) {
+ final FragmentTransaction ft = getFragmentManager().beginTransaction();
+ final ConfirmationDialog dialog = ConfirmationDialog.newInstance(actionIndex);
+ dialog.setTargetFragment(this, 0/*requestCode*/);
+ dialog.show(ft, null/*tag*/);
+ }
+
+ /**
+ * The dialog is cancelled at any step
+ */
+ private void cancel() {
+ getActivity().finish();
+ }
+
+ /**
+ * The dialog to show for user to choose what delete actions to take when storage is low
+ */
+ private static class ChooseActionDialogFragment extends DialogFragment {
+ public static ChooseActionDialogFragment newInstance() {
+ return new ChooseActionDialogFragment();
+ }
+
+ @Override
+ public Dialog onCreateDialog(final Bundle savedInstanceState) {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+
+ final LayoutInflater inflater = getActivity().getLayoutInflater();
+ final View dialogLayout = inflater.inflate(
+ R.layout.sms_storage_low_warning_dialog, null);
+ final ListView actionListView = (ListView) dialogLayout.findViewById(
+ R.id.free_storage_action_list);
+ final List<String> actions = loadFreeStorageActions(getActivity().getResources());
+ final ActionListAdapter listAdapter = new ActionListAdapter(getActivity(), actions);
+ actionListView.setAdapter(listAdapter);
+
+ builder.setTitle(R.string.sms_storage_low_title)
+ .setView(dialogLayout)
+ .setNegativeButton(R.string.ignore, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.cancel();
+ }
+ });
+
+ final Dialog dialog = builder.create();
+ dialog.setCanceledOnTouchOutside(false);
+ return dialog;
+ }
+
+ @Override
+ public void onCancel(final DialogInterface dialog) {
+ ((SmsStorageLowWarningFragment) getTargetFragment()).cancel();
+ }
+
+ private class ActionListAdapter extends ArrayAdapter<String> {
+ public ActionListAdapter(final Context context, final List<String> actions) {
+ super(context, R.layout.sms_free_storage_action_item_view, actions);
+ }
+
+ @Override
+ public View getView(final int position, final View view, final ViewGroup parent) {
+ TextView actionItemView;
+ if (view == null || !(view instanceof TextView)) {
+ final LayoutInflater inflater = LayoutInflater.from(getContext());
+ actionItemView = (TextView) inflater.inflate(
+ R.layout.sms_free_storage_action_item_view, parent, false);
+ } else {
+ actionItemView = (TextView) view;
+ }
+
+ final String action = getItem(position);
+ actionItemView.setText(action);
+ actionItemView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ dismiss();
+ ((SmsStorageLowWarningFragment) getTargetFragment()).confirm(position);
+ }
+ });
+ return actionItemView;
+ }
+ }
+ }
+
+ private static final String KEY_ACTION_INDEX = "action_index";
+
+ /**
+ * The dialog to confirm user's delete action
+ */
+ private static class ConfirmationDialog extends DialogFragment {
+ private Duration mDuration;
+ private String mDurationString;
+
+ public static ConfirmationDialog newInstance(final int actionIndex) {
+ final ConfirmationDialog dialog = new ConfirmationDialog();
+ final Bundle args = new Bundle();
+ args.putInt(KEY_ACTION_INDEX, actionIndex);
+ dialog.setArguments(args);
+ return dialog;
+ }
+
+ @Override
+ public void onCancel(final DialogInterface dialog) {
+ ((SmsStorageLowWarningFragment) getTargetFragment()).cancel();
+ }
+
+ @Override
+ public Dialog onCreateDialog(final Bundle savedInstanceState) {
+ mDuration = SmsReleaseStorage.parseMessageRetainingDuration();
+ mDurationString = SmsReleaseStorage.getMessageRetainingDurationString(mDuration);
+
+ final int actionIndex = getArguments().getInt(KEY_ACTION_INDEX);
+ if (actionIndex < 0 || actionIndex > 1) {
+ return null;
+ }
+ final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setTitle(R.string.sms_storage_low_title)
+ .setMessage(getConfirmDialogMessage(actionIndex))
+ .setNegativeButton(android.R.string.cancel,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int button) {
+ dismiss();
+ ((SmsStorageLowWarningFragment) getTargetFragment()).cancel();
+ }
+ })
+ .setPositiveButton(android.R.string.ok,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int button) {
+ dismiss();
+ handleAction(actionIndex);
+ getActivity().finish();
+ SmsStorageStatusManager.cancelStorageLowNotification();
+ }
+ });
+ return builder.create();
+ }
+
+ private void handleAction(final int actionIndex) {
+ final long durationInMillis =
+ SmsReleaseStorage.durationToTimeInMillis(mDuration);
+ switch (actionIndex) {
+ case 0:
+ HandleLowStorageAction.handleDeleteMediaMessages(durationInMillis);
+ break;
+
+ case 1:
+ HandleLowStorageAction.handleDeleteOldMessages(durationInMillis);
+ break;
+
+ default:
+ Assert.fail("Unsupported action");
+ break;
+ }
+ }
+
+ /**
+ * Get the confirm dialog text for a specific delete action
+ * @param index The action index
+ * @return
+ */
+ private String getConfirmDialogMessage(final int index) {
+ switch (index) {
+ case 0:
+ return getString(R.string.delete_all_media_confirmation, mDurationString);
+ case 1:
+ return getString(R.string.delete_oldest_messages_confirmation, mDurationString);
+ case 2:
+ return getString(R.string.auto_delete_oldest_messages_confirmation,
+ mDurationString);
+ }
+ throw new IllegalArgumentException(
+ "SmsStorageLowWarningFragment: invalid action index " + index);
+ }
+ }
+
+ /**
+ * Load the text of delete message actions
+ *
+ * @param resources
+ * @return
+ */
+ private static List<String> loadFreeStorageActions(final Resources resources) {
+ final Duration duration = SmsReleaseStorage.parseMessageRetainingDuration();
+ final String durationString = SmsReleaseStorage.getMessageRetainingDurationString(duration);
+ final List<String> actions = Lists.newArrayList();
+ actions.add(resources.getString(R.string.delete_all_media));
+ actions.add(resources.getString(R.string.delete_oldest_messages, durationString));
+
+ // TODO: Auto-purging is disabled for Bugle V1.
+ // actions.add(resources.getString(R.string.auto_delete_oldest_messages, durationString));
+ return actions;
+ }
+}
diff --git a/src/com/android/messaging/ui/SnackBar.java b/src/com/android/messaging/ui/SnackBar.java
new file mode 100644
index 0000000..a278040
--- /dev/null
+++ b/src/com/android/messaging/ui/SnackBar.java
@@ -0,0 +1,314 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup.MarginLayoutParams;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.util.Assert;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SnackBar {
+ public static final int LONG_DURATION_IN_MS = 5000;
+ public static final int SHORT_DURATION_IN_MS = 1000;
+ public static final int MAX_DURATION_IN_MS = 10000;
+
+ public interface SnackBarListener {
+ void onActionClick();
+ }
+
+ /**
+ * Defines an action to be performed when the user clicks on the action button on the snack bar
+ */
+ public static class Action {
+ private final Runnable mActionRunnable;
+ private final String mActionLabel;
+
+ public final static int SNACK_BAR_UNDO = 0;
+ public final static int SNACK_BAR_RETRY = 1;
+
+ private Action(@Nullable Runnable actionRunnable, @Nullable String actionLabel) {
+ mActionRunnable = actionRunnable;
+ mActionLabel = actionLabel;
+ }
+
+ Runnable getActionRunnable() {
+ return mActionRunnable;
+ }
+
+ String getActionLabel() {
+ return mActionLabel;
+ }
+
+ public static Action createUndoAction(final Runnable undoRunnable) {
+ return createCustomAction(undoRunnable, Factory.get().getApplicationContext()
+ .getString(R.string.snack_bar_undo));
+ }
+
+ public static Action createRetryAction(final Runnable retryRunnable) {
+ return createCustomAction(retryRunnable, Factory.get().getApplicationContext()
+ .getString(R.string.snack_bar_retry));
+ }
+
+
+ public static Action createCustomAction(final Runnable runnable, final String actionLabel) {
+ return new Action(runnable, actionLabel);
+ }
+ }
+
+ /**
+ * Defines the placement of the snack bar (e.g. anchored view, anchor gravity).
+ */
+ public static class Placement {
+ private final View mAnchorView;
+ private final boolean mAnchorAbove;
+
+ private Placement(@NonNull final View anchorView, final boolean anchorAbove) {
+ Assert.notNull(anchorView);
+ mAnchorView = anchorView;
+ mAnchorAbove = anchorAbove;
+ }
+
+ public View getAnchorView() {
+ return mAnchorView;
+ }
+
+ public boolean getAnchorAbove() {
+ return mAnchorAbove;
+ }
+
+ /**
+ * Anchor the snack bar above the given {@code anchorView}.
+ */
+ public static Placement above(final View anchorView) {
+ return new Placement(anchorView, true);
+ }
+
+ /**
+ * Anchor the snack bar below the given {@code anchorView}.
+ */
+ public static Placement below(final View anchorView) {
+ return new Placement(anchorView, false);
+ }
+ }
+
+ public static class Builder {
+ private static final List<SnackBarInteraction> NO_INTERACTIONS =
+ new ArrayList<SnackBarInteraction>();
+
+ private final Context mContext;
+ private final SnackBarManager mSnackBarManager;
+
+ private String mSnackBarMessage;
+ private int mDuration = LONG_DURATION_IN_MS;
+ private List<SnackBarInteraction> mInteractions = NO_INTERACTIONS;
+ private Action mAction;
+ private Placement mPlacement;
+ // The parent view is only used to get a window token and doesn't affect the layout
+ private View mParentView;
+
+ public Builder(final SnackBarManager snackBarManager, final View parentView) {
+ Assert.notNull(snackBarManager);
+ Assert.notNull(parentView);
+ mSnackBarManager = snackBarManager;
+ mContext = parentView.getContext();
+ mParentView = parentView;
+ }
+
+ public Builder setText(final String snackBarMessage) {
+ Assert.isTrue(!TextUtils.isEmpty(snackBarMessage));
+ mSnackBarMessage = snackBarMessage;
+ return this;
+ }
+
+ public Builder setAction(final Action action) {
+ mAction = action;
+ return this;
+ }
+
+ /**
+ * Sets the duration to show this toast for in milliseconds.
+ */
+ public Builder setDuration(final int duration) {
+ Assert.isTrue(0 < duration && duration < MAX_DURATION_IN_MS);
+ mDuration = duration;
+ return this;
+ }
+
+ /**
+ * Sets the components that this toast's animation will interact with. These components may
+ * be animated to make room for the toast.
+ */
+ public Builder withInteractions(final List<SnackBarInteraction> interactions) {
+ mInteractions = interactions;
+ return this;
+ }
+
+ /**
+ * Place the snack bar with the given placement requirement.
+ */
+ public Builder withPlacement(final Placement placement) {
+ Assert.isNull(mPlacement);
+ mPlacement = placement;
+ return this;
+ }
+
+ public SnackBar build() {
+ return new SnackBar(this);
+ }
+
+ public void show() {
+ mSnackBarManager.show(build());
+ }
+ }
+
+ private final View mRootView;
+ private final Context mContext;
+ private final View mSnackBarView;
+ private final String mText;
+ private final int mDuration;
+ private final List<SnackBarInteraction> mInteractions;
+ private final Action mAction;
+ private final Placement mPlacement;
+ private final TextView mActionTextView;
+ private final TextView mMessageView;
+ private final FrameLayout mMessageWrapper;
+ private final View mParentView;
+
+ private SnackBarListener mListener;
+
+ private SnackBar(final Builder builder) {
+ mContext = builder.mContext;
+ mRootView = LayoutInflater.from(mContext).inflate(R.layout.snack_bar,
+ null /* WindowManager will show this in show() below */);
+ mSnackBarView = mRootView.findViewById(R.id.snack_bar);
+ mText = builder.mSnackBarMessage;
+ mDuration = builder.mDuration;
+ mAction = builder.mAction;
+ mPlacement = builder.mPlacement;
+ mParentView = builder.mParentView;
+ if (builder.mInteractions == null) {
+ mInteractions = new ArrayList<SnackBarInteraction>();
+ } else {
+ mInteractions = builder.mInteractions;
+ }
+
+ mActionTextView = (TextView) mRootView.findViewById(R.id.snack_bar_action);
+ mMessageView = (TextView) mRootView.findViewById(R.id.snack_bar_message);
+ mMessageWrapper = (FrameLayout) mRootView.findViewById(R.id.snack_bar_message_wrapper);
+
+ setUpButton();
+ setUpTextLines();
+ }
+
+ public Context getContext() {
+ return mContext;
+ }
+
+ public View getRootView() {
+ return mRootView;
+ }
+
+ public View getParentView() {
+ return mParentView;
+ }
+
+ public View getSnackBarView() {
+ return mSnackBarView;
+ }
+
+ public String getMessageText() {
+ return mText;
+ }
+
+ public String getActionLabel() {
+ if (mAction == null) {
+ return null;
+ }
+ return mAction.getActionLabel();
+ }
+
+ public int getDuration() {
+ return mDuration;
+ }
+
+ public Placement getPlacement() {
+ return mPlacement;
+ }
+
+ public List<SnackBarInteraction> getInteractions() {
+ return mInteractions;
+ }
+
+ public void setEnabled(final boolean enabled) {
+ mActionTextView.setClickable(enabled);
+ }
+
+ public void setListener(final SnackBarListener snackBarListener) {
+ mListener = snackBarListener;
+ }
+
+ private void setUpButton() {
+ if (mAction == null || mAction.getActionRunnable() == null) {
+ mActionTextView.setVisibility(View.GONE);
+ // In the XML layout we add left/right padding to the button to add space between
+ // the message text and the button and on the right side we add padding to put space
+ // between the button and the edge of the snack bar. This is so the button can use the
+ // padding area as part of it's click target. Since we have no button, we need to put
+ // some margin on the right side. While the left margin is already set on the wrapper,
+ // we're setting it again to not have to make a special case for RTL.
+ final MarginLayoutParams lp = (MarginLayoutParams) mMessageWrapper.getLayoutParams();
+ final int leftRightMargin = mContext.getResources().getDimensionPixelSize(
+ R.dimen.snack_bar_left_right_margin);
+ lp.leftMargin = leftRightMargin;
+ lp.rightMargin = leftRightMargin;
+ mMessageWrapper.setLayoutParams(lp);
+ } else {
+ mActionTextView.setVisibility(View.VISIBLE);
+ mActionTextView.setText(mAction.getActionLabel());
+ mActionTextView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ mAction.getActionRunnable().run();
+ if (mListener != null) {
+ mListener.onActionClick();
+ }
+ }
+ });
+ }
+ }
+
+ private void setUpTextLines() {
+ if (mText == null) {
+ mMessageView.setVisibility(View.GONE);
+ } else {
+ mMessageView.setVisibility(View.VISIBLE);
+ mMessageView.setText(mText);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/SnackBarInteraction.java b/src/com/android/messaging/ui/SnackBarInteraction.java
new file mode 100644
index 0000000..f723caa
--- /dev/null
+++ b/src/com/android/messaging/ui/SnackBarInteraction.java
@@ -0,0 +1,67 @@
+/*
+ * 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.messaging.ui;
+
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewPropertyAnimator;
+
+import com.google.common.base.Preconditions;
+
+/**
+ * An interface that defines how a component can be animated with an {@link SnackBar}.
+ */
+public interface SnackBarInteraction {
+ /**
+ * Returns the animator that will be run in reaction to the given SnackBar being shown.
+ *
+ * Implementations may return null here if it determines that the given SnackBar does not need
+ * to animate this component.
+ */
+ ViewPropertyAnimator animateOnSnackBarShow(SnackBar snackBar);
+
+ /**
+ * Returns the animator that will be run in reaction to the given SnackBar being dismissed.
+ *
+ * Implementations may return null here if it determines that the given SnackBar does not need
+ * to animate this component.
+ */
+ ViewPropertyAnimator animateOnSnackBarDismiss(SnackBar snackBar);
+
+ /**
+ * A basic implementation of {@link SnackBarInteraction} that assumes that the
+ * {@link SnackBar} is always shown with {@link Gravity#BOTTOM} and that the provided View will
+ * always need to be translated up to make room for the SnackBar.
+ */
+ public static class BasicSnackBarInteraction implements SnackBarInteraction {
+ private final View mView;
+
+ public BasicSnackBarInteraction(final View view) {
+ mView = Preconditions.checkNotNull(view);
+ }
+
+ @Override
+ public ViewPropertyAnimator animateOnSnackBarShow(final SnackBar snackBar) {
+ final View rootView = snackBar.getRootView();
+ return mView.animate().translationY(-rootView.getMeasuredHeight());
+ }
+
+ @Override
+ public ViewPropertyAnimator animateOnSnackBarDismiss(final SnackBar snackBar) {
+ return mView.animate().translationY(0);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/ui/SnackBarManager.java b/src/com/android/messaging/ui/SnackBarManager.java
new file mode 100644
index 0000000..e107999
--- /dev/null
+++ b/src/com/android/messaging/ui/SnackBarManager.java
@@ -0,0 +1,365 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.os.Handler;
+import android.text.TextUtils;
+import android.util.DisplayMetrics;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.MeasureSpec;
+import android.view.View.OnTouchListener;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import android.view.ViewPropertyAnimator;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
+import android.view.WindowManager;
+import android.widget.PopupWindow;
+import android.widget.PopupWindow.OnDismissListener;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.ui.SnackBar.Placement;
+import com.android.messaging.ui.SnackBar.SnackBarListener;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.TextUtil;
+import com.android.messaging.util.UiUtils;
+import com.google.common.base.Joiner;
+
+import java.util.List;
+
+public class SnackBarManager {
+
+ private static SnackBarManager sInstance;
+
+ public static SnackBarManager get() {
+ if (sInstance == null) {
+ synchronized (SnackBarManager.class) {
+ if (sInstance == null) {
+ sInstance = new SnackBarManager();
+ }
+ }
+ }
+ return sInstance;
+ }
+
+ private final Runnable mDismissRunnable = new Runnable() {
+ @Override
+ public void run() {
+ dismiss();
+ }
+ };
+
+ private final OnTouchListener mDismissOnTouchListener = new OnTouchListener() {
+ @Override
+ public boolean onTouch(final View view, final MotionEvent event) {
+ // Dismiss the {@link SnackBar} but don't consume the event.
+ dismiss();
+ return false;
+ }
+ };
+
+ private final SnackBarListener mDismissOnUserTapListener = new SnackBarListener() {
+ @Override
+ public void onActionClick() {
+ dismiss();
+ }
+ };
+
+ private final int mTranslationDurationMs;
+ private final Handler mHideHandler;
+
+ private SnackBar mCurrentSnackBar;
+ private SnackBar mLatestSnackBar;
+ private SnackBar mNextSnackBar;
+ private boolean mIsCurrentlyDismissing;
+ private PopupWindow mPopupWindow;
+
+ private SnackBarManager() {
+ mTranslationDurationMs = Factory.get().getApplicationContext().getResources().getInteger(
+ R.integer.snackbar_translation_duration_ms);
+ mHideHandler = new Handler();
+ }
+
+ public SnackBar getLatestSnackBar() {
+ return mLatestSnackBar;
+ }
+
+ public SnackBar.Builder newBuilder(final View parentView) {
+ return new SnackBar.Builder(this, parentView);
+ }
+
+ /**
+ * The given snackBar is not guaranteed to be shown. If the previous snackBar is animating away,
+ * and another snackBar is requested to show after this one, this snackBar will be skipped.
+ */
+ public void show(final SnackBar snackBar) {
+ Assert.notNull(snackBar);
+
+ if (mCurrentSnackBar != null) {
+ LogUtil.d(LogUtil.BUGLE_TAG, "Showing snack bar, but currentSnackBar was not null.");
+
+ // Dismiss the current snack bar. That will cause the next snack bar to be shown on
+ // completion.
+ mNextSnackBar = snackBar;
+ mLatestSnackBar = snackBar;
+ dismiss();
+ return;
+ }
+
+ mCurrentSnackBar = snackBar;
+ mLatestSnackBar = snackBar;
+
+ // We want to know when either button was tapped so we can dismiss.
+ snackBar.setListener(mDismissOnUserTapListener);
+
+ // Cancel previous dismisses & set dismiss for the delay time.
+ mHideHandler.removeCallbacks(mDismissRunnable);
+ mHideHandler.postDelayed(mDismissRunnable, snackBar.getDuration());
+
+ snackBar.setEnabled(false);
+
+ // For some reason, the addView function does not respect layoutParams.
+ // We need to explicitly set it first here.
+ final View rootView = snackBar.getRootView();
+
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
+ LogUtil.d(LogUtil.BUGLE_TAG, "Showing snack bar: " + snackBar);
+ }
+ // Measure the snack bar root view so we know how much to translate by.
+ measureSnackBar(snackBar);
+ mPopupWindow = new PopupWindow(snackBar.getContext());
+ mPopupWindow.setWidth(LayoutParams.MATCH_PARENT);
+ mPopupWindow.setHeight(LayoutParams.WRAP_CONTENT);
+ mPopupWindow.setBackgroundDrawable(null);
+ mPopupWindow.setContentView(rootView);
+ final Placement placement = snackBar.getPlacement();
+ if (placement == null) {
+ mPopupWindow.showAtLocation(
+ snackBar.getParentView(), Gravity.BOTTOM | Gravity.START,
+ 0, getScreenBottomOffset(snackBar));
+ } else {
+ final View anchorView = placement.getAnchorView();
+
+ // You'd expect PopupWindow.showAsDropDown to ensure the popup moves with the anchor
+ // view, which it does for scrolling, but not layout changes, so we have to manually
+ // update while the snackbar is showing
+ final OnGlobalLayoutListener listener = new OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ mPopupWindow.update(anchorView, 0, getRelativeOffset(snackBar),
+ anchorView.getWidth(), LayoutParams.WRAP_CONTENT);
+ }
+ };
+ anchorView.getViewTreeObserver().addOnGlobalLayoutListener(listener);
+ mPopupWindow.setOnDismissListener(new OnDismissListener() {
+ @Override
+ public void onDismiss() {
+ anchorView.getViewTreeObserver().removeOnGlobalLayoutListener(listener);
+ }
+ });
+ mPopupWindow.showAsDropDown(anchorView, 0, getRelativeOffset(snackBar));
+ }
+
+
+ // Animate the toast bar into view.
+ placeSnackBarOffScreen(snackBar);
+ animateSnackBarOnScreen(snackBar).withEndAction(new Runnable() {
+ @Override
+ public void run() {
+ mCurrentSnackBar.setEnabled(true);
+ makeCurrentSnackBarDismissibleOnTouch();
+ // Fire an accessibility event as needed
+ String snackBarText = snackBar.getMessageText();
+ if (!TextUtils.isEmpty(snackBarText) &&
+ TextUtils.getTrimmedLength(snackBarText) > 0) {
+ snackBarText = snackBarText.trim();
+ final String snackBarActionText = snackBar.getActionLabel();
+ if (!TextUtil.isAllWhitespace(snackBarActionText)) {
+ snackBarText = Joiner.on(", ").join(snackBarText, snackBarActionText);
+ }
+ AccessibilityUtil.announceForAccessibilityCompat(snackBar.getSnackBarView(),
+ null /*accessibilityManager*/, snackBarText);
+ }
+ }
+ });
+
+ // Animate any interaction views out of the way.
+ animateInteractionsOnShow(snackBar);
+ }
+
+ /**
+ * Dismisses the current toast that is showing. If there is a toast waiting to be shown, that
+ * toast will be shown when the current one has been dismissed.
+ */
+ public void dismiss() {
+ mHideHandler.removeCallbacks(mDismissRunnable);
+
+ if (mCurrentSnackBar == null || mIsCurrentlyDismissing) {
+ return;
+ }
+
+ final SnackBar snackBar = mCurrentSnackBar;
+
+ LogUtil.d(LogUtil.BUGLE_TAG, "Dismissing snack bar.");
+ mIsCurrentlyDismissing = true;
+
+ snackBar.setEnabled(false);
+
+ // Animate the toast bar down.
+ final View rootView = snackBar.getRootView();
+ animateSnackBarOffScreen(snackBar).withEndAction(new Runnable() {
+ @Override
+ public void run() {
+ rootView.setVisibility(View.GONE);
+ try {
+ mPopupWindow.dismiss();
+ } catch (IllegalArgumentException e) {
+ // PopupWindow.dismiss() will fire an IllegalArgumentException if the activity
+ // has already ended while we were animating
+ }
+
+ mCurrentSnackBar = null;
+ mIsCurrentlyDismissing = false;
+
+ // Show the next toast if one is waiting.
+ if (mNextSnackBar != null) {
+ final SnackBar localNextSnackBar = mNextSnackBar;
+ mNextSnackBar = null;
+ show(localNextSnackBar);
+ }
+ }
+ });
+
+ // Animate any interaction views back.
+ animateInteractionsOnDismiss(snackBar);
+ }
+
+ private void makeCurrentSnackBarDismissibleOnTouch() {
+ // Set touching on the entire view, the {@link SnackBar} itself, as
+ // well as the button's dismiss the toast.
+ mCurrentSnackBar.getRootView().setOnTouchListener(mDismissOnTouchListener);
+ mCurrentSnackBar.getSnackBarView().setOnTouchListener(mDismissOnTouchListener);
+ }
+
+ private void measureSnackBar(final SnackBar snackBar) {
+ final View rootView = snackBar.getRootView();
+ final Point displaySize = new Point();
+ getWindowManager(snackBar.getContext()).getDefaultDisplay().getSize(displaySize);
+ final int widthSpec = ViewGroup.getChildMeasureSpec(
+ MeasureSpec.makeMeasureSpec(displaySize.x, MeasureSpec.EXACTLY),
+ 0, LayoutParams.MATCH_PARENT);
+ final int heightSpec = ViewGroup.getChildMeasureSpec(
+ MeasureSpec.makeMeasureSpec(displaySize.y, MeasureSpec.EXACTLY),
+ 0, LayoutParams.WRAP_CONTENT);
+ rootView.measure(widthSpec, heightSpec);
+ }
+
+ private void placeSnackBarOffScreen(final SnackBar snackBar) {
+ final View rootView = snackBar.getRootView();
+ final View snackBarView = snackBar.getSnackBarView();
+ snackBarView.setTranslationY(rootView.getMeasuredHeight());
+ }
+
+ private ViewPropertyAnimator animateSnackBarOnScreen(final SnackBar snackBar) {
+ final View snackBarView = snackBar.getSnackBarView();
+ return normalizeAnimator(snackBarView.animate()).translationX(0).translationY(0);
+ }
+
+ private ViewPropertyAnimator animateSnackBarOffScreen(final SnackBar snackBar) {
+ final View rootView = snackBar.getRootView();
+ final View snackBarView = snackBar.getSnackBarView();
+ return normalizeAnimator(snackBarView.animate()).translationY(rootView.getHeight());
+ }
+
+ private void animateInteractionsOnShow(final SnackBar snackBar) {
+ final List<SnackBarInteraction> interactions = snackBar.getInteractions();
+ for (final SnackBarInteraction interaction : interactions) {
+ if (interaction != null) {
+ final ViewPropertyAnimator animator = interaction.animateOnSnackBarShow(snackBar);
+ if (animator != null) {
+ normalizeAnimator(animator);
+ }
+ }
+ }
+ }
+
+ private void animateInteractionsOnDismiss(final SnackBar snackBar) {
+ final List<SnackBarInteraction> interactions = snackBar.getInteractions();
+ for (final SnackBarInteraction interaction : interactions) {
+ if (interaction != null) {
+ final ViewPropertyAnimator animator =
+ interaction.animateOnSnackBarDismiss(snackBar);
+ if (animator != null) {
+ normalizeAnimator(animator);
+ }
+ }
+ }
+ }
+
+ private ViewPropertyAnimator normalizeAnimator(final ViewPropertyAnimator animator) {
+ return animator
+ .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR)
+ .setDuration(mTranslationDurationMs);
+ }
+
+ private WindowManager getWindowManager(final Context context) {
+ return (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+ }
+
+ /**
+ * Get the offset from the bottom of the screen where the snack bar should be placed.
+ */
+ private int getScreenBottomOffset(final SnackBar snackBar) {
+ final WindowManager windowManager = getWindowManager(snackBar.getContext());
+ final DisplayMetrics displayMetrics = new DisplayMetrics();
+ if (OsUtil.isAtLeastL()) {
+ windowManager.getDefaultDisplay().getRealMetrics(displayMetrics);
+ } else {
+ windowManager.getDefaultDisplay().getMetrics(displayMetrics);
+ }
+ final int screenHeight = displayMetrics.heightPixels;
+
+ if (OsUtil.isAtLeastL()) {
+ // In L, the navigation bar is included in the space for the popup window, so we have to
+ // offset by the size of the navigation bar
+ final Rect displayRect = new Rect();
+ snackBar.getParentView().getRootView().getWindowVisibleDisplayFrame(displayRect);
+ return screenHeight - displayRect.bottom;
+ }
+
+ return 0;
+ }
+
+ private int getRelativeOffset(final SnackBar snackBar) {
+ final Placement placement = snackBar.getPlacement();
+ Assert.notNull(placement);
+ final View anchorView = placement.getAnchorView();
+ if (placement.getAnchorAbove()) {
+ return -snackBar.getRootView().getMeasuredHeight() - anchorView.getHeight();
+ } else {
+ // Use the default dropdown positioning
+ return 0;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/TestActivity.java b/src/com/android/messaging/ui/TestActivity.java
new file mode 100644
index 0000000..1693660
--- /dev/null
+++ b/src/com/android/messaging/ui/TestActivity.java
@@ -0,0 +1,88 @@
+/*
+ * 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.messaging.ui;
+
+import android.app.Fragment;
+import android.os.Bundle;
+import android.support.v4.app.FragmentActivity;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+
+import com.android.messaging.R;
+import com.android.messaging.util.LogUtil;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * An empty activity that can be used to host a fragment or view for unit testing purposes. Lives in
+ * app code vs test code due to requirement of ActivityInstrumentationTestCase2.
+ */
+public class TestActivity extends FragmentActivity {
+ private FragmentEventListener mFragmentEventListener;
+
+ public interface FragmentEventListener {
+ public void onAttachFragment(Fragment fragment);
+ }
+
+ @Override
+ protected void onCreate(final Bundle bundle) {
+ super.onCreate(bundle);
+
+ if (bundle != null) {
+ // The test case may have configured the fragment, and recreating the activity will
+ // lose that configuration. The real activity is the only activity that would know
+ // how to reapply that configuration.
+ throw new IllegalStateException("TestActivity cannot get recreated");
+ }
+
+ // There is a race condition, but this often makes it possible for tests to run with the
+ // key guard up
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON |
+ WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD |
+ WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED |
+ WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
+
+ setContentView(R.layout.test_activity);
+ }
+
+ @VisibleForTesting
+ public void setFragment(final Fragment fragment) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "TestActivity.setFragment");
+ getFragmentManager()
+ .beginTransaction()
+ .replace(R.id.test_content, fragment)
+ .commit();
+ getFragmentManager().executePendingTransactions();
+ }
+
+ @VisibleForTesting
+ public void setView(final View view) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "TestActivity.setView");
+ ((FrameLayout) findViewById(R.id.test_content)).addView(view);
+ }
+
+ @Override
+ public void onAttachFragment(final Fragment fragment) {
+ if (mFragmentEventListener != null) {
+ mFragmentEventListener.onAttachFragment(fragment);
+ }
+ }
+
+ public void setFragmentEventListener(final FragmentEventListener fragmentEventListener) {
+ mFragmentEventListener = fragmentEventListener;
+ }
+}
diff --git a/src/com/android/messaging/ui/UIIntents.java b/src/com/android/messaging/ui/UIIntents.java
new file mode 100644
index 0000000..e5f8a52
--- /dev/null
+++ b/src/com/android/messaging/ui/UIIntents.java
@@ -0,0 +1,378 @@
+/*
+ * 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.messaging.ui;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.PendingIntent;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Bundle;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.util.ConversationIdSet;
+
+/**
+ * A central repository of Intents used to start activities.
+ */
+public abstract class UIIntents {
+ public static UIIntents get() {
+ return Factory.get().getUIIntents();
+ }
+
+ // Intent extras
+ public static final String UI_INTENT_EXTRA_CONVERSATION_ID = "conversation_id";
+
+ // Sending draft data (from share intent / message forwarding) to the ConversationActivity.
+ public static final String UI_INTENT_EXTRA_DRAFT_DATA = "draft_data";
+
+ // The request code for picking image from the Document picker.
+ public static final int REQUEST_PICK_IMAGE_FROM_DOCUMENT_PICKER = 1400;
+
+ // Indicates what type of notification this applies to (See BugleNotifications:
+ // UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, UPDATE_ALL)
+ public static final String UI_INTENT_EXTRA_NOTIFICATIONS_UPDATE = "notifications_update";
+
+ // Pass a set of conversation id's.
+ public static final String UI_INTENT_EXTRA_CONVERSATION_ID_SET = "conversation_id_set";
+
+ // Sending class zero message to its activity
+ public static final String UI_INTENT_EXTRA_MESSAGE_VALUES = "message_values";
+
+ // For the widget to go to the ConversationList from the Conversation.
+ public static final String UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST = "goto_conv_list";
+
+ // Indicates whether a conversation is launched with custom transition.
+ public static final String UI_INTENT_EXTRA_WITH_CUSTOM_TRANSITION = "with_custom_transition";
+
+ public static final String ACTION_RESET_NOTIFICATIONS =
+ "com.android.messaging.reset_notifications";
+
+ // Sending VCard uri to VCard detail activity
+ public static final String UI_INTENT_EXTRA_VCARD_URI = "vcard_uri";
+
+ public static final String CMAS_COMPONENT = "com.android.cellbroadcastreceiver";
+
+ // Intent action for local broadcast receiver for conversation self id change.
+ public static final String CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION =
+ "conversation_self_id_change";
+
+ // Conversation self id
+ public static final String UI_INTENT_EXTRA_CONVERSATION_SELF_ID = "conversation_self_id";
+
+ // For opening an APN editor on a particular row in the apn database.
+ public static final String UI_INTENT_EXTRA_APN_ROW_ID = "apn_row_id";
+
+ // Subscription id
+ public static final String UI_INTENT_EXTRA_SUB_ID = "sub_id";
+
+ // Per-Subscription setting activity title
+ public static final String UI_INTENT_EXTRA_PER_SUBSCRIPTION_SETTING_TITLE =
+ "per_sub_setting_title";
+
+ // Is application settings launched as the top level settings activity?
+ public static final String UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS = "top_level_settings";
+
+ // Sending attachment uri from widget
+ public static final String UI_INTENT_EXTRA_ATTACHMENT_URI = "attachment_uri";
+
+ // Sending attachment content type from widget
+ public static final String UI_INTENT_EXTRA_ATTACHMENT_TYPE = "attachment_type";
+
+ public static final String ACTION_WIDGET_CONVERSATION =
+ "com.android.messaging.widget_conversation:";
+
+ public static final String UI_INTENT_EXTRA_REQUIRES_MMS = "requires_mms";
+
+ public static final String UI_INTENT_EXTRA_SELF_ID = "self_id";
+
+ // Message position to scroll to.
+ public static final String UI_INTENT_EXTRA_MESSAGE_POSITION = "message_position";
+
+ /**
+ * Launch the permission check activity
+ */
+ public abstract void launchPermissionCheckActivity(final Context context);
+
+ public abstract void launchConversationListActivity(final Context context);
+
+ /**
+ * Launch an activity to show a conversation. This method by default provides no additional
+ * activity options.
+ */
+ public void launchConversationActivity(final Context context,
+ final String conversationId, final MessageData draft) {
+ launchConversationActivity(context, conversationId, draft, null,
+ false /* withCustomTransition */);
+ }
+
+ /**
+ * Launch an activity to show a conversation.
+ */
+ public abstract void launchConversationActivity(final Context context,
+ final String conversationId, final MessageData draft, final Bundle activityOptions,
+ final boolean withCustomTransition);
+
+
+ /**
+ * Launch an activity to show conversation with conversation list in back stack.
+ */
+ public abstract void launchConversationActivityWithParentStack(Context context,
+ String conversationId, String smsBody);
+
+ /**
+ * Launch an activity to show a conversation as a new task.
+ */
+ public abstract void launchConversationActivityNewTask(final Context context,
+ final String conversationId);
+
+ /**
+ * Launch an activity to start a new conversation
+ */
+ public abstract void launchCreateNewConversationActivity(final Context context,
+ final MessageData draft);
+
+ /**
+ * Launch debug activity to set MMS config options.
+ */
+ public abstract void launchDebugMmsConfigActivity(final Context context);
+
+ /**
+ * Launch an activity to change settings.
+ */
+ public abstract void launchSettingsActivity(final Context context);
+
+ /**
+ * Launch an activity to add a contact with a given destination.
+ */
+ public abstract void launchAddContactActivity(final Context context, final String destination);
+
+ /**
+ * Launch an activity to show the document picker to pick an image.
+ * @param fragment the requesting fragment
+ */
+ public abstract void launchDocumentImagePicker(final Fragment fragment);
+
+ /**
+ * Launch an activity to show people & options for a given conversation.
+ */
+ public abstract void launchPeopleAndOptionsActivity(final Activity context,
+ final String conversationId);
+
+ /**
+ * Launch an external activity to handle a phone call
+ * @param phoneNumber the phone number to call
+ * @param clickPosition is the location tapped to start this launch for transition use
+ */
+ public abstract void launchPhoneCallActivity(final Context context, final String phoneNumber,
+ final Point clickPosition);
+
+ /**
+ * Launch an activity to show archived conversations.
+ */
+ public abstract void launchArchivedConversationsActivity(final Context context);
+
+ /**
+ * Launch an activity to show blocked participants.
+ */
+ public abstract void launchBlockedParticipantsActivity(final Context context);
+
+ /**
+ * Launch an activity to show a class zero message
+ */
+ public abstract void launchClassZeroActivity(Context context, ContentValues messageValues);
+
+ /**
+ * Launch an activity to let the user forward a message
+ */
+ public abstract void launchForwardMessageActivity(Context context, MessageData message);
+
+ /**
+ * Launch an activity to show details for a VCard
+ */
+ public abstract void launchVCardDetailActivity(Context context, Uri vcardUri);
+
+ /**
+ * Launch an external activity that handles the intent to add VCard to contacts
+ */
+ public abstract void launchSaveVCardToContactsActivity(Context context, Uri vcardUri);
+
+ /**
+ * Launch an activity to let the user select & unselect the list of attachments to send.
+ */
+ public abstract void launchAttachmentChooserActivity(final Activity activity,
+ final String conversationId, final int requestCode);
+
+ /**
+ * Launch full screen video viewer.
+ */
+ public abstract void launchFullScreenVideoViewer(Context context, Uri videoUri);
+
+ /**
+ * Launch full screen photo viewer.
+ */
+ public abstract void launchFullScreenPhotoViewer(Activity activity, Uri initialPhoto,
+ Rect initialPhotoBounds, Uri photosUri);
+
+ /**
+ * Launch an activity to show general app settings
+ * @param topLevel indicates whether the app settings is launched as the top-level settings
+ * activity (instead of SettingsActivity which shows a collapsed view of the app
+ * settings + one settings item per subscription). This is true when there's only one
+ * active SIM in the system so we can show this activity directly.
+ */
+ public abstract void launchApplicationSettingsActivity(Context context, boolean topLevel);
+
+ /**
+ * Launch an activity to show per-subscription settings
+ */
+ public abstract void launchPerSubscriptionSettingsActivity(Context context, int subId,
+ String settingTitle);
+
+ /**
+ * Get a ACTION_VIEW intent
+ * @param url display the data in the url to users
+ */
+ public abstract Intent getViewUrlIntent(final String url);
+
+ /**
+ * Get an intent to launch the ringtone picker
+ * @param title the title to show in the ringtone picker
+ * @param existingUri the currently set uri
+ * @param defaultUri the default uri if none is currently set
+ * @param toneType type of ringtone to pick, maybe any of RingtoneManager.TYPE_*
+ */
+ public abstract Intent getRingtonePickerIntent(final String title, final Uri existingUri,
+ final Uri defaultUri, final int toneType);
+
+ /**
+ * Get an intent to launch the wireless alert viewer.
+ */
+ public abstract Intent getWirelessAlertsIntent();
+
+ /**
+ * Get an intent to launch the dialog for changing the default SMS App.
+ */
+ public abstract Intent getChangeDefaultSmsAppIntent(final Activity activity);
+
+ /**
+ * Broadcast conversation self id change so it may be reflected in the message compose UI.
+ */
+ public abstract void broadcastConversationSelfIdChange(final Context context,
+ final String conversationId, final String conversationSelfId);
+
+ /**
+ * Get a PendingIntent for starting conversation list from notifications.
+ */
+ public abstract PendingIntent getPendingIntentForConversationListActivity(
+ final Context context);
+
+ /**
+ * Get a PendingIntent for starting conversation list from widget.
+ */
+ public abstract PendingIntent getWidgetPendingIntentForConversationListActivity(
+ final Context context);
+
+ /**
+ * Get a PendingIntent for showing a conversation from notifications.
+ */
+ public abstract PendingIntent getPendingIntentForConversationActivity(final Context context,
+ final String conversationId, final MessageData draft);
+
+ /**
+ * Get an Intent for showing a conversation from the widget.
+ */
+ public abstract Intent getIntentForConversationActivity(final Context context,
+ final String conversationId, final MessageData draft);
+
+ /**
+ * Get a PendingIntent for sending a message to a conversation, without opening the Bugle UI.
+ *
+ * <p>This is intended to be used by the Android Wear companion app when sending transcribed
+ * voice replies.
+ */
+ public abstract PendingIntent getPendingIntentForSendingMessageToConversation(
+ final Context context, final String conversationId, final String selfId,
+ final boolean requiresMms, final int requestCode);
+
+ /**
+ * Get a PendingIntent for clearing notifications.
+ *
+ * <p>This is intended to be used by notifications.
+ */
+ public abstract PendingIntent getPendingIntentForClearingNotifications(final Context context,
+ final int updateTargets, final ConversationIdSet conversationIdSet,
+ final int requestCode);
+
+ /**
+ * Get a PendingIntent for showing low storage notifications.
+ */
+ public abstract PendingIntent getPendingIntentForLowStorageNotifications(final Context context);
+
+ /**
+ * Get a PendingIntent for showing a new message to a secondary user.
+ */
+ public abstract PendingIntent getPendingIntentForSecondaryUserNewMessageNotification(
+ final Context context);
+
+ /**
+ * Get an intent for showing the APN editor.
+ */
+ public abstract Intent getApnEditorIntent(final Context context, final String rowId, int subId);
+
+ /**
+ * Get an intent for showing the APN settings.
+ */
+ public abstract Intent getApnSettingsIntent(final Context context, final int subId);
+
+ /**
+ * Get an intent for showing advanced settings.
+ */
+ public abstract Intent getAdvancedSettingsIntent(final Context context);
+
+ /**
+ * Get an intent for the LaunchConversationActivity.
+ */
+ public abstract Intent getLaunchConversationActivityIntent(final Context context);
+
+ /**
+ * Tell MediaScanner to re-scan the specified volume.
+ */
+ public abstract void kickMediaScanner(final Context context, final String volume);
+
+ /**
+ * Launch to browser for a url.
+ */
+ public abstract void launchBrowserForUrl(final Context context, final String url);
+
+ /**
+ * Get a PendingIntent for the widget conversation template.
+ */
+ public abstract PendingIntent getWidgetPendingIntentForConversationActivity(
+ final Context context, final String conversationId, final int requestCode);
+
+ /**
+ * Get a PendingIntent for the conversation widget configuration activity template.
+ */
+ public abstract PendingIntent getWidgetPendingIntentForConfigurationActivity(
+ final Context context, final int appWidgetId);
+
+}
diff --git a/src/com/android/messaging/ui/UIIntentsImpl.java b/src/com/android/messaging/ui/UIIntentsImpl.java
new file mode 100644
index 0000000..b7db719
--- /dev/null
+++ b/src/com/android/messaging/ui/UIIntentsImpl.java
@@ -0,0 +1,577 @@
+/*
+ * 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.messaging.ui;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.ActivityNotFoundException;
+import android.content.ClipData;
+import android.content.ComponentName;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Intents;
+import android.provider.MediaStore;
+import android.provider.Telephony;
+import android.support.annotation.Nullable;
+import android.support.v4.app.TaskStackBuilder;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+
+import com.android.ex.photo.Intents.PhotoViewIntentBuilder;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.ConversationImagePartsView;
+import com.android.messaging.datamodel.MediaScratchFileProvider;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.receiver.NotificationReceiver;
+import com.android.messaging.sms.MmsSmsUtils;
+import com.android.messaging.ui.appsettings.ApnEditorActivity;
+import com.android.messaging.ui.appsettings.ApnSettingsActivity;
+import com.android.messaging.ui.appsettings.ApplicationSettingsActivity;
+import com.android.messaging.ui.appsettings.PerSubscriptionSettingsActivity;
+import com.android.messaging.ui.appsettings.SettingsActivity;
+import com.android.messaging.ui.attachmentchooser.AttachmentChooserActivity;
+import com.android.messaging.ui.conversation.ConversationActivity;
+import com.android.messaging.ui.conversation.LaunchConversationActivity;
+import com.android.messaging.ui.conversationlist.ArchivedConversationListActivity;
+import com.android.messaging.ui.conversationlist.ConversationListActivity;
+import com.android.messaging.ui.conversationlist.ForwardMessageActivity;
+import com.android.messaging.ui.conversationsettings.PeopleAndOptionsActivity;
+import com.android.messaging.ui.debug.DebugMmsConfigActivity;
+import com.android.messaging.ui.photoviewer.BuglePhotoViewActivity;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.ConversationIdSet;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.UiUtils;
+import com.android.messaging.util.UriUtil;
+
+/**
+ * A central repository of Intents used to start activities.
+ */
+public class UIIntentsImpl extends UIIntents {
+ private static final String CELL_BROADCAST_LIST_ACTIVITY =
+ "com.android.cellbroadcastreceiver.CellBroadcastListActivity";
+ private static final String CALL_TARGET_CLICK_KEY = "touchPoint";
+ private static final String CALL_TARGET_CLICK_EXTRA_KEY =
+ "android.telecom.extra.OUTGOING_CALL_EXTRAS";
+ private static final String MEDIA_SCANNER_CLASS =
+ "com.android.providers.media.MediaScannerService";
+ private static final String MEDIA_SCANNER_PACKAGE = "com.android.providers.media";
+ private static final String MEDIA_SCANNER_SCAN_ACTION = "android.media.IMediaScannerService";
+
+ /**
+ * Get an intent which takes you to a conversation
+ */
+ private Intent getConversationActivityIntent(final Context context,
+ final String conversationId, final MessageData draft,
+ final boolean withCustomTransition) {
+ final Intent intent = new Intent(context, ConversationActivity.class);
+
+ // Always try to reuse the same ConversationActivity in the current task so that we don't
+ // have two conversation activities in the back stack.
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ // Otherwise we're starting a new conversation
+ if (conversationId != null) {
+ intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
+ }
+ if (draft != null) {
+ intent.putExtra(UI_INTENT_EXTRA_DRAFT_DATA, draft);
+
+ // If draft attachments came from an external content provider via a share intent, we
+ // need to propagate the URI permissions through to ConversationActivity. This requires
+ // putting the URIs into the ClipData (setData also works, but accepts only one URI).
+ ClipData clipData = null;
+ for (final MessagePartData partData : draft.getParts()) {
+ if (partData.isAttachment()) {
+ final Uri uri = partData.getContentUri();
+ if (clipData == null) {
+ clipData = ClipData.newRawUri("Attachments", uri);
+ } else {
+ clipData.addItem(new ClipData.Item(uri));
+ }
+ }
+ }
+ if (clipData != null) {
+ intent.setClipData(clipData);
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ }
+ }
+ if (withCustomTransition) {
+ intent.putExtra(UI_INTENT_EXTRA_WITH_CUSTOM_TRANSITION, true);
+ }
+
+ if (!(context instanceof Activity)) {
+ // If the caller supplies an application context, and not an activity context, we must
+ // include this flag
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ }
+ return intent;
+ }
+
+ @Override
+ public void launchPermissionCheckActivity(final Context context) {
+ final Intent intent = new Intent(context, PermissionCheckActivity.class);
+ context.startActivity(intent);
+ }
+
+ /**
+ * Get an intent which takes you to the conversation list
+ */
+ private Intent getConversationListActivityIntent(final Context context) {
+ return new Intent(context, ConversationListActivity.class);
+ }
+
+ @Override
+ public void launchConversationListActivity(final Context context) {
+ final Intent intent = getConversationListActivityIntent(context);
+ context.startActivity(intent);
+ }
+
+ /**
+ * Get an intent which shows the low storage warning activity.
+ */
+ private Intent getSmsStorageLowWarningActivityIntent(final Context context) {
+ return new Intent(context, SmsStorageLowWarningActivity.class);
+ }
+
+ @Override
+ public void launchConversationActivity(final Context context,
+ final String conversationId, final MessageData draft, final Bundle activityOptions,
+ final boolean withCustomTransition) {
+ Assert.isTrue(!withCustomTransition || activityOptions != null);
+ final Intent intent = getConversationActivityIntent(context, conversationId, draft,
+ withCustomTransition);
+ context.startActivity(intent, activityOptions);
+ }
+
+ @Override
+ public void launchConversationActivityNewTask(
+ final Context context, final String conversationId) {
+ final Intent intent = getConversationActivityIntent(context, conversationId, null,
+ false /* withCustomTransition */);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+ }
+
+ @Override
+ public void launchConversationActivityWithParentStack(final Context context,
+ final String conversationId, final String smsBody) {
+ final MessageData messageData = TextUtils.isEmpty(smsBody)
+ ? null
+ : MessageData.createDraftSmsMessage(conversationId, null, smsBody);
+ TaskStackBuilder.create(context)
+ .addNextIntentWithParentStack(
+ getConversationActivityIntent(context, conversationId, messageData,
+ false /* withCustomTransition */))
+ .startActivities();
+ }
+
+ @Override
+ public void launchCreateNewConversationActivity(final Context context,
+ final MessageData draft) {
+ final Intent intent = getConversationActivityIntent(context, null, draft,
+ false /* withCustomTransition */);
+ context.startActivity(intent);
+ }
+
+ @Override
+ public void launchDebugMmsConfigActivity(final Context context) {
+ context.startActivity(new Intent(context, DebugMmsConfigActivity.class));
+ }
+
+ @Override
+ public void launchAddContactActivity(final Context context, final String destination) {
+ final Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT);
+ final String destinationType = MmsSmsUtils.isEmailAddress(destination) ?
+ Intents.Insert.EMAIL : Intents.Insert.PHONE;
+ intent.setType(Contacts.CONTENT_ITEM_TYPE);
+ intent.putExtra(destinationType, destination);
+ startExternalActivity(context, intent);
+ }
+
+ @Override
+ public void launchSettingsActivity(final Context context) {
+ final Intent intent = new Intent(context, SettingsActivity.class);
+ context.startActivity(intent);
+ }
+
+ @Override
+ public void launchArchivedConversationsActivity(final Context context) {
+ final Intent intent = new Intent(context, ArchivedConversationListActivity.class);
+ context.startActivity(intent);
+ }
+
+ @Override
+ public void launchBlockedParticipantsActivity(final Context context) {
+ final Intent intent = new Intent(context, BlockedParticipantsActivity.class);
+ context.startActivity(intent);
+ }
+
+ @Override
+ public void launchDocumentImagePicker(final Fragment fragment) {
+ final Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
+ intent.putExtra(Intent.EXTRA_MIME_TYPES, MessagePartData.ACCEPTABLE_IMAGE_TYPES);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType(ContentType.IMAGE_UNSPECIFIED);
+
+ fragment.startActivityForResult(intent, REQUEST_PICK_IMAGE_FROM_DOCUMENT_PICKER);
+ }
+
+ @Override
+ public void launchPeopleAndOptionsActivity(final Activity activity,
+ final String conversationId) {
+ final Intent intent = new Intent(activity, PeopleAndOptionsActivity.class);
+ intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
+ activity.startActivityForResult(intent, 0);
+ }
+
+ @Override
+ public void launchPhoneCallActivity(final Context context, final String phoneNumber,
+ final Point clickPosition) {
+ final Intent intent = new Intent(Intent.ACTION_CALL,
+ Uri.parse(UriUtil.SCHEME_TEL + phoneNumber));
+ final Bundle extras = new Bundle();
+ extras.putParcelable(CALL_TARGET_CLICK_KEY, clickPosition);
+ intent.putExtra(CALL_TARGET_CLICK_EXTRA_KEY, extras);
+ startExternalActivity(context, intent);
+ }
+
+ @Override
+ public void launchClassZeroActivity(final Context context, final ContentValues messageValues) {
+ final Intent classZeroIntent = new Intent(context, ClassZeroActivity.class)
+ .putExtra(UI_INTENT_EXTRA_MESSAGE_VALUES, messageValues)
+ .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
+ context.startActivity(classZeroIntent);
+ }
+
+ @Override
+ public void launchForwardMessageActivity(final Context context, final MessageData message) {
+ final Intent forwardMessageIntent = new Intent(context, ForwardMessageActivity.class)
+ .putExtra(UI_INTENT_EXTRA_DRAFT_DATA, message);
+ context.startActivity(forwardMessageIntent);
+ }
+
+ @Override
+ public void launchVCardDetailActivity(final Context context, final Uri vcardUri) {
+ final Intent vcardDetailIntent = new Intent(context, VCardDetailActivity.class)
+ .putExtra(UI_INTENT_EXTRA_VCARD_URI, vcardUri);
+ context.startActivity(vcardDetailIntent);
+ }
+
+ @Override
+ public void launchSaveVCardToContactsActivity(final Context context, final Uri vcardUri) {
+ Assert.isTrue(MediaScratchFileProvider.isMediaScratchSpaceUri(vcardUri));
+ final Intent intent = new Intent();
+ intent.setAction(Intent.ACTION_VIEW);
+ intent.setDataAndType(vcardUri, ContentType.TEXT_VCARD.toLowerCase());
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ startExternalActivity(context, intent);
+ }
+
+ @Override
+ public void launchAttachmentChooserActivity(final Activity activity,
+ final String conversationId, final int requestCode) {
+ final Intent intent = new Intent(activity, AttachmentChooserActivity.class);
+ intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
+ activity.startActivityForResult(intent, requestCode);
+ }
+
+ @Override
+ public void launchFullScreenVideoViewer(final Context context, final Uri videoUri) {
+ final Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+
+ // So we don't see "surrounding" images in Gallery
+ intent.putExtra("SingleItemOnly", true);
+ intent.setDataAndType(videoUri, ContentType.VIDEO_UNSPECIFIED);
+ startExternalActivity(context, intent);
+ }
+
+ @Override
+ public void launchFullScreenPhotoViewer(final Activity activity, final Uri initialPhoto,
+ final Rect initialPhotoBounds, final Uri photosUri) {
+ final PhotoViewIntentBuilder builder =
+ com.android.ex.photo.Intents.newPhotoViewIntentBuilder(
+ activity, BuglePhotoViewActivity.class);
+ builder.setPhotosUri(photosUri.toString());
+ builder.setInitialPhotoUri(initialPhoto.toString());
+ builder.setProjection(ConversationImagePartsView.PhotoViewQuery.PROJECTION);
+
+ // Set the location of the imageView so that the photoviewer can animate from that location
+ // to full screen.
+ builder.setScaleAnimation(initialPhotoBounds.left, initialPhotoBounds.top,
+ initialPhotoBounds.width(), initialPhotoBounds.height());
+
+ builder.setDisplayThumbsFullScreen(false);
+ builder.setMaxInitialScale(8);
+ activity.startActivity(builder.build());
+ activity.overridePendingTransition(0, 0);
+ }
+
+ @Override
+ public void launchApplicationSettingsActivity(final Context context, final boolean topLevel) {
+ final Intent intent = new Intent(context, ApplicationSettingsActivity.class);
+ intent.putExtra(UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, topLevel);
+ context.startActivity(intent);
+ }
+
+ @Override
+ public void launchPerSubscriptionSettingsActivity(final Context context, final int subId,
+ final String settingTitle) {
+ final Intent intent = getPerSubscriptionSettingsIntent(context, subId, settingTitle);
+ context.startActivity(intent);
+ }
+
+ @Override
+ public Intent getViewUrlIntent(final String url) {
+ final Uri uri = Uri.parse(url);
+ return new Intent(Intent.ACTION_VIEW, uri);
+ }
+
+ @Override
+ public void broadcastConversationSelfIdChange(final Context context,
+ final String conversationId, final String conversationSelfId) {
+ final Intent intent = new Intent(CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION);
+ intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
+ intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_SELF_ID, conversationSelfId);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
+ }
+
+ @Override
+ public PendingIntent getPendingIntentForConversationListActivity(final Context context) {
+ final Intent intent = getConversationListActivityIntent(context);
+ return getPendingIntentWithParentStack(context, intent, 0);
+ }
+
+ @Override
+ public PendingIntent getPendingIntentForConversationActivity(final Context context,
+ final String conversationId, final MessageData draft) {
+ final Intent intent = getConversationActivityIntent(context, conversationId, draft,
+ false /* withCustomTransition */);
+ // Ensure that the platform doesn't reuse PendingIntents across conversations
+ intent.setData(MessagingContentProvider.buildConversationMetadataUri(conversationId));
+ return getPendingIntentWithParentStack(context, intent, 0);
+ }
+
+ @Override
+ public Intent getIntentForConversationActivity(final Context context,
+ final String conversationId, final MessageData draft) {
+ final Intent intent = getConversationActivityIntent(context, conversationId, draft,
+ false /* withCustomTransition */);
+ return intent;
+ }
+
+ @Override
+ public PendingIntent getPendingIntentForSendingMessageToConversation(final Context context,
+ final String conversationId, final String selfId, final boolean requiresMms,
+ final int requestCode) {
+ final Intent intent = new Intent(context, RemoteInputEntrypointActivity.class);
+ intent.setAction(Intent.ACTION_SENDTO);
+ // Ensure that the platform doesn't reuse PendingIntents across conversations
+ intent.setData(MessagingContentProvider.buildConversationMetadataUri(conversationId));
+ intent.putExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
+ intent.putExtra(UIIntents.UI_INTENT_EXTRA_SELF_ID, selfId);
+ intent.putExtra(UIIntents.UI_INTENT_EXTRA_REQUIRES_MMS, requiresMms);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ return getPendingIntentWithParentStack(context, intent, requestCode);
+ }
+
+ @Override
+ public PendingIntent getPendingIntentForClearingNotifications(final Context context,
+ final int updateTargets, final ConversationIdSet conversationIdSet,
+ final int requestCode) {
+ final Intent intent = new Intent(context, NotificationReceiver.class);
+ intent.setAction(ACTION_RESET_NOTIFICATIONS);
+ intent.putExtra(UI_INTENT_EXTRA_NOTIFICATIONS_UPDATE, updateTargets);
+ if (conversationIdSet != null) {
+ intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID_SET,
+ conversationIdSet.getDelimitedString());
+ }
+ return PendingIntent.getBroadcast(context,
+ requestCode, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ /**
+ * Gets a PendingIntent associated with an Intent to start an Activity. All notifications
+ * that starts an Activity must use this method to get a PendingIntent, which achieves two
+ * goals:
+ * 1. The target activities will be created, with any existing ones destroyed. This ensures
+ * we don't end up with multiple instances of ConversationListActivity, for example.
+ * 2. The target activity, when launched, will have its backstack correctly constructed so
+ * back navigation will work correctly.
+ */
+ private static PendingIntent getPendingIntentWithParentStack(final Context context,
+ final Intent intent, final int requestCode) {
+ final TaskStackBuilder stackBuilder = TaskStackBuilder.create(context);
+ // Adds the back stack for the Intent (plus the Intent itself)
+ stackBuilder.addNextIntentWithParentStack(intent);
+ final PendingIntent resultPendingIntent =
+ stackBuilder.getPendingIntent(requestCode, PendingIntent.FLAG_UPDATE_CURRENT);
+ return resultPendingIntent;
+ }
+
+ @Override
+ public Intent getRingtonePickerIntent(final String title, final Uri existingUri,
+ final Uri defaultUri, final int toneType) {
+ return new Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
+ .putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, toneType)
+ .putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, title)
+ .putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existingUri)
+ .putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, defaultUri);
+ }
+
+ @Override
+ public PendingIntent getPendingIntentForLowStorageNotifications(final Context context) {
+ final TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(context);
+ final Intent conversationListIntent = getConversationListActivityIntent(context);
+ taskStackBuilder.addNextIntent(conversationListIntent);
+ taskStackBuilder.addNextIntentWithParentStack(
+ getSmsStorageLowWarningActivityIntent(context));
+
+ return taskStackBuilder.getPendingIntent(
+ 0, PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ @Override
+ public PendingIntent getPendingIntentForSecondaryUserNewMessageNotification(
+ final Context context) {
+ return getPendingIntentForConversationListActivity(context);
+ }
+
+ @Override
+ public Intent getWirelessAlertsIntent() {
+ final Intent intent = new Intent(Intent.ACTION_MAIN);
+ intent.setComponent(new ComponentName(CMAS_COMPONENT, CELL_BROADCAST_LIST_ACTIVITY));
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ return intent;
+ }
+
+ @Override
+ public Intent getApnEditorIntent(final Context context, final String rowId, final int subId) {
+ final Intent intent = new Intent(context, ApnEditorActivity.class);
+ intent.putExtra(UI_INTENT_EXTRA_APN_ROW_ID, rowId);
+ intent.putExtra(UI_INTENT_EXTRA_SUB_ID, subId);
+ return intent;
+ }
+
+ @Override
+ public Intent getApnSettingsIntent(final Context context, final int subId) {
+ final Intent intent = new Intent(context, ApnSettingsActivity.class)
+ .putExtra(UI_INTENT_EXTRA_SUB_ID, subId);
+ return intent;
+ }
+
+ @Override
+ public Intent getAdvancedSettingsIntent(final Context context) {
+ return getPerSubscriptionSettingsIntent(context, ParticipantData.DEFAULT_SELF_SUB_ID, null);
+ }
+
+ @Override
+ public Intent getChangeDefaultSmsAppIntent(final Activity activity) {
+ final Intent intent = new Intent(Telephony.Sms.Intents.ACTION_CHANGE_DEFAULT);
+ intent.putExtra(Telephony.Sms.Intents.EXTRA_PACKAGE_NAME, activity.getPackageName());
+ return intent;
+ }
+
+ @Override
+ public void launchBrowserForUrl(final Context context, final String url) {
+ final Intent intent = getViewUrlIntent(url);
+ startExternalActivity(context, intent);
+ }
+
+ /**
+ * Provides a safe way to handle external activities which may not exist.
+ */
+ private void startExternalActivity(final Context context, final Intent intent) {
+ try {
+ context.startActivity(intent);
+ } catch (final ActivityNotFoundException ex) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't find activity:", ex);
+ UiUtils.showToastAtBottom(R.string.activity_not_found_message);
+ }
+ }
+
+ private Intent getPerSubscriptionSettingsIntent(final Context context, final int subId,
+ @Nullable final String settingTitle) {
+ return new Intent(context, PerSubscriptionSettingsActivity.class)
+ .putExtra(UI_INTENT_EXTRA_SUB_ID, subId)
+ .putExtra(UI_INTENT_EXTRA_PER_SUBSCRIPTION_SETTING_TITLE, settingTitle);
+ }
+
+ @Override
+ public Intent getLaunchConversationActivityIntent(final Context context) {
+ final Intent intent = new Intent(context, LaunchConversationActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NO_HISTORY);
+ return intent;
+ }
+
+ @Override
+ public void kickMediaScanner(final Context context, final String volume) {
+ final Intent intent = new Intent(MEDIA_SCANNER_SCAN_ACTION)
+ .putExtra(MediaStore.MEDIA_SCANNER_VOLUME, volume)
+ .setClassName(MEDIA_SCANNER_PACKAGE, MEDIA_SCANNER_CLASS);
+ context.startService(intent);
+ }
+
+ @Override
+ public PendingIntent getWidgetPendingIntentForConversationActivity(final Context context,
+ final String conversationId, final int requestCode) {
+ final Intent intent = getConversationActivityIntent(context, null, null,
+ false /* withCustomTransition */);
+ if (conversationId != null) {
+ intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
+
+ // Set the action to something unique to this conversation so if someone calls this
+ // function again on a different conversation, they'll get a new PendingIntent instead
+ // of the old one.
+ intent.setAction(ACTION_WIDGET_CONVERSATION + conversationId);
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ return getPendingIntentWithParentStack(context, intent, requestCode);
+ }
+
+ @Override
+ public PendingIntent getWidgetPendingIntentForConversationListActivity(
+ final Context context) {
+ final Intent intent = getConversationListActivityIntent(context);
+ return getPendingIntentWithParentStack(context, intent, 0);
+ }
+
+ @Override
+ public PendingIntent getWidgetPendingIntentForConfigurationActivity(final Context context,
+ final int appWidgetId) {
+ final Intent configureIntent = new Intent(context, WidgetPickConversationActivity.class);
+ configureIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ configureIntent.setAction(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE);
+ configureIntent.setData(Uri.parse(configureIntent.toUri(Intent.URI_INTENT_SCHEME)));
+ configureIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_HISTORY);
+ return getPendingIntentWithParentStack(context, configureIntent, 0);
+ }
+}
diff --git a/src/com/android/messaging/ui/VCardDetailActivity.java b/src/com/android/messaging/ui/VCardDetailActivity.java
new file mode 100644
index 0000000..fecdc34
--- /dev/null
+++ b/src/com/android/messaging/ui/VCardDetailActivity.java
@@ -0,0 +1,60 @@
+/*
+ * 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.messaging.ui;
+
+import android.app.Fragment;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import com.android.messaging.R;
+import com.android.messaging.util.Assert;
+
+/**
+ * An activity that hosts VCardDetailFragment that shows the content of a VCard that contains one
+ * or more contacts.
+ */
+public class VCardDetailActivity extends BugleActionBarActivity {
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.vcard_detail_activity);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ @Override
+ public void onAttachFragment(final Fragment fragment) {
+ Assert.isTrue(fragment instanceof VCardDetailFragment);
+ final Uri vCardUri = getIntent().getParcelableExtra(UIIntents.UI_INTENT_EXTRA_VCARD_URI);
+ Assert.notNull(vCardUri);
+ final VCardDetailFragment vCardDetailFragment = (VCardDetailFragment) fragment;
+ vCardDetailFragment.setVCardUri(vCardUri);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ // Treat the home press as back press so that when we go back to
+ // ConversationActivity, it doesn't lose its original intent (conversation id etc.)
+ onBackPressed();
+ return true;
+
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/VCardDetailAdapter.java b/src/com/android/messaging/ui/VCardDetailAdapter.java
new file mode 100644
index 0000000..cfdd836
--- /dev/null
+++ b/src/com/android/messaging/ui/VCardDetailAdapter.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.ui;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseExpandableListAdapter;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.media.VCardResourceEntry;
+import com.android.messaging.datamodel.media.VCardResourceEntry.VCardResourceEntryDestinationItem;
+
+import java.util.List;
+
+/**
+ * Displays a list of expandable contact cards shown in the VCardDetailActivity.
+ */
+public class VCardDetailAdapter extends BaseExpandableListAdapter {
+ private final List<VCardResourceEntry> mVCards;
+ private final LayoutInflater mInflater;
+
+ public VCardDetailAdapter(final Context context, final List<VCardResourceEntry> vCards) {
+ mVCards = vCards;
+ mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ }
+
+ @Override
+ public Object getChild(final int groupPosition, final int childPosition) {
+ return mVCards.get(groupPosition).getContactInfo().get(childPosition);
+ }
+
+ @Override
+ public long getChildId(final int groupPosition, final int childPosition) {
+ return childPosition;
+ }
+
+ @Override
+ public View getChildView(final int groupPosition, final int childPosition,
+ final boolean isLastChild, final View convertView, final ViewGroup parent) {
+ PersonItemView v;
+ if (convertView == null) {
+ v = instantiateView(parent);
+ } else {
+ v = (PersonItemView) convertView;
+ }
+
+ final VCardResourceEntryDestinationItem item = (VCardResourceEntryDestinationItem)
+ getChild(groupPosition, childPosition);
+
+ v.bind(item.getDisplayItem());
+ return v;
+ }
+
+ @Override
+ public int getChildrenCount(final int groupPosition) {
+ return mVCards.get(groupPosition).getContactInfo().size();
+ }
+
+ @Override
+ public Object getGroup(final int groupPosition) {
+ return mVCards.get(groupPosition);
+ }
+
+ @Override
+ public int getGroupCount() {
+ return mVCards.size();
+ }
+
+ @Override
+ public long getGroupId(final int groupPosition) {
+ return groupPosition;
+ }
+
+ @Override
+ public View getGroupView(final int groupPosition, final boolean isExpanded,
+ final View convertView, final ViewGroup parent) {
+ PersonItemView v;
+ if (convertView == null) {
+ v = instantiateView(parent);
+ } else {
+ v = (PersonItemView) convertView;
+ }
+
+ final VCardResourceEntry item = (VCardResourceEntry) getGroup(groupPosition);
+ v.bind(item.getDisplayItem());
+ return v;
+ }
+
+ @Override
+ public boolean isChildSelectable(final int groupPosition, final int childPosition) {
+ return true;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ private PersonItemView instantiateView(final ViewGroup parent) {
+ final PersonItemView v = (PersonItemView) mInflater.inflate(R.layout.people_list_item_view,
+ parent, false);
+ v.setClickable(false);
+ return v;
+ }
+}
diff --git a/src/com/android/messaging/ui/VCardDetailFragment.java b/src/com/android/messaging/ui/VCardDetailFragment.java
new file mode 100644
index 0000000..1b2b88d
--- /dev/null
+++ b/src/com/android/messaging/ui/VCardDetailFragment.java
@@ -0,0 +1,197 @@
+/*
+ * 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.messaging.ui;
+
+import android.app.Fragment;
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnLayoutChangeListener;
+import android.view.ViewGroup;
+import android.widget.ExpandableListAdapter;
+import android.widget.ExpandableListView;
+import android.widget.ExpandableListView.OnChildClickListener;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.MediaScratchFileProvider;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.PersonItemData;
+import com.android.messaging.datamodel.data.VCardContactItemData;
+import com.android.messaging.datamodel.data.PersonItemData.PersonItemDataListener;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.SafeAsyncTask;
+import com.android.messaging.util.UiUtils;
+import com.android.messaging.util.UriUtil;
+
+/**
+ * A fragment that shows the content of a VCard that contains one or more contacts.
+ */
+public class VCardDetailFragment extends Fragment implements PersonItemDataListener {
+ private final Binding<VCardContactItemData> mBinding =
+ BindingBase.createBinding(this);
+ private ExpandableListView mListView;
+ private VCardDetailAdapter mAdapter;
+ private Uri mVCardUri;
+
+ /**
+ * We need to persist the VCard in the scratch directory before letting the user view it.
+ * We save this Uri locally, so that if the user cancels the action and re-perform the add
+ * to contacts action we don't have to persist it again.
+ */
+ private Uri mScratchSpaceUri;
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ Assert.notNull(mVCardUri);
+ final View view = inflater.inflate(R.layout.vcard_detail_fragment, container, false);
+ mListView = (ExpandableListView) view.findViewById(R.id.list);
+ mListView.addOnLayoutChangeListener(new OnLayoutChangeListener() {
+ @Override
+ public void onLayoutChange(final View v, final int left, final int top, final int right,
+ final int bottom, final int oldLeft, final int oldTop, final int oldRight,
+ final int oldBottom) {
+ mListView.setIndicatorBounds(mListView.getWidth() - getResources()
+ .getDimensionPixelSize(R.dimen.vcard_detail_group_indicator_width),
+ mListView.getWidth());
+ }
+ });
+ mListView.setOnChildClickListener(new OnChildClickListener() {
+ @Override
+ public boolean onChildClick(ExpandableListView expandableListView, View clickedView,
+ int groupPosition, int childPosition, long childId) {
+ if (!(clickedView instanceof PersonItemView)) {
+ return false;
+ }
+ final Intent intent = ((PersonItemView) clickedView).getClickIntent();
+ if (intent != null) {
+ try {
+ startActivity(intent);
+ } catch (ActivityNotFoundException e) {
+ return false;
+ }
+ return true;
+ }
+ return false;
+ }
+ });
+ mBinding.bind(DataModel.get().createVCardContactItemData(getActivity(), mVCardUri));
+ mBinding.getData().setListener(this);
+ return view;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (mBinding.isBound()) {
+ mBinding.unbind();
+ }
+ mListView.setAdapter((ExpandableListAdapter) null);
+ }
+
+ private boolean shouldShowAddToContactsItem() {
+ return mBinding.isBound() && mBinding.getData().hasValidVCard();
+ }
+
+ @Override
+ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ inflater.inflate(R.menu.vcard_detail_fragment_menu, menu);
+ final MenuItem addToContactsItem = menu.findItem(R.id.action_add_contact);
+ addToContactsItem.setVisible(shouldShowAddToContactsItem());
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.action_add_contact:
+ mBinding.ensureBound();
+ final Uri vCardUri = mBinding.getData().getVCardUri();
+
+ // We have to do things in the background in case we need to copy the vcard data.
+ new SafeAsyncTask<Void, Void, Uri>() {
+ @Override
+ protected Uri doInBackgroundTimed(final Void... params) {
+ // We can't delete the persisted vCard file because we don't know when to
+ // delete it, since the app that uses it (contacts, dialer) may start or
+ // shut down at any point. Therefore, we rely on the system to clean up
+ // the cache directory for us.
+ return mScratchSpaceUri != null ? mScratchSpaceUri :
+ UriUtil.persistContentToScratchSpace(vCardUri);
+ }
+
+ @Override
+ protected void onPostExecute(final Uri result) {
+ if (result != null) {
+ mScratchSpaceUri = result;
+ if (getActivity() != null) {
+ MediaScratchFileProvider.addUriToDisplayNameEntry(
+ result, mBinding.getData().getDisplayName());
+ UIIntents.get().launchSaveVCardToContactsActivity(getActivity(),
+ result);
+ }
+ }
+ }
+ }.executeOnThreadPool();
+ return true;
+
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ public void setVCardUri(final Uri vCardUri) {
+ Assert.isTrue(!mBinding.isBound());
+ mVCardUri = vCardUri;
+ }
+
+ @Override
+ public void onPersonDataUpdated(final PersonItemData data) {
+ Assert.isTrue(data instanceof VCardContactItemData);
+ mBinding.ensureBound();
+ final VCardContactItemData vCardData = (VCardContactItemData) data;
+ Assert.isTrue(vCardData.hasValidVCard());
+ mAdapter = new VCardDetailAdapter(getActivity(), vCardData.getVCardResource().getVCards());
+ mListView.setAdapter(mAdapter);
+
+ // Expand the contact card if there's only one contact.
+ if (mAdapter.getGroupCount() == 1) {
+ mListView.expandGroup(0);
+ }
+ getActivity().invalidateOptionsMenu();
+ }
+
+ @Override
+ public void onPersonDataFailed(final PersonItemData data, final Exception exception) {
+ mBinding.ensureBound();
+ UiUtils.showToastAtBottom(R.string.failed_loading_vcard);
+ getActivity().finish();
+ }
+}
diff --git a/src/com/android/messaging/ui/VideoThumbnailView.java b/src/com/android/messaging/ui/VideoThumbnailView.java
new file mode 100644
index 0000000..966336e
--- /dev/null
+++ b/src/com/android/messaging/ui/VideoThumbnailView.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.messaging.ui;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.media.MediaPlayer;
+import android.net.Uri;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageButton;
+import android.widget.ImageView.ScaleType;
+import android.widget.VideoView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.media.ImageRequest;
+import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor;
+import com.android.messaging.datamodel.media.VideoThumbnailRequest;
+import com.android.messaging.util.Assert;
+
+/**
+ * View that encapsulates a video preview (either as a thumbnail image, or video player), and the
+ * a play button to overlay it. Ensures that the video preview maintains the aspect ratio of the
+ * original video while trying to respect minimum width/height and constraining to the available
+ * bounds
+ */
+public class VideoThumbnailView extends FrameLayout {
+ /**
+ * When in this mode the VideoThumbnailView is a lightweight AsyncImageView with an ImageButton
+ * to play the video. Clicking play will launch a full screen player
+ */
+ private static final int MODE_IMAGE_THUMBNAIL = 0;
+
+ /**
+ * When in this mode the VideoThumbnailVideo will include a VideoView, and the play button will
+ * play the video inline. When in this mode, the loop and playOnLoad attributes can be applied
+ * to auto-play or loop the video.
+ */
+ private static final int MODE_PLAYABLE_VIDEO = 1;
+
+ private final int mMode;
+ private final boolean mPlayOnLoad;
+ private final boolean mAllowCrop;
+ private final VideoView mVideoView;
+ private final ImageButton mPlayButton;
+ private final AsyncImageView mThumbnailImage;
+ private int mVideoWidth;
+ private int mVideoHeight;
+ private Uri mVideoSource;
+ private boolean mAnimating;
+ private boolean mVideoLoaded;
+
+ public VideoThumbnailView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ final TypedArray typedAttributes =
+ context.obtainStyledAttributes(attrs, R.styleable.VideoThumbnailView);
+
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ inflater.inflate(R.layout.video_thumbnail_view, this, true);
+
+ mPlayOnLoad = typedAttributes.getBoolean(R.styleable.VideoThumbnailView_playOnLoad, false);
+ final boolean loop =
+ typedAttributes.getBoolean(R.styleable.VideoThumbnailView_loop, false);
+ mMode = typedAttributes.getInt(R.styleable.VideoThumbnailView_mode, MODE_IMAGE_THUMBNAIL);
+ mAllowCrop = typedAttributes.getBoolean(R.styleable.VideoThumbnailView_allowCrop, false);
+
+ mVideoWidth = ImageRequest.UNSPECIFIED_SIZE;
+ mVideoHeight = ImageRequest.UNSPECIFIED_SIZE;
+
+ if (mMode == MODE_PLAYABLE_VIDEO) {
+ mVideoView = new VideoView(context);
+ // Video view tries to request focus on start which pulls focus from the user's intended
+ // focus when we add this control. Remove focusability to prevent this. The play
+ // button can still be focused
+ mVideoView.setFocusable(false);
+ mVideoView.setFocusableInTouchMode(false);
+ mVideoView.clearFocus();
+ addView(mVideoView, 0, new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ mVideoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
+ @Override
+ public void onPrepared(final MediaPlayer mediaPlayer) {
+ mVideoLoaded = true;
+ mVideoWidth = mediaPlayer.getVideoWidth();
+ mVideoHeight = mediaPlayer.getVideoHeight();
+ mediaPlayer.setLooping(loop);
+ trySwitchToVideo();
+ }
+ });
+ mVideoView.setOnCompletionListener(new MediaPlayer.OnCompletionListener() {
+ @Override
+ public void onCompletion(final MediaPlayer mediaPlayer) {
+ mPlayButton.setVisibility(View.VISIBLE);
+ }
+ });
+ mVideoView.setOnErrorListener(new MediaPlayer.OnErrorListener() {
+ @Override
+ public boolean onError(final MediaPlayer mediaPlayer, final int i, final int i2) {
+ return true;
+ }
+ });
+ } else {
+ mVideoView = null;
+ }
+
+ mPlayButton = (ImageButton) findViewById(R.id.video_thumbnail_play_button);
+ if (loop) {
+ mPlayButton.setVisibility(View.GONE);
+ } else {
+ mPlayButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ if (mVideoSource == null) {
+ return;
+ }
+
+ if (mMode == MODE_PLAYABLE_VIDEO) {
+ mVideoView.seekTo(0);
+ start();
+ } else {
+ UIIntents.get().launchFullScreenVideoViewer(getContext(), mVideoSource);
+ }
+ }
+ });
+ mPlayButton.setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(final View view) {
+ // Button prevents long click from propagating up, do it manually
+ VideoThumbnailView.this.performLongClick();
+ return true;
+ }
+ });
+ }
+
+ mThumbnailImage = (AsyncImageView) findViewById(R.id.video_thumbnail_image);
+ if (mAllowCrop) {
+ mThumbnailImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
+ mThumbnailImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
+ mThumbnailImage.setScaleType(ScaleType.CENTER_CROP);
+ } else {
+ // This is the default setting in the layout, so No-op.
+ }
+ final int maxHeight = typedAttributes.getDimensionPixelSize(
+ R.styleable.VideoThumbnailView_android_maxHeight, ImageRequest.UNSPECIFIED_SIZE);
+ if (maxHeight != ImageRequest.UNSPECIFIED_SIZE) {
+ mThumbnailImage.setMaxHeight(maxHeight);
+ mThumbnailImage.setAdjustViewBounds(true);
+ }
+
+ typedAttributes.recycle();
+ }
+
+ @Override
+ protected void onAnimationStart() {
+ super.onAnimationStart();
+ mAnimating = true;
+ }
+
+ @Override
+ protected void onAnimationEnd() {
+ super.onAnimationEnd();
+ mAnimating = false;
+ trySwitchToVideo();
+ }
+
+ private void trySwitchToVideo() {
+ if (mAnimating) {
+ // Don't start video or hide image until after animation completes
+ return;
+ }
+
+ if (!mVideoLoaded) {
+ // Video hasn't loaded, nothing more to do
+ return;
+ }
+
+ if (mPlayOnLoad) {
+ start();
+ } else {
+ mVideoView.seekTo(0);
+ }
+ }
+
+ private boolean hasVideoSize() {
+ return mVideoWidth != ImageRequest.UNSPECIFIED_SIZE &&
+ mVideoHeight != ImageRequest.UNSPECIFIED_SIZE;
+ }
+
+ public void start() {
+ Assert.equals(MODE_PLAYABLE_VIDEO, mMode);
+ mPlayButton.setVisibility(View.GONE);
+ mThumbnailImage.setVisibility(View.GONE);
+ mVideoView.start();
+ }
+
+ // TODO: The check could be added to MessagePartData itself so that all users of MessagePartData
+ // get the right behavior, instead of requiring all the users to do similar checks.
+ private static boolean shouldUseGenericVideoIcon(final boolean incomingMessage) {
+ return incomingMessage && !VideoThumbnailRequest.shouldShowIncomingVideoThumbnails();
+ }
+
+ public void setSource(final MessagePartData part, final boolean incomingMessage) {
+ if (part == null) {
+ clearSource();
+ } else {
+ mVideoSource = part.getContentUri();
+ if (shouldUseGenericVideoIcon(incomingMessage)) {
+ mThumbnailImage.setImageResource(R.drawable.generic_video_icon);
+ mVideoWidth = ImageRequest.UNSPECIFIED_SIZE;
+ mVideoHeight = ImageRequest.UNSPECIFIED_SIZE;
+ } else {
+ mThumbnailImage.setImageResourceId(
+ new MessagePartVideoThumbnailRequestDescriptor(part));
+ if (mVideoView != null) {
+ mVideoView.setVideoURI(mVideoSource);
+ }
+ mVideoWidth = part.getWidth();
+ mVideoHeight = part.getHeight();
+ }
+ }
+ }
+
+ public void setSource(final Uri videoSource, final boolean incomingMessage) {
+ if (videoSource == null) {
+ clearSource();
+ } else {
+ mVideoSource = videoSource;
+ if (shouldUseGenericVideoIcon(incomingMessage)) {
+ mThumbnailImage.setImageResource(R.drawable.generic_video_icon);
+ mVideoWidth = ImageRequest.UNSPECIFIED_SIZE;
+ mVideoHeight = ImageRequest.UNSPECIFIED_SIZE;
+ } else {
+ mThumbnailImage.setImageResourceId(
+ new MessagePartVideoThumbnailRequestDescriptor(videoSource));
+ if (mVideoView != null) {
+ mVideoView.setVideoURI(videoSource);
+ }
+ }
+ }
+ }
+
+ private void clearSource() {
+ mVideoSource = null;
+ mThumbnailImage.setImageResourceId(null);
+ mVideoWidth = ImageRequest.UNSPECIFIED_SIZE;
+ mVideoHeight = ImageRequest.UNSPECIFIED_SIZE;
+ if (mVideoView != null) {
+ mVideoView.setVideoURI(null);
+ }
+ }
+
+ @Override
+ public void setMinimumWidth(final int minWidth) {
+ super.setMinimumWidth(minWidth);
+ if (mVideoView != null) {
+ mVideoView.setMinimumWidth(minWidth);
+ }
+ }
+
+ @Override
+ public void setMinimumHeight(final int minHeight) {
+ super.setMinimumHeight(minHeight);
+ if (mVideoView != null) {
+ mVideoView.setMinimumHeight(minHeight);
+ }
+ }
+
+ public void setColorFilter(int color) {
+ mThumbnailImage.setColorFilter(color);
+ mPlayButton.setColorFilter(color);
+ }
+
+ public void clearColorFilter() {
+ mThumbnailImage.clearColorFilter();
+ mPlayButton.clearColorFilter();
+ }
+
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ if (mAllowCrop) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ return;
+ }
+ int desiredWidth = 1;
+ int desiredHeight = 1;
+ if (mVideoView != null) {
+ mVideoView.measure(widthMeasureSpec, heightMeasureSpec);
+ }
+ mThumbnailImage.measure(widthMeasureSpec, heightMeasureSpec);
+ if (hasVideoSize()) {
+ desiredWidth = mVideoWidth;
+ desiredHeight = mVideoHeight;
+ } else {
+ desiredWidth = mThumbnailImage.getMeasuredWidth();
+ desiredHeight = mThumbnailImage.getMeasuredHeight();
+ }
+
+ final int minimumWidth = getMinimumWidth();
+ final int minimumHeight = getMinimumHeight();
+
+ // Constrain the scale to fit within the supplied size
+ final float maxScale = Math.max(
+ MeasureSpec.getSize(widthMeasureSpec) / (float) desiredWidth,
+ MeasureSpec.getSize(heightMeasureSpec) / (float) desiredHeight);
+
+ // Scale up to reach minimum width/height
+ final float widthScale = Math.max(1, minimumWidth / (float) desiredWidth);
+ final float heightScale = Math.max(1, minimumHeight / (float) desiredHeight);
+ final float scale = Math.min(maxScale, Math.max(widthScale, heightScale));
+ desiredWidth = (int) (desiredWidth * scale);
+ desiredHeight = (int) (desiredHeight * scale);
+
+ setMeasuredDimension(desiredWidth, desiredHeight);
+ }
+
+ @Override
+ protected void onLayout(final boolean changed, final int left, final int top, final int right,
+ final int bottom) {
+ final int count = getChildCount();
+ for (int i = 0; i < count; i++) {
+ final View child = getChildAt(i);
+ child.layout(0, 0, right - left, bottom - top);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/ViewPagerTabStrip.java b/src/com/android/messaging/ui/ViewPagerTabStrip.java
new file mode 100644
index 0000000..e088296
--- /dev/null
+++ b/src/com/android/messaging/ui/ViewPagerTabStrip.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.messaging.ui;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.LinearLayout;
+
+import com.android.messaging.R;
+import com.android.messaging.util.OsUtil;
+
+public class ViewPagerTabStrip extends LinearLayout {
+ private int mSelectedUnderlineThickness;
+ private final Paint mSelectedUnderlinePaint;
+
+ private int mIndexForSelection;
+ private float mSelectionOffset;
+
+ public ViewPagerTabStrip(Context context) {
+ this(context, null);
+ }
+
+ public ViewPagerTabStrip(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ final Resources res = context.getResources();
+
+ mSelectedUnderlineThickness =
+ res.getDimensionPixelSize(R.dimen.pager_tab_underline_selected);
+ int underlineColor = res.getColor(R.color.contact_picker_tab_underline);
+ int backgroundColor = res.getColor(R.color.action_bar_background_color);
+
+ mSelectedUnderlinePaint = new Paint();
+ mSelectedUnderlinePaint.setColor(underlineColor);
+
+ setBackgroundColor(backgroundColor);
+ setWillNotDraw(false);
+ }
+
+ /**
+ * Notifies this view that view pager has been scrolled. We save the tab index
+ * and selection offset for interpolating the position and width of selection
+ * underline.
+ */
+ void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ mIndexForSelection = position;
+ mSelectionOffset = positionOffset;
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ int childCount = getChildCount();
+
+ // Thick colored underline below the current selection
+ if (childCount > 0) {
+ View selectedTitle = getChildAt(mIndexForSelection);
+ int selectedLeft = selectedTitle.getLeft();
+ int selectedRight = selectedTitle.getRight();
+ final boolean isRtl = isRtl();
+ final boolean hasNextTab = isRtl ? mIndexForSelection > 0
+ : (mIndexForSelection < (getChildCount() - 1));
+ if ((mSelectionOffset > 0.0f) && hasNextTab) {
+ // Draw the selection partway between the tabs
+ View nextTitle = getChildAt(mIndexForSelection + (isRtl ? -1 : 1));
+ int nextLeft = nextTitle.getLeft();
+ int nextRight = nextTitle.getRight();
+
+ selectedLeft = (int) (mSelectionOffset * nextLeft +
+ (1.0f - mSelectionOffset) * selectedLeft);
+ selectedRight = (int) (mSelectionOffset * nextRight +
+ (1.0f - mSelectionOffset) * selectedRight);
+ }
+
+ int height = getHeight();
+ canvas.drawRect(selectedLeft, height - mSelectedUnderlineThickness,
+ selectedRight, height, mSelectedUnderlinePaint);
+ }
+ }
+
+ private boolean isRtl() {
+ return OsUtil.isAtLeastJB_MR2() ? getLayoutDirection() == View.LAYOUT_DIRECTION_RTL : false;
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/ui/ViewPagerTabs.java b/src/com/android/messaging/ui/ViewPagerTabs.java
new file mode 100644
index 0000000..f31a071
--- /dev/null
+++ b/src/com/android/messaging/ui/ViewPagerTabs.java
@@ -0,0 +1,236 @@
+/*
+ * 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.messaging.ui;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.Outline;
+import android.graphics.drawable.ColorDrawable;
+import android.support.v4.view.PagerAdapter;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewOutlineProvider;
+import android.widget.FrameLayout;
+import android.widget.HorizontalScrollView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.util.OsUtil;
+
+/**
+ * Lightweight implementation of ViewPager tabs. This looks similar to traditional actionBar tabs,
+ * but allows for the view containing the tabs to be placed anywhere on screen. Text-related
+ * attributes can also be assigned in XML - these will get propogated to the child TextViews
+ * automatically.
+ *
+ * Note: this file is taken from the AOSP /packages/apps/ContactsCommon/src/com/android/contacts/
+ * common/list/ViewPagerTabs.java. Some platform specific API calls (e.g. ViewOutlineProvider which
+ * assumes L and above) have been modified to support down to Api Level 16.
+ */
+public class ViewPagerTabs extends HorizontalScrollView implements ViewPager.OnPageChangeListener {
+
+ ViewPager mPager;
+ private ViewPagerTabStrip mTabStrip;
+
+ /**
+ * Linearlayout that will contain the TextViews serving as tabs. This is the only child
+ * of the parent HorizontalScrollView.
+ */
+ final int mTextStyle;
+ final ColorStateList mTextColor;
+ final int mTextSize;
+ final boolean mTextAllCaps;
+ int mPrevSelected = -1;
+ int mSidePadding;
+
+ private static final int TAB_SIDE_PADDING_IN_DPS = 10;
+
+ // TODO: This should use <declare-styleable> in the future
+ private static final int[] ATTRS = new int[] {
+ android.R.attr.textSize,
+ android.R.attr.textStyle,
+ android.R.attr.textColor,
+ android.R.attr.textAllCaps
+ };
+
+ /**
+ * Shows a toast with the tab description when long-clicked.
+ */
+ private class OnTabLongClickListener implements OnLongClickListener {
+ final String mTabDescription;
+
+ public OnTabLongClickListener(String tabDescription) {
+ mTabDescription = tabDescription;
+ }
+
+ @Override
+ public boolean onLongClick(View v) {
+ final int[] screenPos = new int[2];
+ getLocationOnScreen(screenPos);
+
+ final Context context = getContext();
+ final int width = getWidth();
+ final int height = getHeight();
+ final int screenWidth = context.getResources().getDisplayMetrics().widthPixels;
+
+ Toast toast = Toast.makeText(context, mTabDescription, Toast.LENGTH_SHORT);
+
+ // Show the toast under the tab
+ toast.setGravity(Gravity.TOP | Gravity.CENTER_HORIZONTAL,
+ (screenPos[0] + width / 2) - screenWidth / 2, screenPos[1] + height);
+
+ toast.show();
+ return true;
+ }
+ }
+
+ public ViewPagerTabs(Context context) {
+ this(context, null);
+ }
+
+ public ViewPagerTabs(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public ViewPagerTabs(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ setFillViewport(true);
+
+ mSidePadding = (int) (getResources().getDisplayMetrics().density * TAB_SIDE_PADDING_IN_DPS);
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, ATTRS);
+ mTextSize = a.getDimensionPixelSize(0, 0);
+ mTextStyle = a.getInt(1, 0);
+ mTextColor = a.getColorStateList(2);
+ mTextAllCaps = a.getBoolean(3, false);
+
+ mTabStrip = new ViewPagerTabStrip(context);
+ addView(mTabStrip,
+ new FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT));
+ a.recycle();
+
+ // enable shadow casting from view bounds
+ if (OsUtil.isAtLeastL()) {
+ setOutlineProvider(new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View view, Outline outline) {
+ outline.setRect(0, 0, view.getWidth(), view.getHeight());
+ }
+ });
+ }
+ }
+
+ public void setViewPager(ViewPager viewPager) {
+ mPager = viewPager;
+ addTabs(mPager.getAdapter());
+ }
+
+ private void addTabs(PagerAdapter adapter) {
+ mTabStrip.removeAllViews();
+
+ final int count = adapter.getCount();
+ for (int i = 0; i < count; i++) {
+ addTab(adapter.getPageTitle(i), i);
+ }
+ }
+
+ private void addTab(CharSequence tabTitle, final int position) {
+ final TextView textView = new TextView(getContext());
+ textView.setText(tabTitle);
+ textView.setBackgroundResource(R.drawable.contact_picker_tab_background_selector);
+ textView.setGravity(Gravity.CENTER);
+ textView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mPager.setCurrentItem(getRtlPosition(position));
+ }
+ });
+
+ // Assign various text appearance related attributes to child views.
+ if (mTextStyle > 0) {
+ textView.setTypeface(textView.getTypeface(), mTextStyle);
+ }
+ if (mTextSize > 0) {
+ textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize);
+ }
+ if (mTextColor != null) {
+ textView.setTextColor(mTextColor);
+ }
+ textView.setAllCaps(mTextAllCaps);
+ textView.setPadding(mSidePadding, 0, mSidePadding, 0);
+ mTabStrip.addView(textView, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT,
+ LayoutParams.MATCH_PARENT, 1));
+ // Default to the first child being selected
+ if (position == 0) {
+ mPrevSelected = 0;
+ textView.setSelected(true);
+ }
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ position = getRtlPosition(position);
+ int tabStripChildCount = mTabStrip.getChildCount();
+ if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) {
+ return;
+ }
+
+ mTabStrip.onPageScrolled(position, positionOffset, positionOffsetPixels);
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ position = getRtlPosition(position);
+ int tabStripChildCount = mTabStrip.getChildCount();
+ if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) {
+ return;
+ }
+
+ if (mPrevSelected >= 0 && mPrevSelected < tabStripChildCount) {
+ mTabStrip.getChildAt(mPrevSelected).setSelected(false);
+ }
+ final View selectedChild = mTabStrip.getChildAt(position);
+ selectedChild.setSelected(true);
+
+ // Update scroll position
+ final int scrollPos = selectedChild.getLeft() - (getWidth() - selectedChild.getWidth()) / 2;
+ smoothScrollTo(scrollPos, 0);
+ mPrevSelected = position;
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ }
+
+ private int getRtlPosition(int position) {
+ if (OsUtil.isAtLeastJB_MR2() && Factory.get().getApplicationContext().getResources()
+ .getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
+ return mTabStrip.getChildCount() - 1 - position;
+ }
+ return position;
+ }
+
+ public int getSelectedItemPosition() {
+ return mPrevSelected;
+ }
+}
diff --git a/src/com/android/messaging/ui/WidgetPickConversationActivity.java b/src/com/android/messaging/ui/WidgetPickConversationActivity.java
new file mode 100644
index 0000000..60e1318
--- /dev/null
+++ b/src/com/android/messaging/ui/WidgetPickConversationActivity.java
@@ -0,0 +1,114 @@
+/*
+ * 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.messaging.ui;
+
+import android.app.Fragment;
+import android.appwidget.AppWidgetManager;
+import android.content.Intent;
+import android.os.Bundle;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.ui.conversationlist.ShareIntentFragment;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.widget.WidgetConversationProvider;
+
+public class WidgetPickConversationActivity extends BaseBugleActivity implements
+ ShareIntentFragment.HostInterface {
+
+ private int mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+
+ // Set the result to CANCELED. This will cause the widget host to cancel
+ // out of the widget placement if they press the back button.
+ setResult(RESULT_CANCELED);
+
+ // Find the widget id from the intent.
+ final Intent intent = getIntent();
+ final Bundle extras = intent.getExtras();
+ if (extras != null) {
+ mAppWidgetId = extras.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
+ }
+
+ // If they gave us an intent without the widget id, just bail.
+ if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ finish();
+ }
+
+ final ShareIntentFragment convPicker = new ShareIntentFragment();
+ final Bundle bundle = new Bundle();
+ bundle.putBoolean(ShareIntentFragment.HIDE_NEW_CONVERSATION_BUTTON_KEY, true);
+ convPicker.setArguments(bundle);
+ convPicker.show(getFragmentManager(), "ShareIntentFragment");
+ }
+
+ @Override
+ public void onAttachFragment(final Fragment fragment) {
+ final Intent intent = getIntent();
+ final String action = intent.getAction();
+ if (!AppWidgetManager.ACTION_APPWIDGET_CONFIGURE.equals(action)) {
+ // Unsupported action.
+ Assert.fail("Unsupported action type: " + action);
+ }
+ }
+
+ @Override
+ public void onConversationClick(final ConversationListItemData conversationListItemData) {
+ saveConversationidPref(mAppWidgetId, conversationListItemData.getConversationId());
+
+ // Push widget update to surface with newly set prefix
+ WidgetConversationProvider.rebuildWidget(this, mAppWidgetId);
+
+ // Make sure we pass back the original appWidgetId
+ Intent resultValue = new Intent();
+ resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
+ setResult(RESULT_OK, resultValue);
+ finish();
+ }
+
+ @Override
+ public void onCreateConversationClick() {
+ // We should never get here because we're hiding the new conversation button in the
+ // ShareIntentFragment by setting HIDE_NEW_CONVERSATION_BUTTON_KEY in the arguments.
+ finish();
+ }
+
+ // Write the ConversationId to the SharedPreferences object for this widget
+ static void saveConversationidPref(int appWidgetId, String conversationId) {
+ final BuglePrefs prefs = Factory.get().getWidgetPrefs();
+ prefs.putString(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID + appWidgetId, conversationId);
+ }
+
+ // Read the ConversationId from the SharedPreferences object for this widget.
+ public static String getConversationIdPref(int appWidgetId) {
+ final BuglePrefs prefs = Factory.get().getWidgetPrefs();
+ return prefs.getString(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID + appWidgetId, null);
+ }
+
+ // Delete the ConversationId preference from the SharedPreferences object for this widget.
+ public static void deleteConversationIdPref(int appWidgetId) {
+ final BuglePrefs prefs = Factory.get().getWidgetPrefs();
+ prefs.remove(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID + appWidgetId);
+ }
+
+}
diff --git a/src/com/android/messaging/ui/animation/PopupTransitionAnimation.java b/src/com/android/messaging/ui/animation/PopupTransitionAnimation.java
new file mode 100644
index 0000000..21529c6
--- /dev/null
+++ b/src/com/android/messaging/ui/animation/PopupTransitionAnimation.java
@@ -0,0 +1,302 @@
+/*
+ * 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.messaging.ui.animation;
+
+import android.animation.TypeEvaluator;
+import android.app.Activity;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.Transformation;
+import android.widget.PopupWindow;
+
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.ThreadUtil;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * Animates viewToAnimate from startRect to the place where it is in the layout, viewToAnimate
+ * should be in its final destination location before startAfterLayoutComplete is called.
+ * viewToAnimate will be drawn scaled and offset in a popupWindow.
+ * This class handles the case where the viewToAnimate moves during the animation
+ */
+public class PopupTransitionAnimation extends Animation {
+ /** The view we're animating */
+ private final View mViewToAnimate;
+
+ /** The rect to start the slide in animation from */
+ private final Rect mStartRect;
+
+ /** The rect of the currently animated view */
+ private Rect mCurrentRect;
+
+ /** The rect that we're animating to. This can change during the animation */
+ private final Rect mDestRect;
+
+ /** The bounds of the popup in window coordinates. Does not include notification bar */
+ private final Rect mPopupRect;
+
+ /** The bounds of the action bar in window coordinates. We clip the popup to below this */
+ private final Rect mActionBarRect;
+
+ /** Interpolates between the start and end rect for every animation tick */
+ private final TypeEvaluator<Rect> mRectEvaluator;
+
+ /** The popup window that holds contains the animating view */
+ private PopupWindow mPopupWindow;
+
+ /** The layout root for the popup which is where the animated view is rendered */
+ private View mPopupRoot;
+
+ /** The action bar's view */
+ private final View mActionBarView;
+
+ private Runnable mOnStartCallback;
+ private Runnable mOnStopCallback;
+
+ public PopupTransitionAnimation(final Rect startRect, final View viewToAnimate) {
+ mViewToAnimate = viewToAnimate;
+ mStartRect = startRect;
+ mCurrentRect = new Rect(mStartRect);
+ mDestRect = new Rect();
+ mPopupRect = new Rect();
+ mActionBarRect = new Rect();
+ final Activity activity = (Activity) viewToAnimate.getRootView().getContext();
+ mActionBarView = activity.getWindow().getDecorView().findViewById(
+ android.support.v7.appcompat.R.id.action_bar);
+ mRectEvaluator = RectEvaluatorCompat.create();
+ setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION);
+ setInterpolator(UiUtils.DEFAULT_INTERPOLATOR);
+ setAnimationListener(new AnimationListener() {
+ @Override
+ public void onAnimationStart(final Animation animation) {
+ if (mOnStartCallback != null) {
+ mOnStartCallback.run();
+ }
+ mEvents.append("oAS,");
+ }
+
+ @Override
+ public void onAnimationEnd(final Animation animation) {
+ if (mOnStopCallback != null) {
+ mOnStopCallback.run();
+ }
+ dismiss();
+ mEvents.append("oAE,");
+ }
+
+ @Override
+ public void onAnimationRepeat(final Animation animation) {
+ }
+ });
+ }
+
+ private final StringBuilder mEvents = new StringBuilder();
+ private final Runnable mCleanupRunnable = new Runnable() {
+ @Override
+ public void run() {
+ LogUtil.w(LogUtil.BUGLE_TAG, "PopupTransitionAnimation: " + mEvents);
+ }
+ };
+
+ /**
+ * Ensures the animation is ready before starting the animation.
+ * viewToAnimate must first be layed out so we know where we will animate to
+ */
+ public void startAfterLayoutComplete() {
+ // We want layout to occur, and then we immediately animate it in, so hide it initially to
+ // reduce jank on the first frame
+ mViewToAnimate.setVisibility(View.INVISIBLE);
+ mViewToAnimate.setAlpha(0);
+
+ final Runnable startAnimation = new Runnable() {
+ boolean mRunComplete = false;
+ boolean mFirstTry = true;
+
+ @Override
+ public void run() {
+ if (mRunComplete) {
+ return;
+ }
+
+ mViewToAnimate.getGlobalVisibleRect(mDestRect);
+ // In Android views which are visible but haven't computed their size yet have a
+ // size of 1x1 because anything with a size of 0x0 is considered hidden. We can't
+ // start the animation until after the size is greater than 1x1
+ if (mDestRect.width() <= 1 || mDestRect.height() <= 1) {
+ // Layout hasn't occurred yet
+ if (!mFirstTry) {
+ // Give up if this is not the first try, since layout change still doesn't
+ // yield a size for the view. This is likely because the media picker is
+ // full screen so there's no space left for the animated view. We give up
+ // on animation, but need to make sure the view that was initially
+ // hidden is re-shown.
+ mViewToAnimate.setAlpha(1);
+ mViewToAnimate.setVisibility(View.VISIBLE);
+ } else {
+ mFirstTry = false;
+ UiUtils.doOnceAfterLayoutChange(mViewToAnimate, this);
+ }
+ return;
+ }
+
+ mRunComplete = true;
+ mViewToAnimate.startAnimation(PopupTransitionAnimation.this);
+ mViewToAnimate.invalidate();
+ // http://b/20856505: The PopupWindow sometimes does not get dismissed.
+ ThreadUtil.getMainThreadHandler().postDelayed(mCleanupRunnable, getDuration() * 2);
+ }
+ };
+
+ startAnimation.run();
+ }
+
+ public PopupTransitionAnimation setOnStartCallback(final Runnable onStart) {
+ mOnStartCallback = onStart;
+ return this;
+ }
+
+ public PopupTransitionAnimation setOnStopCallback(final Runnable onStop) {
+ mOnStopCallback = onStop;
+ return this;
+ }
+
+ @Override
+ protected void applyTransformation(final float interpolatedTime, final Transformation t) {
+ if (mPopupWindow == null) {
+ initPopupWindow();
+ }
+ // Update mDestRect as it may have moved during the animation
+ mPopupRect.set(UiUtils.getMeasuredBoundsOnScreen(mPopupRoot));
+ mActionBarRect.set(UiUtils.getMeasuredBoundsOnScreen(mActionBarView));
+ computeDestRect();
+
+ // Update currentRect to the new animated coordinates, and request mPopupRoot to redraw
+ // itself at the new coordinates
+ mCurrentRect = mRectEvaluator.evaluate(interpolatedTime, mStartRect, mDestRect);
+ mPopupRoot.invalidate();
+
+ if (interpolatedTime >= 0.98) {
+ mEvents.append("aT").append(interpolatedTime).append(',');
+ }
+ if (interpolatedTime == 1) {
+ dismiss();
+ }
+ }
+
+ private void dismiss() {
+ mEvents.append("d,");
+ mViewToAnimate.setAlpha(1);
+ mViewToAnimate.setVisibility(View.VISIBLE);
+ // Delay dismissing the popup window to let mViewToAnimate draw under it and reduce the
+ // flash
+ ThreadUtil.getMainThreadHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ mPopupWindow.dismiss();
+ } catch (IllegalArgumentException e) {
+ // PopupWindow.dismiss() will fire an IllegalArgumentException if the activity
+ // has already ended while we were animating
+ }
+ ThreadUtil.getMainThreadHandler().removeCallbacks(mCleanupRunnable);
+ }
+ });
+ }
+
+ @Override
+ public boolean willChangeBounds() {
+ return false;
+ }
+
+ /**
+ * Computes mDestRect (the position in window space of the placeholder view that we should
+ * animate to). Some frames during the animation fail to compute getGlobalVisibleRect, so use
+ * the last known values in that case
+ */
+ private void computeDestRect() {
+ final int prevTop = mDestRect.top;
+ final int prevLeft = mDestRect.left;
+ final int prevRight = mDestRect.right;
+ final int prevBottom = mDestRect.bottom;
+
+ if (!getViewScreenMeasureRect(mViewToAnimate, mDestRect)) {
+ mDestRect.top = prevTop;
+ mDestRect.left = prevLeft;
+ mDestRect.bottom = prevBottom;
+ mDestRect.right = prevRight;
+ }
+ }
+
+ /**
+ * Sets up the PopupWindow that the view will animate in. Animating the size and position of a
+ * popup can be choppy, so instead we make the popup fill the entire space of the screen, and
+ * animate the position of viewToAnimate within the popup using a Transformation
+ */
+ private void initPopupWindow() {
+ mPopupRoot = new View(mViewToAnimate.getContext()) {
+ @Override
+ protected void onDraw(final Canvas canvas) {
+ canvas.save();
+ canvas.clipRect(getLeft(), mActionBarRect.bottom - mPopupRect.top, getRight(),
+ getBottom());
+ canvas.drawColor(Color.TRANSPARENT);
+ final float previousAlpha = mViewToAnimate.getAlpha();
+ mViewToAnimate.setAlpha(1);
+ // The view's global position includes the notification bar height, but
+ // the popup window may or may not cover the notification bar (depending on screen
+ // rotation, IME status etc.), so we need to compensate for this difference by
+ // offseting vertically.
+ canvas.translate(mCurrentRect.left, mCurrentRect.top - mPopupRect.top);
+
+ final float viewWidth = mViewToAnimate.getWidth();
+ final float viewHeight = mViewToAnimate.getHeight();
+ if (viewWidth > 0 && viewHeight > 0) {
+ canvas.scale(mCurrentRect.width() / viewWidth,
+ mCurrentRect.height() / viewHeight);
+ }
+ canvas.clipRect(0, 0, mCurrentRect.width(), mCurrentRect.height());
+ if (!mPopupRect.isEmpty()) {
+ // HACK: Layout is unstable until mPopupRect is non-empty.
+ mViewToAnimate.draw(canvas);
+ }
+ mViewToAnimate.setAlpha(previousAlpha);
+ canvas.restore();
+ }
+ };
+ mPopupWindow = new PopupWindow(mViewToAnimate.getContext());
+ mPopupWindow.setBackgroundDrawable(null);
+ mPopupWindow.setContentView(mPopupRoot);
+ mPopupWindow.setWidth(ViewGroup.LayoutParams.MATCH_PARENT);
+ mPopupWindow.setHeight(ViewGroup.LayoutParams.MATCH_PARENT);
+ mPopupWindow.setTouchable(false);
+ // We must pass a non-zero value for the y offset, or else the system resets the status bar
+ // color to black (M only) during the animation. The actual position of the window (and
+ // the animated view inside it) are still correct, regardless of what we pass for the y
+ // parameter (e.g. 1 and 100 both work). Not entirely sure why this works.
+ mPopupWindow.showAtLocation(mViewToAnimate, Gravity.TOP, 0, 1);
+ }
+
+ private static boolean getViewScreenMeasureRect(final View view, final Rect outRect) {
+ outRect.set(UiUtils.getMeasuredBoundsOnScreen(view));
+ return !outRect.isEmpty();
+ }
+}
diff --git a/src/com/android/messaging/ui/animation/RectEvaluatorCompat.java b/src/com/android/messaging/ui/animation/RectEvaluatorCompat.java
new file mode 100644
index 0000000..e3c60fc
--- /dev/null
+++ b/src/com/android/messaging/ui/animation/RectEvaluatorCompat.java
@@ -0,0 +1,45 @@
+/*
+ * 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.messaging.ui.animation;
+
+import android.animation.RectEvaluator;
+import android.animation.TypeEvaluator;
+import android.graphics.Rect;
+
+import com.android.messaging.util.OsUtil;
+
+/**
+ * This evaluator can be used to perform type interpolation between <code>Rect</code> values.
+ * It's backward compatible to Api Level 11.
+ */
+public class RectEvaluatorCompat implements TypeEvaluator<Rect> {
+ public static TypeEvaluator<Rect> create() {
+ if (OsUtil.isAtLeastJB_MR2()) {
+ return new RectEvaluator();
+ } else {
+ return new RectEvaluatorCompat();
+ }
+ }
+
+ @Override
+ public Rect evaluate(float fraction, Rect startValue, Rect endValue) {
+ int left = startValue.left + (int) ((endValue.left - startValue.left) * fraction);
+ int top = startValue.top + (int) ((endValue.top - startValue.top) * fraction);
+ int right = startValue.right + (int) ((endValue.right - startValue.right) * fraction);
+ int bottom = startValue.bottom + (int) ((endValue.bottom - startValue.bottom) * fraction);
+ return new Rect(left, top, right, bottom);
+ }
+}
diff --git a/src/com/android/messaging/ui/animation/ViewGroupItemVerticalExplodeAnimation.java b/src/com/android/messaging/ui/animation/ViewGroupItemVerticalExplodeAnimation.java
new file mode 100644
index 0000000..6abfdf9
--- /dev/null
+++ b/src/com/android/messaging/ui/animation/ViewGroupItemVerticalExplodeAnimation.java
@@ -0,0 +1,208 @@
+/*
+ * 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.messaging.ui.animation;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.support.v4.view.ViewCompat;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewGroupOverlay;
+import android.view.ViewOverlay;
+import android.widget.FrameLayout;
+
+import com.android.messaging.R;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * <p>
+ * Shows a vertical "explode" animation for any view inside a view group (e.g. views inside a
+ * ListView). During the animation, a snapshot is taken for the view to the animated and
+ * presented in a popup window or view overlay on top of the original view group. The background
+ * of the view (a highlight) vertically expands (explodes) during the animation.
+ * </p>
+ * <p>
+ * The exact implementation of the animation depends on platform API level. For JB_MR2 and later,
+ * the implementation utilizes ViewOverlay to perform highly performant overlay animations; for
+ * older API levels, the implementation falls back to using a full screen popup window to stage
+ * the animation.
+ * </p>
+ * <p>
+ * To start this animation, call {@link #startAnimationForView(ViewGroup, View, View, boolean, int)}
+ * </p>
+ */
+public class ViewGroupItemVerticalExplodeAnimation {
+ /**
+ * Starts a vertical explode animation for a given view situated in a given container.
+ *
+ * @param container the container of the view which determines the explode animation's final
+ * size
+ * @param viewToAnimate the view to be animated. The view will be highlighted by the explode
+ * highlight, which expands from the size of the view to the size of the container.
+ * @param animationStagingView the view that stages the animation. Since viewToAnimate may be
+ * removed from the view tree during the animation, we need a view that'll be alive
+ * for the duration of the animation so that the animation won't get cancelled.
+ * @param snapshotView whether a snapshot of the view to animate is needed.
+ */
+ public static void startAnimationForView(final ViewGroup container, final View viewToAnimate,
+ final View animationStagingView, final boolean snapshotView, final int duration) {
+ if (OsUtil.isAtLeastJB_MR2() && (viewToAnimate.getContext() instanceof Activity)) {
+ new ViewExplodeAnimationJellyBeanMR2(viewToAnimate, container, snapshotView, duration)
+ .startAnimation();
+ } else {
+ // Pre JB_MR2, this animation can cause rendering failures which causes the framework
+ // to fall back to software rendering where camera preview isn't supported (b/18264647)
+ // just skip the animation to avoid this case.
+ }
+ }
+
+ /**
+ * Implementation class for API level >= 18.
+ */
+ @TargetApi(18)
+ private static class ViewExplodeAnimationJellyBeanMR2 {
+ private final View mViewToAnimate;
+ private final ViewGroup mContainer;
+ private final View mSnapshot;
+ private final Bitmap mViewBitmap;
+ private final int mDuration;
+
+ public ViewExplodeAnimationJellyBeanMR2(final View viewToAnimate, final ViewGroup container,
+ final boolean snapshotView, final int duration) {
+ mViewToAnimate = viewToAnimate;
+ mContainer = container;
+ mDuration = duration;
+ if (snapshotView) {
+ mViewBitmap = snapshotView(viewToAnimate);
+ mSnapshot = new View(viewToAnimate.getContext());
+ } else {
+ mSnapshot = null;
+ mViewBitmap = null;
+ }
+ }
+
+ public void startAnimation() {
+ final Context context = mViewToAnimate.getContext();
+ final Resources resources = context.getResources();
+ final View decorView = ((Activity) context).getWindow().getDecorView();
+ final ViewOverlay viewOverlay = decorView.getOverlay();
+ if (viewOverlay instanceof ViewGroupOverlay) {
+ final ViewGroupOverlay overlay = (ViewGroupOverlay) viewOverlay;
+
+ // Add a shadow layer to the overlay.
+ final FrameLayout shadowContainerLayer = new FrameLayout(context);
+ final Drawable oldBackground = mViewToAnimate.getBackground();
+ final Rect containerRect = UiUtils.getMeasuredBoundsOnScreen(mContainer);
+ final Rect decorRect = UiUtils.getMeasuredBoundsOnScreen(decorView);
+ // Position the container rect relative to the decor rect since the decor rect
+ // defines whether the view overlay will be positioned.
+ containerRect.offset(-decorRect.left, -decorRect.top);
+ shadowContainerLayer.setLeft(containerRect.left);
+ shadowContainerLayer.setTop(containerRect.top);
+ shadowContainerLayer.setBottom(containerRect.bottom);
+ shadowContainerLayer.setRight(containerRect.right);
+ shadowContainerLayer.setBackgroundColor(resources.getColor(
+ R.color.open_conversation_animation_background_shadow));
+ // Per design request, temporarily clear out the background of the item content
+ // to not show any ripple effects during animation.
+ if (!(oldBackground instanceof ColorDrawable)) {
+ mViewToAnimate.setBackground(null);
+ }
+ overlay.add(shadowContainerLayer);
+
+ // Add a expand layer and position it with in the shadow background, so it can
+ // be properly clipped to the container bounds during the animation.
+ final View expandLayer = new View(context);
+ final int elevation = resources.getDimensionPixelSize(
+ R.dimen.explode_animation_highlight_elevation);
+ final Rect viewRect = UiUtils.getMeasuredBoundsOnScreen(mViewToAnimate);
+ // Frame viewRect from screen space to containerRect space.
+ viewRect.offset(-containerRect.left - decorRect.left,
+ -containerRect.top - decorRect.top);
+ // Since the expand layer expands at the same rate above and below, we need to
+ // compute the expand scale using the bigger of the top/bottom distances.
+ final int expandLayerHalfHeight = viewRect.height() / 2;
+ final int topDist = viewRect.top;
+ final int bottomDist = containerRect.height() - viewRect.bottom;
+ final float scale = expandLayerHalfHeight == 0 ? 1 :
+ ((float) Math.max(topDist, bottomDist) + expandLayerHalfHeight) /
+ expandLayerHalfHeight;
+ // Position the expand layer initially to exactly match the animated item.
+ shadowContainerLayer.addView(expandLayer);
+ expandLayer.setLeft(viewRect.left);
+ expandLayer.setTop(viewRect.top);
+ expandLayer.setBottom(viewRect.bottom);
+ expandLayer.setRight(viewRect.right);
+ expandLayer.setBackgroundColor(resources.getColor(
+ R.color.conversation_background));
+ ViewCompat.setElevation(expandLayer, elevation);
+
+ // Conditionally stage the snapshot in the overlay.
+ if (mSnapshot != null) {
+ shadowContainerLayer.addView(mSnapshot);
+ mSnapshot.setLeft(viewRect.left);
+ mSnapshot.setTop(viewRect.top);
+ mSnapshot.setBottom(viewRect.bottom);
+ mSnapshot.setRight(viewRect.right);
+ mSnapshot.setBackground(new BitmapDrawable(resources, mViewBitmap));
+ ViewCompat.setElevation(mSnapshot, elevation);
+ }
+
+ // Apply a scale animation to scale to full screen.
+ expandLayer.animate().scaleY(scale)
+ .setDuration(mDuration)
+ .setInterpolator(UiUtils.EASE_IN_INTERPOLATOR)
+ .withEndAction(new Runnable() {
+ @Override
+ public void run() {
+ // Clean up the views added to overlay on animation finish.
+ overlay.remove(shadowContainerLayer);
+ mViewToAnimate.setBackground(oldBackground);
+ if (mViewBitmap != null) {
+ mViewBitmap.recycle();
+ }
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * Take a snapshot of the given review, return a Bitmap object that's owned by the caller.
+ */
+ static Bitmap snapshotView(final View view) {
+ // Save the content of the view into a bitmap.
+ final Bitmap viewBitmap = Bitmap.createBitmap(view.getWidth(),
+ view.getHeight(), Bitmap.Config.ARGB_8888);
+ // Strip the view of its background when taking a snapshot so that things like touch
+ // feedback don't get accidentally snapshotted.
+ final Drawable viewBackground = view.getBackground();
+ ImageUtils.setBackgroundDrawableOnView(view, null);
+ view.draw(new Canvas(viewBitmap));
+ ImageUtils.setBackgroundDrawableOnView(view, viewBackground);
+ return viewBitmap;
+ }
+}
diff --git a/src/com/android/messaging/ui/appsettings/ApnEditorActivity.java b/src/com/android/messaging/ui/appsettings/ApnEditorActivity.java
new file mode 100644
index 0000000..b7cb7ae
--- /dev/null
+++ b/src/com/android/messaging/ui/appsettings/ApnEditorActivity.java
@@ -0,0 +1,463 @@
+/*
+ * 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.messaging.ui.appsettings;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.ContentValues;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.preference.EditTextPreference;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import android.provider.Telephony;
+import android.support.v4.app.NavUtils;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.ApnDatabase;
+import com.android.messaging.sms.BugleApnSettingsLoader;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.PhoneUtils;
+
+public class ApnEditorActivity extends BugleActionBarActivity {
+ private static final int ERROR_DIALOG_ID = 0;
+ private static final String ERROR_MESSAGE_KEY = "error_msg";
+ private ApnEditorFragment mApnEditorFragment;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ // Display the fragment as the main content.
+ mApnEditorFragment = new ApnEditorFragment();
+ mApnEditorFragment.setSubId(getIntent().getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID,
+ ParticipantData.DEFAULT_SELF_SUB_ID));
+ getFragmentManager().beginTransaction()
+ .replace(android.R.id.content, mApnEditorFragment)
+ .commit();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int id, Bundle args) {
+
+ if (id == ERROR_DIALOG_ID) {
+ String msg = args.getString(ERROR_MESSAGE_KEY);
+
+ return new AlertDialog.Builder(this)
+ .setPositiveButton(android.R.string.ok, null)
+ .setMessage(msg)
+ .create();
+ }
+
+ return super.onCreateDialog(id);
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BACK: {
+ if (mApnEditorFragment.validateAndSave(false)) {
+ finish();
+ }
+ return true;
+ }
+ }
+ return super.onKeyDown(keyCode, event);
+ }
+
+ @Override
+ protected void onPrepareDialog(int id, Dialog dialog, Bundle args) {
+ super.onPrepareDialog(id, dialog);
+
+ if (id == ERROR_DIALOG_ID) {
+ final String msg = args.getString(ERROR_MESSAGE_KEY);
+
+ if (msg != null) {
+ ((AlertDialog) dialog).setMessage(msg);
+ }
+ }
+ }
+
+ public static class ApnEditorFragment extends PreferenceFragment implements
+ SharedPreferences.OnSharedPreferenceChangeListener {
+
+ private static final String SAVED_POS = "pos";
+
+ private static final int MENU_DELETE = Menu.FIRST;
+ private static final int MENU_SAVE = Menu.FIRST + 1;
+ private static final int MENU_CANCEL = Menu.FIRST + 2;
+
+ private EditTextPreference mMmsProxy;
+ private EditTextPreference mMmsPort;
+ private EditTextPreference mName;
+ private EditTextPreference mMmsc;
+ private EditTextPreference mMcc;
+ private EditTextPreference mMnc;
+ private static String sNotSet;
+
+ private String mCurMnc;
+ private String mCurMcc;
+
+ private Cursor mCursor;
+ private boolean mNewApn;
+ private boolean mFirstTime;
+ private String mCurrentId;
+
+ private int mSubId;
+
+ /**
+ * Standard projection for the interesting columns of a normal note.
+ */
+ private static final String[] sProjection = new String[] {
+ Telephony.Carriers._ID, // 0
+ Telephony.Carriers.NAME, // 1
+ Telephony.Carriers.MMSC, // 2
+ Telephony.Carriers.MCC, // 3
+ Telephony.Carriers.MNC, // 4
+ Telephony.Carriers.NUMERIC, // 5
+ Telephony.Carriers.MMSPROXY, // 6
+ Telephony.Carriers.MMSPORT, // 7
+ Telephony.Carriers.TYPE, // 8
+ };
+
+ private static final int ID_INDEX = 0;
+ private static final int NAME_INDEX = 1;
+ private static final int MMSC_INDEX = 2;
+ private static final int MCC_INDEX = 3;
+ private static final int MNC_INDEX = 4;
+ private static final int NUMERIC_INDEX = 5;
+ private static final int MMSPROXY_INDEX = 6;
+ private static final int MMSPORT_INDEX = 7;
+ private static final int TYPE_INDEX = 8;
+
+ private SQLiteDatabase mDatabase;
+
+ @Override
+ public void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ addPreferencesFromResource(R.xml.apn_editor);
+
+ setHasOptionsMenu(true);
+
+ sNotSet = getResources().getString(R.string.apn_not_set);
+ mName = (EditTextPreference) findPreference("apn_name");
+ mMmsProxy = (EditTextPreference) findPreference("apn_mms_proxy");
+ mMmsPort = (EditTextPreference) findPreference("apn_mms_port");
+ mMmsc = (EditTextPreference) findPreference("apn_mmsc");
+ mMcc = (EditTextPreference) findPreference("apn_mcc");
+ mMnc = (EditTextPreference) findPreference("apn_mnc");
+
+ final Intent intent = getActivity().getIntent();
+
+ mFirstTime = savedInstanceState == null;
+ mCurrentId = intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_APN_ROW_ID);
+ mNewApn = mCurrentId == null;
+
+ mDatabase = ApnDatabase.getApnDatabase().getWritableDatabase();
+
+ if (mNewApn) {
+ fillUi();
+ } else {
+ // Do initial query not on the UI thread
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ if (mCurrentId != null) {
+ String selection = Telephony.Carriers._ID + " =?";
+ String[] selectionArgs = new String[]{ mCurrentId };
+ mCursor = mDatabase.query(ApnDatabase.APN_TABLE, sProjection, selection,
+ selectionArgs, null, null, null, null);
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ if (mCursor == null) {
+ getActivity().finish();
+ return;
+ }
+ mCursor.moveToFirst();
+
+ fillUi();
+ }
+ }.execute((Void) null);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (mCursor != null) {
+ mCursor.close();
+ mCursor = null;
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ getPreferenceScreen().getSharedPreferences()
+ .registerOnSharedPreferenceChangeListener(this);
+ }
+
+ @Override
+ public void onPause() {
+ getPreferenceScreen().getSharedPreferences()
+ .unregisterOnSharedPreferenceChangeListener(this);
+ super.onPause();
+ }
+
+ public void setSubId(final int subId) {
+ mSubId = subId;
+ }
+
+ private void fillUi() {
+ if (mNewApn) {
+ mMcc.setText(null);
+ mMnc.setText(null);
+ String numeric = PhoneUtils.get(mSubId).getSimOperatorNumeric();
+ // MCC is first 3 chars and then in 2 - 3 chars of MNC
+ if (numeric != null && numeric.length() > 4) {
+ // Country code
+ String mcc = numeric.substring(0, 3);
+ // Network code
+ String mnc = numeric.substring(3);
+ // Auto populate MNC and MCC for new entries, based on what SIM reports
+ mMcc.setText(mcc);
+ mMnc.setText(mnc);
+ mCurMnc = mnc;
+ mCurMcc = mcc;
+ }
+ mName.setText(null);
+ mMmsProxy.setText(null);
+ mMmsPort.setText(null);
+ mMmsc.setText(null);
+ } else if (mFirstTime) {
+ mFirstTime = false;
+ // Fill in all the values from the db in both text editor and summary
+ mName.setText(mCursor.getString(NAME_INDEX));
+ mMmsProxy.setText(mCursor.getString(MMSPROXY_INDEX));
+ mMmsPort.setText(mCursor.getString(MMSPORT_INDEX));
+ mMmsc.setText(mCursor.getString(MMSC_INDEX));
+ mMcc.setText(mCursor.getString(MCC_INDEX));
+ mMnc.setText(mCursor.getString(MNC_INDEX));
+ }
+
+ mName.setSummary(checkNull(mName.getText()));
+ mMmsProxy.setSummary(checkNull(mMmsProxy.getText()));
+ mMmsPort.setSummary(checkNull(mMmsPort.getText()));
+ mMmsc.setSummary(checkNull(mMmsc.getText()));
+ mMcc.setSummary(checkNull(mMcc.getText()));
+ mMnc.setSummary(checkNull(mMnc.getText()));
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ // If it's a new APN, then cancel will delete the new entry in onPause
+ if (!mNewApn) {
+ menu.add(0, MENU_DELETE, 0, R.string.menu_delete_apn)
+ .setIcon(R.drawable.ic_delete_small_dark);
+ }
+ menu.add(0, MENU_SAVE, 0, R.string.menu_save_apn)
+ .setIcon(android.R.drawable.ic_menu_save);
+ menu.add(0, MENU_CANCEL, 0, R.string.menu_discard_apn_change)
+ .setIcon(android.R.drawable.ic_menu_close_clear_cancel);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case MENU_DELETE:
+ deleteApn();
+ return true;
+
+ case MENU_SAVE:
+ if (validateAndSave(false)) {
+ getActivity().finish();
+ }
+ return true;
+
+ case MENU_CANCEL:
+ getActivity().finish();
+ return true;
+
+ case android.R.id.home:
+ getActivity().onBackPressed();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle icicle) {
+ super.onSaveInstanceState(icicle);
+ if (validateAndSave(true) && mCursor != null) {
+ icicle.putInt(SAVED_POS, mCursor.getInt(ID_INDEX));
+ }
+ }
+
+ /**
+ * Check the key fields' validity and save if valid.
+ * @param force save even if the fields are not valid, if the app is
+ * being suspended
+ * @return true if the data was saved
+ */
+ private boolean validateAndSave(boolean force) {
+ final String name = checkNotSet(mName.getText());
+ final String mcc = checkNotSet(mMcc.getText());
+ final String mnc = checkNotSet(mMnc.getText());
+
+ if (getErrorMsg() != null && !force) {
+ final Bundle bundle = new Bundle();
+ bundle.putString(ERROR_MESSAGE_KEY, getErrorMsg());
+ getActivity().showDialog(ERROR_DIALOG_ID, bundle);
+ return false;
+ }
+
+ // Make database changes not on the UI thread
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ ContentValues values = new ContentValues();
+
+ // Add a dummy name "Untitled", if the user exits the screen without adding a
+ // name but entered other information worth keeping.
+ values.put(Telephony.Carriers.NAME, name.length() < 1 ?
+ getResources().getString(R.string.untitled_apn) : name);
+ values.put(Telephony.Carriers.MMSPROXY, checkNotSet(mMmsProxy.getText()));
+ values.put(Telephony.Carriers.MMSPORT, checkNotSet(mMmsPort.getText()));
+ values.put(Telephony.Carriers.MMSC, checkNotSet(mMmsc.getText()));
+
+ values.put(Telephony.Carriers.TYPE, BugleApnSettingsLoader.APN_TYPE_MMS);
+
+ values.put(Telephony.Carriers.MCC, mcc);
+ values.put(Telephony.Carriers.MNC, mnc);
+
+ values.put(Telephony.Carriers.NUMERIC, mcc + mnc);
+
+ if (mCurMnc != null && mCurMcc != null) {
+ if (mCurMnc.equals(mnc) && mCurMcc.equals(mcc)) {
+ values.put(Telephony.Carriers.CURRENT, 1);
+ }
+ }
+
+ if (mNewApn) {
+ mDatabase.insert(ApnDatabase.APN_TABLE, null, values);
+ } else {
+ // update the APN
+ String selection = Telephony.Carriers._ID + " =?";
+ String[] selectionArgs = new String[]{ mCurrentId };
+ int updated = mDatabase.update(ApnDatabase.APN_TABLE, values,
+ selection, selectionArgs);
+ }
+ return null;
+ }
+ }.execute((Void) null);
+
+ return true;
+ }
+
+ private void deleteApn() {
+ // Make database changes not on the UI thread
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ // delete the APN
+ String where = Telephony.Carriers._ID + " =?";
+ String[] whereArgs = new String[]{ mCurrentId };
+
+ mDatabase.delete(ApnDatabase.APN_TABLE, where, whereArgs);
+ return null;
+ }
+ }.execute((Void) null);
+
+ getActivity().finish();
+ }
+
+ private String checkNull(String value) {
+ if (value == null || value.length() == 0) {
+ return sNotSet;
+ } else {
+ return value;
+ }
+ }
+
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ Preference pref = findPreference(key);
+ if (pref != null) {
+ pref.setSummary(checkNull(sharedPreferences.getString(key, "")));
+ }
+ }
+
+ private String getErrorMsg() {
+ String errorMsg = null;
+
+ String name = checkNotSet(mName.getText());
+ String mcc = checkNotSet(mMcc.getText());
+ String mnc = checkNotSet(mMnc.getText());
+
+ if (name.length() < 1) {
+ errorMsg = getString(R.string.error_apn_name_empty);
+ } else if (mcc.length() != 3) {
+ errorMsg = getString(R.string.error_mcc_not3);
+ } else if ((mnc.length() & 0xFFFE) != 2) {
+ errorMsg = getString(R.string.error_mnc_not23);
+ }
+
+ return errorMsg;
+ }
+
+ private String checkNotSet(String value) {
+ if (value == null || value.equals(sNotSet)) {
+ return "";
+ } else {
+ return value;
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/appsettings/ApnPreference.java b/src/com/android/messaging/ui/appsettings/ApnPreference.java
new file mode 100644
index 0000000..74c6a08
--- /dev/null
+++ b/src/com/android/messaging/ui/appsettings/ApnPreference.java
@@ -0,0 +1,151 @@
+/*
+ * 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.messaging.ui.appsettings;
+
+import android.content.Context;
+import android.preference.Preference;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.CompoundButton;
+import android.widget.RadioButton;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.ui.UIIntents;
+
+/**
+ * ApnPreference implements a pref, typically used as a list item, that has a title/summary on
+ * the left and a radio button on the right.
+ *
+ */
+public class ApnPreference extends Preference implements
+ CompoundButton.OnCheckedChangeListener, OnClickListener {
+ static final String TAG = "ApnPreference";
+
+ public ApnPreference(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ public ApnPreference(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.apnPreferenceStyle);
+ }
+
+ public ApnPreference(Context context) {
+ this(context, null);
+ }
+
+ private static String mSelectedKey = null;
+ private static CompoundButton mCurrentChecked = null;
+ private boolean mProtectFromCheckedChange = false;
+ private boolean mSelectable = true;
+ private int mSubId = ParticipantData.DEFAULT_SELF_SUB_ID;
+
+ @Override
+ public View getView(View convertView, ViewGroup parent) {
+ View view = super.getView(convertView, parent);
+
+ View widget = view.findViewById(R.id.apn_radiobutton);
+ if ((widget != null) && widget instanceof RadioButton) {
+ RadioButton rb = (RadioButton) widget;
+ if (mSelectable) {
+ rb.setOnCheckedChangeListener(this);
+
+ boolean isChecked = getKey().equals(mSelectedKey);
+ if (isChecked) {
+ mCurrentChecked = rb;
+ mSelectedKey = getKey();
+ }
+
+ mProtectFromCheckedChange = true;
+ rb.setChecked(isChecked);
+ mProtectFromCheckedChange = false;
+ } else {
+ rb.setVisibility(View.GONE);
+ }
+ setApnRadioButtonContentDescription(rb);
+ }
+
+ View textLayout = view.findViewById(R.id.text_layout);
+ if ((textLayout != null) && textLayout instanceof RelativeLayout) {
+ textLayout.setOnClickListener(this);
+ }
+
+ return view;
+ }
+
+ public void setApnRadioButtonContentDescription(final CompoundButton buttonView) {
+ final View widget = (View) buttonView.getParent();
+ final TextView tv = (TextView) widget.findViewById(android.R.id.title);
+ final String apnTitle = tv.getText().toString();
+ buttonView.setContentDescription(apnTitle);
+ }
+
+ public boolean isChecked() {
+ return getKey().equals(mSelectedKey);
+ }
+
+ public void setChecked() {
+ mSelectedKey = getKey();
+ }
+
+ public void setSubId(final int subId) {
+ mSubId = subId;
+ }
+
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ Log.i(TAG, "ID: " + getKey() + " :" + isChecked);
+ if (mProtectFromCheckedChange) {
+ return;
+ }
+
+ if (isChecked) {
+ if (mCurrentChecked != null) {
+ mCurrentChecked.setChecked(false);
+ }
+ mCurrentChecked = buttonView;
+ mSelectedKey = getKey();
+ callChangeListener(mSelectedKey);
+ } else {
+ mCurrentChecked = null;
+ mSelectedKey = null;
+ }
+ setApnRadioButtonContentDescription(buttonView);
+ }
+
+ public void onClick(android.view.View v) {
+ if ((v != null) && (R.id.text_layout == v.getId())) {
+ Context context = getContext();
+ if (context != null) {
+ context.startActivity(
+ UIIntents.get().getApnEditorIntent(context, getKey(), mSubId));
+ }
+ }
+ }
+
+ public void setSelectable(boolean selectable) {
+ mSelectable = selectable;
+ }
+
+ public boolean getSelectable() {
+ return mSelectable;
+ }
+}
diff --git a/src/com/android/messaging/ui/appsettings/ApnSettingsActivity.java b/src/com/android/messaging/ui/appsettings/ApnSettingsActivity.java
new file mode 100644
index 0000000..28dfc2a
--- /dev/null
+++ b/src/com/android/messaging/ui/appsettings/ApnSettingsActivity.java
@@ -0,0 +1,406 @@
+/*
+ * 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.messaging.ui.appsettings;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.ProgressDialog;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.os.UserManager;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceScreen;
+import android.provider.Telephony;
+import android.support.v4.app.NavUtils;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.ApnDatabase;
+import com.android.messaging.sms.BugleApnSettingsLoader;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+public class ApnSettingsActivity extends BugleActionBarActivity {
+ private static final int DIALOG_RESTORE_DEFAULTAPN = 1001;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ // Display the fragment as the main content.
+ final ApnSettingsFragment fragment = new ApnSettingsFragment();
+ fragment.setSubId(getIntent().getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID,
+ ParticipantData.DEFAULT_SELF_SUB_ID));
+ getFragmentManager().beginTransaction()
+ .replace(android.R.id.content, fragment)
+ .commit();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int id) {
+ if (id == DIALOG_RESTORE_DEFAULTAPN) {
+ ProgressDialog dialog = new ProgressDialog(this);
+ dialog.setMessage(getResources().getString(R.string.restore_default_apn));
+ dialog.setCancelable(false);
+ return dialog;
+ }
+ return null;
+ }
+
+ public static class ApnSettingsFragment extends PreferenceFragment implements
+ Preference.OnPreferenceChangeListener {
+ public static final String EXTRA_POSITION = "position";
+
+ public static final String APN_ID = "apn_id";
+
+ private static final String[] APN_PROJECTION = {
+ Telephony.Carriers._ID, // 0
+ Telephony.Carriers.NAME, // 1
+ Telephony.Carriers.APN, // 2
+ Telephony.Carriers.TYPE // 3
+ };
+ private static final int ID_INDEX = 0;
+ private static final int NAME_INDEX = 1;
+ private static final int APN_INDEX = 2;
+ private static final int TYPES_INDEX = 3;
+
+ private static final int MENU_NEW = Menu.FIRST;
+ private static final int MENU_RESTORE = Menu.FIRST + 1;
+
+ private static final int EVENT_RESTORE_DEFAULTAPN_START = 1;
+ private static final int EVENT_RESTORE_DEFAULTAPN_COMPLETE = 2;
+
+ private static boolean mRestoreDefaultApnMode;
+
+ private RestoreApnUiHandler mRestoreApnUiHandler;
+ private RestoreApnProcessHandler mRestoreApnProcessHandler;
+ private HandlerThread mRestoreDefaultApnThread;
+
+ private String mSelectedKey;
+
+ private static final ContentValues sCurrentNullMap;
+ private static final ContentValues sCurrentSetMap;
+
+ private UserManager mUm;
+
+ private boolean mUnavailable;
+ private int mSubId;
+
+ static {
+ sCurrentNullMap = new ContentValues(1);
+ sCurrentNullMap.putNull(Telephony.Carriers.CURRENT);
+
+ sCurrentSetMap = new ContentValues(1);
+ sCurrentSetMap.put(Telephony.Carriers.CURRENT, "2"); // 2 for user-selected APN,
+ // 1 for Bugle-selected APN
+ }
+
+ private SQLiteDatabase mDatabase;
+
+ public void setSubId(final int subId) {
+ mSubId = subId;
+ }
+
+ @Override
+ public void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+
+ mDatabase = ApnDatabase.getApnDatabase().getWritableDatabase();
+
+ if (OsUtil.isAtLeastL()) {
+ mUm = (UserManager) getActivity().getSystemService(Context.USER_SERVICE);
+ if (!mUm.hasUserRestriction(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS)) {
+ setHasOptionsMenu(true);
+ }
+ } else {
+ setHasOptionsMenu(true);
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ final ListView lv = (ListView) getView().findViewById(android.R.id.list);
+ TextView empty = (TextView) getView().findViewById(android.R.id.empty);
+ if (empty != null) {
+ empty.setText(R.string.apn_settings_not_available);
+ lv.setEmptyView(empty);
+ }
+
+ if (OsUtil.isAtLeastL() &&
+ mUm.hasUserRestriction(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS)) {
+ mUnavailable = true;
+ setPreferenceScreen(getPreferenceManager().createPreferenceScreen(getActivity()));
+ return;
+ }
+
+ addPreferencesFromResource(R.xml.apn_settings);
+
+ lv.setItemsCanFocus(true);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ if (mUnavailable) {
+ return;
+ }
+
+ if (!mRestoreDefaultApnMode) {
+ fillList();
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ if (mUnavailable) {
+ return;
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ if (mRestoreDefaultApnThread != null) {
+ mRestoreDefaultApnThread.quit();
+ }
+ }
+
+ private void fillList() {
+ final String mccMnc = PhoneUtils.getMccMncString(PhoneUtils.get(mSubId).getMccMnc());
+
+ new AsyncTask<Void, Void, Cursor>() {
+ @Override
+ protected Cursor doInBackground(Void... params) {
+ String selection = Telephony.Carriers.NUMERIC + " =?";
+ String[] selectionArgs = new String[]{ mccMnc };
+ final Cursor cursor = mDatabase.query(ApnDatabase.APN_TABLE, APN_PROJECTION,
+ selection, selectionArgs, null, null, null, null);
+ return cursor;
+ }
+
+ @Override
+ protected void onPostExecute(Cursor cursor) {
+ if (cursor != null) {
+ try {
+ PreferenceGroup apnList = (PreferenceGroup)
+ findPreference(getString(R.string.apn_list_pref_key));
+ apnList.removeAll();
+
+ mSelectedKey = BugleApnSettingsLoader.getFirstTryApn(mDatabase, mccMnc);
+ while (cursor.moveToNext()) {
+ String name = cursor.getString(NAME_INDEX);
+ String apn = cursor.getString(APN_INDEX);
+ String key = cursor.getString(ID_INDEX);
+ String type = cursor.getString(TYPES_INDEX);
+
+ if (BugleApnSettingsLoader.isValidApnType(type,
+ BugleApnSettingsLoader.APN_TYPE_MMS)) {
+ ApnPreference pref = new ApnPreference(getActivity());
+ pref.setKey(key);
+ pref.setTitle(name);
+ pref.setSummary(apn);
+ pref.setPersistent(false);
+ pref.setOnPreferenceChangeListener(ApnSettingsFragment.this);
+ pref.setSelectable(true);
+
+ // Turn on the radio button for the currently selected APN. If
+ // there is no selected APN, don't select an APN.
+ if ((mSelectedKey != null && mSelectedKey.equals(key))) {
+ pref.setChecked();
+ }
+ apnList.addPreference(pref);
+ }
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+ }.execute((Void) null);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ if (!mUnavailable) {
+ menu.add(0, MENU_NEW, 0,
+ getResources().getString(R.string.menu_new_apn))
+ .setIcon(R.drawable.ic_add_gray)
+ .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
+ menu.add(0, MENU_RESTORE, 0,
+ getResources().getString(R.string.menu_restore_default_apn))
+ .setIcon(android.R.drawable.ic_menu_upload);
+ }
+
+ super.onCreateOptionsMenu(menu, inflater);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case MENU_NEW:
+ addNewApn();
+ return true;
+
+ case MENU_RESTORE:
+ restoreDefaultApn();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void addNewApn() {
+ startActivity(UIIntents.get().getApnEditorIntent(getActivity(), null, mSubId));
+ }
+
+ @Override
+ public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen,
+ Preference preference) {
+ startActivity(
+ UIIntents.get().getApnEditorIntent(getActivity(), preference.getKey(), mSubId));
+ return true;
+ }
+
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ if (newValue instanceof String) {
+ setSelectedApnKey((String) newValue);
+ }
+
+ return true;
+ }
+
+ // current=2 means user selected APN
+ private static final String UPDATE_SELECTION = Telephony.Carriers.CURRENT + " =?";
+ private static final String[] UPDATE_SELECTION_ARGS = new String[] { "2" };
+ private void setSelectedApnKey(final String key) {
+ mSelectedKey = key;
+
+ // Make database changes not on the UI thread
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ // null out the previous "current=2" APN
+ mDatabase.update(ApnDatabase.APN_TABLE, sCurrentNullMap,
+ UPDATE_SELECTION, UPDATE_SELECTION_ARGS);
+
+ // set the new "current" APN (2)
+ String selection = Telephony.Carriers._ID + " =?";
+ String[] selectionArgs = new String[]{ key };
+
+ mDatabase.update(ApnDatabase.APN_TABLE, sCurrentSetMap,
+ selection, selectionArgs);
+ return null;
+ }
+ }.execute((Void) null);
+ }
+
+ private boolean restoreDefaultApn() {
+ getActivity().showDialog(DIALOG_RESTORE_DEFAULTAPN);
+ mRestoreDefaultApnMode = true;
+
+ if (mRestoreApnUiHandler == null) {
+ mRestoreApnUiHandler = new RestoreApnUiHandler();
+ }
+
+ if (mRestoreApnProcessHandler == null ||
+ mRestoreDefaultApnThread == null) {
+ mRestoreDefaultApnThread = new HandlerThread(
+ "Restore default APN Handler: Process Thread");
+ mRestoreDefaultApnThread.start();
+ mRestoreApnProcessHandler = new RestoreApnProcessHandler(
+ mRestoreDefaultApnThread.getLooper(), mRestoreApnUiHandler);
+ }
+
+ mRestoreApnProcessHandler.sendEmptyMessage(EVENT_RESTORE_DEFAULTAPN_START);
+ return true;
+ }
+
+ private class RestoreApnUiHandler extends Handler {
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case EVENT_RESTORE_DEFAULTAPN_COMPLETE:
+ fillList();
+ getPreferenceScreen().setEnabled(true);
+ mRestoreDefaultApnMode = false;
+ final Activity activity = getActivity();
+ activity.dismissDialog(DIALOG_RESTORE_DEFAULTAPN);
+ Toast.makeText(activity, getResources().getString(
+ R.string.restore_default_apn_completed), Toast.LENGTH_LONG)
+ .show();
+ break;
+ }
+ }
+ }
+
+ private class RestoreApnProcessHandler extends Handler {
+ private Handler mCachedRestoreApnUiHandler;
+
+ public RestoreApnProcessHandler(Looper looper, Handler restoreApnUiHandler) {
+ super(looper);
+ this.mCachedRestoreApnUiHandler = restoreApnUiHandler;
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case EVENT_RESTORE_DEFAULTAPN_START:
+ ApnDatabase.forceBuildAndLoadApnTables();
+ mCachedRestoreApnUiHandler.sendEmptyMessage(
+ EVENT_RESTORE_DEFAULTAPN_COMPLETE);
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java b/src/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java
new file mode 100644
index 0000000..906009f
--- /dev/null
+++ b/src/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java
@@ -0,0 +1,262 @@
+/*
+ * 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.messaging.ui.appsettings;
+
+import android.app.FragmentTransaction;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
+import android.media.Ringtone;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceScreen;
+import android.preference.RingtonePreference;
+import android.preference.TwoStatePreference;
+import android.provider.Settings;
+import android.support.v4.app.NavUtils;
+import android.text.TextUtils;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.LicenseActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+public class ApplicationSettingsActivity extends BugleActionBarActivity {
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ final boolean topLevel = getIntent().getBooleanExtra(
+ UIIntents.UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, false);
+ if (topLevel) {
+ getSupportActionBar().setTitle(getString(R.string.settings_activity_title));
+ }
+
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ ft.replace(android.R.id.content, new ApplicationSettingsFragment());
+ ft.commit();
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ if (super.onCreateOptionsMenu(menu)) {
+ return true;
+ }
+ getMenuInflater().inflate(R.menu.settings_menu, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ case R.id.action_license:
+ final Intent intent = new Intent(this, LicenseActivity.class);
+ startActivity(intent);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ public static class ApplicationSettingsFragment extends PreferenceFragment implements
+ OnSharedPreferenceChangeListener {
+
+ private String mNotificationsEnabledPreferenceKey;
+ private TwoStatePreference mNotificationsEnabledPreference;
+ private String mRingtonePreferenceKey;
+ private RingtonePreference mRingtonePreference;
+ private Preference mVibratePreference;
+ private String mSmsDisabledPrefKey;
+ private Preference mSmsDisabledPreference;
+ private String mSmsEnabledPrefKey;
+ private Preference mSmsEnabledPreference;
+ private boolean mIsSmsPreferenceClicked;
+
+ public ApplicationSettingsFragment() {
+ // Required empty constructor
+ }
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ getPreferenceManager().setSharedPreferencesName(BuglePrefs.SHARED_PREFERENCES_NAME);
+ addPreferencesFromResource(R.xml.preferences_application);
+
+ mNotificationsEnabledPreferenceKey =
+ getString(R.string.notifications_enabled_pref_key);
+ mNotificationsEnabledPreference = (TwoStatePreference) findPreference(
+ mNotificationsEnabledPreferenceKey);
+ mRingtonePreferenceKey = getString(R.string.notification_sound_pref_key);
+ mRingtonePreference = (RingtonePreference) findPreference(mRingtonePreferenceKey);
+ mVibratePreference = findPreference(
+ getString(R.string.notification_vibration_pref_key));
+ mSmsDisabledPrefKey = getString(R.string.sms_disabled_pref_key);
+ mSmsDisabledPreference = findPreference(mSmsDisabledPrefKey);
+ mSmsEnabledPrefKey = getString(R.string.sms_enabled_pref_key);
+ mSmsEnabledPreference = findPreference(mSmsEnabledPrefKey);
+ mIsSmsPreferenceClicked = false;
+
+ final SharedPreferences prefs = getPreferenceScreen().getSharedPreferences();
+ updateSoundSummary(prefs);
+
+ if (!DebugUtils.isDebugEnabled()) {
+ final Preference debugCategory = findPreference(getString(
+ R.string.debug_pref_key));
+ getPreferenceScreen().removePreference(debugCategory);
+ }
+
+ final PreferenceScreen advancedScreen = (PreferenceScreen) findPreference(
+ getString(R.string.advanced_pref_key));
+ final boolean topLevel = getActivity().getIntent().getBooleanExtra(
+ UIIntents.UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, false);
+ if (topLevel) {
+ advancedScreen.setIntent(UIIntents.get()
+ .getAdvancedSettingsIntent(getPreferenceScreen().getContext()));
+ } else {
+ // Hide the Advanced settings screen if this is not top-level; these are shown at
+ // the parent SettingsActivity.
+ getPreferenceScreen().removePreference(advancedScreen);
+ }
+ }
+
+ @Override
+ public boolean onPreferenceTreeClick (PreferenceScreen preferenceScreen,
+ Preference preference) {
+ if (preference.getKey() == mSmsDisabledPrefKey ||
+ preference.getKey() == mSmsEnabledPrefKey) {
+ mIsSmsPreferenceClicked = true;
+ }
+ return super.onPreferenceTreeClick(preferenceScreen, preference);
+ }
+
+ private void updateSoundSummary(final SharedPreferences sharedPreferences) {
+ // The silent ringtone just returns an empty string
+ String ringtoneName = mRingtonePreference.getContext().getString(
+ R.string.silent_ringtone);
+
+ String ringtoneString = sharedPreferences.getString(mRingtonePreferenceKey, null);
+
+ // Bootstrap the default setting in the preferences so that we have a valid selection
+ // in the dialog the first time that the user opens it.
+ if (ringtoneString == null) {
+ ringtoneString = Settings.System.DEFAULT_NOTIFICATION_URI.toString();
+ final SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putString(mRingtonePreferenceKey, ringtoneString);
+ editor.apply();
+ }
+
+ if (!TextUtils.isEmpty(ringtoneString)) {
+ final Uri ringtoneUri = Uri.parse(ringtoneString);
+ final Ringtone tone = RingtoneManager.getRingtone(mRingtonePreference.getContext(),
+ ringtoneUri);
+
+ if (tone != null) {
+ ringtoneName = tone.getTitle(mRingtonePreference.getContext());
+ }
+ }
+
+ mRingtonePreference.setSummary(ringtoneName);
+ }
+
+ private void updateSmsEnabledPreferences() {
+ if (!OsUtil.isAtLeastKLP()) {
+ getPreferenceScreen().removePreference(mSmsDisabledPreference);
+ getPreferenceScreen().removePreference(mSmsEnabledPreference);
+ } else {
+ final String defaultSmsAppLabel = getString(R.string.default_sms_app,
+ PhoneUtils.getDefault().getDefaultSmsAppLabel());
+ boolean isSmsEnabledBeforeState;
+ boolean isSmsEnabledCurrentState;
+ if (PhoneUtils.getDefault().isDefaultSmsApp()) {
+ if (getPreferenceScreen().findPreference(mSmsEnabledPrefKey) == null) {
+ getPreferenceScreen().addPreference(mSmsEnabledPreference);
+ isSmsEnabledBeforeState = false;
+ } else {
+ isSmsEnabledBeforeState = true;
+ }
+ isSmsEnabledCurrentState = true;
+ getPreferenceScreen().removePreference(mSmsDisabledPreference);
+ mSmsEnabledPreference.setSummary(defaultSmsAppLabel);
+ } else {
+ if (getPreferenceScreen().findPreference(mSmsDisabledPrefKey) == null) {
+ getPreferenceScreen().addPreference(mSmsDisabledPreference);
+ isSmsEnabledBeforeState = true;
+ } else {
+ isSmsEnabledBeforeState = false;
+ }
+ isSmsEnabledCurrentState = false;
+ getPreferenceScreen().removePreference(mSmsEnabledPreference);
+ mSmsDisabledPreference.setSummary(defaultSmsAppLabel);
+ }
+ updateNotificationsPreferences();
+ }
+ mIsSmsPreferenceClicked = false;
+ }
+
+ private void updateNotificationsPreferences() {
+ final boolean canNotify = !OsUtil.isAtLeastKLP()
+ || PhoneUtils.getDefault().isDefaultSmsApp();
+ mNotificationsEnabledPreference.setEnabled(canNotify);
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ // We do this on start rather than on resume because the sound picker is in a
+ // separate activity.
+ getPreferenceScreen().getSharedPreferences()
+ .registerOnSharedPreferenceChangeListener(this);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ updateSmsEnabledPreferences();
+ updateNotificationsPreferences();
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
+ final String key) {
+ if (key.equals(mNotificationsEnabledPreferenceKey)) {
+ updateNotificationsPreferences();
+ } else if (key.equals(mRingtonePreferenceKey)) {
+ updateSoundSummary(sharedPreferences);
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ getPreferenceScreen().getSharedPreferences()
+ .unregisterOnSharedPreferenceChangeListener(this);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/appsettings/GroupMmsSettingDialog.java b/src/com/android/messaging/ui/appsettings/GroupMmsSettingDialog.java
new file mode 100644
index 0000000..739d2dc
--- /dev/null
+++ b/src/com/android/messaging/ui/appsettings/GroupMmsSettingDialog.java
@@ -0,0 +1,92 @@
+/*
+ * 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.messaging.ui.appsettings;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.RadioButton;
+
+import com.android.messaging.R;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BuglePrefs;
+
+/**
+ * Displays an on/off switch for group MMS setting for a given subscription.
+ */
+public class GroupMmsSettingDialog {
+ private final Context mContext;
+ private final int mSubId;
+ private AlertDialog mDialog;
+
+ /**
+ * Shows a new group MMS setting dialog.
+ */
+ public static void showDialog(final Context context, final int subId) {
+ new GroupMmsSettingDialog(context, subId).show();
+ }
+
+ private GroupMmsSettingDialog(final Context context, final int subId) {
+ mContext = context;
+ mSubId = subId;
+ }
+
+ private void show() {
+ Assert.isNull(mDialog);
+ mDialog = new AlertDialog.Builder(mContext)
+ .setView(createView())
+ .setTitle(R.string.group_mms_pref_title)
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ private void changeGroupMmsSettings(final boolean enable) {
+ Assert.notNull(mDialog);
+ BuglePrefs.getSubscriptionPrefs(mSubId).putBoolean(
+ mContext.getString(R.string.group_mms_pref_key), enable);
+ mDialog.dismiss();
+ }
+
+ private View createView() {
+ final LayoutInflater inflater = (LayoutInflater) mContext
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ final View rootView = inflater.inflate(R.layout.group_mms_setting_dialog, null, false);
+ final RadioButton disableButton = (RadioButton)
+ rootView.findViewById(R.id.disable_group_mms_button);
+ final RadioButton enableButton = (RadioButton)
+ rootView.findViewById(R.id.enable_group_mms_button);
+ disableButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ changeGroupMmsSettings(false);
+ }
+ });
+ enableButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ changeGroupMmsSettings(true);
+ }
+ });
+ final boolean mmsEnabled = BuglePrefs.getSubscriptionPrefs(mSubId).getBoolean(
+ mContext.getString(R.string.group_mms_pref_key),
+ mContext.getResources().getBoolean(R.bool.group_mms_pref_default));
+ enableButton.setChecked(mmsEnabled);
+ disableButton.setChecked(!mmsEnabled);
+ return rootView;
+ }
+}
diff --git a/src/com/android/messaging/ui/appsettings/PerSubscriptionSettingsActivity.java b/src/com/android/messaging/ui/appsettings/PerSubscriptionSettingsActivity.java
new file mode 100644
index 0000000..e02823f
--- /dev/null
+++ b/src/com/android/messaging/ui/appsettings/PerSubscriptionSettingsActivity.java
@@ -0,0 +1,246 @@
+/*
+ * 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.messaging.ui.appsettings;
+
+import android.app.FragmentTransaction;
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.preference.Preference;
+import android.preference.Preference.OnPreferenceClickListener;
+import android.preference.PreferenceCategory;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceScreen;
+import android.support.v4.app.NavUtils;
+import android.text.TextUtils;
+import android.view.MenuItem;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.ParticipantRefresh;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.ApnDatabase;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.PhoneUtils;
+
+public class PerSubscriptionSettingsActivity extends BugleActionBarActivity {
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ final String title = getIntent().getStringExtra(
+ UIIntents.UI_INTENT_EXTRA_PER_SUBSCRIPTION_SETTING_TITLE);
+ if (!TextUtils.isEmpty(title)) {
+ getSupportActionBar().setTitle(title);
+ } else {
+ // This will fall back to the default title, i.e. "Messaging settings," so No-op.
+ }
+
+ final FragmentTransaction ft = getFragmentManager().beginTransaction();
+ final PerSubscriptionSettingsFragment fragment = new PerSubscriptionSettingsFragment();
+ ft.replace(android.R.id.content, fragment);
+ ft.commit();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ public static class PerSubscriptionSettingsFragment extends PreferenceFragment
+ implements OnSharedPreferenceChangeListener {
+ private PhoneNumberPreference mPhoneNumberPreference;
+ private Preference mGroupMmsPreference;
+ private String mGroupMmsPrefKey;
+ private String mPhoneNumberKey;
+ private int mSubId;
+
+ public PerSubscriptionSettingsFragment() {
+ // Required empty constructor
+ }
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Get sub id from launch intent
+ final Intent intent = getActivity().getIntent();
+ Assert.notNull(intent);
+ mSubId = (intent != null) ? intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID,
+ ParticipantData.DEFAULT_SELF_SUB_ID) : ParticipantData.DEFAULT_SELF_SUB_ID;
+
+ final BuglePrefs subPrefs = Factory.get().getSubscriptionPrefs(mSubId);
+ getPreferenceManager().setSharedPreferencesName(subPrefs.getSharedPreferencesName());
+ addPreferencesFromResource(R.xml.preferences_per_subscription);
+
+ mPhoneNumberKey = getString(R.string.mms_phone_number_pref_key);
+ mPhoneNumberPreference = (PhoneNumberPreference) findPreference(mPhoneNumberKey);
+ final PreferenceCategory advancedCategory = (PreferenceCategory)
+ findPreference(getString(R.string.advanced_category_pref_key));
+ final PreferenceCategory mmsCategory = (PreferenceCategory)
+ findPreference(getString(R.string.mms_messaging_category_pref_key));
+
+ mPhoneNumberPreference.setDefaultPhoneNumber(
+ PhoneUtils.get(mSubId).getCanonicalForSelf(false/*allowOverride*/), mSubId);
+
+ mGroupMmsPrefKey = getString(R.string.group_mms_pref_key);
+ mGroupMmsPreference = findPreference(mGroupMmsPrefKey);
+ if (!MmsConfig.get(mSubId).getGroupMmsEnabled()) {
+ // Always show group messaging setting even if the SIM has no number
+ // If broadcast sms is selected, the SIM number is not needed
+ // If group mms is selected, the phone number dialog will popup when message
+ // is being sent, making sure we will have a self number for group mms.
+ mmsCategory.removePreference(mGroupMmsPreference);
+ } else {
+ mGroupMmsPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference pref) {
+ GroupMmsSettingDialog.showDialog(getActivity(), mSubId);
+ return true;
+ }
+ });
+ updateGroupMmsPrefSummary();
+ }
+
+ if (!MmsConfig.get(mSubId).getSMSDeliveryReportsEnabled()) {
+ final Preference deliveryReportsPref = findPreference(
+ getString(R.string.delivery_reports_pref_key));
+ mmsCategory.removePreference(deliveryReportsPref);
+ }
+ final Preference wirelessAlertPref = findPreference(getString(
+ R.string.wireless_alerts_key));
+ if (!isCellBroadcastAppLinkEnabled()) {
+ advancedCategory.removePreference(wirelessAlertPref);
+ } else {
+ wirelessAlertPref.setOnPreferenceClickListener(
+ new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(final Preference preference) {
+ try {
+ startActivity(UIIntents.get().getWirelessAlertsIntent());
+ } catch (final ActivityNotFoundException e) {
+ // Handle so we shouldn't crash if the wireless alerts
+ // implementation is broken.
+ LogUtil.e(LogUtil.BUGLE_TAG,
+ "Failed to launch wireless alerts activity", e);
+ }
+ return true;
+ }
+ });
+ }
+
+ // Access Point Names (APNs)
+ final Preference apnsPref = findPreference(getString(R.string.sms_apns_key));
+
+ if (MmsUtils.useSystemApnTable() && !ApnDatabase.doesDatabaseExist()) {
+ // Don't remove the ability to edit the local APN prefs if this device lets us
+ // access the system APN, but we can't find the MCC/MNC in the APN table and we
+ // created the local APN table in case the MCC/MNC was in there. In other words,
+ // if the local APN table exists, let the user edit it.
+ advancedCategory.removePreference(apnsPref);
+ } else {
+ final PreferenceScreen apnsScreen = (PreferenceScreen) findPreference(
+ getString(R.string.sms_apns_key));
+ apnsScreen.setIntent(UIIntents.get()
+ .getApnSettingsIntent(getPreferenceScreen().getContext(), mSubId));
+ }
+
+ // We want to disable preferences if we are not the default app, but we do all of the
+ // above first so that the user sees the correct information on the screen
+ if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
+ mGroupMmsPreference.setEnabled(false);
+ final Preference autoRetrieveMmsPreference =
+ findPreference(getString(R.string.auto_retrieve_mms_pref_key));
+ autoRetrieveMmsPreference.setEnabled(false);
+ final Preference deliveryReportsPreference =
+ findPreference(getString(R.string.delivery_reports_pref_key));
+ deliveryReportsPreference.setEnabled(false);
+ }
+ }
+
+ private boolean isCellBroadcastAppLinkEnabled() {
+ if (!MmsConfig.get(mSubId).getShowCellBroadcast()) {
+ return false;
+ }
+ try {
+ final PackageManager pm = getActivity().getPackageManager();
+ return pm.getApplicationEnabledSetting(UIIntents.CMAS_COMPONENT)
+ != PackageManager.COMPONENT_ENABLED_STATE_DISABLED;
+ } catch (final IllegalArgumentException ignored) {
+ // CMAS app not installed.
+ }
+ return false;
+ }
+
+ private void updateGroupMmsPrefSummary() {
+ final boolean groupMmsEnabled = getPreferenceScreen().getSharedPreferences().getBoolean(
+ mGroupMmsPrefKey, getResources().getBoolean(R.bool.group_mms_pref_default));
+ mGroupMmsPreference.setSummary(groupMmsEnabled ?
+ R.string.enable_group_mms : R.string.disable_group_mms);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ getPreferenceScreen().getSharedPreferences()
+ .registerOnSharedPreferenceChangeListener(this);
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
+ final String key) {
+ if (key.equals(mGroupMmsPrefKey)) {
+ updateGroupMmsPrefSummary();
+ } else if (key.equals(mPhoneNumberKey)) {
+ // Save the changed phone number in preferences specific to the sub id
+ final String newPhoneNumber = mPhoneNumberPreference.getText();
+ final BuglePrefs subPrefs = BuglePrefs.getSubscriptionPrefs(mSubId);
+ if (TextUtils.isEmpty(newPhoneNumber)) {
+ subPrefs.remove(mPhoneNumberKey);
+ } else {
+ subPrefs.putString(getString(R.string.mms_phone_number_pref_key),
+ newPhoneNumber);
+ }
+ // Update the self participants so the new phone number will be reflected
+ // everywhere in the UI.
+ ParticipantRefresh.refreshSelfParticipants();
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ getPreferenceScreen().getSharedPreferences()
+ .unregisterOnSharedPreferenceChangeListener(this);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/appsettings/PhoneNumberPreference.java b/src/com/android/messaging/ui/appsettings/PhoneNumberPreference.java
new file mode 100644
index 0000000..0c9c018
--- /dev/null
+++ b/src/com/android/messaging/ui/appsettings/PhoneNumberPreference.java
@@ -0,0 +1,116 @@
+/*
+ * 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.messaging.ui.appsettings;
+
+import android.content.Context;
+import android.preference.EditTextPreference;
+import android.support.v4.text.BidiFormatter;
+import android.support.v4.text.TextDirectionHeuristicsCompat;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.View;
+
+import com.android.messaging.R;
+import com.android.messaging.util.PhoneUtils;
+
+/**
+ * Preference that displays a phone number and allows editing via a dialog.
+ * <p>
+ * A default number can be assigned, which is shown in the preference view and
+ * used to populate the dialog editor when the preference value is not set. If
+ * the user sets the preference to a number equivalent to the default, the
+ * underlying preference is cleared.
+ */
+public class PhoneNumberPreference extends EditTextPreference {
+
+ private String mDefaultPhoneNumber;
+ private int mSubId;
+
+ public PhoneNumberPreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mDefaultPhoneNumber = "";
+ }
+
+ public void setDefaultPhoneNumber(final String phoneNumber, final int subscriptionId) {
+ mDefaultPhoneNumber = phoneNumber;
+ mSubId = subscriptionId;
+ }
+
+ @Override
+ protected void onBindView(final View view) {
+ // Show the preference value if it's set, or the default number if not.
+ // If we don't have a default, fall back to a static string (e.g. Unknown).
+ String value = getText();
+ if (TextUtils.isEmpty(value)) {
+ value = mDefaultPhoneNumber;
+ }
+ final String displayValue = (!TextUtils.isEmpty(value))
+ ? PhoneUtils.get(mSubId).formatForDisplay(value)
+ : getContext().getString(R.string.unknown_phone_number_pref_display_value);
+ final BidiFormatter bidiFormatter = BidiFormatter.getInstance();
+ final String phoneNumber = bidiFormatter.unicodeWrap
+ (displayValue, TextDirectionHeuristicsCompat.LTR);
+ // Set the value as the summary and let the superclass populate the views
+ setSummary(phoneNumber);
+ super.onBindView(view);
+ }
+
+ @Override
+ protected void onBindDialogView(final View view) {
+ super.onBindDialogView(view);
+
+ final String value = getText();
+
+ // If the preference is empty, populate the EditText with the default number instead.
+ if (TextUtils.isEmpty(value) && !TextUtils.isEmpty(mDefaultPhoneNumber)) {
+ final BidiFormatter bidiFormatter = BidiFormatter.getInstance();
+ final String phoneNumber = bidiFormatter.unicodeWrap
+ (PhoneUtils.get(mSubId).getCanonicalBySystemLocale(mDefaultPhoneNumber),
+ TextDirectionHeuristicsCompat.LTR);
+ getEditText().setText(phoneNumber);
+ }
+ getEditText().setInputType(InputType.TYPE_CLASS_PHONE);
+ }
+
+ @Override
+ protected void onDialogClosed(final boolean positiveResult) {
+ if (positiveResult && mDefaultPhoneNumber != null) {
+ final String value = getEditText().getText().toString();
+ final PhoneUtils phoneUtils = PhoneUtils.get(mSubId);
+ final String phoneNumber = phoneUtils.getCanonicalBySystemLocale(value);
+ final String defaultPhoneNumber = phoneUtils.getCanonicalBySystemLocale(
+ mDefaultPhoneNumber);
+
+ // If the new value is the default, clear the preference.
+ if (phoneNumber.equals(defaultPhoneNumber)) {
+ setText("");
+ return;
+ }
+ }
+ super.onDialogClosed(positiveResult);
+ }
+
+ @Override
+ public void setText(final String text) {
+ super.setText(text);
+
+ // EditTextPreference doesn't show the value on the preference view, but we do.
+ // We thus need to force a rebind of the view when a new value is set.
+ notifyChanged();
+ }
+}
diff --git a/src/com/android/messaging/ui/appsettings/SettingsActivity.java b/src/com/android/messaging/ui/appsettings/SettingsActivity.java
new file mode 100644
index 0000000..75266d8
--- /dev/null
+++ b/src/com/android/messaging/ui/appsettings/SettingsActivity.java
@@ -0,0 +1,178 @@
+/*
+ * 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.messaging.ui.appsettings;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v4.app.NavUtils;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.SettingsData;
+import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
+import com.android.messaging.datamodel.data.SettingsData.SettingsItem;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.PhoneUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shows the "master" settings activity that contains two parts, one for application-wide settings
+ * (dubbed "General settings"), and one or more for per-subscription settings (dubbed "Messaging
+ * settings" for single-SIM, and the actual SIM name for multi-SIM). Clicking on either item
+ * (e.g. "General settings") will open the detail settings activity (ApplicationSettingsActivity
+ * in this case).
+ */
+public class SettingsActivity extends BugleActionBarActivity {
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ // Directly open the detailed settings page as the top-level settings activity if this is
+ // not a multi-SIM device.
+ if (PhoneUtils.getDefault().getActiveSubscriptionCount() <= 1) {
+ UIIntents.get().launchApplicationSettingsActivity(this, true /* topLevel */);
+ finish();
+ } else {
+ getFragmentManager().beginTransaction()
+ .replace(android.R.id.content, new SettingsFragment())
+ .commit();
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ public static class SettingsFragment extends Fragment implements SettingsDataListener {
+ private ListView mListView;
+ private SettingsListAdapter mAdapter;
+ private final Binding<SettingsData> mBinding = BindingBase.createBinding(this);
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mBinding.bind(DataModel.get().createSettingsData(getActivity(), this));
+ mBinding.getData().init(getLoaderManager(), mBinding);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.settings_fragment, container, false);
+ mListView = (ListView) view.findViewById(android.R.id.list);
+ mAdapter = new SettingsListAdapter(getActivity());
+ mListView.setAdapter(mAdapter);
+ return view;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mBinding.unbind();
+ }
+
+ @Override
+ public void onSelfParticipantDataLoaded(SettingsData data) {
+ mBinding.ensureBound(data);
+ mAdapter.setSettingsItems(data.getSettingsItems());
+ }
+
+ /**
+ * An adapter that displays a list of SettingsItem.
+ */
+ private class SettingsListAdapter extends ArrayAdapter<SettingsItem> {
+ public SettingsListAdapter(final Context context) {
+ super(context, R.layout.settings_item_view, new ArrayList<SettingsItem>());
+ }
+
+ public void setSettingsItems(final List<SettingsItem> newList) {
+ clear();
+ addAll(newList);
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public View getView(final int position, final View convertView,
+ final ViewGroup parent) {
+ View itemView;
+ if (convertView != null) {
+ itemView = convertView;
+ } else {
+ final LayoutInflater inflater = (LayoutInflater) getContext()
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ itemView = inflater.inflate(
+ R.layout.settings_item_view, parent, false);
+ }
+ final SettingsItem item = getItem(position);
+ final TextView titleTextView = (TextView) itemView.findViewById(R.id.title);
+ final TextView subtitleTextView = (TextView) itemView.findViewById(R.id.subtitle);
+ final String summaryText = item.getDisplayDetail();
+ titleTextView.setText(item.getDisplayName());
+ if (!TextUtils.isEmpty(summaryText)) {
+ subtitleTextView.setText(summaryText);
+ subtitleTextView.setVisibility(View.VISIBLE);
+ } else {
+ subtitleTextView.setVisibility(View.GONE);
+ }
+ itemView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ switch (item.getType()) {
+ case SettingsItem.TYPE_GENERAL_SETTINGS:
+ UIIntents.get().launchApplicationSettingsActivity(getActivity(),
+ false /* topLevel */);
+ break;
+
+ case SettingsItem.TYPE_PER_SUBSCRIPTION_SETTINGS:
+ UIIntents.get().launchPerSubscriptionSettingsActivity(getActivity(),
+ item.getSubId(), item.getActivityTitle());
+ break;
+
+ default:
+ Assert.fail("unrecognized setting type!");
+ break;
+ }
+ }
+ });
+ return itemView;
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserActivity.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserActivity.java
new file mode 100644
index 0000000..a540597
--- /dev/null
+++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserActivity.java
@@ -0,0 +1,56 @@
+/*
+ * 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.messaging.ui.attachmentchooser;
+
+import android.app.Fragment;
+import android.os.Bundle;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.attachmentchooser.AttachmentChooserFragment.AttachmentChooserFragmentHost;
+import com.android.messaging.util.Assert;
+
+public class AttachmentChooserActivity extends BugleActionBarActivity implements
+ AttachmentChooserFragmentHost {
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.attachment_chooser_activity);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(false);
+ }
+
+ @Override
+ public void onAttachFragment(final Fragment fragment) {
+ if (fragment instanceof AttachmentChooserFragment) {
+ final String conversationId =
+ getIntent().getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
+ Assert.notNull(conversationId);
+ final AttachmentChooserFragment chooserFragment =
+ (AttachmentChooserFragment) fragment;
+ chooserFragment.setConversationId(conversationId);
+ chooserFragment.setHost(this);
+ }
+ }
+
+ @Override
+ public void onConfirmSelection() {
+ setResult(RESULT_OK);
+ finish();
+ }
+}
diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragment.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragment.java
new file mode 100644
index 0000000..b39dc3e
--- /dev/null
+++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragment.java
@@ -0,0 +1,183 @@
+/*
+ * 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.messaging.ui.attachmentchooser;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.attachmentchooser.AttachmentGridView.AttachmentGridHost;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class AttachmentChooserFragment extends Fragment implements DraftMessageDataListener,
+ AttachmentGridHost {
+ public interface AttachmentChooserFragmentHost {
+ void onConfirmSelection();
+ }
+
+ private AttachmentGridView mAttachmentGridView;
+ private AttachmentGridAdapter mAdapter;
+ private AttachmentChooserFragmentHost mHost;
+
+ @VisibleForTesting
+ Binding<DraftMessageData> mBinding = BindingBase.createBinding(this);
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.attachment_chooser_fragment, container, false);
+ mAttachmentGridView = (AttachmentGridView) view.findViewById(R.id.grid);
+ mAdapter = new AttachmentGridAdapter(getActivity());
+ mAttachmentGridView.setAdapter(mAdapter);
+ mAttachmentGridView.setHost(this);
+ setHasOptionsMenu(true);
+ return view;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mBinding.unbind();
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ inflater.inflate(R.menu.attachment_chooser_menu, menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.action_confirm_selection:
+ confirmSelection();
+ return true;
+
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @VisibleForTesting
+ void confirmSelection() {
+ if (mBinding.isBound()) {
+ mBinding.getData().removeExistingAttachments(
+ mAttachmentGridView.getUnselectedAttachments());
+ mBinding.getData().saveToStorage(mBinding);
+ mHost.onConfirmSelection();
+ }
+ }
+
+ public void setConversationId(final String conversationId) {
+ mBinding.bind(DataModel.get().createDraftMessageData(conversationId));
+ mBinding.getData().addListener(this);
+ mBinding.getData().loadFromStorage(mBinding, null, false);
+ }
+
+ public void setHost(final AttachmentChooserFragmentHost host) {
+ mHost = host;
+ }
+
+ @Override
+ public void onDraftChanged(final DraftMessageData data, final int changeFlags) {
+ mBinding.ensureBound(data);
+ if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) ==
+ DraftMessageData.ATTACHMENTS_CHANGED) {
+ mAdapter.onAttachmentsLoaded(data.getReadOnlyAttachments());
+ }
+ }
+
+ @Override
+ public void onDraftAttachmentLimitReached(final DraftMessageData data) {
+ // Do nothing since the user is in the process of unselecting attachments.
+ }
+
+ @Override
+ public void onDraftAttachmentLoadFailed() {
+ // Do nothing since the user is in the process of unselecting attachments.
+ }
+
+ @Override
+ public void displayPhoto(final Rect viewRect, final Uri photoUri) {
+ final Uri imagesUri = MessagingContentProvider.buildDraftImagesUri(
+ mBinding.getData().getConversationId());
+ UIIntents.get().launchFullScreenPhotoViewer(
+ getActivity(), photoUri, viewRect, imagesUri);
+ }
+
+ @Override
+ public void updateSelectionCount(int count) {
+ if (getActivity() instanceof BugleActionBarActivity) {
+ final ActionBar actionBar = ((BugleActionBarActivity) getActivity())
+ .getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(getResources().getString(
+ R.string.attachment_chooser_selection, count));
+ }
+ }
+ }
+
+ class AttachmentGridAdapter extends ArrayAdapter<MessagePartData> {
+ public AttachmentGridAdapter(final Context context) {
+ super(context, R.layout.attachment_grid_item_view, new ArrayList<MessagePartData>());
+ }
+
+ public void onAttachmentsLoaded(final List<MessagePartData> attachments) {
+ clear();
+ addAll(attachments);
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public View getView(final int position, final View convertView, final ViewGroup parent) {
+ AttachmentGridItemView itemView;
+ final MessagePartData item = getItem(position);
+ if (convertView != null && convertView instanceof AttachmentGridItemView) {
+ itemView = (AttachmentGridItemView) convertView;
+ } else {
+ final LayoutInflater inflater = (LayoutInflater) getContext()
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ itemView = (AttachmentGridItemView) inflater.inflate(
+ R.layout.attachment_grid_item_view, parent, false);
+ }
+ itemView.bind(item, mAttachmentGridView);
+ return itemView;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentGridItemView.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridItemView.java
new file mode 100644
index 0000000..8bb7356
--- /dev/null
+++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridItemView.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.messaging.ui.attachmentchooser;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.TouchDelegate;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.FrameLayout;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.ui.AttachmentPreviewFactory;
+import com.android.messaging.util.Assert;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Shows an item in the attachment picker grid.
+ */
+public class AttachmentGridItemView extends FrameLayout {
+ public interface HostInterface {
+ boolean isItemSelected(MessagePartData attachment);
+ void onItemCheckedChanged(AttachmentGridItemView view, MessagePartData attachment);
+ void onItemClicked(AttachmentGridItemView view, MessagePartData attachment);
+ }
+
+ @VisibleForTesting
+ MessagePartData mAttachmentData;
+ private FrameLayout mAttachmentViewContainer;
+ private CheckBox mCheckBox;
+ private HostInterface mHostInterface;
+
+ public AttachmentGridItemView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mAttachmentViewContainer = (FrameLayout) findViewById(R.id.attachment_container);
+ mCheckBox = (CheckBox) findViewById(R.id.checkbox);
+ mCheckBox.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ mHostInterface.onItemCheckedChanged(AttachmentGridItemView.this, mAttachmentData);
+ }
+ });
+ setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ mHostInterface.onItemClicked(AttachmentGridItemView.this, mAttachmentData);
+ }
+ });
+ addOnLayoutChangeListener(new OnLayoutChangeListener() {
+ @Override
+ public void onLayoutChange(View v, int left, int top, int right, int bottom,
+ int oldLeft, int oldTop, int oldRight, int oldBottom) {
+ // Enlarge the clickable region for the checkbox.
+ final int touchAreaIncrease = getResources().getDimensionPixelOffset(
+ R.dimen.attachment_grid_checkbox_area_increase);
+ final Rect region = new Rect();
+ mCheckBox.getHitRect(region);
+ region.inset(-touchAreaIncrease, -touchAreaIncrease);
+ setTouchDelegate(new TouchDelegate(region, mCheckBox));
+ }
+ });
+ }
+
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ // The grid view auto-fits the columns, so we want to let the height match the width
+ // to make the attachment preview square.
+ super.onMeasure(widthMeasureSpec, widthMeasureSpec);
+ }
+
+ public void bind(final MessagePartData attachment, final HostInterface hostInterface) {
+ Assert.isTrue(attachment.isAttachment());
+ mHostInterface = hostInterface;
+ updateSelectedState();
+ if (mAttachmentData == null || !mAttachmentData.equals(attachment)) {
+ mAttachmentData = attachment;
+ updateAttachmentView();
+ }
+ }
+
+ @VisibleForTesting
+ HostInterface testGetHostInterface() {
+ return mHostInterface;
+ }
+
+ public void updateSelectedState() {
+ mCheckBox.setChecked(mHostInterface.isItemSelected(mAttachmentData));
+ }
+
+ private void updateAttachmentView() {
+ mAttachmentViewContainer.removeAllViews();
+ final LayoutInflater inflater = LayoutInflater.from(getContext());
+ final View attachmentView = AttachmentPreviewFactory.createAttachmentPreview(inflater,
+ mAttachmentData, mAttachmentViewContainer,
+ AttachmentPreviewFactory.TYPE_CHOOSER_GRID, true /* startImageRequest */, null);
+ mAttachmentViewContainer.addView(attachmentView);
+ }
+}
diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentGridView.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridView.java
new file mode 100644
index 0000000..abf61dc
--- /dev/null
+++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridView.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.messaging.ui.attachmentchooser;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.AttributeSet;
+import android.widget.GridView;
+
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.ui.attachmentchooser.AttachmentChooserFragment.AttachmentGridAdapter;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.UiUtils;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Displays a grid of attachment previews for the user to choose which to select/unselect
+ */
+public class AttachmentGridView extends GridView implements
+ AttachmentGridItemView.HostInterface {
+ public interface AttachmentGridHost {
+ void displayPhoto(final Rect viewRect, final Uri photoUri);
+ void updateSelectionCount(final int count);
+ }
+
+ // By default everything is selected so only need to keep track of the unselected set.
+ private final Set<MessagePartData> mUnselectedSet;
+ private AttachmentGridHost mHost;
+
+ public AttachmentGridView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mUnselectedSet = new HashSet<>();
+ }
+
+ public void setHost(final AttachmentGridHost host) {
+ mHost = host;
+ }
+
+ @Override
+ public boolean isItemSelected(final MessagePartData attachment) {
+ return !mUnselectedSet.contains(attachment);
+ }
+
+ @Override
+ public void onItemClicked(final AttachmentGridItemView view, final MessagePartData attachment) {
+ // If the item is an image, show the photo viewer. All the other types (video, audio,
+ // vcard) have internal click handling for showing previews so we don't need to handle them
+ if (attachment.isImage()) {
+ mHost.displayPhoto(UiUtils.getMeasuredBoundsOnScreen(view), attachment.getContentUri());
+ }
+ }
+
+ @Override
+ public void onItemCheckedChanged(AttachmentGridItemView view, MessagePartData attachment) {
+ // Toggle selection.
+ if (isItemSelected(attachment)) {
+ mUnselectedSet.add(attachment);
+ } else {
+ mUnselectedSet.remove(attachment);
+ }
+ view.updateSelectedState();
+ updateSelectionCount();
+ }
+
+ public Set<MessagePartData> getUnselectedAttachments() {
+ return Collections.unmodifiableSet(mUnselectedSet);
+ }
+
+ private void updateSelectionCount() {
+ final int count = getAdapter().getCount() - mUnselectedSet.size();
+ Assert.isTrue(count >= 0);
+ mHost.updateSelectionCount(count);
+ }
+
+ private void refreshViews() {
+ if (getAdapter() instanceof AttachmentGridAdapter) {
+ ((AttachmentGridAdapter) getAdapter()).notifyDataSetChanged();
+ }
+ }
+
+ @Override
+ public Parcelable onSaveInstanceState() {
+ final Parcelable superState = super.onSaveInstanceState();
+ final SavedState savedState = new SavedState(superState);
+ savedState.unselectedParts = mUnselectedSet
+ .toArray(new MessagePartData[mUnselectedSet.size()]);
+ return savedState;
+ }
+
+ @Override
+ public void onRestoreInstanceState(final Parcelable state) {
+ if (!(state instanceof SavedState)) {
+ super.onRestoreInstanceState(state);
+ return;
+ }
+
+ final SavedState savedState = (SavedState) state;
+ super.onRestoreInstanceState(savedState.getSuperState());
+ mUnselectedSet.clear();
+ for (int i = 0; i < savedState.unselectedParts.length; i++) {
+ final MessagePartData unselectedPart = savedState.unselectedParts[i];
+ mUnselectedSet.add(unselectedPart);
+ }
+ refreshViews();
+ }
+
+ /**
+ * Persists the item selection state to saved instance state so we can restore on activity
+ * recreation
+ */
+ public static class SavedState extends BaseSavedState {
+ MessagePartData[] unselectedParts;
+
+ SavedState(final Parcelable superState) {
+ super(superState);
+ }
+
+ private SavedState(final Parcel in) {
+ super(in);
+
+ // Read parts
+ final int partCount = in.readInt();
+ unselectedParts = new MessagePartData[partCount];
+ for (int i = 0; i < partCount; i++) {
+ unselectedParts[i] = ((MessagePartData) in.readParcelable(
+ MessagePartData.class.getClassLoader()));
+ }
+ }
+
+ @Override
+ public void writeToParcel(final Parcel out, final int flags) {
+ super.writeToParcel(out, flags);
+
+ // Write parts
+ out.writeInt(unselectedParts.length);
+ for (final MessagePartData image : unselectedParts) {
+ out.writeParcelable(image, flags);
+ }
+ }
+
+ public static final Parcelable.Creator<SavedState> CREATOR =
+ new Parcelable.Creator<SavedState>() {
+ @Override
+ public SavedState createFromParcel(final Parcel in) {
+ return new SavedState(in);
+ }
+ @Override
+ public SavedState[] newArray(final int size) {
+ return new SavedState[size];
+ }
+ };
+ }
+}
diff --git a/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java b/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java
new file mode 100644
index 0000000..9c1393d
--- /dev/null
+++ b/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java
@@ -0,0 +1,85 @@
+/*
+ * 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.messaging.ui.contact;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.ContactIconView;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.AccessibilityUtil;
+
+public class AddContactsConfirmationDialog implements DialogInterface.OnClickListener {
+ private final Context mContext;
+ private final Uri mAvatarUri;
+ private final String mNormalizedDestination;
+
+ public AddContactsConfirmationDialog(final Context context, final Uri avatarUri,
+ final String normalizedDestination) {
+ mContext = context;
+ mAvatarUri = avatarUri;
+ mNormalizedDestination = normalizedDestination;
+ }
+
+ public void show() {
+ final int confirmAddContactStringId = R.string.add_contact_confirmation;
+ final int cancelStringId = android.R.string.cancel;
+ final AlertDialog alertDialog = new AlertDialog.Builder(mContext)
+ .setTitle(R.string.add_contact_confirmation_dialog_title)
+ .setView(createBodyView())
+ .setPositiveButton(confirmAddContactStringId, this)
+ .setNegativeButton(cancelStringId, null)
+ .create();
+ alertDialog.show();
+ final Resources resources = mContext.getResources();
+ final Button cancelButton = alertDialog.getButton(DialogInterface.BUTTON_NEGATIVE);
+ if (cancelButton != null) {
+ cancelButton.setTextColor(resources.getColor(R.color.contact_picker_button_text_color));
+ }
+ final Button addButton = alertDialog.getButton(DialogInterface.BUTTON_POSITIVE);
+ if (addButton != null) {
+ addButton.setTextColor(resources.getColor(R.color.contact_picker_button_text_color));
+ }
+ }
+
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ UIIntents.get().launchAddContactActivity(mContext, mNormalizedDestination);
+ }
+
+ private View createBodyView() {
+ final View view = LayoutInflater.from(mContext).inflate(
+ R.layout.add_contacts_confirmation_dialog_body, null);
+ final ContactIconView iconView = (ContactIconView) view.findViewById(R.id.contact_icon);
+ iconView.setImageResourceUri(mAvatarUri);
+ final TextView textView = (TextView) view.findViewById(R.id.participant_name);
+ textView.setText(mNormalizedDestination);
+ // Accessibility reason : in case phone numbers are mixed in the display name,
+ // we need to vocalize it for talkback.
+ final String vocalizedDisplayName = AccessibilityUtil.getVocalizedPhoneNumber(
+ mContext.getResources(), mNormalizedDestination);
+ textView.setContentDescription(vocalizedDisplayName);
+ return view;
+ }
+}
diff --git a/src/com/android/messaging/ui/contact/AllContactsListViewHolder.java b/src/com/android/messaging/ui/contact/AllContactsListViewHolder.java
new file mode 100644
index 0000000..7263c54
--- /dev/null
+++ b/src/com/android/messaging/ui/contact/AllContactsListViewHolder.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.messaging.ui.contact;
+
+import android.content.Context;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.CustomHeaderPagerListViewHolder;
+import com.android.messaging.ui.contact.ContactListItemView.HostInterface;
+
+/**
+ * Holds the all contacts view for the contact picker's view pager.
+ */
+public class AllContactsListViewHolder extends CustomHeaderPagerListViewHolder {
+ public AllContactsListViewHolder(final Context context, final HostInterface clivHostInterface) {
+ super(context, new ContactListAdapter(context, null, clivHostInterface,
+ true /* needAlphabetHeader */));
+ }
+
+ @Override
+ protected int getLayoutResId() {
+ return R.layout.all_contacts_list_view;
+ }
+
+ @Override
+ protected int getPageTitleResId() {
+ return R.string.contact_picker_all_contacts_tab_title;
+ }
+
+ @Override
+ protected int getEmptyViewResId() {
+ return R.id.empty_view;
+ }
+
+ @Override
+ protected int getListViewResId() {
+ return R.id.all_contacts_list;
+ }
+
+ @Override
+ protected int getEmptyViewTitleResId() {
+ return R.string.contact_list_empty_text;
+ }
+
+ @Override
+ protected int getEmptyViewImageResId() {
+ return R.drawable.ic_oobe_freq_list;
+ }
+}
diff --git a/src/com/android/messaging/ui/contact/ContactDropdownLayouter.java b/src/com/android/messaging/ui/contact/ContactDropdownLayouter.java
new file mode 100644
index 0000000..7df62de
--- /dev/null
+++ b/src/com/android/messaging/ui/contact/ContactDropdownLayouter.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.messaging.ui.contact;
+
+import android.content.Context;
+import android.graphics.drawable.StateListDrawable;
+import android.net.Uri;
+import android.support.v4.text.BidiFormatter;
+import android.support.v4.text.TextDirectionHeuristicsCompat;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import com.android.ex.chips.DropdownChipLayouter;
+import com.android.ex.chips.RecipientEntry;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ContactListItemData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.ui.ContactIconView;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.ContactRecipientEntryUtils;
+
+/**
+ * An implementation for {@link DropdownChipLayouter}. Layouts the dropdown
+ * list in the ContactRecipientAutoCompleteView in Material style.
+ */
+public class ContactDropdownLayouter extends DropdownChipLayouter {
+ private final ContactListItemView.HostInterface mClivHostInterface;
+
+ public ContactDropdownLayouter(final LayoutInflater inflater, final Context context,
+ final ContactListItemView.HostInterface clivHostInterface) {
+ super(inflater, context);
+ mClivHostInterface = new ContactListItemView.HostInterface() {
+
+ @Override
+ public void onContactListItemClicked(final ContactListItemData item,
+ final ContactListItemView view) {
+ // The chips UI will handle auto-complete item click events, so No-op here.
+ }
+
+ @Override
+ public boolean isContactSelected(final ContactListItemData item) {
+ // In chips drop down we don't show any selected checkmark per design.
+ return false;
+ }
+ };
+ }
+
+ /**
+ * Bind a drop down view to a RecipientEntry. We'd like regular dropdown items (BASE_RECIPIENT)
+ * to behave the same as regular ContactListItemViews, while using the chips library's
+ * item styling for alternates dropdown items (happens when you click on a chip).
+ */
+ @Override
+ public View bindView(final View convertView, final ViewGroup parent, final RecipientEntry entry,
+ final int position, AdapterType type, final String substring,
+ final StateListDrawable deleteDrawable) {
+ if (type != AdapterType.BASE_RECIPIENT) {
+ if (type == AdapterType.SINGLE_RECIPIENT) {
+ // Treat single recipients the same way as alternates. The base implementation of
+ // single recipients would try to simplify the destination by tokenizing. We'd
+ // like to always show the full destination address per design request.
+ type = AdapterType.RECIPIENT_ALTERNATES;
+ }
+ return super.bindView(convertView, parent, entry, position, type, substring,
+ deleteDrawable);
+ }
+
+ // Default to show all the information
+ // RTL : To format contact name and detail if they happen to be phone numbers.
+ final BidiFormatter bidiFormatter = BidiFormatter.getInstance();
+ final String displayName = bidiFormatter.unicodeWrap(
+ ContactRecipientEntryUtils.getDisplayNameForContactList(entry),
+ TextDirectionHeuristicsCompat.LTR);
+ final String destination = bidiFormatter.unicodeWrap(
+ ContactRecipientEntryUtils.formatDestination(entry),
+ TextDirectionHeuristicsCompat.LTR);
+ final View itemView = reuseOrInflateView(convertView, parent, type);
+
+ // Bold the string that is matched.
+ final CharSequence[] styledResults =
+ getStyledResults(substring, displayName, destination);
+
+ Assert.isTrue(itemView instanceof ContactListItemView);
+ final ContactListItemView contactListItemView = (ContactListItemView) itemView;
+ contactListItemView.setImageClickHandlerDisabled(true);
+ contactListItemView.bind(entry, styledResults[0], styledResults[1],
+ mClivHostInterface, (type == AdapterType.SINGLE_RECIPIENT));
+ return itemView;
+ }
+
+ @Override
+ protected void bindIconToView(boolean showImage, RecipientEntry entry, ImageView view,
+ AdapterType type) {
+ if (showImage && view instanceof ContactIconView) {
+ final ContactIconView contactView = (ContactIconView) view;
+ // These show contact cards by default, but that isn't what we want here
+ contactView.setImageClickHandlerDisabled(true);
+ final Uri avatarUri = AvatarUriUtil.createAvatarUri(
+ ParticipantData.getFromRecipientEntry(entry));
+ contactView.setImageResourceUri(avatarUri);
+ } else {
+ super.bindIconToView(showImage, entry, view, type);
+ }
+ }
+
+ @Override
+ protected int getItemLayoutResId(AdapterType type) {
+ switch (type) {
+ case BASE_RECIPIENT:
+ return R.layout.contact_list_item_view;
+ case RECIPIENT_ALTERNATES:
+ return R.layout.chips_alternates_dropdown_item;
+ default:
+ return R.layout.chips_alternates_dropdown_item;
+ }
+ }
+
+ @Override
+ protected int getAlternateItemLayoutResId(AdapterType type) {
+ return getItemLayoutResId(type);
+ }
+}
diff --git a/src/com/android/messaging/ui/contact/ContactListAdapter.java b/src/com/android/messaging/ui/contact/ContactListAdapter.java
new file mode 100644
index 0000000..d466b61
--- /dev/null
+++ b/src/com/android/messaging/ui/contact/ContactListAdapter.java
@@ -0,0 +1,86 @@
+/*
+ * 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.messaging.ui.contact;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+import android.widget.SectionIndexer;
+
+import com.android.messaging.R;
+import com.android.messaging.util.Assert;
+
+public class ContactListAdapter extends CursorAdapter implements SectionIndexer {
+ private final ContactListItemView.HostInterface mClivHostInterface;
+ private final boolean mNeedAlphabetHeader;
+ private ContactSectionIndexer mSectionIndexer;
+
+ public ContactListAdapter(final Context context, final Cursor cursor,
+ final ContactListItemView.HostInterface clivHostInterface,
+ final boolean needAlphabetHeader) {
+ super(context, cursor, 0);
+ mClivHostInterface = clivHostInterface;
+ mNeedAlphabetHeader = needAlphabetHeader;
+ mSectionIndexer = new ContactSectionIndexer(cursor);
+ }
+
+ @Override
+ public void bindView(final View view, final Context context, final Cursor cursor) {
+ Assert.isTrue(view instanceof ContactListItemView);
+ final ContactListItemView contactListItemView = (ContactListItemView) view;
+ String alphabetHeader = null;
+ if (mNeedAlphabetHeader) {
+ final int position = cursor.getPosition();
+ final int section = mSectionIndexer.getSectionForPosition(position);
+ // Check if the position is the first in the section.
+ if (mSectionIndexer.getPositionForSection(section) == position) {
+ alphabetHeader = (String) mSectionIndexer.getSections()[section];
+ }
+ }
+ contactListItemView.bind(cursor, mClivHostInterface, mNeedAlphabetHeader, alphabetHeader);
+ }
+
+ @Override
+ public View newView(final Context context, final Cursor cursor, final ViewGroup parent) {
+ final LayoutInflater layoutInflater = LayoutInflater.from(context);
+ return layoutInflater.inflate(R.layout.contact_list_item_view, parent, false);
+ }
+
+ @Override
+ public Cursor swapCursor(final Cursor newCursor) {
+ mSectionIndexer = new ContactSectionIndexer(newCursor);
+ return super.swapCursor(newCursor);
+ }
+
+ @Override
+ public Object[] getSections() {
+ return mSectionIndexer.getSections();
+ }
+
+ @Override
+ public int getPositionForSection(final int sectionIndex) {
+ return mSectionIndexer.getPositionForSection(sectionIndex);
+ }
+
+ @Override
+ public int getSectionForPosition(final int position) {
+ return mSectionIndexer.getSectionForPosition(position);
+ }
+}
diff --git a/src/com/android/messaging/ui/contact/ContactListItemView.java b/src/com/android/messaging/ui/contact/ContactListItemView.java
new file mode 100644
index 0000000..6904da6
--- /dev/null
+++ b/src/com/android/messaging/ui/contact/ContactListItemView.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.messaging.ui.contact;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.ex.chips.RecipientEntry;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.data.ContactListItemData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.ui.ContactIconView;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * The view for a single entry in a contact list.
+ */
+public class ContactListItemView extends LinearLayout implements OnClickListener {
+ public interface HostInterface {
+ void onContactListItemClicked(ContactListItemData item, ContactListItemView view);
+ boolean isContactSelected(ContactListItemData item);
+ }
+
+ @VisibleForTesting
+ final ContactListItemData mData;
+ private TextView mContactNameTextView;
+ private TextView mContactDetailsTextView;
+ private TextView mContactDetailTypeTextView;
+ private TextView mAlphabetHeaderTextView;
+ private ContactIconView mContactIconView;
+ private ImageView mContactCheckmarkView;
+ private HostInterface mHostInterface;
+ private boolean mShouldShowAlphabetHeader;
+
+ public ContactListItemView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mData = DataModel.get().createContactListItemData();
+ }
+
+ @Override
+ protected void onFinishInflate () {
+ mContactNameTextView = (TextView) findViewById(R.id.contact_name);
+ mContactDetailsTextView = (TextView) findViewById(R.id.contact_details);
+ mContactDetailTypeTextView = (TextView) findViewById(R.id.contact_detail_type);
+ mAlphabetHeaderTextView = (TextView) findViewById(R.id.alphabet_header);
+ mContactIconView = (ContactIconView) findViewById(R.id.contact_icon);
+ mContactCheckmarkView = (ImageView) findViewById(R.id.contact_checkmark);
+ }
+
+ /**
+ * Fills in the data associated with this view by binding to a contact cursor provided by
+ * ContactUtil.
+ * @param cursor the contact cursor.
+ * @param hostInterface host interface to this view.
+ * @param shouldShowAlphabetHeader whether an alphabetical header should shown on the side
+ * of this view. If {@code headerLabel} is empty, we will still leave space for it.
+ * @param headerLabel the alphabetical header on the side of this view, if it should be shown.
+ */
+ public void bind(final Cursor cursor, final HostInterface hostInterface,
+ final boolean shouldShowAlphabetHeader, final String headerLabel) {
+ mData.bind(cursor, headerLabel);
+ mHostInterface = hostInterface;
+ mShouldShowAlphabetHeader = shouldShowAlphabetHeader;
+ setOnClickListener(this);
+ updateViewAppearance();
+ }
+
+ /**
+ * Binds a RecipientEntry. This is used by the chips text view's dropdown layout.
+ * @param recipientEntry the source RecipientEntry provided by ContactDropdownLayouter, which
+ * was in turn directly from one of the existing chips, or from filtered results
+ * generated by ContactRecipientAdapter.
+ * @param styledName display name where the portion that matches the search text is bold.
+ * @param styledDestination number where the portion that matches the search text is bold.
+ * @param hostInterface host interface to this view.
+ * @param isSingleRecipient whether this item is shown as the only line item in the single
+ * recipient drop down from the chips view. If this is the case, we always show the
+ * contact avatar even if it's not a first-level entry.
+ */
+ public void bind(final RecipientEntry recipientEntry, final CharSequence styledName,
+ final CharSequence styledDestination, final HostInterface hostInterface,
+ final boolean isSingleRecipient) {
+ mData.bind(recipientEntry, styledName, styledDestination, isSingleRecipient);
+ mHostInterface = hostInterface;
+ mShouldShowAlphabetHeader = false;
+ updateViewAppearance();
+ }
+
+ private void updateViewAppearance() {
+ mContactNameTextView.setText(mData.getDisplayName());
+ mContactDetailsTextView.setText(mData.getDestination());
+ mContactDetailTypeTextView.setText(Phone.getTypeLabel(getResources(),
+ mData.getDestinationType(), mData.getDestinationLabel()));
+ final RecipientEntry recipientEntry = mData.getRecipientEntry();
+ final String destinationString = String.valueOf(mData.getDestination());
+ if (mData.getIsSimpleContactItem()) {
+ // This is a special number-with-avatar type of contact (for unknown contact chips
+ // and for direct "send to destination" item). In this case, make sure we only show
+ // the display name (phone number) and the avatar and hide everything else.
+ final Uri avatarUri = AvatarUriUtil.createAvatarUri(
+ ParticipantData.getFromRecipientEntry(recipientEntry));
+ mContactIconView.setImageResourceUri(avatarUri, mData.getContactId(),
+ mData.getLookupKey(), destinationString);
+ mContactIconView.setVisibility(VISIBLE);
+ mContactCheckmarkView.setVisibility(GONE);
+ mContactDetailTypeTextView.setVisibility(GONE);
+ mContactDetailsTextView.setVisibility(GONE);
+ mContactNameTextView.setVisibility(VISIBLE);
+ } else if (mData.getIsFirstLevel()) {
+ final Uri avatarUri = AvatarUriUtil.createAvatarUri(
+ ParticipantData.getFromRecipientEntry(recipientEntry));
+ mContactIconView.setImageResourceUri(avatarUri, mData.getContactId(),
+ mData.getLookupKey(), destinationString);
+ mContactIconView.setVisibility(VISIBLE);
+ mContactNameTextView.setVisibility(VISIBLE);
+ final boolean isSelected = mHostInterface.isContactSelected(mData);
+ setSelected(isSelected);
+ mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE);
+ mContactDetailsTextView.setVisibility(VISIBLE);
+ mContactDetailTypeTextView.setVisibility(VISIBLE);
+ } else {
+ mContactIconView.setImageResourceUri(null);
+ mContactIconView.setVisibility(INVISIBLE);
+ mContactNameTextView.setVisibility(GONE);
+ final boolean isSelected = mHostInterface.isContactSelected(mData);
+ setSelected(isSelected);
+ mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE);
+ mContactDetailsTextView.setVisibility(VISIBLE);
+ mContactDetailTypeTextView.setVisibility(VISIBLE);
+ }
+
+ if (mShouldShowAlphabetHeader) {
+ mAlphabetHeaderTextView.setVisibility(VISIBLE);
+ mAlphabetHeaderTextView.setText(mData.getAlphabetHeader());
+ } else {
+ mAlphabetHeaderTextView.setVisibility(GONE);
+ }
+ }
+
+ /**
+ * {@inheritDoc} from OnClickListener
+ */
+ @Override
+ public void onClick(final View v) {
+ Assert.isTrue(v == this);
+ Assert.isTrue(mHostInterface != null);
+ mHostInterface.onContactListItemClicked(mData, this);
+ }
+
+ public void setImageClickHandlerDisabled(final boolean isHandlerDisabled) {
+ mContactIconView.setImageClickHandlerDisabled(isHandlerDisabled);
+ }
+}
diff --git a/src/com/android/messaging/ui/contact/ContactPickerFragment.java b/src/com/android/messaging/ui/contact/ContactPickerFragment.java
new file mode 100644
index 0000000..d803087
--- /dev/null
+++ b/src/com/android/messaging/ui/contact/ContactPickerFragment.java
@@ -0,0 +1,607 @@
+/*
+ * 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.messaging.ui.contact;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.database.Cursor;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.support.v7.widget.Toolbar;
+import android.support.v7.widget.Toolbar.OnMenuItemClickListener;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.transition.Explode;
+import android.transition.Transition;
+import android.transition.Transition.EpicenterCallback;
+import android.transition.TransitionManager;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.action.ActionMonitor;
+import com.android.messaging.datamodel.action.GetOrCreateConversationAction;
+import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener;
+import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.ContactListItemData;
+import com.android.messaging.datamodel.data.ContactPickerData;
+import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.ui.CustomHeaderPagerViewHolder;
+import com.android.messaging.ui.CustomHeaderViewPager;
+import com.android.messaging.ui.animation.ViewGroupItemVerticalExplodeAnimation;
+import com.android.messaging.ui.contact.ContactRecipientAutoCompleteView.ContactChipsChangeListener;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.RunsOnMainThread;
+import com.android.messaging.util.ContactUtil;
+import com.android.messaging.util.ImeUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.UiUtils;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.Set;
+
+
+/**
+ * Shows lists of contacts to start conversations with.
+ */
+public class ContactPickerFragment extends Fragment implements ContactPickerDataListener,
+ ContactListItemView.HostInterface, ContactChipsChangeListener, OnMenuItemClickListener,
+ GetOrCreateConversationActionListener {
+ public static final String FRAGMENT_TAG = "contactpicker";
+
+ // Undefined contact picker mode. We should never be in this state after the host activity has
+ // been created.
+ public static final int MODE_UNDEFINED = 0;
+
+ // The initial contact picker mode for starting a new conversation with one contact.
+ public static final int MODE_PICK_INITIAL_CONTACT = 1;
+
+ // The contact picker mode where one initial contact has been picked and we are showing
+ // only the chips edit box.
+ public static final int MODE_CHIPS_ONLY = 2;
+
+ // The contact picker mode for picking more contacts after starting the initial 1-1.
+ public static final int MODE_PICK_MORE_CONTACTS = 3;
+
+ // The contact picker mode when max number of participants is reached.
+ public static final int MODE_PICK_MAX_PARTICIPANTS = 4;
+
+ public interface ContactPickerFragmentHost {
+ void onGetOrCreateNewConversation(String conversationId);
+ void onBackButtonPressed();
+ void onInitiateAddMoreParticipants();
+ void onParticipantCountChanged(boolean canAddMoreParticipants);
+ void invalidateActionBar();
+ }
+
+ @VisibleForTesting
+ final Binding<ContactPickerData> mBinding = BindingBase.createBinding(this);
+
+ private ContactPickerFragmentHost mHost;
+ private ContactRecipientAutoCompleteView mRecipientTextView;
+ private CustomHeaderViewPager mCustomHeaderViewPager;
+ private AllContactsListViewHolder mAllContactsListViewHolder;
+ private FrequentContactsListViewHolder mFrequentContactsListViewHolder;
+ private View mRootView;
+ private View mPendingExplodeView;
+ private View mComposeDivider;
+ private Toolbar mToolbar;
+ private int mContactPickingMode = MODE_UNDEFINED;
+
+ // Keeps track of the currently selected phone numbers in the chips view to enable fast lookup.
+ private Set<String> mSelectedPhoneNumbers = null;
+
+ /**
+ * {@inheritDoc} from Fragment
+ */
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mAllContactsListViewHolder = new AllContactsListViewHolder(getActivity(), this);
+ mFrequentContactsListViewHolder = new FrequentContactsListViewHolder(getActivity(), this);
+
+ if (ContactUtil.hasReadContactsPermission()) {
+ mBinding.bind(DataModel.get().createContactPickerData(getActivity(), this));
+ mBinding.getData().init(getLoaderManager(), mBinding);
+ }
+ }
+
+ /**
+ * {@inheritDoc} from Fragment
+ */
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.contact_picker_fragment, container, false);
+ mRecipientTextView = (ContactRecipientAutoCompleteView)
+ view.findViewById(R.id.recipient_text_view);
+ mRecipientTextView.setThreshold(0);
+ mRecipientTextView.setDropDownAnchor(R.id.compose_contact_divider);
+
+ mRecipientTextView.setContactChipsListener(this);
+ mRecipientTextView.setDropdownChipLayouter(new ContactDropdownLayouter(inflater,
+ getActivity(), this));
+ mRecipientTextView.setAdapter(new ContactRecipientAdapter(getActivity(), this));
+ mRecipientTextView.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void onTextChanged(final CharSequence s, final int start, final int before,
+ final int count) {
+ }
+
+ @Override
+ public void beforeTextChanged(final CharSequence s, final int start, final int count,
+ final int after) {
+ }
+
+ @Override
+ public void afterTextChanged(final Editable s) {
+ updateTextInputButtonsVisibility();
+ }
+ });
+
+ final CustomHeaderPagerViewHolder[] viewHolders = {
+ mFrequentContactsListViewHolder,
+ mAllContactsListViewHolder };
+
+ mCustomHeaderViewPager = (CustomHeaderViewPager) view.findViewById(R.id.contact_pager);
+ mCustomHeaderViewPager.setViewHolders(viewHolders);
+ mCustomHeaderViewPager.setViewPagerTabHeight(CustomHeaderViewPager.DEFAULT_TAB_STRIP_SIZE);
+ mCustomHeaderViewPager.setBackgroundColor(getResources()
+ .getColor(R.color.contact_picker_background));
+
+ // The view pager defaults to the frequent contacts page.
+ mCustomHeaderViewPager.setCurrentItem(0);
+
+ mToolbar = (Toolbar) view.findViewById(R.id.toolbar);
+ mToolbar.setNavigationIcon(R.drawable.ic_arrow_back_light);
+ mToolbar.setNavigationContentDescription(R.string.back);
+ mToolbar.setNavigationOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ mHost.onBackButtonPressed();
+ }
+ });
+
+ mToolbar.inflateMenu(R.menu.compose_menu);
+ mToolbar.setOnMenuItemClickListener(this);
+
+ mComposeDivider = view.findViewById(R.id.compose_contact_divider);
+ mRootView = view;
+ return view;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Called when the host activity has been created. At this point, the host activity should
+ * have set the contact picking mode for us so that we may update our visuals.
+ */
+ @Override
+ public void onActivityCreated(final Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ Assert.isTrue(mContactPickingMode != MODE_UNDEFINED);
+ updateVisualsForContactPickingMode(false /* animate */);
+ mHost.invalidateActionBar();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ // We could not have bound to the data if the permission was denied.
+ if (mBinding.isBound()) {
+ mBinding.unbind();
+ }
+
+ if (mMonitor != null) {
+ mMonitor.unregister();
+ }
+ mMonitor = null;
+ }
+
+ @Override
+ public boolean onMenuItemClick(final MenuItem menuItem) {
+ switch (menuItem.getItemId()) {
+ case R.id.action_ime_dialpad_toggle:
+ final int baseInputType = InputType.TYPE_TEXT_FLAG_MULTI_LINE;
+ if ((mRecipientTextView.getInputType() & InputType.TYPE_CLASS_PHONE) !=
+ InputType.TYPE_CLASS_PHONE) {
+ mRecipientTextView.setInputType(baseInputType | InputType.TYPE_CLASS_PHONE);
+ menuItem.setIcon(R.drawable.ic_ime_light);
+ } else {
+ mRecipientTextView.setInputType(baseInputType | InputType.TYPE_CLASS_TEXT);
+ menuItem.setIcon(R.drawable.ic_numeric_dialpad);
+ }
+ ImeUtil.get().showImeKeyboard(getActivity(), mRecipientTextView);
+ return true;
+
+ case R.id.action_add_more_participants:
+ mHost.onInitiateAddMoreParticipants();
+ return true;
+
+ case R.id.action_confirm_participants:
+ maybeGetOrCreateConversation();
+ return true;
+
+ case R.id.action_delete_text:
+ Assert.equals(MODE_PICK_INITIAL_CONTACT, mContactPickingMode);
+ mRecipientTextView.setText("");
+ return true;
+ }
+ return false;
+ }
+
+ @Override // From ContactPickerDataListener
+ public void onAllContactsCursorUpdated(final Cursor data) {
+ mBinding.ensureBound();
+ mAllContactsListViewHolder.onContactsCursorUpdated(data);
+ }
+
+ @Override // From ContactPickerDataListener
+ public void onFrequentContactsCursorUpdated(final Cursor data) {
+ mBinding.ensureBound();
+ mFrequentContactsListViewHolder.onContactsCursorUpdated(data);
+ if (data != null && data.getCount() == 0) {
+ // Show the all contacts list when there's no frequents.
+ mCustomHeaderViewPager.setCurrentItem(1);
+ }
+ }
+
+ @Override // From ContactListItemView.HostInterface
+ public void onContactListItemClicked(final ContactListItemData item,
+ final ContactListItemView view) {
+ if (!isContactSelected(item)) {
+ if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) {
+ mPendingExplodeView = view;
+ }
+ mRecipientTextView.appendRecipientEntry(item.getRecipientEntry());
+ } else if (mContactPickingMode != MODE_PICK_INITIAL_CONTACT) {
+ mRecipientTextView.removeRecipientEntry(item.getRecipientEntry());
+ }
+ }
+
+ @Override // From ContactListItemView.HostInterface
+ public boolean isContactSelected(final ContactListItemData item) {
+ return mSelectedPhoneNumbers != null &&
+ mSelectedPhoneNumbers.contains(PhoneUtils.getDefault().getCanonicalBySystemLocale(
+ item.getRecipientEntry().getDestination()));
+ }
+
+ /**
+ * Call this immediately after attaching the fragment, or when there's a ui state change that
+ * changes our host (i.e. restore from saved instance state).
+ */
+ public void setHost(final ContactPickerFragmentHost host) {
+ mHost = host;
+ }
+
+ public void setContactPickingMode(final int mode, final boolean animate) {
+ if (mContactPickingMode != mode) {
+ // Guard against impossible transitions.
+ Assert.isTrue(
+ // We may start from undefined mode to any mode when we are restoring state.
+ (mContactPickingMode == MODE_UNDEFINED) ||
+ (mContactPickingMode == MODE_PICK_INITIAL_CONTACT && mode == MODE_CHIPS_ONLY) ||
+ (mContactPickingMode == MODE_CHIPS_ONLY && mode == MODE_PICK_MORE_CONTACTS) ||
+ (mContactPickingMode == MODE_PICK_MORE_CONTACTS
+ && mode == MODE_PICK_MAX_PARTICIPANTS) ||
+ (mContactPickingMode == MODE_PICK_MAX_PARTICIPANTS
+ && mode == MODE_PICK_MORE_CONTACTS));
+
+ mContactPickingMode = mode;
+ updateVisualsForContactPickingMode(animate);
+ }
+ }
+
+ private void showImeKeyboard() {
+ Assert.notNull(mRecipientTextView);
+ mRecipientTextView.requestFocus();
+
+ // showImeKeyboard() won't work until the layout is ready, so wait until layout is complete
+ // before showing the soft keyboard.
+ UiUtils.doOnceAfterLayoutChange(mRootView, new Runnable() {
+ @Override
+ public void run() {
+ final Activity activity = getActivity();
+ if (activity != null) {
+ ImeUtil.get().showImeKeyboard(activity, mRecipientTextView);
+ }
+ }
+ });
+ mRecipientTextView.invalidate();
+ }
+
+ private void updateVisualsForContactPickingMode(final boolean animate) {
+ // Don't update visuals if the visuals haven't been inflated yet.
+ if (mRootView != null) {
+ final Menu menu = mToolbar.getMenu();
+ final MenuItem addMoreParticipantsItem = menu.findItem(
+ R.id.action_add_more_participants);
+ final MenuItem confirmParticipantsItem = menu.findItem(
+ R.id.action_confirm_participants);
+ switch (mContactPickingMode) {
+ case MODE_PICK_INITIAL_CONTACT:
+ addMoreParticipantsItem.setVisible(false);
+ confirmParticipantsItem.setVisible(false);
+ mCustomHeaderViewPager.setVisibility(View.VISIBLE);
+ mComposeDivider.setVisibility(View.INVISIBLE);
+ mRecipientTextView.setEnabled(true);
+ showImeKeyboard();
+ break;
+
+ case MODE_CHIPS_ONLY:
+ if (animate) {
+ if (mPendingExplodeView == null) {
+ // The user didn't click on any contact item, so use the toolbar as
+ // the view to "explode."
+ mPendingExplodeView = mToolbar;
+ }
+ startExplodeTransitionForContactLists(false /* show */);
+
+ ViewGroupItemVerticalExplodeAnimation.startAnimationForView(
+ mCustomHeaderViewPager, mPendingExplodeView, mRootView,
+ true /* snapshotView */, UiUtils.COMPOSE_TRANSITION_DURATION);
+ showHideContactPagerWithAnimation(false /* show */);
+ } else {
+ mCustomHeaderViewPager.setVisibility(View.GONE);
+ }
+
+ addMoreParticipantsItem.setVisible(true);
+ confirmParticipantsItem.setVisible(false);
+ mComposeDivider.setVisibility(View.VISIBLE);
+ mRecipientTextView.setEnabled(true);
+ break;
+
+ case MODE_PICK_MORE_CONTACTS:
+ if (animate) {
+ // Correctly set the start visibility state for the view pager and
+ // individual list items (hidden initially), so that the transition
+ // manager can properly track the visibility change for the explode.
+ mCustomHeaderViewPager.setVisibility(View.VISIBLE);
+ toggleContactListItemsVisibilityForPendingTransition(false /* show */);
+ startExplodeTransitionForContactLists(true /* show */);
+ }
+ addMoreParticipantsItem.setVisible(false);
+ confirmParticipantsItem.setVisible(true);
+ mCustomHeaderViewPager.setVisibility(View.VISIBLE);
+ mComposeDivider.setVisibility(View.INVISIBLE);
+ mRecipientTextView.setEnabled(true);
+ showImeKeyboard();
+ break;
+
+ case MODE_PICK_MAX_PARTICIPANTS:
+ addMoreParticipantsItem.setVisible(false);
+ confirmParticipantsItem.setVisible(true);
+ mCustomHeaderViewPager.setVisibility(View.VISIBLE);
+ mComposeDivider.setVisibility(View.INVISIBLE);
+ // TODO: Verify that this is okay for accessibility
+ mRecipientTextView.setEnabled(false);
+ break;
+
+ default:
+ Assert.fail("Unsupported contact picker mode!");
+ break;
+ }
+ updateTextInputButtonsVisibility();
+ }
+ }
+
+ private void updateTextInputButtonsVisibility() {
+ final Menu menu = mToolbar.getMenu();
+ final MenuItem keypadToggleItem = menu.findItem(R.id.action_ime_dialpad_toggle);
+ final MenuItem deleteTextItem = menu.findItem(R.id.action_delete_text);
+ if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) {
+ if (TextUtils.isEmpty(mRecipientTextView.getText())) {
+ deleteTextItem.setVisible(false);
+ keypadToggleItem.setVisible(true);
+ } else {
+ deleteTextItem.setVisible(true);
+ keypadToggleItem.setVisible(false);
+ }
+ } else {
+ deleteTextItem.setVisible(false);
+ keypadToggleItem.setVisible(false);
+ }
+ }
+
+ private void maybeGetOrCreateConversation() {
+ final ArrayList<ParticipantData> participants =
+ mRecipientTextView.getRecipientParticipantDataForConversationCreation();
+ if (ContactPickerData.isTooManyParticipants(participants.size())) {
+ UiUtils.showToast(R.string.too_many_participants);
+ } else if (participants.size() > 0 && mMonitor == null) {
+ mMonitor = GetOrCreateConversationAction.getOrCreateConversation(participants,
+ null, this);
+ }
+ }
+
+ /**
+ * Watches changes in contact chips to determine possible state transitions (e.g. creating
+ * the initial conversation, adding more participants or finish the current conversation)
+ */
+ @Override
+ public void onContactChipsChanged(final int oldCount, final int newCount) {
+ Assert.isTrue(oldCount != newCount);
+ if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) {
+ // Initial picking mode. Start a conversation once a recipient has been picked.
+ maybeGetOrCreateConversation();
+ } else if (mContactPickingMode == MODE_CHIPS_ONLY) {
+ // oldCount == 0 means we are restoring from savedInstanceState to add the existing
+ // chips, don't switch to "add more participants" mode in this case.
+ if (oldCount > 0 && mRecipientTextView.isFocused()) {
+ // Chips only mode. The user may have picked an additional contact or deleted the
+ // only existing contact. Either way, switch to picking more participants mode.
+ mHost.onInitiateAddMoreParticipants();
+ }
+ }
+ mHost.onParticipantCountChanged(ContactPickerData.getCanAddMoreParticipants(newCount));
+
+ // Refresh our local copy of the selected chips set to keep it up-to-date.
+ mSelectedPhoneNumbers = mRecipientTextView.getSelectedDestinations();
+ invalidateContactLists();
+ }
+
+ /**
+ * Listens for notification that invalid contacts have been removed during resolving them.
+ * These contacts were not local contacts, valid email, or valid phone numbers
+ */
+ @Override
+ public void onInvalidContactChipsPruned(final int prunedCount) {
+ Assert.isTrue(prunedCount > 0);
+ UiUtils.showToast(R.plurals.add_invalid_contact_error, prunedCount);
+ }
+
+ /**
+ * Listens for notification that the user has pressed enter/done on the keyboard with all
+ * contacts in place and we should create or go to the existing conversation now
+ */
+ @Override
+ public void onEntryComplete() {
+ if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT ||
+ mContactPickingMode == MODE_PICK_MORE_CONTACTS ||
+ mContactPickingMode == MODE_PICK_MAX_PARTICIPANTS) {
+ // Avoid multiple calls to create in race cases (hit done right after selecting contact)
+ maybeGetOrCreateConversation();
+ }
+ }
+
+ private void invalidateContactLists() {
+ mAllContactsListViewHolder.invalidateList();
+ mFrequentContactsListViewHolder.invalidateList();
+ }
+
+ /**
+ * Kicks off a scene transition that animates visibility changes of individual contact list
+ * items via explode animation.
+ * @param show whether the contact lists are to be shown or hidden.
+ */
+ private void startExplodeTransitionForContactLists(final boolean show) {
+ if (!OsUtil.isAtLeastL()) {
+ // Explode animation is not supported pre-L.
+ return;
+ }
+ final Explode transition = new Explode();
+ final Rect epicenter = mPendingExplodeView == null ? null :
+ UiUtils.getMeasuredBoundsOnScreen(mPendingExplodeView);
+ transition.setDuration(UiUtils.COMPOSE_TRANSITION_DURATION);
+ transition.setInterpolator(UiUtils.EASE_IN_INTERPOLATOR);
+ transition.setEpicenterCallback(new EpicenterCallback() {
+ @Override
+ public Rect onGetEpicenter(final Transition transition) {
+ return epicenter;
+ }
+ });
+
+ // Kick off the delayed scene explode transition. Anything happens after this line in this
+ // method before the next frame will be tracked by the transition manager for visibility
+ // changes and animated accordingly.
+ TransitionManager.beginDelayedTransition(mCustomHeaderViewPager,
+ transition);
+
+ toggleContactListItemsVisibilityForPendingTransition(show);
+ }
+
+ /**
+ * Toggle the visibility of contact list items in the contact lists for them to be tracked by
+ * the transition manager for pending explode transition.
+ */
+ private void toggleContactListItemsVisibilityForPendingTransition(final boolean show) {
+ if (!OsUtil.isAtLeastL()) {
+ // Explode animation is not supported pre-L.
+ return;
+ }
+ mAllContactsListViewHolder.toggleVisibilityForPendingTransition(show, mPendingExplodeView);
+ mFrequentContactsListViewHolder.toggleVisibilityForPendingTransition(show,
+ mPendingExplodeView);
+ }
+
+ private void showHideContactPagerWithAnimation(final boolean show) {
+ final boolean isPagerVisible = (mCustomHeaderViewPager.getVisibility() == View.VISIBLE);
+ if (show == isPagerVisible) {
+ return;
+ }
+
+ mCustomHeaderViewPager.animate().alpha(show ? 1F : 0F)
+ .setStartDelay(!show ? UiUtils.COMPOSE_TRANSITION_DURATION : 0)
+ .withStartAction(new Runnable() {
+ @Override
+ public void run() {
+ mCustomHeaderViewPager.setVisibility(View.VISIBLE);
+ mCustomHeaderViewPager.setAlpha(show ? 0F : 1F);
+ }
+ })
+ .withEndAction(new Runnable() {
+ @Override
+ public void run() {
+ mCustomHeaderViewPager.setVisibility(show ? View.VISIBLE : View.GONE);
+ mCustomHeaderViewPager.setAlpha(1F);
+ }
+ });
+ }
+
+ @Override
+ public void onContactCustomColorLoaded(final ContactPickerData data) {
+ mBinding.ensureBound(data);
+ invalidateContactLists();
+ }
+
+ public void updateActionBar(final ActionBar actionBar) {
+ // Hide the action bar for contact picker mode. The custom ToolBar containing chips UI
+ // etc. will take the spot of the action bar.
+ actionBar.hide();
+ UiUtils.setStatusBarColor(getActivity(),
+ getResources().getColor(R.color.compose_notification_bar_background));
+ }
+
+ private GetOrCreateConversationActionMonitor mMonitor;
+
+ @Override
+ @RunsOnMainThread
+ public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor,
+ final Object data, final String conversationId) {
+ Assert.isTrue(monitor == mMonitor);
+ Assert.isTrue(conversationId != null);
+
+ mRecipientTextView.setInputType(InputType.TYPE_TEXT_FLAG_MULTI_LINE |
+ InputType.TYPE_CLASS_TEXT);
+ mHost.onGetOrCreateNewConversation(conversationId);
+
+ mMonitor = null;
+ }
+
+ @Override
+ @RunsOnMainThread
+ public void onGetOrCreateConversationFailed(final ActionMonitor monitor,
+ final Object data) {
+ Assert.isTrue(monitor == mMonitor);
+ LogUtil.e(LogUtil.BUGLE_TAG, "onGetOrCreateConversationFailed");
+ mMonitor = null;
+ }
+}
diff --git a/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java b/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java
new file mode 100644
index 0000000..25f422e
--- /dev/null
+++ b/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java
@@ -0,0 +1,286 @@
+/*
+ * 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.messaging.ui.contact;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.MergeCursor;
+import android.support.v4.util.Pair;
+import android.text.TextUtils;
+import android.text.util.Rfc822Token;
+import android.text.util.Rfc822Tokenizer;
+import android.widget.Filter;
+
+import com.android.ex.chips.BaseRecipientAdapter;
+import com.android.ex.chips.RecipientAlternatesAdapter;
+import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback;
+import com.android.ex.chips.RecipientEntry;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.ContactRecipientEntryUtils;
+import com.android.messaging.util.ContactUtil;
+import com.android.messaging.util.PhoneUtils;
+
+import java.text.Collator;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * An extension on the base {@link BaseRecipientAdapter} that uses data layer from Bugle,
+ * such as the ContactRecipientPhotoManager that uses our own MediaResourceManager, and
+ * contact lookup that relies on ContactUtil. It provides data source and filtering ability
+ * for {@link ContactRecipientAutoCompleteView}
+ */
+public final class ContactRecipientAdapter extends BaseRecipientAdapter {
+ public ContactRecipientAdapter(final Context context,
+ final ContactListItemView.HostInterface clivHost) {
+ this(context, Integer.MAX_VALUE, QUERY_TYPE_PHONE, clivHost);
+ }
+
+ public ContactRecipientAdapter(final Context context, final int preferredMaxResultCount,
+ final int queryMode, final ContactListItemView.HostInterface clivHost) {
+ super(context, preferredMaxResultCount, queryMode);
+ setPhotoManager(new ContactRecipientPhotoManager(context, clivHost));
+ }
+
+ @Override
+ public boolean forceShowAddress() {
+ // We should always use the SingleRecipientAddressAdapter
+ // And never use the RecipientAlternatesAdapter
+ return true;
+ }
+
+ @Override
+ public Filter getFilter() {
+ return new ContactFilter();
+ }
+
+ /**
+ * A Filter for a RecipientEditTextView that queries Bugle's ContactUtil for auto-complete
+ * results.
+ */
+ public class ContactFilter extends Filter {
+ // Used to sort filtered contacts when it has combined results from email and phone.
+ private final RecipientEntryComparator mComparator = new RecipientEntryComparator();
+
+ /**
+ * Returns a cursor containing the filtered results in contacts given the search text,
+ * and a boolean indicating whether the results are sorted.
+ *
+ * The queries are synchronously performed since this is not run on the main thread.
+ *
+ * Some locales (e.g. JPN) expect email addresses to be auto-completed for MMS.
+ * If this is the case, perform two queries on phone number followed by email and
+ * return the merged results.
+ */
+ @DoesNotRunOnMainThread
+ private Pair<Cursor, Boolean> getFilteredResultsCursor(final Context context,
+ final String searchText) {
+ Assert.isNotMainThread();
+ if (BugleGservices.get().getBoolean(
+ BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS,
+ BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS_DEFAULT)) {
+ return Pair.create((Cursor) new MergeCursor(new Cursor[] {
+ ContactUtil.filterPhones(getContext(), searchText)
+ .performSynchronousQuery(),
+ ContactUtil.filterEmails(getContext(), searchText)
+ .performSynchronousQuery()
+ }), false /* the merged cursor is not sorted */);
+ } else {
+ return Pair.create(ContactUtil.filterDestination(getContext(), searchText)
+ .performSynchronousQuery(), true);
+ }
+ }
+
+ @Override
+ protected FilterResults performFiltering(final CharSequence constraint) {
+ Assert.isNotMainThread();
+ final FilterResults results = new FilterResults();
+
+ // No query, return empty results.
+ if (TextUtils.isEmpty(constraint)) {
+ clearTempEntries();
+ return results;
+ }
+
+ final String searchText = constraint.toString();
+
+ // Query for auto-complete results, since performFiltering() is not done on the
+ // main thread, perform the cursor loader queries directly.
+ final Pair<Cursor, Boolean> filteredResults = getFilteredResultsCursor(getContext(),
+ searchText);
+ final Cursor cursor = filteredResults.first;
+ final boolean sorted = filteredResults.second;
+ if (cursor != null) {
+ try {
+ final List<RecipientEntry> entries = new ArrayList<RecipientEntry>();
+
+ // First check if the constraint is a valid SMS destination. If so, add the
+ // destination as a suggestion item to the drop down.
+ if (PhoneUtils.isValidSmsMmsDestination(searchText)) {
+ entries.add(ContactRecipientEntryUtils
+ .constructSendToDestinationEntry(searchText));
+ }
+
+ HashSet<Long> existingContactIds = new HashSet<Long>();
+ while (cursor.moveToNext()) {
+ // Make sure there's only one first-level contact (i.e. contact for which
+ // we show the avatar picture and name) for every contact id.
+ final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID);
+ final boolean isFirstLevel = !existingContactIds.contains(contactId);
+ if (isFirstLevel) {
+ existingContactIds.add(contactId);
+ }
+ entries.add(ContactUtil.createRecipientEntryForPhoneQuery(cursor,
+ isFirstLevel));
+ }
+
+ if (!sorted) {
+ Collections.sort(entries, mComparator);
+ }
+ results.values = entries;
+ results.count = 1;
+
+ } finally {
+ cursor.close();
+ }
+ }
+ return results;
+ }
+
+ @Override
+ protected void publishResults(final CharSequence constraint, final FilterResults results) {
+ mCurrentConstraint = constraint;
+ clearTempEntries();
+
+ if (results.values != null) {
+ @SuppressWarnings("unchecked")
+ final List<RecipientEntry> entries = (List<RecipientEntry>) results.values;
+ updateEntries(entries);
+ } else {
+ updateEntries(Collections.<RecipientEntry>emptyList());
+ }
+ }
+
+ private class RecipientEntryComparator implements Comparator<RecipientEntry> {
+ private final Collator mCollator;
+
+ public RecipientEntryComparator() {
+ mCollator = Collator.getInstance(Locale.getDefault());
+ mCollator.setStrength(Collator.PRIMARY);
+ }
+
+ /**
+ * Compare two RecipientEntry's, first by locale-aware display name comparison, then by
+ * contact id comparison, finally by first-level-ness comparison.
+ */
+ @Override
+ public int compare(RecipientEntry lhs, RecipientEntry rhs) {
+ // Send-to-destinations always appear before everything else.
+ final boolean sendToLhs = ContactRecipientEntryUtils
+ .isSendToDestinationContact(lhs);
+ final boolean sendToRhs = ContactRecipientEntryUtils
+ .isSendToDestinationContact(lhs);
+ if (sendToLhs != sendToRhs) {
+ if (sendToLhs) {
+ return -1;
+ } else if (sendToRhs) {
+ return 1;
+ }
+ }
+
+ final int displayNameCompare = mCollator.compare(lhs.getDisplayName(),
+ rhs.getDisplayName());
+ if (displayNameCompare != 0) {
+ return displayNameCompare;
+ }
+
+ // Long.compare could accomplish the following three lines, but this is only
+ // available in API 19+
+ final long lhsContactId = lhs.getContactId();
+ final long rhsContactId = rhs.getContactId();
+ final int contactCompare = lhsContactId < rhsContactId ? -1 :
+ (lhsContactId == rhsContactId ? 0 : 1);
+ if (contactCompare != 0) {
+ return contactCompare;
+ }
+
+ // These are the same contact. Make sure first-level contacts always
+ // appear at the front.
+ if (lhs.isFirstLevel()) {
+ return -1;
+ } else if (rhs.isFirstLevel()) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+ }
+ }
+
+ /**
+ * Called when we need to substitute temporary recipient chips with better alternatives.
+ * For example, if a list of comma-delimited phone numbers are pasted into the edit box,
+ * we want to be able to look up in the ContactUtil for exact matches and get contact
+ * details such as name and photo thumbnail for the contact to display a better chip.
+ */
+ @Override
+ public void getMatchingRecipients(final ArrayList<String> inAddresses,
+ final RecipientMatchCallback callback) {
+ final int addressesSize = Math.min(
+ RecipientAlternatesAdapter.MAX_LOOKUPS, inAddresses.size());
+ final HashSet<String> addresses = new HashSet<String>();
+ for (int i = 0; i < addressesSize; i++) {
+ final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase());
+ addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i));
+ }
+
+ final Map<String, RecipientEntry> recipientEntries =
+ new HashMap<String, RecipientEntry>();
+ // query for each address
+ for (final String address : addresses) {
+ final Cursor cursor = ContactUtil.lookupDestination(getContext(), address)
+ .performSynchronousQuery();
+ if (cursor != null) {
+ try {
+ if (cursor.moveToNext()) {
+ // There may be multiple matches to the same number, always take the
+ // first match.
+ // TODO: May need to consider if there's an existing conversation
+ // that matches this particular contact and prioritize that contact.
+ final RecipientEntry entry =
+ ContactUtil.createRecipientEntryForPhoneQuery(cursor, true);
+ recipientEntries.put(address, entry);
+ }
+
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+
+ // report matches
+ callback.matchesFound(recipientEntries);
+ }
+}
diff --git a/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java b/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java
new file mode 100644
index 0000000..c7c2731
--- /dev/null
+++ b/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java
@@ -0,0 +1,289 @@
+/*
+ * 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.messaging.ui.contact;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Rect;
+import android.os.AsyncTask;
+import android.support.v7.appcompat.R;
+import android.text.Editable;
+import android.text.TextPaint;
+import android.text.TextWatcher;
+import android.text.util.Rfc822Tokenizer;
+import android.util.AttributeSet;
+import android.view.ContextThemeWrapper;
+import android.view.KeyEvent;
+import android.view.inputmethod.EditorInfo;
+import android.widget.TextView;
+
+import com.android.ex.chips.RecipientEditTextView;
+import com.android.ex.chips.RecipientEntry;
+import com.android.ex.chips.recipientchip.DrawableRecipientChip;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.util.ContactRecipientEntryUtils;
+import com.android.messaging.util.ContactUtil;
+import com.android.messaging.util.PhoneUtils;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+/**
+ * An extension for {@link RecipientEditTextView} which shows a list of Materialized contact chips.
+ * It uses Bugle's ContactUtil to perform contact lookup, and is able to return the list of
+ * recipients in the form of a ParticipantData list.
+ */
+public class ContactRecipientAutoCompleteView extends RecipientEditTextView {
+ public interface ContactChipsChangeListener {
+ void onContactChipsChanged(int oldCount, int newCount);
+ void onInvalidContactChipsPruned(int prunedCount);
+ void onEntryComplete();
+ }
+
+ private final int mTextHeight;
+ private ContactChipsChangeListener mChipsChangeListener;
+
+ /**
+ * Watches changes in contact chips to determine possible state transitions.
+ */
+ private class ContactChipsWatcher implements TextWatcher {
+ /**
+ * Tracks the old chips count before text changes. Note that we currently don't compare
+ * the entire chip sets but just the cheaper-to-do before and after counts, because
+ * the chips view don't allow for replacing chips.
+ */
+ private int mLastChipsCount = 0;
+
+ @Override
+ public void onTextChanged(final CharSequence s, final int start, final int before,
+ final int count) {
+ }
+
+ @Override
+ public void beforeTextChanged(final CharSequence s, final int start, final int count,
+ final int after) {
+ // We don't take mLastChipsCount from here but from the last afterTextChanged() run.
+ // The reason is because at this point, any chip spans to be removed is already removed
+ // from s in the chips text view.
+ }
+
+ @Override
+ public void afterTextChanged(final Editable s) {
+ final int currentChipsCount = s.getSpans(0, s.length(),
+ DrawableRecipientChip.class).length;
+ if (currentChipsCount != mLastChipsCount) {
+ // When a sanitizing task is running, we don't want to notify any chips count
+ // change, but we do want to track the last chip count.
+ if (mChipsChangeListener != null && mCurrentSanitizeTask == null) {
+ mChipsChangeListener.onContactChipsChanged(mLastChipsCount, currentChipsCount);
+ }
+ mLastChipsCount = currentChipsCount;
+ }
+ }
+ }
+
+ private static final String TEXT_HEIGHT_SAMPLE = "a";
+
+ public ContactRecipientAutoCompleteView(final Context context, final AttributeSet attrs) {
+ super(new ContextThemeWrapper(context, R.style.ColorAccentGrayOverrideStyle), attrs);
+
+ // Get the height of the text, given the currently set font face and size.
+ final Rect textBounds = new Rect(0, 0, 0, 0);
+ final TextPaint paint = getPaint();
+ paint.getTextBounds(TEXT_HEIGHT_SAMPLE, 0, TEXT_HEIGHT_SAMPLE.length(), textBounds);
+ mTextHeight = textBounds.height();
+
+ setTokenizer(new Rfc822Tokenizer());
+ addTextChangedListener(new ContactChipsWatcher());
+ setOnFocusListShrinkRecipients(false);
+
+ setBackground(context.getResources().getDrawable(
+ R.drawable.abc_textfield_search_default_mtrl_alpha));
+ }
+
+ public void setContactChipsListener(final ContactChipsChangeListener listener) {
+ mChipsChangeListener = listener;
+ }
+
+ /**
+ * A tuple of chips which AsyncContactChipSanitizeTask reports as progress to have the
+ * chip actually replaced/removed on the UI thread.
+ */
+ private class ChipReplacementTuple {
+ public final DrawableRecipientChip removedChip;
+ public final RecipientEntry replacedChipEntry;
+
+ public ChipReplacementTuple(final DrawableRecipientChip removedChip,
+ final RecipientEntry replacedChipEntry) {
+ this.removedChip = removedChip;
+ this.replacedChipEntry = replacedChipEntry;
+ }
+ }
+
+ /**
+ * An AsyncTask that cleans up contact chips on every chips commit (i.e. get or create a new
+ * conversation with the given chips).
+ */
+ private class AsyncContactChipSanitizeTask extends
+ AsyncTask<Void, ChipReplacementTuple, Integer> {
+
+ @Override
+ protected Integer doInBackground(final Void... params) {
+ final DrawableRecipientChip[] recips = getText()
+ .getSpans(0, getText().length(), DrawableRecipientChip.class);
+ int invalidChipsRemoved = 0;
+ for (final DrawableRecipientChip recipient : recips) {
+ final RecipientEntry entry = recipient.getEntry();
+ if (entry != null) {
+ if (entry.isValid()) {
+ if (RecipientEntry.isCreatedRecipient(entry.getContactId()) ||
+ ContactRecipientEntryUtils.isSendToDestinationContact(entry)) {
+ // This is a generated/send-to contact chip, try to look it up and
+ // display a chip for the corresponding local contact.
+ final Cursor lookupResult = ContactUtil.lookupDestination(getContext(),
+ entry.getDestination()).performSynchronousQuery();
+ if (lookupResult != null && lookupResult.moveToNext()) {
+ // Found a match, remove the generated entry and replace with
+ // a better local entry.
+ publishProgress(new ChipReplacementTuple(recipient,
+ ContactUtil.createRecipientEntryForPhoneQuery(
+ lookupResult, true)));
+ } else if (PhoneUtils.isValidSmsMmsDestination(
+ entry.getDestination())){
+ // No match was found, but we have a valid destination so let's at
+ // least create an entry that shows an avatar.
+ publishProgress(new ChipReplacementTuple(recipient,
+ ContactRecipientEntryUtils.constructNumberWithAvatarEntry(
+ entry.getDestination())));
+ } else {
+ // Not a valid contact. Remove and show an error.
+ publishProgress(new ChipReplacementTuple(recipient, null));
+ invalidChipsRemoved++;
+ }
+ }
+ } else {
+ publishProgress(new ChipReplacementTuple(recipient, null));
+ invalidChipsRemoved++;
+ }
+ }
+ }
+ return invalidChipsRemoved;
+ }
+
+ @Override
+ protected void onProgressUpdate(final ChipReplacementTuple... values) {
+ for (final ChipReplacementTuple tuple : values) {
+ if (tuple.removedChip != null) {
+ final Editable text = getText();
+ final int chipStart = text.getSpanStart(tuple.removedChip);
+ final int chipEnd = text.getSpanEnd(tuple.removedChip);
+ if (chipStart >= 0 && chipEnd >= 0) {
+ text.delete(chipStart, chipEnd);
+ }
+
+ if (tuple.replacedChipEntry != null) {
+ appendRecipientEntry(tuple.replacedChipEntry);
+ }
+ }
+ }
+ }
+
+ @Override
+ protected void onPostExecute(final Integer invalidChipsRemoved) {
+ mCurrentSanitizeTask = null;
+ if (invalidChipsRemoved > 0) {
+ mChipsChangeListener.onInvalidContactChipsPruned(invalidChipsRemoved);
+ }
+ }
+ }
+
+ /**
+ * We don't use SafeAsyncTask but instead use a single threaded executor to ensure that
+ * all sanitization tasks are serially executed so as not to interfere with each other.
+ */
+ private static final Executor SANITIZE_EXECUTOR = Executors.newSingleThreadExecutor();
+
+ private AsyncContactChipSanitizeTask mCurrentSanitizeTask;
+
+ /**
+ * Whenever the caller wants to start a new conversation with the list of chips we have,
+ * make sure we asynchronously:
+ * 1. Remove invalid chips.
+ * 2. Attempt to resolve unknown contacts to known local contacts.
+ * 3. Convert still unknown chips to chips with generated avatar.
+ *
+ * Note that we don't need to perform this synchronously since we can
+ * resolve any unknown contacts to local contacts when needed.
+ */
+ private void sanitizeContactChips() {
+ if (mCurrentSanitizeTask != null && !mCurrentSanitizeTask.isCancelled()) {
+ mCurrentSanitizeTask.cancel(false);
+ mCurrentSanitizeTask = null;
+ }
+ mCurrentSanitizeTask = new AsyncContactChipSanitizeTask();
+ mCurrentSanitizeTask.executeOnExecutor(SANITIZE_EXECUTOR);
+ }
+
+ /**
+ * Returns a list of ParticipantData from the entered chips in order to create
+ * new conversation.
+ */
+ public ArrayList<ParticipantData> getRecipientParticipantDataForConversationCreation() {
+ final DrawableRecipientChip[] recips = getText()
+ .getSpans(0, getText().length(), DrawableRecipientChip.class);
+ final ArrayList<ParticipantData> contacts =
+ new ArrayList<ParticipantData>(recips.length);
+ for (final DrawableRecipientChip recipient : recips) {
+ final RecipientEntry entry = recipient.getEntry();
+ if (entry != null && entry.isValid() && entry.getDestination() != null &&
+ PhoneUtils.isValidSmsMmsDestination(entry.getDestination())) {
+ contacts.add(ParticipantData.getFromRecipientEntry(recipient.getEntry()));
+ }
+ }
+ sanitizeContactChips();
+ return contacts;
+ }
+
+ /**c
+ * Gets a set of currently selected chips' emails/phone numbers. This will facilitate the
+ * consumer with determining quickly whether a contact is currently selected.
+ */
+ public Set<String> getSelectedDestinations() {
+ Set<String> set = new HashSet<String>();
+ final DrawableRecipientChip[] recips = getText()
+ .getSpans(0, getText().length(), DrawableRecipientChip.class);
+
+ for (final DrawableRecipientChip recipient : recips) {
+ final RecipientEntry entry = recipient.getEntry();
+ if (entry != null && entry.isValid() && entry.getDestination() != null) {
+ set.add(PhoneUtils.getDefault().getCanonicalBySystemLocale(
+ entry.getDestination()));
+ }
+ }
+ return set;
+ }
+
+ @Override
+ public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ mChipsChangeListener.onEntryComplete();
+ }
+ return super.onEditorAction(view, actionId, event);
+ }
+}
diff --git a/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java b/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java
new file mode 100644
index 0000000..d69ba64
--- /dev/null
+++ b/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java
@@ -0,0 +1,96 @@
+/*
+ * 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.messaging.ui.contact;
+
+import android.content.Context;
+import android.net.Uri;
+
+import com.android.ex.chips.PhotoManager;
+import com.android.ex.chips.RecipientEntry;
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.media.AvatarRequestDescriptor;
+import com.android.messaging.datamodel.media.BindableMediaRequest;
+import com.android.messaging.datamodel.media.ImageResource;
+import com.android.messaging.datamodel.media.MediaRequest;
+import com.android.messaging.datamodel.media.MediaResourceManager;
+import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.ThreadUtil;
+
+/**
+ * An implementation of {@link PhotoManager} that hooks up the chips UI's photos with our own
+ * {@link MediaResourceManager} for retrieving and caching contact avatars.
+ */
+public class ContactRecipientPhotoManager implements PhotoManager {
+ private static final String IMAGE_BYTES_REQUEST_STATIC_BINDING_ID = "imagebytes";
+ private final Context mContext;
+ private final int mIconSize;
+ private final ContactListItemView.HostInterface mClivHostInterface;
+
+ public ContactRecipientPhotoManager(final Context context,
+ final ContactListItemView.HostInterface clivHostInterface) {
+ mContext = context;
+ mIconSize = context.getResources().getDimensionPixelSize(
+ R.dimen.compose_message_chip_height) - context.getResources().getDimensionPixelSize(
+ R.dimen.compose_message_chip_padding) * 2;
+ mClivHostInterface = clivHostInterface;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void populatePhotoBytesAsync(final RecipientEntry entry,
+ final PhotoManagerCallback callback) {
+ // Post all media resource request to the main thread.
+ ThreadUtil.getMainThreadHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ final Uri avatarUri = AvatarUriUtil.createAvatarUri(
+ ParticipantData.getFromRecipientEntry(entry));
+ final AvatarRequestDescriptor descriptor =
+ new AvatarRequestDescriptor(avatarUri, mIconSize, mIconSize);
+ final BindableMediaRequest<ImageResource> req = descriptor.buildAsyncMediaRequest(
+ mContext,
+ new MediaResourceLoadListener<ImageResource>() {
+ @Override
+ public void onMediaResourceLoaded(final MediaRequest<ImageResource> request,
+ final ImageResource resource, final boolean isCached) {
+ entry.setPhotoBytes(resource.getBytes());
+ callback.onPhotoBytesAsynchronouslyPopulated();
+ }
+
+ @Override
+ public void onMediaResourceLoadError(final MediaRequest<ImageResource> request,
+ final Exception exception) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Photo bytes loading failed due to " +
+ exception + " request key=" + request.getKey());
+
+ // Fall back to the default avatar image.
+ callback.onPhotoBytesAsyncLoadFailed();
+ }});
+
+ // Statically bind the request since it's not bound to any specific piece of UI.
+ req.bind(IMAGE_BYTES_REQUEST_STATIC_BINDING_ID);
+
+ Factory.get().getMediaResourceManager().requestMediaResourceAsync(req);
+ }
+ });
+ }
+}
diff --git a/src/com/android/messaging/ui/contact/ContactSectionIndexer.java b/src/com/android/messaging/ui/contact/ContactSectionIndexer.java
new file mode 100644
index 0000000..1d5abf3
--- /dev/null
+++ b/src/com/android/messaging/ui/contact/ContactSectionIndexer.java
@@ -0,0 +1,169 @@
+/*
+ * 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.messaging.ui.contact;
+
+import android.database.Cursor;
+import android.os.Bundle;
+import android.provider.ContactsContract.Contacts;
+import android.text.TextUtils;
+import android.widget.SectionIndexer;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContactUtil;
+import com.android.messaging.util.LogUtil;
+
+import java.util.ArrayList;
+
+/**
+ * Indexes contact alphabetical sections so we can report to the fast scrolling list view
+ * where we are in the list when the user scrolls through the contact list, allowing us to show
+ * alphabetical indicators for the fast scroller as well as list section headers.
+ */
+public class ContactSectionIndexer implements SectionIndexer {
+ private String[] mSections;
+ private ArrayList<Integer> mSectionStartingPositions;
+ private static final String BLANK_HEADER_STRING = " ";
+
+ public ContactSectionIndexer(final Cursor contactsCursor) {
+ buildIndexer(contactsCursor);
+ }
+
+ @Override
+ public Object[] getSections() {
+ return mSections;
+ }
+
+ @Override
+ public int getPositionForSection(final int sectionIndex) {
+ if (mSectionStartingPositions.isEmpty()) {
+ return 0;
+ }
+ // Clamp to the bounds of the section position array per Android API doc.
+ return mSectionStartingPositions.get(
+ Math.max(Math.min(sectionIndex, mSectionStartingPositions.size() - 1), 0));
+ }
+
+ @Override
+ public int getSectionForPosition(final int position) {
+ if (mSectionStartingPositions.isEmpty()) {
+ return 0;
+ }
+
+ // Perform a binary search on the starting positions of the sections to the find the
+ // section for the position.
+ int left = 0;
+ int right = mSectionStartingPositions.size() - 1;
+
+ // According to getSectionForPosition()'s doc, we should always clamp the value when the
+ // position is out of bound.
+ if (position <= mSectionStartingPositions.get(left)) {
+ return left;
+ } else if (position >= mSectionStartingPositions.get(right)) {
+ return right;
+ }
+
+ while (left <= right) {
+ final int mid = (left + right) / 2;
+ final int startingPos = mSectionStartingPositions.get(mid);
+ final int nextStartingPos = mSectionStartingPositions.get(mid + 1);
+ if (position >= startingPos && position < nextStartingPos) {
+ return mid;
+ } else if (position < startingPos) {
+ right = mid - 1;
+ } else if (position >= nextStartingPos) {
+ left = mid + 1;
+ }
+ }
+ Assert.fail("Invalid section indexer state: couldn't find section for pos " + position);
+ return -1;
+ }
+
+ private boolean buildIndexerFromCursorExtras(final Cursor cursor) {
+ if (cursor == null) {
+ return false;
+ }
+ final Bundle cursorExtras = cursor.getExtras();
+ if (cursorExtras == null) {
+ return false;
+ }
+ final String[] sections = cursorExtras.getStringArray(
+ Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES);
+ final int[] counts = cursorExtras.getIntArray(Contacts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS);
+ if (sections == null || counts == null) {
+ return false;
+ }
+
+ if (sections.length != counts.length) {
+ return false;
+ }
+
+ this.mSections = sections;
+ mSectionStartingPositions = new ArrayList<Integer>(counts.length);
+ int position = 0;
+ for (int i = 0; i < counts.length; i++) {
+ if (TextUtils.isEmpty(mSections[i])) {
+ mSections[i] = BLANK_HEADER_STRING;
+ } else if (!mSections[i].equals(BLANK_HEADER_STRING)) {
+ mSections[i] = mSections[i].trim();
+ }
+
+ mSectionStartingPositions.add(position);
+ position += counts[i];
+ }
+ return true;
+ }
+
+ private void buildIndexerFromDisplayNames(final Cursor cursor) {
+ // Loop through the contact cursor and get the starting position for each first character.
+ // The result is stored into two arrays, one for the section header (i.e. the first
+ // character), and one for the starting position, which is guaranteed to be sorted in
+ // ascending order.
+ final ArrayList<String> sections = new ArrayList<String>();
+ mSectionStartingPositions = new ArrayList<Integer>();
+ if (cursor != null) {
+ cursor.moveToPosition(-1);
+ int currentPosition = 0;
+ while (cursor.moveToNext()) {
+ // The sort key is typically the contact's display name, so for example, a contact
+ // named "Bob" will go into section "B". The Contacts provider generally uses a
+ // a slightly more sophisticated heuristic, but as a fallback this is good enough.
+ final String sortKey = cursor.getString(ContactUtil.INDEX_SORT_KEY);
+ final String section = TextUtils.isEmpty(sortKey) ? BLANK_HEADER_STRING :
+ sortKey.substring(0, 1).toUpperCase();
+
+ final int lastIndex = sections.size() - 1;
+ final String currentSection = lastIndex >= 0 ? sections.get(lastIndex) : null;
+ if (!TextUtils.equals(currentSection, section)) {
+ sections.add(section);
+ mSectionStartingPositions.add(currentPosition);
+ }
+ currentPosition++;
+ }
+ }
+ mSections = new String[sections.size()];
+ sections.toArray(mSections);
+ }
+
+ private void buildIndexer(final Cursor cursor) {
+ // First check if we get indexer label extras from the contact provider; if not, fall back
+ // to building from display names.
+ if (!buildIndexerFromCursorExtras(cursor)) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "contact provider didn't provide contact label " +
+ "information, fall back to using display name!");
+ buildIndexerFromDisplayNames(cursor);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java b/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java
new file mode 100644
index 0000000..1f3c795
--- /dev/null
+++ b/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.ui.contact;
+
+import android.content.Context;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.CustomHeaderPagerListViewHolder;
+import com.android.messaging.ui.contact.ContactListItemView.HostInterface;
+
+/**
+ * Holds the frequent contacts view for the contact picker's view pager.
+ */
+public class FrequentContactsListViewHolder extends CustomHeaderPagerListViewHolder {
+ public FrequentContactsListViewHolder(final Context context,
+ final HostInterface clivHostInterface) {
+ super(context, new ContactListAdapter(context, null, clivHostInterface,
+ false /* needAlphabetHeader */));
+ }
+
+ @Override
+ protected int getLayoutResId() {
+ return R.layout.frequent_contacts_list_view;
+ }
+
+ @Override
+ protected int getPageTitleResId() {
+ return R.string.contact_picker_frequents_tab_title;
+ }
+
+ @Override
+ protected int getEmptyViewResId() {
+ return R.id.empty_view;
+ }
+
+ @Override
+ protected int getListViewResId() {
+ return R.id.frequent_contacts_list;
+ }
+
+ @Override
+ protected int getEmptyViewTitleResId() {
+ return R.string.contact_list_empty_text;
+ }
+
+ @Override
+ protected int getEmptyViewImageResId() {
+ return R.drawable.ic_oobe_freq_list;
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ComposeMessageView.java b/src/com/android/messaging/ui/conversation/ComposeMessageView.java
new file mode 100644
index 0000000..17f8f74
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ComposeMessageView.java
@@ -0,0 +1,962 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.text.Editable;
+import android.text.Html;
+import android.text.InputFilter;
+import android.text.InputFilter.LengthFilter;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.view.ContextThemeWrapper;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.inputmethod.EditorInfo;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.binding.ImmutableBindingRef;
+import com.android.messaging.datamodel.data.ConversationData;
+import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
+import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftForSendTask;
+import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftTaskCallback;
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.data.PendingAttachmentData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.ui.AttachmentPreview;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.PlainTextEditText;
+import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputSink;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.MediaUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.UiUtils;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * This view contains the UI required to generate and send messages.
+ */
+public class ComposeMessageView extends LinearLayout
+ implements TextView.OnEditorActionListener, DraftMessageDataListener, TextWatcher,
+ ConversationInputSink {
+
+ public interface IComposeMessageViewHost extends
+ DraftMessageData.DraftMessageSubscriptionDataProvider {
+ void sendMessage(MessageData message);
+ void onComposeEditTextFocused();
+ void onAttachmentsCleared();
+ void onAttachmentsChanged(final boolean haveAttachments);
+ void displayPhoto(Uri photoUri, Rect imageBounds, boolean isDraft);
+ void promptForSelfPhoneNumber();
+ boolean isReadyForAction();
+ void warnOfMissingActionConditions(final boolean sending,
+ final Runnable commandToRunAfterActionConditionResolved);
+ void warnOfExceedingMessageLimit(final boolean showAttachmentChooser,
+ boolean tooManyVideos);
+ void notifyOfAttachmentLoadFailed();
+ void showAttachmentChooser();
+ boolean shouldShowSubjectEditor();
+ boolean shouldHideAttachmentsWhenSimSelectorShown();
+ Uri getSelfSendButtonIconUri();
+ int overrideCounterColor();
+ int getAttachmentsClearedFlags();
+ }
+
+ public static final int CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN = 10;
+
+ // There is no draft and there is no need for the SIM selector
+ private static final int SEND_WIDGET_MODE_SELF_AVATAR = 1;
+ // There is no draft but we need to show the SIM selector
+ private static final int SEND_WIDGET_MODE_SIM_SELECTOR = 2;
+ // There is a draft
+ private static final int SEND_WIDGET_MODE_SEND_BUTTON = 3;
+
+ private PlainTextEditText mComposeEditText;
+ private PlainTextEditText mComposeSubjectText;
+ private TextView mCharCounter;
+ private TextView mMmsIndicator;
+ private SimIconView mSelfSendIcon;
+ private ImageButton mSendButton;
+ private View mSubjectView;
+ private ImageButton mDeleteSubjectButton;
+ private AttachmentPreview mAttachmentPreview;
+ private ImageButton mAttachMediaButton;
+
+ private final Binding<DraftMessageData> mBinding;
+ private IComposeMessageViewHost mHost;
+ private final Context mOriginalContext;
+ private int mSendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR;
+
+ // Shared data model object binding from the conversation.
+ private ImmutableBindingRef<ConversationData> mConversationDataModel;
+
+ // Centrally manages all the mutual exclusive UI components accepting user input, i.e.
+ // media picker, IME keyboard and SIM selector.
+ private ConversationInputManager mInputManager;
+
+ private final ConversationDataListener mDataListener = new SimpleConversationDataListener() {
+ @Override
+ public void onConversationMetadataUpdated(ConversationData data) {
+ mConversationDataModel.ensureBound(data);
+ updateVisualsOnDraftChanged();
+ }
+
+ @Override
+ public void onConversationParticipantDataLoaded(ConversationData data) {
+ mConversationDataModel.ensureBound(data);
+ updateVisualsOnDraftChanged();
+ }
+
+ @Override
+ public void onSubscriptionListDataLoaded(ConversationData data) {
+ mConversationDataModel.ensureBound(data);
+ updateOnSelfSubscriptionChange();
+ updateVisualsOnDraftChanged();
+ }
+ };
+
+ public ComposeMessageView(final Context context, final AttributeSet attrs) {
+ super(new ContextThemeWrapper(context, R.style.ColorAccentBlueOverrideStyle), attrs);
+ mOriginalContext = context;
+ mBinding = BindingBase.createBinding(this);
+ }
+
+ /**
+ * Host calls this to bind view to DraftMessageData object
+ */
+ public void bind(final DraftMessageData data, final IComposeMessageViewHost host) {
+ mHost = host;
+ mBinding.bind(data);
+ data.addListener(this);
+ data.setSubscriptionDataProvider(host);
+
+ final int counterColor = mHost.overrideCounterColor();
+ if (counterColor != -1) {
+ mCharCounter.setTextColor(counterColor);
+ }
+ }
+
+ /**
+ * Host calls this to unbind view
+ */
+ public void unbind() {
+ mBinding.unbind();
+ mHost = null;
+ mInputManager.onDetach();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ mComposeEditText = (PlainTextEditText) findViewById(
+ R.id.compose_message_text);
+ mComposeEditText.setOnEditorActionListener(this);
+ mComposeEditText.addTextChangedListener(this);
+ mComposeEditText.setOnFocusChangeListener(new OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(final View v, final boolean hasFocus) {
+ if (v == mComposeEditText && hasFocus) {
+ mHost.onComposeEditTextFocused();
+ }
+ }
+ });
+ mComposeEditText.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View arg0) {
+ if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) {
+ hideSimSelector();
+ }
+ }
+ });
+
+ // onFinishInflate() is called before self is loaded from db. We set the default text
+ // limit here, and apply the real limit later in updateOnSelfSubscriptionChange().
+ mComposeEditText.setFilters(new InputFilter[] {
+ new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID)
+ .getMaxTextLimit()) });
+
+ mSelfSendIcon = (SimIconView) findViewById(R.id.self_send_icon);
+ mSelfSendIcon.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ boolean shown = mInputManager.toggleSimSelector(true /* animate */,
+ getSelfSubscriptionListEntry());
+ hideAttachmentsWhenShowingSims(shown);
+ }
+ });
+ mSelfSendIcon.setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(final View v) {
+ if (mHost.shouldShowSubjectEditor()) {
+ showSubjectEditor();
+ } else {
+ boolean shown = mInputManager.toggleSimSelector(true /* animate */,
+ getSelfSubscriptionListEntry());
+ hideAttachmentsWhenShowingSims(shown);
+ }
+ return true;
+ }
+ });
+
+ mComposeSubjectText = (PlainTextEditText) findViewById(
+ R.id.compose_subject_text);
+ // We need the listener to change the avatar to the send button when the user starts
+ // typing a subject without a message.
+ mComposeSubjectText.addTextChangedListener(this);
+ // onFinishInflate() is called before self is loaded from db. We set the default text
+ // limit here, and apply the real limit later in updateOnSelfSubscriptionChange().
+ mComposeSubjectText.setFilters(new InputFilter[] {
+ new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID)
+ .getMaxSubjectLength())});
+
+ mDeleteSubjectButton = (ImageButton) findViewById(R.id.delete_subject_button);
+ mDeleteSubjectButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View clickView) {
+ hideSubjectEditor();
+ mComposeSubjectText.setText(null);
+ mBinding.getData().setMessageSubject(null);
+ }
+ });
+
+ mSubjectView = findViewById(R.id.subject_view);
+
+ mSendButton = (ImageButton) findViewById(R.id.send_message_button);
+ mSendButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View clickView) {
+ sendMessageInternal(true /* checkMessageSize */);
+ }
+ });
+ mSendButton.setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(final View arg0) {
+ boolean shown = mInputManager.toggleSimSelector(true /* animate */,
+ getSelfSubscriptionListEntry());
+ hideAttachmentsWhenShowingSims(shown);
+ if (mHost.shouldShowSubjectEditor()) {
+ showSubjectEditor();
+ }
+ return true;
+ }
+ });
+ mSendButton.setAccessibilityDelegate(new AccessibilityDelegate() {
+ @Override
+ public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) {
+ super.onPopulateAccessibilityEvent(host, event);
+ // When the send button is long clicked, we want TalkBack to announce the real
+ // action (select SIM or edit subject), as opposed to "long press send button."
+ if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_LONG_CLICKED) {
+ event.getText().clear();
+ event.getText().add(getResources()
+ .getText(shouldShowSimSelector(mConversationDataModel.getData()) ?
+ R.string.send_button_long_click_description_with_sim_selector :
+ R.string.send_button_long_click_description_no_sim_selector));
+ // Make this an announcement so TalkBack will read our custom message.
+ event.setEventType(AccessibilityEvent.TYPE_ANNOUNCEMENT);
+ }
+ }
+ });
+
+ mAttachMediaButton =
+ (ImageButton) findViewById(R.id.attach_media_button);
+ mAttachMediaButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View clickView) {
+ // Showing the media picker is treated as starting to compose the message.
+ mInputManager.showHideMediaPicker(true /* show */, true /* animate */);
+ }
+ });
+
+ mAttachmentPreview = (AttachmentPreview) findViewById(R.id.attachment_draft_view);
+ mAttachmentPreview.setComposeMessageView(this);
+
+ mCharCounter = (TextView) findViewById(R.id.char_counter);
+ mMmsIndicator = (TextView) findViewById(R.id.mms_indicator);
+ }
+
+ private void hideAttachmentsWhenShowingSims(final boolean simPickerVisible) {
+ if (!mHost.shouldHideAttachmentsWhenSimSelectorShown()) {
+ return;
+ }
+ final boolean haveAttachments = mBinding.getData().hasAttachments();
+ if (simPickerVisible && haveAttachments) {
+ mHost.onAttachmentsChanged(false);
+ mAttachmentPreview.hideAttachmentPreview();
+ } else {
+ mHost.onAttachmentsChanged(haveAttachments);
+ mAttachmentPreview.onAttachmentsChanged(mBinding.getData());
+ }
+ }
+
+ public void setInputManager(final ConversationInputManager inputManager) {
+ mInputManager = inputManager;
+ }
+
+ public void setConversationDataModel(final ImmutableBindingRef<ConversationData> refDataModel) {
+ mConversationDataModel = refDataModel;
+ mConversationDataModel.getData().addConversationDataListener(mDataListener);
+ }
+
+ ImmutableBindingRef<DraftMessageData> getDraftDataModel() {
+ return BindingBase.createBindingReference(mBinding);
+ }
+
+ // returns true if it actually shows the subject editor and false if already showing
+ private boolean showSubjectEditor() {
+ // show the subject editor
+ if (mSubjectView.getVisibility() == View.GONE) {
+ mSubjectView.setVisibility(View.VISIBLE);
+ mSubjectView.requestFocus();
+ return true;
+ }
+ return false;
+ }
+
+ private void hideSubjectEditor() {
+ mSubjectView.setVisibility(View.GONE);
+ mComposeEditText.requestFocus();
+ }
+
+ /**
+ * {@inheritDoc} from TextView.OnEditorActionListener
+ */
+ @Override // TextView.OnEditorActionListener.onEditorAction
+ public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_SEND) {
+ sendMessageInternal(true /* checkMessageSize */);
+ return true;
+ }
+ return false;
+ }
+
+ private void sendMessageInternal(final boolean checkMessageSize) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "UI initiated message sending in conversation " +
+ mBinding.getData().getConversationId());
+ if (mBinding.getData().isCheckingDraft()) {
+ // Don't send message if we are currently checking draft for sending.
+ LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: still checking draft");
+ return;
+ }
+ // Check the host for pre-conditions about any action.
+ if (mHost.isReadyForAction()) {
+ mInputManager.showHideSimSelector(false /* show */, true /* animate */);
+ final String messageToSend = mComposeEditText.getText().toString();
+ mBinding.getData().setMessageText(messageToSend);
+ final String subject = mComposeSubjectText.getText().toString();
+ mBinding.getData().setMessageSubject(subject);
+ // Asynchronously check the draft against various requirements before sending.
+ mBinding.getData().checkDraftForAction(checkMessageSize,
+ mHost.getConversationSelfSubId(), new CheckDraftTaskCallback() {
+ @Override
+ public void onDraftChecked(DraftMessageData data, int result) {
+ mBinding.ensureBound(data);
+ switch (result) {
+ case CheckDraftForSendTask.RESULT_PASSED:
+ // Continue sending after check succeeded.
+ final MessageData message = mBinding.getData()
+ .prepareMessageForSending(mBinding);
+ if (message != null && message.hasContent()) {
+ playSentSound();
+ mHost.sendMessage(message);
+ hideSubjectEditor();
+ if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) {
+ AccessibilityUtil.announceForAccessibilityCompat(
+ ComposeMessageView.this, null,
+ R.string.sending_message);
+ }
+ }
+ break;
+
+ case CheckDraftForSendTask.RESULT_HAS_PENDING_ATTACHMENTS:
+ // Cannot send while there's still attachment(s) being loaded.
+ UiUtils.showToastAtBottom(
+ R.string.cant_send_message_while_loading_attachments);
+ break;
+
+ case CheckDraftForSendTask.RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS:
+ mHost.promptForSelfPhoneNumber();
+ break;
+
+ case CheckDraftForSendTask.RESULT_MESSAGE_OVER_LIMIT:
+ Assert.isTrue(checkMessageSize);
+ mHost.warnOfExceedingMessageLimit(
+ true /*sending*/, false /* tooManyVideos */);
+ break;
+
+ case CheckDraftForSendTask.RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED:
+ Assert.isTrue(checkMessageSize);
+ mHost.warnOfExceedingMessageLimit(
+ true /*sending*/, true /* tooManyVideos */);
+ break;
+
+ case CheckDraftForSendTask.RESULT_SIM_NOT_READY:
+ // Cannot send if there is no active subscription
+ UiUtils.showToastAtBottom(
+ R.string.cant_send_message_without_active_subscription);
+ break;
+
+ default:
+ break;
+ }
+ }
+ }, mBinding);
+ } else {
+ mHost.warnOfMissingActionConditions(true /*sending*/,
+ new Runnable() {
+ @Override
+ public void run() {
+ sendMessageInternal(checkMessageSize);
+ }
+
+ });
+ }
+ }
+
+ public static void playSentSound() {
+ // Check if this setting is enabled before playing
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ final Context context = Factory.get().getApplicationContext();
+ final String prefKey = context.getString(R.string.send_sound_pref_key);
+ final boolean defaultValue = context.getResources().getBoolean(
+ R.bool.send_sound_pref_default);
+ if (!prefs.getBoolean(prefKey, defaultValue)) {
+ return;
+ }
+ MediaUtil.get().playSound(context, R.raw.message_sent, null /* completionListener */);
+ }
+
+ /**
+ * {@inheritDoc} from DraftMessageDataListener
+ */
+ @Override // From DraftMessageDataListener
+ public void onDraftChanged(final DraftMessageData data, final int changeFlags) {
+ // As this is called asynchronously when message read check bound before updating text
+ mBinding.ensureBound(data);
+
+ // We have to cache the values of the DraftMessageData because when we set
+ // mComposeEditText, its onTextChanged calls updateVisualsOnDraftChanged,
+ // which immediately reloads the text from the subject and message fields and replaces
+ // what's in the DraftMessageData.
+
+ final String subject = data.getMessageSubject();
+ final String message = data.getMessageText();
+
+ if ((changeFlags & DraftMessageData.MESSAGE_SUBJECT_CHANGED) ==
+ DraftMessageData.MESSAGE_SUBJECT_CHANGED) {
+ mComposeSubjectText.setText(subject);
+
+ // Set the cursor selection to the end since setText resets it to the start
+ mComposeSubjectText.setSelection(mComposeSubjectText.getText().length());
+ }
+
+ if ((changeFlags & DraftMessageData.MESSAGE_TEXT_CHANGED) ==
+ DraftMessageData.MESSAGE_TEXT_CHANGED) {
+ mComposeEditText.setText(message);
+
+ // Set the cursor selection to the end since setText resets it to the start
+ mComposeEditText.setSelection(mComposeEditText.getText().length());
+ }
+
+ if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) ==
+ DraftMessageData.ATTACHMENTS_CHANGED) {
+ final boolean haveAttachments = mAttachmentPreview.onAttachmentsChanged(data);
+ mHost.onAttachmentsChanged(haveAttachments);
+ }
+
+ if ((changeFlags & DraftMessageData.SELF_CHANGED) == DraftMessageData.SELF_CHANGED) {
+ updateOnSelfSubscriptionChange();
+ }
+ updateVisualsOnDraftChanged();
+ }
+
+ @Override // From DraftMessageDataListener
+ public void onDraftAttachmentLimitReached(final DraftMessageData data) {
+ mBinding.ensureBound(data);
+ mHost.warnOfExceedingMessageLimit(false /* sending */, false /* tooManyVideos */);
+ }
+
+ private void updateOnSelfSubscriptionChange() {
+ // Refresh the length filters according to the selected self's MmsConfig.
+ mComposeEditText.setFilters(new InputFilter[] {
+ new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId())
+ .getMaxTextLimit()) });
+ mComposeSubjectText.setFilters(new InputFilter[] {
+ new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId())
+ .getMaxSubjectLength())});
+ }
+
+ @Override
+ public void onMediaItemsSelected(final Collection<MessagePartData> items) {
+ mBinding.getData().addAttachments(items);
+ announceMediaItemState(true /*isSelected*/);
+ }
+
+ @Override
+ public void onMediaItemsUnselected(final MessagePartData item) {
+ mBinding.getData().removeAttachment(item);
+ announceMediaItemState(false /*isSelected*/);
+ }
+
+ @Override
+ public void onPendingAttachmentAdded(final PendingAttachmentData pendingItem) {
+ mBinding.getData().addPendingAttachment(pendingItem, mBinding);
+ resumeComposeMessage();
+ }
+
+ private void announceMediaItemState(final boolean isSelected) {
+ final Resources res = getContext().getResources();
+ final String announcement = isSelected ? res.getString(
+ R.string.mediapicker_gallery_item_selected_content_description) :
+ res.getString(R.string.mediapicker_gallery_item_unselected_content_description);
+ AccessibilityUtil.announceForAccessibilityCompat(
+ this, null, announcement);
+ }
+
+ private void announceAttachmentState() {
+ if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) {
+ int attachmentCount = mBinding.getData().getReadOnlyAttachments().size()
+ + mBinding.getData().getReadOnlyPendingAttachments().size();
+ final String announcement = getContext().getResources().getQuantityString(
+ R.plurals.attachment_changed_accessibility_announcement,
+ attachmentCount, attachmentCount);
+ AccessibilityUtil.announceForAccessibilityCompat(
+ this, null, announcement);
+ }
+ }
+
+ @Override
+ public void resumeComposeMessage() {
+ mComposeEditText.requestFocus();
+ mInputManager.showHideImeKeyboard(true, true);
+ announceAttachmentState();
+ }
+
+ public void clearAttachments() {
+ mBinding.getData().clearAttachments(mHost.getAttachmentsClearedFlags());
+ mHost.onAttachmentsCleared();
+ }
+
+ public void requestDraftMessage(boolean clearLocalDraft) {
+ mBinding.getData().loadFromStorage(mBinding, null, clearLocalDraft);
+ }
+
+ public void setDraftMessage(final MessageData message) {
+ mBinding.getData().loadFromStorage(mBinding, message, false);
+ }
+
+ public void writeDraftMessage() {
+ final String messageText = mComposeEditText.getText().toString();
+ mBinding.getData().setMessageText(messageText);
+
+ final String subject = mComposeSubjectText.getText().toString();
+ mBinding.getData().setMessageSubject(subject);
+
+ mBinding.getData().saveToStorage(mBinding);
+ }
+
+ private void updateConversationSelfId(final String selfId, final boolean notify) {
+ mBinding.getData().setSelfId(selfId, notify);
+ }
+
+ private Uri getSelfSendButtonIconUri() {
+ final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri();
+ if (overridenSelfUri != null) {
+ return overridenSelfUri;
+ }
+ final SubscriptionListEntry subscriptionListEntry = getSelfSubscriptionListEntry();
+
+ if (subscriptionListEntry != null) {
+ return subscriptionListEntry.selectedIconUri;
+ }
+
+ // Fall back to default self-avatar in the base case.
+ final ParticipantData self = mConversationDataModel.getData().getDefaultSelfParticipant();
+ return self == null ? null : AvatarUriUtil.createAvatarUri(self);
+ }
+
+ private SubscriptionListEntry getSelfSubscriptionListEntry() {
+ return mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant(
+ mBinding.getData().getSelfId(), false /* excludeDefault */);
+ }
+
+ private boolean isDataLoadedForMessageSend() {
+ // Check data loading prerequisites for sending a message.
+ return mConversationDataModel != null && mConversationDataModel.isBound() &&
+ mConversationDataModel.getData().getParticipantsLoaded();
+ }
+
+ private void updateVisualsOnDraftChanged() {
+ final String messageText = mComposeEditText.getText().toString();
+ final DraftMessageData draftMessageData = mBinding.getData();
+ draftMessageData.setMessageText(messageText);
+
+ final String subject = mComposeSubjectText.getText().toString();
+ draftMessageData.setMessageSubject(subject);
+ if (!TextUtils.isEmpty(subject)) {
+ mSubjectView.setVisibility(View.VISIBLE);
+ }
+
+ final boolean hasMessageText = (TextUtils.getTrimmedLength(messageText) > 0);
+ final boolean hasSubject = (TextUtils.getTrimmedLength(subject) > 0);
+ final boolean hasWorkingDraft = hasMessageText || hasSubject ||
+ mBinding.getData().hasAttachments();
+
+ // Update the SMS text counter.
+ final int messageCount = draftMessageData.getNumMessagesToBeSent();
+ final int codePointsRemaining = draftMessageData.getCodePointsRemainingInCurrentMessage();
+ // Show the counter only if:
+ // - We are not in MMS mode
+ // - We are going to send more than one message OR we are getting close
+ boolean showCounter = false;
+ if (!draftMessageData.getIsMms() && (messageCount > 1 ||
+ codePointsRemaining <= CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN)) {
+ showCounter = true;
+ }
+
+ if (showCounter) {
+ // Update the remaining characters and number of messages required.
+ final String counterText = messageCount > 1 ? codePointsRemaining + " / " +
+ messageCount : String.valueOf(codePointsRemaining);
+ mCharCounter.setText(counterText);
+ mCharCounter.setVisibility(View.VISIBLE);
+ } else {
+ mCharCounter.setVisibility(View.INVISIBLE);
+ }
+
+ // Update the send message button. Self icon uri might be null if self participant data
+ // and/or conversation metadata hasn't been loaded by the host.
+ final Uri selfSendButtonUri = getSelfSendButtonIconUri();
+ int sendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR;
+ if (selfSendButtonUri != null) {
+ if (hasWorkingDraft && isDataLoadedForMessageSend()) {
+ UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null);
+ if (isOverriddenAvatarAGroup()) {
+ // If the host has overriden the avatar to show a group avatar where the
+ // send button sits, we have to hide the group avatar because it can be larger
+ // than the send button and pieces of the avatar will stick out from behind
+ // the send button.
+ UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, GONE, null);
+ }
+ mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE);
+ sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON;
+ } else {
+ mSelfSendIcon.setImageResourceUri(selfSendButtonUri);
+ if (isOverriddenAvatarAGroup()) {
+ UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, VISIBLE, null);
+ }
+ UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, null);
+ mMmsIndicator.setVisibility(INVISIBLE);
+ if (shouldShowSimSelector(mConversationDataModel.getData())) {
+ sendWidgetMode = SEND_WIDGET_MODE_SIM_SELECTOR;
+ }
+ }
+ } else {
+ mSelfSendIcon.setImageResourceUri(null);
+ }
+
+ if (mSendWidgetMode != sendWidgetMode || sendWidgetMode == SEND_WIDGET_MODE_SIM_SELECTOR) {
+ setSendButtonAccessibility(sendWidgetMode);
+ mSendWidgetMode = sendWidgetMode;
+ }
+
+ // Update the text hint on the message box depending on the attachment type.
+ final List<MessagePartData> attachments = draftMessageData.getReadOnlyAttachments();
+ final int attachmentCount = attachments.size();
+ if (attachmentCount == 0) {
+ final SubscriptionListEntry subscriptionListEntry =
+ mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant(
+ mBinding.getData().getSelfId(), false /* excludeDefault */);
+ if (subscriptionListEntry == null) {
+ mComposeEditText.setHint(R.string.compose_message_view_hint_text);
+ } else {
+ mComposeEditText.setHint(Html.fromHtml(getResources().getString(
+ R.string.compose_message_view_hint_text_multi_sim,
+ subscriptionListEntry.displayName)));
+ }
+ } else {
+ int type = -1;
+ for (final MessagePartData attachment : attachments) {
+ int newType;
+ if (attachment.isImage()) {
+ newType = ContentType.TYPE_IMAGE;
+ } else if (attachment.isAudio()) {
+ newType = ContentType.TYPE_AUDIO;
+ } else if (attachment.isVideo()) {
+ newType = ContentType.TYPE_VIDEO;
+ } else if (attachment.isVCard()) {
+ newType = ContentType.TYPE_VCARD;
+ } else {
+ newType = ContentType.TYPE_OTHER;
+ }
+
+ if (type == -1) {
+ type = newType;
+ } else if (type != newType || type == ContentType.TYPE_OTHER) {
+ type = ContentType.TYPE_OTHER;
+ break;
+ }
+ }
+
+ switch (type) {
+ case ContentType.TYPE_IMAGE:
+ mComposeEditText.setHint(getResources().getQuantityString(
+ R.plurals.compose_message_view_hint_text_photo, attachmentCount));
+ break;
+
+ case ContentType.TYPE_AUDIO:
+ mComposeEditText.setHint(getResources().getQuantityString(
+ R.plurals.compose_message_view_hint_text_audio, attachmentCount));
+ break;
+
+ case ContentType.TYPE_VIDEO:
+ mComposeEditText.setHint(getResources().getQuantityString(
+ R.plurals.compose_message_view_hint_text_video, attachmentCount));
+ break;
+
+ case ContentType.TYPE_VCARD:
+ mComposeEditText.setHint(getResources().getQuantityString(
+ R.plurals.compose_message_view_hint_text_vcard, attachmentCount));
+ break;
+
+ case ContentType.TYPE_OTHER:
+ mComposeEditText.setHint(getResources().getQuantityString(
+ R.plurals.compose_message_view_hint_text_attachments, attachmentCount));
+ break;
+
+ default:
+ Assert.fail("Unsupported attachment type!");
+ break;
+ }
+ }
+ }
+
+ private void setSendButtonAccessibility(final int sendWidgetMode) {
+ switch (sendWidgetMode) {
+ case SEND_WIDGET_MODE_SELF_AVATAR:
+ // No send button and no SIM selector; the self send button is no longer
+ // important for accessibility.
+ mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ mSelfSendIcon.setContentDescription(null);
+ mSendButton.setVisibility(View.GONE);
+ setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SELF_AVATAR);
+ break;
+
+ case SEND_WIDGET_MODE_SIM_SELECTOR:
+ mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
+ mSelfSendIcon.setContentDescription(getSimContentDescription());
+ setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SIM_SELECTOR);
+ break;
+
+ case SEND_WIDGET_MODE_SEND_BUTTON:
+ mMmsIndicator.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ mMmsIndicator.setContentDescription(null);
+ setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SEND_BUTTON);
+ break;
+ }
+ }
+
+ private String getSimContentDescription() {
+ final SubscriptionListEntry sub = getSelfSubscriptionListEntry();
+ if (sub != null) {
+ return getResources().getString(
+ R.string.sim_selector_button_content_description_with_selection,
+ sub.displayName);
+ } else {
+ return getResources().getString(
+ R.string.sim_selector_button_content_description);
+ }
+ }
+
+ // Set accessibility traversal order of the components in the send widget.
+ private void setSendWidgetAccessibilityTraversalOrder(final int mode) {
+ if (OsUtil.isAtLeastL_MR1()) {
+ mAttachMediaButton.setAccessibilityTraversalBefore(R.id.compose_message_text);
+ switch (mode) {
+ case SEND_WIDGET_MODE_SIM_SELECTOR:
+ mComposeEditText.setAccessibilityTraversalBefore(R.id.self_send_icon);
+ break;
+ case SEND_WIDGET_MODE_SEND_BUTTON:
+ mComposeEditText.setAccessibilityTraversalBefore(R.id.send_message_button);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ @Override
+ public void afterTextChanged(final Editable editable) {
+ }
+
+ @Override
+ public void beforeTextChanged(final CharSequence s, final int start, final int count,
+ final int after) {
+ if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) {
+ hideSimSelector();
+ }
+ }
+
+ private void hideSimSelector() {
+ if (mInputManager.showHideSimSelector(false /* show */, true /* animate */)) {
+ // Now that the sim selector has been hidden, reshow the attachments if they
+ // have been hidden.
+ hideAttachmentsWhenShowingSims(false /*simPickerVisible*/);
+ }
+ }
+
+ @Override
+ public void onTextChanged(final CharSequence s, final int start, final int before,
+ final int count) {
+ final BugleActionBarActivity activity = (mOriginalContext instanceof BugleActionBarActivity)
+ ? (BugleActionBarActivity) mOriginalContext : null;
+ if (activity != null && activity.getIsDestroyed()) {
+ LogUtil.v(LogUtil.BUGLE_TAG, "got onTextChanged after onDestroy");
+
+ // if we get onTextChanged after the activity is destroyed then, ah, wtf
+ // b/18176615
+ // This appears to have occurred as the result of orientation change.
+ return;
+ }
+
+ mBinding.ensureBound();
+ updateVisualsOnDraftChanged();
+ }
+
+ @Override
+ public PlainTextEditText getComposeEditText() {
+ return mComposeEditText;
+ }
+
+ public void displayPhoto(final Uri photoUri, final Rect imageBounds) {
+ mHost.displayPhoto(photoUri, imageBounds, true /* isDraft */);
+ }
+
+ public void updateConversationSelfIdOnExternalChange(final String selfId) {
+ updateConversationSelfId(selfId, true /* notify */);
+ }
+
+ /**
+ * The selfId of the conversation. As soon as the DraftMessageData successfully loads (i.e.
+ * getSelfId() is non-null), the selfId in DraftMessageData is treated as the sole source
+ * of truth for conversation self id since it reflects any pending self id change the user
+ * makes in the UI.
+ */
+ public String getConversationSelfId() {
+ return mBinding.getData().getSelfId();
+ }
+
+ public void selectSim(SubscriptionListEntry subscriptionData) {
+ final String oldSelfId = getConversationSelfId();
+ final String newSelfId = subscriptionData.selfParticipantId;
+ Assert.notNull(newSelfId);
+ // Don't attempt to change self if self hasn't been loaded, or if self hasn't changed.
+ if (oldSelfId == null || TextUtils.equals(oldSelfId, newSelfId)) {
+ return;
+ }
+ updateConversationSelfId(newSelfId, true /* notify */);
+ }
+
+ public void hideAllComposeInputs(final boolean animate) {
+ mInputManager.hideAllInputs(animate);
+ }
+
+ public void saveInputState(final Bundle outState) {
+ mInputManager.onSaveInputState(outState);
+ }
+
+ public void resetMediaPickerState() {
+ mInputManager.resetMediaPickerState();
+ }
+
+ public boolean onBackPressed() {
+ return mInputManager.onBackPressed();
+ }
+
+ public boolean onNavigationUpPressed() {
+ return mInputManager.onNavigationUpPressed();
+ }
+
+ public boolean updateActionBar(final ActionBar actionBar) {
+ return mInputManager != null ? mInputManager.updateActionBar(actionBar) : false;
+ }
+
+ public static boolean shouldShowSimSelector(final ConversationData convData) {
+ return OsUtil.isAtLeastL_MR1() &&
+ convData.getSelfParticipantsCountExcludingDefault(true /* activeOnly */) > 1;
+ }
+
+ public void sendMessageIgnoreMessageSizeLimit() {
+ sendMessageInternal(false /* checkMessageSize */);
+ }
+
+ public void onAttachmentPreviewLongClicked() {
+ mHost.showAttachmentChooser();
+ }
+
+ @Override
+ public void onDraftAttachmentLoadFailed() {
+ mHost.notifyOfAttachmentLoadFailed();
+ }
+
+ private boolean isOverriddenAvatarAGroup() {
+ final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri();
+ if (overridenSelfUri == null) {
+ return false;
+ }
+ return AvatarUriUtil.TYPE_GROUP_URI.equals(AvatarUriUtil.getAvatarType(overridenSelfUri));
+ }
+
+ @Override
+ public void setAccessibility(boolean enabled) {
+ if (enabled) {
+ mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
+ mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
+ mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
+ setSendButtonAccessibility(mSendWidgetMode);
+ } else {
+ mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationActivity.java b/src/com/android/messaging/ui/conversation/ConversationActivity.java
new file mode 100644
index 0000000..66310ea
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationActivity.java
@@ -0,0 +1,379 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.text.TextUtils;
+import android.view.MenuItem;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.contact.ContactPickerFragment;
+import com.android.messaging.ui.contact.ContactPickerFragment.ContactPickerFragmentHost;
+import com.android.messaging.ui.conversation.ConversationActivityUiState.ConversationActivityUiStateHost;
+import com.android.messaging.ui.conversation.ConversationFragment.ConversationFragmentHost;
+import com.android.messaging.ui.conversationlist.ConversationListActivity;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.UiUtils;
+
+public class ConversationActivity extends BugleActionBarActivity
+ implements ContactPickerFragmentHost, ConversationFragmentHost,
+ ConversationActivityUiStateHost {
+ public static final int FINISH_RESULT_CODE = 1;
+ private static final String SAVED_INSTANCE_STATE_UI_STATE_KEY = "uistate";
+
+ private ConversationActivityUiState mUiState;
+
+ // Fragment transactions cannot be performed after onSaveInstanceState() has been called since
+ // it will cause state loss. We don't want to call commitAllowingStateLoss() since it's
+ // dangerous. Therefore, we note when instance state is saved and avoid performing UI state
+ // updates concerning fragments past that point.
+ private boolean mInstanceStateSaved;
+
+ // Tracks whether onPause is called.
+ private boolean mIsPaused;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.conversation_activity);
+
+ final Intent intent = getIntent();
+
+ // Do our best to restore UI state from saved instance state.
+ if (savedInstanceState != null) {
+ mUiState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY);
+ } else {
+ if (intent.
+ getBooleanExtra(UIIntents.UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST, false)) {
+ // See the comment in BugleWidgetService.getViewMoreConversationsView() why this
+ // is unfortunately necessary. The Bugle desktop widget can display a list of
+ // conversations. When there are more conversations that can be displayed in
+ // the widget, the last item is a "More conversations" item. The way widgets
+ // are built, the list items can only go to a single fill-in intent which points
+ // to this ConversationActivity. When the user taps on "More conversations", we
+ // really want to go to the ConversationList. This code makes that possible.
+ finish();
+ final Intent convListIntent = new Intent(this, ConversationListActivity.class);
+ convListIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ startActivity(convListIntent);
+ return;
+ }
+ }
+
+ // If saved instance state doesn't offer a clue, get the info from the intent.
+ if (mUiState == null) {
+ final String conversationId = intent.getStringExtra(
+ UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
+ mUiState = new ConversationActivityUiState(conversationId);
+ }
+ mUiState.setHost(this);
+ mInstanceStateSaved = false;
+
+ // Don't animate UI state change for initial setup.
+ updateUiState(false /* animate */);
+
+ // See if we're getting called from a widget to directly display an image or video
+ final String extraToDisplay =
+ intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_URI);
+ if (!TextUtils.isEmpty(extraToDisplay)) {
+ final String contentType =
+ intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE);
+ final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(
+ findViewById(R.id.conversation_and_compose_container));
+ if (ContentType.isImageType(contentType)) {
+ final Uri imagesUri = MessagingContentProvider.buildConversationImagesUri(
+ mUiState.getConversationId());
+ UIIntents.get().launchFullScreenPhotoViewer(
+ this, Uri.parse(extraToDisplay), bounds, imagesUri);
+ } else if (ContentType.isVideoType(contentType)) {
+ UIIntents.get().launchFullScreenVideoViewer(this, Uri.parse(extraToDisplay));
+ }
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(final Bundle outState) {
+ super.onSaveInstanceState(outState);
+ // After onSaveInstanceState() is called, future changes to mUiState won't update the UI
+ // anymore, because fragment transactions are not allowed past this point.
+ // For an activity recreation due to orientation change, the saved instance state keeps
+ // using the in-memory copy of the UI state instead of writing it to parcel as an
+ // optimization, so the UI state values may still change in response to, for example,
+ // focus change from the framework, making mUiState and actual UI inconsistent.
+ // Therefore, save an exact "snapshot" (clone) of the UI state object to make sure the
+ // restored UI state ALWAYS matches the actual restored UI components.
+ outState.putParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY, mUiState.clone());
+ mInstanceStateSaved = true;
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ // we need to reset the mInstanceStateSaved flag since we may have just been restored from
+ // a previous onStop() instead of an onDestroy().
+ mInstanceStateSaved = false;
+ mIsPaused = false;
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ mIsPaused = true;
+ }
+
+ @Override
+ public void onWindowFocusChanged(final boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ final ConversationFragment conversationFragment = getConversationFragment();
+ // When the screen is turned on, the last used activity gets resumed, but it gets
+ // window focus only after the lock screen is unlocked.
+ if (hasFocus && conversationFragment != null) {
+ conversationFragment.setConversationFocus();
+ }
+ }
+
+ @Override
+ public void onDisplayHeightChanged(final int heightSpecification) {
+ super.onDisplayHeightChanged(heightSpecification);
+ invalidateActionBar();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (mUiState != null) {
+ mUiState.setHost(null);
+ }
+ }
+
+ @Override
+ public void updateActionBar(final ActionBar actionBar) {
+ super.updateActionBar(actionBar);
+ final ConversationFragment conversation = getConversationFragment();
+ final ContactPickerFragment contactPicker = getContactPicker();
+ if (contactPicker != null && mUiState.shouldShowContactPickerFragment()) {
+ contactPicker.updateActionBar(actionBar);
+ } else if (conversation != null && mUiState.shouldShowConversationFragment()) {
+ conversation.updateActionBar(actionBar);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem menuItem) {
+ if (super.onOptionsItemSelected(menuItem)) {
+ return true;
+ }
+ if (menuItem.getItemId() == android.R.id.home) {
+ onNavigationUpPressed();
+ return true;
+ }
+ return false;
+ }
+
+ public void onNavigationUpPressed() {
+ // Let the conversation fragment handle the navigation up press.
+ final ConversationFragment conversationFragment = getConversationFragment();
+ if (conversationFragment != null && conversationFragment.onNavigationUpPressed()) {
+ return;
+ }
+ onFinishCurrentConversation();
+ }
+
+ @Override
+ public void onBackPressed() {
+ // If action mode is active dismiss it
+ if (getActionMode() != null) {
+ dismissActionMode();
+ return;
+ }
+
+ // Let the conversation fragment handle the back press.
+ final ConversationFragment conversationFragment = getConversationFragment();
+ if (conversationFragment != null && conversationFragment.onBackPressed()) {
+ return;
+ }
+ super.onBackPressed();
+ }
+
+ private ContactPickerFragment getContactPicker() {
+ return (ContactPickerFragment) getFragmentManager().findFragmentByTag(
+ ContactPickerFragment.FRAGMENT_TAG);
+ }
+
+ private ConversationFragment getConversationFragment() {
+ return (ConversationFragment) getFragmentManager().findFragmentByTag(
+ ConversationFragment.FRAGMENT_TAG);
+ }
+
+ @Override // From ContactPickerFragmentHost
+ public void onGetOrCreateNewConversation(final String conversationId) {
+ Assert.isTrue(conversationId != null);
+ mUiState.onGetOrCreateConversation(conversationId);
+ }
+
+ @Override // From ContactPickerFragmentHost
+ public void onBackButtonPressed() {
+ onBackPressed();
+ }
+
+ @Override // From ContactPickerFragmentHost
+ public void onInitiateAddMoreParticipants() {
+ mUiState.onAddMoreParticipants();
+ }
+
+
+ @Override
+ public void onParticipantCountChanged(final boolean canAddMoreParticipants) {
+ mUiState.onParticipantCountUpdated(canAddMoreParticipants);
+ }
+
+ @Override // From ConversationFragmentHost
+ public void onStartComposeMessage() {
+ mUiState.onStartMessageCompose();
+ }
+
+ @Override // From ConversationFragmentHost
+ public void onConversationMetadataUpdated() {
+ invalidateActionBar();
+ }
+
+ @Override // From ConversationFragmentHost
+ public void onConversationMessagesUpdated(final int numberOfMessages) {
+ }
+
+ @Override // From ConversationFragmentHost
+ public void onConversationParticipantDataLoaded(final int numberOfParticipants) {
+ }
+
+ @Override // From ConversationFragmentHost
+ public boolean isActiveAndFocused() {
+ return !mIsPaused && hasWindowFocus();
+ }
+
+ @Override // From ConversationActivityUiStateListener
+ public void onConversationContactPickerUiStateChanged(final int oldState, final int newState,
+ final boolean animate) {
+ Assert.isTrue(oldState != newState);
+ updateUiState(animate);
+ }
+
+ private void updateUiState(final boolean animate) {
+ if (mInstanceStateSaved || mIsPaused) {
+ return;
+ }
+ Assert.notNull(mUiState);
+ final Intent intent = getIntent();
+ final String conversationId = mUiState.getConversationId();
+
+ final FragmentManager fragmentManager = getFragmentManager();
+ final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
+
+ final boolean needConversationFragment = mUiState.shouldShowConversationFragment();
+ final boolean needContactPickerFragment = mUiState.shouldShowContactPickerFragment();
+ ConversationFragment conversationFragment = getConversationFragment();
+
+ // Set up the conversation fragment.
+ if (needConversationFragment) {
+ Assert.notNull(conversationId);
+ if (conversationFragment == null) {
+ conversationFragment = new ConversationFragment();
+ fragmentTransaction.add(R.id.conversation_fragment_container,
+ conversationFragment, ConversationFragment.FRAGMENT_TAG);
+ }
+ final MessageData draftData = intent.getParcelableExtra(
+ UIIntents.UI_INTENT_EXTRA_DRAFT_DATA);
+ if (!needContactPickerFragment) {
+ // Once the user has committed the audience,remove the draft data from the
+ // intent to prevent reuse
+ intent.removeExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA);
+ }
+ conversationFragment.setHost(this);
+ conversationFragment.setConversationInfo(this, conversationId, draftData);
+ } else if (conversationFragment != null) {
+ // Don't save draft to DB when removing conversation fragment and switching to
+ // contact picking mode. The draft is intended for the new group.
+ conversationFragment.suppressWriteDraft();
+ fragmentTransaction.remove(conversationFragment);
+ }
+
+ // Set up the contact picker fragment.
+ ContactPickerFragment contactPickerFragment = getContactPicker();
+ if (needContactPickerFragment) {
+ if (contactPickerFragment == null) {
+ contactPickerFragment = new ContactPickerFragment();
+ fragmentTransaction.add(R.id.contact_picker_fragment_container,
+ contactPickerFragment, ContactPickerFragment.FRAGMENT_TAG);
+ }
+ contactPickerFragment.setHost(this);
+ contactPickerFragment.setContactPickingMode(mUiState.getDesiredContactPickingMode(),
+ animate);
+ } else if (contactPickerFragment != null) {
+ fragmentTransaction.remove(contactPickerFragment);
+ }
+
+ fragmentTransaction.commit();
+ invalidateActionBar();
+ }
+
+ @Override
+ public void onFinishCurrentConversation() {
+ // Simply finish the current activity. The current design is to leave any empty
+ // conversations as is.
+ if (OsUtil.isAtLeastL()) {
+ finishAfterTransition();
+ } else {
+ finish();
+ }
+ }
+
+ @Override
+ public boolean shouldResumeComposeMessage() {
+ return mUiState.shouldResumeComposeMessage();
+ }
+
+ @Override
+ protected void onActivityResult(final int requestCode, final int resultCode,
+ final Intent data) {
+ if (requestCode == ConversationFragment.REQUEST_CHOOSE_ATTACHMENTS &&
+ resultCode == RESULT_OK) {
+ final ConversationFragment conversationFragment = getConversationFragment();
+ if (conversationFragment != null) {
+ conversationFragment.onAttachmentChoosen();
+ } else {
+ LogUtil.e(LogUtil.BUGLE_TAG, "ConversationFragment is missing after launching " +
+ "AttachmentChooserActivity!");
+ }
+ } else if (resultCode == FINISH_RESULT_CODE) {
+ finish();
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java b/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java
new file mode 100644
index 0000000..1469c93
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java
@@ -0,0 +1,306 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.ui.contact.ContactPickerFragment;
+import com.android.messaging.util.Assert;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Keeps track of the different UI states that the ConversationActivity may be in. This acts as
+ * a state machine which, based on different actions (e.g. onAddMoreParticipants), notifies the
+ * ConversationActivity about any state UI change so it can update the visuals. This class
+ * implements Parcelable and it's persisted across activity tear down and relaunch.
+ */
+public class ConversationActivityUiState implements Parcelable, Cloneable {
+ interface ConversationActivityUiStateHost {
+ void onConversationContactPickerUiStateChanged(int oldState, int newState, boolean animate);
+ }
+
+ /*------ Overall UI states (conversation & contact picker) ------*/
+
+ /** Only a full screen conversation is showing. */
+ public static final int STATE_CONVERSATION_ONLY = 1;
+ /** Only a full screen contact picker is showing asking user to pick the initial contact. */
+ public static final int STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT = 2;
+ /**
+ * Only a full screen contact picker is showing asking user to pick more participants. This
+ * happens after the user picked the initial contact, and then decide to go back and add more.
+ */
+ public static final int STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS = 3;
+ /**
+ * Only a full screen contact picker is showing asking user to pick more participants. However
+ * user has reached max number of conversation participants and can add no more.
+ */
+ public static final int STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS = 4;
+ /**
+ * A hybrid mode where the conversation view + contact chips view are showing. This happens
+ * right after the user picked the initial contact for which a 1-1 conversation is fetched or
+ * created.
+ */
+ public static final int STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW = 5;
+
+ // The overall UI state of the ConversationActivity.
+ private int mConversationContactUiState;
+
+ // The currently displayed conversation (if any).
+ private String mConversationId;
+
+ // Indicates whether we should put focus in the compose message view when the
+ // ConversationFragment is attached. This is a transient state that's not persisted as
+ // part of the parcelable.
+ private boolean mPendingResumeComposeMessage = false;
+
+ // The owner ConversationActivity. This is not parceled since the instance always change upon
+ // object reuse.
+ private ConversationActivityUiStateHost mHost;
+
+ // Indicates the owning ConverastionActivity is in the process of updating its UI presentation
+ // to be in sync with the UI states. Outside of the UI updates, the UI states here should
+ // ALWAYS be consistent with the actual states of the activity.
+ private int mUiUpdateCount;
+
+ /**
+ * Create a new instance with an initial conversation id.
+ */
+ ConversationActivityUiState(final String conversationId) {
+ // The conversation activity may be initialized with only one of two states:
+ // Conversation-only (when there's a conversation id) or picking initial contact
+ // (when no conversation id is given).
+ mConversationId = conversationId;
+ mConversationContactUiState = conversationId == null ?
+ STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT : STATE_CONVERSATION_ONLY;
+ }
+
+ public void setHost(final ConversationActivityUiStateHost host) {
+ mHost = host;
+ }
+
+ public boolean shouldShowConversationFragment() {
+ return mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW ||
+ mConversationContactUiState == STATE_CONVERSATION_ONLY;
+ }
+
+ public boolean shouldShowContactPickerFragment() {
+ return mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS ||
+ mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS ||
+ mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT ||
+ mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW;
+ }
+
+ /**
+ * Returns whether there's a pending request to resume message compose (i.e. set focus to
+ * the compose message view and show the soft keyboard). If so, this request will be served
+ * when the conversation fragment get created and resumed. This happens when the user commits
+ * participant selection for a group conversation and goes back to the conversation fragment.
+ * Since conversation fragment creation happens asynchronously, we issue and track this
+ * pending request for it to be eventually fulfilled.
+ */
+ public boolean shouldResumeComposeMessage() {
+ if (mPendingResumeComposeMessage) {
+ // This is a one-shot operation that just keeps track of the pending resume compose
+ // state. This is also a non-critical operation so we don't care about failure case.
+ mPendingResumeComposeMessage = false;
+ return true;
+ }
+ return false;
+ }
+
+ public int getDesiredContactPickingMode() {
+ switch (mConversationContactUiState) {
+ case STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS:
+ return ContactPickerFragment.MODE_PICK_MORE_CONTACTS;
+ case STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS:
+ return ContactPickerFragment.MODE_PICK_MAX_PARTICIPANTS;
+ case STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT:
+ return ContactPickerFragment.MODE_PICK_INITIAL_CONTACT;
+ case STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW:
+ return ContactPickerFragment.MODE_CHIPS_ONLY;
+ default:
+ Assert.fail("Invalid contact picking mode for ConversationActivity!");
+ return ContactPickerFragment.MODE_UNDEFINED;
+ }
+ }
+
+ public String getConversationId() {
+ return mConversationId;
+ }
+
+ /**
+ * Called whenever the contact picker fragment successfully fetched or created a conversation.
+ */
+ public void onGetOrCreateConversation(final String conversationId) {
+ int newState = STATE_CONVERSATION_ONLY;
+ if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) {
+ newState = STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW;
+ } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS ||
+ mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS) {
+ newState = STATE_CONVERSATION_ONLY;
+ } else {
+ // New conversation should only be created when we are in one of the contact picking
+ // modes.
+ Assert.fail("Invalid conversation activity state: can't create conversation!");
+ }
+ mConversationId = conversationId;
+ performUiStateUpdate(newState, true);
+ }
+
+ /**
+ * Called when the user started composing message. If we are in the hybrid chips state, we
+ * should commit to enter the conversation only state.
+ */
+ public void onStartMessageCompose() {
+ // This cannot happen when we are in one of the full-screen contact picking states.
+ Assert.isTrue(mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT &&
+ mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS &&
+ mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS);
+ if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) {
+ performUiStateUpdate(STATE_CONVERSATION_ONLY, true);
+ }
+ }
+
+ /**
+ * Called when the user initiated an action to add more participants in the hybrid state,
+ * namely clicking on the "add more participants" button or entered a new contact chip via
+ * auto-complete.
+ */
+ public void onAddMoreParticipants() {
+ if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) {
+ mPendingResumeComposeMessage = true;
+ performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, true);
+ } else {
+ // This is only possible in the hybrid state.
+ Assert.fail("Invalid conversation activity state: can't add more participants!");
+ }
+ }
+
+ /**
+ * Called each time the number of participants is updated to check against the limit and
+ * update the ui state accordingly.
+ */
+ public void onParticipantCountUpdated(final boolean canAddMoreParticipants) {
+ if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS
+ && !canAddMoreParticipants) {
+ performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS, false);
+ } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS
+ && canAddMoreParticipants) {
+ performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, false);
+ }
+ }
+
+ private void performUiStateUpdate(final int conversationContactState, final boolean animate) {
+ // This starts one UI update cycle, during which we allow the conversation activity's
+ // UI presentation to be temporarily out of sync with the states here.
+ beginUiUpdate();
+
+ if (conversationContactState != mConversationContactUiState) {
+ final int oldState = mConversationContactUiState;
+ mConversationContactUiState = conversationContactState;
+ notifyOnOverallUiStateChanged(oldState, mConversationContactUiState, animate);
+ }
+ endUiUpdate();
+ }
+
+ private void notifyOnOverallUiStateChanged(
+ final int oldState, final int newState, final boolean animate) {
+ // Always verify state validity whenever we have a state change.
+ assertValidState();
+ Assert.isTrue(isUiUpdateInProgress());
+
+ // Only do this if we are still attached to the host. mHost can be null if the host
+ // activity is already destroyed, but due to timing the contained UI components may still
+ // receive events such as focus change and trigger a callback to the Ui state. We'd like
+ // to guard against those cases.
+ if (mHost != null) {
+ mHost.onConversationContactPickerUiStateChanged(oldState, newState, animate);
+ }
+ }
+
+ private void assertValidState() {
+ // Conversation id may be null IF AND ONLY IF the user is picking the initial contact to
+ // start a conversation.
+ Assert.isTrue((mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) ==
+ (mConversationId == null));
+ }
+
+ private void beginUiUpdate() {
+ mUiUpdateCount++;
+ }
+
+ private void endUiUpdate() {
+ if (--mUiUpdateCount < 0) {
+ Assert.fail("Unbalanced Ui updates!");
+ }
+ }
+
+ private boolean isUiUpdateInProgress() {
+ return mUiUpdateCount > 0;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeInt(mConversationContactUiState);
+ dest.writeString(mConversationId);
+ }
+
+ private ConversationActivityUiState(final Parcel in) {
+ mConversationContactUiState = in.readInt();
+ mConversationId = in.readString();
+
+ // Always verify state validity whenever we initialize states.
+ assertValidState();
+ }
+
+ public static final Parcelable.Creator<ConversationActivityUiState> CREATOR
+ = new Parcelable.Creator<ConversationActivityUiState>() {
+ @Override
+ public ConversationActivityUiState createFromParcel(final Parcel in) {
+ return new ConversationActivityUiState(in);
+ }
+
+ @Override
+ public ConversationActivityUiState[] newArray(final int size) {
+ return new ConversationActivityUiState[size];
+ }
+ };
+
+ @Override
+ protected ConversationActivityUiState clone() {
+ try {
+ return (ConversationActivityUiState) super.clone();
+ } catch (CloneNotSupportedException e) {
+ Assert.fail("ConversationActivityUiState: failed to clone(). Is there a mutable " +
+ "reference?");
+ }
+ return null;
+ }
+
+ /**
+ * allows for overridding the internal UI state. Should never be called except by test code.
+ */
+ @VisibleForTesting
+ void testSetUiState(final int uiState) {
+ mConversationContactUiState = uiState;
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationFastScroller.java b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java
new file mode 100644
index 0000000..b15f05a
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java
@@ -0,0 +1,489 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Rect;
+import android.graphics.drawable.StateListDrawable;
+import android.os.Handler;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.support.v7.widget.RecyclerView.AdapterDataObserver;
+import android.support.v7.widget.RecyclerView.ViewHolder;
+import android.util.StateSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.MeasureSpec;
+import android.view.View.OnLayoutChangeListener;
+import android.view.ViewGroupOverlay;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.ui.ConversationDrawables;
+import com.android.messaging.util.Dates;
+import com.android.messaging.util.OsUtil;
+
+/**
+ * Adds a "fast-scroll" bar to the conversation RecyclerView that shows the current position within
+ * the conversation and allows quickly moving to another position by dragging the scrollbar thumb
+ * up or down. As the thumb is dragged, we show a floating bubble alongside it that shows the
+ * date/time of the first visible message at the current position.
+ */
+public class ConversationFastScroller extends RecyclerView.OnScrollListener implements
+ OnLayoutChangeListener, RecyclerView.OnItemTouchListener {
+
+ /**
+ * Creates a {@link ConversationFastScroller} instance, attached to the provided
+ * {@link RecyclerView}.
+ *
+ * @param rv the conversation RecyclerView
+ * @param position where the scrollbar should appear (either {@code POSITION_RIGHT_SIDE} or
+ * {@code POSITION_LEFT_SIDE})
+ * @return a new ConversationFastScroller, or {@code null} if fast-scrolling is not supported
+ * (the feature requires Jellybean MR2 or newer)
+ */
+ public static ConversationFastScroller addTo(RecyclerView rv, int position) {
+ if (OsUtil.isAtLeastJB_MR2()) {
+ return new ConversationFastScroller(rv, position);
+ }
+ return null;
+ }
+
+ public static final int POSITION_RIGHT_SIDE = 0;
+ public static final int POSITION_LEFT_SIDE = 1;
+
+ private static final int MIN_PAGES_TO_ENABLE = 7;
+ private static final int SHOW_ANIMATION_DURATION_MS = 150;
+ private static final int HIDE_ANIMATION_DURATION_MS = 300;
+ private static final int HIDE_DELAY_MS = 1500;
+
+ private final Context mContext;
+ private final RecyclerView mRv;
+ private final ViewGroupOverlay mOverlay;
+ private final ImageView mTrackImageView;
+ private final ImageView mThumbImageView;
+ private final TextView mPreviewTextView;
+
+ private final int mTrackWidth;
+ private final int mThumbHeight;
+ private final int mPreviewHeight;
+ private final int mPreviewMinWidth;
+ private final int mPreviewMarginTop;
+ private final int mPreviewMarginLeftRight;
+ private final int mTouchSlop;
+
+ private final Rect mContainer = new Rect();
+ private final Handler mHandler = new Handler();
+
+ // Whether to render the scrollbar on the right side (otherwise it'll be on the left).
+ private final boolean mPosRight;
+
+ // Whether the scrollbar is currently visible (it may still be animating).
+ private boolean mVisible = false;
+
+ // Whether we are waiting to hide the scrollbar (i.e. scrolling has stopped).
+ private boolean mPendingHide = false;
+
+ // Whether the user is currently dragging the thumb up or down.
+ private boolean mDragging = false;
+
+ // Animations responsible for hiding the scrollbar & preview. May be null.
+ private AnimatorSet mHideAnimation;
+ private ObjectAnimator mHidePreviewAnimation;
+
+ private final Runnable mHideTrackRunnable = new Runnable() {
+ @Override
+ public void run() {
+ hide(true /* animate */);
+ mPendingHide = false;
+ }
+ };
+
+ private ConversationFastScroller(RecyclerView rv, int position) {
+ mContext = rv.getContext();
+ mRv = rv;
+ mRv.addOnLayoutChangeListener(this);
+ mRv.addOnScrollListener(this);
+ mRv.addOnItemTouchListener(this);
+ mRv.getAdapter().registerAdapterDataObserver(new AdapterDataObserver() {
+ @Override
+ public void onChanged() {
+ updateScrollPos();
+ }
+ });
+ mPosRight = (position == POSITION_RIGHT_SIDE);
+
+ // Cache the dimensions we'll need during layout
+ final Resources res = mContext.getResources();
+ mTrackWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_width);
+ mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height);
+ mPreviewHeight = res.getDimensionPixelSize(R.dimen.fastscroll_preview_height);
+ mPreviewMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_preview_min_width);
+ mPreviewMarginTop = res.getDimensionPixelOffset(R.dimen.fastscroll_preview_margin_top);
+ mPreviewMarginLeftRight = res.getDimensionPixelOffset(
+ R.dimen.fastscroll_preview_margin_left_right);
+ mTouchSlop = res.getDimensionPixelOffset(R.dimen.fastscroll_touch_slop);
+
+ final LayoutInflater inflator = LayoutInflater.from(mContext);
+ mTrackImageView = (ImageView) inflator.inflate(R.layout.fastscroll_track, null);
+ mThumbImageView = (ImageView) inflator.inflate(R.layout.fastscroll_thumb, null);
+ mPreviewTextView = (TextView) inflator.inflate(R.layout.fastscroll_preview, null);
+
+ refreshConversationThemeColor();
+
+ // Add the fast scroll views to the overlay, so they are rendered above the list
+ mOverlay = rv.getOverlay();
+ mOverlay.add(mTrackImageView);
+ mOverlay.add(mThumbImageView);
+ mOverlay.add(mPreviewTextView);
+
+ hide(false /* animate */);
+ mPreviewTextView.setAlpha(0f);
+ }
+
+ public void refreshConversationThemeColor() {
+ mPreviewTextView.setBackground(
+ ConversationDrawables.get().getFastScrollPreviewDrawable(mPosRight));
+ if (OsUtil.isAtLeastL()) {
+ final StateListDrawable drawable = new StateListDrawable();
+ drawable.addState(new int[]{ android.R.attr.state_pressed },
+ ConversationDrawables.get().getFastScrollThumbDrawable(true /* pressed */));
+ drawable.addState(StateSet.WILD_CARD,
+ ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */));
+ mThumbImageView.setImageDrawable(drawable);
+ } else {
+ // Android pre-L doesn't seem to handle a StateListDrawable containing a tinted
+ // drawable (it's rendered in the filter base color, which is red), so fall back to
+ // just the regular (non-pressed) drawable.
+ mThumbImageView.setImageDrawable(
+ ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */));
+ }
+ }
+
+ @Override
+ public void onScrollStateChanged(final RecyclerView view, final int newState) {
+ if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
+ // Only show the scrollbar once the user starts scrolling
+ if (!mVisible && isEnabled()) {
+ show();
+ }
+ cancelAnyPendingHide();
+ } else if (newState == RecyclerView.SCROLL_STATE_IDLE && !mDragging) {
+ // Hide the scrollbar again after scrolling stops
+ hideAfterDelay();
+ }
+ }
+
+ private boolean isEnabled() {
+ final int range = mRv.computeVerticalScrollRange();
+ final int extent = mRv.computeVerticalScrollExtent();
+
+ if (range == 0 || extent == 0) {
+ return false; // Conversation isn't long enough to scroll
+ }
+ // Only enable scrollbars for conversations long enough that they would require several
+ // flings to scroll through.
+ final float pages = (float) range / extent;
+ return (pages > MIN_PAGES_TO_ENABLE);
+ }
+
+ private void show() {
+ if (mHideAnimation != null && mHideAnimation.isRunning()) {
+ mHideAnimation.cancel();
+ }
+ // Slide the scrollbar in from the side
+ ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, 0);
+ ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, 0);
+ AnimatorSet animation = new AnimatorSet();
+ animation.playTogether(trackSlide, thumbSlide);
+ animation.setDuration(SHOW_ANIMATION_DURATION_MS);
+ animation.start();
+
+ mVisible = true;
+ updateScrollPos();
+ }
+
+ private void hideAfterDelay() {
+ cancelAnyPendingHide();
+ mHandler.postDelayed(mHideTrackRunnable, HIDE_DELAY_MS);
+ mPendingHide = true;
+ }
+
+ private void cancelAnyPendingHide() {
+ if (mPendingHide) {
+ mHandler.removeCallbacks(mHideTrackRunnable);
+ }
+ }
+
+ private void hide(boolean animate) {
+ final int hiddenTranslationX = mPosRight ? mTrackWidth : -mTrackWidth;
+ if (animate) {
+ // Slide the scrollbar off to the side
+ ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X,
+ hiddenTranslationX);
+ ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X,
+ hiddenTranslationX);
+ mHideAnimation = new AnimatorSet();
+ mHideAnimation.playTogether(trackSlide, thumbSlide);
+ mHideAnimation.setDuration(HIDE_ANIMATION_DURATION_MS);
+ mHideAnimation.start();
+ } else {
+ mTrackImageView.setTranslationX(hiddenTranslationX);
+ mThumbImageView.setTranslationX(hiddenTranslationX);
+ }
+
+ mVisible = false;
+ }
+
+ private void showPreview() {
+ if (mHidePreviewAnimation != null && mHidePreviewAnimation.isRunning()) {
+ mHidePreviewAnimation.cancel();
+ }
+ mPreviewTextView.setAlpha(1f);
+ }
+
+ private void hidePreview() {
+ mHidePreviewAnimation = ObjectAnimator.ofFloat(mPreviewTextView, View.ALPHA, 0f);
+ mHidePreviewAnimation.setDuration(HIDE_ANIMATION_DURATION_MS);
+ mHidePreviewAnimation.start();
+ }
+
+ @Override
+ public void onScrolled(final RecyclerView view, final int dx, final int dy) {
+ updateScrollPos();
+ }
+
+ private void updateScrollPos() {
+ if (!mVisible) {
+ return;
+ }
+ final int verticalScrollLength = mContainer.height() - mThumbHeight;
+ final int verticalScrollStart = mContainer.top + mThumbHeight / 2;
+
+ final float scrollRatio = computeScrollRatio();
+ final int thumbCenterY = verticalScrollStart + (int)(verticalScrollLength * scrollRatio);
+ layoutThumb(thumbCenterY);
+
+ if (mDragging) {
+ updatePreviewText();
+ layoutPreview(thumbCenterY);
+ }
+ }
+
+ /**
+ * Returns the current position in the conversation, as a value between 0 and 1, inclusive.
+ * The top of the conversation is 0, the bottom is 1, the exact middle is 0.5, and so on.
+ */
+ private float computeScrollRatio() {
+ final int range = mRv.computeVerticalScrollRange();
+ final int extent = mRv.computeVerticalScrollExtent();
+ int offset = mRv.computeVerticalScrollOffset();
+
+ if (range == 0 || extent == 0) {
+ // If the conversation doesn't scroll, we're at the bottom.
+ return 1.0f;
+ }
+ final int scrollRange = range - extent;
+ offset = Math.min(offset, scrollRange);
+ return offset / (float) scrollRange;
+ }
+
+ private void updatePreviewText() {
+ final LinearLayoutManager lm = (LinearLayoutManager) mRv.getLayoutManager();
+ final int pos = lm.findFirstVisibleItemPosition();
+ if (pos == RecyclerView.NO_POSITION) {
+ return;
+ }
+ final ViewHolder vh = mRv.findViewHolderForAdapterPosition(pos);
+ if (vh == null) {
+ // This can happen if the messages update while we're dragging the thumb.
+ return;
+ }
+ final ConversationMessageView messageView = (ConversationMessageView) vh.itemView;
+ final ConversationMessageData messageData = messageView.getData();
+ final long timestamp = messageData.getReceivedTimeStamp();
+ final CharSequence timestampText = Dates.getFastScrollPreviewTimeString(timestamp);
+ mPreviewTextView.setText(timestampText);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
+ if (!mVisible) {
+ return false;
+ }
+ // If the user presses down on the scroll thumb, we'll start intercepting events from the
+ // RecyclerView so we can handle the move events while they're dragging it up/down.
+ final int action = e.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ if (isInsideThumb(e.getX(), e.getY())) {
+ startDrag();
+ return true;
+ }
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (mDragging) {
+ return true;
+ }
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ if (mDragging) {
+ cancelDrag();
+ }
+ return false;
+ }
+ return false;
+ }
+
+ private boolean isInsideThumb(float x, float y) {
+ final int hitTargetLeft = mThumbImageView.getLeft() - mTouchSlop;
+ final int hitTargetRight = mThumbImageView.getRight() + mTouchSlop;
+
+ if (x < hitTargetLeft || x > hitTargetRight) {
+ return false;
+ }
+ if (y < mThumbImageView.getTop() || y > mThumbImageView.getBottom()) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public void onTouchEvent(RecyclerView rv, MotionEvent e) {
+ if (!mDragging) {
+ return;
+ }
+ final int action = e.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_MOVE:
+ handleDragMove(e.getY());
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ cancelDrag();
+ break;
+ }
+ }
+
+ @Override
+ public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
+ }
+
+ private void startDrag() {
+ mDragging = true;
+ mThumbImageView.setPressed(true);
+ updateScrollPos();
+ showPreview();
+ cancelAnyPendingHide();
+ }
+
+ private void handleDragMove(float y) {
+ final int verticalScrollLength = mContainer.height() - mThumbHeight;
+ final int verticalScrollStart = mContainer.top + (mThumbHeight / 2);
+
+ // Convert the desired position from px to a scroll position in the conversation.
+ float dragScrollRatio = (y - verticalScrollStart) / verticalScrollLength;
+ dragScrollRatio = Math.max(dragScrollRatio, 0.0f);
+ dragScrollRatio = Math.min(dragScrollRatio, 1.0f);
+
+ // Scroll the RecyclerView to a new position.
+ final int itemCount = mRv.getAdapter().getItemCount();
+ final int itemPos = (int)((itemCount - 1) * dragScrollRatio);
+ mRv.scrollToPosition(itemPos);
+ }
+
+ private void cancelDrag() {
+ mDragging = false;
+ mThumbImageView.setPressed(false);
+ hidePreview();
+ hideAfterDelay();
+ }
+
+ @Override
+ public void onLayoutChange(View v, int left, int top, int right, int bottom,
+ int oldLeft, int oldTop, int oldRight, int oldBottom) {
+ if (!mVisible) {
+ hide(false /* animate */);
+ }
+ // The container is the size of the RecyclerView that's visible on screen. We have to
+ // exclude the top padding, because it's usually hidden behind the conversation action bar.
+ mContainer.set(left, top + mRv.getPaddingTop(), right, bottom);
+ layoutTrack();
+ updateScrollPos();
+ }
+
+ private void layoutTrack() {
+ int trackHeight = Math.max(0, mContainer.height());
+ int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY);
+ int heightMeasureSpec = MeasureSpec.makeMeasureSpec(trackHeight, MeasureSpec.EXACTLY);
+ mTrackImageView.measure(widthMeasureSpec, heightMeasureSpec);
+
+ int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left;
+ int top = mContainer.top;
+ int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth);
+ int bottom = mContainer.bottom;
+ mTrackImageView.layout(left, top, right, bottom);
+ }
+
+ private void layoutThumb(int centerY) {
+ int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY);
+ int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mThumbHeight, MeasureSpec.EXACTLY);
+ mThumbImageView.measure(widthMeasureSpec, heightMeasureSpec);
+
+ int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left;
+ int top = centerY - (mThumbImageView.getHeight() / 2);
+ int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth);
+ int bottom = top + mThumbHeight;
+ mThumbImageView.layout(left, top, right, bottom);
+ }
+
+ private void layoutPreview(int centerY) {
+ int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mContainer.width(), MeasureSpec.AT_MOST);
+ int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewHeight, MeasureSpec.EXACTLY);
+ mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec);
+
+ // Ensure that the preview bubble is at least as wide as it is tall
+ if (mPreviewTextView.getMeasuredWidth() < mPreviewMinWidth) {
+ widthMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewMinWidth, MeasureSpec.EXACTLY);
+ mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec);
+ }
+ final int previewMinY = mContainer.top + mPreviewMarginTop;
+
+ final int left, right;
+ if (mPosRight) {
+ right = mContainer.right - mTrackWidth - mPreviewMarginLeftRight;
+ left = right - mPreviewTextView.getMeasuredWidth();
+ } else {
+ left = mContainer.left + mTrackWidth + mPreviewMarginLeftRight;
+ right = left + mPreviewTextView.getMeasuredWidth();
+ }
+
+ int bottom = centerY;
+ int top = bottom - mPreviewTextView.getMeasuredHeight();
+ if (top < previewMinY) {
+ top = previewMinY;
+ bottom = top + mPreviewTextView.getMeasuredHeight();
+ }
+ mPreviewTextView.layout(left, top, right, bottom);
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationFragment.java b/src/com/android/messaging/ui/conversation/ConversationFragment.java
new file mode 100644
index 0000000..a6a191a
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationFragment.java
@@ -0,0 +1,1662 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.Manifest;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.DownloadManager;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.BroadcastReceiver;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnCancelListener;
+import android.content.DialogInterface.OnClickListener;
+import android.content.DialogInterface.OnDismissListener;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.res.Configuration;
+import android.database.Cursor;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.drawable.ColorDrawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.Parcelable;
+import android.support.v4.content.LocalBroadcastManager;
+import android.support.v4.text.BidiFormatter;
+import android.support.v4.text.TextDirectionHeuristicsCompat;
+import android.support.v7.app.ActionBar;
+import android.support.v7.widget.DefaultItemAnimator;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.support.v7.widget.RecyclerView.ViewHolder;
+import android.text.TextUtils;
+import android.view.ActionMode;
+import android.view.Display;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.action.InsertNewMessageAction;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.binding.ImmutableBindingRef;
+import com.android.messaging.datamodel.data.ConversationData;
+import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.datamodel.data.ConversationParticipantsData;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.ui.AttachmentPreview;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.ConversationDrawables;
+import com.android.messaging.ui.SnackBar;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.animation.PopupTransitionAnimation;
+import com.android.messaging.ui.contact.AddContactsConfirmationDialog;
+import com.android.messaging.ui.conversation.ComposeMessageView.IComposeMessageViewHost;
+import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputHost;
+import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost;
+import com.android.messaging.ui.mediapicker.MediaPicker;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.ChangeDefaultSmsAppHelper;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.ImeUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.SafeAsyncTask;
+import com.android.messaging.util.TextUtil;
+import com.android.messaging.util.UiUtils;
+import com.android.messaging.util.UriUtil;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shows a list of messages/parts comprising a conversation.
+ */
+public class ConversationFragment extends Fragment implements ConversationDataListener,
+ IComposeMessageViewHost, ConversationMessageViewHost, ConversationInputHost,
+ DraftMessageDataListener {
+
+ public interface ConversationFragmentHost extends ImeUtil.ImeStateHost {
+ void onStartComposeMessage();
+ void onConversationMetadataUpdated();
+ boolean shouldResumeComposeMessage();
+ void onFinishCurrentConversation();
+ void invalidateActionBar();
+ ActionMode startActionMode(ActionMode.Callback callback);
+ void dismissActionMode();
+ ActionMode getActionMode();
+ void onConversationMessagesUpdated(int numberOfMessages);
+ void onConversationParticipantDataLoaded(int numberOfParticipants);
+ boolean isActiveAndFocused();
+ }
+
+ public static final String FRAGMENT_TAG = "conversation";
+
+ static final int REQUEST_CHOOSE_ATTACHMENTS = 2;
+ private static final int JUMP_SCROLL_THRESHOLD = 15;
+ // We animate the message from draft to message list, if we the message doesn't show up in the
+ // list within this time limit, then we just do a fade in animation instead
+ public static final int MESSAGE_ANIMATION_MAX_WAIT = 500;
+
+ private ComposeMessageView mComposeMessageView;
+ private RecyclerView mRecyclerView;
+ private ConversationMessageAdapter mAdapter;
+ private ConversationFastScroller mFastScroller;
+
+ private View mConversationComposeDivider;
+ private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper;
+
+ private String mConversationId;
+ // If the fragment receives a draft as part of the invocation this is set
+ private MessageData mIncomingDraft;
+
+ // This binding keeps track of our associated ConversationData instance
+ // A binding should have the lifetime of the owning component,
+ // don't recreate, unbind and bind if you need new data
+ @VisibleForTesting
+ final Binding<ConversationData> mBinding = BindingBase.createBinding(this);
+
+ // Saved Instance State Data - only for temporal data which is nice to maintain but not
+ // critical for correctness.
+ private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = "conversationViewState";
+ private Parcelable mListState;
+
+ private ConversationFragmentHost mHost;
+
+ protected List<Integer> mFilterResults;
+
+ // The minimum scrolling distance between RecyclerView's scroll change event beyong which
+ // a fling motion is considered fast, in which case we'll delay load image attachments for
+ // perf optimization.
+ private int mFastFlingThreshold;
+
+ // ConversationMessageView that is currently selected
+ private ConversationMessageView mSelectedMessage;
+
+ // Attachment data for the attachment within the selected message that was long pressed
+ private MessagePartData mSelectedAttachment;
+
+ // Normally, as soon as draft message is loaded, we trust the UI state held in
+ // ComposeMessageView to be the only source of truth (incl. the conversation self id). However,
+ // there can be external events that forces the UI state to change, such as SIM state changes
+ // or SIM auto-switching on receiving a message. This receiver is used to receive such
+ // local broadcast messages and reflect the change in the UI.
+ private final BroadcastReceiver mConversationSelfIdChangeReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ final String conversationId =
+ intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
+ final String selfId =
+ intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_SELF_ID);
+ Assert.notNull(conversationId);
+ Assert.notNull(selfId);
+ if (TextUtils.equals(mBinding.getData().getConversationId(), conversationId)) {
+ mComposeMessageView.updateConversationSelfIdOnExternalChange(selfId);
+ }
+ }
+ };
+
+ // Flag to prevent writing draft to DB on pause
+ private boolean mSuppressWriteDraft;
+
+ // Indicates whether local draft should be cleared due to external draft changes that must
+ // be reloaded from db
+ private boolean mClearLocalDraft;
+ private ImmutableBindingRef<DraftMessageData> mDraftMessageDataModel;
+
+ private boolean isScrolledToBottom() {
+ if (mRecyclerView.getChildCount() == 0) {
+ return true;
+ }
+ final View lastView = mRecyclerView.getChildAt(mRecyclerView.getChildCount() - 1);
+ int lastVisibleItem = ((LinearLayoutManager) mRecyclerView
+ .getLayoutManager()).findLastVisibleItemPosition();
+ if (lastVisibleItem < 0) {
+ // If the recyclerView height is 0, then the last visible item position is -1
+ // Try to compute the position of the last item, even though it's not visible
+ final long id = mRecyclerView.getChildItemId(lastView);
+ final RecyclerView.ViewHolder holder = mRecyclerView.findViewHolderForItemId(id);
+ if (holder != null) {
+ lastVisibleItem = holder.getAdapterPosition();
+ }
+ }
+ final int totalItemCount = mRecyclerView.getAdapter().getItemCount();
+ final boolean isAtBottom = (lastVisibleItem + 1 == totalItemCount);
+ return isAtBottom && lastView.getBottom() <= mRecyclerView.getHeight();
+ }
+
+ private void scrollToBottom(final boolean smoothScroll) {
+ if (mAdapter.getItemCount() > 0) {
+ scrollToPosition(mAdapter.getItemCount() - 1, smoothScroll);
+ }
+ }
+
+ private int mScrollToDismissThreshold;
+ private final RecyclerView.OnScrollListener mListScrollListener =
+ new RecyclerView.OnScrollListener() {
+ // Keeps track of cumulative scroll delta during a scroll event, which we may use to
+ // hide the media picker & co.
+ private int mCumulativeScrollDelta;
+ private boolean mScrollToDismissHandled;
+ private boolean mWasScrolledToBottom = true;
+ private int mScrollState = RecyclerView.SCROLL_STATE_IDLE;
+
+ @Override
+ public void onScrollStateChanged(final RecyclerView view, final int newState) {
+ if (newState == RecyclerView.SCROLL_STATE_IDLE) {
+ // Reset scroll states.
+ mCumulativeScrollDelta = 0;
+ mScrollToDismissHandled = false;
+ } else if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
+ mRecyclerView.getItemAnimator().endAnimations();
+ }
+ mScrollState = newState;
+ }
+
+ @Override
+ public void onScrolled(final RecyclerView view, final int dx, final int dy) {
+ if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING &&
+ !mScrollToDismissHandled) {
+ mCumulativeScrollDelta += dy;
+ // Dismiss the keyboard only when the user scroll up (into the past).
+ if (mCumulativeScrollDelta < -mScrollToDismissThreshold) {
+ mComposeMessageView.hideAllComposeInputs(false /* animate */);
+ mScrollToDismissHandled = true;
+ }
+ }
+ if (mWasScrolledToBottom != isScrolledToBottom()) {
+ mConversationComposeDivider.animate().alpha(isScrolledToBottom() ? 0 : 1);
+ mWasScrolledToBottom = isScrolledToBottom();
+ }
+ }
+ };
+
+ private final ActionMode.Callback mMessageActionModeCallback = new ActionMode.Callback() {
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ if (mSelectedMessage == null) {
+ return false;
+ }
+ final ConversationMessageData data = mSelectedMessage.getData();
+ final MenuInflater menuInflater = getActivity().getMenuInflater();
+ menuInflater.inflate(R.menu.conversation_fragment_select_menu, menu);
+ menu.findItem(R.id.action_download).setVisible(data.getShowDownloadMessage());
+ menu.findItem(R.id.action_send).setVisible(data.getShowResendMessage());
+
+ // ShareActionProvider does not work with ActionMode. So we use a normal menu item.
+ menu.findItem(R.id.share_message_menu).setVisible(data.getCanForwardMessage());
+ menu.findItem(R.id.save_attachment).setVisible(mSelectedAttachment != null);
+ menu.findItem(R.id.forward_message_menu).setVisible(data.getCanForwardMessage());
+
+ // TODO: We may want to support copying attachments in the future, but it's
+ // unclear which attachment to pick when we make this context menu at the message level
+ // instead of the part level
+ menu.findItem(R.id.copy_text).setVisible(data.getCanCopyMessageToClipboard());
+
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ return true;
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ final ConversationMessageData data = mSelectedMessage.getData();
+ final String messageId = data.getMessageId();
+ switch (menuItem.getItemId()) {
+ case R.id.save_attachment:
+ if (OsUtil.hasStoragePermission()) {
+ final SaveAttachmentTask saveAttachmentTask = new SaveAttachmentTask(
+ getActivity());
+ for (final MessagePartData part : data.getAttachments()) {
+ saveAttachmentTask.addAttachmentToSave(part.getContentUri(),
+ part.getContentType());
+ }
+ if (saveAttachmentTask.getAttachmentCount() > 0) {
+ saveAttachmentTask.executeOnThreadPool();
+ mHost.dismissActionMode();
+ }
+ } else {
+ getActivity().requestPermissions(
+ new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 0);
+ }
+ return true;
+ case R.id.action_delete_message:
+ if (mSelectedMessage != null) {
+ deleteMessage(messageId);
+ }
+ return true;
+ case R.id.action_download:
+ if (mSelectedMessage != null) {
+ retryDownload(messageId);
+ mHost.dismissActionMode();
+ }
+ return true;
+ case R.id.action_send:
+ if (mSelectedMessage != null) {
+ retrySend(messageId);
+ mHost.dismissActionMode();
+ }
+ return true;
+ case R.id.copy_text:
+ Assert.isTrue(data.hasText());
+ final ClipboardManager clipboard = (ClipboardManager) getActivity()
+ .getSystemService(Context.CLIPBOARD_SERVICE);
+ clipboard.setPrimaryClip(
+ ClipData.newPlainText(null /* label */, data.getText()));
+ mHost.dismissActionMode();
+ return true;
+ case R.id.details_menu:
+ MessageDetailsDialog.show(
+ getActivity(), data, mBinding.getData().getParticipants(),
+ mBinding.getData().getSelfParticipantById(data.getSelfParticipantId()));
+ mHost.dismissActionMode();
+ return true;
+ case R.id.share_message_menu:
+ shareMessage(data);
+ mHost.dismissActionMode();
+ return true;
+ case R.id.forward_message_menu:
+ // TODO: Currently we are forwarding one part at a time, instead of
+ // the entire message. Change this to forwarding the entire message when we
+ // use message-based cursor in conversation.
+ final MessageData message = mBinding.getData().createForwardedMessage(data);
+ UIIntents.get().launchForwardMessageActivity(getActivity(), message);
+ mHost.dismissActionMode();
+ return true;
+ }
+ return false;
+ }
+
+ private void shareMessage(final ConversationMessageData data) {
+ // Figure out what to share.
+ MessagePartData attachmentToShare = mSelectedAttachment;
+ // If the user long-pressed on the background, we will share the text (if any)
+ // or the first attachment.
+ if (mSelectedAttachment == null
+ && TextUtil.isAllWhitespace(data.getText())) {
+ final List<MessagePartData> attachments = data.getAttachments();
+ if (attachments.size() > 0) {
+ attachmentToShare = attachments.get(0);
+ }
+ }
+
+ final Intent shareIntent = new Intent();
+ shareIntent.setAction(Intent.ACTION_SEND);
+ if (attachmentToShare == null) {
+ shareIntent.putExtra(Intent.EXTRA_TEXT, data.getText());
+ shareIntent.setType("text/plain");
+ } else {
+ shareIntent.putExtra(
+ Intent.EXTRA_STREAM, attachmentToShare.getContentUri());
+ shareIntent.setType(attachmentToShare.getContentType());
+ }
+ final CharSequence title = getResources().getText(R.string.action_share);
+ startActivity(Intent.createChooser(shareIntent, title));
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ selectMessage(null);
+ }
+ };
+
+ /**
+ * {@inheritDoc} from Fragment
+ */
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mFastFlingThreshold = getResources().getDimensionPixelOffset(
+ R.dimen.conversation_fast_fling_threshold);
+ mAdapter = new ConversationMessageAdapter(getActivity(), null, this,
+ null,
+ // Sets the item click listener on the Recycler item views.
+ new View.OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ final ConversationMessageView messageView = (ConversationMessageView) v;
+ handleMessageClick(messageView);
+ }
+ },
+ new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(final View view) {
+ selectMessage((ConversationMessageView) view);
+ return true;
+ }
+ }
+ );
+ }
+
+ /**
+ * setConversationInfo() may be called before or after onCreate(). When a user initiate a
+ * conversation from compose, the ConversationActivity creates this fragment and calls
+ * setConversationInfo(), so it happens before onCreate(). However, when the activity is
+ * restored from saved instance state, the ConversationFragment is created automatically by
+ * the fragment, before ConversationActivity has a chance to call setConversationInfo(). Since
+ * the ability to start loading data depends on both methods being called, we need to start
+ * loading when onActivityCreated() is called, which is guaranteed to happen after both.
+ */
+ @Override
+ public void onActivityCreated(final Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ // Delay showing the message list until the participant list is loaded.
+ mRecyclerView.setVisibility(View.INVISIBLE);
+ mBinding.ensureBound();
+ mBinding.getData().init(getLoaderManager(), mBinding);
+
+ // Build the input manager with all its required dependencies and pass it along to the
+ // compose message view.
+ final ConversationInputManager inputManager = new ConversationInputManager(
+ getActivity(), this, mComposeMessageView, mHost, getFragmentManagerToUse(),
+ mBinding, mComposeMessageView.getDraftDataModel(), savedInstanceState);
+ mComposeMessageView.setInputManager(inputManager);
+ mComposeMessageView.setConversationDataModel(BindingBase.createBindingReference(mBinding));
+ mHost.invalidateActionBar();
+
+ mDraftMessageDataModel =
+ BindingBase.createBindingReference(mComposeMessageView.getDraftDataModel());
+ mDraftMessageDataModel.getData().addListener(this);
+ }
+
+ public void onAttachmentChoosen() {
+ // Attachment has been choosen in the AttachmentChooserActivity, so clear local draft
+ // and reload draft on resume.
+ mClearLocalDraft = true;
+ }
+
+ private int getScrollToMessagePosition() {
+ final Activity activity = getActivity();
+ if (activity == null) {
+ return -1;
+ }
+
+ final Intent intent = activity.getIntent();
+ if (intent == null) {
+ return -1;
+ }
+
+ return intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1);
+ }
+
+ private void clearScrollToMessagePosition() {
+ final Activity activity = getActivity();
+ if (activity == null) {
+ return;
+ }
+
+ final Intent intent = activity.getIntent();
+ if (intent == null) {
+ return;
+ }
+ intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1);
+ }
+
+ private final Handler mHandler = new Handler();
+
+ /**
+ * {@inheritDoc} from Fragment
+ */
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.conversation_fragment, container, false);
+ mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list);
+ final LinearLayoutManager manager = new LinearLayoutManager(getActivity());
+ manager.setStackFromEnd(true);
+ manager.setReverseLayout(false);
+ mRecyclerView.setHasFixedSize(true);
+ mRecyclerView.setLayoutManager(manager);
+ mRecyclerView.setItemAnimator(new DefaultItemAnimator() {
+ private final List<ViewHolder> mAddAnimations = new ArrayList<ViewHolder>();
+ private PopupTransitionAnimation mPopupTransitionAnimation;
+
+ @Override
+ public boolean animateAdd(final ViewHolder holder) {
+ final ConversationMessageView view =
+ (ConversationMessageView) holder.itemView;
+ final ConversationMessageData data = view.getData();
+ endAnimation(holder);
+ final long timeSinceSend = System.currentTimeMillis() - data.getReceivedTimeStamp();
+ if (data.getReceivedTimeStamp() ==
+ InsertNewMessageAction.getLastSentMessageTimestamp() &&
+ !data.getIsIncoming() &&
+ timeSinceSend < MESSAGE_ANIMATION_MAX_WAIT) {
+ final ConversationMessageBubbleView messageBubble =
+ (ConversationMessageBubbleView) view
+ .findViewById(R.id.message_content);
+ final Rect startRect = UiUtils.getMeasuredBoundsOnScreen(mComposeMessageView);
+ final View composeBubbleView = mComposeMessageView.findViewById(
+ R.id.compose_message_text);
+ final Rect composeBubbleRect =
+ UiUtils.getMeasuredBoundsOnScreen(composeBubbleView);
+ final AttachmentPreview attachmentView =
+ (AttachmentPreview) mComposeMessageView.findViewById(
+ R.id.attachment_draft_view);
+ final Rect attachmentRect = UiUtils.getMeasuredBoundsOnScreen(attachmentView);
+ if (attachmentView.getVisibility() == View.VISIBLE) {
+ startRect.top = attachmentRect.top;
+ } else {
+ startRect.top = composeBubbleRect.top;
+ }
+ startRect.top -= view.getPaddingTop();
+ startRect.bottom =
+ composeBubbleRect.bottom;
+ startRect.left += view.getPaddingRight();
+
+ view.setAlpha(0);
+ mPopupTransitionAnimation = new PopupTransitionAnimation(startRect, view);
+ mPopupTransitionAnimation.setOnStartCallback(new Runnable() {
+ @Override
+ public void run() {
+ final int startWidth = composeBubbleRect.width();
+ attachmentView.onMessageAnimationStart();
+ messageBubble.kickOffMorphAnimation(startWidth,
+ messageBubble.findViewById(R.id.message_text_and_info)
+ .getMeasuredWidth());
+ }
+ });
+ mPopupTransitionAnimation.setOnStopCallback(new Runnable() {
+ @Override
+ public void run() {
+ view.setAlpha(1);
+ }
+ });
+ mPopupTransitionAnimation.startAfterLayoutComplete();
+ mAddAnimations.add(holder);
+ return true;
+ } else {
+ return super.animateAdd(holder);
+ }
+ }
+
+ @Override
+ public void endAnimation(final ViewHolder holder) {
+ if (mAddAnimations.remove(holder)) {
+ holder.itemView.clearAnimation();
+ }
+ super.endAnimation(holder);
+ }
+
+ @Override
+ public void endAnimations() {
+ for (final ViewHolder holder : mAddAnimations) {
+ holder.itemView.clearAnimation();
+ }
+ mAddAnimations.clear();
+ if (mPopupTransitionAnimation != null) {
+ mPopupTransitionAnimation.cancel();
+ }
+ super.endAnimations();
+ }
+ });
+ mRecyclerView.setAdapter(mAdapter);
+
+ if (savedInstanceState != null) {
+ mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY);
+ }
+
+ mConversationComposeDivider = view.findViewById(R.id.conversation_compose_divider);
+ mScrollToDismissThreshold = ViewConfiguration.get(getActivity()).getScaledTouchSlop();
+ mRecyclerView.addOnScrollListener(mListScrollListener);
+ mFastScroller = ConversationFastScroller.addTo(mRecyclerView,
+ UiUtils.isRtlMode() ? ConversationFastScroller.POSITION_LEFT_SIDE :
+ ConversationFastScroller.POSITION_RIGHT_SIDE);
+
+ mComposeMessageView = (ComposeMessageView)
+ view.findViewById(R.id.message_compose_view_container);
+ // Bind the compose message view to the DraftMessageData
+ mComposeMessageView.bind(DataModel.get().createDraftMessageData(
+ mBinding.getData().getConversationId()), this);
+
+ return view;
+ }
+
+ private void scrollToPosition(final int targetPosition, final boolean smoothScroll) {
+ if (smoothScroll) {
+ final int maxScrollDelta = JUMP_SCROLL_THRESHOLD;
+
+ final LinearLayoutManager layoutManager =
+ (LinearLayoutManager) mRecyclerView.getLayoutManager();
+ final int firstVisibleItemPosition =
+ layoutManager.findFirstVisibleItemPosition();
+ final int delta = targetPosition - firstVisibleItemPosition;
+ final int intermediatePosition;
+
+ if (delta > maxScrollDelta) {
+ intermediatePosition = Math.max(0, targetPosition - maxScrollDelta);
+ } else if (delta < -maxScrollDelta) {
+ final int count = layoutManager.getItemCount();
+ intermediatePosition = Math.min(count - 1, targetPosition + maxScrollDelta);
+ } else {
+ intermediatePosition = -1;
+ }
+ if (intermediatePosition != -1) {
+ mRecyclerView.scrollToPosition(intermediatePosition);
+ }
+ mRecyclerView.smoothScrollToPosition(targetPosition);
+ } else {
+ mRecyclerView.scrollToPosition(targetPosition);
+ }
+ }
+
+ private int getScrollPositionFromBottom() {
+ final LinearLayoutManager layoutManager =
+ (LinearLayoutManager) mRecyclerView.getLayoutManager();
+ final int lastVisibleItem =
+ layoutManager.findLastVisibleItemPosition();
+ return Math.max(mAdapter.getItemCount() - 1 - lastVisibleItem, 0);
+ }
+
+ /**
+ * Display a photo using the Photoviewer component.
+ */
+ @Override
+ public void displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft) {
+ displayPhoto(photoUri, imageBounds, isDraft, mConversationId, getActivity());
+ }
+
+ public static void displayPhoto(final Uri photoUri, final Rect imageBounds,
+ final boolean isDraft, final String conversationId, final Activity activity) {
+ final Uri imagesUri =
+ isDraft ? MessagingContentProvider.buildDraftImagesUri(conversationId)
+ : MessagingContentProvider.buildConversationImagesUri(conversationId);
+ UIIntents.get().launchFullScreenPhotoViewer(
+ activity, photoUri, imageBounds, imagesUri);
+ }
+
+ private void selectMessage(final ConversationMessageView messageView) {
+ selectMessage(messageView, null /* attachment */);
+ }
+
+ private void selectMessage(final ConversationMessageView messageView,
+ final MessagePartData attachment) {
+ mSelectedMessage = messageView;
+ if (mSelectedMessage == null) {
+ mAdapter.setSelectedMessage(null);
+ mHost.dismissActionMode();
+ mSelectedAttachment = null;
+ return;
+ }
+ mSelectedAttachment = attachment;
+ mAdapter.setSelectedMessage(messageView.getData().getMessageId());
+ mHost.startActionMode(mMessageActionModeCallback);
+ }
+
+ @Override
+ public void onSaveInstanceState(final Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (mListState != null) {
+ outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState);
+ }
+ mComposeMessageView.saveInputState(outState);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ if (mIncomingDraft == null) {
+ mComposeMessageView.requestDraftMessage(mClearLocalDraft);
+ } else {
+ mComposeMessageView.setDraftMessage(mIncomingDraft);
+ mIncomingDraft = null;
+ }
+ mClearLocalDraft = false;
+
+ // On resume, check if there's a pending request for resuming message compose. This
+ // may happen when the user commits the contact selection for a group conversation and
+ // goes from compose back to the conversation fragment.
+ if (mHost.shouldResumeComposeMessage()) {
+ mComposeMessageView.resumeComposeMessage();
+ }
+
+ setConversationFocus();
+
+ // On resume, invalidate all message views to show the updated timestamp.
+ mAdapter.notifyDataSetChanged();
+
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
+ mConversationSelfIdChangeReceiver,
+ new IntentFilter(UIIntents.CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION));
+ }
+
+ void setConversationFocus() {
+ if (mHost.isActiveAndFocused()) {
+ mBinding.getData().setFocus();
+ }
+ }
+
+ @Override
+ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
+ if (mHost.getActionMode() != null) {
+ return;
+ }
+
+ inflater.inflate(R.menu.conversation_menu, menu);
+
+ final ConversationData data = mBinding.getData();
+
+ // Disable the "people & options" item if we haven't loaded participants yet.
+ menu.findItem(R.id.action_people_and_options).setEnabled(data.getParticipantsLoaded());
+
+ // See if we can show add contact action.
+ final ParticipantData participant = data.getOtherParticipant();
+ final boolean addContactActionVisible = (participant != null
+ && TextUtils.isEmpty(participant.getLookupKey()));
+ menu.findItem(R.id.action_add_contact).setVisible(addContactActionVisible);
+
+ // See if we should show archive or unarchive.
+ final boolean isArchived = data.getIsArchived();
+ menu.findItem(R.id.action_archive).setVisible(!isArchived);
+ menu.findItem(R.id.action_unarchive).setVisible(isArchived);
+
+ // Conditionally enable the phone call button.
+ final boolean supportCallAction = (PhoneUtils.getDefault().isVoiceCapable() &&
+ data.getParticipantPhoneNumber() != null);
+ menu.findItem(R.id.action_call).setVisible(supportCallAction);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.action_people_and_options:
+ Assert.isTrue(mBinding.getData().getParticipantsLoaded());
+ UIIntents.get().launchPeopleAndOptionsActivity(getActivity(), mConversationId);
+ return true;
+
+ case R.id.action_call:
+ final String phoneNumber = mBinding.getData().getParticipantPhoneNumber();
+ Assert.notNull(phoneNumber);
+ final View targetView = getActivity().findViewById(R.id.action_call);
+ Point centerPoint;
+ if (targetView != null) {
+ final int screenLocation[] = new int[2];
+ targetView.getLocationOnScreen(screenLocation);
+ final int centerX = screenLocation[0] + targetView.getWidth() / 2;
+ final int centerY = screenLocation[1] + targetView.getHeight() / 2;
+ centerPoint = new Point(centerX, centerY);
+ } else {
+ // In the overflow menu, just use the center of the screen.
+ final Display display = getActivity().getWindowManager().getDefaultDisplay();
+ centerPoint = new Point(display.getWidth() / 2, display.getHeight() / 2);
+ }
+ UIIntents.get().launchPhoneCallActivity(getActivity(), phoneNumber, centerPoint);
+ return true;
+
+ case R.id.action_archive:
+ mBinding.getData().archiveConversation(mBinding);
+ closeConversation(mConversationId);
+ return true;
+
+ case R.id.action_unarchive:
+ mBinding.getData().unarchiveConversation(mBinding);
+ return true;
+
+ case R.id.action_settings:
+ return true;
+
+ case R.id.action_add_contact:
+ final ParticipantData participant = mBinding.getData().getOtherParticipant();
+ Assert.notNull(participant);
+ final String destination = participant.getNormalizedDestination();
+ final Uri avatarUri = AvatarUriUtil.createAvatarUri(participant);
+ (new AddContactsConfirmationDialog(getActivity(), avatarUri, destination)).show();
+ return true;
+
+ case R.id.action_delete:
+ if (isReadyForAction()) {
+ new AlertDialog.Builder(getActivity())
+ .setTitle(getResources().getQuantityString(
+ R.plurals.delete_conversations_confirmation_dialog_title, 1))
+ .setPositiveButton(R.string.delete_conversation_confirmation_button,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int button) {
+ deleteConversation();
+ }
+ })
+ .setNegativeButton(R.string.delete_conversation_decline_button, null)
+ .show();
+ } else {
+ warnOfMissingActionConditions(false /*sending*/,
+ null /*commandToRunAfterActionConditionResolved*/);
+ }
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ /**
+ * {@inheritDoc} from ConversationDataListener
+ */
+ @Override
+ public void onConversationMessagesCursorUpdated(final ConversationData data,
+ final Cursor cursor, final ConversationMessageData newestMessage,
+ final boolean isSync) {
+ mBinding.ensureBound(data);
+
+ // This needs to be determined before swapping cursor, which may change the scroll state.
+ final boolean scrolledToBottom = isScrolledToBottom();
+ final int positionFromBottom = getScrollPositionFromBottom();
+
+ // If participants not loaded, assume 1:1 since that's the 99% case
+ final boolean oneOnOne =
+ !data.getParticipantsLoaded() || data.getOtherParticipant() != null;
+ mAdapter.setOneOnOne(oneOnOne, false /* invalidate */);
+
+ // Ensure that the action bar is updated with the current data.
+ invalidateOptionsMenu();
+ final Cursor oldCursor = mAdapter.swapCursor(cursor);
+
+ if (cursor != null && oldCursor == null) {
+ if (mListState != null) {
+ mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState);
+ // RecyclerView restores scroll states without triggering scroll change events, so
+ // we need to manually ensure that they are correctly handled.
+ mListScrollListener.onScrolled(mRecyclerView, 0, 0);
+ }
+ }
+
+ if (isSync) {
+ // This is a message sync. Syncing messages changes cursor item count, which would
+ // implicitly change RV's scroll position. We'd like the RV to keep scrolled to the same
+ // relative position from the bottom (because RV is stacked from bottom), so that it
+ // stays relatively put as we sync.
+ final int position = Math.max(mAdapter.getItemCount() - 1 - positionFromBottom, 0);
+ scrollToPosition(position, false /* smoothScroll */);
+ } else if (newestMessage != null) {
+ // Show a snack bar notification if we are not scrolled to the bottom and the new
+ // message is an incoming message.
+ if (!scrolledToBottom && newestMessage.getIsIncoming()) {
+ // If the conversation activity is started but not resumed (if another dialog
+ // activity was in the foregrond), we will show a system notification instead of
+ // the snack bar.
+ if (mBinding.getData().isFocused()) {
+ UiUtils.showSnackBarWithCustomAction(getActivity(),
+ getView().getRootView(),
+ getString(R.string.in_conversation_notify_new_message_text),
+ SnackBar.Action.createCustomAction(new Runnable() {
+ @Override
+ public void run() {
+ scrollToBottom(true /* smoothScroll */);
+ mComposeMessageView.hideAllComposeInputs(false /* animate */);
+ }
+ },
+ getString(R.string.in_conversation_notify_new_message_action)),
+ null /* interactions */,
+ SnackBar.Placement.above(mComposeMessageView));
+ }
+ } else {
+ // We are either already scrolled to the bottom or this is an outgoing message,
+ // scroll to the bottom to reveal it.
+ // Don't smooth scroll if we were already at the bottom; instead, we scroll
+ // immediately so RecyclerView's view animation will take place.
+ scrollToBottom(!scrolledToBottom);
+ }
+ }
+
+ if (cursor != null) {
+ mHost.onConversationMessagesUpdated(cursor.getCount());
+
+ // Are we coming from a widget click where we're told to scroll to a particular item?
+ final int scrollToPos = getScrollToMessagePosition();
+ if (scrollToPos >= 0) {
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(LogUtil.BUGLE_TAG, "onConversationMessagesCursorUpdated " +
+ " scrollToPos: " + scrollToPos +
+ " cursorCount: " + cursor.getCount());
+ }
+ scrollToPosition(scrollToPos, true /*smoothScroll*/);
+ clearScrollToMessagePosition();
+ }
+ }
+
+ mHost.invalidateActionBar();
+ }
+
+ /**
+ * {@inheritDoc} from ConversationDataListener
+ */
+ @Override
+ public void onConversationMetadataUpdated(final ConversationData conversationData) {
+ mBinding.ensureBound(conversationData);
+
+ if (mSelectedMessage != null && mSelectedAttachment != null) {
+ // We may have just sent a message and the temp attachment we selected is now gone.
+ // and it was replaced with some new attachment. Since we don't know which one it
+ // is we shouldn't reselect it (unless there is just one) In the multi-attachment
+ // case we would just deselect the message and allow the user to reselect, otherwise we
+ // may act on old temp data and may crash.
+ final List<MessagePartData> currentAttachments = mSelectedMessage.getData().getAttachments();
+ if (currentAttachments.size() == 1) {
+ mSelectedAttachment = currentAttachments.get(0);
+ } else if (!currentAttachments.contains(mSelectedAttachment)) {
+ selectMessage(null);
+ }
+ }
+ // Ensure that the action bar is updated with the current data.
+ invalidateOptionsMenu();
+ mHost.onConversationMetadataUpdated();
+ mAdapter.notifyDataSetChanged();
+ }
+
+ public void setConversationInfo(final Context context, final String conversationId,
+ final MessageData draftData) {
+ // TODO: Eventually I would like the Factory to implement
+ // Factory.get().bindConversationData(mBinding, getActivity(), this, conversationId));
+ if (!mBinding.isBound()) {
+ mConversationId = conversationId;
+ mIncomingDraft = draftData;
+ mBinding.bind(DataModel.get().createConversationData(context, this, conversationId));
+ } else {
+ Assert.isTrue(TextUtils.equals(mBinding.getData().getConversationId(), conversationId));
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ // Unbind all the views that we bound to data
+ if (mComposeMessageView != null) {
+ mComposeMessageView.unbind();
+ }
+
+ // And unbind this fragment from its data
+ mBinding.unbind();
+ mConversationId = null;
+ }
+
+ void suppressWriteDraft() {
+ mSuppressWriteDraft = true;
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (mComposeMessageView != null && !mSuppressWriteDraft) {
+ mComposeMessageView.writeDraftMessage();
+ }
+ mSuppressWriteDraft = false;
+ mBinding.getData().unsetFocus();
+ mListState = mRecyclerView.getLayoutManager().onSaveInstanceState();
+
+ LocalBroadcastManager.getInstance(getActivity())
+ .unregisterReceiver(mConversationSelfIdChangeReceiver);
+ }
+
+ @Override
+ public void onConfigurationChanged(final Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ mRecyclerView.getItemAnimator().endAnimations();
+ }
+
+ // TODO: Remove isBound and replace it with ensureBound after b/15704674.
+ public boolean isBound() {
+ return mBinding.isBound();
+ }
+
+ private FragmentManager getFragmentManagerToUse() {
+ return OsUtil.isAtLeastJB_MR1() ? getChildFragmentManager() : getFragmentManager();
+ }
+
+ public MediaPicker getMediaPicker() {
+ return (MediaPicker) getFragmentManagerToUse().findFragmentByTag(
+ MediaPicker.FRAGMENT_TAG);
+ }
+
+ @Override
+ public void sendMessage(final MessageData message) {
+ if (isReadyForAction()) {
+ if (ensureKnownRecipients()) {
+ // Merge the caption text from attachments into the text body of the messages
+ message.consolidateText();
+
+ mBinding.getData().sendMessage(mBinding, message);
+ mComposeMessageView.resetMediaPickerState();
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: conv participants not loaded");
+ }
+ } else {
+ warnOfMissingActionConditions(true /*sending*/,
+ new Runnable() {
+ @Override
+ public void run() {
+ sendMessage(message);
+ }
+ });
+ }
+ }
+
+ public void setHost(final ConversationFragmentHost host) {
+ mHost = host;
+ }
+
+ public String getConversationName() {
+ return mBinding.getData().getConversationName();
+ }
+
+ @Override
+ public void onComposeEditTextFocused() {
+ mHost.onStartComposeMessage();
+ }
+
+ @Override
+ public void onAttachmentsCleared() {
+ // When attachments are removed, reset transient media picker state such as image selection.
+ mComposeMessageView.resetMediaPickerState();
+ }
+
+ /**
+ * Called to check if all conditions are nominal and a "go" for some action, such as deleting
+ * a message, that requires this app to be the default app. This is also a precondition
+ * required for sending a draft.
+ * @return true if all conditions are nominal and we're ready to send a message
+ */
+ @Override
+ public boolean isReadyForAction() {
+ return UiUtils.isReadyForAction();
+ }
+
+ /**
+ * When there's some condition that prevents an operation, such as sending a message,
+ * call warnOfMissingActionConditions to put up a snackbar and allow the user to repair
+ * that condition.
+ * @param sending - true if we're called during a sending operation
+ * @param commandToRunAfterActionConditionResolved - a runnable to run after the user responds
+ * positively to the condition prompt and resolves the condition. If null,
+ * the user will be shown a toast to tap the send button again.
+ */
+ @Override
+ public void warnOfMissingActionConditions(final boolean sending,
+ final Runnable commandToRunAfterActionConditionResolved) {
+ if (mChangeDefaultSmsAppHelper == null) {
+ mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper();
+ }
+ mChangeDefaultSmsAppHelper.warnOfMissingActionConditions(sending,
+ commandToRunAfterActionConditionResolved, mComposeMessageView,
+ getView().getRootView(),
+ getActivity(), this);
+ }
+
+ private boolean ensureKnownRecipients() {
+ final ConversationData conversationData = mBinding.getData();
+
+ if (!conversationData.getParticipantsLoaded()) {
+ // We can't tell yet whether or not we have an unknown recipient
+ return false;
+ }
+
+ final ConversationParticipantsData participants = conversationData.getParticipants();
+ for (final ParticipantData participant : participants) {
+
+
+ if (participant.isUnknownSender()) {
+ UiUtils.showToast(R.string.unknown_sender);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public void retryDownload(final String messageId) {
+ if (isReadyForAction()) {
+ mBinding.getData().downloadMessage(mBinding, messageId);
+ } else {
+ warnOfMissingActionConditions(false /*sending*/,
+ null /*commandToRunAfterActionConditionResolved*/);
+ }
+ }
+
+ public void retrySend(final String messageId) {
+ if (isReadyForAction()) {
+ if (ensureKnownRecipients()) {
+ mBinding.getData().resendMessage(mBinding, messageId);
+ }
+ } else {
+ warnOfMissingActionConditions(true /*sending*/,
+ new Runnable() {
+ @Override
+ public void run() {
+ retrySend(messageId);
+ }
+
+ });
+ }
+ }
+
+ void deleteMessage(final String messageId) {
+ if (isReadyForAction()) {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity())
+ .setTitle(R.string.delete_message_confirmation_dialog_title)
+ .setMessage(R.string.delete_message_confirmation_dialog_text)
+ .setPositiveButton(R.string.delete_message_confirmation_button,
+ new OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ mBinding.getData().deleteMessage(mBinding, messageId);
+ }
+ })
+ .setNegativeButton(android.R.string.cancel, null);
+ if (OsUtil.isAtLeastJB_MR1()) {
+ builder.setOnDismissListener(new OnDismissListener() {
+ @Override
+ public void onDismiss(final DialogInterface dialog) {
+ mHost.dismissActionMode();
+ }
+ });
+ } else {
+ builder.setOnCancelListener(new OnCancelListener() {
+ @Override
+ public void onCancel(final DialogInterface dialog) {
+ mHost.dismissActionMode();
+ }
+ });
+ }
+ builder.create().show();
+ } else {
+ warnOfMissingActionConditions(false /*sending*/,
+ null /*commandToRunAfterActionConditionResolved*/);
+ mHost.dismissActionMode();
+ }
+ }
+
+ public void deleteConversation() {
+ if (isReadyForAction()) {
+ final Context context = getActivity();
+ mBinding.getData().deleteConversation(mBinding);
+ closeConversation(mConversationId);
+ } else {
+ warnOfMissingActionConditions(false /*sending*/,
+ null /*commandToRunAfterActionConditionResolved*/);
+ }
+ }
+
+ @Override
+ public void closeConversation(final String conversationId) {
+ if (TextUtils.equals(conversationId, mConversationId)) {
+ mHost.onFinishCurrentConversation();
+ // TODO: Explicitly transition to ConversationList (or just go back)?
+ }
+ }
+
+ @Override
+ public void onConversationParticipantDataLoaded(final ConversationData data) {
+ mBinding.ensureBound(data);
+ if (mBinding.getData().getParticipantsLoaded()) {
+ final boolean oneOnOne = mBinding.getData().getOtherParticipant() != null;
+ mAdapter.setOneOnOne(oneOnOne, true /* invalidate */);
+
+ // refresh the options menu which will enable the "people & options" item.
+ invalidateOptionsMenu();
+
+ mHost.invalidateActionBar();
+
+ mRecyclerView.setVisibility(View.VISIBLE);
+ mHost.onConversationParticipantDataLoaded
+ (mBinding.getData().getNumberOfParticipantsExcludingSelf());
+ }
+ }
+
+ @Override
+ public void onSubscriptionListDataLoaded(final ConversationData data) {
+ mBinding.ensureBound(data);
+ mAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public void promptForSelfPhoneNumber() {
+ if (mComposeMessageView != null) {
+ // Avoid bug in system which puts soft keyboard over dialog after orientation change
+ ImeUtil.hideSoftInput(getActivity(), mComposeMessageView);
+ }
+
+ final FragmentTransaction ft = getActivity().getFragmentManager().beginTransaction();
+ final EnterSelfPhoneNumberDialog dialog = EnterSelfPhoneNumberDialog
+ .newInstance(getConversationSelfSubId());
+ dialog.setTargetFragment(this, 0/*requestCode*/);
+ dialog.show(ft, null/*tag*/);
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
+ if (mChangeDefaultSmsAppHelper == null) {
+ mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper();
+ }
+ mChangeDefaultSmsAppHelper.handleChangeDefaultSmsResult(requestCode, resultCode, null);
+ }
+
+ public boolean hasMessages() {
+ return mAdapter != null && mAdapter.getItemCount() > 0;
+ }
+
+ public boolean onBackPressed() {
+ if (mComposeMessageView.onBackPressed()) {
+ return true;
+ }
+ return false;
+ }
+
+ public boolean onNavigationUpPressed() {
+ return mComposeMessageView.onNavigationUpPressed();
+ }
+
+ @Override
+ public boolean onAttachmentClick(final ConversationMessageView messageView,
+ final MessagePartData attachment, final Rect imageBounds, final boolean longPress) {
+ if (longPress) {
+ selectMessage(messageView, attachment);
+ return true;
+ } else if (messageView.getData().getOneClickResendMessage()) {
+ handleMessageClick(messageView);
+ return true;
+ }
+
+ if (attachment.isImage()) {
+ displayPhoto(attachment.getContentUri(), imageBounds, false /* isDraft */);
+ }
+
+ if (attachment.isVCard()) {
+ UIIntents.get().launchVCardDetailActivity(getActivity(), attachment.getContentUri());
+ }
+
+ return false;
+ }
+
+ private void handleMessageClick(final ConversationMessageView messageView) {
+ if (messageView != mSelectedMessage) {
+ final ConversationMessageData data = messageView.getData();
+ final boolean isReadyToSend = isReadyForAction();
+ if (data.getOneClickResendMessage()) {
+ // Directly resend the message on tap if it's failed
+ retrySend(data.getMessageId());
+ selectMessage(null);
+ } else if (data.getShowResendMessage() && isReadyToSend) {
+ // Select the message to show the resend/download/delete options
+ selectMessage(messageView);
+ } else if (data.getShowDownloadMessage() && isReadyToSend) {
+ // Directly download the message on tap
+ retryDownload(data.getMessageId());
+ } else {
+ // Let the toast from warnOfMissingActionConditions show and skip
+ // selecting
+ warnOfMissingActionConditions(false /*sending*/,
+ null /*commandToRunAfterActionConditionResolved*/);
+ selectMessage(null);
+ }
+ } else {
+ selectMessage(null);
+ }
+ }
+
+ private static class AttachmentToSave {
+ public final Uri uri;
+ public final String contentType;
+ public Uri persistedUri;
+
+ AttachmentToSave(final Uri uri, final String contentType) {
+ this.uri = uri;
+ this.contentType = contentType;
+ }
+ }
+
+ public static class SaveAttachmentTask extends SafeAsyncTask<Void, Void, Void> {
+ private final Context mContext;
+ private final List<AttachmentToSave> mAttachmentsToSave = new ArrayList<>();
+
+ public SaveAttachmentTask(final Context context, final Uri contentUri,
+ final String contentType) {
+ mContext = context;
+ addAttachmentToSave(contentUri, contentType);
+ }
+
+ public SaveAttachmentTask(final Context context) {
+ mContext = context;
+ }
+
+ public void addAttachmentToSave(final Uri contentUri, final String contentType) {
+ mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType));
+ }
+
+ public int getAttachmentCount() {
+ return mAttachmentsToSave.size();
+ }
+
+ @Override
+ protected Void doInBackgroundTimed(final Void... arg) {
+ final File appDir = new File(Environment.getExternalStoragePublicDirectory(
+ Environment.DIRECTORY_PICTURES),
+ mContext.getResources().getString(R.string.app_name));
+ final File downloadDir = Environment.getExternalStoragePublicDirectory(
+ Environment.DIRECTORY_DOWNLOADS);
+ for (final AttachmentToSave attachment : mAttachmentsToSave) {
+ final boolean isImageOrVideo = ContentType.isImageType(attachment.contentType)
+ || ContentType.isVideoType(attachment.contentType);
+ attachment.persistedUri = UriUtil.persistContent(attachment.uri,
+ isImageOrVideo ? appDir : downloadDir, attachment.contentType);
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(final Void result) {
+ int failCount = 0;
+ int imageCount = 0;
+ int videoCount = 0;
+ int otherCount = 0;
+ for (final AttachmentToSave attachment : mAttachmentsToSave) {
+ if (attachment.persistedUri == null) {
+ failCount++;
+ continue;
+ }
+
+ // Inform MediaScanner about the new file
+ final Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
+ scanFileIntent.setData(attachment.persistedUri);
+ mContext.sendBroadcast(scanFileIntent);
+
+ if (ContentType.isImageType(attachment.contentType)) {
+ imageCount++;
+ } else if (ContentType.isVideoType(attachment.contentType)) {
+ videoCount++;
+ } else {
+ otherCount++;
+ // Inform DownloadManager of the file so it will show in the "downloads" app
+ final DownloadManager downloadManager =
+ (DownloadManager) mContext.getSystemService(
+ Context.DOWNLOAD_SERVICE);
+ final String filePath = attachment.persistedUri.getPath();
+ final File file = new File(filePath);
+
+ if (file.exists()) {
+ downloadManager.addCompletedDownload(
+ file.getName() /* title */,
+ mContext.getString(
+ R.string.attachment_file_description) /* description */,
+ true /* isMediaScannerScannable */,
+ attachment.contentType,
+ file.getAbsolutePath(),
+ file.length(),
+ false /* showNotification */);
+ }
+ }
+ }
+
+ String message;
+ if (failCount > 0) {
+ message = mContext.getResources().getQuantityString(
+ R.plurals.attachment_save_error, failCount, failCount);
+ } else {
+ int messageId = R.plurals.attachments_saved;
+ if (otherCount > 0) {
+ if (imageCount + videoCount == 0) {
+ messageId = R.plurals.attachments_saved_to_downloads;
+ }
+ } else {
+ if (videoCount == 0) {
+ messageId = R.plurals.photos_saved_to_album;
+ } else if (imageCount == 0) {
+ messageId = R.plurals.videos_saved_to_album;
+ } else {
+ messageId = R.plurals.attachments_saved_to_album;
+ }
+ }
+ final String appName = mContext.getResources().getString(R.string.app_name);
+ final int count = imageCount + videoCount + otherCount;
+ message = mContext.getResources().getQuantityString(
+ messageId, count, count, appName);
+ }
+ UiUtils.showToastAtBottom(message);
+ }
+ }
+
+ private void invalidateOptionsMenu() {
+ final Activity activity = getActivity();
+ // TODO: Add the supportInvalidateOptionsMenu call to the host activity.
+ if (activity == null || !(activity instanceof BugleActionBarActivity)) {
+ return;
+ }
+ ((BugleActionBarActivity) activity).supportInvalidateOptionsMenu();
+ }
+
+ @Override
+ public void setOptionsMenuVisibility(final boolean visible) {
+ setHasOptionsMenu(visible);
+ }
+
+ @Override
+ public int getConversationSelfSubId() {
+ final String selfParticipantId = mComposeMessageView.getConversationSelfId();
+ final ParticipantData self = mBinding.getData().getSelfParticipantById(selfParticipantId);
+ // If the self id or the self participant data hasn't been loaded yet, fallback to
+ // the default setting.
+ return self == null ? ParticipantData.DEFAULT_SELF_SUB_ID : self.getSubId();
+ }
+
+ @Override
+ public void invalidateActionBar() {
+ mHost.invalidateActionBar();
+ }
+
+ @Override
+ public void dismissActionMode() {
+ mHost.dismissActionMode();
+ }
+
+ @Override
+ public void selectSim(final SubscriptionListEntry subscriptionData) {
+ mComposeMessageView.selectSim(subscriptionData);
+ mHost.onStartComposeMessage();
+ }
+
+ @Override
+ public void onStartComposeMessage() {
+ mHost.onStartComposeMessage();
+ }
+
+ @Override
+ public SubscriptionListEntry getSubscriptionEntryForSelfParticipant(
+ final String selfParticipantId, final boolean excludeDefault) {
+ // TODO: ConversationMessageView is the only one using this. We should probably
+ // inject this into the view during binding in the ConversationMessageAdapter.
+ return mBinding.getData().getSubscriptionEntryForSelfParticipant(selfParticipantId,
+ excludeDefault);
+ }
+
+ @Override
+ public SimSelectorView getSimSelectorView() {
+ return (SimSelectorView) getView().findViewById(R.id.sim_selector);
+ }
+
+ @Override
+ public MediaPicker createMediaPicker() {
+ return new MediaPicker(getActivity());
+ }
+
+ @Override
+ public void notifyOfAttachmentLoadFailed() {
+ UiUtils.showToastAtBottom(R.string.attachment_load_failed_dialog_message);
+ }
+
+ @Override
+ public void warnOfExceedingMessageLimit(final boolean sending, final boolean tooManyVideos) {
+ warnOfExceedingMessageLimit(sending, mComposeMessageView, mConversationId,
+ getActivity(), tooManyVideos);
+ }
+
+ public static void warnOfExceedingMessageLimit(final boolean sending,
+ final ComposeMessageView composeMessageView, final String conversationId,
+ final Activity activity, final boolean tooManyVideos) {
+ final AlertDialog.Builder builder =
+ new AlertDialog.Builder(activity)
+ .setTitle(R.string.mms_attachment_limit_reached);
+
+ if (sending) {
+ if (tooManyVideos) {
+ builder.setMessage(R.string.video_attachment_limit_exceeded_when_sending);
+ } else {
+ builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_sending)
+ .setNegativeButton(R.string.attachment_limit_reached_send_anyway,
+ new OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int which) {
+ composeMessageView.sendMessageIgnoreMessageSizeLimit();
+ }
+ });
+ }
+ builder.setPositiveButton(android.R.string.ok, new OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ showAttachmentChooser(conversationId, activity);
+ }});
+ } else {
+ builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_composing)
+ .setPositiveButton(android.R.string.ok, null);
+ }
+ builder.show();
+ }
+
+ @Override
+ public void showAttachmentChooser() {
+ showAttachmentChooser(mConversationId, getActivity());
+ }
+
+ public static void showAttachmentChooser(final String conversationId,
+ final Activity activity) {
+ UIIntents.get().launchAttachmentChooserActivity(activity,
+ conversationId, REQUEST_CHOOSE_ATTACHMENTS);
+ }
+
+ private void updateActionAndStatusBarColor(final ActionBar actionBar) {
+ final int themeColor = ConversationDrawables.get().getConversationThemeColor();
+ actionBar.setBackgroundDrawable(new ColorDrawable(themeColor));
+ UiUtils.setStatusBarColor(getActivity(), themeColor);
+ }
+
+ public void updateActionBar(final ActionBar actionBar) {
+ if (mComposeMessageView == null || !mComposeMessageView.updateActionBar(actionBar)) {
+ updateActionAndStatusBarColor(actionBar);
+ // We update this regardless of whether or not the action bar is showing so that we
+ // don't get a race when it reappears.
+ actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ // Reset the back arrow to its default
+ actionBar.setHomeAsUpIndicator(0);
+ View customView = actionBar.getCustomView();
+ if (customView == null || customView.getId() != R.id.conversation_title_container) {
+ final LayoutInflater inflator = (LayoutInflater)
+ getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ customView = inflator.inflate(R.layout.action_bar_conversation_name, null);
+ customView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ onBackPressed();
+ }
+ });
+ actionBar.setCustomView(customView);
+ }
+
+ final TextView conversationNameView =
+ (TextView) customView.findViewById(R.id.conversation_title);
+ final String conversationName = getConversationName();
+ if (!TextUtils.isEmpty(conversationName)) {
+ // RTL : To format conversation title if it happens to be phone numbers.
+ final BidiFormatter bidiFormatter = BidiFormatter.getInstance();
+ final String formattedName = bidiFormatter.unicodeWrap(
+ UiUtils.commaEllipsize(
+ conversationName,
+ conversationNameView.getPaint(),
+ conversationNameView.getWidth(),
+ getString(R.string.plus_one),
+ getString(R.string.plus_n)).toString(),
+ TextDirectionHeuristicsCompat.LTR);
+ conversationNameView.setText(formattedName);
+ // In case phone numbers are mixed in the conversation name, we need to vocalize it.
+ final String vocalizedConversationName =
+ AccessibilityUtil.getVocalizedPhoneNumber(getResources(), conversationName);
+ conversationNameView.setContentDescription(vocalizedConversationName);
+ getActivity().setTitle(conversationName);
+ } else {
+ final String appName = getString(R.string.app_name);
+ conversationNameView.setText(appName);
+ getActivity().setTitle(appName);
+ }
+
+ // When conversation is showing and media picker is not showing, then hide the action
+ // bar only when we are in landscape mode, with IME open.
+ if (mHost.isImeOpen() && UiUtils.isLandscapeMode()) {
+ actionBar.hide();
+ } else {
+ actionBar.show();
+ }
+ }
+ }
+
+ @Override
+ public boolean shouldShowSubjectEditor() {
+ return true;
+ }
+
+ @Override
+ public boolean shouldHideAttachmentsWhenSimSelectorShown() {
+ return false;
+ }
+
+ @Override
+ public void showHideSimSelector(final boolean show) {
+ // no-op for now
+ }
+
+ @Override
+ public int getSimSelectorItemLayoutId() {
+ return R.layout.sim_selector_item_view;
+ }
+
+ @Override
+ public Uri getSelfSendButtonIconUri() {
+ return null; // use default button icon uri
+ }
+
+ @Override
+ public int overrideCounterColor() {
+ return -1; // don't override the color
+ }
+
+ @Override
+ public void onAttachmentsChanged(final boolean haveAttachments) {
+ // no-op for now
+ }
+
+ @Override
+ public void onDraftChanged(final DraftMessageData data, final int changeFlags) {
+ mDraftMessageDataModel.ensureBound(data);
+ // We're specifically only interested in ATTACHMENTS_CHANGED from the widget. Ignore
+ // other changes. When the widget changes an attachment, we need to reload the draft.
+ if (changeFlags ==
+ (DraftMessageData.WIDGET_CHANGED | DraftMessageData.ATTACHMENTS_CHANGED)) {
+ mClearLocalDraft = true; // force a reload of the draft in onResume
+ }
+ }
+
+ @Override
+ public void onDraftAttachmentLimitReached(final DraftMessageData data) {
+ // no-op for now
+ }
+
+ @Override
+ public void onDraftAttachmentLoadFailed() {
+ // no-op for now
+ }
+
+ @Override
+ public int getAttachmentsClearedFlags() {
+ return DraftMessageData.ATTACHMENTS_CHANGED;
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationInput.java b/src/com/android/messaging/ui/conversation/ConversationInput.java
new file mode 100644
index 0000000..bf60aa8
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationInput.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.ui.conversation;
+
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+
+/**
+ * The base class for a method of user input, e.g. media picker.
+ */
+public abstract class ConversationInput {
+ /**
+ * The host component where all input components are contained. This is typically the
+ * conversation fragment but may be mocked in test code.
+ */
+ public interface ConversationInputBase {
+ boolean showHideInternal(final ConversationInput target, final boolean show,
+ final boolean animate);
+ String getInputStateKey(final ConversationInput input);
+ void beginUpdate();
+ void handleOnShow(final ConversationInput target);
+ void endUpdate();
+ }
+
+ protected boolean mShowing;
+ protected ConversationInputBase mConversationInputBase;
+
+ public abstract boolean show(boolean animate);
+ public abstract boolean hide(boolean animate);
+
+ public ConversationInput(ConversationInputBase baseHost, final boolean isShowing) {
+ mConversationInputBase = baseHost;
+ mShowing = isShowing;
+ }
+
+ public boolean onBackPressed() {
+ if (mShowing) {
+ mConversationInputBase.showHideInternal(this, false /* show */, true /* animate */);
+ return true;
+ }
+ return false;
+ }
+
+ public boolean onNavigationUpPressed() {
+ return false;
+ }
+
+ /**
+ * Toggle the visibility of this view.
+ * @param animate
+ * @return true if the view is now shown, false if it now hidden
+ */
+ public boolean toggle(final boolean animate) {
+ mConversationInputBase.showHideInternal(this, !mShowing /* show */, true /* animate */);
+ return mShowing;
+ }
+
+ public void saveState(final Bundle savedState) {
+ savedState.putBoolean(mConversationInputBase.getInputStateKey(this), mShowing);
+ }
+
+ public void restoreState(final Bundle savedState) {
+ // Things are hidden by default, so only handle show.
+ if (savedState.getBoolean(mConversationInputBase.getInputStateKey(this))) {
+ mConversationInputBase.showHideInternal(this, true /* show */, false /* animate */);
+ }
+ }
+
+ public boolean updateActionBar(final ActionBar actionBar) {
+ return false;
+ }
+
+ /**
+ * Update our visibility flag in response to visibility change, both for actions
+ * initiated by this class (through the show/hide methods), and for external changes
+ * tracked by event listeners (e.g. ImeStateObserver, MediaPickerListener). As part of
+ * handling an input showing, we will hide all other inputs to ensure they are mutually
+ * exclusive.
+ */
+ protected void onVisibilityChanged(final boolean visible) {
+ if (mShowing != visible) {
+ mConversationInputBase.beginUpdate();
+ mShowing = visible;
+ if (visible) {
+ mConversationInputBase.handleOnShow(this);
+ }
+ mConversationInputBase.endUpdate();
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationInputManager.java b/src/com/android/messaging/ui/conversation/ConversationInputManager.java
new file mode 100644
index 0000000..e10abe7
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationInputManager.java
@@ -0,0 +1,550 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.app.FragmentManager;
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.widget.EditText;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.binding.ImmutableBindingRef;
+import com.android.messaging.datamodel.data.ConversationData;
+import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
+import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.PendingAttachmentData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.ui.ConversationDrawables;
+import com.android.messaging.ui.mediapicker.MediaPicker;
+import com.android.messaging.ui.mediapicker.MediaPicker.MediaPickerListener;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ImeUtil;
+import com.android.messaging.util.ImeUtil.ImeStateHost;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.Collection;
+
+/**
+ * Manages showing/hiding/persisting different mutually exclusive UI components nested in
+ * ConversationFragment that take user inputs, i.e. media picker, SIM selector and
+ * IME keyboard (the IME keyboard is not owned by Bugle, but we try to model it the same way
+ * as the other components).
+ */
+public class ConversationInputManager implements ConversationInput.ConversationInputBase {
+ /**
+ * The host component where all input components are contained. This is typically the
+ * conversation fragment but may be mocked in test code.
+ */
+ public interface ConversationInputHost extends DraftMessageSubscriptionDataProvider {
+ void invalidateActionBar();
+ void setOptionsMenuVisibility(boolean visible);
+ void dismissActionMode();
+ void selectSim(SubscriptionListEntry subscriptionData);
+ void onStartComposeMessage();
+ SimSelectorView getSimSelectorView();
+ MediaPicker createMediaPicker();
+ void showHideSimSelector(boolean show);
+ int getSimSelectorItemLayoutId();
+ }
+
+ /**
+ * The "sink" component where all inputs components will direct the user inputs to. This is
+ * typically the ComposeMessageView but may be mocked in test code.
+ */
+ public interface ConversationInputSink {
+ void onMediaItemsSelected(Collection<MessagePartData> items);
+ void onMediaItemsUnselected(MessagePartData item);
+ void onPendingAttachmentAdded(PendingAttachmentData pendingItem);
+ void resumeComposeMessage();
+ EditText getComposeEditText();
+ void setAccessibility(boolean enabled);
+ }
+
+ private final ConversationInputHost mHost;
+ private final ConversationInputSink mSink;
+
+ /** Dependencies injected from the host during construction */
+ private final FragmentManager mFragmentManager;
+ private final Context mContext;
+ private final ImeStateHost mImeStateHost;
+ private final ImmutableBindingRef<ConversationData> mConversationDataModel;
+ private final ImmutableBindingRef<DraftMessageData> mDraftDataModel;
+
+ private final ConversationInput[] mInputs;
+ private final ConversationMediaPicker mMediaInput;
+ private final ConversationSimSelector mSimInput;
+ private final ConversationImeKeyboard mImeInput;
+ private int mUpdateCount;
+
+ private final ImeUtil.ImeStateObserver mImeStateObserver = new ImeUtil.ImeStateObserver() {
+ @Override
+ public void onImeStateChanged(final boolean imeOpen) {
+ mImeInput.onVisibilityChanged(imeOpen);
+ }
+ };
+
+ private final ConversationDataListener mDataListener = new SimpleConversationDataListener() {
+ @Override
+ public void onConversationParticipantDataLoaded(ConversationData data) {
+ mConversationDataModel.ensureBound(data);
+ }
+
+ @Override
+ public void onSubscriptionListDataLoaded(ConversationData data) {
+ mConversationDataModel.ensureBound(data);
+ mSimInput.onSubscriptionListDataLoaded(data.getSubscriptionListData());
+ }
+ };
+
+ public ConversationInputManager(
+ final Context context,
+ final ConversationInputHost host,
+ final ConversationInputSink sink,
+ final ImeStateHost imeStateHost,
+ final FragmentManager fm,
+ final BindingBase<ConversationData> conversationDataModel,
+ final BindingBase<DraftMessageData> draftDataModel,
+ final Bundle savedState) {
+ mHost = host;
+ mSink = sink;
+ mFragmentManager = fm;
+ mContext = context;
+ mImeStateHost = imeStateHost;
+ mConversationDataModel = BindingBase.createBindingReference(conversationDataModel);
+ mDraftDataModel = BindingBase.createBindingReference(draftDataModel);
+
+ // Register listeners on dependencies.
+ mImeStateHost.registerImeStateObserver(mImeStateObserver);
+ mConversationDataModel.getData().addConversationDataListener(mDataListener);
+
+ // Initialize the inputs
+ mMediaInput = new ConversationMediaPicker(this);
+ mSimInput = new SimSelector(this);
+ mImeInput = new ConversationImeKeyboard(this, mImeStateHost.isImeOpen());
+ mInputs = new ConversationInput[] { mMediaInput, mSimInput, mImeInput };
+
+ if (savedState != null) {
+ for (int i = 0; i < mInputs.length; i++) {
+ mInputs[i].restoreState(savedState);
+ }
+ }
+ updateHostOptionsMenu();
+ }
+
+ public void onDetach() {
+ mImeStateHost.unregisterImeStateObserver(mImeStateObserver);
+ // Don't need to explicitly unregister for data model events. It will unregister all
+ // listeners automagically on unbind.
+ }
+
+ public void onSaveInputState(final Bundle savedState) {
+ for (int i = 0; i < mInputs.length; i++) {
+ mInputs[i].saveState(savedState);
+ }
+ }
+
+ @Override
+ public String getInputStateKey(final ConversationInput input) {
+ return input.getClass().getCanonicalName() + "_savedstate_";
+ }
+
+ public boolean onBackPressed() {
+ for (int i = 0; i < mInputs.length; i++) {
+ if (mInputs[i].onBackPressed()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public boolean onNavigationUpPressed() {
+ for (int i = 0; i < mInputs.length; i++) {
+ if (mInputs[i].onNavigationUpPressed()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void resetMediaPickerState() {
+ mMediaInput.resetViewHolderState();
+ }
+
+ public void showHideMediaPicker(final boolean show, final boolean animate) {
+ showHideInternal(mMediaInput, show, animate);
+ }
+
+ /**
+ * Show or hide the sim selector
+ * @param show visibility
+ * @param animate whether to animate the change in visibility
+ * @return true if the state of the visibility was changed
+ */
+ public boolean showHideSimSelector(final boolean show, final boolean animate) {
+ return showHideInternal(mSimInput, show, animate);
+ }
+
+ public void showHideImeKeyboard(final boolean show, final boolean animate) {
+ showHideInternal(mImeInput, show, animate);
+ }
+
+ public void hideAllInputs(final boolean animate) {
+ beginUpdate();
+ for (int i = 0; i < mInputs.length; i++) {
+ showHideInternal(mInputs[i], false, animate);
+ }
+ endUpdate();
+ }
+
+ /**
+ * Toggle the visibility of the sim selector.
+ * @param animate
+ * @param subEntry
+ * @return true if the view is now shown, false if it now hidden
+ */
+ public boolean toggleSimSelector(final boolean animate, final SubscriptionListEntry subEntry) {
+ mSimInput.setSelected(subEntry);
+ return mSimInput.toggle(animate);
+ }
+
+ public boolean updateActionBar(final ActionBar actionBar) {
+ for (int i = 0; i < mInputs.length; i++) {
+ if (mInputs[i].mShowing) {
+ return mInputs[i].updateActionBar(actionBar);
+ }
+ }
+ return false;
+ }
+
+ @VisibleForTesting
+ boolean isMediaPickerVisible() {
+ return mMediaInput.mShowing;
+ }
+
+ @VisibleForTesting
+ boolean isSimSelectorVisible() {
+ return mSimInput.mShowing;
+ }
+
+ @VisibleForTesting
+ boolean isImeKeyboardVisible() {
+ return mImeInput.mShowing;
+ }
+
+ @VisibleForTesting
+ void testNotifyImeStateChanged(final boolean imeOpen) {
+ mImeStateObserver.onImeStateChanged(imeOpen);
+ }
+
+ /**
+ * returns true if the state of the visibility was actually changed
+ */
+ @Override
+ public boolean showHideInternal(final ConversationInput target, final boolean show,
+ final boolean animate) {
+ if (!mConversationDataModel.isBound()) {
+ return false;
+ }
+
+ if (target.mShowing == show) {
+ return false;
+ }
+ beginUpdate();
+ boolean success;
+ if (!show) {
+ success = target.hide(animate);
+ } else {
+ success = target.show(animate);
+ }
+
+ if (success) {
+ target.onVisibilityChanged(show);
+ }
+ endUpdate();
+ return true;
+ }
+
+ @Override
+ public void handleOnShow(final ConversationInput target) {
+ if (!mConversationDataModel.isBound()) {
+ return;
+ }
+ beginUpdate();
+
+ // All inputs are mutually exclusive. Showing one will hide everything else.
+ // The one exception, is that the keyboard and location media chooser can be open at the
+ // time to enable searching within that chooser
+ for (int i = 0; i < mInputs.length; i++) {
+ final ConversationInput currInput = mInputs[i];
+ if (currInput != target) {
+ // TODO : If there's more exceptions we will want to make this more
+ // generic
+ if (currInput instanceof ConversationMediaPicker &&
+ target instanceof ConversationImeKeyboard &&
+ mMediaInput.getExistingOrCreateMediaPicker() != null &&
+ mMediaInput.getExistingOrCreateMediaPicker().canShowIme()) {
+ // Allow the keyboard and location mediaPicker to be open at the same time,
+ // but ensure the media picker is full screen to allow enough room
+ mMediaInput.getExistingOrCreateMediaPicker().setFullScreen(true);
+ continue;
+ }
+ showHideInternal(currInput, false /* show */, false /* animate */);
+ }
+ }
+ // Always dismiss action mode on show.
+ mHost.dismissActionMode();
+ // Invoking any non-keyboard input UI is treated as starting message compose.
+ if (target != mImeInput) {
+ mHost.onStartComposeMessage();
+ }
+ endUpdate();
+ }
+
+ @Override
+ public void beginUpdate() {
+ mUpdateCount++;
+ }
+
+ @Override
+ public void endUpdate() {
+ Assert.isTrue(mUpdateCount > 0);
+ if (--mUpdateCount == 0) {
+ // Always try to update the host action bar after every update cycle.
+ mHost.invalidateActionBar();
+ }
+ }
+
+ private void updateHostOptionsMenu() {
+ mHost.setOptionsMenuVisibility(!mMediaInput.isOpen());
+ }
+
+ /**
+ * Manages showing/hiding the media picker in conversation.
+ */
+ private class ConversationMediaPicker extends ConversationInput {
+ public ConversationMediaPicker(ConversationInputBase baseHost) {
+ super(baseHost, false);
+ }
+
+ private MediaPicker mMediaPicker;
+
+ @Override
+ public boolean show(boolean animate) {
+ if (mMediaPicker == null) {
+ mMediaPicker = getExistingOrCreateMediaPicker();
+ setConversationThemeColor(ConversationDrawables.get().getConversationThemeColor());
+ mMediaPicker.setSubscriptionDataProvider(mHost);
+ mMediaPicker.setDraftMessageDataModel(mDraftDataModel);
+ mMediaPicker.setListener(new MediaPickerListener() {
+ @Override
+ public void onOpened() {
+ handleStateChange();
+ }
+
+ @Override
+ public void onFullScreenChanged(boolean fullScreen) {
+ // When we're full screen, we want to disable accessibility on the
+ // ComposeMessageView controls (attach button, message input, sim chooser)
+ // that are hiding underneath the action bar.
+ mSink.setAccessibility(!fullScreen /*enabled*/);
+ handleStateChange();
+ }
+
+ @Override
+ public void onDismissed() {
+ // Re-enable accessibility on all controls now that the media picker is
+ // going away.
+ mSink.setAccessibility(true /*enabled*/);
+ handleStateChange();
+ }
+
+ private void handleStateChange() {
+ onVisibilityChanged(isOpen());
+ mHost.invalidateActionBar();
+ updateHostOptionsMenu();
+ }
+
+ @Override
+ public void onItemsSelected(final Collection<MessagePartData> items,
+ final boolean resumeCompose) {
+ mSink.onMediaItemsSelected(items);
+ mHost.invalidateActionBar();
+ if (resumeCompose) {
+ mSink.resumeComposeMessage();
+ }
+ }
+
+ @Override
+ public void onItemUnselected(final MessagePartData item) {
+ mSink.onMediaItemsUnselected(item);
+ mHost.invalidateActionBar();
+ }
+
+ @Override
+ public void onConfirmItemSelection() {
+ mSink.resumeComposeMessage();
+ }
+
+ @Override
+ public void onPendingItemAdded(final PendingAttachmentData pendingItem) {
+ mSink.onPendingAttachmentAdded(pendingItem);
+ }
+
+ @Override
+ public void onChooserSelected(final int chooserIndex) {
+ mHost.invalidateActionBar();
+ mHost.dismissActionMode();
+ }
+ });
+ }
+
+ mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate);
+
+ return isOpen();
+ }
+
+ @Override
+ public boolean hide(boolean animate) {
+ if (mMediaPicker != null) {
+ mMediaPicker.dismiss(animate);
+ }
+ return !isOpen();
+ }
+
+ public void resetViewHolderState() {
+ if (mMediaPicker != null) {
+ mMediaPicker.resetViewHolderState();
+ }
+ }
+
+ public void setConversationThemeColor(final int themeColor) {
+ if (mMediaPicker != null) {
+ mMediaPicker.setConversationThemeColor(themeColor);
+ }
+ }
+
+ private boolean isOpen() {
+ return (mMediaPicker != null && mMediaPicker.isOpen());
+ }
+
+ private MediaPicker getExistingOrCreateMediaPicker() {
+ if (mMediaPicker != null) {
+ return mMediaPicker;
+ }
+ MediaPicker mediaPicker = (MediaPicker)
+ mFragmentManager.findFragmentByTag(MediaPicker.FRAGMENT_TAG);
+ if (mediaPicker == null) {
+ mediaPicker = mHost.createMediaPicker();
+ if (mediaPicker == null) {
+ return null; // this use of ComposeMessageView doesn't support media picking
+ }
+ mFragmentManager.beginTransaction().replace(
+ R.id.mediapicker_container,
+ mediaPicker,
+ MediaPicker.FRAGMENT_TAG).commit();
+ }
+ return mediaPicker;
+ }
+
+ @Override
+ public boolean updateActionBar(ActionBar actionBar) {
+ if (isOpen()) {
+ mMediaPicker.updateActionBar(actionBar);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onNavigationUpPressed() {
+ if (isOpen() && mMediaPicker.isFullScreen()) {
+ return onBackPressed();
+ }
+ return super.onNavigationUpPressed();
+ }
+
+ public boolean onBackPressed() {
+ if (mMediaPicker != null && mMediaPicker.onBackPressed()) {
+ return true;
+ }
+ return super.onBackPressed();
+ }
+ }
+
+ /**
+ * Manages showing/hiding the SIM selector in conversation.
+ */
+ private class SimSelector extends ConversationSimSelector {
+ public SimSelector(ConversationInputBase baseHost) {
+ super(baseHost);
+ }
+
+ @Override
+ protected SimSelectorView getSimSelectorView() {
+ return mHost.getSimSelectorView();
+ }
+
+ @Override
+ public int getSimSelectorItemLayoutId() {
+ return mHost.getSimSelectorItemLayoutId();
+ }
+
+ @Override
+ protected void selectSim(SubscriptionListEntry item) {
+ mHost.selectSim(item);
+ }
+
+ @Override
+ public boolean show(boolean animate) {
+ final boolean result = super.show(animate);
+ mHost.showHideSimSelector(true /*show*/);
+ return result;
+ }
+
+ @Override
+ public boolean hide(boolean animate) {
+ final boolean result = super.hide(animate);
+ mHost.showHideSimSelector(false /*show*/);
+ return result;
+ }
+ }
+
+ /**
+ * Manages showing/hiding the IME keyboard in conversation.
+ */
+ private class ConversationImeKeyboard extends ConversationInput {
+ public ConversationImeKeyboard(ConversationInputBase baseHost, final boolean isShowing) {
+ super(baseHost, isShowing);
+ }
+
+ @Override
+ public boolean show(boolean animate) {
+ ImeUtil.get().showImeKeyboard(mContext, mSink.getComposeEditText());
+ return true;
+ }
+
+ @Override
+ public boolean hide(boolean animate) {
+ ImeUtil.get().hideImeKeyboard(mContext, mSink.getComposeEditText());
+ return true;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java b/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java
new file mode 100644
index 0000000..2748fff
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java
@@ -0,0 +1,117 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.AsyncImageView;
+import com.android.messaging.ui.CursorRecyclerAdapter;
+import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader;
+import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost;
+import com.android.messaging.util.Assert;
+
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * Provides an interface to expose Conversation Message Cursor data to a UI widget like a
+ * RecyclerView.
+ */
+public class ConversationMessageAdapter extends
+ CursorRecyclerAdapter<ConversationMessageAdapter.ConversationMessageViewHolder> {
+
+ private final ConversationMessageViewHost mHost;
+ private final AsyncImageViewDelayLoader mImageViewDelayLoader;
+ private final View.OnClickListener mViewClickListener;
+ private final View.OnLongClickListener mViewLongClickListener;
+ private boolean mOneOnOne;
+ private String mSelectedMessageId;
+
+ public ConversationMessageAdapter(final Context context, final Cursor cursor,
+ final ConversationMessageViewHost host,
+ final AsyncImageViewDelayLoader imageViewDelayLoader,
+ final View.OnClickListener viewClickListener,
+ final View.OnLongClickListener longClickListener) {
+ super(context, cursor, 0);
+ mHost = host;
+ mViewClickListener = viewClickListener;
+ mViewLongClickListener = longClickListener;
+ mImageViewDelayLoader = imageViewDelayLoader;
+ setHasStableIds(true);
+ }
+
+ @Override
+ public void bindViewHolder(final ConversationMessageViewHolder holder,
+ final Context context, final Cursor cursor) {
+ Assert.isTrue(holder.mView instanceof ConversationMessageView);
+ final ConversationMessageView conversationMessageView =
+ (ConversationMessageView) holder.mView;
+ conversationMessageView.bind(cursor, mOneOnOne, mSelectedMessageId);
+ }
+
+ @Override
+ public ConversationMessageViewHolder createViewHolder(final Context context,
+ final ViewGroup parent, final int viewType) {
+ final LayoutInflater layoutInflater = LayoutInflater.from(context);
+ final ConversationMessageView conversationMessageView = (ConversationMessageView)
+ layoutInflater.inflate(R.layout.conversation_message_view, null);
+ conversationMessageView.setHost(mHost);
+ conversationMessageView.setImageViewDelayLoader(mImageViewDelayLoader);
+ return new ConversationMessageViewHolder(conversationMessageView,
+ mViewClickListener, mViewLongClickListener);
+ }
+
+ public void setSelectedMessage(final String messageId) {
+ mSelectedMessageId = messageId;
+ notifyDataSetChanged();
+ }
+
+ public void setOneOnOne(final boolean oneOnOne, final boolean invalidate) {
+ if (mOneOnOne != oneOnOne) {
+ mOneOnOne = oneOnOne;
+ if (invalidate) {
+ notifyDataSetChanged();
+ }
+ }
+ }
+
+ /**
+ * ViewHolder that holds a ConversationMessageView.
+ */
+ public static class ConversationMessageViewHolder extends RecyclerView.ViewHolder {
+ final View mView;
+
+ /**
+ * @param viewClickListener a View.OnClickListener that should define the interaction when
+ * an item in the RecyclerView is clicked.
+ */
+ public ConversationMessageViewHolder(final View itemView,
+ final View.OnClickListener viewClickListener,
+ final View.OnLongClickListener viewLongClickListener) {
+ super(itemView);
+ mView = itemView;
+
+ mView.setOnClickListener(viewClickListener);
+ mView.setOnLongClickListener(viewLongClickListener);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java b/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java
new file mode 100644
index 0000000..ef6aeb4
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java
@@ -0,0 +1,132 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.animation.Animator;
+import android.animation.Animator.AnimatorListener;
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+
+import com.android.messaging.R;
+import com.android.messaging.annotation.VisibleForAnimation;
+import com.android.messaging.datamodel.data.ConversationMessageBubbleData;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * Shows the message bubble for one conversation message. It is able to animate size changes
+ * by morphing when the message content changes size.
+ */
+// TODO: Move functionality from ConversationMessageView into this class as appropriate
+public class ConversationMessageBubbleView extends LinearLayout {
+ private int mIntrinsicWidth;
+ private int mMorphedWidth;
+ private ObjectAnimator mAnimator;
+ private boolean mShouldAnimateWidthChange;
+ private final ConversationMessageBubbleData mData;
+ private int mRunningStartWidth;
+ private ViewGroup mBubbleBackground;
+
+ public ConversationMessageBubbleView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mData = new ConversationMessageBubbleData();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mBubbleBackground = (ViewGroup) findViewById(R.id.message_text_and_info);
+ }
+
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ final int newIntrinsicWidth = getMeasuredWidth();
+ if (mIntrinsicWidth == 0 && newIntrinsicWidth != mIntrinsicWidth) {
+ if (mShouldAnimateWidthChange) {
+ kickOffMorphAnimation(mIntrinsicWidth, newIntrinsicWidth);
+ }
+ mIntrinsicWidth = newIntrinsicWidth;
+ }
+
+ if (mMorphedWidth > 0) {
+ mBubbleBackground.getLayoutParams().width = mMorphedWidth;
+ } else {
+ mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT;
+ }
+ mBubbleBackground.requestLayout();
+ }
+
+ @VisibleForAnimation
+ public void setMorphWidth(final int width) {
+ mMorphedWidth = width;
+ requestLayout();
+ }
+
+ public void bind(final ConversationMessageData data) {
+ final boolean changed = mData.bind(data);
+ // Animate width change only when we are binding to the same message, so that we may
+ // animate view size changes on the same message bubble due to things like status text
+ // change.
+ // Don't animate width change when the bubble contains attachments. Width animation is
+ // only suitable for text-only messages (where the bubble size change due to status or
+ // time stamp changes).
+ mShouldAnimateWidthChange = !changed && !data.hasAttachments();
+ if (mAnimator == null) {
+ mMorphedWidth = 0;
+ }
+ }
+
+ public void kickOffMorphAnimation(final int oldWidth, final int newWidth) {
+ if (mAnimator != null) {
+ mAnimator.setIntValues(mRunningStartWidth, newWidth);
+ return;
+ }
+ mRunningStartWidth = oldWidth;
+ mAnimator = ObjectAnimator.ofInt(this, "morphWidth", oldWidth, newWidth);
+ mAnimator.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION);
+ mAnimator.addListener(new AnimatorListener() {
+ @Override
+ public void onAnimationStart(Animator animator) {
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animator) {
+ mAnimator = null;
+ mMorphedWidth = 0;
+ // Allow the bubble to resize if, for example, the status text changed during
+ // the animation. This will snap to the bigger size if needed. This is intentional
+ // as animating immediately after looks really bad and switching layout params
+ // during the original animation does not achieve the desired effect.
+ mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT;
+ mBubbleBackground.requestLayout();
+ }
+
+ @Override
+ public void onAnimationCancel(Animator animator) {
+ }
+
+ @Override
+ public void onAnimationRepeat(Animator animator) {
+ }
+ });
+ mAnimator.start();
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageView.java b/src/com/android/messaging/ui/conversation/ConversationMessageView.java
new file mode 100644
index 0000000..e22e2c7
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationMessageView.java
@@ -0,0 +1,1206 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.support.annotation.Nullable;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.text.format.Formatter;
+import android.text.style.URLSpan;
+import android.text.util.Linkify;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+import android.widget.ImageView.ScaleType;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.datamodel.media.ImageRequestDescriptor;
+import com.android.messaging.datamodel.media.MessagePartImageRequestDescriptor;
+import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.AsyncImageView;
+import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader;
+import com.android.messaging.ui.AudioAttachmentView;
+import com.android.messaging.ui.ContactIconView;
+import com.android.messaging.ui.ConversationDrawables;
+import com.android.messaging.ui.MultiAttachmentLayout;
+import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener;
+import com.android.messaging.ui.PersonItemView;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.VideoThumbnailView;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.UiUtils;
+import com.android.messaging.util.YouTubeUtil;
+import com.google.common.base.Predicate;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * The view for a single entry in a conversation.
+ */
+public class ConversationMessageView extends FrameLayout implements View.OnClickListener,
+ View.OnLongClickListener, OnAttachmentClickListener {
+ public interface ConversationMessageViewHost {
+ boolean onAttachmentClick(ConversationMessageView view, MessagePartData attachment,
+ Rect imageBounds, boolean longPress);
+ SubscriptionListEntry getSubscriptionEntryForSelfParticipant(String selfParticipantId,
+ boolean excludeDefault);
+ }
+
+ private final ConversationMessageData mData;
+
+ private LinearLayout mMessageAttachmentsView;
+ private MultiAttachmentLayout mMultiAttachmentView;
+ private AsyncImageView mMessageImageView;
+ private TextView mMessageTextView;
+ private boolean mMessageTextHasLinks;
+ private boolean mMessageHasYouTubeLink;
+ private TextView mStatusTextView;
+ private TextView mTitleTextView;
+ private TextView mMmsInfoTextView;
+ private LinearLayout mMessageTitleLayout;
+ private TextView mSenderNameTextView;
+ private ContactIconView mContactIconView;
+ private ConversationMessageBubbleView mMessageBubble;
+ private View mSubjectView;
+ private TextView mSubjectLabel;
+ private TextView mSubjectText;
+ private View mDeliveredBadge;
+ private ViewGroup mMessageMetadataView;
+ private ViewGroup mMessageTextAndInfoView;
+ private TextView mSimNameView;
+
+ private boolean mOneOnOne;
+ private ConversationMessageViewHost mHost;
+
+ public ConversationMessageView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ // TODO: we should switch to using Binding and DataModel factory methods.
+ mData = new ConversationMessageData();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ mContactIconView = (ContactIconView) findViewById(R.id.conversation_icon);
+ mContactIconView.setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(final View view) {
+ ConversationMessageView.this.performLongClick();
+ return true;
+ }
+ });
+
+ mMessageAttachmentsView = (LinearLayout) findViewById(R.id.message_attachments);
+ mMultiAttachmentView = (MultiAttachmentLayout) findViewById(R.id.multiple_attachments);
+ mMultiAttachmentView.setOnAttachmentClickListener(this);
+
+ mMessageImageView = (AsyncImageView) findViewById(R.id.message_image);
+ mMessageImageView.setOnClickListener(this);
+ mMessageImageView.setOnLongClickListener(this);
+
+ mMessageTextView = (TextView) findViewById(R.id.message_text);
+ mMessageTextView.setOnClickListener(this);
+ IgnoreLinkLongClickHelper.ignoreLinkLongClick(mMessageTextView, this);
+
+ mStatusTextView = (TextView) findViewById(R.id.message_status);
+ mTitleTextView = (TextView) findViewById(R.id.message_title);
+ mMmsInfoTextView = (TextView) findViewById(R.id.mms_info);
+ mMessageTitleLayout = (LinearLayout) findViewById(R.id.message_title_layout);
+ mSenderNameTextView = (TextView) findViewById(R.id.message_sender_name);
+ mMessageBubble = (ConversationMessageBubbleView) findViewById(R.id.message_content);
+ mSubjectView = findViewById(R.id.subject_container);
+ mSubjectLabel = (TextView) mSubjectView.findViewById(R.id.subject_label);
+ mSubjectText = (TextView) mSubjectView.findViewById(R.id.subject_text);
+ mDeliveredBadge = findViewById(R.id.smsDeliveredBadge);
+ mMessageMetadataView = (ViewGroup) findViewById(R.id.message_metadata);
+ mMessageTextAndInfoView = (ViewGroup) findViewById(R.id.message_text_and_info);
+ mSimNameView = (TextView) findViewById(R.id.sim_name);
+ }
+
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ final int horizontalSpace = MeasureSpec.getSize(widthMeasureSpec);
+ final int iconSize = getResources()
+ .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size);
+
+ final int unspecifiedMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ final int iconMeasureSpec = MeasureSpec.makeMeasureSpec(iconSize, MeasureSpec.EXACTLY);
+
+ mContactIconView.measure(iconMeasureSpec, iconMeasureSpec);
+
+ final int arrowWidth =
+ getResources().getDimensionPixelSize(R.dimen.message_bubble_arrow_width);
+
+ // We need to subtract contact icon width twice from the horizontal space to get
+ // the max leftover space because we want the message bubble to extend no further than the
+ // starting position of the message bubble in the opposite direction.
+ final int maxLeftoverSpace = horizontalSpace - mContactIconView.getMeasuredWidth() * 2
+ - arrowWidth - getPaddingLeft() - getPaddingRight();
+ final int messageContentWidthMeasureSpec = MeasureSpec.makeMeasureSpec(maxLeftoverSpace,
+ MeasureSpec.AT_MOST);
+
+ mMessageBubble.measure(messageContentWidthMeasureSpec, unspecifiedMeasureSpec);
+
+ final int maxHeight = Math.max(mContactIconView.getMeasuredHeight(),
+ mMessageBubble.getMeasuredHeight());
+ setMeasuredDimension(horizontalSpace, maxHeight + getPaddingBottom() + getPaddingTop());
+ }
+
+ @Override
+ protected void onLayout(final boolean changed, final int left, final int top, final int right,
+ final int bottom) {
+ final boolean isRtl = AccessibilityUtil.isLayoutRtl(this);
+
+ final int iconWidth = mContactIconView.getMeasuredWidth();
+ final int iconHeight = mContactIconView.getMeasuredHeight();
+ final int iconTop = getPaddingTop();
+ final int contentWidth = (right -left) - iconWidth - getPaddingLeft() - getPaddingRight();
+ final int contentHeight = mMessageBubble.getMeasuredHeight();
+ final int contentTop = iconTop;
+
+ final int iconLeft;
+ final int contentLeft;
+ if (mData.getIsIncoming()) {
+ if (isRtl) {
+ iconLeft = (right - left) - getPaddingRight() - iconWidth;
+ contentLeft = iconLeft - contentWidth;
+ } else {
+ iconLeft = getPaddingLeft();
+ contentLeft = iconLeft + iconWidth;
+ }
+ } else {
+ if (isRtl) {
+ iconLeft = getPaddingLeft();
+ contentLeft = iconLeft + iconWidth;
+ } else {
+ iconLeft = (right - left) - getPaddingRight() - iconWidth;
+ contentLeft = iconLeft - contentWidth;
+ }
+ }
+
+ mContactIconView.layout(iconLeft, iconTop, iconLeft + iconWidth, iconTop + iconHeight);
+
+ mMessageBubble.layout(contentLeft, contentTop, contentLeft + contentWidth,
+ contentTop + contentHeight);
+ }
+
+ /**
+ * Fills in the data associated with this view.
+ *
+ * @param cursor The cursor from a MessageList that this view is in, pointing to its entry.
+ */
+ public void bind(final Cursor cursor) {
+ bind(cursor, true, null);
+ }
+
+ /**
+ * Fills in the data associated with this view.
+ *
+ * @param cursor The cursor from a MessageList that this view is in, pointing to its entry.
+ * @param oneOnOne Whether this is a 1:1 conversation
+ */
+ public void bind(final Cursor cursor,
+ final boolean oneOnOne, final String selectedMessageId) {
+ mOneOnOne = oneOnOne;
+
+ // Update our UI model
+ mData.bind(cursor);
+ setSelected(TextUtils.equals(mData.getMessageId(), selectedMessageId));
+
+ // Update text and image content for the view.
+ updateViewContent();
+
+ // Update colors and layout parameters for the view.
+ updateViewAppearance();
+
+ updateContentDescription();
+ }
+
+ public void setHost(final ConversationMessageViewHost host) {
+ mHost = host;
+ }
+
+ /**
+ * Sets a delay loader instance to manage loading / resuming of image attachments.
+ */
+ public void setImageViewDelayLoader(final AsyncImageViewDelayLoader delayLoader) {
+ Assert.notNull(mMessageImageView);
+ mMessageImageView.setDelayLoader(delayLoader);
+ mMultiAttachmentView.setImageViewDelayLoader(delayLoader);
+ }
+
+ public ConversationMessageData getData() {
+ return mData;
+ }
+
+ /**
+ * Returns whether we should show simplified visual style for the message view (i.e. hide the
+ * avatar and bubble arrow, reduce padding).
+ */
+ private boolean shouldShowSimplifiedVisualStyle() {
+ return mData.getCanClusterWithPreviousMessage();
+ }
+
+ /**
+ * Returns whether we need to show message bubble arrow. We don't show arrow if the message
+ * contains media attachments or if shouldShowSimplifiedVisualStyle() is true.
+ */
+ private boolean shouldShowMessageBubbleArrow() {
+ return !shouldShowSimplifiedVisualStyle()
+ && !(mData.hasAttachments() || mMessageHasYouTubeLink);
+ }
+
+ /**
+ * Returns whether we need to show a message bubble for text content.
+ */
+ private boolean shouldShowMessageTextBubble() {
+ if (mData.hasText()) {
+ return true;
+ }
+ final String subjectText = MmsUtils.cleanseMmsSubject(getResources(),
+ mData.getMmsSubject());
+ if (!TextUtils.isEmpty(subjectText)) {
+ return true;
+ }
+ return false;
+ }
+
+ private void updateViewContent() {
+ updateMessageContent();
+ int titleResId = -1;
+ int statusResId = -1;
+ String statusText = null;
+ switch(mData.getStatus()) {
+ case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING:
+ case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING:
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
+ titleResId = R.string.message_title_downloading;
+ statusResId = R.string.message_status_downloading;
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD:
+ if (!OsUtil.isSecondaryUser()) {
+ titleResId = R.string.message_title_manual_download;
+ if (isSelected()) {
+ statusResId = R.string.message_status_download_action;
+ } else {
+ statusResId = R.string.message_status_download;
+ }
+ }
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE:
+ if (!OsUtil.isSecondaryUser()) {
+ titleResId = R.string.message_title_download_failed;
+ statusResId = R.string.message_status_download_error;
+ }
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED:
+ if (!OsUtil.isSecondaryUser()) {
+ titleResId = R.string.message_title_download_failed;
+ if (isSelected()) {
+ statusResId = R.string.message_status_download_action;
+ } else {
+ statusResId = R.string.message_status_download;
+ }
+ }
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND:
+ case MessageData.BUGLE_STATUS_OUTGOING_SENDING:
+ statusResId = R.string.message_status_sending;
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
+ case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
+ statusResId = R.string.message_status_send_retrying;
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
+ statusResId = R.string.message_status_send_failed_emergency_number;
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED:
+ // don't show the error state unless we're the default sms app
+ if (PhoneUtils.getDefault().isDefaultSmsApp()) {
+ if (isSelected()) {
+ statusResId = R.string.message_status_resend;
+ } else {
+ statusResId = MmsUtils.mapRawStatusToErrorResourceId(
+ mData.getStatus(), mData.getRawTelephonyStatus());
+ }
+ break;
+ }
+ // FALL THROUGH HERE
+
+ case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE:
+ case MessageData.BUGLE_STATUS_INCOMING_COMPLETE:
+ default:
+ if (!mData.getCanClusterWithNextMessage()) {
+ statusText = mData.getFormattedReceivedTimeStamp();
+ }
+ break;
+ }
+
+ final boolean titleVisible = (titleResId >= 0);
+ if (titleVisible) {
+ final String titleText = getResources().getString(titleResId);
+ mTitleTextView.setText(titleText);
+
+ final String mmsInfoText = getResources().getString(
+ R.string.mms_info,
+ Formatter.formatFileSize(getContext(), mData.getSmsMessageSize()),
+ DateUtils.formatDateTime(
+ getContext(),
+ mData.getMmsExpiry(),
+ DateUtils.FORMAT_SHOW_DATE |
+ DateUtils.FORMAT_SHOW_TIME |
+ DateUtils.FORMAT_NUMERIC_DATE |
+ DateUtils.FORMAT_NO_YEAR));
+ mMmsInfoTextView.setText(mmsInfoText);
+ mMessageTitleLayout.setVisibility(View.VISIBLE);
+ } else {
+ mMessageTitleLayout.setVisibility(View.GONE);
+ }
+
+ final String subjectText = MmsUtils.cleanseMmsSubject(getResources(),
+ mData.getMmsSubject());
+ final boolean subjectVisible = !TextUtils.isEmpty(subjectText);
+
+ final boolean senderNameVisible = !mOneOnOne && !mData.getCanClusterWithNextMessage()
+ && mData.getIsIncoming();
+ if (senderNameVisible) {
+ mSenderNameTextView.setText(mData.getSenderDisplayName());
+ mSenderNameTextView.setVisibility(View.VISIBLE);
+ } else {
+ mSenderNameTextView.setVisibility(View.GONE);
+ }
+
+ if (statusResId >= 0) {
+ statusText = getResources().getString(statusResId);
+ }
+
+ // We set the text even if the view will be GONE for accessibility
+ mStatusTextView.setText(statusText);
+ final boolean statusVisible = !TextUtils.isEmpty(statusText);
+ if (statusVisible) {
+ mStatusTextView.setVisibility(View.VISIBLE);
+ } else {
+ mStatusTextView.setVisibility(View.GONE);
+ }
+
+ final boolean deliveredBadgeVisible =
+ mData.getStatus() == MessageData.BUGLE_STATUS_OUTGOING_DELIVERED;
+ mDeliveredBadge.setVisibility(deliveredBadgeVisible ? View.VISIBLE : View.GONE);
+
+ // Update the sim indicator.
+ final boolean showSimIconAsIncoming = mData.getIsIncoming() &&
+ (!mData.hasAttachments() || shouldShowMessageTextBubble());
+ final SubscriptionListEntry subscriptionEntry =
+ mHost.getSubscriptionEntryForSelfParticipant(mData.getSelfParticipantId(),
+ true /* excludeDefault */);
+ final boolean simNameVisible = subscriptionEntry != null &&
+ !TextUtils.isEmpty(subscriptionEntry.displayName) &&
+ !mData.getCanClusterWithNextMessage();
+ if (simNameVisible) {
+ final String simNameText = mData.getIsIncoming() ? getResources().getString(
+ R.string.incoming_sim_name_text, subscriptionEntry.displayName) :
+ subscriptionEntry.displayName;
+ mSimNameView.setText(simNameText);
+ mSimNameView.setTextColor(showSimIconAsIncoming ? getResources().getColor(
+ R.color.timestamp_text_incoming) : subscriptionEntry.displayColor);
+ mSimNameView.setVisibility(VISIBLE);
+ } else {
+ mSimNameView.setText(null);
+ mSimNameView.setVisibility(GONE);
+ }
+
+ final boolean metadataVisible = senderNameVisible || statusVisible
+ || deliveredBadgeVisible || simNameVisible;
+ mMessageMetadataView.setVisibility(metadataVisible ? View.VISIBLE : View.GONE);
+
+ final boolean messageTextAndOrInfoVisible = titleVisible || subjectVisible
+ || mData.hasText() || metadataVisible;
+ mMessageTextAndInfoView.setVisibility(
+ messageTextAndOrInfoVisible ? View.VISIBLE : View.GONE);
+
+ if (shouldShowSimplifiedVisualStyle()) {
+ mContactIconView.setVisibility(View.GONE);
+ mContactIconView.setImageResourceUri(null);
+ } else {
+ mContactIconView.setVisibility(View.VISIBLE);
+ final Uri avatarUri = AvatarUriUtil.createAvatarUri(
+ mData.getSenderProfilePhotoUri(),
+ mData.getSenderFullName(),
+ mData.getSenderNormalizedDestination(),
+ mData.getSenderContactLookupKey());
+ mContactIconView.setImageResourceUri(avatarUri, mData.getSenderContactId(),
+ mData.getSenderContactLookupKey(), mData.getSenderNormalizedDestination());
+ }
+ }
+
+ private void updateMessageContent() {
+ // We must update the text before the attachments since we search the text to see if we
+ // should make a preview youtube image in the attachments
+ updateMessageText();
+ updateMessageAttachments();
+ updateMessageSubject();
+ mMessageBubble.bind(mData);
+ }
+
+ private void updateMessageAttachments() {
+ // Bind video, audio, and VCard attachments. If there are multiple, they stack vertically.
+ bindAttachmentsOfSameType(sVideoFilter,
+ R.layout.message_video_attachment, mVideoViewBinder, VideoThumbnailView.class);
+ bindAttachmentsOfSameType(sAudioFilter,
+ R.layout.message_audio_attachment, mAudioViewBinder, AudioAttachmentView.class);
+ bindAttachmentsOfSameType(sVCardFilter,
+ R.layout.message_vcard_attachment, mVCardViewBinder, PersonItemView.class);
+
+ // Bind image attachments. If there are multiple, they are shown in a collage view.
+ final List<MessagePartData> imageParts = mData.getAttachments(sImageFilter);
+ if (imageParts.size() > 1) {
+ Collections.sort(imageParts, sImageComparator);
+ mMultiAttachmentView.bindAttachments(imageParts, null, imageParts.size());
+ mMultiAttachmentView.setVisibility(View.VISIBLE);
+ } else {
+ mMultiAttachmentView.setVisibility(View.GONE);
+ }
+
+ // In the case that we have no image attachments and exactly one youtube link in a message
+ // then we will show a preview.
+ String youtubeThumbnailUrl = null;
+ String originalYoutubeLink = null;
+ if (mMessageTextHasLinks && imageParts.size() == 0) {
+ CharSequence messageTextWithSpans = mMessageTextView.getText();
+ final URLSpan[] spans = ((Spanned) messageTextWithSpans).getSpans(0,
+ messageTextWithSpans.length(), URLSpan.class);
+ for (URLSpan span : spans) {
+ String url = span.getURL();
+ String youtubeLinkForUrl = YouTubeUtil.getYoutubePreviewImageLink(url);
+ if (!TextUtils.isEmpty(youtubeLinkForUrl)) {
+ if (TextUtils.isEmpty(youtubeThumbnailUrl)) {
+ // Save the youtube link if we don't already have one
+ youtubeThumbnailUrl = youtubeLinkForUrl;
+ originalYoutubeLink = url;
+ } else {
+ // We already have a youtube link. This means we have two youtube links so
+ // we shall show none.
+ youtubeThumbnailUrl = null;
+ originalYoutubeLink = null;
+ break;
+ }
+ }
+ }
+ }
+ // We need to keep track if we have a youtube link in the message so that we will not show
+ // the arrow
+ mMessageHasYouTubeLink = !TextUtils.isEmpty(youtubeThumbnailUrl);
+
+ // We will show the message image view if there is one attachment or one youtube link
+ if (imageParts.size() == 1 || mMessageHasYouTubeLink) {
+ // Get the display metrics for a hint for how large to pull the image data into
+ final WindowManager windowManager = (WindowManager) getContext().
+ getSystemService(Context.WINDOW_SERVICE);
+ final DisplayMetrics displayMetrics = new DisplayMetrics();
+ windowManager.getDefaultDisplay().getMetrics(displayMetrics);
+
+ final int iconSize = getResources()
+ .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size);
+ final int desiredWidth = displayMetrics.widthPixels - iconSize - iconSize;
+
+ if (imageParts.size() == 1) {
+ final MessagePartData imagePart = imageParts.get(0);
+ // If the image is big, we want to scale it down to save memory since we're going to
+ // scale it down to fit into the bubble width. We don't constrain the height.
+ final ImageRequestDescriptor imageRequest =
+ new MessagePartImageRequestDescriptor(imagePart,
+ desiredWidth,
+ MessagePartData.UNSPECIFIED_SIZE,
+ false);
+ adjustImageViewBounds(imagePart);
+ mMessageImageView.setImageResourceId(imageRequest);
+ mMessageImageView.setTag(imagePart);
+ } else {
+ // Youtube Thumbnail image
+ final ImageRequestDescriptor imageRequest =
+ new UriImageRequestDescriptor(Uri.parse(youtubeThumbnailUrl), desiredWidth,
+ MessagePartData.UNSPECIFIED_SIZE, true /* allowCompression */,
+ true /* isStatic */, false /* cropToCircle */,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ mMessageImageView.setImageResourceId(imageRequest);
+ mMessageImageView.setTag(originalYoutubeLink);
+ }
+ mMessageImageView.setVisibility(View.VISIBLE);
+ } else {
+ mMessageImageView.setImageResourceId(null);
+ mMessageImageView.setVisibility(View.GONE);
+ }
+
+ // Show the message attachments container if any of its children are visible
+ boolean attachmentsVisible = false;
+ for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) {
+ final View attachmentView = mMessageAttachmentsView.getChildAt(i);
+ if (attachmentView.getVisibility() == View.VISIBLE) {
+ attachmentsVisible = true;
+ break;
+ }
+ }
+ mMessageAttachmentsView.setVisibility(attachmentsVisible ? View.VISIBLE : View.GONE);
+ }
+
+ private void bindAttachmentsOfSameType(final Predicate<MessagePartData> attachmentTypeFilter,
+ final int attachmentViewLayoutRes, final AttachmentViewBinder viewBinder,
+ final Class<?> attachmentViewClass) {
+ final LayoutInflater layoutInflater = LayoutInflater.from(getContext());
+
+ // Iterate through all attachments of a particular type (video, audio, etc).
+ // Find the first attachment index that matches the given type if possible.
+ int attachmentViewIndex = -1;
+ View existingAttachmentView;
+ do {
+ existingAttachmentView = mMessageAttachmentsView.getChildAt(++attachmentViewIndex);
+ } while (existingAttachmentView != null &&
+ !(attachmentViewClass.isInstance(existingAttachmentView)));
+
+ for (final MessagePartData attachment : mData.getAttachments(attachmentTypeFilter)) {
+ View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex);
+ if (!attachmentViewClass.isInstance(attachmentView)) {
+ attachmentView = layoutInflater.inflate(attachmentViewLayoutRes,
+ mMessageAttachmentsView, false /* attachToRoot */);
+ attachmentView.setOnClickListener(this);
+ attachmentView.setOnLongClickListener(this);
+ mMessageAttachmentsView.addView(attachmentView, attachmentViewIndex);
+ }
+ viewBinder.bindView(attachmentView, attachment);
+ attachmentView.setTag(attachment);
+ attachmentView.setVisibility(View.VISIBLE);
+ attachmentViewIndex++;
+ }
+ // If there are unused views left over, unbind or remove them.
+ while (attachmentViewIndex < mMessageAttachmentsView.getChildCount()) {
+ final View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex);
+ if (attachmentViewClass.isInstance(attachmentView)) {
+ mMessageAttachmentsView.removeViewAt(attachmentViewIndex);
+ } else {
+ // No more views of this type; we're done.
+ break;
+ }
+ }
+ }
+
+ private void updateMessageSubject() {
+ final String subjectText = MmsUtils.cleanseMmsSubject(getResources(),
+ mData.getMmsSubject());
+ final boolean subjectVisible = !TextUtils.isEmpty(subjectText);
+
+ if (subjectVisible) {
+ mSubjectText.setText(subjectText);
+ mSubjectView.setVisibility(View.VISIBLE);
+ } else {
+ mSubjectView.setVisibility(View.GONE);
+ }
+ }
+
+ private void updateMessageText() {
+ final String text = mData.getText();
+ if (!TextUtils.isEmpty(text)) {
+ mMessageTextView.setText(text);
+ // Linkify phone numbers, web urls, emails, and map addresses to allow users to
+ // click on them and take the default intent.
+ mMessageTextHasLinks = Linkify.addLinks(mMessageTextView, Linkify.ALL);
+ mMessageTextView.setVisibility(View.VISIBLE);
+ } else {
+ mMessageTextView.setVisibility(View.GONE);
+ mMessageTextHasLinks = false;
+ }
+ }
+
+ private void updateViewAppearance() {
+ final Resources res = getResources();
+ final ConversationDrawables drawableProvider = ConversationDrawables.get();
+ final boolean incoming = mData.getIsIncoming();
+ final boolean outgoing = !incoming;
+ final boolean showArrow = shouldShowMessageBubbleArrow();
+
+ final int messageTopPaddingClustered =
+ res.getDimensionPixelSize(R.dimen.message_padding_same_author);
+ final int messageTopPaddingDefault =
+ res.getDimensionPixelSize(R.dimen.message_padding_default);
+ final int arrowWidth = res.getDimensionPixelOffset(R.dimen.message_bubble_arrow_width);
+ final int messageTextMinHeightDefault = res.getDimensionPixelSize(
+ R.dimen.conversation_message_contact_icon_size);
+ final int messageTextLeftRightPadding = res.getDimensionPixelOffset(
+ R.dimen.message_text_left_right_padding);
+ final int textTopPaddingDefault = res.getDimensionPixelOffset(
+ R.dimen.message_text_top_padding);
+ final int textBottomPaddingDefault = res.getDimensionPixelOffset(
+ R.dimen.message_text_bottom_padding);
+
+ // These values depend on whether the message has text, attachments, or both.
+ // We intentionally don't set defaults, so the compiler will tell us if we forget
+ // to set one of them, or if we set one more than once.
+ final int contentLeftPadding, contentRightPadding;
+ final Drawable textBackground;
+ final int textMinHeight;
+ final int textTopMargin;
+ final int textTopPadding, textBottomPadding;
+ final int textLeftPadding, textRightPadding;
+
+ if (mData.hasAttachments()) {
+ if (shouldShowMessageTextBubble()) {
+ // Text and attachment(s)
+ contentLeftPadding = incoming ? arrowWidth : 0;
+ contentRightPadding = outgoing ? arrowWidth : 0;
+ textBackground = drawableProvider.getBubbleDrawable(
+ isSelected(),
+ incoming,
+ false /* needArrow */,
+ mData.hasIncomingErrorStatus());
+ textMinHeight = messageTextMinHeightDefault;
+ textTopMargin = messageTopPaddingClustered;
+ textTopPadding = textTopPaddingDefault;
+ textBottomPadding = textBottomPaddingDefault;
+ textLeftPadding = messageTextLeftRightPadding;
+ textRightPadding = messageTextLeftRightPadding;
+ } else {
+ // Attachment(s) only
+ contentLeftPadding = incoming ? arrowWidth : 0;
+ contentRightPadding = outgoing ? arrowWidth : 0;
+ textBackground = null;
+ textMinHeight = 0;
+ textTopMargin = 0;
+ textTopPadding = 0;
+ textBottomPadding = 0;
+ textLeftPadding = 0;
+ textRightPadding = 0;
+ }
+ } else {
+ // Text only
+ contentLeftPadding = (!showArrow && incoming) ? arrowWidth : 0;
+ contentRightPadding = (!showArrow && outgoing) ? arrowWidth : 0;
+ textBackground = drawableProvider.getBubbleDrawable(
+ isSelected(),
+ incoming,
+ shouldShowMessageBubbleArrow(),
+ mData.hasIncomingErrorStatus());
+ textMinHeight = messageTextMinHeightDefault;
+ textTopMargin = 0;
+ textTopPadding = textTopPaddingDefault;
+ textBottomPadding = textBottomPaddingDefault;
+ if (showArrow && incoming) {
+ textLeftPadding = messageTextLeftRightPadding + arrowWidth;
+ } else {
+ textLeftPadding = messageTextLeftRightPadding;
+ }
+ if (showArrow && outgoing) {
+ textRightPadding = messageTextLeftRightPadding + arrowWidth;
+ } else {
+ textRightPadding = messageTextLeftRightPadding;
+ }
+ }
+
+ // These values do not depend on whether the message includes attachments
+ final int gravity = incoming ? (Gravity.START | Gravity.CENTER_VERTICAL) :
+ (Gravity.END | Gravity.CENTER_VERTICAL);
+ final int messageTopPadding = shouldShowSimplifiedVisualStyle() ?
+ messageTopPaddingClustered : messageTopPaddingDefault;
+ final int metadataTopPadding = res.getDimensionPixelOffset(
+ R.dimen.message_metadata_top_padding);
+
+ // Update the message text/info views
+ ImageUtils.setBackgroundDrawableOnView(mMessageTextAndInfoView, textBackground);
+ mMessageTextAndInfoView.setMinimumHeight(textMinHeight);
+ final LinearLayout.LayoutParams textAndInfoLayoutParams =
+ (LinearLayout.LayoutParams) mMessageTextAndInfoView.getLayoutParams();
+ textAndInfoLayoutParams.topMargin = textTopMargin;
+
+ if (UiUtils.isRtlMode()) {
+ // Need to switch right and left padding in RtL mode
+ mMessageTextAndInfoView.setPadding(textRightPadding, textTopPadding, textLeftPadding,
+ textBottomPadding);
+ mMessageBubble.setPadding(contentRightPadding, 0, contentLeftPadding, 0);
+ } else {
+ mMessageTextAndInfoView.setPadding(textLeftPadding, textTopPadding, textRightPadding,
+ textBottomPadding);
+ mMessageBubble.setPadding(contentLeftPadding, 0, contentRightPadding, 0);
+ }
+
+ // Update the message row and message bubble views
+ setPadding(getPaddingLeft(), messageTopPadding, getPaddingRight(), 0);
+ mMessageBubble.setGravity(gravity);
+ updateMessageAttachmentsAppearance(gravity);
+
+ mMessageMetadataView.setPadding(0, metadataTopPadding, 0, 0);
+
+ updateTextAppearance();
+
+ requestLayout();
+ }
+
+ private void updateContentDescription() {
+ StringBuilder description = new StringBuilder();
+
+ Resources res = getResources();
+ String separator = res.getString(R.string.enumeration_comma);
+
+ // Sender information
+ boolean hasPlainTextMessage = !(TextUtils.isEmpty(mData.getText()) ||
+ mMessageTextHasLinks);
+ if (mData.getIsIncoming()) {
+ int senderResId = hasPlainTextMessage
+ ? R.string.incoming_text_sender_content_description
+ : R.string.incoming_sender_content_description;
+ description.append(res.getString(senderResId, mData.getSenderDisplayName()));
+ } else {
+ int senderResId = hasPlainTextMessage
+ ? R.string.outgoing_text_sender_content_description
+ : R.string.outgoing_sender_content_description;
+ description.append(res.getString(senderResId));
+ }
+
+ if (mSubjectView.getVisibility() == View.VISIBLE) {
+ description.append(separator);
+ description.append(mSubjectText.getText());
+ }
+
+ if (mMessageTextView.getVisibility() == View.VISIBLE) {
+ // If the message has hyperlinks, we will let the user navigate to the text message so
+ // that the hyperlink can be clicked. Otherwise, the text message does not need to
+ // be reachable.
+ if (mMessageTextHasLinks) {
+ mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ } else {
+ mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
+ description.append(separator);
+ description.append(mMessageTextView.getText());
+ }
+ }
+
+ if (mMessageTitleLayout.getVisibility() == View.VISIBLE) {
+ description.append(separator);
+ description.append(mTitleTextView.getText());
+
+ description.append(separator);
+ description.append(mMmsInfoTextView.getText());
+ }
+
+ if (mStatusTextView.getVisibility() == View.VISIBLE) {
+ description.append(separator);
+ description.append(mStatusTextView.getText());
+ }
+
+ if (mSimNameView.getVisibility() == View.VISIBLE) {
+ description.append(separator);
+ description.append(mSimNameView.getText());
+ }
+
+ if (mDeliveredBadge.getVisibility() == View.VISIBLE) {
+ description.append(separator);
+ description.append(res.getString(R.string.delivered_status_content_description));
+ }
+
+ setContentDescription(description);
+ }
+
+ private void updateMessageAttachmentsAppearance(final int gravity) {
+ mMessageAttachmentsView.setGravity(gravity);
+
+ // Tint image/video attachments when selected
+ final int selectedImageTint = getResources().getColor(R.color.message_image_selected_tint);
+ if (mMessageImageView.getVisibility() == View.VISIBLE) {
+ if (isSelected()) {
+ mMessageImageView.setColorFilter(selectedImageTint);
+ } else {
+ mMessageImageView.clearColorFilter();
+ }
+ }
+ if (mMultiAttachmentView.getVisibility() == View.VISIBLE) {
+ if (isSelected()) {
+ mMultiAttachmentView.setColorFilter(selectedImageTint);
+ } else {
+ mMultiAttachmentView.clearColorFilter();
+ }
+ }
+ for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) {
+ final View attachmentView = mMessageAttachmentsView.getChildAt(i);
+ if (attachmentView instanceof VideoThumbnailView
+ && attachmentView.getVisibility() == View.VISIBLE) {
+ final VideoThumbnailView videoView = (VideoThumbnailView) attachmentView;
+ if (isSelected()) {
+ videoView.setColorFilter(selectedImageTint);
+ } else {
+ videoView.clearColorFilter();
+ }
+ }
+ }
+
+ // If there are multiple attachment bubbles in a single message, add some separation.
+ final int multipleAttachmentPadding =
+ getResources().getDimensionPixelSize(R.dimen.message_padding_same_author);
+
+ boolean previousVisibleView = false;
+ for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) {
+ final View attachmentView = mMessageAttachmentsView.getChildAt(i);
+ if (attachmentView.getVisibility() == View.VISIBLE) {
+ final int margin = previousVisibleView ? multipleAttachmentPadding : 0;
+ ((LinearLayout.LayoutParams) attachmentView.getLayoutParams()).topMargin = margin;
+ // updateViewAppearance calls requestLayout() at the end, so we don't need to here
+ previousVisibleView = true;
+ }
+ }
+ }
+
+ private void updateTextAppearance() {
+ int messageColorResId;
+ int statusColorResId = -1;
+ int infoColorResId = -1;
+ int timestampColorResId;
+ int subjectLabelColorResId;
+ if (isSelected()) {
+ messageColorResId = R.color.message_text_color_incoming;
+ statusColorResId = R.color.message_action_status_text;
+ infoColorResId = R.color.message_action_info_text;
+ if (shouldShowMessageTextBubble()) {
+ timestampColorResId = R.color.message_action_timestamp_text;
+ subjectLabelColorResId = R.color.message_action_timestamp_text;
+ } else {
+ // If there's no text, the timestamp will be shown below the attachments,
+ // against the conversation view background.
+ timestampColorResId = R.color.timestamp_text_outgoing;
+ subjectLabelColorResId = R.color.timestamp_text_outgoing;
+ }
+ } else {
+ messageColorResId = (mData.getIsIncoming() ?
+ R.color.message_text_color_incoming : R.color.message_text_color_outgoing);
+ statusColorResId = messageColorResId;
+ infoColorResId = R.color.timestamp_text_incoming;
+ switch(mData.getStatus()) {
+
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED:
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
+ timestampColorResId = R.color.message_failed_timestamp_text;
+ subjectLabelColorResId = R.color.timestamp_text_outgoing;
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND:
+ case MessageData.BUGLE_STATUS_OUTGOING_SENDING:
+ case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
+ case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
+ case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE:
+ case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED:
+ timestampColorResId = R.color.timestamp_text_outgoing;
+ subjectLabelColorResId = R.color.timestamp_text_outgoing;
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE:
+ case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED:
+ messageColorResId = R.color.message_text_color_incoming_download_failed;
+ timestampColorResId = R.color.message_download_failed_timestamp_text;
+ subjectLabelColorResId = R.color.message_text_color_incoming_download_failed;
+ statusColorResId = R.color.message_download_failed_status_text;
+ infoColorResId = R.color.message_info_text_incoming_download_failed;
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING:
+ case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING:
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
+ case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD:
+ timestampColorResId = R.color.message_text_color_incoming;
+ subjectLabelColorResId = R.color.message_text_color_incoming;
+ infoColorResId = R.color.timestamp_text_incoming;
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_COMPLETE:
+ default:
+ timestampColorResId = R.color.timestamp_text_incoming;
+ subjectLabelColorResId = R.color.timestamp_text_incoming;
+ infoColorResId = -1; // Not used
+ break;
+ }
+ }
+ final int messageColor = getResources().getColor(messageColorResId);
+ mMessageTextView.setTextColor(messageColor);
+ mMessageTextView.setLinkTextColor(messageColor);
+ mSubjectText.setTextColor(messageColor);
+ if (statusColorResId >= 0) {
+ mTitleTextView.setTextColor(getResources().getColor(statusColorResId));
+ }
+ if (infoColorResId >= 0) {
+ mMmsInfoTextView.setTextColor(getResources().getColor(infoColorResId));
+ }
+ if (timestampColorResId == R.color.timestamp_text_incoming &&
+ mData.hasAttachments() && !shouldShowMessageTextBubble()) {
+ timestampColorResId = R.color.timestamp_text_outgoing;
+ }
+ mStatusTextView.setTextColor(getResources().getColor(timestampColorResId));
+
+ mSubjectLabel.setTextColor(getResources().getColor(subjectLabelColorResId));
+ mSenderNameTextView.setTextColor(getResources().getColor(timestampColorResId));
+ }
+
+ /**
+ * If we don't know the size of the image, we want to show it in a fixed-sized frame to
+ * avoid janks when the image is loaded and resized. Otherwise, we can set the imageview to
+ * take on normal layout params.
+ */
+ private void adjustImageViewBounds(final MessagePartData imageAttachment) {
+ Assert.isTrue(ContentType.isImageType(imageAttachment.getContentType()));
+ final ViewGroup.LayoutParams layoutParams = mMessageImageView.getLayoutParams();
+ if (imageAttachment.getWidth() == MessagePartData.UNSPECIFIED_SIZE ||
+ imageAttachment.getHeight() == MessagePartData.UNSPECIFIED_SIZE) {
+ // We don't know the size of the image attachment, enable letterboxing on the image
+ // and show a fixed sized attachment. This should happen at most once per image since
+ // after the image is loaded we then save the image dimensions to the db so that the
+ // next time we can display the full size.
+ layoutParams.width = getResources()
+ .getDimensionPixelSize(R.dimen.image_attachment_fallback_width);
+ layoutParams.height = getResources()
+ .getDimensionPixelSize(R.dimen.image_attachment_fallback_height);
+ mMessageImageView.setScaleType(ScaleType.CENTER_CROP);
+ } else {
+ layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT;
+ layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
+ // ScaleType.CENTER_INSIDE and FIT_CENTER behave similarly for most images. However,
+ // FIT_CENTER works better for small images as it enlarges the image such that the
+ // minimum size ("android:minWidth" etc) is honored.
+ mMessageImageView.setScaleType(ScaleType.FIT_CENTER);
+ }
+ }
+
+ @Override
+ public void onClick(final View view) {
+ final Object tag = view.getTag();
+ if (tag instanceof MessagePartData) {
+ final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view);
+ onAttachmentClick((MessagePartData) tag, bounds, false /* longPress */);
+ } else if (tag instanceof String) {
+ // Currently the only object that would make a tag of a string is a youtube preview
+ // image
+ UIIntents.get().launchBrowserForUrl(getContext(), (String) tag);
+ }
+ }
+
+ @Override
+ public boolean onLongClick(final View view) {
+ if (view == mMessageTextView) {
+ // Preemptively handle the long click event on message text so it's not handled by
+ // the link spans.
+ return performLongClick();
+ }
+
+ final Object tag = view.getTag();
+ if (tag instanceof MessagePartData) {
+ final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view);
+ return onAttachmentClick((MessagePartData) tag, bounds, true /* longPress */);
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean onAttachmentClick(final MessagePartData attachment,
+ final Rect viewBoundsOnScreen, final boolean longPress) {
+ return mHost.onAttachmentClick(this, attachment, viewBoundsOnScreen, longPress);
+ }
+
+ public ContactIconView getContactIconView() {
+ return mContactIconView;
+ }
+
+ // Sort photos in MultiAttachLayout in the same order as the ConversationImagePartsView
+ static final Comparator<MessagePartData> sImageComparator = new Comparator<MessagePartData>(){
+ @Override
+ public int compare(final MessagePartData x, final MessagePartData y) {
+ return x.getPartId().compareTo(y.getPartId());
+ }
+ };
+
+ static final Predicate<MessagePartData> sVideoFilter = new Predicate<MessagePartData>() {
+ @Override
+ public boolean apply(final MessagePartData part) {
+ return part.isVideo();
+ }
+ };
+
+ static final Predicate<MessagePartData> sAudioFilter = new Predicate<MessagePartData>() {
+ @Override
+ public boolean apply(final MessagePartData part) {
+ return part.isAudio();
+ }
+ };
+
+ static final Predicate<MessagePartData> sVCardFilter = new Predicate<MessagePartData>() {
+ @Override
+ public boolean apply(final MessagePartData part) {
+ return part.isVCard();
+ }
+ };
+
+ static final Predicate<MessagePartData> sImageFilter = new Predicate<MessagePartData>() {
+ @Override
+ public boolean apply(final MessagePartData part) {
+ return part.isImage();
+ }
+ };
+
+ interface AttachmentViewBinder {
+ void bindView(View view, MessagePartData attachment);
+ void unbind(View view);
+ }
+
+ final AttachmentViewBinder mVideoViewBinder = new AttachmentViewBinder() {
+ @Override
+ public void bindView(final View view, final MessagePartData attachment) {
+ ((VideoThumbnailView) view).setSource(attachment, mData.getIsIncoming());
+ }
+
+ @Override
+ public void unbind(final View view) {
+ ((VideoThumbnailView) view).setSource((Uri) null, mData.getIsIncoming());
+ }
+ };
+
+ final AttachmentViewBinder mAudioViewBinder = new AttachmentViewBinder() {
+ @Override
+ public void bindView(final View view, final MessagePartData attachment) {
+ final AudioAttachmentView audioView = (AudioAttachmentView) view;
+ audioView.bindMessagePartData(attachment, isSelected() || mData.getIsIncoming());
+ audioView.setBackground(ConversationDrawables.get().getBubbleDrawable(
+ isSelected(), mData.getIsIncoming(), false /* needArrow */,
+ mData.hasIncomingErrorStatus()));
+ }
+
+ @Override
+ public void unbind(final View view) {
+ ((AudioAttachmentView) view).bindMessagePartData(null, mData.getIsIncoming());
+ }
+ };
+
+ final AttachmentViewBinder mVCardViewBinder = new AttachmentViewBinder() {
+ @Override
+ public void bindView(final View view, final MessagePartData attachment) {
+ final PersonItemView personView = (PersonItemView) view;
+ personView.bind(DataModel.get().createVCardContactItemData(getContext(),
+ attachment));
+ personView.setBackground(ConversationDrawables.get().getBubbleDrawable(
+ isSelected(), mData.getIsIncoming(), false /* needArrow */,
+ mData.hasIncomingErrorStatus()));
+ final int nameTextColorRes;
+ final int detailsTextColorRes;
+ if (isSelected()) {
+ nameTextColorRes = R.color.message_text_color_incoming;
+ detailsTextColorRes = R.color.message_text_color_incoming;
+ } else {
+ nameTextColorRes = mData.getIsIncoming() ? R.color.message_text_color_incoming
+ : R.color.message_text_color_outgoing;
+ detailsTextColorRes = mData.getIsIncoming() ? R.color.timestamp_text_incoming
+ : R.color.timestamp_text_outgoing;
+ }
+ personView.setNameTextColor(getResources().getColor(nameTextColorRes));
+ personView.setDetailsTextColor(getResources().getColor(detailsTextColorRes));
+ }
+
+ @Override
+ public void unbind(final View view) {
+ ((PersonItemView) view).bind(null);
+ }
+ };
+
+ /**
+ * A helper class that allows us to handle long clicks on linkified message text view (i.e. to
+ * select the message) so it's not handled by the link spans to launch apps for the links.
+ */
+ private static class IgnoreLinkLongClickHelper implements OnLongClickListener, OnTouchListener {
+ private boolean mIsLongClick;
+ private final OnLongClickListener mDelegateLongClickListener;
+
+ /**
+ * Ignore long clicks on linkified texts for a given text view.
+ * @param textView the TextView to ignore long clicks on
+ * @param longClickListener a delegate OnLongClickListener to be called when the view is
+ * long clicked.
+ */
+ public static void ignoreLinkLongClick(final TextView textView,
+ @Nullable final OnLongClickListener longClickListener) {
+ final IgnoreLinkLongClickHelper helper =
+ new IgnoreLinkLongClickHelper(longClickListener);
+ textView.setOnLongClickListener(helper);
+ textView.setOnTouchListener(helper);
+ }
+
+ private IgnoreLinkLongClickHelper(@Nullable final OnLongClickListener longClickListener) {
+ mDelegateLongClickListener = longClickListener;
+ }
+
+ @Override
+ public boolean onLongClick(final View v) {
+ // Record that this click is a long click.
+ mIsLongClick = true;
+ if (mDelegateLongClickListener != null) {
+ return mDelegateLongClickListener.onLongClick(v);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onTouch(final View v, final MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_UP && mIsLongClick) {
+ // This touch event is a long click, preemptively handle this touch event so that
+ // the link span won't get a onClicked() callback.
+ mIsLongClick = false;
+ return true;
+ }
+
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ mIsLongClick = false;
+ }
+ return false;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationSimSelector.java b/src/com/android/messaging/ui/conversation/ConversationSimSelector.java
new file mode 100644
index 0000000..fc43a46
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationSimSelector.java
@@ -0,0 +1,128 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.content.Context;
+import android.support.v4.util.Pair;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.SubscriptionListData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.ui.conversation.SimSelectorView.SimSelectorViewListener;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.ThreadUtil;
+
+/**
+ * Manages showing/hiding the SIM selector in conversation.
+ */
+abstract class ConversationSimSelector extends ConversationInput {
+ private SimSelectorView mSimSelectorView;
+ private Pair<Boolean /* show */, Boolean /* animate */> mPendingShow;
+ private boolean mDataReady;
+ private String mSelectedSimText;
+
+ public ConversationSimSelector(ConversationInputBase baseHost) {
+ super(baseHost, false);
+ }
+
+ public void onSubscriptionListDataLoaded(final SubscriptionListData subscriptionListData) {
+ ensureSimSelectorView();
+ mSimSelectorView.bind(subscriptionListData);
+ mDataReady = subscriptionListData != null && subscriptionListData.hasData();
+ if (mPendingShow != null && mDataReady) {
+ Assert.isTrue(OsUtil.isAtLeastL_MR1());
+ final boolean show = mPendingShow.first;
+ final boolean animate = mPendingShow.second;
+ ThreadUtil.getMainThreadHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ // This will No-Op if we are no longer attached to the host.
+ mConversationInputBase.showHideInternal(ConversationSimSelector.this,
+ show, animate);
+ }
+ });
+ mPendingShow = null;
+ }
+ }
+
+ private void announcedSelectedSim() {
+ final Context context = Factory.get().getApplicationContext();
+ if (AccessibilityUtil.isTouchExplorationEnabled(context) &&
+ !TextUtils.isEmpty(mSelectedSimText)) {
+ AccessibilityUtil.announceForAccessibilityCompat(
+ mSimSelectorView, null,
+ context.getString(R.string.selected_sim_content_message, mSelectedSimText));
+ }
+ }
+
+ public void setSelected(final SubscriptionListEntry subEntry) {
+ mSelectedSimText = subEntry == null ? null : subEntry.displayName;
+ }
+
+ @Override
+ public boolean show(boolean animate) {
+ announcedSelectedSim();
+ return showHide(true, animate);
+ }
+
+ @Override
+ public boolean hide(boolean animate) {
+ return showHide(false, animate);
+ }
+
+ private boolean showHide(final boolean show, final boolean animate) {
+ if (!OsUtil.isAtLeastL_MR1()) {
+ return false;
+ }
+
+ if (mDataReady) {
+ mSimSelectorView.showOrHide(show, animate);
+ return mSimSelectorView.isOpen() == show;
+ } else {
+ mPendingShow = Pair.create(show, animate);
+ return false;
+ }
+ }
+
+ private void ensureSimSelectorView() {
+ if (mSimSelectorView == null) {
+ // Grab the SIM selector view from the host. This class assumes ownership of it.
+ mSimSelectorView = getSimSelectorView();
+ mSimSelectorView.setItemLayoutId(getSimSelectorItemLayoutId());
+ mSimSelectorView.setListener(new SimSelectorViewListener() {
+
+ @Override
+ public void onSimSelectorVisibilityChanged(boolean visible) {
+ onVisibilityChanged(visible);
+ }
+
+ @Override
+ public void onSimItemClicked(SubscriptionListEntry item) {
+ selectSim(item);
+ }
+ });
+ }
+ }
+
+ protected abstract SimSelectorView getSimSelectorView();
+ protected abstract void selectSim(final SubscriptionListEntry item);
+ protected abstract int getSimSelectorItemLayoutId();
+
+}
diff --git a/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java b/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java
new file mode 100644
index 0000000..e3ad601
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java
@@ -0,0 +1,92 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.widget.EditText;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.ParticipantRefresh;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * The dialog for the user to enter the phone number of their sim.
+ */
+public class EnterSelfPhoneNumberDialog extends DialogFragment {
+ private EditText mEditText;
+ private int mSubId;
+
+ public static EnterSelfPhoneNumberDialog newInstance(final int subId) {
+ final EnterSelfPhoneNumberDialog dialog = new EnterSelfPhoneNumberDialog();
+ dialog.mSubId = subId;
+ return dialog;
+ }
+
+ @Override
+ public Dialog onCreateDialog(final Bundle savedInstanceState) {
+ final Context context = getActivity();
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ mEditText = (EditText) inflater.inflate(R.layout.enter_phone_number_view, null, false);
+
+ final AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(R.string.enter_phone_number_title)
+ .setMessage(R.string.enter_phone_number_text)
+ .setView(mEditText)
+ .setNegativeButton(android.R.string.cancel,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int button) {
+ dismiss();
+ }
+ })
+ .setPositiveButton(android.R.string.ok,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int button) {
+ final String newNumber = mEditText.getText().toString();
+ dismiss();
+ if (!TextUtils.isEmpty(newNumber)) {
+ savePhoneNumberInPrefs(newNumber);
+ // TODO: Remove this toast and just auto-send
+ // the message instead
+ UiUtils.showToast(
+ R.string
+ .toast_after_setting_default_sms_app_for_message_send);
+ }
+ }
+ });
+ return builder.create();
+ }
+
+ private void savePhoneNumberInPrefs(final String newPhoneNumber) {
+ final BuglePrefs subPrefs = BuglePrefs.getSubscriptionPrefs(mSubId);
+ subPrefs.putString(getString(R.string.mms_phone_number_pref_key),
+ newPhoneNumber);
+ // Update the self participants so the new phone number will be reflected
+ // everywhere in the UI.
+ ParticipantRefresh.refreshSelfParticipants();
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java b/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java
new file mode 100644
index 0000000..8af9f75
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java
@@ -0,0 +1,134 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.LaunchConversationData;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.UiUtils;
+import com.android.messaging.util.UriUtil;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+
+/**
+ * Launches ConversationActivity for sending a message to, or viewing messages from, a specific
+ * recipient.
+ * <p>
+ * (This activity should be marked noHistory="true" in AndroidManifest.xml)
+ */
+public class LaunchConversationActivity extends Activity implements
+ LaunchConversationData.LaunchConversationDataListener {
+ static final String SMS_BODY = "sms_body";
+ static final String ADDRESS = "address";
+ final Binding<LaunchConversationData> mBinding = BindingBase.createBinding(this);
+ String mSmsBody;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (UiUtils.redirectToPermissionCheckIfNeeded(this)) {
+ return;
+ }
+
+ final Intent intent = getIntent();
+ final String action = intent.getAction();
+ if (Intent.ACTION_SENDTO.equals(action) || Intent.ACTION_VIEW.equals(action)) {
+ String[] recipients = UriUtil.parseRecipientsFromSmsMmsUri(intent.getData());
+ final boolean haveAddress = !TextUtils.isEmpty(intent.getStringExtra(ADDRESS));
+ final boolean haveEmail = !TextUtils.isEmpty(intent.getStringExtra(Intent.EXTRA_EMAIL));
+ if (recipients == null && (haveAddress || haveEmail)) {
+ if (haveAddress) {
+ recipients = new String[] { intent.getStringExtra(ADDRESS) };
+ } else {
+ recipients = new String[] { intent.getStringExtra(Intent.EXTRA_EMAIL) };
+ }
+ }
+ mSmsBody = intent.getStringExtra(SMS_BODY);
+ if (TextUtils.isEmpty(mSmsBody)) {
+ // Used by intents sent from the web YouTube (and perhaps others).
+ mSmsBody = getBody(intent.getData());
+ if (TextUtils.isEmpty(mSmsBody)) {
+ // If that fails, try yet another method apps use to share text
+ if (ContentType.TEXT_PLAIN.equals(intent.getType())) {
+ mSmsBody = intent.getStringExtra(Intent.EXTRA_TEXT);
+ }
+ }
+ }
+ if (recipients != null) {
+ mBinding.bind(DataModel.get().createLaunchConversationData(this));
+ mBinding.getData().getOrCreateConversation(mBinding, recipients);
+ } else {
+ // No recipients were specified in the intent.
+ // Start a new conversation with contact picker. The new conversation will be
+ // primed with the (optional) message in mSmsBody.
+ onGetOrCreateNewConversation(null);
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Unsupported conversation intent action : " + action);
+ }
+ // As of M, activities without a visible window must finish before onResume completes.
+ finish();
+ }
+
+ private String getBody(final Uri uri) {
+ if (uri == null) {
+ return null;
+ }
+ String urlStr = uri.getSchemeSpecificPart();
+ if (!urlStr.contains("?")) {
+ return null;
+ }
+ urlStr = urlStr.substring(urlStr.indexOf('?') + 1);
+ final String[] params = urlStr.split("&");
+ for (final String p : params) {
+ if (p.startsWith("body=")) {
+ try {
+ return URLDecoder.decode(p.substring(5), "UTF-8");
+ } catch (final UnsupportedEncodingException e) {
+ // Invalid URL, ignore
+ }
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public void onGetOrCreateNewConversation(final String conversationId) {
+ final Context context = Factory.get().getApplicationContext();
+ UIIntents.get().launchConversationActivityWithParentStack(context, conversationId,
+ mSmsBody);
+ }
+
+ @Override
+ public void onGetOrCreateNewConversationFailed() {
+ UiUtils.showToastAtBottom(R.string.conversation_creation_failure);
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java b/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java
new file mode 100644
index 0000000..4c22970
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java
@@ -0,0 +1,47 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+
+import com.android.messaging.R;
+
+public class MessageBubbleBackground extends LinearLayout {
+ private final int mSnapWidthPixels;
+
+ public MessageBubbleBackground(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mSnapWidthPixels = context.getResources().getDimensionPixelSize(
+ R.dimen.conversation_bubble_width_snap);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ final int widthPadding = getPaddingLeft() + getPaddingRight();
+ int bubbleWidth = getMeasuredWidth() - widthPadding;
+ final int maxWidth = MeasureSpec.getSize(widthMeasureSpec) - widthPadding;
+ // Round up to next snapWidthPixels
+ bubbleWidth = Math.min(maxWidth,
+ (int) (Math.ceil(bubbleWidth / (float) mSnapWidthPixels) * mSnapWidthPixels));
+ super.onMeasure(
+ MeasureSpec.makeMeasureSpec(bubbleWidth + widthPadding, MeasureSpec.EXACTLY),
+ heightMeasureSpec);
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java b/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java
new file mode 100644
index 0000000..89b9148
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java
@@ -0,0 +1,381 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.text.format.Formatter;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.datamodel.data.ConversationParticipantsData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.mmslib.pdu.PduHeaders;
+import com.android.messaging.sms.DatabaseMessages.MmsMessage;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.Dates;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.SafeAsyncTask;
+
+import java.util.List;
+
+public class MessageDetailsDialog {
+ private static final String RECIPIENT_SEPARATOR = ", ";
+
+ // All methods are static, no creating this class
+ private MessageDetailsDialog() {
+ }
+
+ public static void show(final Context context, final ConversationMessageData data,
+ final ConversationParticipantsData participants, final ParticipantData self) {
+ if (DebugUtils.isDebugEnabled()) {
+ new SafeAsyncTask<Void, Void, String>() {
+ @Override
+ protected String doInBackgroundTimed(Void... params) {
+ return getMessageDetails(context, data, participants, self);
+ }
+
+ @Override
+ protected void onPostExecute(String messageDetails) {
+ showDialog(context, messageDetails);
+ }
+ }.executeOnThreadPool(null, null, null);
+ } else {
+ String messageDetails = getMessageDetails(context, data, participants, self);
+ showDialog(context, messageDetails);
+ }
+ }
+
+ private static String getMessageDetails(final Context context,
+ final ConversationMessageData data,
+ final ConversationParticipantsData participants, final ParticipantData self) {
+ String messageDetails = null;
+ if (data.getIsSms()) {
+ messageDetails = getSmsMessageDetails(data, participants, self);
+ } else {
+ // TODO: Handle SMS_TYPE_MMS_PUSH_NOTIFICATION type differently?
+ messageDetails = getMmsMessageDetails(context, data, participants, self);
+ }
+
+ return messageDetails;
+ }
+
+ private static void showDialog(final Context context, String messageDetails) {
+ if (!TextUtils.isEmpty(messageDetails)) {
+ new AlertDialog.Builder(context)
+ .setTitle(R.string.message_details_title)
+ .setMessage(messageDetails)
+ .setCancelable(true)
+ .show();
+ }
+ }
+
+ /**
+ * Return a string, separated by newlines, that contains a number of labels and values
+ * for this sms message. The string will be displayed in a modal dialog.
+ * @return string list of various message properties
+ */
+ private static String getSmsMessageDetails(final ConversationMessageData data,
+ final ConversationParticipantsData participants, final ParticipantData self) {
+ final Resources res = Factory.get().getApplicationContext().getResources();
+ final StringBuilder details = new StringBuilder();
+
+ // Type: Text message
+ details.append(res.getString(R.string.message_type_label));
+ details.append(res.getString(R.string.text_message));
+
+ // From: +1425xxxxxxx
+ // or To: +1425xxxxxxx
+ final String rawSender = data.getSenderNormalizedDestination();
+ if (!TextUtils.isEmpty(rawSender)) {
+ details.append('\n');
+ details.append(res.getString(R.string.from_label));
+ details.append(rawSender);
+ }
+ final String rawRecipients = getRecipientParticipantString(participants,
+ data.getParticipantId(), data.getIsIncoming(), data.getSelfParticipantId());
+ if (!TextUtils.isEmpty(rawRecipients)) {
+ details.append('\n');
+ details.append(res.getString(R.string.to_address_label));
+ details.append(rawRecipients);
+ }
+
+ // Sent: Mon 11:42AM
+ if (data.getIsIncoming()) {
+ if (data.getSentTimeStamp() != MmsUtils.INVALID_TIMESTAMP) {
+ details.append('\n');
+ details.append(res.getString(R.string.sent_label));
+ details.append(
+ Dates.getMessageDetailsTimeString(data.getSentTimeStamp()).toString());
+ }
+ }
+
+ // Sent: Mon 11:43AM
+ // or Received: Mon 11:43AM
+ appendSentOrReceivedTimestamp(res, details, data);
+
+ appendSimInfo(res, self, details);
+
+ if (DebugUtils.isDebugEnabled()) {
+ appendDebugInfo(details, data);
+ }
+
+ return details.toString();
+ }
+
+ /**
+ * Return a string, separated by newlines, that contains a number of labels and values
+ * for this mms message. The string will be displayed in a modal dialog.
+ * @return string list of various message properties
+ */
+ private static String getMmsMessageDetails(Context context, final ConversationMessageData data,
+ final ConversationParticipantsData participants, final ParticipantData self) {
+ final Resources res = Factory.get().getApplicationContext().getResources();
+ // TODO: when we support non-auto-download of mms messages, we'll have to handle
+ // the case when the message is a PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND and display
+ // something different. See the Messaging app's MessageUtils.getNotificationIndDetails()
+
+ final StringBuilder details = new StringBuilder();
+
+ // Type: Multimedia message.
+ details.append(res.getString(R.string.message_type_label));
+ details.append(res.getString(R.string.multimedia_message));
+
+ // From: +1425xxxxxxx
+ final String rawSender = data.getSenderNormalizedDestination();
+ details.append('\n');
+ details.append(res.getString(R.string.from_label));
+ details.append(!TextUtils.isEmpty(rawSender) ? rawSender :
+ res.getString(R.string.hidden_sender_address));
+
+ // To: +1425xxxxxxx
+ final String rawRecipients = getRecipientParticipantString(participants,
+ data.getParticipantId(), data.getIsIncoming(), data.getSelfParticipantId());
+ if (!TextUtils.isEmpty(rawRecipients)) {
+ details.append('\n');
+ details.append(res.getString(R.string.to_address_label));
+ details.append(rawRecipients);
+ }
+
+ // Sent: Tue 3:05PM
+ // or Received: Tue 3:05PM
+ appendSentOrReceivedTimestamp(res, details, data);
+
+ // Subject: You're awesome
+ details.append('\n');
+ details.append(res.getString(R.string.subject_label));
+ if (!TextUtils.isEmpty(MmsUtils.cleanseMmsSubject(res, data.getMmsSubject()))) {
+ details.append(data.getMmsSubject());
+ }
+
+ // Priority: High/Normal/Low
+ details.append('\n');
+ details.append(res.getString(R.string.priority_label));
+ details.append(getPriorityDescription(res, data.getSmsPriority()));
+
+ // Message size: 30 KB
+ if (data.getSmsMessageSize() > 0) {
+ details.append('\n');
+ details.append(res.getString(R.string.message_size_label));
+ details.append(Formatter.formatFileSize(context, data.getSmsMessageSize()));
+ }
+
+ appendSimInfo(res, self, details);
+
+ if (DebugUtils.isDebugEnabled()) {
+ appendDebugInfo(details, data);
+ }
+
+ return details.toString();
+ }
+
+ private static void appendSentOrReceivedTimestamp(Resources res, StringBuilder details,
+ ConversationMessageData data) {
+ int labelId = -1;
+ if (data.getIsIncoming()) {
+ labelId = R.string.received_label;
+ } else if (data.getIsSendComplete()) {
+ labelId = R.string.sent_label;
+ }
+ if (labelId >= 0) {
+ details.append('\n');
+ details.append(res.getString(labelId));
+ details.append(
+ Dates.getMessageDetailsTimeString(data.getReceivedTimeStamp()).toString());
+ }
+ }
+
+ @DoesNotRunOnMainThread
+ private static void appendDebugInfo(StringBuilder details, ConversationMessageData data) {
+ // We grab the thread id from the database, so this needs to run in the background
+ Assert.isNotMainThread();
+ details.append("\n\n");
+ details.append("DEBUG");
+
+ details.append('\n');
+ details.append("Message id: ");
+ details.append(data.getMessageId());
+
+ final String telephonyUri = data.getSmsMessageUri();
+ details.append('\n');
+ details.append("Telephony uri: ");
+ details.append(telephonyUri);
+
+ final String conversationId = data.getConversationId();
+
+ if (conversationId == null) {
+ return;
+ }
+
+ details.append('\n');
+ details.append("Conversation id: ");
+ details.append(conversationId);
+
+ final long threadId = BugleDatabaseOperations.getThreadId(DataModel.get().getDatabase(),
+ conversationId);
+
+ details.append('\n');
+ details.append("Conversation telephony thread id: ");
+ details.append(threadId);
+
+ MmsMessage mms = null;
+
+ if (data.getIsMms()) {
+ if (telephonyUri == null) {
+ return;
+ }
+ mms = MmsUtils.loadMms(Uri.parse(telephonyUri));
+ if (mms == null) {
+ return;
+ }
+
+ // We log the thread id again to check that they are internally consistent
+ final long mmsThreadId = mms.mThreadId;
+ details.append('\n');
+ details.append("Telephony thread id: ");
+ details.append(mmsThreadId);
+
+ // Log the MMS content location
+ final String mmsContentLocation = mms.mContentLocation;
+ details.append('\n');
+ details.append("Content location URL: ");
+ details.append(mmsContentLocation);
+ }
+
+ final String recipientsString = MmsUtils.getRawRecipientIdsForThread(threadId);
+ if (recipientsString != null) {
+ details.append('\n');
+ details.append("Thread recipient ids: ");
+ details.append(recipientsString);
+ }
+
+ final List<String> recipients = MmsUtils.getRecipientsByThread(threadId);
+ if (recipients != null) {
+ details.append('\n');
+ details.append("Thread recipients: ");
+ details.append(recipients.toString());
+
+ if (mms != null) {
+ final String from = MmsUtils.getMmsSender(recipients, mms.getUri());
+ details.append('\n');
+ details.append("Sender: ");
+ details.append(from);
+ }
+ }
+ }
+
+ private static String getRecipientParticipantString(
+ final ConversationParticipantsData participants, final String senderId,
+ final boolean addSelf, final String selfId) {
+ final StringBuilder recipients = new StringBuilder();
+ for (final ParticipantData participant : participants) {
+ if (TextUtils.equals(participant.getId(), senderId)) {
+ // Don't add sender
+ continue;
+ }
+ if (participant.isSelf() &&
+ (!participant.getId().equals(selfId) || !addSelf)) {
+ // For self participants, don't add the one that's not relevant to this message
+ // or if we are asked not to add self
+ continue;
+ }
+ final String phoneNumber = participant.getNormalizedDestination();
+ // Don't add empty number. This should not happen. But if that happens
+ // we should not add it.
+ if (!TextUtils.isEmpty(phoneNumber)) {
+ if (recipients.length() > 0) {
+ recipients.append(RECIPIENT_SEPARATOR);
+ }
+ recipients.append(phoneNumber);
+ }
+ }
+ return recipients.toString();
+ }
+
+ /**
+ * Convert the numeric mms priority into a human-readable string
+ * @param res
+ * @param priorityValue coded PduHeader priority
+ * @return string representation of the priority
+ */
+ private static String getPriorityDescription(final Resources res, final int priorityValue) {
+ switch(priorityValue) {
+ case PduHeaders.PRIORITY_HIGH:
+ return res.getString(R.string.priority_high);
+ case PduHeaders.PRIORITY_LOW:
+ return res.getString(R.string.priority_low);
+ case PduHeaders.PRIORITY_NORMAL:
+ default:
+ return res.getString(R.string.priority_normal);
+ }
+ }
+
+ private static void appendSimInfo(final Resources res,
+ final ParticipantData self, final StringBuilder outString) {
+ if (!OsUtil.isAtLeastL_MR1()
+ || self == null
+ || PhoneUtils.getDefault().getActiveSubscriptionCount() < 2) {
+ return;
+ }
+ // The appended SIM info would look like:
+ // SIM: SUB 01
+ // or SIM: SIM 1
+ // or SIM: Unknown
+ Assert.isTrue(self.isSelf());
+ outString.append('\n');
+ outString.append(res.getString(R.string.sim_label));
+ if (self.isActiveSubscription() && !self.isDefaultSelf()) {
+ final String subscriptionName = self.getSubscriptionName();
+ if (TextUtils.isEmpty(subscriptionName)) {
+ outString.append(res.getString(R.string.sim_slot_identifier,
+ self.getDisplaySlotId()));
+ } else {
+ outString.append(subscriptionName);
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/SimIconView.java b/src/com/android/messaging/ui/conversation/SimIconView.java
new file mode 100644
index 0000000..e2e446c
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/SimIconView.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.messaging.ui.conversation;
+
+import android.content.Context;
+import android.graphics.Outline;
+import android.net.Uri;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewOutlineProvider;
+
+import com.android.messaging.ui.ContactIconView;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.OsUtil;
+
+/**
+ * Shows SIM avatar icon in the SIM switcher / Self-send button.
+ */
+public class SimIconView extends ContactIconView {
+ public SimIconView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ if (OsUtil.isAtLeastL()) {
+ setOutlineProvider(new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View v, Outline outline) {
+ outline.setOval(0, 0, v.getWidth(), v.getHeight());
+ }
+ });
+ }
+ }
+
+ @Override
+ protected void maybeInitializeOnClickListener() {
+ // TODO: SIM icon view shouldn't consume or handle clicks, but it should if
+ // this is the send button for the only SIM in the device or if MSIM is not supported.
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/SimSelectorItemView.java b/src/com/android/messaging/ui/conversation/SimSelectorItemView.java
new file mode 100644
index 0000000..3058d31
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/SimSelectorItemView.java
@@ -0,0 +1,90 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.util.Assert;
+
+/**
+ * Shows a view for a SIM in the SIM selector.
+ */
+public class SimSelectorItemView extends LinearLayout {
+ public interface HostInterface {
+ void onSimItemClicked(SubscriptionListEntry item);
+ }
+
+ private SubscriptionListEntry mData;
+ private TextView mNameTextView;
+ private TextView mDetailsTextView;
+ private SimIconView mSimIconView;
+ private HostInterface mHost;
+
+ public SimSelectorItemView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ mNameTextView = (TextView) findViewById(R.id.name);
+ mDetailsTextView = (TextView) findViewById(R.id.details);
+ mSimIconView = (SimIconView) findViewById(R.id.sim_icon);
+ setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mHost.onSimItemClicked(mData);
+ }
+ });
+ }
+
+ public void bind(final SubscriptionListEntry simEntry) {
+ Assert.notNull(simEntry);
+ mData = simEntry;
+ updateViewAppearance();
+ }
+
+ public void setHostInterface(final HostInterface host) {
+ mHost = host;
+ }
+
+ private void updateViewAppearance() {
+ Assert.notNull(mData);
+ final String displayName = mData.displayName;
+ if (TextUtils.isEmpty(displayName)) {
+ mNameTextView.setVisibility(GONE);
+ } else {
+ mNameTextView.setVisibility(VISIBLE);
+ mNameTextView.setText(displayName);
+ }
+
+ final String details = mData.displayDestination;
+ if (TextUtils.isEmpty(details)) {
+ mDetailsTextView.setVisibility(GONE);
+ } else {
+ mDetailsTextView.setVisibility(VISIBLE);
+ mDetailsTextView.setText(details);
+ }
+
+ mSimIconView.setImageResourceUri(mData.iconUri);
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/SimSelectorView.java b/src/com/android/messaging/ui/conversation/SimSelectorView.java
new file mode 100644
index 0000000..b07ff19
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/SimSelectorView.java
@@ -0,0 +1,169 @@
+/*
+ * 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.messaging.ui.conversation;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.TranslateAnimation;
+import android.widget.ArrayAdapter;
+import android.widget.FrameLayout;
+import android.widget.ListView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.SubscriptionListData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.util.UiUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Displays a SIM selector above the compose message view and overlays the message list.
+ */
+public class SimSelectorView extends FrameLayout implements SimSelectorItemView.HostInterface {
+ public interface SimSelectorViewListener {
+ void onSimItemClicked(SubscriptionListEntry item);
+ void onSimSelectorVisibilityChanged(boolean visible);
+ }
+
+ private ListView mSimListView;
+ private final SimSelectorAdapter mAdapter;
+ private boolean mShow;
+ private SimSelectorViewListener mListener;
+ private int mItemLayoutId;
+
+ public SimSelectorView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mAdapter = new SimSelectorAdapter(getContext());
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mSimListView = (ListView) findViewById(R.id.sim_list);
+ mSimListView.setAdapter(mAdapter);
+
+ // Clicking anywhere outside the switcher list should dismiss.
+ setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ showOrHide(false, true);
+ }
+ });
+ }
+
+ public void bind(final SubscriptionListData data) {
+ mAdapter.bindData(data.getActiveSubscriptionEntriesExcludingDefault());
+ }
+
+ public void setItemLayoutId(final int layoutId) {
+ mItemLayoutId = layoutId;
+ }
+
+ public void setListener(final SimSelectorViewListener listener) {
+ mListener = listener;
+ }
+
+ public void toggleVisibility() {
+ showOrHide(!mShow, true);
+ }
+
+ public void showOrHide(final boolean show, final boolean animate) {
+ final boolean oldShow = mShow;
+ mShow = show && mAdapter.getCount() > 1;
+ if (oldShow != mShow) {
+ if (mListener != null) {
+ mListener.onSimSelectorVisibilityChanged(mShow);
+ }
+
+ if (animate) {
+ // Fade in the background pane.
+ setVisibility(VISIBLE);
+ setAlpha(mShow ? 0.0f : 1.0f);
+ animate().alpha(mShow ? 1.0f : 0.0f)
+ .setDuration(UiUtils.REVEAL_ANIMATION_DURATION)
+ .withEndAction(new Runnable() {
+ @Override
+ public void run() {
+ setAlpha(1.0f);
+ setVisibility(mShow ? VISIBLE : GONE);
+ }
+ });
+ } else {
+ setVisibility(mShow ? VISIBLE : GONE);
+ }
+
+ // Slide in the SIM selector list via a translate animation.
+ mSimListView.setVisibility(mShow ? VISIBLE : GONE);
+ if (animate) {
+ mSimListView.clearAnimation();
+ final TranslateAnimation translateAnimation = new TranslateAnimation(
+ Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0,
+ Animation.RELATIVE_TO_SELF, mShow ? 1.0f : 0.0f,
+ Animation.RELATIVE_TO_SELF, mShow ? 0.0f : 1.0f);
+ translateAnimation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR);
+ translateAnimation.setDuration(UiUtils.REVEAL_ANIMATION_DURATION);
+ mSimListView.startAnimation(translateAnimation);
+ }
+ }
+ }
+
+ /**
+ * An adapter that takes a list of SubscriptionListEntry and displays them as a list of
+ * available SIMs in the SIM selector.
+ */
+ private class SimSelectorAdapter extends ArrayAdapter<SubscriptionListEntry> {
+ public SimSelectorAdapter(final Context context) {
+ super(context, R.layout.sim_selector_item_view, new ArrayList<SubscriptionListEntry>());
+ }
+
+ public void bindData(final List<SubscriptionListEntry> newList) {
+ clear();
+ addAll(newList);
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public View getView(final int position, final View convertView, final ViewGroup parent) {
+ SimSelectorItemView itemView;
+ if (convertView != null && convertView instanceof SimSelectorItemView) {
+ itemView = (SimSelectorItemView) convertView;
+ } else {
+ final LayoutInflater inflater = (LayoutInflater) getContext()
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ itemView = (SimSelectorItemView) inflater.inflate(mItemLayoutId,
+ parent, false);
+ itemView.setHostInterface(SimSelectorView.this);
+ }
+ itemView.bind(getItem(position));
+ return itemView;
+ }
+ }
+
+ @Override
+ public void onSimItemClicked(SubscriptionListEntry item) {
+ mListener.onSimItemClicked(item);
+ showOrHide(false, true);
+ }
+
+ public boolean isOpen() {
+ return mShow;
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java
new file mode 100644
index 0000000..dbbbb15
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java
@@ -0,0 +1,339 @@
+/*
+ * 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.messaging.ui.conversationlist;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Fragment;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.view.View;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.action.DeleteConversationAction;
+import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction;
+import com.android.messaging.datamodel.action.UpdateConversationOptionsAction;
+import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction;
+import com.android.messaging.datamodel.data.ConversationListData;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.SnackBar;
+import com.android.messaging.ui.SnackBarInteraction;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.contact.AddContactsConfirmationDialog;
+import com.android.messaging.ui.conversationlist.ConversationListFragment.ConversationListFragmentHost;
+import com.android.messaging.ui.conversationlist.MultiSelectActionModeCallback.SelectedConversation;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.Trace;
+import com.android.messaging.util.UiUtils;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import javax.annotation.Nullable;
+
+/**
+ * Base class for many Conversation List activities. This will handle the common actions of multi
+ * select and common launching of intents.
+ */
+public abstract class AbstractConversationListActivity extends BugleActionBarActivity
+ implements ConversationListFragmentHost, MultiSelectActionModeCallback.Listener {
+
+ private static final int REQUEST_SET_DEFAULT_SMS_APP = 1;
+
+ protected ConversationListFragment mConversationListFragment;
+
+ @Override
+ public void onAttachFragment(final Fragment fragment) {
+ Trace.beginSection("AbstractConversationListActivity.onAttachFragment");
+ // Fragment could be debug dialog
+ if (fragment instanceof ConversationListFragment) {
+ mConversationListFragment = (ConversationListFragment) fragment;
+ mConversationListFragment.setHost(this);
+ }
+ Trace.endSection();
+ }
+
+ @Override
+ public void onBackPressed() {
+ // If action mode is active dismiss it
+ if (getActionMode() != null) {
+ dismissActionMode();
+ return;
+ }
+ super.onBackPressed();
+ }
+
+ protected void startMultiSelectActionMode() {
+ startActionMode(new MultiSelectActionModeCallback(this));
+ }
+
+ protected void exitMultiSelectState() {
+ mConversationListFragment.showFab();
+ dismissActionMode();
+ mConversationListFragment.updateUi();
+ }
+
+ protected boolean isInConversationListSelectMode() {
+ return getActionModeCallback() instanceof MultiSelectActionModeCallback;
+ }
+
+ @Override
+ public boolean isSelectionMode() {
+ return isInConversationListSelectMode();
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
+ }
+
+ @Override
+ public void onActionBarDelete(final Collection<SelectedConversation> conversations) {
+ if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
+ // TODO: figure out a good way to combine this with the implementation in
+ // ConversationFragment doing similar things
+ final Activity activity = this;
+ UiUtils.showSnackBarWithCustomAction(this,
+ getWindow().getDecorView().getRootView(),
+ getString(R.string.requires_default_sms_app),
+ SnackBar.Action.createCustomAction(new Runnable() {
+ @Override
+ public void run() {
+ final Intent intent =
+ UIIntents.get().getChangeDefaultSmsAppIntent(activity);
+ startActivityForResult(intent, REQUEST_SET_DEFAULT_SMS_APP);
+ }
+ },
+ getString(R.string.requires_default_sms_change_button)),
+ null /* interactions */,
+ null /* placement */);
+ return;
+ }
+
+ new AlertDialog.Builder(this)
+ .setTitle(getResources().getQuantityString(
+ R.plurals.delete_conversations_confirmation_dialog_title,
+ conversations.size()))
+ .setPositiveButton(R.string.delete_conversation_confirmation_button,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int button) {
+ for (final SelectedConversation conversation : conversations) {
+ DeleteConversationAction.deleteConversation(
+ conversation.conversationId,
+ conversation.timestamp);
+ }
+ exitMultiSelectState();
+ }
+ })
+ .setNegativeButton(R.string.delete_conversation_decline_button, null)
+ .show();
+ }
+
+ @Override
+ public void onActionBarArchive(final Iterable<SelectedConversation> conversations,
+ final boolean isToArchive) {
+ final ArrayList<String> conversationIds = new ArrayList<String>();
+ for (final SelectedConversation conversation : conversations) {
+ final String conversationId = conversation.conversationId;
+ conversationIds.add(conversationId);
+ if (isToArchive) {
+ UpdateConversationArchiveStatusAction.archiveConversation(conversationId);
+ } else {
+ UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId);
+ }
+ }
+
+ final Runnable undoRunnable = new Runnable() {
+ @Override
+ public void run() {
+ for (final String conversationId : conversationIds) {
+ if (isToArchive) {
+ UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId);
+ } else {
+ UpdateConversationArchiveStatusAction.archiveConversation(conversationId);
+ }
+ }
+ }
+ };
+
+ final int textId =
+ isToArchive ? R.string.archived_toast_message : R.string.unarchived_toast_message;
+ final String message = getResources().getString(textId, conversationIds.size());
+ UiUtils.showSnackBar(this, findViewById(android.R.id.list), message, undoRunnable,
+ SnackBar.Action.SNACK_BAR_UNDO,
+ mConversationListFragment.getSnackBarInteractions());
+ exitMultiSelectState();
+ }
+
+ @Override
+ public void onActionBarNotification(final Iterable<SelectedConversation> conversations,
+ final boolean isNotificationOn) {
+ for (final SelectedConversation conversation : conversations) {
+ UpdateConversationOptionsAction.enableConversationNotifications(
+ conversation.conversationId, isNotificationOn);
+ }
+
+ final int textId = isNotificationOn ?
+ R.string.notification_on_toast_message : R.string.notification_off_toast_message;
+ final String message = getResources().getString(textId, 1);
+ UiUtils.showSnackBar(this, findViewById(android.R.id.list), message,
+ null /* undoRunnable */,
+ SnackBar.Action.SNACK_BAR_UNDO, mConversationListFragment.getSnackBarInteractions());
+ exitMultiSelectState();
+ }
+
+ @Override
+ public void onActionBarAddContact(final SelectedConversation conversation) {
+ final Uri avatarUri;
+ if (conversation.icon != null) {
+ avatarUri = Uri.parse(conversation.icon);
+ } else {
+ avatarUri = null;
+ }
+ final AddContactsConfirmationDialog dialog = new AddContactsConfirmationDialog(
+ this, avatarUri, conversation.otherParticipantNormalizedDestination);
+ dialog.show();
+ exitMultiSelectState();
+ }
+
+ @Override
+ public void onActionBarBlock(final SelectedConversation conversation) {
+ final Resources res = getResources();
+ new AlertDialog.Builder(this)
+ .setTitle(res.getString(R.string.block_confirmation_title,
+ conversation.otherParticipantNormalizedDestination))
+ .setMessage(res.getString(R.string.block_confirmation_message))
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface arg0, final int arg1) {
+ final Context context = AbstractConversationListActivity.this;
+ final View listView = findViewById(android.R.id.list);
+ final List<SnackBarInteraction> interactions =
+ mConversationListFragment.getSnackBarInteractions();
+ final UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener
+ undoListener =
+ new UpdateDestinationBlockedActionSnackBar(
+ context, listView, null /* undoRunnable */,
+ interactions);
+ final Runnable undoRunnable = new Runnable() {
+ @Override
+ public void run() {
+ UpdateDestinationBlockedAction.updateDestinationBlocked(
+ conversation.otherParticipantNormalizedDestination, false,
+ conversation.conversationId,
+ undoListener);
+ }
+ };
+ final UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener
+ listener = new UpdateDestinationBlockedActionSnackBar(
+ context, listView, undoRunnable, interactions);
+ UpdateDestinationBlockedAction.updateDestinationBlocked(
+ conversation.otherParticipantNormalizedDestination, true,
+ conversation.conversationId,
+ listener);
+ exitMultiSelectState();
+ }
+ })
+ .create()
+ .show();
+ }
+
+ @Override
+ public void onConversationClick(final ConversationListData listData,
+ final ConversationListItemData conversationListItemData,
+ final boolean isLongClick,
+ final ConversationListItemView conversationView) {
+ if (isLongClick && !isInConversationListSelectMode()) {
+ startMultiSelectActionMode();
+ }
+
+ if (isInConversationListSelectMode()) {
+ final MultiSelectActionModeCallback multiSelectActionMode =
+ (MultiSelectActionModeCallback) getActionModeCallback();
+ multiSelectActionMode.toggleSelect(listData, conversationListItemData);
+ mConversationListFragment.updateUi();
+ } else {
+ final String conversationId = conversationListItemData.getConversationId();
+ Bundle sceneTransitionAnimationOptions = null;
+ boolean hasCustomTransitions = false;
+
+ UIIntents.get().launchConversationActivity(
+ this, conversationId, null,
+ sceneTransitionAnimationOptions,
+ hasCustomTransitions);
+ }
+ }
+
+ @Override
+ public void onCreateConversationClick() {
+ UIIntents.get().launchCreateNewConversationActivity(this, null);
+ }
+
+
+ @Override
+ public boolean isConversationSelected(final String conversationId) {
+ return isInConversationListSelectMode() &&
+ ((MultiSelectActionModeCallback) getActionModeCallback()).isSelected(
+ conversationId);
+ }
+
+ public void onActionBarDebug() {
+ DebugUtils.showDebugOptions(this);
+ }
+
+ private static class UpdateDestinationBlockedActionSnackBar
+ implements UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener {
+ private final Context mContext;
+ private final View mParentView;
+ private final Runnable mUndoRunnable;
+ private final List<SnackBarInteraction> mInteractions;
+
+ UpdateDestinationBlockedActionSnackBar(final Context context,
+ @NonNull final View parentView, @Nullable final Runnable undoRunnable,
+ @Nullable List<SnackBarInteraction> interactions) {
+ mContext = context;
+ mParentView = parentView;
+ mUndoRunnable = undoRunnable;
+ mInteractions = interactions;
+ }
+
+ @Override
+ public void onUpdateDestinationBlockedAction(
+ final UpdateDestinationBlockedAction action,
+ final boolean success, final boolean block,
+ final String destination) {
+ if (success) {
+ final int messageId = block ? R.string.blocked_toast_message
+ : R.string.unblocked_toast_message;
+ final String message = mContext.getResources().getString(messageId, 1);
+ UiUtils.showSnackBar(mContext, mParentView, message, mUndoRunnable,
+ SnackBar.Action.SNACK_BAR_UNDO, mInteractions);
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java
new file mode 100644
index 0000000..366c7d3
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java
@@ -0,0 +1,96 @@
+/*
+ * 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.messaging.ui.conversationlist;
+
+import android.graphics.drawable.ColorDrawable;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import com.android.messaging.R;
+import com.android.messaging.util.DebugUtils;
+
+public class ArchivedConversationListActivity extends AbstractConversationListActivity {
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final ConversationListFragment fragment =
+ ConversationListFragment.createArchivedConversationListFragment();
+ getFragmentManager().beginTransaction().add(android.R.id.content, fragment).commit();
+ invalidateActionBar();
+ }
+
+ protected void updateActionBar(ActionBar actionBar) {
+ actionBar.setTitle(getString(R.string.archived_activity_title));
+ actionBar.setDisplayShowTitleEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.setBackgroundDrawable(new ColorDrawable(
+ getResources().getColor(
+ R.color.archived_conversation_action_bar_background_color_dark)));
+ actionBar.show();
+ super.updateActionBar(actionBar);
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (isInConversationListSelectMode()) {
+ exitMultiSelectState();
+ } else {
+ super.onBackPressed();
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ if (super.onCreateOptionsMenu(menu)) {
+ return true;
+ }
+ getMenuInflater().inflate(R.menu.archived_conversation_list_menu, menu);
+ final MenuItem item = menu.findItem(R.id.action_debug_options);
+ if (item != null) {
+ final boolean enableDebugItems = DebugUtils.isDebugEnabled();
+ item.setVisible(enableDebugItems).setEnabled(enableDebugItems);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem menuItem) {
+ switch(menuItem.getItemId()) {
+ case R.id.action_debug_options:
+ onActionBarDebug();
+ return true;
+ case android.R.id.home:
+ onActionBarHome();
+ return true;
+ default:
+ return super.onOptionsItemSelected(menuItem);
+ }
+ }
+
+ @Override
+ public void onActionBarHome() {
+ onBackPressed();
+ }
+
+ @Override
+ public boolean isSwipeAnimatable() {
+ return false;
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java
new file mode 100644
index 0000000..f8abe81
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.ui.conversationlist;
+
+import android.graphics.drawable.ColorDrawable;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.Trace;
+
+public class ConversationListActivity extends AbstractConversationListActivity {
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ Trace.beginSection("ConversationListActivity.onCreate");
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.conversation_list_activity);
+ Trace.endSection();
+ invalidateActionBar();
+ }
+
+ @Override
+ protected void updateActionBar(final ActionBar actionBar) {
+ actionBar.setTitle(getString(R.string.app_name));
+ actionBar.setDisplayShowTitleEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(false);
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
+ actionBar.setBackgroundDrawable(new ColorDrawable(
+ getResources().getColor(R.color.action_bar_background_color)));
+ actionBar.show();
+ super.updateActionBar(actionBar);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ // Invalidate the menu as items that are based on settings may have changed
+ // while not in the app (e.g. Talkback enabled/disable affects new conversation
+ // button)
+ supportInvalidateOptionsMenu();
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (isInConversationListSelectMode()) {
+ exitMultiSelectState();
+ } else {
+ super.onBackPressed();
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ if (super.onCreateOptionsMenu(menu)) {
+ return true;
+ }
+ getMenuInflater().inflate(R.menu.conversation_list_fragment_menu, menu);
+ final MenuItem item = menu.findItem(R.id.action_debug_options);
+ if (item != null) {
+ final boolean enableDebugItems = DebugUtils.isDebugEnabled();
+ item.setVisible(enableDebugItems).setEnabled(enableDebugItems);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem menuItem) {
+ switch(menuItem.getItemId()) {
+ case R.id.action_start_new_conversation:
+ onActionBarStartNewConversation();
+ return true;
+ case R.id.action_settings:
+ onActionBarSettings();
+ return true;
+ case R.id.action_debug_options:
+ onActionBarDebug();
+ return true;
+ case R.id.action_show_archived:
+ onActionBarArchived();
+ return true;
+ case R.id.action_show_blocked_contacts:
+ onActionBarBlockedParticipants();
+ return true;
+ }
+ return super.onOptionsItemSelected(menuItem);
+ }
+
+ @Override
+ public void onActionBarHome() {
+ exitMultiSelectState();
+ }
+
+ public void onActionBarStartNewConversation() {
+ UIIntents.get().launchCreateNewConversationActivity(this, null);
+ }
+
+ public void onActionBarSettings() {
+ UIIntents.get().launchSettingsActivity(this);
+ }
+
+ public void onActionBarBlockedParticipants() {
+ UIIntents.get().launchBlockedParticipantsActivity(this);
+ }
+
+ public void onActionBarArchived() {
+ UIIntents.get().launchArchivedConversationsActivity(this);
+ }
+
+ @Override
+ public boolean isSwipeAnimatable() {
+ return !isInConversationListSelectMode();
+ }
+
+ @Override
+ public void onWindowFocusChanged(final boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ final ConversationListFragment conversationListFragment =
+ (ConversationListFragment) getFragmentManager().findFragmentById(
+ R.id.conversation_list_fragment);
+ // When the screen is turned on, the last used activity gets resumed, but it gets
+ // window focus only after the lock screen is unlocked.
+ if (hasFocus && conversationListFragment != null) {
+ conversationListFragment.setScrolledToNewestConversationIfNeeded();
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java b/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java
new file mode 100644
index 0000000..629c4ae
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java
@@ -0,0 +1,77 @@
+/*
+ * 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.messaging.ui.conversationlist;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.CursorRecyclerAdapter;
+import com.android.messaging.ui.conversationlist.ConversationListItemView.HostInterface;
+
+/**
+ * Provides an interface to expose Conversation List Cursor data to a UI widget like a ListView.
+ */
+public class ConversationListAdapter
+ extends CursorRecyclerAdapter<ConversationListAdapter.ConversationListViewHolder> {
+
+ private final ConversationListItemView.HostInterface mClivHostInterface;
+
+ public ConversationListAdapter(final Context context, final Cursor cursor,
+ final ConversationListItemView.HostInterface clivHostInterface) {
+ super(context, cursor, 0);
+ mClivHostInterface = clivHostInterface;
+ setHasStableIds(true);
+ }
+
+ /**
+ * @see com.android.messaging.ui.CursorRecyclerAdapter#bindViewHolder(
+ * android.support.v7.widget.RecyclerView.ViewHolder, android.content.Context,
+ * android.database.Cursor)
+ */
+ @Override
+ public void bindViewHolder(final ConversationListViewHolder holder, final Context context,
+ final Cursor cursor) {
+ final ConversationListItemView conversationListItemView = holder.mView;
+ conversationListItemView.bind(cursor, mClivHostInterface);
+ }
+
+ @Override
+ public ConversationListViewHolder createViewHolder(final Context context,
+ final ViewGroup parent, final int viewType) {
+ final LayoutInflater layoutInflater = LayoutInflater.from(context);
+ final ConversationListItemView itemView =
+ (ConversationListItemView) layoutInflater.inflate(
+ R.layout.conversation_list_item_view, null);
+ return new ConversationListViewHolder(itemView);
+ }
+
+ /**
+ * ViewHolder that holds a ConversationListItemView.
+ */
+ public static class ConversationListViewHolder extends RecyclerView.ViewHolder {
+ final ConversationListItemView mView;
+
+ public ConversationListViewHolder(final ConversationListItemView itemView) {
+ super(itemView);
+ mView = itemView;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java b/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java
new file mode 100644
index 0000000..2f868d4
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java
@@ -0,0 +1,446 @@
+/*
+ * 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.messaging.ui.conversationlist;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.ViewGroupCompat;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.ViewGroup.MarginLayoutParams;
+import android.view.ViewPropertyAnimator;
+import android.view.accessibility.AccessibilityManager;
+import android.widget.AbsListView;
+import android.widget.ImageView;
+
+import com.android.messaging.R;
+import com.android.messaging.annotation.VisibleForAnimation;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.ConversationListData;
+import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.ui.BugleAnimationTags;
+import com.android.messaging.ui.ListEmptyView;
+import com.android.messaging.ui.SnackBarInteraction;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ImeUtil;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.UiUtils;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shows a list of conversations.
+ */
+public class ConversationListFragment extends Fragment implements ConversationListDataListener,
+ ConversationListItemView.HostInterface {
+ private static final String BUNDLE_ARCHIVED_MODE = "archived_mode";
+ private static final String BUNDLE_FORWARD_MESSAGE_MODE = "forward_message_mode";
+ private static final boolean VERBOSE = false;
+
+ private MenuItem mShowBlockedMenuItem;
+ private boolean mArchiveMode;
+ private boolean mBlockedAvailable;
+ private boolean mForwardMessageMode;
+
+ public interface ConversationListFragmentHost {
+ public void onConversationClick(final ConversationListData listData,
+ final ConversationListItemData conversationListItemData,
+ final boolean isLongClick,
+ final ConversationListItemView conversationView);
+ public void onCreateConversationClick();
+ public boolean isConversationSelected(final String conversationId);
+ public boolean isSwipeAnimatable();
+ public boolean isSelectionMode();
+ public boolean hasWindowFocus();
+ }
+
+ private ConversationListFragmentHost mHost;
+ private RecyclerView mRecyclerView;
+ private ImageView mStartNewConversationButton;
+ private ListEmptyView mEmptyListMessageView;
+ private ConversationListAdapter mAdapter;
+
+ // Saved Instance State Data - only for temporal data which is nice to maintain but not
+ // critical for correctness.
+ private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY =
+ "conversationListViewState";
+ private Parcelable mListState;
+
+ @VisibleForTesting
+ final Binding<ConversationListData> mListBinding = BindingBase.createBinding(this);
+
+ public static ConversationListFragment createArchivedConversationListFragment() {
+ return createConversationListFragment(BUNDLE_ARCHIVED_MODE);
+ }
+
+ public static ConversationListFragment createForwardMessageConversationListFragment() {
+ return createConversationListFragment(BUNDLE_FORWARD_MESSAGE_MODE);
+ }
+
+ public static ConversationListFragment createConversationListFragment(String modeKeyName) {
+ final ConversationListFragment fragment = new ConversationListFragment();
+ final Bundle bundle = new Bundle();
+ bundle.putBoolean(modeKeyName, true);
+ fragment.setArguments(bundle);
+ return fragment;
+ }
+
+ /**
+ * {@inheritDoc} from Fragment
+ */
+ @Override
+ public void onCreate(final Bundle bundle) {
+ super.onCreate(bundle);
+ mListBinding.getData().init(getLoaderManager(), mListBinding);
+ mAdapter = new ConversationListAdapter(getActivity(), null, this);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ Assert.notNull(mHost);
+ setScrolledToNewestConversationIfNeeded();
+
+ updateUi();
+ }
+
+ public void setScrolledToNewestConversationIfNeeded() {
+ if (!mArchiveMode
+ && !mForwardMessageMode
+ && isScrolledToFirstConversation()
+ && mHost.hasWindowFocus()) {
+ mListBinding.getData().setScrolledToNewestConversation(true);
+ }
+ }
+
+ private boolean isScrolledToFirstConversation() {
+ int firstItemPosition = ((LinearLayoutManager) mRecyclerView.getLayoutManager())
+ .findFirstCompletelyVisibleItemPosition();
+ return firstItemPosition == 0;
+ }
+
+ /**
+ * {@inheritDoc} from Fragment
+ */
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mListBinding.unbind();
+ mHost = null;
+ }
+
+ /**
+ * {@inheritDoc} from Fragment
+ */
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.conversation_list_fragment,
+ container, false);
+ mRecyclerView = (RecyclerView) rootView.findViewById(android.R.id.list);
+ mEmptyListMessageView = (ListEmptyView) rootView.findViewById(R.id.no_conversations_view);
+ mEmptyListMessageView.setImageHint(R.drawable.ic_oobe_conv_list);
+ // The default behavior for default layout param generation by LinearLayoutManager is to
+ // provide width and height of WRAP_CONTENT, but this is not desirable for
+ // ConversationListFragment; the view in each row should be a width of MATCH_PARENT so that
+ // the entire row is tappable.
+ final Activity activity = getActivity();
+ final LinearLayoutManager manager = new LinearLayoutManager(activity) {
+ @Override
+ public RecyclerView.LayoutParams generateDefaultLayoutParams() {
+ return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ }
+ };
+ mRecyclerView.setLayoutManager(manager);
+ mRecyclerView.setHasFixedSize(true);
+ mRecyclerView.setAdapter(mAdapter);
+ mRecyclerView.setOnScrollListener(new RecyclerView.OnScrollListener() {
+ int mCurrentState = AbsListView.OnScrollListener.SCROLL_STATE_IDLE;
+
+ @Override
+ public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) {
+ if (mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL
+ || mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_FLING) {
+ ImeUtil.get().hideImeKeyboard(getActivity(), mRecyclerView);
+ }
+
+ if (isScrolledToFirstConversation()) {
+ setScrolledToNewestConversationIfNeeded();
+ } else {
+ mListBinding.getData().setScrolledToNewestConversation(false);
+ }
+ }
+
+ @Override
+ public void onScrollStateChanged(final RecyclerView recyclerView, final int newState) {
+ mCurrentState = newState;
+ }
+ });
+ mRecyclerView.addOnItemTouchListener(new ConversationListSwipeHelper(mRecyclerView));
+
+ if (savedInstanceState != null) {
+ mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY);
+ }
+
+ mStartNewConversationButton = (ImageView) rootView.findViewById(
+ R.id.start_new_conversation_button);
+ if (mArchiveMode) {
+ mStartNewConversationButton.setVisibility(View.GONE);
+ } else {
+ mStartNewConversationButton.setVisibility(View.VISIBLE);
+ mStartNewConversationButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View clickView) {
+ mHost.onCreateConversationClick();
+ }
+ });
+ }
+ ViewCompat.setTransitionName(mStartNewConversationButton, BugleAnimationTags.TAG_FABICON);
+
+ // The root view has a non-null background, which by default is deemed by the framework
+ // to be a "transition group," where all child views are animated together during an
+ // activity transition. However, we want each individual items in the recycler view to
+ // show explode animation themselves, so we explicitly tag the root view to be a non-group.
+ ViewGroupCompat.setTransitionGroup(rootView, false);
+
+ setHasOptionsMenu(true);
+ return rootView;
+ }
+
+ @Override
+ public void onAttach(final Activity activity) {
+ super.onAttach(activity);
+ if (VERBOSE) {
+ LogUtil.v(LogUtil.BUGLE_TAG, "Attaching List");
+ }
+ final Bundle arguments = getArguments();
+ if (arguments != null) {
+ mArchiveMode = arguments.getBoolean(BUNDLE_ARCHIVED_MODE, false);
+ mForwardMessageMode = arguments.getBoolean(BUNDLE_FORWARD_MESSAGE_MODE, false);
+ }
+ mListBinding.bind(DataModel.get().createConversationListData(activity, this, mArchiveMode));
+ }
+
+
+ @Override
+ public void onSaveInstanceState(final Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (mListState != null) {
+ outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState);
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mListState = mRecyclerView.getLayoutManager().onSaveInstanceState();
+ mListBinding.getData().setScrolledToNewestConversation(false);
+ }
+
+ /**
+ * Call this immediately after attaching the fragment
+ */
+ public void setHost(final ConversationListFragmentHost host) {
+ Assert.isNull(mHost);
+ mHost = host;
+ }
+
+ @Override
+ public void onConversationListCursorUpdated(final ConversationListData data,
+ final Cursor cursor) {
+ mListBinding.ensureBound(data);
+ final Cursor oldCursor = mAdapter.swapCursor(cursor);
+ updateEmptyListUi(cursor == null || cursor.getCount() == 0);
+ if (mListState != null && cursor != null && oldCursor == null) {
+ mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState);
+ }
+ }
+
+ @Override
+ public void setBlockedParticipantsAvailable(final boolean blockedAvailable) {
+ mBlockedAvailable = blockedAvailable;
+ if (mShowBlockedMenuItem != null) {
+ mShowBlockedMenuItem.setVisible(blockedAvailable);
+ }
+ }
+
+ public void updateUi() {
+ mAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(final Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ final MenuItem startNewConversationMenuItem =
+ menu.findItem(R.id.action_start_new_conversation);
+ if (startNewConversationMenuItem != null) {
+ // It is recommended for the Floating Action button functionality to be duplicated as a
+ // menu
+ AccessibilityManager accessibilityManager = (AccessibilityManager)
+ getActivity().getSystemService(Context.ACCESSIBILITY_SERVICE);
+ startNewConversationMenuItem.setVisible(accessibilityManager
+ .isTouchExplorationEnabled());
+ }
+
+ final MenuItem archive = menu.findItem(R.id.action_show_archived);
+ if (archive != null) {
+ archive.setVisible(true);
+ }
+ }
+
+ @Override
+ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
+ if (!isAdded()) {
+ // Guard against being called before we're added to the activity
+ return;
+ }
+
+ mShowBlockedMenuItem = menu.findItem(R.id.action_show_blocked_contacts);
+ if (mShowBlockedMenuItem != null) {
+ mShowBlockedMenuItem.setVisible(mBlockedAvailable);
+ }
+ }
+
+ /**
+ * {@inheritDoc} from ConversationListItemView.HostInterface
+ */
+ @Override
+ public void onConversationClicked(final ConversationListItemData conversationListItemData,
+ final boolean isLongClick, final ConversationListItemView conversationView) {
+ final ConversationListData listData = mListBinding.getData();
+ mHost.onConversationClick(listData, conversationListItemData, isLongClick,
+ conversationView);
+ }
+
+ /**
+ * {@inheritDoc} from ConversationListItemView.HostInterface
+ */
+ @Override
+ public boolean isConversationSelected(final String conversationId) {
+ return mHost.isConversationSelected(conversationId);
+ }
+
+ @Override
+ public boolean isSwipeAnimatable() {
+ return mHost.isSwipeAnimatable();
+ }
+
+ // Show and hide empty list UI as needed with appropriate text based on view specifics
+ private void updateEmptyListUi(final boolean isEmpty) {
+ if (isEmpty) {
+ int emptyListText;
+ if (!mListBinding.getData().getHasFirstSyncCompleted()) {
+ emptyListText = R.string.conversation_list_first_sync_text;
+ } else if (mArchiveMode) {
+ emptyListText = R.string.archived_conversation_list_empty_text;
+ } else {
+ emptyListText = R.string.conversation_list_empty_text;
+ }
+ mEmptyListMessageView.setTextHint(emptyListText);
+ mEmptyListMessageView.setVisibility(View.VISIBLE);
+ mEmptyListMessageView.setIsImageVisible(true);
+ mEmptyListMessageView.setIsVerticallyCentered(true);
+ } else {
+ mEmptyListMessageView.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public List<SnackBarInteraction> getSnackBarInteractions() {
+ final List<SnackBarInteraction> interactions = new ArrayList<SnackBarInteraction>(1);
+ final SnackBarInteraction fabInteraction =
+ new SnackBarInteraction.BasicSnackBarInteraction(mStartNewConversationButton);
+ interactions.add(fabInteraction);
+ return interactions;
+ }
+
+ private ViewPropertyAnimator getNormalizedFabAnimator() {
+ return mStartNewConversationButton.animate()
+ .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR)
+ .setDuration(getActivity().getResources().getInteger(
+ R.integer.fab_animation_duration_ms));
+ }
+
+ public ViewPropertyAnimator dismissFab() {
+ // To prevent clicking while animating.
+ mStartNewConversationButton.setEnabled(false);
+ final MarginLayoutParams lp =
+ (MarginLayoutParams) mStartNewConversationButton.getLayoutParams();
+ final float fabWidthWithLeftRightMargin = mStartNewConversationButton.getWidth()
+ + lp.leftMargin + lp.rightMargin;
+ final int direction = AccessibilityUtil.isLayoutRtl(mStartNewConversationButton) ? -1 : 1;
+ return getNormalizedFabAnimator().translationX(direction * fabWidthWithLeftRightMargin);
+ }
+
+ public ViewPropertyAnimator showFab() {
+ return getNormalizedFabAnimator().translationX(0).withEndAction(new Runnable() {
+ @Override
+ public void run() {
+ // Re-enable clicks after the animation.
+ mStartNewConversationButton.setEnabled(true);
+ }
+ });
+ }
+
+ public View getHeroElementForTransition() {
+ return mArchiveMode ? null : mStartNewConversationButton;
+ }
+
+ @VisibleForAnimation
+ public RecyclerView getRecyclerView() {
+ return mRecyclerView;
+ }
+
+ @Override
+ public void startFullScreenPhotoViewer(
+ final Uri initialPhoto, final Rect initialPhotoBounds, final Uri photosUri) {
+ UIIntents.get().launchFullScreenPhotoViewer(
+ getActivity(), initialPhoto, initialPhotoBounds, photosUri);
+ }
+
+ @Override
+ public void startFullScreenVideoViewer(final Uri videoUri) {
+ UIIntents.get().launchFullScreenVideoViewer(getActivity(), videoUri);
+ }
+
+ @Override
+ public boolean isSelectionMode() {
+ return mHost != null && mHost.isSelectionMode();
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java b/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java
new file mode 100644
index 0000000..7525182
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java
@@ -0,0 +1,643 @@
+/*
+ * 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.messaging.ui.conversationlist;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.net.Uri;
+import android.support.v4.text.BidiFormatter;
+import android.support.v4.text.TextDirectionHeuristicsCompat;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.View.OnLayoutChangeListener;
+import android.view.View.OnLongClickListener;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.annotation.VisibleForAnimation;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.AsyncImageView;
+import com.android.messaging.ui.AudioAttachmentView;
+import com.android.messaging.ui.ContactIconView;
+import com.android.messaging.ui.SnackBar;
+import com.android.messaging.ui.SnackBarInteraction;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.Typefaces;
+import com.android.messaging.util.UiUtils;
+import com.android.messaging.util.UriUtil;
+
+import java.util.List;
+
+/**
+ * The view for a single entry in a conversation list.
+ */
+public class ConversationListItemView extends FrameLayout implements OnClickListener,
+ OnLongClickListener, OnLayoutChangeListener {
+ static final int UNREAD_SNIPPET_LINE_COUNT = 3;
+ static final int NO_UNREAD_SNIPPET_LINE_COUNT = 1;
+ private int mListItemReadColor;
+ private int mListItemUnreadColor;
+ private Typeface mListItemReadTypeface;
+ private Typeface mListItemUnreadTypeface;
+ private static String sPlusOneString;
+ private static String sPlusNString;
+
+ public interface HostInterface {
+ boolean isConversationSelected(final String conversationId);
+ void onConversationClicked(final ConversationListItemData conversationListItemData,
+ boolean isLongClick, final ConversationListItemView conversationView);
+ boolean isSwipeAnimatable();
+ List<SnackBarInteraction> getSnackBarInteractions();
+ void startFullScreenPhotoViewer(final Uri initialPhoto, final Rect initialPhotoBounds,
+ final Uri photosUri);
+ void startFullScreenVideoViewer(final Uri videoUri);
+ boolean isSelectionMode();
+ }
+
+ private final OnClickListener fullScreenPreviewClickListener = new OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ final String previewType = mData.getShowDraft() ?
+ mData.getDraftPreviewContentType() : mData.getPreviewContentType();
+ Assert.isTrue(ContentType.isImageType(previewType) ||
+ ContentType.isVideoType(previewType));
+
+ final Uri previewUri = mData.getShowDraft() ?
+ mData.getDraftPreviewUri() : mData.getPreviewUri();
+ if (ContentType.isImageType(previewType)) {
+ final Uri imagesUri = mData.getShowDraft() ?
+ MessagingContentProvider.buildDraftImagesUri(mData.getConversationId()) :
+ MessagingContentProvider
+ .buildConversationImagesUri(mData.getConversationId());
+ final Rect previewImageBounds = UiUtils.getMeasuredBoundsOnScreen(v);
+ mHostInterface.startFullScreenPhotoViewer(
+ previewUri, previewImageBounds, imagesUri);
+ } else {
+ mHostInterface.startFullScreenVideoViewer(previewUri);
+ }
+ }
+ };
+
+ private final ConversationListItemData mData;
+
+ private int mAnimatingCount;
+ private ViewGroup mSwipeableContainer;
+ private ViewGroup mCrossSwipeBackground;
+ private ViewGroup mSwipeableContent;
+ private TextView mConversationNameView;
+ private TextView mSnippetTextView;
+ private TextView mSubjectTextView;
+ private TextView mTimestampTextView;
+ private ContactIconView mContactIconView;
+ private ImageView mContactCheckmarkView;
+ private ImageView mNotificationBellView;
+ private ImageView mFailedStatusIconView;
+ private ImageView mCrossSwipeArchiveLeftImageView;
+ private ImageView mCrossSwipeArchiveRightImageView;
+ private AsyncImageView mImagePreviewView;
+ private AudioAttachmentView mAudioAttachmentView;
+ private HostInterface mHostInterface;
+
+ public ConversationListItemView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mData = new ConversationListItemData();
+ final Resources res = context.getResources();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ mSwipeableContainer = (ViewGroup) findViewById(R.id.swipeableContainer);
+ mCrossSwipeBackground = (ViewGroup) findViewById(R.id.crossSwipeBackground);
+ mSwipeableContent = (ViewGroup) findViewById(R.id.swipeableContent);
+ mConversationNameView = (TextView) findViewById(R.id.conversation_name);
+ mSnippetTextView = (TextView) findViewById(R.id.conversation_snippet);
+ mSubjectTextView = (TextView) findViewById(R.id.conversation_subject);
+ mTimestampTextView = (TextView) findViewById(R.id.conversation_timestamp);
+ mContactIconView = (ContactIconView) findViewById(R.id.conversation_icon);
+ mContactCheckmarkView = (ImageView) findViewById(R.id.conversation_checkmark);
+ mNotificationBellView = (ImageView) findViewById(R.id.conversation_notification_bell);
+ mFailedStatusIconView = (ImageView) findViewById(R.id.conversation_failed_status_icon);
+ mCrossSwipeArchiveLeftImageView = (ImageView) findViewById(R.id.crossSwipeArchiveIconLeft);
+ mCrossSwipeArchiveRightImageView =
+ (ImageView) findViewById(R.id.crossSwipeArchiveIconRight);
+ mImagePreviewView = (AsyncImageView) findViewById(R.id.conversation_image_preview);
+ mAudioAttachmentView = (AudioAttachmentView) findViewById(R.id.audio_attachment_view);
+ mConversationNameView.addOnLayoutChangeListener(this);
+ mSnippetTextView.addOnLayoutChangeListener(this);
+
+ final Resources resources = getContext().getResources();
+ mListItemReadColor = resources.getColor(R.color.conversation_list_item_read);
+ mListItemUnreadColor = resources.getColor(R.color.conversation_list_item_unread);
+
+ mListItemReadTypeface = Typefaces.getRobotoNormal();
+ mListItemUnreadTypeface = Typefaces.getRobotoBold();
+
+ if (OsUtil.isAtLeastL()) {
+ setTransitionGroup(true);
+ }
+ }
+
+ @Override
+ public void onLayoutChange(final View v, final int left, final int top, final int right,
+ final int bottom, final int oldLeft, final int oldTop, final int oldRight,
+ final int oldBottom) {
+ if (v == mConversationNameView) {
+ setConversationName();
+ } else if (v == mSnippetTextView) {
+ setSnippet();
+ } else if (v == mSubjectTextView) {
+ setSubject();
+ }
+ }
+
+ private void setConversationName() {
+ if (mData.getIsRead() || mData.getShowDraft()) {
+ mConversationNameView.setTextColor(mListItemReadColor);
+ mConversationNameView.setTypeface(mListItemReadTypeface);
+ } else {
+ mConversationNameView.setTextColor(mListItemUnreadColor);
+ mConversationNameView.setTypeface(mListItemUnreadTypeface);
+ }
+
+ final String conversationName = mData.getName();
+
+ // For group conversations, ellipsize the group members that do not fit
+ final CharSequence ellipsizedName = UiUtils.commaEllipsize(
+ conversationName,
+ mConversationNameView.getPaint(),
+ mConversationNameView.getMeasuredWidth(),
+ getPlusOneString(),
+ getPlusNString());
+ // RTL : To format conversation name if it happens to be phone number.
+ final BidiFormatter bidiFormatter = BidiFormatter.getInstance();
+ final String bidiFormattedName = bidiFormatter.unicodeWrap(
+ ellipsizedName.toString(),
+ TextDirectionHeuristicsCompat.LTR);
+
+ mConversationNameView.setText(bidiFormattedName);
+ }
+
+ private static String getPlusOneString() {
+ if (sPlusOneString == null) {
+ sPlusOneString = Factory.get().getApplicationContext().getResources()
+ .getString(R.string.plus_one);
+ }
+ return sPlusOneString;
+ }
+
+ private static String getPlusNString() {
+ if (sPlusNString == null) {
+ sPlusNString = Factory.get().getApplicationContext().getResources()
+ .getString(R.string.plus_n);
+ }
+ return sPlusNString;
+ }
+
+ private void setSubject() {
+ final String subjectText = mData.getShowDraft() ?
+ mData.getDraftSubject() :
+ MmsUtils.cleanseMmsSubject(getContext().getResources(), mData.getSubject());
+ if (!TextUtils.isEmpty(subjectText)) {
+ final String subjectPrepend = getResources().getString(R.string.subject_label);
+ mSubjectTextView.setText(TextUtils.concat(subjectPrepend, subjectText));
+ mSubjectTextView.setVisibility(VISIBLE);
+ } else {
+ mSubjectTextView.setVisibility(GONE);
+ }
+ }
+
+ private void setSnippet() {
+ mSnippetTextView.setText(getSnippetText());
+ }
+
+ // Resource Ids of content descriptions prefixes for different message status.
+ private static final int [][][] sPrimaryContentDescriptions = {
+ // 1:1 conversation
+ {
+ // Incoming message
+ {
+ R.string.one_on_one_incoming_failed_message_prefix,
+ R.string.one_on_one_incoming_successful_message_prefix
+ },
+ // Outgoing message
+ {
+ R.string.one_on_one_outgoing_failed_message_prefix,
+ R.string.one_on_one_outgoing_successful_message_prefix,
+ R.string.one_on_one_outgoing_draft_message_prefix,
+ R.string.one_on_one_outgoing_sending_message_prefix,
+ }
+ },
+
+ // Group conversation
+ {
+ // Incoming message
+ {
+ R.string.group_incoming_failed_message_prefix,
+ R.string.group_incoming_successful_message_prefix,
+ },
+ // Outgoing message
+ {
+ R.string.group_outgoing_failed_message_prefix,
+ R.string.group_outgoing_successful_message_prefix,
+ R.string.group_outgoing_draft_message_prefix,
+ R.string.group_outgoing_sending_message_prefix,
+ }
+ }
+ };
+
+ // Resource Id of the secondary part of the content description for an edge case of a message
+ // which is in both draft status and failed status.
+ private static final int sSecondaryContentDescription =
+ R.string.failed_message_content_description;
+
+ // 1:1 versus group
+ private static final int CONV_TYPE_ONE_ON_ONE_INDEX = 0;
+ private static final int CONV_TYPE_ONE_GROUP_INDEX = 1;
+ // Direction
+ private static final int DIRECTION_INCOMING_INDEX = 0;
+ private static final int DIRECTION_OUTGOING_INDEX = 1;
+ // Message status
+ private static final int MESSAGE_STATUS_FAILED_INDEX = 0;
+ private static final int MESSAGE_STATUS_SUCCESSFUL_INDEX = 1;
+ private static final int MESSAGE_STATUS_DRAFT_INDEX = 2;
+ private static final int MESSAGE_STATUS_SENDING_INDEX = 3;
+
+ private static final int WIDTH_FOR_ACCESSIBLE_CONVERSATION_NAME = 600;
+
+ public static String buildContentDescription(final Resources resources,
+ final ConversationListItemData data, final TextPaint conversationNameViewPaint) {
+ int messageStatusIndex;
+ boolean outgoingSnippet = data.getIsMessageTypeOutgoing() || data.getShowDraft();
+ if (outgoingSnippet) {
+ if (data.getShowDraft()) {
+ messageStatusIndex = MESSAGE_STATUS_DRAFT_INDEX;
+ } else if (data.getIsSendRequested()) {
+ messageStatusIndex = MESSAGE_STATUS_SENDING_INDEX;
+ } else {
+ messageStatusIndex = data.getIsFailedStatus() ? MESSAGE_STATUS_FAILED_INDEX
+ : MESSAGE_STATUS_SUCCESSFUL_INDEX;
+ }
+ } else {
+ messageStatusIndex = data.getIsFailedStatus() ? MESSAGE_STATUS_FAILED_INDEX
+ : MESSAGE_STATUS_SUCCESSFUL_INDEX;
+ }
+
+ int resId = sPrimaryContentDescriptions
+ [data.getIsGroup() ? CONV_TYPE_ONE_GROUP_INDEX : CONV_TYPE_ONE_ON_ONE_INDEX]
+ [outgoingSnippet ? DIRECTION_OUTGOING_INDEX : DIRECTION_INCOMING_INDEX]
+ [messageStatusIndex];
+
+ final String snippetText = data.getShowDraft() ?
+ data.getDraftSnippetText() : data.getSnippetText();
+
+ final String conversationName = data.getName();
+ String senderOrConvName = outgoingSnippet ? conversationName : data.getSnippetSenderName();
+
+ String primaryContentDescription = resources.getString(resId, senderOrConvName,
+ snippetText == null ? "" : snippetText,
+ data.getFormattedTimestamp(),
+ // This is used only for incoming group messages
+ conversationName);
+ String contentDescription = primaryContentDescription;
+
+ // An edge case : for an outgoing message, it might be in both draft status and
+ // failed status.
+ if (outgoingSnippet && data.getShowDraft() && data.getIsFailedStatus()) {
+ StringBuilder contentDescriptionBuilder = new StringBuilder();
+ contentDescriptionBuilder.append(primaryContentDescription);
+
+ String secondaryContentDescription =
+ resources.getString(sSecondaryContentDescription);
+ contentDescriptionBuilder.append(" ");
+ contentDescriptionBuilder.append(secondaryContentDescription);
+ contentDescription = contentDescriptionBuilder.toString();
+ }
+ return contentDescription;
+ }
+
+ /**
+ * Fills in the data associated with this view.
+ *
+ * @param cursor The cursor from a ConversationList that this view is in, pointing to its
+ * entry.
+ */
+ public void bind(final Cursor cursor, final HostInterface hostInterface) {
+ // Update our UI model
+ mHostInterface = hostInterface;
+ mData.bind(cursor);
+
+ resetAnimatingState();
+
+ mSwipeableContainer.setOnClickListener(this);
+ mSwipeableContainer.setOnLongClickListener(this);
+
+ final Resources resources = getContext().getResources();
+
+ int color;
+ final int maxLines;
+ final Typeface typeface;
+ final int typefaceStyle = mData.getShowDraft() ? Typeface.ITALIC : Typeface.NORMAL;
+ final String snippetText = getSnippetText();
+
+ if (mData.getIsRead() || mData.getShowDraft()) {
+ maxLines = TextUtils.isEmpty(snippetText) ? 0 : NO_UNREAD_SNIPPET_LINE_COUNT;
+ color = mListItemReadColor;
+ typeface = mListItemReadTypeface;
+ } else {
+ maxLines = TextUtils.isEmpty(snippetText) ? 0 : UNREAD_SNIPPET_LINE_COUNT;
+ color = mListItemUnreadColor;
+ typeface = mListItemUnreadTypeface;
+ }
+
+ mSnippetTextView.setMaxLines(maxLines);
+ mSnippetTextView.setTextColor(color);
+ mSnippetTextView.setTypeface(typeface, typefaceStyle);
+ mSubjectTextView.setTextColor(color);
+ mSubjectTextView.setTypeface(typeface, typefaceStyle);
+
+ setSnippet();
+ setConversationName();
+ setSubject();
+ setContentDescription(buildContentDescription(resources, mData,
+ mConversationNameView.getPaint()));
+
+ final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp();
+ // don't show the error state unless we're the default sms app
+ if (mData.getIsFailedStatus() && isDefaultSmsApp) {
+ mTimestampTextView.setTextColor(resources.getColor(R.color.conversation_list_error));
+ mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle);
+ int failureMessageId = R.string.message_status_download_failed;
+ if (mData.getIsMessageTypeOutgoing()) {
+ failureMessageId = MmsUtils.mapRawStatusToErrorResourceId(mData.getMessageStatus(),
+ mData.getMessageRawTelephonyStatus());
+ }
+ mTimestampTextView.setText(resources.getString(failureMessageId));
+ } else if (mData.getShowDraft()
+ || mData.getMessageStatus() == MessageData.BUGLE_STATUS_OUTGOING_DRAFT
+ // also check for unknown status which we get because sometimes the conversation
+ // row is left with a latest_message_id of a no longer existing message and
+ // therefore the join values come back as null (or in this case zero).
+ || mData.getMessageStatus() == MessageData.BUGLE_STATUS_UNKNOWN) {
+ mTimestampTextView.setTextColor(mListItemReadColor);
+ mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle);
+ mTimestampTextView.setText(resources.getString(
+ R.string.conversation_list_item_view_draft_message));
+ } else {
+ mTimestampTextView.setTextColor(mListItemReadColor);
+ mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle);
+ final String formattedTimestamp = mData.getFormattedTimestamp();
+ if (mData.getIsSendRequested()) {
+ mTimestampTextView.setText(R.string.message_status_sending);
+ } else {
+ mTimestampTextView.setText(formattedTimestamp);
+ }
+ }
+
+ final boolean isSelected = mHostInterface.isConversationSelected(mData.getConversationId());
+ setSelected(isSelected);
+ Uri iconUri = null;
+ int contactIconVisibility = GONE;
+ int checkmarkVisiblity = GONE;
+ int failStatusVisiblity = GONE;
+ if (isSelected) {
+ checkmarkVisiblity = VISIBLE;
+ } else {
+ contactIconVisibility = VISIBLE;
+ // Only show the fail icon if it is not a group conversation.
+ // And also require that we be the default sms app.
+ if (mData.getIsFailedStatus() && !mData.getIsGroup() && isDefaultSmsApp) {
+ failStatusVisiblity = VISIBLE;
+ }
+ }
+ if (mData.getIcon() != null) {
+ iconUri = Uri.parse(mData.getIcon());
+ }
+ mContactIconView.setImageResourceUri(iconUri, mData.getParticipantContactId(),
+ mData.getParticipantLookupKey(), mData.getOtherParticipantNormalizedDestination());
+ mContactIconView.setVisibility(contactIconVisibility);
+ mContactIconView.setOnLongClickListener(this);
+ mContactIconView.setClickable(!mHostInterface.isSelectionMode());
+ mContactIconView.setLongClickable(!mHostInterface.isSelectionMode());
+
+ mContactCheckmarkView.setVisibility(checkmarkVisiblity);
+ mFailedStatusIconView.setVisibility(failStatusVisiblity);
+
+ final Uri previewUri = mData.getShowDraft() ?
+ mData.getDraftPreviewUri() : mData.getPreviewUri();
+ final String previewContentType = mData.getShowDraft() ?
+ mData.getDraftPreviewContentType() : mData.getPreviewContentType();
+ OnClickListener previewClickListener = null;
+ Uri previewImageUri = null;
+ int previewImageVisibility = GONE;
+ int audioPreviewVisiblity = GONE;
+ if (previewUri != null && !TextUtils.isEmpty(previewContentType)) {
+ if (ContentType.isAudioType(previewContentType)) {
+ mAudioAttachmentView.bind(previewUri, false);
+ audioPreviewVisiblity = VISIBLE;
+ } else if (ContentType.isVideoType(previewContentType)) {
+ previewImageUri = UriUtil.getUriForResourceId(
+ getContext(), R.drawable.ic_preview_play);
+ previewClickListener = fullScreenPreviewClickListener;
+ previewImageVisibility = VISIBLE;
+ } else if (ContentType.isImageType(previewContentType)) {
+ previewImageUri = previewUri;
+ previewClickListener = fullScreenPreviewClickListener;
+ previewImageVisibility = VISIBLE;
+ }
+ }
+
+ final int imageSize = resources.getDimensionPixelSize(
+ R.dimen.conversation_list_image_preview_size);
+ mImagePreviewView.setImageResourceId(
+ new UriImageRequestDescriptor(previewImageUri, imageSize, imageSize,
+ true /* allowCompression */, false /* isStatic */, false /*cropToCircle*/,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */));
+ mImagePreviewView.setOnLongClickListener(this);
+ mImagePreviewView.setVisibility(previewImageVisibility);
+ mImagePreviewView.setOnClickListener(previewClickListener);
+ mAudioAttachmentView.setOnLongClickListener(this);
+ mAudioAttachmentView.setVisibility(audioPreviewVisiblity);
+
+ final int notificationBellVisiblity = mData.getNotificationEnabled() ? GONE : VISIBLE;
+ mNotificationBellView.setVisibility(notificationBellVisiblity);
+ }
+
+ public boolean isSwipeAnimatable() {
+ return mHostInterface.isSwipeAnimatable();
+ }
+
+ @VisibleForAnimation
+ public float getSwipeTranslationX() {
+ return mSwipeableContainer.getTranslationX();
+ }
+
+ @VisibleForAnimation
+ public void setSwipeTranslationX(final float translationX) {
+ mSwipeableContainer.setTranslationX(translationX);
+ if (translationX == 0) {
+ mCrossSwipeBackground.setVisibility(View.GONE);
+ mCrossSwipeArchiveLeftImageView.setVisibility(GONE);
+ mCrossSwipeArchiveRightImageView.setVisibility(GONE);
+
+ mSwipeableContainer.setBackgroundColor(Color.TRANSPARENT);
+ } else {
+ mCrossSwipeBackground.setVisibility(View.VISIBLE);
+ if (translationX > 0) {
+ mCrossSwipeArchiveLeftImageView.setVisibility(VISIBLE);
+ mCrossSwipeArchiveRightImageView.setVisibility(GONE);
+ } else {
+ mCrossSwipeArchiveLeftImageView.setVisibility(GONE);
+ mCrossSwipeArchiveRightImageView.setVisibility(VISIBLE);
+ }
+ mSwipeableContainer.setBackgroundResource(R.drawable.swipe_shadow_drag);
+ }
+ }
+
+ public void onSwipeComplete() {
+ final String conversationId = mData.getConversationId();
+ UpdateConversationArchiveStatusAction.archiveConversation(conversationId);
+
+ final Runnable undoRunnable = new Runnable() {
+ @Override
+ public void run() {
+ UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId);
+ }
+ };
+ final String message = getResources().getString(R.string.archived_toast_message, 1);
+ UiUtils.showSnackBar(getContext(), getRootView(), message, undoRunnable,
+ SnackBar.Action.SNACK_BAR_UNDO,
+ mHostInterface.getSnackBarInteractions());
+ }
+
+ private void setShortAndLongClickable(final boolean clickable) {
+ setClickable(clickable);
+ setLongClickable(clickable);
+ }
+
+ private void resetAnimatingState() {
+ mAnimatingCount = 0;
+ setShortAndLongClickable(true);
+ setSwipeTranslationX(0);
+ }
+
+ /**
+ * Notifies this view that it is undergoing animation. This view should disable its click
+ * targets.
+ *
+ * The animating counter is used to reset the swipe controller when the counter becomes 0. A
+ * positive counter also makes the view not clickable.
+ */
+ public final void setAnimating(final boolean animating) {
+ final int oldAnimatingCount = mAnimatingCount;
+ if (animating) {
+ mAnimatingCount++;
+ } else {
+ mAnimatingCount--;
+ if (mAnimatingCount < 0) {
+ mAnimatingCount = 0;
+ }
+ }
+
+ if (mAnimatingCount == 0) {
+ // New count is 0. All animations ended.
+ setShortAndLongClickable(true);
+ } else if (oldAnimatingCount == 0) {
+ // New count is > 0. Waiting for some animations to end.
+ setShortAndLongClickable(false);
+ }
+ }
+
+ public boolean isAnimating() {
+ return mAnimatingCount > 0;
+ }
+
+ /**
+ * {@inheritDoc} from OnClickListener
+ */
+ @Override
+ public void onClick(final View v) {
+ processClick(v, false);
+ }
+
+ /**
+ * {@inheritDoc} from OnLongClickListener
+ */
+ @Override
+ public boolean onLongClick(final View v) {
+ return processClick(v, true);
+ }
+
+ private boolean processClick(final View v, final boolean isLongClick) {
+ Assert.isTrue(v == mSwipeableContainer || v == mContactIconView || v == mImagePreviewView);
+ Assert.notNull(mData.getName());
+
+ if (mHostInterface != null) {
+ mHostInterface.onConversationClicked(mData, isLongClick, this);
+ return true;
+ }
+ return false;
+ }
+
+ public View getSwipeableContent() {
+ return mSwipeableContent;
+ }
+
+ public View getContactIconView() {
+ return mContactIconView;
+ }
+
+ private String getSnippetText() {
+ String snippetText = mData.getShowDraft() ?
+ mData.getDraftSnippetText() : mData.getSnippetText();
+ final String previewContentType = mData.getShowDraft() ?
+ mData.getDraftPreviewContentType() : mData.getPreviewContentType();
+ if (TextUtils.isEmpty(snippetText)) {
+ Resources resources = getResources();
+ // Use the attachment type as a snippet so the preview doesn't look odd
+ if (ContentType.isAudioType(previewContentType)) {
+ snippetText = resources.getString(R.string.conversation_list_snippet_audio_clip);
+ } else if (ContentType.isImageType(previewContentType)) {
+ snippetText = resources.getString(R.string.conversation_list_snippet_picture);
+ } else if (ContentType.isVideoType(previewContentType)) {
+ snippetText = resources.getString(R.string.conversation_list_snippet_video);
+ } else if (ContentType.isVCardType(previewContentType)) {
+ snippetText = resources.getString(R.string.conversation_list_snippet_vcard);
+ }
+ }
+ return snippetText;
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java b/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java
new file mode 100644
index 0000000..4988259
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java
@@ -0,0 +1,462 @@
+/*
+ * 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.messaging.ui.conversationlist;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ObjectAnimator;
+import android.animation.TimeInterpolator;
+import android.content.Context;
+import android.content.res.Resources;
+import android.support.v4.view.ViewCompat;
+import android.support.v7.widget.RecyclerView;
+import android.support.v7.widget.RecyclerView.OnItemTouchListener;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ViewConfiguration;
+
+import com.android.messaging.R;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * Animation and touch helper class for Conversation List swipe.
+ */
+public class ConversationListSwipeHelper implements OnItemTouchListener {
+ private static final int UNIT_SECONDS = 1000;
+ private static final boolean ANIMATING = true;
+
+ private static final float ERROR_FACTOR_MULTIPLIER = 1.2f;
+ private static final float PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.4f;
+ private static final float FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.05f;
+
+ private static final int SWIPE_DIRECTION_NONE = 0;
+ private static final int SWIPE_DIRECTION_LEFT = 1;
+ private static final int SWIPE_DIRECTION_RIGHT = 2;
+
+ private final RecyclerView mRecyclerView;
+ private final long mDefaultRestoreAnimationDuration;
+ private final long mDefaultDismissAnimationDuration;
+ private final long mMaxTranslationAnimationDuration;
+ private final int mTouchSlop;
+ private final int mMinimumFlingVelocity;
+ private final int mMaximumFlingVelocity;
+
+ /* Valid throughout a single gesture. */
+ private VelocityTracker mVelocityTracker;
+ private float mInitialX;
+ private float mInitialY;
+ private boolean mIsSwiping;
+ private ConversationListItemView mListItemView;
+
+ public ConversationListSwipeHelper(final RecyclerView recyclerView) {
+ mRecyclerView = recyclerView;
+
+ final Context context = mRecyclerView.getContext();
+ final Resources res = context.getResources();
+ mDefaultRestoreAnimationDuration = res.getInteger(R.integer.swipe_duration_ms);
+ mDefaultDismissAnimationDuration = res.getInteger(R.integer.swipe_duration_ms);
+ mMaxTranslationAnimationDuration = res.getInteger(R.integer.swipe_duration_ms);
+
+ final ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
+ mTouchSlop = viewConfiguration.getScaledPagingTouchSlop();
+ mMaximumFlingVelocity = Math.min(
+ viewConfiguration.getScaledMaximumFlingVelocity(),
+ res.getInteger(R.integer.swipe_max_fling_velocity_px_per_s));
+ mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity();
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(final RecyclerView recyclerView, final MotionEvent event) {
+ if (event.getPointerCount() > 1) {
+ // Ignore subsequent pointers.
+ return false;
+ }
+
+ // We are not yet tracking a swipe gesture. Begin detection by spying on
+ // touch events bubbling down to our children.
+ final int action = event.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ if (!hasGestureSwipeTarget()) {
+ onGestureStart();
+
+ mVelocityTracker.addMovement(event);
+ mInitialX = event.getX();
+ mInitialY = event.getY();
+
+ final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY);
+ final ConversationListItemView child = (ConversationListItemView) viewAtPoint;
+ if (viewAtPoint instanceof ConversationListItemView &&
+ child != null && child.isSwipeAnimatable()) {
+ // Begin detecting swipe on the target for the rest of the gesture.
+ mListItemView = child;
+ if (mListItemView.isAnimating()) {
+ mListItemView = null;
+ }
+ } else {
+ mListItemView = null;
+ }
+ }
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (hasValidGestureSwipeTarget()) {
+ mVelocityTracker.addMovement(event);
+
+ final int historicalCount = event.getHistorySize();
+ // First consume the historical events, then consume the current ones.
+ for (int i = 0; i < historicalCount + 1; i++) {
+ float currX;
+ float currY;
+ if (i < historicalCount) {
+ currX = event.getHistoricalX(i);
+ currY = event.getHistoricalY(i);
+ } else {
+ currX = event.getX();
+ currY = event.getY();
+ }
+ final float deltaX = currX - mInitialX;
+ final float deltaY = currY - mInitialY;
+ final float absDeltaX = Math.abs(deltaX);
+ final float absDeltaY = Math.abs(deltaY);
+
+ if (!mIsSwiping && absDeltaY > mTouchSlop
+ && absDeltaY > (ERROR_FACTOR_MULTIPLIER * absDeltaX)) {
+ // Stop detecting swipe for the remainder of this gesture.
+ onGestureEnd();
+ return false;
+ }
+
+ if (absDeltaX > mTouchSlop) {
+ // Swipe detected. Return true so we can handle the gesture in
+ // onTouchEvent.
+ mIsSwiping = true;
+
+ // We don't want to suddenly jump the slop distance.
+ mInitialX = event.getX();
+ mInitialY = event.getY();
+
+ onSwipeGestureStart(mListItemView);
+ return true;
+ }
+ }
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ if (hasGestureSwipeTarget()) {
+ onGestureEnd();
+ }
+ break;
+ }
+
+ // Start intercepting touch events from children if we detect a swipe.
+ return mIsSwiping;
+ }
+
+ @Override
+ public void onTouchEvent(final RecyclerView recyclerView, final MotionEvent event) {
+ // We should only be here if we intercepted the touch due to swipe.
+ Assert.isTrue(mIsSwiping);
+
+ // We are now tracking a swipe gesture.
+ mVelocityTracker.addMovement(event);
+
+ final int action = event.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_OUTSIDE:
+ case MotionEvent.ACTION_MOVE:
+ if (hasValidGestureSwipeTarget()) {
+ mListItemView.setSwipeTranslationX(event.getX() - mInitialX);
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ if (hasValidGestureSwipeTarget()) {
+ final float maxVelocity = mMaximumFlingVelocity;
+ mVelocityTracker.computeCurrentVelocity(UNIT_SECONDS, maxVelocity);
+ final float velocityX = getLastComputedXVelocity();
+
+ final float translationX = mListItemView.getSwipeTranslationX();
+
+ int swipeDirection = SWIPE_DIRECTION_NONE;
+ if (translationX != 0) {
+ swipeDirection =
+ translationX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT;
+ } else if (velocityX != 0) {
+ swipeDirection =
+ velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT;
+ }
+
+ final boolean fastEnough = isTargetSwipedFastEnough();
+ final boolean farEnough = isTargetSwipedFarEnough();
+
+ final boolean shouldDismiss = (fastEnough || farEnough);
+
+ if (shouldDismiss) {
+ if (fastEnough) {
+ animateDismiss(mListItemView, velocityX);
+ } else {
+ animateDismiss(mListItemView, swipeDirection);
+ }
+ } else {
+ animateRestore(mListItemView, velocityX);
+ }
+
+ onSwipeGestureEnd(mListItemView,
+ shouldDismiss ? swipeDirection : SWIPE_DIRECTION_NONE);
+ } else {
+ onGestureEnd();
+ }
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ if (hasValidGestureSwipeTarget()) {
+ animateRestore(mListItemView, 0f);
+ onSwipeGestureEnd(mListItemView, SWIPE_DIRECTION_NONE);
+ } else {
+ onGestureEnd();
+ }
+ break;
+ }
+ }
+
+
+ @Override
+ public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
+ }
+
+ /**
+ * We have started to intercept a series of touch events.
+ */
+ private void onGestureStart() {
+ mIsSwiping = false;
+ // Work around bug in RecyclerView that sends two identical ACTION_DOWN
+ // events to #onInterceptTouchEvent.
+ if (mVelocityTracker == null) {
+ mVelocityTracker = VelocityTracker.obtain();
+ }
+ mVelocityTracker.clear();
+ }
+
+ /**
+ * The series of touch events has been detected as a swipe.
+ *
+ * Now that the gesture is a swipe, we will begin translating the view of the
+ * given viewHolder.
+ */
+ private void onSwipeGestureStart(final ConversationListItemView itemView) {
+ mRecyclerView.getParent().requestDisallowInterceptTouchEvent(true);
+ setHardwareAnimatingLayerType(itemView, ANIMATING);
+ itemView.setAnimating(true);
+ }
+
+ /**
+ * The current swipe gesture is complete.
+ */
+ private void onSwipeGestureEnd(final ConversationListItemView itemView,
+ final int swipeDirection) {
+ if (swipeDirection == SWIPE_DIRECTION_RIGHT || swipeDirection == SWIPE_DIRECTION_LEFT) {
+ itemView.onSwipeComplete();
+ }
+
+ // Balances out onSwipeGestureStart.
+ itemView.setAnimating(false);
+
+ onGestureEnd();
+ }
+
+ /**
+ * The series of touch events has ended in an {@link MotionEvent#ACTION_UP}
+ * or {@link MotionEvent#ACTION_CANCEL}.
+ */
+ private void onGestureEnd() {
+ mVelocityTracker.recycle();
+ mVelocityTracker = null;
+ mIsSwiping = false;
+ mListItemView = null;
+ }
+
+ /**
+ * A swipe animation has started.
+ */
+ private void onSwipeAnimationStart(final ConversationListItemView itemView) {
+ // Disallow interactions.
+ itemView.setAnimating(true);
+ ViewCompat.setHasTransientState(itemView, true);
+ setHardwareAnimatingLayerType(itemView, ANIMATING);
+ }
+
+ /**
+ * The swipe animation has ended.
+ */
+ private void onSwipeAnimationEnd(final ConversationListItemView itemView) {
+ // Restore interactions.
+ itemView.setAnimating(false);
+ ViewCompat.setHasTransientState(itemView, false);
+ setHardwareAnimatingLayerType(itemView, !ANIMATING);
+ }
+
+ /**
+ * Animate the dismissal of the given item. The given velocityX is taken into consideration for
+ * the animation duration. Whether the item is dismissed to the left or right is dependent on
+ * the given velocityX.
+ */
+ private void animateDismiss(final ConversationListItemView itemView, final float velocityX) {
+ Assert.isTrue(velocityX != 0);
+ final int direction = velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT;
+ animateDismiss(itemView, direction, velocityX);
+ }
+
+ /**
+ * Animate the dismissal of the given item. The velocityX is assumed to be 0.
+ */
+ private void animateDismiss(final ConversationListItemView itemView, final int swipeDirection) {
+ animateDismiss(itemView, swipeDirection, 0f);
+ }
+
+ /**
+ * Animate the dismissal of the given item.
+ */
+ private void animateDismiss(final ConversationListItemView itemView,
+ final int swipeDirection, final float velocityX) {
+ Assert.isTrue(swipeDirection != SWIPE_DIRECTION_NONE);
+
+ onSwipeAnimationStart(itemView);
+
+ final float animateTo = (swipeDirection == SWIPE_DIRECTION_RIGHT) ?
+ mRecyclerView.getWidth() : -mRecyclerView.getWidth();
+ final long duration;
+ if (velocityX != 0) {
+ final float deltaX = animateTo - itemView.getSwipeTranslationX();
+ duration = calculateTranslationDuration(deltaX, velocityX);
+ } else {
+ duration = mDefaultDismissAnimationDuration;
+ }
+
+ final ObjectAnimator animator = getSwipeTranslationXAnimator(
+ itemView, animateTo, duration, UiUtils.DEFAULT_INTERPOLATOR);
+ animator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ onSwipeAnimationEnd(itemView);
+ }
+ });
+ animator.start();
+ }
+
+ /**
+ * Animate the bounce back of the given item.
+ */
+ private void animateRestore(final ConversationListItemView itemView,
+ final float velocityX) {
+ onSwipeAnimationStart(itemView);
+
+ final float translationX = itemView.getSwipeTranslationX();
+ final long duration;
+ if (velocityX != 0 // Has velocity.
+ && velocityX > 0 != translationX > 0) { // Right direction.
+ duration = calculateTranslationDuration(translationX, velocityX);
+ } else {
+ duration = mDefaultRestoreAnimationDuration;
+ }
+ final ObjectAnimator animator = getSwipeTranslationXAnimator(
+ itemView, 0f, duration, UiUtils.DEFAULT_INTERPOLATOR);
+ animator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ onSwipeAnimationEnd(itemView);
+ }
+ });
+ animator.start();
+ }
+
+ /**
+ * Create and start an animator that animates the given view's translationX
+ * from its current value to the value given by animateTo.
+ */
+ private ObjectAnimator getSwipeTranslationXAnimator(final ConversationListItemView itemView,
+ final float animateTo, final long duration, final TimeInterpolator interpolator) {
+ final ObjectAnimator animator =
+ ObjectAnimator.ofFloat(itemView, "swipeTranslationX", animateTo);
+ animator.setDuration(duration);
+ animator.setInterpolator(interpolator);
+ return animator;
+ }
+
+ /**
+ * Determine if the swipe has enough velocity to be dismissed.
+ */
+ private boolean isTargetSwipedFastEnough() {
+ final float velocityX = getLastComputedXVelocity();
+ final float velocityY = mVelocityTracker.getYVelocity();
+ final float minVelocity = mMinimumFlingVelocity;
+ final float translationX = mListItemView.getSwipeTranslationX();
+ final float width = mListItemView.getWidth();
+ return (Math.abs(velocityX) > minVelocity) // Fast enough.
+ && (Math.abs(velocityX) > Math.abs(velocityY)) // Not unintentional.
+ && (velocityX > 0) == (translationX > 0) // Right direction.
+ && Math.abs(translationX) >
+ FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement.
+ }
+
+ /**
+ * Only used during a swipe gesture. Determine if the swipe has enough distance to be
+ * dismissed.
+ */
+ private boolean isTargetSwipedFarEnough() {
+ final float velocityX = getLastComputedXVelocity();
+
+ final float translationX = mListItemView.getSwipeTranslationX();
+ final float width = mListItemView.getWidth();
+
+ return (velocityX >= 0) == (translationX > 0) // Right direction.
+ && Math.abs(translationX) >
+ PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement.
+ }
+
+ private long calculateTranslationDuration(final float deltaPosition, final float velocity) {
+ Assert.isTrue(velocity != 0);
+ final float durationInSeconds = Math.abs(deltaPosition / velocity);
+ return Math.min((int) (durationInSeconds * UNIT_SECONDS), mMaxTranslationAnimationDuration);
+ }
+
+ private boolean hasGestureSwipeTarget() {
+ return mListItemView != null;
+ }
+
+ private boolean hasValidGestureSwipeTarget() {
+ return hasGestureSwipeTarget() && mListItemView.getParent() == mRecyclerView;
+ }
+
+ /**
+ * Enable a hardware layer for the it view and build that layer.
+ */
+ private void setHardwareAnimatingLayerType(final ConversationListItemView itemView,
+ final boolean animating) {
+ if (animating) {
+ itemView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
+ if (itemView.getWindowToken() != null) {
+ itemView.buildLayer();
+ }
+ } else {
+ itemView.setLayerType(View.LAYER_TYPE_NONE, null);
+ }
+ }
+
+ private float getLastComputedXVelocity() {
+ return mVelocityTracker.getXVelocity();
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java b/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java
new file mode 100644
index 0000000..61e3640
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.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.messaging.ui.conversationlist;
+
+import android.app.Fragment;
+import android.os.Bundle;
+
+import com.android.messaging.datamodel.data.ConversationListData;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.ui.BaseBugleActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.conversationlist.ConversationListFragment.ConversationListFragmentHost;
+import com.android.messaging.util.Assert;
+
+/**
+ * An activity that lets the user forward a SMS/MMS message by picking from a conversation in the
+ * conversation list.
+ */
+public class ForwardMessageActivity extends BaseBugleActivity
+ implements ConversationListFragmentHost {
+ private MessageData mDraftMessage;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final ConversationListFragment fragment =
+ ConversationListFragment.createForwardMessageConversationListFragment();
+ getFragmentManager().beginTransaction().add(android.R.id.content, fragment).commit();
+ mDraftMessage = getIntent().getParcelableExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA);
+ }
+
+ @Override
+ public void onAttachFragment(final Fragment fragment) {
+ Assert.isTrue(fragment instanceof ConversationListFragment);
+ final ConversationListFragment clf = (ConversationListFragment) fragment;
+ clf.setHost(this);
+ }
+
+ @Override
+ public void onConversationClick(final ConversationListData listData,
+ final ConversationListItemData conversationListItemData,
+ final boolean isLongClick, final ConversationListItemView converastionView) {
+ UIIntents.get().launchConversationActivity(
+ this, conversationListItemData.getConversationId(), mDraftMessage);
+ }
+
+ @Override
+ public void onCreateConversationClick() {
+ UIIntents.get().launchCreateNewConversationActivity(this, mDraftMessage);
+ }
+
+ @Override
+ public boolean isConversationSelected(final String conversationId) {
+ return false;
+ }
+
+ @Override
+ public boolean isSwipeAnimatable() {
+ return false;
+ }
+
+ @Override
+ public boolean isSelectionMode() {
+ return false;
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java b/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java
new file mode 100644
index 0000000..bfeec51
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.ui.conversationlist;
+
+import android.support.v4.util.ArrayMap;
+import android.text.TextUtils;
+import android.view.ActionMode;
+import android.view.ActionMode.Callback;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ConversationListData;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.util.Assert;
+
+import java.util.Collection;
+import java.util.HashSet;
+
+public class MultiSelectActionModeCallback implements Callback {
+ private HashSet<String> mBlockedSet;
+
+ public interface Listener {
+ void onActionBarDelete(Collection<SelectedConversation> conversations);
+ void onActionBarArchive(Iterable<SelectedConversation> conversations,
+ boolean isToArchive);
+ void onActionBarNotification(Iterable<SelectedConversation> conversations,
+ boolean isNotificationOn);
+ void onActionBarAddContact(final SelectedConversation conversation);
+ void onActionBarBlock(final SelectedConversation conversation);
+ void onActionBarHome();
+ }
+
+ static class SelectedConversation {
+ public final String conversationId;
+ public final long timestamp;
+ public final String icon;
+ public final String otherParticipantNormalizedDestination;
+ public final CharSequence participantLookupKey;
+ public final boolean isGroup;
+ public final boolean isArchived;
+ public final boolean notificationEnabled;
+ public SelectedConversation(ConversationListItemData data) {
+ conversationId = data.getConversationId();
+ timestamp = data.getTimestamp();
+ icon = data.getIcon();
+ otherParticipantNormalizedDestination = data.getOtherParticipantNormalizedDestination();
+ participantLookupKey = data.getParticipantLookupKey();
+ isGroup = data.getIsGroup();
+ isArchived = data.getIsArchived();
+ notificationEnabled = data.getNotificationEnabled();
+ }
+ }
+
+ private final ArrayMap<String, SelectedConversation> mSelectedConversations;
+
+ private Listener mListener;
+ private MenuItem mArchiveMenuItem;
+ private MenuItem mUnarchiveMenuItem;
+ private MenuItem mAddContactMenuItem;
+ private MenuItem mBlockMenuItem;
+ private MenuItem mNotificationOnMenuItem;
+ private MenuItem mNotificationOffMenuItem;
+ private boolean mHasInflated;
+
+ public MultiSelectActionModeCallback(final Listener listener) {
+ mListener = listener;
+ mSelectedConversations = new ArrayMap<>();
+
+ }
+
+ @Override
+ public boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
+ actionMode.getMenuInflater().inflate(R.menu.conversation_list_fragment_select_menu, menu);
+ mArchiveMenuItem = menu.findItem(R.id.action_archive);
+ mUnarchiveMenuItem = menu.findItem(R.id.action_unarchive);
+ mAddContactMenuItem = menu.findItem(R.id.action_add_contact);
+ mBlockMenuItem = menu.findItem(R.id.action_block);
+ mNotificationOffMenuItem = menu.findItem(R.id.action_notification_off);
+ mNotificationOnMenuItem = menu.findItem(R.id.action_notification_on);
+ mHasInflated = true;
+ updateActionIconsVisiblity();
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
+ return true;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
+ switch(menuItem.getItemId()) {
+ case R.id.action_delete:
+ mListener.onActionBarDelete(mSelectedConversations.values());
+ return true;
+ case R.id.action_archive:
+ mListener.onActionBarArchive(mSelectedConversations.values(), true);
+ return true;
+ case R.id.action_unarchive:
+ mListener.onActionBarArchive(mSelectedConversations.values(), false);
+ return true;
+ case R.id.action_notification_off:
+ mListener.onActionBarNotification(mSelectedConversations.values(), false);
+ return true;
+ case R.id.action_notification_on:
+ mListener.onActionBarNotification(mSelectedConversations.values(), true);
+ return true;
+ case R.id.action_add_contact:
+ Assert.isTrue(mSelectedConversations.size() == 1);
+ mListener.onActionBarAddContact(mSelectedConversations.valueAt(0));
+ return true;
+ case R.id.action_block:
+ Assert.isTrue(mSelectedConversations.size() == 1);
+ mListener.onActionBarBlock(mSelectedConversations.valueAt(0));
+ return true;
+ case android.R.id.home:
+ mListener.onActionBarHome();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode actionMode) {
+ mListener = null;
+ mSelectedConversations.clear();
+ mHasInflated = false;
+ }
+
+ public void toggleSelect(final ConversationListData listData,
+ final ConversationListItemData conversationListItemData) {
+ Assert.notNull(conversationListItemData);
+ mBlockedSet = listData.getBlockedParticipants();
+ final String id = conversationListItemData.getConversationId();
+ if (mSelectedConversations.containsKey(id)) {
+ mSelectedConversations.remove(id);
+ } else {
+ mSelectedConversations.put(id, new SelectedConversation(conversationListItemData));
+ }
+
+ if (mSelectedConversations.isEmpty()) {
+ mListener.onActionBarHome();
+ } else {
+ updateActionIconsVisiblity();
+ }
+ }
+
+ public boolean isSelected(final String selectedId) {
+ return mSelectedConversations.containsKey(selectedId);
+ }
+
+ private void updateActionIconsVisiblity() {
+ if (!mHasInflated) {
+ return;
+ }
+
+ if (mSelectedConversations.size() == 1) {
+ final SelectedConversation conversation = mSelectedConversations.valueAt(0);
+ // The look up key is a key given to us by contacts app, so if we have a look up key,
+ // we know that the participant is already in contacts.
+ final boolean isInContacts = !TextUtils.isEmpty(conversation.participantLookupKey);
+ mAddContactMenuItem.setVisible(!conversation.isGroup && !isInContacts);
+ // ParticipantNormalizedDestination is always null for group conversations.
+ final String otherParticipant = conversation.otherParticipantNormalizedDestination;
+ mBlockMenuItem.setVisible(otherParticipant != null
+ && !mBlockedSet.contains(otherParticipant));
+ } else {
+ mBlockMenuItem.setVisible(false);
+ mAddContactMenuItem.setVisible(false);
+ }
+
+ boolean hasCurrentlyArchived = false;
+ boolean hasCurrentlyUnarchived = false;
+ boolean hasCurrentlyOnNotification = false;
+ boolean hasCurrentlyOffNotification = false;
+ final Iterable<SelectedConversation> conversations = mSelectedConversations.values();
+ for (final SelectedConversation conversation : conversations) {
+ if (conversation.notificationEnabled) {
+ hasCurrentlyOnNotification = true;
+ } else {
+ hasCurrentlyOffNotification = true;
+ }
+
+ if (conversation.isArchived) {
+ hasCurrentlyArchived = true;
+ } else {
+ hasCurrentlyUnarchived = true;
+ }
+
+ // If we found at least one of each example we don't need to keep looping.
+ if (hasCurrentlyOffNotification && hasCurrentlyOnNotification &&
+ hasCurrentlyArchived && hasCurrentlyUnarchived) {
+ break;
+ }
+ }
+ // If we have notification off conversations we show on button, if we have notification on
+ // conversation we show off button. We can show both if we have a mixture.
+ mNotificationOffMenuItem.setVisible(hasCurrentlyOnNotification);
+ mNotificationOnMenuItem.setVisible(hasCurrentlyOffNotification);
+
+ mArchiveMenuItem.setVisible(hasCurrentlyUnarchived);
+ mUnarchiveMenuItem.setVisible(hasCurrentlyArchived);
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java b/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java
new file mode 100644
index 0000000..ef7fcef
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.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.messaging.ui.conversationlist;
+
+import android.app.Fragment;
+import android.content.ContentResolver;
+import android.content.Intent;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.PendingAttachmentData;
+import com.android.messaging.ui.BaseBugleActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.MediaMetadataRetrieverWrapper;
+
+import java.io.IOException;
+import java.util.ArrayList;
+
+public class ShareIntentActivity extends BaseBugleActivity implements
+ ShareIntentFragment.HostInterface {
+
+ private MessageData mDraftMessage;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Intent intent = getIntent();
+ if (Intent.ACTION_SEND.equals(intent.getAction()) &&
+ (!TextUtils.isEmpty(intent.getStringExtra("address")) ||
+ !TextUtils.isEmpty(intent.getStringExtra(Intent.EXTRA_EMAIL)))) {
+ // This is really more like a SENDTO intent because a destination is supplied.
+ // It's coming through the SEND intent because that's the intent that is used
+ // when invoking the chooser with Intent.createChooser().
+ final Intent convIntent = UIIntents.get().getLaunchConversationActivityIntent(this);
+ // Copy the important items from the original intent to the new intent.
+ convIntent.putExtras(intent);
+ convIntent.setAction(Intent.ACTION_SENDTO);
+ convIntent.setDataAndType(intent.getData(), intent.getType());
+ // We have to fire off the intent and finish before trying to show the fragment,
+ // otherwise we get some flashing.
+ startActivity(convIntent);
+ finish();
+ return;
+ }
+ new ShareIntentFragment().show(getFragmentManager(), "ShareIntentFragment");
+ }
+
+ @Override
+ public void onAttachFragment(final Fragment fragment) {
+ final Intent intent = getIntent();
+ final String action = intent.getAction();
+ if (Intent.ACTION_SEND.equals(action)) {
+ final Uri contentUri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
+ final String contentType = extractContentType(contentUri, intent.getType());
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
+ LogUtil.d(LogUtil.BUGLE_TAG, String.format(
+ "onAttachFragment: contentUri=%s, intent.getType()=%s, inferredType=%s",
+ contentUri, intent.getType(), contentType));
+ }
+ if (ContentType.TEXT_PLAIN.equals(contentType)) {
+ final String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT);
+ if (sharedText != null) {
+ mDraftMessage = MessageData.createSharedMessage(sharedText);
+ } else {
+ mDraftMessage = null;
+ }
+ } else if (ContentType.isImageType(contentType) ||
+ ContentType.isVCardType(contentType) ||
+ ContentType.isAudioType(contentType) ||
+ ContentType.isVideoType(contentType)) {
+ if (contentUri != null) {
+ mDraftMessage = MessageData.createSharedMessage(null);
+ addSharedImagePartToDraft(contentType, contentUri);
+ } else {
+ mDraftMessage = null;
+ }
+ } else {
+ // Unsupported content type.
+ Assert.fail("Unsupported shared content type for " + contentUri + ": " + contentType
+ + " (" + intent.getType() + ")");
+ }
+ } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
+ final String contentType = intent.getType();
+ if (ContentType.isImageType(contentType)) {
+ // Handle sharing multiple images.
+ final ArrayList<Uri> imageUris = intent.getParcelableArrayListExtra(
+ Intent.EXTRA_STREAM);
+ if (imageUris != null && imageUris.size() > 0) {
+ mDraftMessage = MessageData.createSharedMessage(null);
+ for (final Uri imageUri : imageUris) {
+ final String actualContentType = extractContentType(imageUri, contentType);
+ addSharedImagePartToDraft(actualContentType, imageUri);
+ }
+ } else {
+ mDraftMessage = null;
+ }
+ } else {
+ // Unsupported content type.
+ Assert.fail("Unsupported shared content type: " + contentType);
+ }
+ } else {
+ // Unsupported action.
+ Assert.fail("Unsupported action type for sharing: " + action);
+ }
+ }
+
+ private static String extractContentType(final Uri uri, final String contentType) {
+ if (uri == null) {
+ return contentType;
+ }
+ // First try looking at file extension. This is less reliable in some ways but it's
+ // recommended by
+ // https://developer.android.com/training/secure-file-sharing/retrieve-info.html
+ // Some implementations of MediaMetadataRetriever get things horribly
+ // wrong for common formats such as jpeg (reports as video/ffmpeg)
+ final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver();
+ final String typeFromExtension = resolver.getType(uri);
+ if (typeFromExtension != null) {
+ return typeFromExtension;
+ }
+ final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper();
+ try {
+ retriever.setDataSource(uri);
+ final String extractedType = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_MIMETYPE);
+ if (extractedType != null) {
+ return extractedType;
+ }
+ } catch (final IOException e) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "Could not determine type of " + uri, e);
+ } finally {
+ retriever.release();
+ }
+ return contentType;
+ }
+
+ private void addSharedImagePartToDraft(final String contentType, final Uri imageUri) {
+ mDraftMessage.addPart(PendingAttachmentData.createPendingAttachmentData(contentType,
+ imageUri));
+ }
+
+ @Override
+ public void onConversationClick(final ConversationListItemData conversationListItemData) {
+ UIIntents.get().launchConversationActivity(
+ this, conversationListItemData.getConversationId(), mDraftMessage);
+ finish();
+ }
+
+ @Override
+ public void onCreateConversationClick() {
+ UIIntents.get().launchCreateNewConversationActivity(this, mDraftMessage);
+ finish();
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java b/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java
new file mode 100644
index 0000000..e894145
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.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.messaging.ui.conversationlist;
+
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.data.PersonItemData;
+import com.android.messaging.ui.CursorRecyclerAdapter;
+import com.android.messaging.ui.PersonItemView;
+import com.android.messaging.ui.PersonItemView.PersonItemViewListener;
+import com.android.messaging.util.PhoneUtils;
+
+/**
+ * Turn conversation rows into PeopleItemViews
+ */
+public class ShareIntentAdapter
+ extends CursorRecyclerAdapter<ShareIntentAdapter.ShareIntentViewHolder> {
+
+ public interface HostInterface {
+ void onConversationClicked(final ConversationListItemData conversationListItemData);
+ }
+
+ private final HostInterface mHostInterface;
+
+ public ShareIntentAdapter(final Context context, final Cursor cursor,
+ final HostInterface hostInterface) {
+ super(context, cursor, 0);
+ mHostInterface = hostInterface;
+ setHasStableIds(true);
+ }
+
+ @Override
+ public void bindViewHolder(final ShareIntentViewHolder holder, final Context context,
+ final Cursor cursor) {
+ holder.bind(cursor);
+ }
+
+ @Override
+ public ShareIntentViewHolder createViewHolder(final Context context,
+ final ViewGroup parent, final int viewType) {
+ final PersonItemView itemView = (PersonItemView) LayoutInflater.from(context).inflate(
+ R.layout.people_list_item_view, null);
+ return new ShareIntentViewHolder(itemView);
+ }
+
+ /**
+ * Holds a PersonItemView and keeps it synced with a ConversationListItemData.
+ */
+ public class ShareIntentViewHolder extends RecyclerView.ViewHolder implements
+ PersonItemView.PersonItemViewListener {
+ private final ConversationListItemData mData = new ConversationListItemData();
+ private final PersonItemData mItemData = new PersonItemData() {
+ @Override
+ public Uri getAvatarUri() {
+ return mData.getIcon() == null ? null : Uri.parse(mData.getIcon());
+ }
+
+ @Override
+ public String getDisplayName() {
+ return mData.getName();
+ }
+
+ @Override
+ public String getDetails() {
+ final String conversationName = mData.getName();
+ final String conversationPhone = PhoneUtils.getDefault().formatForDisplay(
+ mData.getOtherParticipantNormalizedDestination());
+ if (conversationPhone == null || conversationPhone.equals(conversationName)) {
+ return null;
+ }
+ return conversationPhone;
+ }
+
+ @Override
+ public Intent getClickIntent() {
+ return null;
+ }
+
+ @Override
+ public long getContactId() {
+ return ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED;
+ }
+
+ @Override
+ public String getLookupKey() {
+ return null;
+ }
+
+ @Override
+ public String getNormalizedDestination() {
+ return null;
+ }
+ };
+
+ public ShareIntentViewHolder(final PersonItemView itemView) {
+ super(itemView);
+ itemView.setListener(this);
+ }
+
+ public void bind(Cursor cursor) {
+ mData.bind(cursor);
+ ((PersonItemView) itemView).bind(mItemData);
+ }
+
+ @Override
+ public void onPersonClicked(PersonItemData data) {
+ mHostInterface.onConversationClicked(mData);
+ }
+
+ @Override
+ public boolean onPersonLongClicked(PersonItemData data) {
+ return false;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java b/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java
new file mode 100644
index 0000000..bc549ea
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java
@@ -0,0 +1,163 @@
+/*
+ * 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.messaging.ui.conversationlist;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.AlertDialog.Builder;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.ConversationListData;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener;
+import com.android.messaging.ui.ListEmptyView;
+import com.android.messaging.datamodel.DataModel;
+
+/**
+ * Allow user to pick conversation to which an incoming attachment will be shared.
+ */
+public class ShareIntentFragment extends DialogFragment implements ConversationListDataListener,
+ ShareIntentAdapter.HostInterface {
+ public static final String HIDE_NEW_CONVERSATION_BUTTON_KEY = "hide_conv_button_key";
+
+ public interface HostInterface {
+ public void onConversationClick(final ConversationListItemData conversationListItemData);
+ public void onCreateConversationClick();
+ }
+
+ private final Binding<ConversationListData> mListBinding = BindingBase.createBinding(this);
+ private RecyclerView mRecyclerView;
+ private ListEmptyView mEmptyListMessageView;
+ private ShareIntentAdapter mAdapter;
+ private HostInterface mHost;
+ private boolean mDismissed;
+
+ /**
+ * {@inheritDoc} from Fragment
+ */
+ @Override
+ public Dialog onCreateDialog(final Bundle bundle) {
+ final Activity activity = getActivity();
+ final LayoutInflater inflater = activity.getLayoutInflater();
+ View view = inflater.inflate(R.layout.share_intent_conversation_list_view, null);
+ mEmptyListMessageView = (ListEmptyView) view.findViewById(R.id.no_conversations_view);
+ mEmptyListMessageView.setImageHint(R.drawable.ic_oobe_conv_list);
+ // The default behavior for default layout param generation by LinearLayoutManager is to
+ // provide width and height of WRAP_CONTENT, but this is not desirable for
+ // ShareIntentFragment; the view in each row should be a width of MATCH_PARENT so that
+ // the entire row is tappable.
+ final LinearLayoutManager manager = new LinearLayoutManager(activity) {
+ @Override
+ public RecyclerView.LayoutParams generateDefaultLayoutParams() {
+ return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ }
+ };
+ mListBinding.getData().init(getLoaderManager(), mListBinding);
+ mAdapter = new ShareIntentAdapter(activity, null, this);
+ mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list);
+ mRecyclerView.setLayoutManager(manager);
+ mRecyclerView.setHasFixedSize(true);
+ mRecyclerView.setAdapter(mAdapter);
+ final Builder dialogBuilder = new AlertDialog.Builder(activity)
+ .setView(view)
+ .setTitle(R.string.share_intent_activity_label);
+
+ final Bundle arguments = getArguments();
+ if (arguments == null || !arguments.getBoolean(HIDE_NEW_CONVERSATION_BUTTON_KEY)) {
+ dialogBuilder.setPositiveButton(R.string.share_new_message, new OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ mDismissed = true;
+ mHost.onCreateConversationClick();
+ }
+ });
+ }
+ return dialogBuilder.setNegativeButton(R.string.share_cancel, null)
+ .create();
+ }
+
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ if (!mDismissed) {
+ final Activity activity = getActivity();
+ if (activity != null) {
+ activity.finish();
+ }
+ }
+ }
+
+ /**
+ * {@inheritDoc} from Fragment
+ */
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mListBinding.unbind();
+ }
+
+ @Override
+ public void onAttach(final Activity activity) {
+ super.onAttach(activity);
+ if (activity instanceof HostInterface) {
+ mHost = (HostInterface) activity;
+ }
+ mListBinding.bind(DataModel.get().createConversationListData(activity, this, false));
+ }
+
+ @Override
+ public void onConversationListCursorUpdated(final ConversationListData data,
+ final Cursor cursor) {
+ mListBinding.ensureBound(data);
+ mAdapter.swapCursor(cursor);
+ updateEmptyListUi(cursor == null || cursor.getCount() == 0);
+ }
+
+ /**
+ * {@inheritDoc} from SharIntentItemView.HostInterface
+ */
+ @Override
+ public void onConversationClicked(final ConversationListItemData conversationListItemData) {
+ mHost.onConversationClick(conversationListItemData);
+ }
+
+ // Show and hide empty list UI as needed with appropriate text based on view specifics
+ private void updateEmptyListUi(final boolean isEmpty) {
+ if (isEmpty) {
+ mEmptyListMessageView.setTextHint(R.string.conversation_list_empty_text);
+ mEmptyListMessageView.setVisibility(View.VISIBLE);
+ } else {
+ mEmptyListMessageView.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void setBlockedParticipantsAvailable(boolean blockedAvailable) {
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationsettings/CopyContactDetailDialog.java b/src/com/android/messaging/ui/conversationsettings/CopyContactDetailDialog.java
new file mode 100644
index 0000000..d727001
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationsettings/CopyContactDetailDialog.java
@@ -0,0 +1,66 @@
+/*
+ * 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.messaging.ui.conversationsettings;
+
+import android.app.AlertDialog;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.util.AccessibilityUtil;
+
+public class CopyContactDetailDialog implements DialogInterface.OnClickListener {
+
+ private final Context mContext;
+ private final String mContactDetail;
+
+ public CopyContactDetailDialog(final Context context, final String contactDetail) {
+ mContext = context;
+ mContactDetail = contactDetail;
+ }
+
+ public void show() {
+ new AlertDialog.Builder(mContext)
+ .setView(createBodyView())
+ .setTitle(R.string.copy_to_clipboard_dialog_title)
+ .setPositiveButton(R.string.copy_to_clipboard, this)
+ .show();
+ }
+
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ final ClipboardManager clipboard =
+ (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE);
+ clipboard.setPrimaryClip(ClipData.newPlainText(null /* label */, mContactDetail));
+ }
+
+ private View createBodyView() {
+ LayoutInflater inflater = (LayoutInflater) mContext
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ TextView textView = (TextView) inflater.inflate(R.layout.copy_contact_dialog_view, null,
+ false);
+ textView.setText(mContactDetail);
+ final String vocalizedDisplayName = AccessibilityUtil.getVocalizedPhoneNumber(
+ mContext.getResources(), mContactDetail);
+ textView.setContentDescription(vocalizedDisplayName);
+ return textView;
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java
new file mode 100644
index 0000000..f017328
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java
@@ -0,0 +1,66 @@
+/*
+ * 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.messaging.ui.conversationsettings;
+
+import android.app.Fragment;
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.Assert;
+
+/**
+ * Shows a list of participants in a conversation.
+ */
+public class PeopleAndOptionsActivity extends BugleActionBarActivity {
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.people_and_options_activity);
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ @Override
+ public void onAttachFragment(final Fragment fragment) {
+ if (fragment instanceof PeopleAndOptionsFragment) {
+ final String conversationId =
+ getIntent().getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
+ Assert.notNull(conversationId);
+ final PeopleAndOptionsFragment peopleAndOptionsFragment =
+ (PeopleAndOptionsFragment) fragment;
+ peopleAndOptionsFragment.setConversationId(conversationId);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ // Treat the home press as back press so that when we go back to
+ // ConversationActivity, it doesn't lose its original intent (conversation id etc.)
+ onBackPressed();
+ return true;
+
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java
new file mode 100644
index 0000000..b86d952
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java
@@ -0,0 +1,329 @@
+/*
+ * 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.messaging.ui.conversationsettings;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Fragment;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.media.RingtoneManager;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.provider.Settings;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.BaseAdapter;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.data.ParticipantListItemData;
+import com.android.messaging.datamodel.data.PeopleAndOptionsData;
+import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener;
+import com.android.messaging.datamodel.data.PeopleOptionsItemData;
+import com.android.messaging.datamodel.data.PersonItemData;
+import com.android.messaging.ui.CompositeAdapter;
+import com.android.messaging.ui.PersonItemView;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.conversation.ConversationActivity;
+import com.android.messaging.util.Assert;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shows a list of participants of a conversation and displays options.
+ */
+public class PeopleAndOptionsFragment extends Fragment
+ implements PeopleAndOptionsDataListener, PeopleOptionsItemView.HostInterface {
+ private ListView mListView;
+ private OptionsListAdapter mOptionsListAdapter;
+ private PeopleListAdapter mPeopleListAdapter;
+ private final Binding<PeopleAndOptionsData> mBinding =
+ BindingBase.createBinding(this);
+
+ private static final int REQUEST_CODE_RINGTONE_PICKER = 1000;
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mBinding.getData().init(getLoaderManager(), mBinding);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.people_and_options_fragment, container, false);
+ mListView = (ListView) view.findViewById(android.R.id.list);
+ mPeopleListAdapter = new PeopleListAdapter(getActivity());
+ mOptionsListAdapter = new OptionsListAdapter();
+ final CompositeAdapter compositeAdapter = new CompositeAdapter(getActivity());
+ compositeAdapter.addPartition(new PeopleAndOptionsPartition(mOptionsListAdapter,
+ R.string.general_settings_title, false));
+ compositeAdapter.addPartition(new PeopleAndOptionsPartition(mPeopleListAdapter,
+ R.string.participant_list_title, true));
+ mListView.setAdapter(compositeAdapter);
+ return view;
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_CODE_RINGTONE_PICKER) {
+ final Parcelable pick = data.getParcelableExtra(
+ RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
+ final String pickedUri = pick == null ? "" : pick.toString();
+ mBinding.getData().setConversationNotificationSound(mBinding, pickedUri);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mBinding.unbind();
+ }
+
+ public void setConversationId(final String conversationId) {
+ Assert.isTrue(getView() == null);
+ Assert.notNull(conversationId);
+ mBinding.bind(DataModel.get().createPeopleAndOptionsData(conversationId, getActivity(),
+ this));
+ }
+
+ @Override
+ public void onOptionsCursorUpdated(final PeopleAndOptionsData data, final Cursor cursor) {
+ Assert.isTrue(cursor == null || cursor.getCount() == 1);
+ mBinding.ensureBound(data);
+ mOptionsListAdapter.swapCursor(cursor);
+ }
+
+ @Override
+ public void onParticipantsListLoaded(final PeopleAndOptionsData data,
+ final List<ParticipantData> participants) {
+ mBinding.ensureBound(data);
+ mPeopleListAdapter.updateParticipants(participants);
+ final ParticipantData otherParticipant = participants.size() == 1 ?
+ participants.get(0) : null;
+ mOptionsListAdapter.setOtherParticipant(otherParticipant);
+ }
+
+ @Override
+ public void onOptionsItemViewClicked(final PeopleOptionsItemData item,
+ final boolean isChecked) {
+ switch (item.getItemId()) {
+ case PeopleOptionsItemData.SETTING_NOTIFICATION_ENABLED:
+ mBinding.getData().enableConversationNotifications(mBinding, isChecked);
+ break;
+
+ case PeopleOptionsItemData.SETTING_NOTIFICATION_SOUND_URI:
+ final Intent ringtonePickerIntent = UIIntents.get().getRingtonePickerIntent(
+ getString(R.string.notification_sound_pref_title),
+ item.getRingtoneUri(), Settings.System.DEFAULT_NOTIFICATION_URI,
+ RingtoneManager.TYPE_NOTIFICATION);
+ startActivityForResult(ringtonePickerIntent, REQUEST_CODE_RINGTONE_PICKER);
+ break;
+
+ case PeopleOptionsItemData.SETTING_NOTIFICATION_VIBRATION:
+ mBinding.getData().enableConversationNotificationVibration(mBinding,
+ isChecked);
+ break;
+
+ case PeopleOptionsItemData.SETTING_BLOCKED:
+ if (item.getOtherParticipant().isBlocked()) {
+ mBinding.getData().setDestinationBlocked(mBinding, false);
+ break;
+ }
+ final Resources res = getResources();
+ final Activity activity = getActivity();
+ new AlertDialog.Builder(activity)
+ .setTitle(res.getString(R.string.block_confirmation_title,
+ item.getOtherParticipant().getDisplayDestination()))
+ .setMessage(res.getString(R.string.block_confirmation_message))
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(android.R.string.ok,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface arg0, int arg1) {
+ mBinding.getData().setDestinationBlocked(mBinding, true);
+ activity.setResult(ConversationActivity.FINISH_RESULT_CODE);
+ activity.finish();
+ }
+ })
+ .create()
+ .show();
+ break;
+ }
+ }
+
+ /**
+ * A simple adapter that takes a conversation metadata cursor and binds
+ * PeopleAndOptionsItemViews to individual COLUMNS of the first cursor record. (Note
+ * that this is not a CursorAdapter because it treats individual columns of the cursor as
+ * separate options to display for the conversation, e.g. notification settings).
+ */
+ private class OptionsListAdapter extends BaseAdapter {
+ private Cursor mOptionsCursor;
+ private ParticipantData mOtherParticipantData;
+
+ public Cursor swapCursor(final Cursor newCursor) {
+ final Cursor oldCursor = mOptionsCursor;
+ if (newCursor != oldCursor) {
+ mOptionsCursor = newCursor;
+ notifyDataSetChanged();
+ }
+ return oldCursor;
+ }
+
+ public void setOtherParticipant(final ParticipantData participantData) {
+ if (mOtherParticipantData != participantData) {
+ mOtherParticipantData = participantData;
+ notifyDataSetChanged();
+ }
+ }
+
+ @Override
+ public int getCount() {
+ int count = PeopleOptionsItemData.SETTINGS_COUNT;
+ if (mOtherParticipantData == null) {
+ count--;
+ }
+ return mOptionsCursor == null ? 0 : count;
+ }
+
+ @Override
+ public Object getItem(final int position) {
+ return null;
+ }
+
+ @Override
+ public long getItemId(final int position) {
+ return 0;
+ }
+
+ @Override
+ public View getView(final int position, final View convertView, final ViewGroup parent) {
+ final PeopleOptionsItemView itemView;
+ if (convertView != null && convertView instanceof PeopleOptionsItemView) {
+ itemView = (PeopleOptionsItemView) convertView;
+ } else {
+ final LayoutInflater inflater = (LayoutInflater) getActivity()
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ itemView = (PeopleOptionsItemView)
+ inflater.inflate(R.layout.people_options_item_view, parent, false);
+ }
+ mOptionsCursor.moveToFirst();
+ itemView.bind(mOptionsCursor, position, mOtherParticipantData,
+ PeopleAndOptionsFragment.this);
+ return itemView;
+ }
+ }
+
+ /**
+ * An adapter that takes a list of ParticipantData and displays them as a list of
+ * ParticipantListItemViews.
+ */
+ private class PeopleListAdapter extends ArrayAdapter<ParticipantData> {
+ public PeopleListAdapter(final Context context) {
+ super(context, R.layout.people_list_item_view, new ArrayList<ParticipantData>());
+ }
+
+ public void updateParticipants(final List<ParticipantData> newList) {
+ clear();
+ addAll(newList);
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public View getView(final int position, final View convertView, final ViewGroup parent) {
+ PersonItemView itemView;
+ final ParticipantData item = getItem(position);
+ if (convertView != null && convertView instanceof PersonItemView) {
+ itemView = (PersonItemView) convertView;
+ } else {
+ final LayoutInflater inflater = (LayoutInflater) getContext()
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ itemView = (PersonItemView) inflater.inflate(R.layout.people_list_item_view, parent,
+ false);
+ }
+ final ParticipantListItemData itemData =
+ DataModel.get().createParticipantListItemData(item);
+ itemView.bind(itemData);
+
+ // Any click on the row should have the same effect as clicking the avatar icon
+ final PersonItemView itemViewClosure = itemView;
+ itemView.setListener(new PersonItemView.PersonItemViewListener() {
+ @Override
+ public void onPersonClicked(final PersonItemData data) {
+ itemViewClosure.performClickOnAvatar();
+ }
+
+ @Override
+ public boolean onPersonLongClicked(final PersonItemData data) {
+ if (mBinding.isBound()) {
+ final CopyContactDetailDialog dialog = new CopyContactDetailDialog(
+ getContext(), data.getDetails());
+ dialog.show();
+ return true;
+ }
+ return false;
+ }
+ });
+ return itemView;
+ }
+ }
+
+ /**
+ * Represents a partition/section in the People & Options list (e.g. "general options" and
+ * "people in this conversation" sections).
+ */
+ private class PeopleAndOptionsPartition extends CompositeAdapter.Partition {
+ private final int mHeaderResId;
+ private final boolean mNeedDivider;
+
+ public PeopleAndOptionsPartition(final BaseAdapter adapter, final int headerResId,
+ final boolean needDivider) {
+ super(true /* showIfEmpty */, true /* hasHeader */, adapter);
+ mHeaderResId = headerResId;
+ mNeedDivider = needDivider;
+ }
+
+ @Override
+ public View getHeaderView(final View convertView, final ViewGroup parentView) {
+ View view = null;
+ if (convertView != null && convertView.getId() == R.id.people_and_options_header) {
+ view = convertView;
+ } else {
+ view = LayoutInflater.from(getActivity()).inflate(
+ R.layout.people_and_options_section_header, parentView, false);
+ }
+ final TextView text = (TextView) view.findViewById(R.id.header_text);
+ final View divider = view.findViewById(R.id.divider);
+ text.setText(mHeaderResId);
+ divider.setVisibility(mNeedDivider ? View.VISIBLE : View.GONE);
+ return view;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java b/src/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java
new file mode 100644
index 0000000..42ecfeb
--- /dev/null
+++ b/src/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java
@@ -0,0 +1,99 @@
+/*
+ * 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.messaging.ui.conversationsettings;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v7.widget.SwitchCompat;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.data.PeopleOptionsItemData;
+import com.android.messaging.util.Assert;
+
+/**
+ * The view for a single entry in the options section of people & options activity.
+ */
+public class PeopleOptionsItemView extends LinearLayout {
+ /**
+ * Implemented by the host of this view that handles options click event.
+ */
+ public interface HostInterface {
+ void onOptionsItemViewClicked(PeopleOptionsItemData item, boolean isChecked);
+ }
+
+ private TextView mTitle;
+ private TextView mSubtitle;
+ private SwitchCompat mSwitch;
+ private final PeopleOptionsItemData mData;
+ private HostInterface mHostInterface;
+
+ public PeopleOptionsItemView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mData = DataModel.get().createPeopleOptionsItemData(context);
+ }
+
+ @Override
+ protected void onFinishInflate () {
+ mTitle = (TextView) findViewById(R.id.title);
+ mSubtitle = (TextView) findViewById(R.id.subtitle);
+ mSwitch = (SwitchCompat) findViewById(R.id.switch_button);
+ setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ mHostInterface.onOptionsItemViewClicked(mData, !mData.getChecked());
+ }
+ });
+ }
+
+ public void bind(final Cursor cursor, final int columnIndex, ParticipantData otherParticipant,
+ final HostInterface hostInterface) {
+ Assert.isTrue(columnIndex < PeopleOptionsItemData.SETTINGS_COUNT && columnIndex >= 0);
+ mData.bind(cursor, otherParticipant, columnIndex);
+ mHostInterface = hostInterface;
+
+ mTitle.setText(mData.getTitle());
+ final String subtitle = mData.getSubtitle();
+ if (TextUtils.isEmpty(subtitle)) {
+ mSubtitle.setVisibility(GONE);
+ } else {
+ mSubtitle.setVisibility(VISIBLE);
+ mSubtitle.setText(subtitle);
+ }
+
+ if (mData.getCheckable()) {
+ mSwitch.setVisibility(VISIBLE);
+ mSwitch.setChecked(mData.getChecked());
+ } else {
+ mSwitch.setVisibility(GONE);
+ }
+
+ final boolean enabled = mData.getEnabled();
+ if (enabled != isEnabled()) {
+ mTitle.setEnabled(enabled);
+ mSubtitle.setEnabled(enabled);
+ mSwitch.setEnabled(enabled);
+ setAlpha(enabled ? 1.0f : 0.5f);
+ setEnabled(enabled);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/debug/DebugMmsConfigActivity.java b/src/com/android/messaging/ui/debug/DebugMmsConfigActivity.java
new file mode 100644
index 0000000..485dcf7
--- /dev/null
+++ b/src/com/android/messaging/ui/debug/DebugMmsConfigActivity.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.ui.debug;
+
+import android.os.Bundle;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.BaseBugleActivity;
+
+/**
+ * Show list of all MmsConfig key/value pairs and allow editing.
+ */
+public class DebugMmsConfigActivity extends BaseBugleActivity {
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.debug_mmsconfig_activity);
+ }
+}
diff --git a/src/com/android/messaging/ui/debug/DebugMmsConfigFragment.java b/src/com/android/messaging/ui/debug/DebugMmsConfigFragment.java
new file mode 100644
index 0000000..7c54db5
--- /dev/null
+++ b/src/com/android/messaging/ui/debug/DebugMmsConfigFragment.java
@@ -0,0 +1,147 @@
+/*
+ * 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.messaging.ui.debug;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.os.Bundle;
+import android.telephony.SubscriptionInfo;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.ArrayAdapter;
+import android.widget.BaseAdapter;
+import android.widget.ListView;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.ui.debug.DebugMmsConfigItemView.MmsConfigItemListener;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Show list of all MmsConfig key/value pairs and allow editing.
+ */
+public class DebugMmsConfigFragment extends Fragment {
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View fragmentView = inflater.inflate(R.layout.mms_config_debug_fragment, container,
+ false);
+ final ListView listView = (ListView) fragmentView.findViewById(android.R.id.list);
+ final Spinner spinner = (Spinner) fragmentView.findViewById(R.id.sim_selector);
+ final Integer[] subIdArray = getActiveSubIds();
+ ArrayAdapter<Integer> spinnerAdapter = new ArrayAdapter<Integer>(getActivity(),
+ android.R.layout.simple_spinner_item, subIdArray);
+ spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ spinner.setAdapter(spinnerAdapter);
+ spinner.setOnItemSelectedListener(new OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ listView.setAdapter(new MmsConfigAdapter(getActivity(), subIdArray[position]));
+
+ final int[] mccmnc = PhoneUtils.get(subIdArray[position]).getMccMnc();
+ // Set the title with the mcc/mnc
+ final TextView title = (TextView) fragmentView.findViewById(R.id.sim_title);
+ title.setText("(" + mccmnc[0] + "/" + mccmnc[1] + ") " +
+ getActivity().getString(R.string.debug_sub_id_spinner_text));
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+ }
+ });
+
+ return fragmentView;
+ }
+
+ public static Integer[] getActiveSubIds() {
+ if (!OsUtil.isAtLeastL_MR1()) {
+ return new Integer[] { ParticipantData.DEFAULT_SELF_SUB_ID };
+ }
+ final List<SubscriptionInfo> subRecords =
+ PhoneUtils.getDefault().toLMr1().getActiveSubscriptionInfoList();
+ if (subRecords == null) {
+ return new Integer[0];
+ }
+ final Integer[] retArray = new Integer[subRecords.size()];
+ for (int i = 0; i < subRecords.size(); i++) {
+ retArray[i] = subRecords.get(i).getSubscriptionId();
+ }
+ return retArray;
+ }
+
+ private class MmsConfigAdapter extends BaseAdapter implements
+ DebugMmsConfigItemView.MmsConfigItemListener {
+ private final LayoutInflater mInflater;
+ private final List<String> mKeys;
+ private final MmsConfig mMmsConfig;
+
+ public MmsConfigAdapter(Context context, int subId) {
+ mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ mMmsConfig = MmsConfig.get(subId);
+ mKeys = new ArrayList<>(mMmsConfig.keySet());
+ Collections.sort(mKeys);
+ }
+
+ @Override
+ public View getView(final int position, final View convertView, final ViewGroup parent) {
+ final DebugMmsConfigItemView view;
+ if (convertView != null && convertView instanceof DebugMmsConfigItemView) {
+ view = (DebugMmsConfigItemView) convertView;
+ } else {
+ view = (DebugMmsConfigItemView) mInflater.inflate(
+ R.layout.debug_mmsconfig_item_view, parent, false);
+ }
+ final String key = mKeys.get(position);
+ view.bind(key,
+ MmsConfig.getKeyType(key),
+ String.valueOf(mMmsConfig.getValue(key)),
+ this);
+ return view;
+ }
+
+ @Override
+ public void onValueChanged(String key, String keyType, String value) {
+ mMmsConfig.update(key, value, keyType);
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getCount() {
+ return mKeys.size();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return mKeys.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/debug/DebugMmsConfigItemView.java b/src/com/android/messaging/ui/debug/DebugMmsConfigItemView.java
new file mode 100644
index 0000000..7b899c0
--- /dev/null
+++ b/src/com/android/messaging/ui/debug/DebugMmsConfigItemView.java
@@ -0,0 +1,134 @@
+/*
+ * 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.messaging.ui.debug;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnShowListener;
+import android.text.InputType;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.CompoundButton;
+import android.widget.CompoundButton.OnCheckedChangeListener;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.Switch;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.util.LogUtil;
+
+public class DebugMmsConfigItemView extends LinearLayout implements OnClickListener,
+ OnCheckedChangeListener, DialogInterface.OnClickListener {
+
+ public interface MmsConfigItemListener {
+ void onValueChanged(String key, String keyType, String value);
+ }
+
+ private TextView mTitle;
+ private TextView mTextValue;
+ private Switch mSwitch;
+ private String mKey;
+ private String mKeyType;
+ private MmsConfigItemListener mListener;
+ private EditText mEditText;
+
+ public DebugMmsConfigItemView(Context context, AttributeSet attributeSet) {
+ super(context, attributeSet);
+ }
+
+ @Override
+ protected void onFinishInflate () {
+ mTitle = (TextView) findViewById(R.id.title);
+ mTextValue = (TextView) findViewById(R.id.text_value);
+ mSwitch = (Switch) findViewById(R.id.switch_button);
+ setOnClickListener(this);
+ mSwitch.setOnCheckedChangeListener(this);
+ }
+
+ public void bind(final String key, final String keyType, final String value,
+ final MmsConfigItemListener listener) {
+ mListener = listener;
+ mKey = key;
+ mKeyType = keyType;
+ mTitle.setText(key);
+
+ switch (keyType) {
+ case MmsConfig.KEY_TYPE_BOOL:
+ mSwitch.setVisibility(View.VISIBLE);
+ mTextValue.setVisibility(View.GONE);
+ mSwitch.setChecked(Boolean.valueOf(value));
+ break;
+ case MmsConfig.KEY_TYPE_STRING:
+ case MmsConfig.KEY_TYPE_INT:
+ mTextValue.setVisibility(View.VISIBLE);
+ mSwitch.setVisibility(View.GONE);
+ mTextValue.setText(value);
+ break;
+ default:
+ mTextValue.setVisibility(View.GONE);
+ mSwitch.setVisibility(View.GONE);
+ LogUtil.e(LogUtil.BUGLE_TAG, "Unexpected keytype: " + keyType);
+ break;
+ }
+ }
+
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ mListener.onValueChanged(mKey, mKeyType, String.valueOf(isChecked));
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (MmsConfig.KEY_TYPE_BOOL.equals(mKeyType)) {
+ return;
+ }
+ final Context context = getContext();
+ mEditText = new EditText(context);
+ mEditText.setText(mTextValue.getText());
+ mEditText.setFocusable(true);
+ if (MmsConfig.KEY_TYPE_INT.equals(mKeyType)) {
+ mEditText.setInputType(InputType.TYPE_CLASS_PHONE);
+ } else {
+ mEditText.setInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ }
+ final AlertDialog dialog = new AlertDialog.Builder(context)
+ .setTitle(mKey)
+ .setView(mEditText)
+ .setPositiveButton(android.R.string.ok, this)
+ .setNegativeButton(android.R.string.cancel, null)
+ .create();
+ dialog.setOnShowListener(new OnShowListener() {
+ @Override
+ public void onShow(DialogInterface dialog) {
+ mEditText.requestFocus();
+ mEditText.selectAll();
+ ((InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE))
+ .toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0);
+ }
+ });
+ dialog.show();
+ }
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ mListener.onValueChanged(mKey, mKeyType, mEditText.getText().toString());
+ }
+}
diff --git a/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java b/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java
new file mode 100644
index 0000000..1aa8be3
--- /dev/null
+++ b/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java
@@ -0,0 +1,171 @@
+/*
+ * 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.messaging.ui.debug;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.telephony.SmsMessage;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.action.ReceiveMmsMessageAction;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.receiver.SmsReceiver;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Class that displays UI for choosing SMS/MMS dump files for debugging
+ */
+public class DebugSmsMmsFromDumpFileDialogFragment extends DialogFragment {
+ public static final String APPLICATION_OCTET_STREAM = "application/octet-stream";
+ public static final String KEY_DUMP_FILES = "dump_files";
+ public static final String KEY_ACTION = "action";
+
+ public static final String ACTION_LOAD = "load";
+ public static final String ACTION_EMAIL = "email";
+
+ private String[] mDumpFiles;
+ private String mAction;
+
+ public static DebugSmsMmsFromDumpFileDialogFragment newInstance(final String[] dumpFiles,
+ final String action) {
+ final DebugSmsMmsFromDumpFileDialogFragment frag =
+ new DebugSmsMmsFromDumpFileDialogFragment();
+ final Bundle args = new Bundle();
+ args.putSerializable(KEY_DUMP_FILES, dumpFiles);
+ args.putString(KEY_ACTION, action);
+ frag.setArguments(args);
+ return frag;
+ }
+
+ @Override
+ public Dialog onCreateDialog(final Bundle savedInstanceState) {
+ final Bundle args = getArguments();
+ mDumpFiles = (String[]) args.getSerializable(KEY_DUMP_FILES);
+ mAction = args.getString(KEY_ACTION);
+
+ final LayoutInflater inflater = getActivity().getLayoutInflater();
+ final View layout = inflater.inflate(
+ R.layout.debug_sms_mms_from_dump_file_dialog, null/*root*/);
+ final ListView list = (ListView) layout.findViewById(R.id.dump_file_list);
+ list.setAdapter(new DumpFileListAdapter(getActivity(), mDumpFiles));
+ final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ final Resources resources = getResources();
+ if (ACTION_LOAD.equals(mAction)) {
+ builder.setTitle(resources.getString(
+ R.string.load_sms_mms_from_dump_file_dialog_title));
+ } else if (ACTION_EMAIL.equals(mAction)) {
+ builder.setTitle(resources.getString(
+ R.string.email_sms_mms_from_dump_file_dialog_title));
+ }
+ builder.setView(layout);
+ return builder.create();
+ }
+
+ private class DumpFileListAdapter extends ArrayAdapter<String> {
+ public DumpFileListAdapter(final Context context, final String[] dumpFiles) {
+ super(context, R.layout.sms_mms_dump_file_list_item, dumpFiles);
+ }
+
+ @Override
+ public View getView(final int position, final View view, final ViewGroup parent) {
+ TextView actionItemView;
+ if (view == null || !(view instanceof TextView)) {
+ final LayoutInflater inflater = LayoutInflater.from(getContext());
+ actionItemView = (TextView) inflater.inflate(
+ R.layout.sms_mms_dump_file_list_item, parent, false);
+ } else {
+ actionItemView = (TextView) view;
+ }
+
+ final String file = getItem(position);
+ actionItemView.setText(file);
+ actionItemView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ dismiss();
+ if (ACTION_LOAD.equals(mAction)) {
+ receiveFromDumpFile(file);
+ } else if (ACTION_EMAIL.equals(mAction)) {
+ emailDumpFile(file);
+ }
+ }
+ });
+ return actionItemView;
+ }
+ }
+
+ /**
+ * Load MMS/SMS from the dump file
+ */
+ private void receiveFromDumpFile(final String dumpFileName) {
+ if (dumpFileName.startsWith(MmsUtils.SMS_DUMP_PREFIX)) {
+ final SmsMessage[] messages = DebugUtils.retreiveSmsFromDumpFile(dumpFileName);
+ if (messages != null) {
+ SmsReceiver.deliverSmsMessages(getActivity(), ParticipantData.DEFAULT_SELF_SUB_ID,
+ 0, messages);
+ } else {
+ LogUtil.e(LogUtil.BUGLE_TAG,
+ "receiveFromDumpFile: invalid sms dump file " + dumpFileName);
+ }
+ } else if (dumpFileName.startsWith(MmsUtils.MMS_DUMP_PREFIX)) {
+ final byte[] data = MmsUtils.createDebugNotificationInd(dumpFileName);
+ if (data != null) {
+ final ReceiveMmsMessageAction action = new ReceiveMmsMessageAction(
+ ParticipantData.DEFAULT_SELF_SUB_ID, data);
+ action.start();
+ } else {
+ LogUtil.e(LogUtil.BUGLE_TAG,
+ "receiveFromDumpFile: invalid mms dump file " + dumpFileName);
+ }
+ } else {
+ LogUtil.e(LogUtil.BUGLE_TAG,
+ "receiveFromDumpFile: invalid dump file name " + dumpFileName);
+ }
+ }
+
+ /**
+ * Launch email app to send the dump file
+ */
+ private void emailDumpFile(final String file) {
+ final Resources resources = getResources();
+ final String fileLocation = "file://"
+ + Environment.getExternalStorageDirectory() + "/" + file;
+ final Intent sharingIntent = new Intent(Intent.ACTION_SEND);
+ sharingIntent.setType(APPLICATION_OCTET_STREAM);
+ sharingIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fileLocation));
+ sharingIntent.putExtra(Intent.EXTRA_SUBJECT,
+ resources.getString(R.string.email_sms_mms_dump_file_subject));
+ getActivity().startActivity(Intent.createChooser(sharingIntent,
+ resources.getString(R.string.email_sms_mms_dump_file_chooser_title)));
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java b/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java
new file mode 100644
index 0000000..a211058
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java
@@ -0,0 +1,73 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import com.google.common.base.Preconditions;
+
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * Keeps track of the speech level as last observed by the recognition
+ * engine as microphone data flows through it. Can be polled by the UI to
+ * animate its views.
+ */
+@ThreadSafe
+public class AudioLevelSource {
+ private volatile int mSpeechLevel;
+ private volatile Listener mListener;
+
+ public static final int LEVEL_UNKNOWN = -1;
+
+ public interface Listener {
+ void onSpeechLevel(int speechLevel);
+ }
+
+ public void setSpeechLevel(int speechLevel) {
+ Preconditions.checkArgument(speechLevel >= 0 && speechLevel <= 100 ||
+ speechLevel == LEVEL_UNKNOWN);
+ mSpeechLevel = speechLevel;
+ maybeNotify();
+ }
+
+ public int getSpeechLevel() {
+ return mSpeechLevel;
+ }
+
+ public void reset() {
+ setSpeechLevel(LEVEL_UNKNOWN);
+ }
+
+ public boolean isValid() {
+ return mSpeechLevel > 0;
+ }
+
+ private void maybeNotify() {
+ final Listener l = mListener;
+ if (l != null) {
+ l.onSpeechLevel(mSpeechLevel);
+ }
+ }
+
+ public synchronized void setListener(Listener listener) {
+ mListener = listener;
+ }
+
+ public synchronized void clearListener(Listener listener) {
+ if (mListener == listener) {
+ mListener = null;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java b/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java
new file mode 100644
index 0000000..5d79293
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java
@@ -0,0 +1,130 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.Manifest;
+import android.content.pm.PackageManager;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.util.OsUtil;
+
+/**
+ * Chooser which allows the user to record audio
+ */
+class AudioMediaChooser extends MediaChooser implements
+ AudioRecordView.HostInterface {
+ private View mEnabledView;
+ private View mMissingPermissionView;
+
+ AudioMediaChooser(final MediaPicker mediaPicker) {
+ super(mediaPicker);
+ }
+
+ @Override
+ public int getSupportedMediaTypes() {
+ return MediaPicker.MEDIA_TYPE_AUDIO;
+ }
+
+ @Override
+ public int getIconResource() {
+ return R.drawable.ic_audio_light;
+ }
+
+ @Override
+ public int getIconDescriptionResource() {
+ return R.string.mediapicker_audioChooserDescription;
+ }
+
+ @Override
+ public void onAudioRecorded(final MessagePartData item) {
+ mMediaPicker.dispatchItemsSelected(item, true);
+ }
+
+ @Override
+ public void setThemeColor(final int color) {
+ if (mView != null) {
+ ((AudioRecordView) mView).setThemeColor(color);
+ }
+ }
+
+ @Override
+ protected View createView(final ViewGroup container) {
+ final LayoutInflater inflater = getLayoutInflater();
+ final AudioRecordView view = (AudioRecordView) inflater.inflate(
+ R.layout.mediapicker_audio_chooser,
+ container /* root */,
+ false /* attachToRoot */);
+ view.setHostInterface(this);
+ view.setThemeColor(mMediaPicker.getConversationThemeColor());
+ mEnabledView = view.findViewById(R.id.mediapicker_enabled);
+ mMissingPermissionView = view.findViewById(R.id.missing_permission_view);
+ return view;
+ }
+
+ @Override
+ int getActionBarTitleResId() {
+ return R.string.mediapicker_audio_title;
+ }
+
+ @Override
+ public boolean isHandlingTouch() {
+ // Whenever the user is in the process of recording audio, we want to allow the user
+ // to move the finger within the panel without interpreting that as dragging the media
+ // picker panel.
+ return ((AudioRecordView) mView).shouldHandleTouch();
+ }
+
+ @Override
+ public void stopTouchHandling() {
+ ((AudioRecordView) mView).stopTouchHandling();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (mView != null) {
+ ((AudioRecordView) mView).onPause();
+ }
+ }
+
+ @Override
+ protected void setSelected(final boolean selected) {
+ super.setSelected(selected);
+ if (selected && !OsUtil.hasRecordAudioPermission()) {
+ requestRecordAudioPermission();
+ }
+ }
+
+ private void requestRecordAudioPermission() {
+ mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO },
+ MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE);
+ }
+
+ @Override
+ protected void onRequestPermissionsResult(
+ final int requestCode, final String permissions[], final int[] grantResults) {
+ if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) {
+ final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED;
+ mEnabledView.setVisibility(permissionGranted ? View.VISIBLE : View.GONE);
+ mMissingPermissionView.setVisibility(permissionGranted ? View.GONE : View.VISIBLE);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/AudioRecordView.java b/src/com/android/messaging/ui/mediapicker/AudioRecordView.java
new file mode 100644
index 0000000..fba493f
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/AudioRecordView.java
@@ -0,0 +1,351 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.GradientDrawable;
+import android.media.MediaRecorder;
+import android.net.Uri;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider;
+import com.android.messaging.datamodel.data.MediaPickerMessagePartData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.MediaUtil;
+import com.android.messaging.util.MediaUtil.OnCompletionListener;
+import com.android.messaging.util.SafeAsyncTask;
+import com.android.messaging.util.ThreadUtil;
+import com.android.messaging.util.UiUtils;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Hosts an audio recorder with tap and hold to record functionality.
+ */
+public class AudioRecordView extends FrameLayout implements
+ MediaRecorder.OnErrorListener,
+ MediaRecorder.OnInfoListener {
+ /**
+ * An interface that communicates with the hosted AudioRecordView.
+ */
+ public interface HostInterface extends DraftMessageSubscriptionDataProvider {
+ void onAudioRecorded(final MessagePartData item);
+ }
+
+ /** The initial state, the user may press and hold to start recording */
+ private static final int MODE_IDLE = 1;
+
+ /** The user has pressed the record button and we are playing the sound indicating the
+ * start of recording session. Don't record yet since we don't want the beeping sound
+ * to get into the recording. */
+ private static final int MODE_STARTING = 2;
+
+ /** When the user is actively recording */
+ private static final int MODE_RECORDING = 3;
+
+ /** When the user has finished recording, we need to record for some additional time. */
+ private static final int MODE_STOPPING = 4;
+
+ // Bug: 16020175: The framework's MediaRecorder would cut off the ending portion of the
+ // recorded audio by about half a second. To mitigate this issue, we continue the recording
+ // for some extra time before stopping it.
+ private static final int AUDIO_RECORD_ENDING_BUFFER_MILLIS = 500;
+
+ /**
+ * The minimum duration of any recording. Below this threshold, it will be treated as if the
+ * user clicked the record button and inform the user to tap and hold to record.
+ */
+ private static final int AUDIO_RECORD_MINIMUM_DURATION_MILLIS = 300;
+
+ // For accessibility, the touchable record button is bigger than the record button visual.
+ private ImageView mRecordButtonVisual;
+ private View mRecordButton;
+ private SoundLevels mSoundLevels;
+ private TextView mHintTextView;
+ private PausableChronometer mTimerTextView;
+ private LevelTrackingMediaRecorder mMediaRecorder;
+ private long mAudioRecordStartTimeMillis;
+
+ private int mCurrentMode = MODE_IDLE;
+ private HostInterface mHostInterface;
+ private int mThemeColor;
+
+ public AudioRecordView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mMediaRecorder = new LevelTrackingMediaRecorder();
+ }
+
+ public void setHostInterface(final HostInterface hostInterface) {
+ mHostInterface = hostInterface;
+ }
+
+ @VisibleForTesting
+ public void testSetMediaRecorder(final LevelTrackingMediaRecorder recorder) {
+ mMediaRecorder = recorder;
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mSoundLevels = (SoundLevels) findViewById(R.id.sound_levels);
+ mRecordButtonVisual = (ImageView) findViewById(R.id.record_button_visual);
+ mRecordButton = findViewById(R.id.record_button);
+ mHintTextView = (TextView) findViewById(R.id.hint_text);
+ mTimerTextView = (PausableChronometer) findViewById(R.id.timer_text);
+ mSoundLevels.setLevelSource(mMediaRecorder.getLevelSource());
+ mRecordButton.setOnTouchListener(new OnTouchListener() {
+ @Override
+ public boolean onTouch(final View v, final MotionEvent event) {
+ final int action = event.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ onRecordButtonTouchDown();
+
+ // Don't let the record button handle the down event to let it fall through
+ // so that we can handle it for the entire panel in onTouchEvent(). This is
+ // done so that: 1) the user taps on the record button to start recording
+ // 2) the entire panel owns the touch event so we'd keep recording even
+ // if the user moves outside the button region.
+ return false;
+ }
+ return false;
+ }
+ });
+ }
+
+ @Override
+ public boolean onTouchEvent(final MotionEvent event) {
+ final int action = event.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ return shouldHandleTouch();
+
+ case MotionEvent.ACTION_MOVE:
+ return true;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ return onRecordButtonTouchUp();
+ }
+ return super.onTouchEvent(event);
+ }
+
+ public void onPause() {
+ // The conversation draft cannot take any updates when it's paused. Therefore, forcibly
+ // stop recording on pause.
+ stopRecording();
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ stopRecording();
+ }
+
+ private boolean isRecording() {
+ return mMediaRecorder.isRecording() && mCurrentMode == MODE_RECORDING;
+ }
+
+ public boolean shouldHandleTouch() {
+ return mCurrentMode != MODE_IDLE;
+ }
+
+ public void stopTouchHandling() {
+ setMode(MODE_IDLE);
+ stopRecording();
+ }
+
+ private void setMode(final int mode) {
+ if (mCurrentMode != mode) {
+ mCurrentMode = mode;
+ updateVisualState();
+ }
+ }
+
+ private void updateVisualState() {
+ switch (mCurrentMode) {
+ case MODE_IDLE:
+ mHintTextView.setVisibility(VISIBLE);
+ mHintTextView.setTypeface(null, Typeface.NORMAL);
+ mTimerTextView.setVisibility(GONE);
+ mSoundLevels.setEnabled(false);
+ mTimerTextView.stop();
+ break;
+
+ case MODE_RECORDING:
+ case MODE_STOPPING:
+ mHintTextView.setVisibility(GONE);
+ mTimerTextView.setVisibility(VISIBLE);
+ mSoundLevels.setEnabled(true);
+ mTimerTextView.restart();
+ break;
+
+ case MODE_STARTING:
+ break; // No-Op.
+
+ default:
+ Assert.fail("invalid mode for AudioRecordView!");
+ break;
+ }
+ updateRecordButtonAppearance();
+ }
+
+ public void setThemeColor(final int color) {
+ mThemeColor = color;
+ updateRecordButtonAppearance();
+ }
+
+ private void updateRecordButtonAppearance() {
+ final Drawable foregroundDrawable = getResources().getDrawable(R.drawable.ic_mp_audio_mic);
+ final GradientDrawable backgroundDrawable = ((GradientDrawable) getResources()
+ .getDrawable(R.drawable.audio_record_control_button_background));
+ if (isRecording()) {
+ foregroundDrawable.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP);
+ backgroundDrawable.setColor(mThemeColor);
+ } else {
+ foregroundDrawable.setColorFilter(mThemeColor, PorterDuff.Mode.SRC_ATOP);
+ backgroundDrawable.setColor(Color.WHITE);
+ }
+ mRecordButtonVisual.setImageDrawable(foregroundDrawable);
+ mRecordButtonVisual.setBackground(backgroundDrawable);
+ }
+
+ @VisibleForTesting
+ boolean onRecordButtonTouchDown() {
+ if (!mMediaRecorder.isRecording() && mCurrentMode == MODE_IDLE) {
+ setMode(MODE_STARTING);
+ playAudioStartSound(new OnCompletionListener() {
+ @Override
+ public void onCompletion() {
+ // Double-check the current mode before recording since the user may have
+ // lifted finger from the button before the beeping sound is played through.
+ final int maxSize = MmsConfig.get(mHostInterface.getConversationSelfSubId())
+ .getMaxMessageSize();
+ if (mCurrentMode == MODE_STARTING &&
+ mMediaRecorder.startRecording(AudioRecordView.this,
+ AudioRecordView.this, maxSize)) {
+ setMode(MODE_RECORDING);
+ }
+ }
+ });
+ mAudioRecordStartTimeMillis = System.currentTimeMillis();
+ return true;
+ }
+ return false;
+ }
+
+ @VisibleForTesting
+ boolean onRecordButtonTouchUp() {
+ if (System.currentTimeMillis() - mAudioRecordStartTimeMillis <
+ AUDIO_RECORD_MINIMUM_DURATION_MILLIS) {
+ // The recording is too short, bolden the hint text to instruct the user to
+ // "tap+hold" to record audio.
+ final Uri outputUri = stopRecording();
+ if (outputUri != null) {
+ SafeAsyncTask.executeOnThreadPool(new Runnable() {
+ @Override
+ public void run() {
+ Factory.get().getApplicationContext().getContentResolver().delete(
+ outputUri, null, null);
+ }
+ });
+ }
+ setMode(MODE_IDLE);
+ mHintTextView.setTypeface(null, Typeface.BOLD);
+ } else if (isRecording()) {
+ // Record for some extra time to ensure the ending part is saved.
+ setMode(MODE_STOPPING);
+ ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ onFinishedRecording();
+ }
+ }, AUDIO_RECORD_ENDING_BUFFER_MILLIS);
+ } else {
+ setMode(MODE_IDLE);
+ }
+ return true;
+ }
+
+ private Uri stopRecording() {
+ if (mMediaRecorder.isRecording()) {
+ return mMediaRecorder.stopRecording();
+ }
+ return null;
+ }
+
+ @Override // From MediaRecorder.OnInfoListener
+ public void onInfo(final MediaRecorder mr, final int what, final int extra) {
+ if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
+ // Max size reached. Finish recording immediately.
+ LogUtil.i(LogUtil.BUGLE_TAG, "Max size reached while recording audio");
+ onFinishedRecording();
+ } else {
+ // These are unknown errors.
+ onErrorWhileRecording(what, extra);
+ }
+ }
+
+ @Override // From MediaRecorder.OnErrorListener
+ public void onError(final MediaRecorder mr, final int what, final int extra) {
+ onErrorWhileRecording(what, extra);
+ }
+
+ private void onErrorWhileRecording(final int what, final int extra) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Error occurred during audio recording what=" + what +
+ ", extra=" + extra);
+ UiUtils.showToastAtBottom(R.string.audio_recording_error);
+ setMode(MODE_IDLE);
+ stopRecording();
+ }
+
+ private void onFinishedRecording() {
+ final Uri outputUri = stopRecording();
+ if (outputUri != null) {
+ final Rect startRect = new Rect();
+ mRecordButtonVisual.getGlobalVisibleRect(startRect);
+ final MediaPickerMessagePartData audioItem =
+ new MediaPickerMessagePartData(startRect,
+ ContentType.AUDIO_3GPP, outputUri, 0, 0);
+ mHostInterface.onAudioRecorded(audioItem);
+ }
+ playAudioEndSound();
+ setMode(MODE_IDLE);
+ }
+
+ private void playAudioStartSound(final OnCompletionListener completionListener) {
+ MediaUtil.get().playSound(getContext(), R.raw.audio_initiate, completionListener);
+ }
+
+ private void playAudioEndSound() {
+ MediaUtil.get().playSound(getContext(), R.raw.audio_end, null);
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/CameraManager.java b/src/com/android/messaging/ui/mediapicker/CameraManager.java
new file mode 100644
index 0000000..166ebd7
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/CameraManager.java
@@ -0,0 +1,1200 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Context;
+import android.content.pm.ActivityInfo;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.hardware.Camera;
+import android.hardware.Camera.CameraInfo;
+import android.media.MediaRecorder;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Looper;
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+import android.util.DisplayMetrics;
+import android.view.MotionEvent;
+import android.view.OrientationEventListener;
+import android.view.Surface;
+import android.view.View;
+import android.view.WindowManager;
+
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider;
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.media.ImageRequest;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.ui.mediapicker.camerafocus.FocusOverlayManager;
+import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.UiUtils;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Class which manages interactions with the camera, but does not do any UI. This class is
+ * designed to be a singleton to ensure there is one component managing the camera and releasing
+ * the native resources.
+ * In order to acquire a camera, a caller must:
+ * <ul>
+ * <li>Call selectCamera to select front or back camera
+ * <li>Call setSurface to control where the preview is shown
+ * <li>Call openCamera to request the camera start preview
+ * </ul>
+ * Callers should call onPause and onResume to ensure that the camera is release while the activity
+ * is not active.
+ * This class is not thread safe. It should only be called from one thread (the UI thread or test
+ * thread)
+ */
+class CameraManager implements FocusOverlayManager.Listener {
+ /**
+ * Wrapper around the framework camera API to allow mocking different hardware scenarios while
+ * unit testing
+ */
+ interface CameraWrapper {
+ int getNumberOfCameras();
+ void getCameraInfo(int index, CameraInfo cameraInfo);
+ Camera open(int cameraId);
+ /** Add a wrapper for release because a final method cannot be mocked */
+ void release(Camera camera);
+ }
+
+ /**
+ * Callbacks for the camera manager listener
+ */
+ interface CameraManagerListener {
+ void onCameraError(int errorCode, Exception e);
+ void onCameraChanged();
+ }
+
+ /**
+ * Callback when taking image or video
+ */
+ interface MediaCallback {
+ static final int MEDIA_CAMERA_CHANGED = 1;
+ static final int MEDIA_NO_DATA = 2;
+
+ void onMediaReady(Uri uriToMedia, String contentType, int width, int height);
+ void onMediaFailed(Exception exception);
+ void onMediaInfo(int what);
+ }
+
+ // Error codes
+ static final int ERROR_OPENING_CAMERA = 1;
+ static final int ERROR_SHOWING_PREVIEW = 2;
+ static final int ERROR_INITIALIZING_VIDEO = 3;
+ static final int ERROR_STORAGE_FAILURE = 4;
+ static final int ERROR_RECORDING_VIDEO = 5;
+ static final int ERROR_HARDWARE_ACCELERATION_DISABLED = 6;
+ static final int ERROR_TAKING_PICTURE = 7;
+
+ private static final String TAG = LogUtil.BUGLE_TAG;
+ private static final int NO_CAMERA_SELECTED = -1;
+
+ private static CameraManager sInstance;
+
+ /** Default camera wrapper which directs calls to the framework APIs */
+ private static CameraWrapper sCameraWrapper = new CameraWrapper() {
+ @Override
+ public int getNumberOfCameras() {
+ return Camera.getNumberOfCameras();
+ }
+
+ @Override
+ public void getCameraInfo(final int index, final CameraInfo cameraInfo) {
+ Camera.getCameraInfo(index, cameraInfo);
+ }
+
+ @Override
+ public Camera open(final int cameraId) {
+ return Camera.open(cameraId);
+ }
+
+ @Override
+ public void release(final Camera camera) {
+ camera.release();
+ }
+ };
+
+ /** The CameraInfo for the currently selected camera */
+ private final CameraInfo mCameraInfo;
+
+ /**
+ * The index of the selected camera or NO_CAMERA_SELECTED if a camera hasn't been selected yet
+ */
+ private int mCameraIndex;
+
+ /** True if the device has front and back cameras */
+ private final boolean mHasFrontAndBackCamera;
+
+ /** True if the camera should be open (may not yet be actually open) */
+ private boolean mOpenRequested;
+
+ /** True if the camera is requested to be in video mode */
+ private boolean mVideoModeRequested;
+
+ /** The media recorder for video mode */
+ private MmsVideoRecorder mMediaRecorder;
+
+ /** Callback to call with video recording updates */
+ private MediaCallback mVideoCallback;
+
+ /** The preview view to show the preview on */
+ private CameraPreview mCameraPreview;
+
+ /** The helper classs to handle orientation changes */
+ private OrientationHandler mOrientationHandler;
+
+ /** Tracks whether the preview has hardware acceleration */
+ private boolean mIsHardwareAccelerationSupported;
+
+ /**
+ * The task for opening the camera, so it doesn't block the UI thread
+ * Using AsyncTask rather than SafeAsyncTask because the tasks need to be serialized, but don't
+ * need to be on the UI thread
+ * TODO: If we have other AyncTasks (not SafeAsyncTasks) this may contend and we may
+ * need to create a dedicated thread, or synchronize the threads in the thread pool
+ */
+ private AsyncTask<Integer, Void, Camera> mOpenCameraTask;
+
+ /**
+ * The camera index that is queued to be opened, but not completed yet, or NO_CAMERA_SELECTED if
+ * no open task is pending
+ */
+ private int mPendingOpenCameraIndex = NO_CAMERA_SELECTED;
+
+ /** The instance of the currently opened camera */
+ private Camera mCamera;
+
+ /** The rotation of the screen relative to the camera's natural orientation */
+ private int mRotation;
+
+ /** The callback to notify when errors or other events occur */
+ private CameraManagerListener mListener;
+
+ /** True if the camera is currently in the process of taking an image */
+ private boolean mTakingPicture;
+
+ /** Provides subscription-related data to access per-subscription configurations. */
+ private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider;
+
+ /** Manages auto focus visual and behavior */
+ private final FocusOverlayManager mFocusOverlayManager;
+
+ private CameraManager() {
+ mCameraInfo = new CameraInfo();
+ mCameraIndex = NO_CAMERA_SELECTED;
+
+ // Check to see if a front and back camera exist
+ boolean hasFrontCamera = false;
+ boolean hasBackCamera = false;
+ final CameraInfo cameraInfo = new CameraInfo();
+ final int cameraCount = sCameraWrapper.getNumberOfCameras();
+ try {
+ for (int i = 0; i < cameraCount; i++) {
+ sCameraWrapper.getCameraInfo(i, cameraInfo);
+ if (cameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT) {
+ hasFrontCamera = true;
+ } else if (cameraInfo.facing == CameraInfo.CAMERA_FACING_BACK) {
+ hasBackCamera = true;
+ }
+ if (hasFrontCamera && hasBackCamera) {
+ break;
+ }
+ }
+ } catch (final RuntimeException e) {
+ LogUtil.e(TAG, "Unable to load camera info", e);
+ }
+ mHasFrontAndBackCamera = hasFrontCamera && hasBackCamera;
+ mFocusOverlayManager = new FocusOverlayManager(this, Looper.getMainLooper());
+
+ // Assume the best until we are proven otherwise
+ mIsHardwareAccelerationSupported = true;
+ }
+
+ /** Gets the singleton instance */
+ static CameraManager get() {
+ if (sInstance == null) {
+ sInstance = new CameraManager();
+ }
+ return sInstance;
+ }
+
+ /** Allows tests to inject a custom camera wrapper */
+ @VisibleForTesting
+ static void setCameraWrapper(final CameraWrapper cameraWrapper) {
+ sCameraWrapper = cameraWrapper;
+ sInstance = null;
+ }
+
+ /**
+ * Sets the surface to use to display the preview
+ * This must only be called AFTER the CameraPreview has a texture ready
+ * @param preview The preview surface view
+ */
+ void setSurface(final CameraPreview preview) {
+ if (preview == mCameraPreview) {
+ return;
+ }
+
+ if (preview != null) {
+ Assert.isTrue(preview.isValid());
+ preview.setOnTouchListener(new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(final View view, final MotionEvent motionEvent) {
+ if ((motionEvent.getActionMasked() & MotionEvent.ACTION_UP) ==
+ MotionEvent.ACTION_UP) {
+ mFocusOverlayManager.setPreviewSize(view.getWidth(), view.getHeight());
+ mFocusOverlayManager.onSingleTapUp(
+ (int) motionEvent.getX() + view.getLeft(),
+ (int) motionEvent.getY() + view.getTop());
+ }
+ return true;
+ }
+ });
+ }
+ mCameraPreview = preview;
+ tryShowPreview();
+ }
+
+ void setRenderOverlay(final RenderOverlay renderOverlay) {
+ mFocusOverlayManager.setFocusRenderer(renderOverlay != null ?
+ renderOverlay.getPieRenderer() : null);
+ }
+
+ /** Convenience function to swap between front and back facing cameras */
+ void swapCamera() {
+ Assert.isTrue(mCameraIndex >= 0);
+ selectCamera(mCameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT ?
+ CameraInfo.CAMERA_FACING_BACK :
+ CameraInfo.CAMERA_FACING_FRONT);
+ }
+
+ /**
+ * Selects the first camera facing the desired direction, or the first camera if there is no
+ * camera in the desired direction
+ * @param desiredFacing One of the CameraInfo.CAMERA_FACING_* constants
+ * @return True if a camera was selected, or false if selecting a camera failed
+ */
+ boolean selectCamera(final int desiredFacing) {
+ try {
+ // We already selected a camera facing that direction
+ if (mCameraIndex >= 0 && mCameraInfo.facing == desiredFacing) {
+ return true;
+ }
+
+ final int cameraCount = sCameraWrapper.getNumberOfCameras();
+ Assert.isTrue(cameraCount > 0);
+
+ mCameraIndex = NO_CAMERA_SELECTED;
+ setCamera(null);
+ final CameraInfo cameraInfo = new CameraInfo();
+ for (int i = 0; i < cameraCount; i++) {
+ sCameraWrapper.getCameraInfo(i, cameraInfo);
+ if (cameraInfo.facing == desiredFacing) {
+ mCameraIndex = i;
+ sCameraWrapper.getCameraInfo(i, mCameraInfo);
+ break;
+ }
+ }
+
+ // There's no camera in the desired facing direction, just select the first camera
+ // regardless of direction
+ if (mCameraIndex < 0) {
+ mCameraIndex = 0;
+ sCameraWrapper.getCameraInfo(0, mCameraInfo);
+ }
+
+ if (mOpenRequested) {
+ // The camera is open, so reopen with the newly selected camera
+ openCamera();
+ }
+ return true;
+ } catch (final RuntimeException e) {
+ LogUtil.e(TAG, "RuntimeException in CameraManager.selectCamera", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_OPENING_CAMERA, e);
+ }
+ return false;
+ }
+ }
+
+ int getCameraIndex() {
+ return mCameraIndex;
+ }
+
+ void selectCameraByIndex(final int cameraIndex) {
+ if (mCameraIndex == cameraIndex) {
+ return;
+ }
+
+ try {
+ mCameraIndex = cameraIndex;
+ sCameraWrapper.getCameraInfo(mCameraIndex, mCameraInfo);
+ if (mOpenRequested) {
+ openCamera();
+ }
+ } catch (final RuntimeException e) {
+ LogUtil.e(TAG, "RuntimeException in CameraManager.selectCameraByIndex", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_OPENING_CAMERA, e);
+ }
+ }
+ }
+
+ @VisibleForTesting
+ CameraInfo getCameraInfo() {
+ if (mCameraIndex == NO_CAMERA_SELECTED) {
+ return null;
+ }
+ return mCameraInfo;
+ }
+
+ /** @return True if this device has camera capabilities */
+ boolean hasAnyCamera() {
+ return sCameraWrapper.getNumberOfCameras() > 0;
+ }
+
+ /** @return True if the device has both a front and back camera */
+ boolean hasFrontAndBackCamera() {
+ return mHasFrontAndBackCamera;
+ }
+
+ /**
+ * Opens the camera on a separate thread and initiates the preview if one is available
+ */
+ void openCamera() {
+ if (mCameraIndex == NO_CAMERA_SELECTED) {
+ // Ensure a selected camera if none is currently selected. This may happen if the
+ // camera chooser is not the default media chooser.
+ selectCamera(CameraInfo.CAMERA_FACING_BACK);
+ }
+ mOpenRequested = true;
+ // We're already opening the camera or already have the camera handle, nothing more to do
+ if (mPendingOpenCameraIndex == mCameraIndex || mCamera != null) {
+ return;
+ }
+
+ // True if the task to open the camera has to be delayed until the current one completes
+ boolean delayTask = false;
+
+ // Cancel any previous open camera tasks
+ if (mOpenCameraTask != null) {
+ mPendingOpenCameraIndex = NO_CAMERA_SELECTED;
+ delayTask = true;
+ }
+
+ mPendingOpenCameraIndex = mCameraIndex;
+ mOpenCameraTask = new AsyncTask<Integer, Void, Camera>() {
+ private Exception mException;
+
+ @Override
+ protected Camera doInBackground(final Integer... params) {
+ try {
+ final int cameraIndex = params[0];
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Opening camera " + mCameraIndex);
+ }
+ return sCameraWrapper.open(cameraIndex);
+ } catch (final Exception e) {
+ LogUtil.e(TAG, "Exception while opening camera", e);
+ mException = e;
+ return null;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(final Camera camera) {
+ // If we completed, but no longer want this camera, then release the camera
+ if (mOpenCameraTask != this || !mOpenRequested) {
+ releaseCamera(camera);
+ cleanup();
+ return;
+ }
+
+ cleanup();
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Opened camera " + mCameraIndex + " " + (camera != null));
+ }
+
+ setCamera(camera);
+ if (camera == null) {
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_OPENING_CAMERA, mException);
+ }
+ LogUtil.e(TAG, "Error opening camera");
+ }
+ }
+
+ @Override
+ protected void onCancelled() {
+ super.onCancelled();
+ cleanup();
+ }
+
+ private void cleanup() {
+ mPendingOpenCameraIndex = NO_CAMERA_SELECTED;
+ if (mOpenCameraTask != null && mOpenCameraTask.getStatus() == Status.PENDING) {
+ // If there's another task waiting on this one to complete, start it now
+ mOpenCameraTask.execute(mCameraIndex);
+ } else {
+ mOpenCameraTask = null;
+ }
+
+ }
+ };
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Start opening camera " + mCameraIndex);
+ }
+
+ if (!delayTask) {
+ mOpenCameraTask.execute(mCameraIndex);
+ }
+ }
+
+ boolean isVideoMode() {
+ return mVideoModeRequested;
+ }
+
+ boolean isRecording() {
+ return mVideoModeRequested && mVideoCallback != null;
+ }
+
+ void setVideoMode(final boolean videoMode) {
+ if (mVideoModeRequested == videoMode) {
+ return;
+ }
+ mVideoModeRequested = videoMode;
+ tryInitOrCleanupVideoMode();
+ }
+
+ /** Closes the camera releasing the resources it uses */
+ void closeCamera() {
+ mOpenRequested = false;
+ setCamera(null);
+ }
+
+ /** Temporarily closes the camera if it is open */
+ void onPause() {
+ setCamera(null);
+ }
+
+ /** Reopens the camera if it was opened when onPause was called */
+ void onResume() {
+ if (mOpenRequested) {
+ openCamera();
+ }
+ }
+
+ /**
+ * Sets the listener which will be notified of errors or other events in the camera
+ * @param listener The listener to notify
+ */
+ void setListener(final CameraManagerListener listener) {
+ Assert.isMainThread();
+ mListener = listener;
+ if (!mIsHardwareAccelerationSupported && mListener != null) {
+ mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null);
+ }
+ }
+
+ void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) {
+ mSubscriptionDataProvider = provider;
+ }
+
+ void takePicture(final float heightPercent, @NonNull final MediaCallback callback) {
+ Assert.isTrue(!mVideoModeRequested);
+ Assert.isTrue(!mTakingPicture);
+ Assert.notNull(callback);
+ if (mCamera == null) {
+ // The caller should have checked isCameraAvailable first, but just in case, protect
+ // against a null camera by notifying the callback that taking the picture didn't work
+ callback.onMediaFailed(null);
+ return;
+ }
+ final Camera.PictureCallback jpegCallback = new Camera.PictureCallback() {
+ @Override
+ public void onPictureTaken(final byte[] bytes, final Camera camera) {
+ mTakingPicture = false;
+ if (mCamera != camera) {
+ // This may happen if the camera was changed between front/back while the
+ // picture is being taken.
+ callback.onMediaInfo(MediaCallback.MEDIA_CAMERA_CHANGED);
+ return;
+ }
+
+ if (bytes == null) {
+ callback.onMediaInfo(MediaCallback.MEDIA_NO_DATA);
+ return;
+ }
+
+ final Camera.Size size = camera.getParameters().getPictureSize();
+ int width;
+ int height;
+ if (mRotation == 90 || mRotation == 270) {
+ width = size.height;
+ height = size.width;
+ } else {
+ width = size.width;
+ height = size.height;
+ }
+ new ImagePersistTask(
+ width, height, heightPercent, bytes, mCameraPreview.getContext(), callback)
+ .executeOnThreadPool();
+ }
+ };
+
+ mTakingPicture = true;
+ try {
+ mCamera.takePicture(
+ null /* shutter */,
+ null /* raw */,
+ null /* postView */,
+ jpegCallback);
+ } catch (final RuntimeException e) {
+ LogUtil.e(TAG, "RuntimeException in CameraManager.takePicture", e);
+ mTakingPicture = false;
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_TAKING_PICTURE, e);
+ }
+ }
+ }
+
+ void startVideo(final MediaCallback callback) {
+ Assert.notNull(callback);
+ Assert.isTrue(!isRecording());
+ mVideoCallback = callback;
+ tryStartVideoCapture();
+ }
+
+ /**
+ * Asynchronously releases a camera
+ * @param camera The camera to release
+ */
+ private void releaseCamera(final Camera camera) {
+ if (camera == null) {
+ return;
+ }
+
+ mFocusOverlayManager.onCameraReleased();
+
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(final Void... params) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Releasing camera " + mCameraIndex);
+ }
+ sCameraWrapper.release(camera);
+ return null;
+ }
+ }.execute();
+ }
+
+ private void releaseMediaRecorder(final boolean cleanupFile) {
+ if (mMediaRecorder == null) {
+ return;
+ }
+ mVideoModeRequested = false;
+
+ if (cleanupFile) {
+ mMediaRecorder.cleanupTempFile();
+ if (mVideoCallback != null) {
+ final MediaCallback callback = mVideoCallback;
+ mVideoCallback = null;
+ // Notify the callback that we've stopped recording
+ callback.onMediaReady(null /*uri*/, null /*contentType*/, 0 /*width*/,
+ 0 /*height*/);
+ }
+ }
+
+ mMediaRecorder.release();
+ mMediaRecorder = null;
+
+ if (mCamera != null) {
+ try {
+ mCamera.reconnect();
+ } catch (final IOException e) {
+ LogUtil.e(TAG, "IOException in CameraManager.releaseMediaRecorder", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_OPENING_CAMERA, e);
+ }
+ } catch (final RuntimeException e) {
+ LogUtil.e(TAG, "RuntimeException in CameraManager.releaseMediaRecorder", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_OPENING_CAMERA, e);
+ }
+ }
+ }
+ restoreRequestedOrientation();
+ }
+
+ /** Updates the orientation of the camera to match the orientation of the device */
+ private void updateCameraOrientation() {
+ if (mCamera == null || mCameraPreview == null || mTakingPicture) {
+ return;
+ }
+
+ final WindowManager windowManager =
+ (WindowManager) mCameraPreview.getContext().getSystemService(
+ Context.WINDOW_SERVICE);
+
+ int degrees = 0;
+ switch (windowManager.getDefaultDisplay().getRotation()) {
+ case Surface.ROTATION_0: degrees = 0; break;
+ case Surface.ROTATION_90: degrees = 90; break;
+ case Surface.ROTATION_180: degrees = 180; break;
+ case Surface.ROTATION_270: degrees = 270; break;
+ }
+
+ // The display orientation of the camera (this controls the preview image).
+ int orientation;
+
+ // The clockwise rotation angle relative to the orientation of the camera. This affects
+ // pictures returned by the camera in Camera.PictureCallback.
+ int rotation;
+ if (mCameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
+ orientation = (mCameraInfo.orientation + degrees) % 360;
+ rotation = orientation;
+ // compensate the mirror but only for orientation
+ orientation = (360 - orientation) % 360;
+ } else { // back-facing
+ orientation = (mCameraInfo.orientation - degrees + 360) % 360;
+ rotation = orientation;
+ }
+ mRotation = rotation;
+ if (mMediaRecorder == null) {
+ try {
+ mCamera.setDisplayOrientation(orientation);
+ final Camera.Parameters params = mCamera.getParameters();
+ params.setRotation(rotation);
+ mCamera.setParameters(params);
+ } catch (final RuntimeException e) {
+ LogUtil.e(TAG, "RuntimeException in CameraManager.updateCameraOrientation", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_OPENING_CAMERA, e);
+ }
+ }
+ }
+ }
+
+ /** Sets the current camera, releasing any previously opened camera */
+ private void setCamera(final Camera camera) {
+ if (mCamera == camera) {
+ return;
+ }
+
+ releaseMediaRecorder(true /* cleanupFile */);
+ releaseCamera(mCamera);
+ mCamera = camera;
+ tryShowPreview();
+ if (mListener != null) {
+ mListener.onCameraChanged();
+ }
+ }
+
+ /** Shows the preview if the camera is open and the preview is loaded */
+ private void tryShowPreview() {
+ if (mCameraPreview == null || mCamera == null) {
+ if (mOrientationHandler != null) {
+ mOrientationHandler.disable();
+ mOrientationHandler = null;
+ }
+ releaseMediaRecorder(true /* cleanupFile */);
+ mFocusOverlayManager.onPreviewStopped();
+ return;
+ }
+ try {
+ mCamera.stopPreview();
+ updateCameraOrientation();
+
+ final Camera.Parameters params = mCamera.getParameters();
+ final Camera.Size pictureSize = chooseBestPictureSize();
+ final Camera.Size previewSize = chooseBestPreviewSize(pictureSize);
+ params.setPreviewSize(previewSize.width, previewSize.height);
+ params.setPictureSize(pictureSize.width, pictureSize.height);
+ logCameraSize("Setting preview size: ", previewSize);
+ logCameraSize("Setting picture size: ", pictureSize);
+ mCameraPreview.setSize(previewSize, mCameraInfo.orientation);
+ for (final String focusMode : params.getSupportedFocusModes()) {
+ if (TextUtils.equals(focusMode, Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) {
+ // Use continuous focus if available
+ params.setFocusMode(focusMode);
+ break;
+ }
+ }
+
+ mCamera.setParameters(params);
+ mCameraPreview.startPreview(mCamera);
+ mCamera.startPreview();
+ mCamera.setAutoFocusMoveCallback(new Camera.AutoFocusMoveCallback() {
+ @Override
+ public void onAutoFocusMoving(final boolean start, final Camera camera) {
+ mFocusOverlayManager.onAutoFocusMoving(start);
+ }
+ });
+ mFocusOverlayManager.setParameters(mCamera.getParameters());
+ mFocusOverlayManager.setMirror(mCameraInfo.facing == CameraInfo.CAMERA_FACING_BACK);
+ mFocusOverlayManager.onPreviewStarted();
+ tryInitOrCleanupVideoMode();
+ if (mOrientationHandler == null) {
+ mOrientationHandler = new OrientationHandler(mCameraPreview.getContext());
+ mOrientationHandler.enable();
+ }
+ } catch (final IOException e) {
+ LogUtil.e(TAG, "IOException in CameraManager.tryShowPreview", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_SHOWING_PREVIEW, e);
+ }
+ } catch (final RuntimeException e) {
+ LogUtil.e(TAG, "RuntimeException in CameraManager.tryShowPreview", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_SHOWING_PREVIEW, e);
+ }
+ }
+ }
+
+ private void tryInitOrCleanupVideoMode() {
+ if (!mVideoModeRequested || mCamera == null || mCameraPreview == null) {
+ releaseMediaRecorder(true /* cleanupFile */);
+ return;
+ }
+
+ if (mMediaRecorder != null) {
+ return;
+ }
+
+ try {
+ mCamera.unlock();
+ final int maxMessageSize = getMmsConfig().getMaxMessageSize();
+ mMediaRecorder = new MmsVideoRecorder(mCamera, mCameraIndex, mRotation, maxMessageSize);
+ mMediaRecorder.prepare();
+ } catch (final FileNotFoundException e) {
+ LogUtil.e(TAG, "FileNotFoundException in CameraManager.tryInitOrCleanupVideoMode", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_STORAGE_FAILURE, e);
+ }
+ setVideoMode(false);
+ return;
+ } catch (final IOException e) {
+ LogUtil.e(TAG, "IOException in CameraManager.tryInitOrCleanupVideoMode", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e);
+ }
+ setVideoMode(false);
+ return;
+ } catch (final RuntimeException e) {
+ LogUtil.e(TAG, "RuntimeException in CameraManager.tryInitOrCleanupVideoMode", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e);
+ }
+ setVideoMode(false);
+ return;
+ }
+
+ tryStartVideoCapture();
+ }
+
+ private void tryStartVideoCapture() {
+ if (mMediaRecorder == null || mVideoCallback == null) {
+ return;
+ }
+
+ mMediaRecorder.setOnErrorListener(new MediaRecorder.OnErrorListener() {
+ @Override
+ public void onError(final MediaRecorder mediaRecorder, final int what,
+ final int extra) {
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_RECORDING_VIDEO, null);
+ }
+ restoreRequestedOrientation();
+ }
+ });
+
+ mMediaRecorder.setOnInfoListener(new MediaRecorder.OnInfoListener() {
+ @Override
+ public void onInfo(final MediaRecorder mediaRecorder, final int what, final int extra) {
+ if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED ||
+ what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
+ stopVideo();
+ }
+ }
+ });
+
+ try {
+ mMediaRecorder.start();
+ final Activity activity = UiUtils.getActivity(mCameraPreview.getContext());
+ activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ lockOrientation();
+ } catch (final IllegalStateException e) {
+ LogUtil.e(TAG, "IllegalStateException in CameraManager.tryStartVideoCapture", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_RECORDING_VIDEO, e);
+ }
+ setVideoMode(false);
+ restoreRequestedOrientation();
+ } catch (final RuntimeException e) {
+ LogUtil.e(TAG, "RuntimeException in CameraManager.tryStartVideoCapture", e);
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_RECORDING_VIDEO, e);
+ }
+ setVideoMode(false);
+ restoreRequestedOrientation();
+ }
+ }
+
+ void stopVideo() {
+ int width = ImageRequest.UNSPECIFIED_SIZE;
+ int height = ImageRequest.UNSPECIFIED_SIZE;
+ Uri uri = null;
+ String contentType = null;
+ try {
+ final Activity activity = UiUtils.getActivity(mCameraPreview.getContext());
+ activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ mMediaRecorder.stop();
+ width = mMediaRecorder.getVideoWidth();
+ height = mMediaRecorder.getVideoHeight();
+ uri = mMediaRecorder.getVideoUri();
+ contentType = mMediaRecorder.getContentType();
+ } catch (final RuntimeException e) {
+ // MediaRecorder.stop will throw a RuntimeException if the video was too short, let the
+ // finally clause call the callback with null uri and handle cleanup
+ LogUtil.e(TAG, "RuntimeException in CameraManager.stopVideo", e);
+ } finally {
+ final MediaCallback videoCallback = mVideoCallback;
+ mVideoCallback = null;
+ releaseMediaRecorder(false /* cleanupFile */);
+ if (uri == null) {
+ tryInitOrCleanupVideoMode();
+ }
+ videoCallback.onMediaReady(uri, contentType, width, height);
+ }
+ }
+
+ boolean isCameraAvailable() {
+ return mCamera != null && !mTakingPicture && mIsHardwareAccelerationSupported;
+ }
+
+ /**
+ * External components call into this to report if hardware acceleration is supported. When
+ * hardware acceleration isn't supported, we need to report an error through the listener
+ * interface
+ * @param isHardwareAccelerationSupported True if the preview is rendering in a hardware
+ * accelerated view.
+ */
+ void reportHardwareAccelerationSupported(final boolean isHardwareAccelerationSupported) {
+ Assert.isMainThread();
+ if (mIsHardwareAccelerationSupported == isHardwareAccelerationSupported) {
+ // If the value hasn't changed nothing more to do
+ return;
+ }
+
+ mIsHardwareAccelerationSupported = isHardwareAccelerationSupported;
+ if (!isHardwareAccelerationSupported) {
+ LogUtil.e(TAG, "Software rendering - cannot open camera");
+ if (mListener != null) {
+ mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null);
+ }
+ }
+ }
+
+ /** Returns the scale factor to scale the width/height to max allowed in MmsConfig */
+ private float getScaleFactorForMaxAllowedSize(final int width, final int height,
+ final int maxWidth, final int maxHeight) {
+ if (maxWidth <= 0 || maxHeight <= 0) {
+ // MmsConfig initialization runs asynchronously on application startup, so there's a
+ // chance (albeit a very slight one) that we don't have it yet.
+ LogUtil.w(LogUtil.BUGLE_TAG, "Max image size not loaded in MmsConfig");
+ return 1.0f;
+ }
+
+ if (width <= maxWidth && height <= maxHeight) {
+ // Already meeting requirements.
+ return 1.0f;
+ }
+
+ return Math.min(maxWidth * 1.0f / width, maxHeight * 1.0f / height);
+ }
+
+ private MmsConfig getMmsConfig() {
+ final int subId = mSubscriptionDataProvider != null ?
+ mSubscriptionDataProvider.getConversationSelfSubId() :
+ ParticipantData.DEFAULT_SELF_SUB_ID;
+ return MmsConfig.get(subId);
+ }
+
+ /**
+ * Choose the best picture size by trying to find a size close to the MmsConfig's max size,
+ * which is closest to the screen aspect ratio
+ */
+ private Camera.Size chooseBestPictureSize() {
+ final Context context = mCameraPreview.getContext();
+ final Resources resources = context.getResources();
+ final DisplayMetrics displayMetrics = resources.getDisplayMetrics();
+ final int displayOrientation = resources.getConfiguration().orientation;
+ int cameraOrientation = mCameraInfo.orientation;
+
+ int screenWidth;
+ int screenHeight;
+ if (displayOrientation == Configuration.ORIENTATION_LANDSCAPE) {
+ // Rotate the camera orientation 90 degrees to compensate for the rotated display
+ // metrics. Direction doesn't matter because we're just using it for width/height
+ cameraOrientation += 90;
+ }
+
+ // Check the camera orientation relative to the display.
+ // For 0, 180, 360, the screen width/height are the display width/height
+ // For 90, 270, the screen width/height are inverted from the display
+ if (cameraOrientation % 180 == 0) {
+ screenWidth = displayMetrics.widthPixels;
+ screenHeight = displayMetrics.heightPixels;
+ } else {
+ screenWidth = displayMetrics.heightPixels;
+ screenHeight = displayMetrics.widthPixels;
+ }
+
+ final MmsConfig mmsConfig = getMmsConfig();
+ final int maxWidth = mmsConfig.getMaxImageWidth();
+ final int maxHeight = mmsConfig.getMaxImageHeight();
+
+ // Constrain the size within the max width/height defined by MmsConfig.
+ final float scaleFactor = getScaleFactorForMaxAllowedSize(screenWidth, screenHeight,
+ maxWidth, maxHeight);
+ screenWidth *= scaleFactor;
+ screenHeight *= scaleFactor;
+
+ final float aspectRatio = BugleGservices.get().getFloat(
+ BugleGservicesKeys.CAMERA_ASPECT_RATIO,
+ screenWidth / (float) screenHeight);
+ final List<Camera.Size> sizes = new ArrayList<Camera.Size>(
+ mCamera.getParameters().getSupportedPictureSizes());
+ final int maxPixels = maxWidth * maxHeight;
+
+ // Sort the sizes so the best size is first
+ Collections.sort(sizes, new SizeComparator(maxWidth, maxHeight, aspectRatio, maxPixels));
+
+ return sizes.get(0);
+ }
+
+ /**
+ * Chose the best preview size based on the picture size. Try to find a size with the same
+ * aspect ratio and size as the picture if possible
+ */
+ private Camera.Size chooseBestPreviewSize(final Camera.Size pictureSize) {
+ final List<Camera.Size> sizes = new ArrayList<Camera.Size>(
+ mCamera.getParameters().getSupportedPreviewSizes());
+ final float aspectRatio = pictureSize.width / (float) pictureSize.height;
+ final int capturePixels = pictureSize.width * pictureSize.height;
+
+ // Sort the sizes so the best size is first
+ Collections.sort(sizes, new SizeComparator(Integer.MAX_VALUE, Integer.MAX_VALUE,
+ aspectRatio, capturePixels));
+
+ return sizes.get(0);
+ }
+
+ private class OrientationHandler extends OrientationEventListener {
+ OrientationHandler(final Context context) {
+ super(context);
+ }
+
+ @Override
+ public void onOrientationChanged(final int orientation) {
+ updateCameraOrientation();
+ }
+ }
+
+ private static class SizeComparator implements Comparator<Camera.Size> {
+ private static final int PREFER_LEFT = -1;
+ private static final int PREFER_RIGHT = 1;
+
+ // The max width/height for the preferred size. Integer.MAX_VALUE if no size limit
+ private final int mMaxWidth;
+ private final int mMaxHeight;
+
+ // The desired aspect ratio
+ private final float mTargetAspectRatio;
+
+ // The desired size (width x height) to try to match
+ private final int mTargetPixels;
+
+ public SizeComparator(final int maxWidth, final int maxHeight,
+ final float targetAspectRatio, final int targetPixels) {
+ mMaxWidth = maxWidth;
+ mMaxHeight = maxHeight;
+ mTargetAspectRatio = targetAspectRatio;
+ mTargetPixels = targetPixels;
+ }
+
+ /**
+ * Returns a negative value if left is a better choice than right, or a positive value if
+ * right is a better choice is better than left. 0 if they are equal
+ */
+ @Override
+ public int compare(final Camera.Size left, final Camera.Size right) {
+ // If one size is less than the max size prefer it over the other
+ if ((left.width <= mMaxWidth && left.height <= mMaxHeight) !=
+ (right.width <= mMaxWidth && right.height <= mMaxHeight)) {
+ return left.width <= mMaxWidth ? PREFER_LEFT : PREFER_RIGHT;
+ }
+
+ // If one is closer to the target aspect ratio, prefer it.
+ final float leftAspectRatio = left.width / (float) left.height;
+ final float rightAspectRatio = right.width / (float) right.height;
+ final float leftAspectRatioDiff = Math.abs(leftAspectRatio - mTargetAspectRatio);
+ final float rightAspectRatioDiff = Math.abs(rightAspectRatio - mTargetAspectRatio);
+ if (leftAspectRatioDiff != rightAspectRatioDiff) {
+ return (leftAspectRatioDiff - rightAspectRatioDiff) < 0 ?
+ PREFER_LEFT : PREFER_RIGHT;
+ }
+
+ // At this point they have the same aspect ratio diff and are either both bigger
+ // than the max size or both smaller than the max size, so prefer the one closest
+ // to target size
+ final int leftDiff = Math.abs((left.width * left.height) - mTargetPixels);
+ final int rightDiff = Math.abs((right.width * right.height) - mTargetPixels);
+ return leftDiff - rightDiff;
+ }
+ }
+
+ @Override // From FocusOverlayManager.Listener
+ public void autoFocus() {
+ if (mCamera == null) {
+ return;
+ }
+
+ try {
+ mCamera.autoFocus(new Camera.AutoFocusCallback() {
+ @Override
+ public void onAutoFocus(final boolean success, final Camera camera) {
+ mFocusOverlayManager.onAutoFocus(success, false /* shutterDown */);
+ }
+ });
+ } catch (final RuntimeException e) {
+ LogUtil.e(TAG, "RuntimeException in CameraManager.autoFocus", e);
+ // If autofocus fails, the camera should have called the callback with success=false,
+ // but some throw an exception here
+ mFocusOverlayManager.onAutoFocus(false /*success*/, false /*shutterDown*/);
+ }
+ }
+
+ @Override // From FocusOverlayManager.Listener
+ public void cancelAutoFocus() {
+ if (mCamera == null) {
+ return;
+ }
+ try {
+ mCamera.cancelAutoFocus();
+ } catch (final RuntimeException e) {
+ // Ignore
+ LogUtil.e(TAG, "RuntimeException in CameraManager.cancelAutoFocus", e);
+ }
+ }
+
+ @Override // From FocusOverlayManager.Listener
+ public boolean capture() {
+ return false;
+ }
+
+ @Override // From FocusOverlayManager.Listener
+ public void setFocusParameters() {
+ if (mCamera == null) {
+ return;
+ }
+ try {
+ final Camera.Parameters parameters = mCamera.getParameters();
+ parameters.setFocusMode(mFocusOverlayManager.getFocusMode());
+ if (parameters.getMaxNumFocusAreas() > 0) {
+ // Don't set focus areas (even to null) if focus areas aren't supported, camera may
+ // crash
+ parameters.setFocusAreas(mFocusOverlayManager.getFocusAreas());
+ }
+ parameters.setMeteringAreas(mFocusOverlayManager.getMeteringAreas());
+ mCamera.setParameters(parameters);
+ } catch (final RuntimeException e) {
+ // This occurs when the device is out of space or when the camera is locked
+ LogUtil.e(TAG, "RuntimeException in CameraManager setFocusParameters");
+ }
+ }
+
+ private void logCameraSize(final String prefix, final Camera.Size size) {
+ // Log the camera size and aspect ratio for help when examining bug reports for camera
+ // failures
+ LogUtil.i(TAG, prefix + size.width + "x" + size.height +
+ " (" + (size.width / (float) size.height) + ")");
+ }
+
+
+ private Integer mSavedOrientation = null;
+
+ private void lockOrientation() {
+ // when we start recording, lock our orientation
+ final Activity a = UiUtils.getActivity(mCameraPreview.getContext());
+ final WindowManager windowManager =
+ (WindowManager) a.getSystemService(Context.WINDOW_SERVICE);
+ final int rotation = windowManager.getDefaultDisplay().getRotation();
+
+ mSavedOrientation = a.getRequestedOrientation();
+ switch (rotation) {
+ case Surface.ROTATION_0:
+ a.setRequestedOrientation(
+ ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
+ break;
+ case Surface.ROTATION_90:
+ a.setRequestedOrientation(
+ ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
+ break;
+ case Surface.ROTATION_180:
+ a.setRequestedOrientation(
+ ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT);
+ break;
+ case Surface.ROTATION_270:
+ a.setRequestedOrientation(
+ ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE);
+ break;
+ }
+
+ }
+
+ private void restoreRequestedOrientation() {
+ if (mSavedOrientation != null) {
+ final Activity a = UiUtils.getActivity(mCameraPreview.getContext());
+ if (a != null) {
+ a.setRequestedOrientation(mSavedOrientation);
+ }
+ mSavedOrientation = null;
+ }
+ }
+
+ static boolean hasCameraPermission() {
+ return OsUtil.hasPermission(Manifest.permission.CAMERA);
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java
new file mode 100644
index 0000000..2c7a7f2
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java
@@ -0,0 +1,481 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.graphics.Rect;
+import android.hardware.Camera;
+import android.net.Uri;
+import android.os.SystemClock;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.animation.AnimationSet;
+import android.widget.Chronometer;
+import android.widget.ImageButton;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.MediaPickerMessagePartData;
+import com.android.messaging.ui.mediapicker.CameraManager.MediaCallback;
+import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * Chooser which allows the user to take pictures or video without leaving the current app/activity
+ */
+class CameraMediaChooser extends MediaChooser implements
+ CameraManager.CameraManagerListener {
+ private CameraPreview.CameraPreviewHost mCameraPreviewHost;
+ private ImageButton mFullScreenButton;
+ private ImageButton mSwapCameraButton;
+ private ImageButton mSwapModeButton;
+ private ImageButton mCaptureButton;
+ private ImageButton mCancelVideoButton;
+ private Chronometer mVideoCounter;
+ private boolean mVideoCancelled;
+ private int mErrorToast;
+ private View mEnabledView;
+ private View mMissingPermissionView;
+
+ CameraMediaChooser(final MediaPicker mediaPicker) {
+ super(mediaPicker);
+ }
+
+ @Override
+ public int getSupportedMediaTypes() {
+ if (CameraManager.get().hasAnyCamera()) {
+ return MediaPicker.MEDIA_TYPE_IMAGE | MediaPicker.MEDIA_TYPE_VIDEO;
+ } else {
+ return MediaPicker.MEDIA_TYPE_NONE;
+ }
+ }
+
+ @Override
+ public View destroyView() {
+ CameraManager.get().closeCamera();
+ CameraManager.get().setListener(null);
+ CameraManager.get().setSubscriptionDataProvider(null);
+ return super.destroyView();
+ }
+
+ @Override
+ protected View createView(final ViewGroup container) {
+ CameraManager.get().setListener(this);
+ CameraManager.get().setSubscriptionDataProvider(this);
+ CameraManager.get().setVideoMode(false);
+ final LayoutInflater inflater = getLayoutInflater();
+ final CameraMediaChooserView view = (CameraMediaChooserView) inflater.inflate(
+ R.layout.mediapicker_camera_chooser,
+ container /* root */,
+ false /* attachToRoot */);
+ mCameraPreviewHost = (CameraPreview.CameraPreviewHost) view.findViewById(
+ R.id.camera_preview);
+ mCameraPreviewHost.getView().setOnTouchListener(new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(final View view, final MotionEvent motionEvent) {
+ if (CameraManager.get().isVideoMode()) {
+ // Prevent the swipe down in video mode because video is always captured in
+ // full screen
+ return true;
+ }
+
+ return false;
+ }
+ });
+
+ final View shutterVisual = view.findViewById(R.id.camera_shutter_visual);
+
+ mFullScreenButton = (ImageButton) view.findViewById(R.id.camera_fullScreen_button);
+ mFullScreenButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ mMediaPicker.setFullScreen(true);
+ }
+ });
+
+ mSwapCameraButton = (ImageButton) view.findViewById(R.id.camera_swapCamera_button);
+ mSwapCameraButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ CameraManager.get().swapCamera();
+ }
+ });
+
+ mCaptureButton = (ImageButton) view.findViewById(R.id.camera_capture_button);
+ mCaptureButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ final float heightPercent = Math.min(mMediaPicker.getViewPager().getHeight() /
+ (float) mCameraPreviewHost.getView().getHeight(), 1);
+
+ if (CameraManager.get().isRecording()) {
+ CameraManager.get().stopVideo();
+ } else {
+ final CameraManager.MediaCallback callback = new CameraManager.MediaCallback() {
+ @Override
+ public void onMediaReady(
+ final Uri uriToVideo, final String contentType,
+ final int width, final int height) {
+ mVideoCounter.stop();
+ if (mVideoCancelled || uriToVideo == null) {
+ mVideoCancelled = false;
+ } else {
+ final Rect startRect = new Rect();
+ // It's possible to throw out the chooser while taking the
+ // picture/video. In that case, still use the attachment, just
+ // skip the startRect
+ if (mView != null) {
+ mView.getGlobalVisibleRect(startRect);
+ }
+ mMediaPicker.dispatchItemsSelected(
+ new MediaPickerMessagePartData(startRect, contentType,
+ uriToVideo, width, height),
+ true /* dismissMediaPicker */);
+ }
+ updateViewState();
+ }
+
+ @Override
+ public void onMediaFailed(final Exception exception) {
+ UiUtils.showToastAtBottom(R.string.camera_media_failure);
+ updateViewState();
+ }
+
+ @Override
+ public void onMediaInfo(final int what) {
+ if (what == MediaCallback.MEDIA_NO_DATA) {
+ UiUtils.showToastAtBottom(R.string.camera_media_failure);
+ }
+ updateViewState();
+ }
+ };
+ if (CameraManager.get().isVideoMode()) {
+ CameraManager.get().startVideo(callback);
+ mVideoCounter.setBase(SystemClock.elapsedRealtime());
+ mVideoCounter.start();
+ updateViewState();
+ } else {
+ showShutterEffect(shutterVisual);
+ CameraManager.get().takePicture(heightPercent, callback);
+ updateViewState();
+ }
+ }
+ }
+ });
+
+ mSwapModeButton = (ImageButton) view.findViewById(R.id.camera_swap_mode_button);
+ mSwapModeButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ final boolean isSwitchingToVideo = !CameraManager.get().isVideoMode();
+ if (isSwitchingToVideo && !OsUtil.hasRecordAudioPermission()) {
+ requestRecordAudioPermission();
+ } else {
+ onSwapMode();
+ }
+ }
+ });
+
+ mCancelVideoButton = (ImageButton) view.findViewById(R.id.camera_cancel_button);
+ mCancelVideoButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ mVideoCancelled = true;
+ CameraManager.get().stopVideo();
+ mMediaPicker.dismiss(true);
+ }
+ });
+
+ mVideoCounter = (Chronometer) view.findViewById(R.id.camera_video_counter);
+
+ CameraManager.get().setRenderOverlay((RenderOverlay) view.findViewById(R.id.focus_visual));
+
+ mEnabledView = view.findViewById(R.id.mediapicker_enabled);
+ mMissingPermissionView = view.findViewById(R.id.missing_permission_view);
+
+ // Must set mView before calling updateViewState because it operates on mView
+ mView = view;
+ updateViewState();
+ updateForPermissionState(CameraManager.hasCameraPermission());
+ return view;
+ }
+
+ @Override
+ public int getIconResource() {
+ return R.drawable.ic_camera_light;
+ }
+
+ @Override
+ public int getIconDescriptionResource() {
+ return R.string.mediapicker_cameraChooserDescription;
+ }
+
+ /**
+ * Updates the view when entering or leaving full-screen camera mode
+ * @param fullScreen
+ */
+ @Override
+ void onFullScreenChanged(final boolean fullScreen) {
+ super.onFullScreenChanged(fullScreen);
+ if (!fullScreen && CameraManager.get().isVideoMode()) {
+ CameraManager.get().setVideoMode(false);
+ }
+ updateViewState();
+ }
+
+ /**
+ * Initializes the control to a default state when it is opened / closed
+ * @param open True if the control is opened
+ */
+ @Override
+ void onOpenedChanged(final boolean open) {
+ super.onOpenedChanged(open);
+ updateViewState();
+ }
+
+ @Override
+ protected void setSelected(final boolean selected) {
+ super.setSelected(selected);
+ if (selected) {
+ if (CameraManager.hasCameraPermission()) {
+ // If an error occurred before the chooser was selected, show it now
+ showErrorToastIfNeeded();
+ } else {
+ requestCameraPermission();
+ }
+ }
+ }
+
+ private void requestCameraPermission() {
+ mMediaPicker.requestPermissions(new String[] { Manifest.permission.CAMERA },
+ MediaPicker.CAMERA_PERMISSION_REQUEST_CODE);
+ }
+
+ private void requestRecordAudioPermission() {
+ mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO },
+ MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE);
+ }
+
+ @Override
+ protected void onRequestPermissionsResult(
+ final int requestCode, final String permissions[], final int[] grantResults) {
+ if (requestCode == MediaPicker.CAMERA_PERMISSION_REQUEST_CODE) {
+ final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED;
+ updateForPermissionState(permissionGranted);
+ if (permissionGranted) {
+ mCameraPreviewHost.onCameraPermissionGranted();
+ }
+ } else if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) {
+ Assert.isFalse(CameraManager.get().isVideoMode());
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ // Switch to video mode
+ onSwapMode();
+ } else {
+ // Stay in still-photo mode
+ }
+ }
+ }
+
+ private void updateForPermissionState(final boolean granted) {
+ // onRequestPermissionsResult can sometimes get called before createView().
+ if (mEnabledView == null) {
+ return;
+ }
+
+ mEnabledView.setVisibility(granted ? View.VISIBLE : View.GONE);
+ mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE);
+ }
+
+ @Override
+ public boolean canSwipeDown() {
+ if (CameraManager.get().isVideoMode()) {
+ return true;
+ }
+ return super.canSwipeDown();
+ }
+
+ /**
+ * Handles an error from the camera manager by showing the appropriate error message to the user
+ * @param errorCode One of the CameraManager.ERROR_* constants
+ * @param e The exception which caused the error, if any
+ */
+ @Override
+ public void onCameraError(final int errorCode, final Exception e) {
+ switch (errorCode) {
+ case CameraManager.ERROR_OPENING_CAMERA:
+ case CameraManager.ERROR_SHOWING_PREVIEW:
+ mErrorToast = R.string.camera_error_opening;
+ break;
+ case CameraManager.ERROR_INITIALIZING_VIDEO:
+ mErrorToast = R.string.camera_error_video_init_fail;
+ updateViewState();
+ break;
+ case CameraManager.ERROR_STORAGE_FAILURE:
+ mErrorToast = R.string.camera_error_storage_fail;
+ updateViewState();
+ break;
+ case CameraManager.ERROR_TAKING_PICTURE:
+ mErrorToast = R.string.camera_error_failure_taking_picture;
+ break;
+ default:
+ mErrorToast = R.string.camera_error_unknown;
+ LogUtil.w(LogUtil.BUGLE_TAG, "Unknown camera error:" + errorCode);
+ break;
+ }
+ showErrorToastIfNeeded();
+ }
+
+ private void showErrorToastIfNeeded() {
+ if (mErrorToast != 0 && mSelected) {
+ UiUtils.showToastAtBottom(mErrorToast);
+ mErrorToast = 0;
+ }
+ }
+
+ @Override
+ public void onCameraChanged() {
+ updateViewState();
+ }
+
+ private void onSwapMode() {
+ CameraManager.get().setVideoMode(!CameraManager.get().isVideoMode());
+ if (CameraManager.get().isVideoMode()) {
+ mMediaPicker.setFullScreen(true);
+
+ // For now we start recording immediately
+ mCaptureButton.performClick();
+ }
+ updateViewState();
+ }
+
+ private void showShutterEffect(final View shutterVisual) {
+ final float maxAlpha = getContext().getResources().getFraction(
+ R.fraction.camera_shutter_max_alpha, 1 /* base */, 1 /* pBase */);
+
+ // Divide by 2 so each half of the animation adds up to the full duration
+ final int animationDuration = getContext().getResources().getInteger(
+ R.integer.camera_shutter_duration) / 2;
+
+ final AnimationSet animation = new AnimationSet(false /* shareInterpolator */);
+ final Animation alphaInAnimation = new AlphaAnimation(0.0f, maxAlpha);
+ alphaInAnimation.setDuration(animationDuration);
+ animation.addAnimation(alphaInAnimation);
+
+ final Animation alphaOutAnimation = new AlphaAnimation(maxAlpha, 0.0f);
+ alphaOutAnimation.setStartOffset(animationDuration);
+ alphaOutAnimation.setDuration(animationDuration);
+ animation.addAnimation(alphaOutAnimation);
+
+ animation.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(final Animation animation) {
+ shutterVisual.setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public void onAnimationEnd(final Animation animation) {
+ shutterVisual.setVisibility(View.GONE);
+ }
+
+ @Override
+ public void onAnimationRepeat(final Animation animation) {
+ }
+ });
+ shutterVisual.startAnimation(animation);
+ }
+
+ /** Updates the state of the buttons and overlays based on the current state of the view */
+ private void updateViewState() {
+ if (mView == null) {
+ return;
+ }
+
+ final Context context = getContext();
+ if (context == null) {
+ // Context is null if the fragment was already removed from the activity
+ return;
+ }
+ final boolean fullScreen = mMediaPicker.isFullScreen();
+ final boolean videoMode = CameraManager.get().isVideoMode();
+ final boolean isRecording = CameraManager.get().isRecording();
+ final boolean isCameraAvailable = isCameraAvailable();
+ final Camera.CameraInfo cameraInfo = CameraManager.get().getCameraInfo();
+ final boolean frontCamera = cameraInfo != null && cameraInfo.facing ==
+ Camera.CameraInfo.CAMERA_FACING_FRONT;
+
+ mView.setSystemUiVisibility(
+ fullScreen ? View.SYSTEM_UI_FLAG_LOW_PROFILE :
+ View.SYSTEM_UI_FLAG_VISIBLE);
+
+ mFullScreenButton.setVisibility(!fullScreen ? View.VISIBLE : View.GONE);
+ mFullScreenButton.setEnabled(isCameraAvailable);
+ mSwapCameraButton.setVisibility(
+ fullScreen && !isRecording && CameraManager.get().hasFrontAndBackCamera() ?
+ View.VISIBLE : View.GONE);
+ mSwapCameraButton.setImageResource(frontCamera ?
+ R.drawable.ic_camera_front_light :
+ R.drawable.ic_camera_rear_light);
+ mSwapCameraButton.setEnabled(isCameraAvailable);
+
+ mCancelVideoButton.setVisibility(isRecording ? View.VISIBLE : View.GONE);
+ mVideoCounter.setVisibility(isRecording ? View.VISIBLE : View.GONE);
+
+ mSwapModeButton.setImageResource(videoMode ?
+ R.drawable.ic_mp_camera_small_light :
+ R.drawable.ic_mp_video_small_light);
+ mSwapModeButton.setContentDescription(context.getString(videoMode ?
+ R.string.camera_switch_to_still_mode : R.string.camera_switch_to_video_mode));
+ mSwapModeButton.setVisibility(isRecording ? View.GONE : View.VISIBLE);
+ mSwapModeButton.setEnabled(isCameraAvailable);
+
+ if (isRecording) {
+ mCaptureButton.setImageResource(R.drawable.ic_mp_capture_stop_large_light);
+ mCaptureButton.setContentDescription(context.getString(
+ R.string.camera_stop_recording));
+ } else if (videoMode) {
+ mCaptureButton.setImageResource(R.drawable.ic_mp_video_large_light);
+ mCaptureButton.setContentDescription(context.getString(
+ R.string.camera_start_recording));
+ } else {
+ mCaptureButton.setImageResource(R.drawable.ic_checkmark_large_light);
+ mCaptureButton.setContentDescription(context.getString(
+ R.string.camera_take_picture));
+ }
+ mCaptureButton.setEnabled(isCameraAvailable);
+ }
+
+ @Override
+ int getActionBarTitleResId() {
+ return 0;
+ }
+
+ /**
+ * Returns if the camera is currently ready camera is loaded and not taking a picture.
+ * otherwise we should avoid taking another picture, swapping camera or recording video.
+ */
+ private boolean isCameraAvailable() {
+ return CameraManager.get().isCameraAvailable();
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java
new file mode 100644
index 0000000..64c07b2
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.hardware.Camera;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.util.AttributeSet;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import com.android.messaging.R;
+import com.android.messaging.ui.PersistentInstanceState;
+import com.android.messaging.util.ThreadUtil;
+
+public class CameraMediaChooserView extends FrameLayout implements PersistentInstanceState {
+ private static final String KEY_CAMERA_INDEX = "camera_index";
+
+ // True if we have at least queued an update to the view tree to support software rendering
+ // fallback
+ private boolean mIsSoftwareFallbackActive;
+
+ public CameraMediaChooserView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected Parcelable onSaveInstanceState() {
+ final Bundle bundle = new Bundle();
+ bundle.putInt(KEY_CAMERA_INDEX, CameraManager.get().getCameraIndex());
+ return bundle;
+ }
+
+ @Override
+ protected void onRestoreInstanceState(final Parcelable state) {
+ if (!(state instanceof Bundle)) {
+ return;
+ }
+
+ final Bundle bundle = (Bundle) state;
+ CameraManager.get().selectCameraByIndex(bundle.getInt(KEY_CAMERA_INDEX));
+ }
+
+ @Override
+ public Parcelable saveState() {
+ return onSaveInstanceState();
+ }
+
+ @Override
+ public void restoreState(final Parcelable restoredState) {
+ onRestoreInstanceState(restoredState);
+ }
+
+ @Override
+ public void resetState() {
+ CameraManager.get().selectCamera(Camera.CameraInfo.CAMERA_FACING_BACK);
+ }
+
+ @Override
+ protected void onDraw(final Canvas canvas) {
+ super.onDraw(canvas);
+ // If the canvas isn't hardware accelerated, we have to replace the HardwareCameraPreview
+ // with a SoftwareCameraPreview which supports software rendering
+ if (!canvas.isHardwareAccelerated() && !mIsSoftwareFallbackActive) {
+ mIsSoftwareFallbackActive = true;
+ // Post modifying the tree since we can't modify the view tree during a draw pass
+ ThreadUtil.getMainThreadHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ final HardwareCameraPreview cameraPreview =
+ (HardwareCameraPreview) findViewById(R.id.camera_preview);
+ if (cameraPreview == null) {
+ return;
+ }
+ final ViewGroup parent = ((ViewGroup) cameraPreview.getParent());
+ final int index = parent.indexOfChild(cameraPreview);
+ final SoftwareCameraPreview softwareCameraPreview =
+ new SoftwareCameraPreview(getContext());
+ // Be sure to remove the hardware view before adding the software view to
+ // prevent having 2 camera previews active at the same time
+ parent.removeView(cameraPreview);
+ parent.addView(softwareCameraPreview, index);
+ }
+ });
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/CameraPreview.java b/src/com/android/messaging/ui/mediapicker/CameraPreview.java
new file mode 100644
index 0000000..ecac978
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/CameraPreview.java
@@ -0,0 +1,152 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.hardware.Camera;
+import android.view.View;
+import android.view.View.MeasureSpec;
+import com.android.messaging.util.Assert;
+
+import java.io.IOException;
+
+/**
+ * Contains shared code for SoftwareCameraPreview and HardwareCameraPreview, cannot use inheritance
+ * because those classes must inherit from separate Views, so those classes delegate calls to this
+ * helper class. Specifics for each implementation are in CameraPreviewHost
+ */
+public class CameraPreview {
+ public interface CameraPreviewHost {
+ View getView();
+ boolean isValid();
+ void startPreview(final Camera camera) throws IOException;
+ void onCameraPermissionGranted();
+
+ }
+
+ private int mCameraWidth = -1;
+ private int mCameraHeight = -1;
+
+ private final CameraPreviewHost mHost;
+
+ public CameraPreview(final CameraPreviewHost host) {
+ Assert.notNull(host);
+ Assert.notNull(host.getView());
+ mHost = host;
+ }
+
+ public void setSize(final Camera.Size size, final int orientation) {
+ switch (orientation) {
+ case 0:
+ case 180:
+ mCameraWidth = size.width;
+ mCameraHeight = size.height;
+ break;
+ case 90:
+ case 270:
+ default:
+ mCameraWidth = size.height;
+ mCameraHeight = size.width;
+ }
+ mHost.getView().requestLayout();
+ }
+
+ public int getWidthMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) {
+ if (mCameraHeight >= 0) {
+ final int width = View.MeasureSpec.getSize(widthMeasureSpec);
+ return MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY);
+ } else {
+ return widthMeasureSpec;
+ }
+ }
+
+ public int getHeightMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) {
+ if (mCameraHeight >= 0) {
+ final int orientation = getContext().getResources().getConfiguration().orientation;
+ final int width = View.MeasureSpec.getSize(widthMeasureSpec);
+ final float aspectRatio = (float) mCameraWidth / (float) mCameraHeight;
+ int height;
+ if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ height = (int) (width * aspectRatio);
+ } else {
+ height = (int) (width / aspectRatio);
+ }
+ return View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY);
+ } else {
+ return heightMeasureSpec;
+ }
+ }
+
+ public void onVisibilityChanged(final int visibility) {
+ if (CameraManager.hasCameraPermission()) {
+ if (visibility == View.VISIBLE) {
+ CameraManager.get().openCamera();
+ } else {
+ CameraManager.get().closeCamera();
+ }
+ }
+ }
+
+ public Context getContext() {
+ return mHost.getView().getContext();
+ }
+
+ public void setOnTouchListener(final View.OnTouchListener listener) {
+ mHost.getView().setOnTouchListener(listener);
+ }
+
+ public int getHeight() {
+ return mHost.getView().getHeight();
+ }
+
+ public void onAttachedToWindow() {
+ if (CameraManager.hasCameraPermission()) {
+ CameraManager.get().openCamera();
+ }
+ }
+
+ public void onDetachedFromWindow() {
+ CameraManager.get().closeCamera();
+ }
+
+ public void onRestoreInstanceState() {
+ if (CameraManager.hasCameraPermission()) {
+ CameraManager.get().openCamera();
+ }
+ }
+
+ public void onCameraPermissionGranted() {
+ CameraManager.get().openCamera();
+ }
+
+ /**
+ * @return True if the view is valid and prepared for the camera to start showing the preview
+ */
+ public boolean isValid() {
+ return mHost.isValid();
+ }
+
+ /**
+ * Starts the camera preview on the current surface. Abstracts out the differences in API
+ * from the CameraManager
+ * @throws IOException Which is caught by the CameraManager to display an error
+ */
+ public void startPreview(final Camera camera) throws IOException {
+ mHost.startPreview(camera);
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java b/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java
new file mode 100644
index 0000000..2c36752
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java
@@ -0,0 +1,128 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.data.PendingAttachmentData;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.SafeAsyncTask;
+
+/**
+ * Wraps around the functionalities to allow the user to pick images from the document
+ * picker. Instances of this class must be tied to a Fragment which is able to delegate activity
+ * result callbacks.
+ */
+public class DocumentImagePicker {
+
+ /**
+ * An interface for a listener that listens for when a document has been picked.
+ */
+ public interface SelectionListener {
+ /**
+ * Called when an document is selected from picker. At this point, the file hasn't been
+ * actually loaded and staged in the temp directory, so we are passing in a pending
+ * MessagePartData, which the consumer should use to display a placeholder image.
+ * @param pendingItem a temporary attachment data for showing the placeholder state.
+ */
+ void onDocumentSelected(PendingAttachmentData pendingItem);
+ }
+
+ // The owning fragment.
+ private final Fragment mFragment;
+
+ // The listener on the picker events.
+ private final SelectionListener mListener;
+
+ private static final String EXTRA_PHOTO_URL = "photo_url";
+
+ /**
+ * Creates a new instance of DocumentImagePicker.
+ * @param activity The activity that owns the picker, or the activity that hosts the owning
+ * fragment.
+ */
+ public DocumentImagePicker(final Fragment fragment,
+ final SelectionListener listener) {
+ mFragment = fragment;
+ mListener = listener;
+ }
+
+ /**
+ * Intent out to open an image/video from document picker.
+ */
+ public void launchPicker() {
+ UIIntents.get().launchDocumentImagePicker(mFragment);
+ }
+
+ /**
+ * Must be called from the fragment/activity's onActivityResult().
+ */
+ public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
+ if (requestCode == UIIntents.REQUEST_PICK_IMAGE_FROM_DOCUMENT_PICKER &&
+ resultCode == Activity.RESULT_OK) {
+ // Sometimes called after media item has been picked from the document picker.
+ String url = data.getStringExtra(EXTRA_PHOTO_URL);
+ if (url == null) {
+ // we're using the builtin photo picker which supplies the return
+ // url as it's "data"
+ url = data.getDataString();
+ if (url == null) {
+ final Bundle extras = data.getExtras();
+ if (extras != null) {
+ final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM);
+ if (uri != null) {
+ url = uri.toString();
+ }
+ }
+ }
+ }
+
+ // Guard against null uri cases for when the activity returns a null/invalid intent.
+ if (url != null) {
+ final Uri uri = Uri.parse(url);
+ prepareDocumentForAttachment(uri);
+ }
+ }
+ }
+
+ private void prepareDocumentForAttachment(final Uri documentUri) {
+ // Notify our listener with a PendingAttachmentData containing the metadata.
+ // Asynchronously get the content type for the picked image since
+ // ImageUtils.getContentType() potentially involves I/O and can be expensive.
+ new SafeAsyncTask<Void, Void, String>() {
+ @Override
+ protected String doInBackgroundTimed(final Void... params) {
+ return ImageUtils.getContentType(
+ Factory.get().getApplicationContext().getContentResolver(), documentUri);
+ }
+
+ @Override
+ protected void onPostExecute(final String contentType) {
+ // Ask the listener to create a temporary placeholder item to show the progress.
+ final PendingAttachmentData pendingItem =
+ PendingAttachmentData.createPendingAttachmentData(contentType,
+ documentUri);
+ mListener.onDocumentSelected(pendingItem);
+ }
+ }.executeOnThreadPool();
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java b/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java
new file mode 100644
index 0000000..fda3b19
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.mediapicker.GalleryGridItemView.HostInterface;
+import com.android.messaging.util.Assert;
+
+/**
+ * Bridges between the image cursor loaded by GalleryBoundCursorLoader and the GalleryGridView.
+ */
+public class GalleryGridAdapter extends CursorAdapter {
+ private GalleryGridItemView.HostInterface mGgivHostInterface;
+
+ public GalleryGridAdapter(final Context context, final Cursor cursor) {
+ super(context, cursor, 0);
+ }
+
+ public void setHostInterface(final HostInterface ggivHostInterface) {
+ mGgivHostInterface = ggivHostInterface;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void bindView(final View view, final Context context, final Cursor cursor) {
+ Assert.isTrue(view instanceof GalleryGridItemView);
+ final GalleryGridItemView galleryImageView = (GalleryGridItemView) view;
+ galleryImageView.bind(cursor, mGgivHostInterface);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public View newView(final Context context, final Cursor cursor, final ViewGroup parent) {
+ final LayoutInflater layoutInflater = LayoutInflater.from(context);
+ return layoutInflater.inflate(R.layout.gallery_grid_item_view, parent, false);
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java
new file mode 100644
index 0000000..3d71fe6
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java
@@ -0,0 +1,159 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.TouchDelegate;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.FrameLayout;
+import android.widget.ImageView.ScaleType;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.data.GalleryGridItemData;
+import com.android.messaging.ui.AsyncImageView;
+import com.android.messaging.ui.ConversationDrawables;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Shows an item in the gallery picker grid view. Hosts an FileImageView with a checkbox.
+ */
+public class GalleryGridItemView extends FrameLayout {
+ /**
+ * Implemented by the owner of this GalleryGridItemView instance to communicate on media
+ * picking and selection events.
+ */
+ public interface HostInterface {
+ void onItemClicked(View view, GalleryGridItemData data, boolean longClick);
+ boolean isItemSelected(GalleryGridItemData data);
+ boolean isMultiSelectEnabled();
+ }
+
+ @VisibleForTesting
+ GalleryGridItemData mData;
+ private AsyncImageView mImageView;
+ private CheckBox mCheckBox;
+ private HostInterface mHostInterface;
+ private final OnClickListener mOnClickListener = new OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ mHostInterface.onItemClicked(GalleryGridItemView.this, mData, false /*longClick*/);
+ }
+ };
+
+ public GalleryGridItemView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mData = DataModel.get().createGalleryGridItemData();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mImageView = (AsyncImageView) findViewById(R.id.image);
+ mCheckBox = (CheckBox) findViewById(R.id.checkbox);
+ mCheckBox.setOnClickListener(mOnClickListener);
+ setOnClickListener(mOnClickListener);
+ final OnLongClickListener longClickListener = new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(final View v) {
+ mHostInterface.onItemClicked(v, mData, true /* longClick */);
+ return true;
+ }
+ };
+ setOnLongClickListener(longClickListener);
+ mCheckBox.setOnLongClickListener(longClickListener);
+ addOnLayoutChangeListener(new OnLayoutChangeListener() {
+ @Override
+ public void onLayoutChange(View v, int left, int top, int right, int bottom,
+ int oldLeft, int oldTop, int oldRight, int oldBottom) {
+ // Enlarge the clickable region for the checkbox to fill the entire view.
+ final Rect region = new Rect(0, 0, getWidth(), getHeight());
+ setTouchDelegate(new TouchDelegate(region, mCheckBox) {
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ setPressed(true);
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ setPressed(false);
+ break;
+ }
+ return super.onTouchEvent(event);
+ }
+ });
+ }
+ });
+ }
+
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ // The grid view auto-fit the columns, so we want to let the height match the width
+ // to make the image square.
+ super.onMeasure(widthMeasureSpec, widthMeasureSpec);
+ }
+
+ public void bind(final Cursor cursor, final HostInterface hostInterface) {
+ final int desiredSize = getResources()
+ .getDimensionPixelSize(R.dimen.gallery_image_cell_size);
+ mData.bind(cursor, desiredSize, desiredSize);
+ mHostInterface = hostInterface;
+ updateViewState();
+ }
+
+ private void updateViewState() {
+ updateImageView();
+ if (mHostInterface.isMultiSelectEnabled() && !mData.isDocumentPickerItem()) {
+ mCheckBox.setVisibility(VISIBLE);
+ mCheckBox.setClickable(true);
+ mCheckBox.setChecked(mHostInterface.isItemSelected(mData));
+ } else {
+ mCheckBox.setVisibility(GONE);
+ mCheckBox.setClickable(false);
+ }
+ }
+
+ private void updateImageView() {
+ if (mData.isDocumentPickerItem()) {
+ mImageView.setScaleType(ScaleType.CENTER);
+ setBackgroundColor(ConversationDrawables.get().getConversationThemeColor());
+ mImageView.setImageResourceId(null);
+ mImageView.setImageResource(R.drawable.ic_photo_library_light);
+ mImageView.setContentDescription(getResources().getString(
+ R.string.pick_image_from_document_library_content_description));
+ } else {
+ mImageView.setScaleType(ScaleType.CENTER_CROP);
+ setBackgroundColor(getResources().getColor(R.color.gallery_image_default_background));
+ mImageView.setImageResourceId(mData.getImageRequestDescriptor());
+ final long dateSeconds = mData.getDateSeconds();
+ final boolean isValidDate = (dateSeconds > 0);
+ final int templateId = isValidDate ?
+ R.string.mediapicker_gallery_image_item_description :
+ R.string.mediapicker_gallery_image_item_description_no_date;
+ String contentDescription = String.format(getResources().getString(templateId),
+ dateSeconds * TimeUnit.SECONDS.toMillis(1));
+ mImageView.setContentDescription(contentDescription);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridView.java
new file mode 100644
index 0000000..a5a7dad
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/GalleryGridView.java
@@ -0,0 +1,315 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.v4.util.ArrayMap;
+import android.util.AttributeSet;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.binding.ImmutableBindingRef;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.GalleryGridItemData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener;
+import com.android.messaging.ui.PersistentInstanceState;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * Shows a list of galley images from external storage in a GridView with multi-select
+ * capabilities, and with the option to intent out to a standalone image picker.
+ */
+public class GalleryGridView extends MediaPickerGridView implements
+ GalleryGridItemView.HostInterface,
+ PersistentInstanceState,
+ DraftMessageDataListener {
+ /**
+ * Implemented by the owner of this GalleryGridView instance to communicate on image
+ * picking and multi-image selection events.
+ */
+ public interface GalleryGridViewListener {
+ void onDocumentPickerItemClicked();
+ void onItemSelected(MessagePartData item);
+ void onItemUnselected(MessagePartData item);
+ void onConfirmSelection();
+ void onUpdate();
+ }
+
+ private GalleryGridViewListener mListener;
+
+ // TODO: Consider putting this into the data model object if we add more states.
+ private final ArrayMap<Uri, MessagePartData> mSelectedImages;
+ private boolean mIsMultiSelectMode = false;
+ private ImmutableBindingRef<DraftMessageData> mDraftMessageDataModel;
+
+ public GalleryGridView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mSelectedImages = new ArrayMap<Uri, MessagePartData>();
+ }
+
+ public void setHostInterface(final GalleryGridViewListener hostInterface) {
+ mListener = hostInterface;
+ }
+
+ public void setDraftMessageDataModel(final BindingBase<DraftMessageData> dataModel) {
+ mDraftMessageDataModel = BindingBase.createBindingReference(dataModel);
+ mDraftMessageDataModel.getData().addListener(this);
+ }
+
+ @Override
+ public void onItemClicked(final View view, final GalleryGridItemData data,
+ final boolean longClick) {
+ if (data.isDocumentPickerItem()) {
+ mListener.onDocumentPickerItemClicked();
+ } else if (ContentType.isMediaType(data.getContentType())) {
+ if (longClick) {
+ // Turn on multi-select mode when an item is long-pressed.
+ setMultiSelectEnabled(true);
+ }
+
+ final Rect startRect = new Rect();
+ view.getGlobalVisibleRect(startRect);
+ if (isMultiSelectEnabled()) {
+ toggleItemSelection(startRect, data);
+ } else {
+ mListener.onItemSelected(data.constructMessagePartData(startRect));
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG,
+ "Selected item has invalid contentType " + data.getContentType());
+ }
+ }
+
+ @Override
+ public boolean isItemSelected(final GalleryGridItemData data) {
+ return mSelectedImages.containsKey(data.getImageUri());
+ }
+
+ int getSelectionCount() {
+ return mSelectedImages.size();
+ }
+
+ @Override
+ public boolean isMultiSelectEnabled() {
+ return mIsMultiSelectMode;
+ }
+
+ private void toggleItemSelection(final Rect startRect, final GalleryGridItemData data) {
+ Assert.isTrue(isMultiSelectEnabled());
+ if (isItemSelected(data)) {
+ final MessagePartData item = mSelectedImages.remove(data.getImageUri());
+ mListener.onItemUnselected(item);
+ if (mSelectedImages.size() == 0) {
+ // No image is selected any more, turn off multi-select mode.
+ setMultiSelectEnabled(false);
+ }
+ } else {
+ final MessagePartData item = data.constructMessagePartData(startRect);
+ mSelectedImages.put(data.getImageUri(), item);
+ mListener.onItemSelected(item);
+ }
+ invalidateViews();
+ }
+
+ private void toggleMultiSelect() {
+ mIsMultiSelectMode = !mIsMultiSelectMode;
+ invalidateViews();
+ }
+
+ private void setMultiSelectEnabled(final boolean enabled) {
+ if (mIsMultiSelectMode != enabled) {
+ toggleMultiSelect();
+ }
+ }
+
+ private boolean canToggleMultiSelect() {
+ // We allow the user to toggle multi-select mode only when nothing has selected. If
+ // something has been selected, we show a confirm button instead.
+ return mSelectedImages.size() == 0;
+ }
+
+ public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) {
+ inflater.inflate(R.menu.gallery_picker_menu, menu);
+ final MenuItem toggleMultiSelect = menu.findItem(R.id.action_multiselect);
+ final MenuItem confirmMultiSelect = menu.findItem(R.id.action_confirm_multiselect);
+ final boolean canToggleMultiSelect = canToggleMultiSelect();
+ toggleMultiSelect.setVisible(canToggleMultiSelect);
+ confirmMultiSelect.setVisible(!canToggleMultiSelect);
+ }
+
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.action_multiselect:
+ Assert.isTrue(canToggleMultiSelect());
+ toggleMultiSelect();
+ return true;
+
+ case R.id.action_confirm_multiselect:
+ Assert.isTrue(!canToggleMultiSelect());
+ mListener.onConfirmSelection();
+ return true;
+ }
+ return false;
+ }
+
+
+ @Override
+ public void onDraftChanged(final DraftMessageData data, final int changeFlags) {
+ mDraftMessageDataModel.ensureBound(data);
+ // Whenever attachment changed, refresh selection state to remove those that are not
+ // selected.
+ if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) ==
+ DraftMessageData.ATTACHMENTS_CHANGED) {
+ refreshImageSelectionStateOnAttachmentChange();
+ }
+ }
+
+ @Override
+ public void onDraftAttachmentLimitReached(final DraftMessageData data) {
+ mDraftMessageDataModel.ensureBound(data);
+ // Whenever draft attachment limit is reach, refresh selection state to remove those
+ // not actually added to draft.
+ refreshImageSelectionStateOnAttachmentChange();
+ }
+
+ @Override
+ public void onDraftAttachmentLoadFailed() {
+ // Nothing to do since the failed attachment gets removed automatically.
+ }
+
+ private void refreshImageSelectionStateOnAttachmentChange() {
+ boolean changed = false;
+ final Iterator<Map.Entry<Uri, MessagePartData>> iterator =
+ mSelectedImages.entrySet().iterator();
+ while (iterator.hasNext()) {
+ Map.Entry<Uri, MessagePartData> entry = iterator.next();
+ if (!mDraftMessageDataModel.getData().containsAttachment(entry.getKey())) {
+ iterator.remove();
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ mListener.onUpdate();
+ invalidateViews();
+ }
+ }
+
+ @Override // PersistentInstanceState
+ public Parcelable saveState() {
+ return onSaveInstanceState();
+ }
+
+ @Override // PersistentInstanceState
+ public void restoreState(final Parcelable restoredState) {
+ onRestoreInstanceState(restoredState);
+ invalidateViews();
+ }
+
+ @Override
+ public Parcelable onSaveInstanceState() {
+ final Parcelable superState = super.onSaveInstanceState();
+ final SavedState savedState = new SavedState(superState);
+ savedState.isMultiSelectMode = mIsMultiSelectMode;
+ savedState.selectedImages = mSelectedImages.values()
+ .toArray(new MessagePartData[mSelectedImages.size()]);
+ return savedState;
+ }
+
+ @Override
+ public void onRestoreInstanceState(final Parcelable state) {
+ if (!(state instanceof SavedState)) {
+ super.onRestoreInstanceState(state);
+ return;
+ }
+
+ final SavedState savedState = (SavedState) state;
+ super.onRestoreInstanceState(savedState.getSuperState());
+ mIsMultiSelectMode = savedState.isMultiSelectMode;
+ mSelectedImages.clear();
+ for (int i = 0; i < savedState.selectedImages.length; i++) {
+ final MessagePartData selectedImage = savedState.selectedImages[i];
+ mSelectedImages.put(selectedImage.getContentUri(), selectedImage);
+ }
+ }
+
+ @Override // PersistentInstanceState
+ public void resetState() {
+ mSelectedImages.clear();
+ mIsMultiSelectMode = false;
+ invalidateViews();
+ }
+
+ public static class SavedState extends BaseSavedState {
+ boolean isMultiSelectMode;
+ MessagePartData[] selectedImages;
+
+ SavedState(final Parcelable superState) {
+ super(superState);
+ }
+
+ private SavedState(final Parcel in) {
+ super(in);
+ isMultiSelectMode = in.readInt() == 1 ? true : false;
+
+ // Read parts
+ final int partCount = in.readInt();
+ selectedImages = new MessagePartData[partCount];
+ for (int i = 0; i < partCount; i++) {
+ selectedImages[i] = ((MessagePartData) in.readParcelable(
+ MessagePartData.class.getClassLoader()));
+ }
+ }
+
+ @Override
+ public void writeToParcel(final Parcel out, final int flags) {
+ super.writeToParcel(out, flags);
+ out.writeInt(isMultiSelectMode ? 1 : 0);
+
+ // Write parts
+ out.writeInt(selectedImages.length);
+ for (final MessagePartData image : selectedImages) {
+ out.writeParcelable(image, flags);
+ }
+ }
+
+ public static final Parcelable.Creator<SavedState> CREATOR =
+ new Parcelable.Creator<SavedState>() {
+ @Override
+ public SavedState createFromParcel(final Parcel in) {
+ return new SavedState(in);
+ }
+ @Override
+ public SavedState[] newArray(final int size) {
+ return new SavedState[size];
+ }
+ };
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java b/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java
new file mode 100644
index 0000000..9422386
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java
@@ -0,0 +1,230 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.Manifest;
+import android.content.pm.PackageManager;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MergeCursor;
+import android.support.v7.app.ActionBar;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.GalleryGridItemData;
+import com.android.messaging.datamodel.data.MediaPickerData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.MediaPickerData.MediaPickerDataListener;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.OsUtil;
+
+/**
+ * Chooser which allows the user to select one or more existing images or videos
+ */
+class GalleryMediaChooser extends MediaChooser implements
+ GalleryGridView.GalleryGridViewListener, MediaPickerDataListener {
+ private final GalleryGridAdapter mAdapter;
+ private GalleryGridView mGalleryGridView;
+ private View mMissingPermissionView;
+
+ GalleryMediaChooser(final MediaPicker mediaPicker) {
+ super(mediaPicker);
+ mAdapter = new GalleryGridAdapter(Factory.get().getApplicationContext(), null);
+ }
+
+ @Override
+ public int getSupportedMediaTypes() {
+ return MediaPicker.MEDIA_TYPE_IMAGE | MediaPicker.MEDIA_TYPE_VIDEO;
+ }
+
+ @Override
+ public View destroyView() {
+ mGalleryGridView.setAdapter(null);
+ mAdapter.setHostInterface(null);
+ // The loader is started only if startMediaPickerDataLoader() is called
+ if (OsUtil.hasStoragePermission()) {
+ mBindingRef.getData().destroyLoader(MediaPickerData.GALLERY_IMAGE_LOADER);
+ }
+ return super.destroyView();
+ }
+
+ @Override
+ public int getIconResource() {
+ return R.drawable.ic_image_light;
+ }
+
+ @Override
+ public int getIconDescriptionResource() {
+ return R.string.mediapicker_galleryChooserDescription;
+ }
+
+ @Override
+ public boolean canSwipeDown() {
+ return mGalleryGridView.canSwipeDown();
+ }
+
+ @Override
+ public void onItemSelected(final MessagePartData item) {
+ mMediaPicker.dispatchItemsSelected(item, !mGalleryGridView.isMultiSelectEnabled());
+ }
+
+ @Override
+ public void onItemUnselected(final MessagePartData item) {
+ mMediaPicker.dispatchItemUnselected(item);
+ }
+
+ @Override
+ public void onConfirmSelection() {
+ // The user may only confirm if multiselect is enabled.
+ Assert.isTrue(mGalleryGridView.isMultiSelectEnabled());
+ mMediaPicker.dispatchConfirmItemSelection();
+ }
+
+ @Override
+ public void onUpdate() {
+ mMediaPicker.invalidateOptionsMenu();
+ }
+
+ @Override
+ public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) {
+ if (mView != null) {
+ mGalleryGridView.onCreateOptionsMenu(inflater, menu);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ return (mView != null) ? mGalleryGridView.onOptionsItemSelected(item) : false;
+ }
+
+ @Override
+ protected View createView(final ViewGroup container) {
+ final LayoutInflater inflater = getLayoutInflater();
+ final View view = inflater.inflate(
+ R.layout.mediapicker_image_chooser,
+ container /* root */,
+ false /* attachToRoot */);
+
+ mGalleryGridView = (GalleryGridView) view.findViewById(R.id.gallery_grid_view);
+ mAdapter.setHostInterface(mGalleryGridView);
+ mGalleryGridView.setAdapter(mAdapter);
+ mGalleryGridView.setHostInterface(this);
+ mGalleryGridView.setDraftMessageDataModel(mMediaPicker.getDraftMessageDataModel());
+ if (OsUtil.hasStoragePermission()) {
+ startMediaPickerDataLoader();
+ }
+
+ mMissingPermissionView = view.findViewById(R.id.missing_permission_view);
+ updateForPermissionState(OsUtil.hasStoragePermission());
+ return view;
+ }
+
+ @Override
+ int getActionBarTitleResId() {
+ return R.string.mediapicker_gallery_title;
+ }
+
+ @Override
+ public void onDocumentPickerItemClicked() {
+ mMediaPicker.launchDocumentPicker();
+ }
+
+ @Override
+ void updateActionBar(final ActionBar actionBar) {
+ super.updateActionBar(actionBar);
+ if (mGalleryGridView == null) {
+ return;
+ }
+ final int selectionCount = mGalleryGridView.getSelectionCount();
+ if (selectionCount > 0 && mGalleryGridView.isMultiSelectEnabled()) {
+ actionBar.setTitle(getContext().getResources().getString(
+ R.string.mediapicker_gallery_title_selection,
+ selectionCount));
+ }
+ }
+
+ @Override
+ public void onMediaPickerDataUpdated(final MediaPickerData mediaPickerData, final Object data,
+ final int loaderId) {
+ mBindingRef.ensureBound(mediaPickerData);
+ Assert.equals(MediaPickerData.GALLERY_IMAGE_LOADER, loaderId);
+ Cursor rawCursor = null;
+ if (data instanceof Cursor) {
+ rawCursor = (Cursor) data;
+ }
+ // Before delivering the cursor, wrap around the local gallery cursor
+ // with an extra item for document picker integration in the front.
+ final MatrixCursor specialItemsCursor =
+ new MatrixCursor(GalleryGridItemData.SPECIAL_ITEM_COLUMNS);
+ specialItemsCursor.addRow(new Object[] { GalleryGridItemData.ID_DOCUMENT_PICKER_ITEM });
+ final MergeCursor cursor =
+ new MergeCursor(new Cursor[] { specialItemsCursor, rawCursor });
+ mAdapter.swapCursor(cursor);
+ }
+
+ @Override
+ public void onResume() {
+ if (OsUtil.hasStoragePermission()) {
+ // Work around a bug in MediaStore where cursors querying the Files provider don't get
+ // updated for changes to Images.Media or Video.Media.
+ startMediaPickerDataLoader();
+ }
+ }
+
+ @Override
+ protected void setSelected(final boolean selected) {
+ super.setSelected(selected);
+ if (selected && !OsUtil.hasStoragePermission()) {
+ mMediaPicker.requestPermissions(
+ new String[] { Manifest.permission.READ_EXTERNAL_STORAGE },
+ MediaPicker.GALLERY_PERMISSION_REQUEST_CODE);
+ }
+ }
+
+ private void startMediaPickerDataLoader() {
+ mBindingRef.getData().startLoader(MediaPickerData.GALLERY_IMAGE_LOADER, mBindingRef, null,
+ this);
+ }
+
+ @Override
+ protected void onRequestPermissionsResult(
+ final int requestCode, final String permissions[], final int[] grantResults) {
+ if (requestCode == MediaPicker.GALLERY_PERMISSION_REQUEST_CODE) {
+ final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED;
+ if (permissionGranted) {
+ startMediaPickerDataLoader();
+ }
+ updateForPermissionState(permissionGranted);
+ }
+ }
+
+ private void updateForPermissionState(final boolean granted) {
+ // onRequestPermissionsResult can sometimes get called before createView().
+ if (mGalleryGridView == null) {
+ return;
+ }
+
+ mGalleryGridView.setVisibility(granted ? View.VISIBLE : View.GONE);
+ mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE);
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java
new file mode 100644
index 0000000..45d9579
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java
@@ -0,0 +1,118 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.graphics.SurfaceTexture;
+import android.hardware.Camera;
+import android.os.Parcelable;
+import android.util.AttributeSet;
+import android.view.TextureView;
+import android.view.View;
+
+import java.io.IOException;
+
+/**
+ * A hardware accelerated preview texture for the camera. This is the preferred CameraPreview
+ * because it animates smoother. When hardware acceleration isn't available, SoftwareCameraPreview
+ * is used.
+ *
+ * There is a significant amount of duplication between HardwareCameraPreview and
+ * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The
+ * implementations of the shared methods are delegated to CameraPreview
+ */
+public class HardwareCameraPreview extends TextureView implements CameraPreview.CameraPreviewHost {
+ private CameraPreview mPreview;
+
+ public HardwareCameraPreview(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mPreview = new CameraPreview(this);
+ setSurfaceTextureListener(new SurfaceTextureListener() {
+ @Override
+ public void onSurfaceTextureAvailable(final SurfaceTexture surfaceTexture, final int i, final int i2) {
+ CameraManager.get().setSurface(mPreview);
+ }
+
+ @Override
+ public void onSurfaceTextureSizeChanged(final SurfaceTexture surfaceTexture, final int i, final int i2) {
+ CameraManager.get().setSurface(mPreview);
+ }
+
+ @Override
+ public boolean onSurfaceTextureDestroyed(final SurfaceTexture surfaceTexture) {
+ CameraManager.get().setSurface(null);
+ return true;
+ }
+
+ @Override
+ public void onSurfaceTextureUpdated(final SurfaceTexture surfaceTexture) {
+ CameraManager.get().setSurface(mPreview);
+ }
+ });
+ }
+
+ @Override
+ protected void onVisibilityChanged(final View changedView, final int visibility) {
+ super.onVisibilityChanged(changedView, visibility);
+ mPreview.onVisibilityChanged(visibility);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ mPreview.onDetachedFromWindow();
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ mPreview.onAttachedToWindow();
+ }
+
+ @Override
+ protected void onRestoreInstanceState(final Parcelable state) {
+ super.onRestoreInstanceState(state);
+ mPreview.onRestoreInstanceState();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec);
+ heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec);
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+
+ @Override
+ public View getView() {
+ return this;
+ }
+
+ @Override
+ public boolean isValid() {
+ return getSurfaceTexture() != null;
+ }
+
+ @Override
+ public void startPreview(final Camera camera) throws IOException {
+ camera.setPreviewTexture(getSurfaceTexture());
+ }
+
+ @Override
+ public void onCameraPermissionGranted() {
+ mPreview.onCameraPermissionGranted();
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java b/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java
new file mode 100644
index 0000000..637eb84
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/ImagePersistTask.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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.net.Uri;
+
+import com.android.messaging.datamodel.MediaScratchFileProvider;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.SafeAsyncTask;
+import com.android.messaging.util.exif.ExifInterface;
+import com.android.messaging.util.exif.ExifTag;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+public class ImagePersistTask extends SafeAsyncTask<Void, Void, Void> {
+ private static final String JPEG_EXTENSION = "jpg";
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private int mWidth;
+ private int mHeight;
+ private final float mHeightPercent;
+ private final byte[] mBytes;
+ private final Context mContext;
+ private final CameraManager.MediaCallback mCallback;
+ private Uri mOutputUri;
+ private Exception mException;
+
+ public ImagePersistTask(
+ final int width,
+ final int height,
+ final float heightPercent,
+ final byte[] bytes,
+ final Context context,
+ final CameraManager.MediaCallback callback) {
+ Assert.isTrue(heightPercent >= 0 && heightPercent <= 1);
+ Assert.notNull(bytes);
+ Assert.notNull(context);
+ Assert.notNull(callback);
+ mWidth = width;
+ mHeight = height;
+ mHeightPercent = heightPercent;
+ mBytes = bytes;
+ mContext = context;
+ mCallback = callback;
+ // TODO: We probably want to store directly in MMS storage to prevent this
+ // intermediate step
+ mOutputUri = MediaScratchFileProvider.buildMediaScratchSpaceUri(JPEG_EXTENSION);
+ }
+
+ @Override
+ protected Void doInBackgroundTimed(final Void... params) {
+ OutputStream outputStream = null;
+ Bitmap bitmap = null;
+ Bitmap clippedBitmap = null;
+ try {
+ outputStream =
+ mContext.getContentResolver().openOutputStream(mOutputUri);
+ if (mHeightPercent != 1.0f) {
+ int orientation = android.media.ExifInterface.ORIENTATION_UNDEFINED;
+ final ExifInterface exifInterface = new ExifInterface();
+ try {
+ exifInterface.readExif(mBytes);
+ final Integer orientationValue =
+ exifInterface.getTagIntValue(ExifInterface.TAG_ORIENTATION);
+ if (orientationValue != null) {
+ orientation = orientationValue.intValue();
+ }
+ // The thumbnail is of the full image, but we're cropping it, so just clear
+ // the thumbnail
+ exifInterface.setCompressedThumbnail((byte[]) null);
+ } catch (IOException e) {
+ // Couldn't get exif tags, not the end of the world
+ }
+ bitmap = BitmapFactory.decodeByteArray(mBytes, 0, mBytes.length);
+ final int clippedWidth;
+ final int clippedHeight;
+ if (ExifInterface.getOrientationParams(orientation).invertDimensions) {
+ Assert.equals(mWidth, bitmap.getHeight());
+ Assert.equals(mHeight, bitmap.getWidth());
+ clippedWidth = (int) (mHeight * mHeightPercent);
+ clippedHeight = mWidth;
+ } else {
+ Assert.equals(mWidth, bitmap.getWidth());
+ Assert.equals(mHeight, bitmap.getHeight());
+ clippedWidth = mWidth;
+ clippedHeight = (int) (mHeight * mHeightPercent);
+ }
+ final int offsetTop = (bitmap.getHeight() - clippedHeight) / 2;
+ final int offsetLeft = (bitmap.getWidth() - clippedWidth) / 2;
+ mWidth = clippedWidth;
+ mHeight = clippedHeight;
+ clippedBitmap = Bitmap.createBitmap(clippedWidth, clippedHeight,
+ Bitmap.Config.ARGB_8888);
+ clippedBitmap.setDensity(bitmap.getDensity());
+ final Canvas clippedBitmapCanvas = new Canvas(clippedBitmap);
+ final Matrix matrix = new Matrix();
+ matrix.postTranslate(-offsetLeft, -offsetTop);
+ clippedBitmapCanvas.drawBitmap(bitmap, matrix, null /* paint */);
+ clippedBitmapCanvas.save();
+ // EXIF data can take a big chunk of the file size and is often cleared by the
+ // carrier, only store orientation since that's critical
+ ExifTag orientationTag = exifInterface.getTag(ExifInterface.TAG_ORIENTATION);
+ exifInterface.clearExif();
+ exifInterface.setTag(orientationTag);
+ exifInterface.writeExif(clippedBitmap, outputStream);
+ } else {
+ outputStream.write(mBytes);
+ }
+ } catch (final IOException e) {
+ mOutputUri = null;
+ mException = e;
+ LogUtil.e(TAG, "Unable to persist image to temp storage " + e);
+ } finally {
+ if (bitmap != null) {
+ bitmap.recycle();
+ }
+
+ if (clippedBitmap != null) {
+ clippedBitmap.recycle();
+ }
+
+ if (outputStream != null) {
+ try {
+ outputStream.flush();
+ } catch (final IOException e) {
+ mOutputUri = null;
+ mException = e;
+ LogUtil.e(TAG, "error trying to flush and close the outputStream" + e);
+ } finally {
+ try {
+ outputStream.close();
+ } catch (final IOException e) {
+ // Do nothing.
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(final Void aVoid) {
+ if (mOutputUri != null) {
+ mCallback.onMediaReady(mOutputUri, ContentType.IMAGE_JPEG, mWidth, mHeight);
+ } else {
+ Assert.notNull(mException);
+ mCallback.onMediaFailed(mException);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java
new file mode 100644
index 0000000..06730a3
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java
@@ -0,0 +1,223 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.media.MediaRecorder;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.MediaScratchFileProvider;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.SafeAsyncTask;
+import com.android.messaging.util.UiUtils;
+
+import java.io.IOException;
+
+/**
+ * Wraps around the functionalities of MediaRecorder, performs routine setup for audio recording
+ * and updates the audio level to be displayed in UI.
+ *
+ * During the start and end of a recording session, we kick off a thread that polls for audio
+ * levels, and updates the thread-safe AudioLevelSource instance. Consumers may bind to the
+ * sound level by either polling from the level source, or register for a level change callback
+ * on the level source object. In Bugle, the UI element (SoundLevels) polls for the sound level
+ * on the UI thread by using animation ticks and invalidating itself.
+ *
+ * Aside from tracking sound levels, this also encapsulates the functionality to save the file
+ * to the scratch space. The saved file is returned by calling stopRecording().
+ */
+public class LevelTrackingMediaRecorder {
+ // We refresh sound level every 100ms during a recording session.
+ private static final int REFRESH_INTERVAL_MILLIS = 100;
+
+ // The native amplitude returned from MediaRecorder ranges from 0~32768 (unfortunately, this
+ // is not a constant that's defined anywhere, but the framework's Recorder app is using the
+ // same hard-coded number). Therefore, a constant is needed in order to make it 0~100.
+ private static final int MAX_AMPLITUDE_FACTOR = 32768 / 100;
+
+ // We want to limit the max audio file size by the max message size allowed by MmsConfig,
+ // plus multiplied by this fudge ratio to guarantee that we don't go over limit.
+ private static final float MAX_SIZE_RATIO = 0.8f;
+
+ // Default recorder settings for Bugle.
+ // TODO: Do we want these to be tweakable?
+ private static final int MEDIA_RECORDER_AUDIO_SOURCE = MediaRecorder.AudioSource.MIC;
+ private static final int MEDIA_RECORDER_OUTPUT_FORMAT = MediaRecorder.OutputFormat.THREE_GPP;
+ private static final int MEDIA_RECORDER_AUDIO_ENCODER = MediaRecorder.AudioEncoder.AMR_NB;
+
+ private final AudioLevelSource mLevelSource;
+ private Thread mRefreshLevelThread;
+ private MediaRecorder mRecorder;
+ private Uri mOutputUri;
+ private ParcelFileDescriptor mOutputFD;
+
+ public LevelTrackingMediaRecorder() {
+ mLevelSource = new AudioLevelSource();
+ }
+
+ public AudioLevelSource getLevelSource() {
+ return mLevelSource;
+ }
+
+ /**
+ * @return if we are currently in a recording session.
+ */
+ public boolean isRecording() {
+ return mRecorder != null;
+ }
+
+ /**
+ * Start a new recording session.
+ * @return true if a session is successfully started; false if something went wrong or if
+ * we are already recording.
+ */
+ public boolean startRecording(final MediaRecorder.OnErrorListener errorListener,
+ final MediaRecorder.OnInfoListener infoListener, int maxSize) {
+ synchronized (LevelTrackingMediaRecorder.class) {
+ if (mRecorder == null) {
+ mOutputUri = MediaScratchFileProvider.buildMediaScratchSpaceUri(
+ ContentType.THREE_GPP_EXTENSION);
+ mRecorder = new MediaRecorder();
+ try {
+ // The scratch space file is a Uri, however MediaRecorder
+ // API only accepts absolute FD's. Therefore, get the
+ // FileDescriptor from the content resolver to ensure the
+ // directory is created and get the file path to output the
+ // audio to.
+ maxSize *= MAX_SIZE_RATIO;
+ mOutputFD = Factory.get().getApplicationContext()
+ .getContentResolver().openFileDescriptor(mOutputUri, "w");
+ mRecorder.setAudioSource(MEDIA_RECORDER_AUDIO_SOURCE);
+ mRecorder.setOutputFormat(MEDIA_RECORDER_OUTPUT_FORMAT);
+ mRecorder.setAudioEncoder(MEDIA_RECORDER_AUDIO_ENCODER);
+ mRecorder.setOutputFile(mOutputFD.getFileDescriptor());
+ mRecorder.setMaxFileSize(maxSize);
+ mRecorder.setOnErrorListener(errorListener);
+ mRecorder.setOnInfoListener(infoListener);
+ mRecorder.prepare();
+ mRecorder.start();
+ startTrackingSoundLevel();
+ return true;
+ } catch (final Exception e) {
+ // There may be a device failure or I/O failure, record the error but
+ // don't fail.
+ LogUtil.e(LogUtil.BUGLE_TAG, "Something went wrong when starting " +
+ "media recorder. " + e);
+ UiUtils.showToastAtBottom(R.string.audio_recording_start_failed);
+ stopRecording();
+ }
+ } else {
+ Assert.fail("Trying to start a new recording session while already recording!");
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Stop the current recording session.
+ * @return the Uri of the output file, or null if not currently recording.
+ */
+ public Uri stopRecording() {
+ synchronized (LevelTrackingMediaRecorder.class) {
+ if (mRecorder != null) {
+ try {
+ mRecorder.stop();
+ } catch (final RuntimeException ex) {
+ // This may happen when the recording is too short, so just drop the recording
+ // in this case.
+ LogUtil.w(LogUtil.BUGLE_TAG, "Something went wrong when stopping " +
+ "media recorder. " + ex);
+ if (mOutputUri != null) {
+ final Uri outputUri = mOutputUri;
+ SafeAsyncTask.executeOnThreadPool(new Runnable() {
+ @Override
+ public void run() {
+ Factory.get().getApplicationContext().getContentResolver().delete(
+ outputUri, null, null);
+ }
+ });
+ mOutputUri = null;
+ }
+ } finally {
+ mRecorder.release();
+ mRecorder = null;
+ }
+ } else {
+ Assert.fail("Not currently recording!");
+ return null;
+ }
+ }
+
+ if (mOutputFD != null) {
+ try {
+ mOutputFD.close();
+ } catch (final IOException e) {
+ // Nothing to do
+ }
+ mOutputFD = null;
+ }
+
+ stopTrackingSoundLevel();
+ return mOutputUri;
+ }
+
+ private int getAmplitude() {
+ synchronized (LevelTrackingMediaRecorder.class) {
+ if (mRecorder != null) {
+ final int maxAmplitude = mRecorder.getMaxAmplitude() / MAX_AMPLITUDE_FACTOR;
+ return Math.min(maxAmplitude, 100);
+ } else {
+ return 0;
+ }
+ }
+ }
+
+ private void startTrackingSoundLevel() {
+ stopTrackingSoundLevel();
+ mRefreshLevelThread = new Thread() {
+ @Override
+ public void run() {
+ try {
+ while (true) {
+ synchronized (LevelTrackingMediaRecorder.class) {
+ if (mRecorder != null) {
+ mLevelSource.setSpeechLevel(getAmplitude());
+ } else {
+ // The recording session is over, finish the thread.
+ return;
+ }
+ }
+ Thread.sleep(REFRESH_INTERVAL_MILLIS);
+ }
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ };
+ mRefreshLevelThread.start();
+ }
+
+ private void stopTrackingSoundLevel() {
+ if (mRefreshLevelThread != null && mRefreshLevelThread.isAlive()) {
+ mRefreshLevelThread.interrupt();
+ mRefreshLevelThread = null;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/MediaChooser.java b/src/com/android/messaging/ui/mediapicker/MediaChooser.java
new file mode 100644
index 0000000..9ac0d1b
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/MediaChooser.java
@@ -0,0 +1,216 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.app.FragmentManager;
+import android.content.Context;
+import android.support.v7.app.ActionBar;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.binding.ImmutableBindingRef;
+import com.android.messaging.datamodel.data.MediaPickerData;
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider;
+import com.android.messaging.ui.BasePagerViewHolder;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.OsUtil;
+
+abstract class MediaChooser extends BasePagerViewHolder
+ implements DraftMessageSubscriptionDataProvider {
+ /** The media picker that the chooser is hosted in */
+ protected final MediaPicker mMediaPicker;
+
+ /** Referencing the main media picker binding to perform data loading */
+ protected final ImmutableBindingRef<MediaPickerData> mBindingRef;
+
+ /** True if this is the selected chooser */
+ protected boolean mSelected;
+
+ /** True if this chooser is open */
+ protected boolean mOpen;
+
+ /** The button to show in the tab strip */
+ private ImageButton mTabButton;
+
+ /** Used by subclasses to indicate that no loader is required from the data model in order for
+ * this chooser to function.
+ */
+ public static final int NO_LOADER_REQUIRED = -1;
+
+ /**
+ * Initializes a new instance of the Chooser class
+ * @param mediaPicker The media picker that the chooser is hosted in
+ */
+ MediaChooser(final MediaPicker mediaPicker) {
+ Assert.notNull(mediaPicker);
+ mMediaPicker = mediaPicker;
+ mBindingRef = mediaPicker.getMediaPickerDataBinding();
+ mSelected = false;
+ }
+
+ protected void setSelected(final boolean selected) {
+ mSelected = selected;
+ if (selected) {
+ // If we're selected, it must be open
+ mOpen = true;
+ }
+ if (mTabButton != null) {
+ mTabButton.setSelected(selected);
+ mTabButton.setAlpha(selected ? 1 : 0.5f);
+ }
+ }
+
+ ImageButton getTabButton() {
+ return mTabButton;
+ }
+
+ void onCreateTabButton(final LayoutInflater inflater, final ViewGroup parent) {
+ mTabButton = (ImageButton) inflater.inflate(
+ R.layout.mediapicker_tab_button,
+ parent,
+ false /* addToParent */);
+ mTabButton.setImageResource(getIconResource());
+ mTabButton.setContentDescription(
+ inflater.getContext().getResources().getString(getIconDescriptionResource()));
+ setSelected(mSelected);
+ mTabButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ mMediaPicker.selectChooser(MediaChooser.this);
+ }
+ });
+ }
+
+ protected Context getContext() {
+ return mMediaPicker.getActivity();
+ }
+
+ protected FragmentManager getFragmentManager() {
+ return OsUtil.isAtLeastJB_MR1() ? mMediaPicker.getChildFragmentManager() :
+ mMediaPicker.getFragmentManager();
+ }
+ protected LayoutInflater getLayoutInflater() {
+ return LayoutInflater.from(getContext());
+ }
+
+ /** Allows the chooser to handle full screen change */
+ void onFullScreenChanged(final boolean fullScreen) {}
+
+ /** Allows the chooser to handle the chooser being opened or closed */
+ void onOpenedChanged(final boolean open) {
+ mOpen = open;
+ }
+
+ /** @return The bit field of media types that this chooser can pick */
+ public abstract int getSupportedMediaTypes();
+
+ /** @return The resource id of the icon for the chooser */
+ abstract int getIconResource();
+
+ /** @return The resource id of the string to use for the accessibility text of the icon */
+ abstract int getIconDescriptionResource();
+
+ /**
+ * Sets up the action bar to show the current state of the full-screen chooser
+ * @param actionBar The action bar to populate
+ */
+ void updateActionBar(final ActionBar actionBar) {
+ final int actionBarTitleResId = getActionBarTitleResId();
+ if (actionBarTitleResId == 0) {
+ actionBar.hide();
+ } else {
+ actionBar.setCustomView(null);
+ actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.show();
+ // Use X instead of <- in the action bar
+ actionBar.setHomeAsUpIndicator(R.drawable.ic_remove_small_light);
+ actionBar.setTitle(actionBarTitleResId);
+ }
+ }
+
+ /**
+ * Returns the resource Id used for the action bar title.
+ */
+ abstract int getActionBarTitleResId();
+
+ /**
+ * Throws an exception if the media chooser object doesn't require data support.
+ */
+ public void onDataUpdated(final Object data, final int loaderId) {
+ throw new IllegalStateException();
+ }
+
+ /**
+ * Called by the MediaPicker to determine whether this panel can be swiped down further. If
+ * not, then a swipe down gestured will be captured by the MediaPickerPanel to shrink the
+ * entire panel.
+ */
+ public boolean canSwipeDown() {
+ return false;
+ }
+
+ /**
+ * Typically the media picker is closed when the IME is opened, but this allows the chooser to
+ * specify that showing the IME is okay while the chooser is up
+ */
+ public boolean canShowIme() {
+ return false;
+ }
+
+ public boolean onBackPressed() {
+ return false;
+ }
+
+ public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) {
+ }
+
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ return false;
+ }
+
+ public void setThemeColor(final int color) {
+ }
+
+ /**
+ * Returns true if the chooser is owning any incoming touch events, so that the media picker
+ * panel won't process it and slide the panel.
+ */
+ public boolean isHandlingTouch() {
+ return false;
+ }
+
+ public void stopTouchHandling() {
+ }
+
+ @Override
+ public int getConversationSelfSubId() {
+ return mMediaPicker.getConversationSelfSubId();
+ }
+
+ /** Optional activity life-cycle methods to be overridden by subclasses */
+ public void onPause() { }
+ public void onResume() { }
+ protected void onRequestPermissionsResult(
+ final int requestCode, final String permissions[], final int[] grantResults) { }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/MediaPicker.java b/src/com/android/messaging/ui/mediapicker/MediaPicker.java
new file mode 100644
index 0000000..f441d09
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/MediaPicker.java
@@ -0,0 +1,736 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v4.view.PagerAdapter;
+import android.support.v4.view.ViewPager;
+import android.support.v7.app.ActionBar;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.binding.ImmutableBindingRef;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.MediaPickerData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.PendingAttachmentData;
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.FixedViewPagerAdapter;
+import com.android.messaging.ui.mediapicker.DocumentImagePicker.SelectionListener;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.UiUtils;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Fragment used to select or capture media to be added to the message
+ */
+public class MediaPicker extends Fragment implements DraftMessageSubscriptionDataProvider {
+ /** The listener interface for events from the media picker */
+ public interface MediaPickerListener {
+ /** Called when the media picker is opened so the host can accommodate the UI */
+ void onOpened();
+
+ /**
+ * Called when the media picker goes into or leaves full screen mode so the host can
+ * accommodate the fullscreen UI
+ */
+ void onFullScreenChanged(boolean fullScreen);
+
+ /**
+ * Called when the user selects one or more items
+ * @param items The list of items which were selected
+ */
+ void onItemsSelected(Collection<MessagePartData> items, boolean dismissMediaPicker);
+
+ /**
+ * Called when the user unselects one item.
+ */
+ void onItemUnselected(MessagePartData item);
+
+ /**
+ * Called when the media picker is closed. Always called immediately after onItemsSelected
+ */
+ void onDismissed();
+
+ /**
+ * Called when media item selection is confirmed in a multi-select action.
+ */
+ void onConfirmItemSelection();
+
+ /**
+ * Called when a pending attachment is added.
+ * @param pendingItem the pending attachment data being loaded.
+ */
+ void onPendingItemAdded(PendingAttachmentData pendingItem);
+
+ /**
+ * Called when a new media chooser is selected.
+ */
+ void onChooserSelected(final int chooserIndex);
+ }
+
+ /** The tag used when registering and finding this fragment */
+ public static final String FRAGMENT_TAG = "mediapicker";
+
+ // Media type constants that the media picker supports
+ public static final int MEDIA_TYPE_DEFAULT = 0x0000;
+ public static final int MEDIA_TYPE_NONE = 0x0000;
+ public static final int MEDIA_TYPE_IMAGE = 0x0001;
+ public static final int MEDIA_TYPE_VIDEO = 0x0002;
+ public static final int MEDIA_TYPE_AUDIO = 0x0004;
+ public static final int MEDIA_TYPE_VCARD = 0x0008;
+ public static final int MEDIA_TYPE_LOCATION = 0x0010;
+ private static final int MEDA_TYPE_INVALID = 0x0020;
+ public static final int MEDIA_TYPE_ALL = 0xFFFF;
+
+ /** The listener to call when events occur */
+ private MediaPickerListener mListener;
+
+ /** The handler used to dispatch calls to the listener */
+ private Handler mListenerHandler;
+
+ /** The bit flags of media types supported */
+ private int mSupportedMediaTypes;
+
+ /** The list of choosers which could be within the media picker */
+ private final MediaChooser[] mChoosers;
+
+ /** The list of currently enabled choosers */
+ private final ArrayList<MediaChooser> mEnabledChoosers;
+
+ /** The currently selected chooser */
+ private MediaChooser mSelectedChooser;
+
+ /** The main panel that controls the custom layout */
+ private MediaPickerPanel mMediaPickerPanel;
+
+ /** The linear layout that holds the icons to select individual chooser tabs */
+ private LinearLayout mTabStrip;
+
+ /** The view pager to swap between choosers */
+ private ViewPager mViewPager;
+
+ /** The current pager adapter for the view pager */
+ private FixedViewPagerAdapter<MediaChooser> mPagerAdapter;
+
+ /** True if the media picker is visible */
+ private boolean mOpen;
+
+ /** The theme color to use to make the media picker match the rest of the UI */
+ private int mThemeColor;
+
+ @VisibleForTesting
+ final Binding<MediaPickerData> mBinding = BindingBase.createBinding(this);
+
+ /** Handles picking image from the document picker */
+ private DocumentImagePicker mDocumentImagePicker;
+
+ /** Provides subscription-related data to access per-subscription configurations. */
+ private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider;
+
+ /** Provides access to DraftMessageData associated with the current conversation */
+ private ImmutableBindingRef<DraftMessageData> mDraftMessageDataModel;
+
+ public MediaPicker() {
+ this(Factory.get().getApplicationContext());
+ }
+
+ public MediaPicker(final Context context) {
+ mBinding.bind(DataModel.get().createMediaPickerData(context));
+ mEnabledChoosers = new ArrayList<MediaChooser>();
+ mChoosers = new MediaChooser[] {
+ new CameraMediaChooser(this),
+ new GalleryMediaChooser(this),
+ new AudioMediaChooser(this),
+ };
+
+ mOpen = false;
+ setSupportedMediaTypes(MEDIA_TYPE_ALL);
+ }
+
+ private boolean mIsAttached;
+ private int mStartingMediaTypeOnAttach = MEDA_TYPE_INVALID;
+ private boolean mAnimateOnAttach;
+
+ @Override
+ public void onAttach (final Activity activity) {
+ super.onAttach(activity);
+ mIsAttached = true;
+ if (mStartingMediaTypeOnAttach != MEDA_TYPE_INVALID) {
+ // open() was previously called. Do the pending open now.
+ doOpen(mStartingMediaTypeOnAttach, mAnimateOnAttach);
+ }
+ }
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mBinding.getData().init(getLoaderManager());
+ mDocumentImagePicker = new DocumentImagePicker(this,
+ new SelectionListener() {
+ @Override
+ public void onDocumentSelected(final PendingAttachmentData data) {
+ if (mBinding.isBound()) {
+ dispatchPendingItemAdded(data);
+ }
+ }
+ });
+ }
+
+ @Override
+ public View onCreateView(
+ final LayoutInflater inflater,
+ final ViewGroup container,
+ final Bundle savedInstanceState) {
+ mMediaPickerPanel = (MediaPickerPanel) inflater.inflate(
+ R.layout.mediapicker_fragment,
+ container,
+ false);
+ mMediaPickerPanel.setMediaPicker(this);
+ mTabStrip = (LinearLayout) mMediaPickerPanel.findViewById(R.id.mediapicker_tabstrip);
+ mTabStrip.setBackgroundColor(mThemeColor);
+ for (final MediaChooser chooser : mChoosers) {
+ chooser.onCreateTabButton(inflater, mTabStrip);
+ final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) !=
+ MEDIA_TYPE_NONE;
+ final ImageButton tabButton = chooser.getTabButton();
+ if (tabButton != null) {
+ tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE);
+ mTabStrip.addView(tabButton);
+ }
+ }
+
+ mViewPager = (ViewPager) mMediaPickerPanel.findViewById(R.id.mediapicker_view_pager);
+ mViewPager.setOnPageChangeListener(new ViewPager.OnPageChangeListener() {
+ @Override
+ public void onPageScrolled(
+ final int position,
+ final float positionOffset,
+ final int positionOffsetPixels) {
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ // The position returned is relative to if we are in RtL mode. This class never
+ // switches the indices of the elements if we are in RtL mode so we need to
+ // translate the index back. For example, if the user clicked the item most to the
+ // right in RtL mode we would want the index to appear as 0 here, however the
+ // position returned would the last possible index.
+ if (UiUtils.isRtlMode()) {
+ position = mEnabledChoosers.size() - 1 - position;
+ }
+ selectChooser(mEnabledChoosers.get(position));
+ }
+
+ @Override
+ public void onPageScrollStateChanged(final int state) {
+ }
+ });
+ // Camera initialization is expensive, so don't realize offscreen pages if not needed.
+ mViewPager.setOffscreenPageLimit(0);
+ mViewPager.setAdapter(mPagerAdapter);
+ final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled(
+ getActivity());
+ mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled);
+ mMediaPickerPanel.setExpanded(mOpen, true, mEnabledChoosers.indexOf(mSelectedChooser));
+ return mMediaPickerPanel;
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ CameraManager.get().onPause();
+ for (final MediaChooser chooser : mEnabledChoosers) {
+ chooser.onPause();
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ CameraManager.get().onResume();
+
+ for (final MediaChooser chooser : mEnabledChoosers) {
+ chooser.onResume();
+ }
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ mDocumentImagePicker.onActivityResult(requestCode, resultCode, data);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mBinding.unbind();
+ }
+
+ /**
+ * Sets the theme color to make the media picker match the surrounding UI
+ * @param themeColor The new theme color
+ */
+ public void setConversationThemeColor(final int themeColor) {
+ mThemeColor = themeColor;
+ if (mTabStrip != null) {
+ mTabStrip.setBackgroundColor(mThemeColor);
+ }
+
+ for (final MediaChooser chooser : mEnabledChoosers) {
+ chooser.setThemeColor(mThemeColor);
+ }
+ }
+
+ /**
+ * Gets the current conversation theme color.
+ */
+ public int getConversationThemeColor() {
+ return mThemeColor;
+ }
+
+ public void setDraftMessageDataModel(final BindingBase<DraftMessageData> draftBinding) {
+ mDraftMessageDataModel = Binding.createBindingReference(draftBinding);
+ }
+
+ public ImmutableBindingRef<DraftMessageData> getDraftMessageDataModel() {
+ return mDraftMessageDataModel;
+ }
+
+ public void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) {
+ mSubscriptionDataProvider = provider;
+ }
+
+ @Override
+ public int getConversationSelfSubId() {
+ return mSubscriptionDataProvider.getConversationSelfSubId();
+ }
+
+ /**
+ * Opens the media picker and optionally shows the chooser for the supplied media type
+ * @param startingMediaType The media type of the chooser to open if {@link #MEDIA_TYPE_DEFAULT}
+ * is used, then the default chooser from saved shared prefs is opened
+ */
+ public void open(final int startingMediaType, final boolean animate) {
+ mOpen = true;
+ if (mIsAttached) {
+ doOpen(startingMediaType, animate);
+ } else {
+ // open() can get called immediately after the MediaPicker is created. In that case,
+ // we defer doing work as it may require an attached fragment (eg. calling
+ // Fragment#requestPermission)
+ mStartingMediaTypeOnAttach = startingMediaType;
+ mAnimateOnAttach = animate;
+ }
+ }
+
+ private void doOpen(int startingMediaType, final boolean animate) {
+ final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled(
+ // getActivity() will be null at this point
+ Factory.get().getApplicationContext());
+
+ // If no specific starting type is specified (i.e. MEDIA_TYPE_DEFAULT), try to get the
+ // last opened chooser index from shared prefs.
+ if (startingMediaType == MEDIA_TYPE_DEFAULT) {
+ final int selectedChooserIndex = mBinding.getData().getSelectedChooserIndex();
+ if (selectedChooserIndex >= 0 && selectedChooserIndex < mEnabledChoosers.size()) {
+ selectChooser(mEnabledChoosers.get(selectedChooserIndex));
+ } else {
+ // This is the first time the picker is being used
+ if (isTouchExplorationEnabled) {
+ // Accessibility defaults to audio attachment mode.
+ startingMediaType = MEDIA_TYPE_AUDIO;
+ }
+ }
+ }
+
+ if (mSelectedChooser == null) {
+ for (final MediaChooser chooser : mEnabledChoosers) {
+ if (startingMediaType == MEDIA_TYPE_DEFAULT ||
+ (startingMediaType & chooser.getSupportedMediaTypes()) != MEDIA_TYPE_NONE) {
+ selectChooser(chooser);
+ break;
+ }
+ }
+ }
+
+ if (mSelectedChooser == null) {
+ // Fall back to the first chooser.
+ selectChooser(mEnabledChoosers.get(0));
+ }
+
+ if (mMediaPickerPanel != null) {
+ mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled);
+ mMediaPickerPanel.setExpanded(true, animate,
+ mEnabledChoosers.indexOf(mSelectedChooser));
+ }
+ }
+
+ /** @return True if the media picker is open */
+ public boolean isOpen() {
+ return mOpen;
+ }
+
+ /**
+ * Sets the list of media types to allow the user to select
+ * @param mediaTypes The bit flags of media types to allow. Can be any combination of the
+ * MEDIA_TYPE_* values
+ */
+ void setSupportedMediaTypes(final int mediaTypes) {
+ mSupportedMediaTypes = mediaTypes;
+ mEnabledChoosers.clear();
+ boolean selectNextChooser = false;
+ for (final MediaChooser chooser : mChoosers) {
+ final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) !=
+ MEDIA_TYPE_NONE;
+ if (enabled) {
+ // TODO Add a way to inform the chooser which media types are supported
+ mEnabledChoosers.add(chooser);
+ if (selectNextChooser) {
+ selectChooser(chooser);
+ selectNextChooser = false;
+ }
+ } else if (mSelectedChooser == chooser) {
+ selectNextChooser = true;
+ }
+ final ImageButton tabButton = chooser.getTabButton();
+ if (tabButton != null) {
+ tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ if (selectNextChooser && mEnabledChoosers.size() > 0) {
+ selectChooser(mEnabledChoosers.get(0));
+ }
+ final MediaChooser[] enabledChoosers = new MediaChooser[mEnabledChoosers.size()];
+ mEnabledChoosers.toArray(enabledChoosers);
+ mPagerAdapter = new FixedViewPagerAdapter<MediaChooser>(enabledChoosers);
+ if (mViewPager != null) {
+ mViewPager.setAdapter(mPagerAdapter);
+ }
+
+ // Only rebind data if we are currently bound. Otherwise, we must have not
+ // bound to any data yet and should wait until onCreate() to bind data.
+ if (mBinding.isBound() && getActivity() != null) {
+ mBinding.unbind();
+ mBinding.bind(DataModel.get().createMediaPickerData(getActivity()));
+ mBinding.getData().init(getLoaderManager());
+ }
+ }
+
+ ViewPager getViewPager() {
+ return mViewPager;
+ }
+
+ /** Hides the media picker, and frees up any resources it’s using */
+ public void dismiss(final boolean animate) {
+ mOpen = false;
+ if (mMediaPickerPanel != null) {
+ mMediaPickerPanel.setExpanded(false, animate, MediaPickerPanel.PAGE_NOT_SET);
+ }
+ mSelectedChooser = null;
+ }
+
+ /**
+ * Sets the listener for the media picker events
+ * @param listener The listener which will receive events
+ */
+ public void setListener(final MediaPickerListener listener) {
+ Assert.isMainThread();
+ mListener = listener;
+ mListenerHandler = listener != null ? new Handler() : null;
+ }
+
+ /** @return True if the media picker is in full-screen mode */
+ public boolean isFullScreen() {
+ return mMediaPickerPanel != null && mMediaPickerPanel.isFullScreen();
+ }
+
+ public void setFullScreen(final boolean fullScreen) {
+ mMediaPickerPanel.setFullScreenView(fullScreen, true);
+ }
+
+ public void updateActionBar(final ActionBar actionBar) {
+ if (getActivity() == null) {
+ return;
+ }
+ if (isFullScreen() && mSelectedChooser != null) {
+ mSelectedChooser.updateActionBar(actionBar);
+ } else {
+ actionBar.hide();
+ }
+ }
+
+ /**
+ * Selects a new chooser
+ * @param newSelectedChooser The newly selected chooser
+ */
+ void selectChooser(final MediaChooser newSelectedChooser) {
+ if (mSelectedChooser == newSelectedChooser) {
+ return;
+ }
+
+ if (mSelectedChooser != null) {
+ mSelectedChooser.setSelected(false);
+ }
+ mSelectedChooser = newSelectedChooser;
+ if (mSelectedChooser != null) {
+ mSelectedChooser.setSelected(true);
+ }
+
+ final int chooserIndex = mEnabledChoosers.indexOf(mSelectedChooser);
+ if (mViewPager != null) {
+ mViewPager.setCurrentItem(chooserIndex, true /* smoothScroll */);
+ }
+
+ if (isFullScreen()) {
+ invalidateOptionsMenu();
+ }
+
+ // Save the newly selected chooser's index so we may directly switch to it the
+ // next time user opens the media picker.
+ mBinding.getData().saveSelectedChooserIndex(chooserIndex);
+ if (mMediaPickerPanel != null) {
+ mMediaPickerPanel.onChooserChanged();
+ }
+ dispatchChooserSelected(chooserIndex);
+ }
+
+ public boolean canShowIme() {
+ if (mSelectedChooser != null) {
+ return mSelectedChooser.canShowIme();
+ }
+ return false;
+ }
+
+ public boolean onBackPressed() {
+ return mSelectedChooser != null && mSelectedChooser.onBackPressed();
+ }
+
+ void invalidateOptionsMenu() {
+ ((BugleActionBarActivity) getActivity()).supportInvalidateOptionsMenu();
+ }
+
+ void dispatchOpened() {
+ setHasOptionsMenu(false);
+ mOpen = true;
+ mPagerAdapter.notifyDataSetChanged();
+ if (mListener != null) {
+ mListenerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mListener.onOpened();
+ }
+ });
+ }
+ if (mSelectedChooser != null) {
+ mSelectedChooser.onFullScreenChanged(false);
+ mSelectedChooser.onOpenedChanged(true);
+ }
+ }
+
+ void dispatchDismissed() {
+ setHasOptionsMenu(false);
+ mOpen = false;
+ if (mListener != null) {
+ mListenerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mListener.onDismissed();
+ }
+ });
+ }
+ if (mSelectedChooser != null) {
+ mSelectedChooser.onOpenedChanged(false);
+ }
+ }
+
+ void dispatchFullScreen(final boolean fullScreen) {
+ setHasOptionsMenu(fullScreen);
+ if (mListener != null) {
+ mListenerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mListener.onFullScreenChanged(fullScreen);
+ }
+ });
+ }
+ if (mSelectedChooser != null) {
+ mSelectedChooser.onFullScreenChanged(fullScreen);
+ }
+ }
+
+ void dispatchItemsSelected(final MessagePartData item, final boolean dismissMediaPicker) {
+ final List<MessagePartData> items = new ArrayList<MessagePartData>(1);
+ items.add(item);
+ dispatchItemsSelected(items, dismissMediaPicker);
+ }
+
+ void dispatchItemsSelected(final Collection<MessagePartData> items,
+ final boolean dismissMediaPicker) {
+ if (mListener != null) {
+ mListenerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mListener.onItemsSelected(items, dismissMediaPicker);
+ }
+ });
+ }
+
+ if (isFullScreen() && !dismissMediaPicker) {
+ invalidateOptionsMenu();
+ }
+ }
+
+ void dispatchItemUnselected(final MessagePartData item) {
+ if (mListener != null) {
+ mListenerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mListener.onItemUnselected(item);
+ }
+ });
+ }
+
+ if (isFullScreen()) {
+ invalidateOptionsMenu();
+ }
+ }
+
+ void dispatchConfirmItemSelection() {
+ if (mListener != null) {
+ mListenerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mListener.onConfirmItemSelection();
+ }
+ });
+ }
+ }
+
+ void dispatchPendingItemAdded(final PendingAttachmentData pendingItem) {
+ if (mListener != null) {
+ mListenerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mListener.onPendingItemAdded(pendingItem);
+ }
+ });
+ }
+
+ if (isFullScreen()) {
+ invalidateOptionsMenu();
+ }
+ }
+
+ void dispatchChooserSelected(final int chooserIndex) {
+ if (mListener != null) {
+ mListenerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mListener.onChooserSelected(chooserIndex);
+ }
+ });
+ }
+ }
+
+ public boolean canSwipeDownChooser() {
+ return mSelectedChooser == null ? false : mSelectedChooser.canSwipeDown();
+ }
+
+ public boolean isChooserHandlingTouch() {
+ return mSelectedChooser == null ? false : mSelectedChooser.isHandlingTouch();
+ }
+
+ public void stopChooserTouchHandling() {
+ if (mSelectedChooser != null) {
+ mSelectedChooser.stopTouchHandling();
+ }
+ }
+
+ boolean getChooserShowsActionBarInFullScreen() {
+ return mSelectedChooser == null ? false : mSelectedChooser.getActionBarTitleResId() != 0;
+ }
+
+ @Override
+ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
+ if (mSelectedChooser != null) {
+ mSelectedChooser.onCreateOptionsMenu(inflater, menu);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ return (mSelectedChooser != null && mSelectedChooser.onOptionsItemSelected(item)) ||
+ super.onOptionsItemSelected(item);
+ }
+
+ PagerAdapter getPagerAdapter() {
+ return mPagerAdapter;
+ }
+
+ public void resetViewHolderState() {
+ mPagerAdapter.resetState();
+ }
+
+ /**
+ * Launch an external picker to pick item from document picker as attachment.
+ */
+ public void launchDocumentPicker() {
+ mDocumentImagePicker.launchPicker();
+ }
+
+ public ImmutableBindingRef<MediaPickerData> getMediaPickerDataBinding() {
+ return BindingBase.createBindingReference(mBinding);
+ }
+
+ protected static final int CAMERA_PERMISSION_REQUEST_CODE = 1;
+ protected static final int LOCATION_PERMISSION_REQUEST_CODE = 2;
+ protected static final int RECORD_AUDIO_PERMISSION_REQUEST_CODE = 3;
+ protected static final int GALLERY_PERMISSION_REQUEST_CODE = 4;
+
+ @Override
+ public void onRequestPermissionsResult(
+ final int requestCode, final String permissions[], final int[] grantResults) {
+ if (mSelectedChooser != null) {
+ mSelectedChooser.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java b/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java
new file mode 100644
index 0000000..cc3a4a1
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java
@@ -0,0 +1,44 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.GridView;
+
+public class MediaPickerGridView extends GridView {
+
+ public MediaPickerGridView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ /**
+ * Returns if the grid view can be swiped down further. It cannot be swiped down
+ * if there's no item or if we are already at the top.
+ */
+ public boolean canSwipeDown() {
+ if (getAdapter() == null || getAdapter().getCount() == 0 || getChildCount() == 0) {
+ return false;
+ }
+
+ final int firstVisiblePosition = getFirstVisiblePosition();
+ if (firstVisiblePosition == 0 && getChildAt(0).getTop() >= 0) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java
new file mode 100644
index 0000000..56b0a03
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java
@@ -0,0 +1,563 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.os.Handler;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.Transformation;
+import android.widget.LinearLayout;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.PagingAwareViewPager;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * Custom layout panel which makes the MediaPicker animations seamless and synchronized
+ * Designed to be very specific to the MediaPicker's usage
+ */
+public class MediaPickerPanel extends ViewGroup {
+ /**
+ * The window of time in which we might to decide to reinterpret the intent of a gesture.
+ */
+ private static final long TOUCH_RECAPTURE_WINDOW_MS = 500L;
+
+ // The two view components to layout
+ private LinearLayout mTabStrip;
+ private boolean mFullScreenOnly;
+ private PagingAwareViewPager mViewPager;
+
+ /**
+ * True if the MediaPicker is full screen or animating into it
+ */
+ private boolean mFullScreen;
+
+ /**
+ * True if the MediaPicker is open at all
+ */
+ private boolean mExpanded;
+
+ /**
+ * The current desired height of the MediaPicker. This property may be animated and the
+ * measure pass uses it to determine what size the components are.
+ */
+ private int mCurrentDesiredHeight;
+
+ private final Handler mHandler = new Handler();
+
+ /**
+ * The media picker for dispatching events to the MediaPicker's listener
+ */
+ private MediaPicker mMediaPicker;
+
+ /**
+ * The computed default "half-screen" height of the view pager in px
+ */
+ private final int mDefaultViewPagerHeight;
+
+ /**
+ * The action bar height used to compute the padding on the view pager when it's full screen.
+ */
+ private final int mActionBarHeight;
+
+ private TouchHandler mTouchHandler;
+
+ static final int PAGE_NOT_SET = -1;
+
+ public MediaPickerPanel(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ // Cache the computed dimension
+ mDefaultViewPagerHeight = getResources().getDimensionPixelSize(
+ R.dimen.mediapicker_default_chooser_height);
+ mActionBarHeight = getResources().getDimensionPixelSize(R.dimen.action_bar_height);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mTabStrip = (LinearLayout) findViewById(R.id.mediapicker_tabstrip);
+ mViewPager = (PagingAwareViewPager) findViewById(R.id.mediapicker_view_pager);
+ mTouchHandler = new TouchHandler();
+ setOnTouchListener(mTouchHandler);
+ mViewPager.setOnTouchListener(mTouchHandler);
+
+ // Make sure full screen mode is updated in landscape mode change when the panel is open.
+ addOnLayoutChangeListener(new OnLayoutChangeListener() {
+ private boolean mLandscapeMode = UiUtils.isLandscapeMode();
+
+ @Override
+ public void onLayoutChange(View v, int left, int top, int right, int bottom,
+ int oldLeft, int oldTop, int oldRight, int oldBottom) {
+ final boolean newLandscapeMode = UiUtils.isLandscapeMode();
+ if (mLandscapeMode != newLandscapeMode) {
+ mLandscapeMode = newLandscapeMode;
+ if (mExpanded) {
+ setExpanded(mExpanded, false /* animate */, mViewPager.getCurrentItem(),
+ true /* force */);
+ }
+ }
+ }
+ });
+ }
+
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ int requestedHeight = MeasureSpec.getSize(heightMeasureSpec);
+ if (mMediaPicker.getChooserShowsActionBarInFullScreen()) {
+ requestedHeight -= mActionBarHeight;
+ }
+ int desiredHeight = Math.min(mCurrentDesiredHeight, requestedHeight);
+ if (mExpanded && desiredHeight == 0) {
+ // If we want to be shown, we have to have a non-0 height. Returning a height of 0 will
+ // cause the framework to abort the animation from 0, so we must always have some
+ // height once we start expanding
+ desiredHeight = 1;
+ } else if (!mExpanded && desiredHeight == 0) {
+ mViewPager.setVisibility(View.GONE);
+ mViewPager.setAdapter(null);
+ }
+
+ measureChild(mTabStrip, widthMeasureSpec, heightMeasureSpec);
+
+ int tabStripHeight;
+ if (requiresFullScreen()) {
+ // Ensure that the tab strip is always visible, even in full screen.
+ tabStripHeight = mTabStrip.getMeasuredHeight();
+ } else {
+ // Slide out the tab strip at the end of the animation to full screen.
+ tabStripHeight = Math.min(mTabStrip.getMeasuredHeight(),
+ requestedHeight - desiredHeight);
+ }
+
+ // If we are animating and have an interim desired height, use the default height. We can't
+ // take the max here as on some devices the mDefaultViewPagerHeight may be too big in
+ // landscape mode after animation.
+ final int tabAdjustedDesiredHeight = desiredHeight - tabStripHeight;
+ final int viewPagerHeight =
+ tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight;
+
+ int viewPagerHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
+ viewPagerHeight, MeasureSpec.EXACTLY);
+ measureChild(mViewPager, widthMeasureSpec, viewPagerHeightMeasureSpec);
+ setMeasuredDimension(mViewPager.getMeasuredWidth(), desiredHeight);
+ }
+
+ @Override
+ protected void onLayout(final boolean changed, final int left, final int top, final int right,
+ final int bottom) {
+ int y = top;
+ final int width = right - left;
+
+ final int viewPagerHeight = mViewPager.getMeasuredHeight();
+ mViewPager.layout(0, y, width, y + viewPagerHeight);
+ y += viewPagerHeight;
+
+ mTabStrip.layout(0, y, width, y + mTabStrip.getMeasuredHeight());
+ }
+
+ void onChooserChanged() {
+ if (mFullScreen) {
+ setDesiredHeight(getDesiredHeight(), true);
+ }
+ }
+
+ void setFullScreenOnly(boolean fullScreenOnly) {
+ mFullScreenOnly = fullScreenOnly;
+ }
+
+ boolean isFullScreen() {
+ return mFullScreen;
+ }
+
+ void setMediaPicker(final MediaPicker mediaPicker) {
+ mMediaPicker = mediaPicker;
+ }
+
+ /**
+ * Get the desired height of the media picker panel for when the panel is not in motion (i.e.
+ * not being dragged by the user).
+ */
+ private int getDesiredHeight() {
+ if (mFullScreen) {
+ int fullHeight = getContext().getResources().getDisplayMetrics().heightPixels;
+ if (OsUtil.isAtLeastKLP() && isAttachedToWindow()) {
+ // When we're attached to the window, we can get an accurate height, not necessary
+ // on older API level devices because they don't include the action bar height
+ View composeContainer =
+ getRootView().findViewById(R.id.conversation_and_compose_container);
+ if (composeContainer != null) {
+ // protect against composeContainer having been unloaded already
+ fullHeight -= UiUtils.getMeasuredBoundsOnScreen(composeContainer).top;
+ }
+ }
+ if (mMediaPicker.getChooserShowsActionBarInFullScreen()) {
+ return fullHeight - mActionBarHeight;
+ } else {
+ return fullHeight;
+ }
+ } else if (mExpanded) {
+ return LayoutParams.WRAP_CONTENT;
+ } else {
+ return 0;
+ }
+ }
+
+ private void setupViewPager(final int startingPage) {
+ mViewPager.setVisibility(View.VISIBLE);
+ if (startingPage >= 0 && startingPage < mMediaPicker.getPagerAdapter().getCount()) {
+ mViewPager.setAdapter(mMediaPicker.getPagerAdapter());
+ mViewPager.setCurrentItem(startingPage);
+ }
+ updateViewPager();
+ }
+
+ /**
+ * Expand the media picker panel. Since we always set the pager adapter to null when the panel
+ * is collapsed, we need to restore the adapter and the starting page.
+ * @param expanded expanded or collapsed
+ * @param animate need animation
+ * @param startingPage the desired selected page to start
+ */
+ void setExpanded(final boolean expanded, final boolean animate, final int startingPage) {
+ setExpanded(expanded, animate, startingPage, false /* force */);
+ }
+
+ private void setExpanded(final boolean expanded, final boolean animate, final int startingPage,
+ final boolean force) {
+ if (expanded == mExpanded && !force) {
+ return;
+ }
+ mFullScreen = false;
+ mExpanded = expanded;
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ setDesiredHeight(getDesiredHeight(), animate);
+ }
+ });
+ if (expanded) {
+ setupViewPager(startingPage);
+ mMediaPicker.dispatchOpened();
+ } else {
+ mMediaPicker.dispatchDismissed();
+ }
+
+ // Call setFullScreenView() when we are in landscape mode so it can go full screen as
+ // soon as it is expanded.
+ if (expanded && requiresFullScreen()) {
+ setFullScreenView(true, animate);
+ }
+ }
+
+ private boolean requiresFullScreen() {
+ return mFullScreenOnly || UiUtils.isLandscapeMode();
+ }
+
+ private void setDesiredHeight(int height, final boolean animate) {
+ final int startHeight = mCurrentDesiredHeight;
+ if (height == LayoutParams.WRAP_CONTENT) {
+ height = measureHeight();
+ }
+ clearAnimation();
+ if (animate) {
+ final int deltaHeight = height - startHeight;
+ final Animation animation = new Animation() {
+ @Override
+ protected void applyTransformation(final float interpolatedTime,
+ final Transformation t) {
+ mCurrentDesiredHeight = (int) (startHeight + deltaHeight * interpolatedTime);
+ requestLayout();
+ }
+
+ @Override
+ public boolean willChangeBounds() {
+ return true;
+ }
+ };
+ animation.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION);
+ animation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR);
+ startAnimation(animation);
+ } else {
+ mCurrentDesiredHeight = height;
+ }
+ requestLayout();
+ }
+
+ /**
+ * @return The minimum total height of the view
+ */
+ private int measureHeight() {
+ final int measureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE, MeasureSpec.AT_MOST);
+ measureChild(mTabStrip, measureSpec, measureSpec);
+ return mDefaultViewPagerHeight + mTabStrip.getMeasuredHeight();
+ }
+
+ /**
+ * Enters or leaves full screen view
+ *
+ * @param fullScreen True to enter full screen view, false to leave
+ * @param animate True to animate the transition
+ */
+ void setFullScreenView(final boolean fullScreen, final boolean animate) {
+ if (fullScreen == mFullScreen) {
+ return;
+ }
+
+ if (requiresFullScreen() && !fullScreen) {
+ setExpanded(false /* expanded */, true /* animate */, PAGE_NOT_SET);
+ return;
+ }
+ mFullScreen = fullScreen;
+ setDesiredHeight(getDesiredHeight(), animate);
+ mMediaPicker.dispatchFullScreen(mFullScreen);
+ updateViewPager();
+ }
+
+ /**
+ * ViewPager should have its paging disabled when in full screen mode.
+ */
+ private void updateViewPager() {
+ mViewPager.setPagingEnabled(!mFullScreen);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(final MotionEvent ev) {
+ return mTouchHandler.onInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev);
+ }
+
+ /**
+ * Helper class to handle touch events and swipe gestures
+ */
+ private class TouchHandler implements OnTouchListener {
+ /**
+ * The height of the view when the touch press started
+ */
+ private int mDownHeight = -1;
+
+ /**
+ * True if the panel moved at all (changed height) during the drag
+ */
+ private boolean mMoved = false;
+
+ // The threshold constants converted from DP to px
+ private final float mFlingThresholdPx;
+ private final float mBigFlingThresholdPx;
+
+ // The system defined pixel size to determine when a movement is considered a drag.
+ private final int mTouchSlop;
+
+ /**
+ * A copy of the MotionEvent that started the drag/swipe gesture
+ */
+ private MotionEvent mDownEvent;
+
+ /**
+ * Whether we are currently moving down. We may not be able to move down in full screen
+ * mode when the child view can swipe down (such as a list view).
+ */
+ private boolean mMovedDown = false;
+
+ /**
+ * Indicates whether the child view contained in the panel can swipe down at the beginning
+ * of the drag event (i.e. the initial down). The MediaPanel can contain
+ * scrollable children such as a list view / grid view. If the child view can swipe down,
+ * We want to let the child view handle the scroll first instead of handling it ourselves.
+ */
+ private boolean mCanChildViewSwipeDown = false;
+
+ /**
+ * Necessary direction ratio for a fling to be considered in one direction this prevents
+ * horizontal swipes with small vertical components from triggering vertical swipe actions
+ */
+ private static final float DIRECTION_RATIO = 1.1f;
+
+ TouchHandler() {
+ final Resources resources = getContext().getResources();
+ final ViewConfiguration configuration = ViewConfiguration.get(getContext());
+ mFlingThresholdPx = resources.getDimensionPixelSize(
+ R.dimen.mediapicker_fling_threshold);
+ mBigFlingThresholdPx = resources.getDimensionPixelSize(
+ R.dimen.mediapicker_big_fling_threshold);
+ mTouchSlop = configuration.getScaledTouchSlop();
+ }
+
+ /**
+ * The media picker panel may contain scrollable children such as a GridView, which eats
+ * all touch events before we get to it. Therefore, we'd like to intercept these events
+ * before the children to determine if we should handle swiping down in full screen mode.
+ * In non-full screen mode, we should handle all vertical scrolling events and leave
+ * horizontal scrolling to the view pager.
+ */
+ public boolean onInterceptTouchEvent(final MotionEvent ev) {
+ switch (ev.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ // Never capture the initial down, so that the children may handle it
+ // as well. Let the touch handler know about the down event as well.
+ mTouchHandler.onTouch(MediaPickerPanel.this, ev);
+
+ // Ask the MediaPicker whether the contained view can be swiped down.
+ // We record the value at the start of the drag to decide the swiping mode
+ // for the entire motion.
+ mCanChildViewSwipeDown = mMediaPicker.canSwipeDownChooser();
+ return false;
+
+ case MotionEvent.ACTION_MOVE: {
+ if (mMediaPicker.isChooserHandlingTouch()) {
+ if (shouldAllowRecaptureTouch(ev)) {
+ mMediaPicker.stopChooserTouchHandling();
+ mViewPager.setPagingEnabled(true);
+ return false;
+ }
+ // If the chooser is claiming ownership on all touch events, then we
+ // shouldn't try to handle them (neither should the view pager).
+ mViewPager.setPagingEnabled(false);
+ return false;
+ } else if (mCanChildViewSwipeDown) {
+ // Never capture event if the child view can swipe down.
+ return false;
+ } else if (!mFullScreen && mMoved) {
+ // When we are not fullscreen, we own any vertical drag motion.
+ return true;
+ } else if (mMovedDown) {
+ // We are currently handling the down swipe ourselves, so always
+ // capture this event.
+ return true;
+ } else {
+ // The current interaction mode is undetermined, so always let the
+ // touch handler know about this event. However, don't capture this
+ // event so the child may handle it as well.
+ mTouchHandler.onTouch(MediaPickerPanel.this, ev);
+
+ // Capture the touch event from now on if we are handling the drag.
+ return mFullScreen ? mMovedDown : mMoved;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Determine whether we think the user is actually trying to expand or slide despite the
+ * fact that they touched first on a chooser that captured the input.
+ */
+ private boolean shouldAllowRecaptureTouch(MotionEvent ev) {
+ final long elapsedMs = ev.getEventTime() - ev.getDownTime();
+ if (mDownEvent == null || elapsedMs == 0 || elapsedMs > TOUCH_RECAPTURE_WINDOW_MS) {
+ // Either we don't have info to decide or it's been long enough that we no longer
+ // want to reinterpret user intent.
+ return false;
+ }
+ final float dx = ev.getRawX() - mDownEvent.getRawX();
+ final float dy = ev.getRawY() - mDownEvent.getRawY();
+ final float dt = elapsedMs / 1000.0f;
+ final float maxAbsDelta = Math.max(Math.abs(dx), Math.abs(dy));
+ final float velocity = maxAbsDelta / dt;
+ return velocity > mFlingThresholdPx;
+ }
+
+ @Override
+ public boolean onTouch(final View view, final MotionEvent motionEvent) {
+ switch (motionEvent.getAction()) {
+ case MotionEvent.ACTION_UP: {
+ if (!mMoved || mDownEvent == null) {
+ return false;
+ }
+ final float dx = motionEvent.getRawX() - mDownEvent.getRawX();
+ final float dy = motionEvent.getRawY() - mDownEvent.getRawY();
+
+ final float dt =
+ (motionEvent.getEventTime() - mDownEvent.getEventTime()) / 1000.0f;
+ final float yVelocity = dy / dt;
+
+ boolean handled = false;
+
+ // Vertical swipe occurred if the direction is as least mostly in the y
+ // component and has the required velocity (px/sec)
+ if ((dx == 0 || (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) &&
+ Math.abs(yVelocity) > mFlingThresholdPx) {
+ if (yVelocity < 0 && mExpanded) {
+ setFullScreenView(true, true);
+ handled = true;
+ } else if (yVelocity > 0) {
+ if (mFullScreen && yVelocity < mBigFlingThresholdPx) {
+ setFullScreenView(false, true);
+ } else {
+ setExpanded(false, true, PAGE_NOT_SET);
+ }
+ handled = true;
+ }
+ }
+
+ if (!handled) {
+ // If they didn't swipe enough, animate back to resting state
+ setDesiredHeight(getDesiredHeight(), true);
+ }
+ resetState();
+ break;
+ }
+ case MotionEvent.ACTION_DOWN: {
+ mDownHeight = getHeight();
+ mDownEvent = MotionEvent.obtain(motionEvent);
+ // If we are here and care about the return value (i.e. this is not called
+ // from onInterceptTouchEvent), then presumably no children view in the panel
+ // handles the down event. We'd like to handle future ACTION_MOVE events, so
+ // always claim ownership on this event so it doesn't fall through and gets
+ // cancelled by the framework.
+ return true;
+ }
+ case MotionEvent.ACTION_MOVE: {
+ if (mDownEvent == null) {
+ return mMoved;
+ }
+
+ final float dx = mDownEvent.getRawX() - motionEvent.getRawX();
+ final float dy = mDownEvent.getRawY() - motionEvent.getRawY();
+ // Don't act if the move is mostly horizontal
+ if (Math.abs(dy) > mTouchSlop &&
+ (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) {
+ setDesiredHeight((int) (mDownHeight + dy), false);
+ mMoved = true;
+ if (dy < -mTouchSlop) {
+ mMovedDown = true;
+ }
+ }
+ return mMoved;
+ }
+
+ }
+ return mMoved;
+ }
+
+ private void resetState() {
+ mDownEvent = null;
+ mDownHeight = -1;
+ mMoved = false;
+ mMovedDown = false;
+ mCanChildViewSwipeDown = false;
+ updateViewPager();
+ }
+ }
+}
+
diff --git a/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java b/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java
new file mode 100644
index 0000000..7ac7871
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java
@@ -0,0 +1,127 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.hardware.Camera;
+import android.media.CamcorderProfile;
+import android.media.MediaRecorder;
+import android.net.Uri;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.MediaScratchFileProvider;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.SafeAsyncTask;
+
+import java.io.FileNotFoundException;
+
+class MmsVideoRecorder extends MediaRecorder {
+ private static final float VIDEO_OVERSHOOT_SLOP = .85F;
+
+ private static final int BITS_PER_BYTE = 8;
+
+ // We think user will expect to be able to record videos at least this long
+ private static final long MIN_DURATION_LIMIT_SECONDS = 25;
+
+ /** The uri where video is being recorded to */
+ private Uri mTempVideoUri;
+
+ /** The settings used for video recording */
+ private final CamcorderProfile mCamcorderProfile;
+
+ public MmsVideoRecorder(final Camera camera, final int cameraIndex, final int orientation,
+ final int maxMessageSize)
+ throws FileNotFoundException {
+ mCamcorderProfile =
+ CamcorderProfile.get(cameraIndex, CamcorderProfile.QUALITY_LOW);
+ mTempVideoUri = MediaScratchFileProvider.buildMediaScratchSpaceUri(
+ ContentType.getExtension(getContentType()));
+
+ // The video recorder can sometimes return a file that's larger than the max we
+ // say we can handle. Try to handle that overshoot by specifying an 85% limit.
+ final long sizeLimit = (long) (maxMessageSize * VIDEO_OVERSHOOT_SLOP);
+
+ // The QUALITY_LOW profile might not be low enough to allow for video of a reasonable
+ // minimum duration. Adjust a/v bitrates to allow at least MIN_DURATION_LIMIT video
+ // to be recorded.
+ int audioBitRate = mCamcorderProfile.audioBitRate;
+ int videoBitRate = mCamcorderProfile.videoBitRate;
+ final double initialDurationLimit = sizeLimit * BITS_PER_BYTE
+ / (double) (audioBitRate + videoBitRate);
+ if (initialDurationLimit < MIN_DURATION_LIMIT_SECONDS) {
+ // Reduce the suggested bitrates. These bitrates are only requests, if implementation
+ // can't actually hit these goals it will still record video at higher rate and stop when
+ // it hits the size limit.
+ final double bitRateAdjustmentFactor = initialDurationLimit / MIN_DURATION_LIMIT_SECONDS;
+ audioBitRate *= bitRateAdjustmentFactor;
+ videoBitRate *= bitRateAdjustmentFactor;
+ }
+
+ setCamera(camera);
+ setOrientationHint(orientation);
+ setAudioSource(MediaRecorder.AudioSource.CAMCORDER);
+ setVideoSource(MediaRecorder.VideoSource.CAMERA);
+ setOutputFormat(mCamcorderProfile.fileFormat);
+ setOutputFile(
+ Factory.get().getApplicationContext().getContentResolver().openFileDescriptor(
+ mTempVideoUri, "w").getFileDescriptor());
+
+ // Copy settings from CamcorderProfile to MediaRecorder
+ setAudioEncodingBitRate(audioBitRate);
+ setAudioChannels(mCamcorderProfile.audioChannels);
+ setAudioEncoder(mCamcorderProfile.audioCodec);
+ setAudioSamplingRate(mCamcorderProfile.audioSampleRate);
+ setVideoEncodingBitRate(videoBitRate);
+ setVideoEncoder(mCamcorderProfile.videoCodec);
+ setVideoFrameRate(mCamcorderProfile.videoFrameRate);
+ setVideoSize(
+ mCamcorderProfile.videoFrameWidth, mCamcorderProfile.videoFrameHeight);
+ setMaxFileSize(sizeLimit);
+ }
+
+ Uri getVideoUri() {
+ return mTempVideoUri;
+ }
+
+ int getVideoWidth() {
+ return mCamcorderProfile.videoFrameWidth;
+ }
+
+ int getVideoHeight() {
+ return mCamcorderProfile.videoFrameHeight;
+ }
+
+ void cleanupTempFile() {
+ final Uri tempUri = mTempVideoUri;
+ SafeAsyncTask.executeOnThreadPool(new Runnable() {
+ @Override
+ public void run() {
+ Factory.get().getApplicationContext().getContentResolver().delete(
+ tempUri, null, null);
+ }
+ });
+ mTempVideoUri = null;
+ }
+
+ String getContentType() {
+ if (mCamcorderProfile.fileFormat == OutputFormat.MPEG_4) {
+ return ContentType.VIDEO_MP4;
+ } else {
+ // 3GPP is the only other video format with a constant in OutputFormat
+ return ContentType.VIDEO_3GPP;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/PausableChronometer.java b/src/com/android/messaging/ui/mediapicker/PausableChronometer.java
new file mode 100644
index 0000000..dc8f90b
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/PausableChronometer.java
@@ -0,0 +1,75 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.os.SystemClock;
+import android.util.AttributeSet;
+import android.widget.Chronometer;
+
+import com.android.messaging.ui.PlaybackStateView;
+
+/**
+ * A pausable Chronometer implementation. The default Chronometer in Android only stops the UI
+ * from updating when you call stop(), but doesn't actually pause it. This implementation adds an
+ * additional timestamp that tracks the timespan for the pause and compensate for that.
+ */
+public class PausableChronometer extends Chronometer implements PlaybackStateView {
+ // Keeps track of how far long the Chronometer has been tracking when it's paused. We'd like
+ // to start from this time the next time it's resumed.
+ private long mTimeWhenPaused = 0;
+
+ public PausableChronometer(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ /**
+ * Reset the timer and start counting from zero.
+ */
+ @Override
+ public void restart() {
+ reset();
+ start();
+ }
+
+ /**
+ * Reset the timer to zero, but don't start it.
+ */
+ @Override
+ public void reset() {
+ stop();
+ setBase(SystemClock.elapsedRealtime());
+ mTimeWhenPaused = 0;
+ }
+
+ /**
+ * Resume the timer after a previous pause.
+ */
+ @Override
+ public void resume() {
+ setBase(SystemClock.elapsedRealtime() - mTimeWhenPaused);
+ start();
+ }
+
+ /**
+ * Pause the timer.
+ */
+ @Override
+ public void pause() {
+ stop();
+ mTimeWhenPaused = SystemClock.elapsedRealtime() - getBase();
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java
new file mode 100644
index 0000000..5dc3185
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java
@@ -0,0 +1,114 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.content.Context;
+import android.hardware.Camera;
+import android.os.Parcelable;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.View;
+
+import java.io.IOException;
+
+/**
+ * A software rendered preview surface for the camera. This renders slower and causes more jank, so
+ * HardwareCameraPreview is preferred if possible.
+ *
+ * There is a significant amount of duplication between HardwareCameraPreview and
+ * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The
+ * implementations of the shared methods are delegated to CameraPreview
+ */
+public class SoftwareCameraPreview extends SurfaceView implements CameraPreview.CameraPreviewHost {
+ private final CameraPreview mPreview;
+
+ public SoftwareCameraPreview(final Context context) {
+ super(context);
+ mPreview = new CameraPreview(this);
+ getHolder().addCallback(new SurfaceHolder.Callback() {
+ @Override
+ public void surfaceCreated(final SurfaceHolder surfaceHolder) {
+ CameraManager.get().setSurface(mPreview);
+ }
+
+ @Override
+ public void surfaceChanged(final SurfaceHolder surfaceHolder, final int format, final int width,
+ final int height) {
+ CameraManager.get().setSurface(mPreview);
+ }
+
+ @Override
+ public void surfaceDestroyed(final SurfaceHolder surfaceHolder) {
+ CameraManager.get().setSurface(null);
+ }
+ });
+ }
+
+
+ @Override
+ protected void onVisibilityChanged(final View changedView, final int visibility) {
+ super.onVisibilityChanged(changedView, visibility);
+ mPreview.onVisibilityChanged(visibility);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ mPreview.onDetachedFromWindow();
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ mPreview.onAttachedToWindow();
+ }
+
+ @Override
+ protected void onRestoreInstanceState(final Parcelable state) {
+ super.onRestoreInstanceState(state);
+ mPreview.onRestoreInstanceState();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec);
+ heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec);
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+
+ @Override
+ public View getView() {
+ return this;
+ }
+
+ @Override
+ public boolean isValid() {
+ return getHolder() != null;
+ }
+
+ @Override
+ public void startPreview(final Camera camera) throws IOException {
+ camera.setPreviewDisplay(getHolder());
+ }
+
+ @Override
+ public void onCameraPermissionGranted() {
+ mPreview.onCameraPermissionGranted();
+ }
+}
+
+
diff --git a/src/com/android/messaging/ui/mediapicker/SoundLevels.java b/src/com/android/messaging/ui/mediapicker/SoundLevels.java
new file mode 100644
index 0000000..6f4dca6
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/SoundLevels.java
@@ -0,0 +1,212 @@
+/*
+ * 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.messaging.ui.mediapicker;
+
+import android.animation.ObjectAnimator;
+import android.animation.TimeAnimator;
+import android.animation.TimeAnimator.TimeListener;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Paint.Style;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import com.android.messaging.R;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * This view draws circular sound levels. By default the sound levels are black, unless
+ * otherwise defined via {@link #mPrimaryLevelPaint}.
+ */
+public class SoundLevels extends View {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+ private static final boolean DEBUG = false;
+
+ private boolean mCenterDefined;
+ private int mCenterX;
+ private int mCenterY;
+
+ // Paint for the main level meter, most closely follows the mic.
+ private final Paint mPrimaryLevelPaint;
+
+ // The minimum size of the levels as a percentage of the max, that is the size when volume is 0.
+ private final float mMinimumLevel;
+
+ // The minimum size of the levels, that is the size when volume is 0.
+ private final float mMinimumLevelSize;
+
+ // The maximum size of the levels, that is the size when volume is 100.
+ private final float mMaximumLevelSize;
+
+ // Generates clock ticks for the animation using the global animation loop.
+ private final TimeAnimator mSpeechLevelsAnimator;
+
+ private float mCurrentVolume;
+
+ // Indicates whether we should be animating the sound level.
+ private boolean mIsEnabled;
+
+ // Input level is pulled from here.
+ private AudioLevelSource mLevelSource;
+
+ public SoundLevels(final Context context) {
+ this(context, null);
+ }
+
+ public SoundLevels(final Context context, final AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public SoundLevels(final Context context, final AttributeSet attrs, final int defStyle) {
+ super(context, attrs, defStyle);
+
+ // Safe source, replaced with system one when attached.
+ mLevelSource = new AudioLevelSource();
+ mLevelSource.setSpeechLevel(0);
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SoundLevels,
+ defStyle, 0);
+
+ mMaximumLevelSize = a.getDimensionPixelOffset(
+ R.styleable.SoundLevels_maxLevelRadius, 0);
+ mMinimumLevelSize = a.getDimensionPixelOffset(
+ R.styleable.SoundLevels_minLevelRadius, 0);
+ mMinimumLevel = mMinimumLevelSize / mMaximumLevelSize;
+
+ mPrimaryLevelPaint = new Paint();
+ mPrimaryLevelPaint.setColor(
+ a.getColor(R.styleable.SoundLevels_primaryColor, Color.BLACK));
+ mPrimaryLevelPaint.setFlags(Paint.ANTI_ALIAS_FLAG);
+
+ a.recycle();
+
+ // This animator generates ticks that invalidate the
+ // view so that the animation is synced with the global animation loop.
+ // TODO: We could probably remove this in favor of using postInvalidateOnAnimation
+ // which might improve things further.
+ mSpeechLevelsAnimator = new TimeAnimator();
+ mSpeechLevelsAnimator.setRepeatCount(ObjectAnimator.INFINITE);
+ mSpeechLevelsAnimator.setTimeListener(new TimeListener() {
+ @Override
+ public void onTimeUpdate(final TimeAnimator animation, final long totalTime,
+ final long deltaTime) {
+ invalidate();
+ }
+ });
+ }
+
+ @Override
+ protected void onDraw(final Canvas canvas) {
+ if (!mIsEnabled) {
+ return;
+ }
+
+ if (!mCenterDefined) {
+ // One time computation here, because we can't rely on getWidth() to be computed at
+ // constructor time or in onFinishInflate :(.
+ mCenterX = getWidth() / 2;
+ mCenterY = getWidth() / 2;
+ mCenterDefined = true;
+ }
+
+ final int level = mLevelSource.getSpeechLevel();
+ // Either ease towards the target level, or decay away from it depending on whether
+ // its higher or lower than the current.
+ if (level > mCurrentVolume) {
+ mCurrentVolume = mCurrentVolume + ((level - mCurrentVolume) / 4);
+ } else {
+ mCurrentVolume = mCurrentVolume * 0.95f;
+ }
+
+ final float radius = mMinimumLevel + (1f - mMinimumLevel) * mCurrentVolume / 100;
+ mPrimaryLevelPaint.setStyle(Style.FILL);
+ canvas.drawCircle(mCenterX, mCenterY, radius * mMaximumLevelSize, mPrimaryLevelPaint);
+ }
+
+ public void setLevelSource(final AudioLevelSource source) {
+ if (DEBUG) {
+ Log.d(TAG, "Speech source set.");
+ }
+ mLevelSource = source;
+ }
+
+ private void startSpeechLevelsAnimator() {
+ if (DEBUG) {
+ Log.d(TAG, "startAnimator()");
+ }
+ if (!mSpeechLevelsAnimator.isStarted()) {
+ mSpeechLevelsAnimator.start();
+ }
+ }
+
+ private void stopSpeechLevelsAnimator() {
+ if (DEBUG) {
+ Log.d(TAG, "stopAnimator()");
+ }
+ if (mSpeechLevelsAnimator.isStarted()) {
+ mSpeechLevelsAnimator.end();
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ stopSpeechLevelsAnimator();
+ }
+
+ @Override
+ public void setEnabled(final boolean enabled) {
+ if (enabled == mIsEnabled) {
+ return;
+ }
+ if (DEBUG) {
+ Log.d("TAG", "setEnabled: " + enabled);
+ }
+ super.setEnabled(enabled);
+ mIsEnabled = enabled;
+ setKeepScreenOn(enabled);
+ updateSpeechLevelsAnimatorState();
+ }
+
+ private void updateSpeechLevelsAnimatorState() {
+ if (mIsEnabled) {
+ startSpeechLevelsAnimator();
+ } else {
+ stopSpeechLevelsAnimator();
+ }
+ }
+
+ /**
+ * This is required to make the View findable by uiautomator
+ */
+ @Override
+ public void onInitializeAccessibilityNodeInfo(final AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(info);
+ info.setClassName(SoundLevels.class.getCanonicalName());
+ }
+
+ /**
+ * Set the alpha level of the sound circles.
+ */
+ public void setPrimaryColorAlpha(final int alpha) {
+ mPrimaryLevelPaint.setAlpha(alpha);
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java
new file mode 100644
index 0000000..92ed3c1
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.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.messaging.ui.mediapicker.camerafocus;
+
+public interface FocusIndicator {
+ public void showStart();
+ public void showSuccess(boolean timeout);
+ public void showFail(boolean timeout);
+ public void clear();
+} \ No newline at end of file
diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java
new file mode 100644
index 0000000..e620fc2
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java
@@ -0,0 +1,589 @@
+/*
+ * 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.messaging.ui.mediapicker.camerafocus;
+
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.hardware.Camera.Area;
+import android.hardware.Camera.Parameters;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.LogUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/* A class that handles everything about focus in still picture mode.
+ * This also handles the metering area because it is the same as focus area.
+ *
+ * The test cases:
+ * (1) The camera has continuous autofocus. Move the camera. Take a picture when
+ * CAF is not in progress.
+ * (2) The camera has continuous autofocus. Move the camera. Take a picture when
+ * CAF is in progress.
+ * (3) The camera has face detection. Point the camera at some faces. Hold the
+ * shutter. Release to take a picture.
+ * (4) The camera has face detection. Point the camera at some faces. Single tap
+ * the shutter to take a picture.
+ * (5) The camera has autofocus. Single tap the shutter to take a picture.
+ * (6) The camera has autofocus. Hold the shutter. Release to take a picture.
+ * (7) The camera has no autofocus. Single tap the shutter and take a picture.
+ * (8) The camera has autofocus and supports focus area. Touch the screen to
+ * trigger autofocus. Take a picture.
+ * (9) The camera has autofocus and supports focus area. Touch the screen to
+ * trigger autofocus. Wait until it times out.
+ * (10) The camera has no autofocus and supports metering area. Touch the screen
+ * to change metering area.
+ */
+public class FocusOverlayManager {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+ private static final String TRUE = "true";
+ private static final String AUTO_EXPOSURE_LOCK_SUPPORTED = "auto-exposure-lock-supported";
+ private static final String AUTO_WHITE_BALANCE_LOCK_SUPPORTED =
+ "auto-whitebalance-lock-supported";
+
+ private static final int RESET_TOUCH_FOCUS = 0;
+ private static final int RESET_TOUCH_FOCUS_DELAY = 3000;
+
+ private int mState = STATE_IDLE;
+ private static final int STATE_IDLE = 0; // Focus is not active.
+ private static final int STATE_FOCUSING = 1; // Focus is in progress.
+ // Focus is in progress and the camera should take a picture after focus finishes.
+ private static final int STATE_FOCUSING_SNAP_ON_FINISH = 2;
+ private static final int STATE_SUCCESS = 3; // Focus finishes and succeeds.
+ private static final int STATE_FAIL = 4; // Focus finishes and fails.
+
+ private boolean mInitialized;
+ private boolean mFocusAreaSupported;
+ private boolean mMeteringAreaSupported;
+ private boolean mLockAeAwbNeeded;
+ private boolean mAeAwbLock;
+ private Matrix mMatrix;
+
+ private PieRenderer mPieRenderer;
+
+ private int mPreviewWidth; // The width of the preview frame layout.
+ private int mPreviewHeight; // The height of the preview frame layout.
+ private boolean mMirror; // true if the camera is front-facing.
+ private int mDisplayOrientation;
+ private List<Object> mFocusArea; // focus area in driver format
+ private List<Object> mMeteringArea; // metering area in driver format
+ private String mFocusMode;
+ private String mOverrideFocusMode;
+ private Parameters mParameters;
+ private Handler mHandler;
+ Listener mListener;
+
+ public interface Listener {
+ public void autoFocus();
+ public void cancelAutoFocus();
+ public boolean capture();
+ public void setFocusParameters();
+ }
+
+ private class MainHandler extends Handler {
+ public MainHandler(Looper looper) {
+ super(looper);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case RESET_TOUCH_FOCUS: {
+ cancelAutoFocus();
+ break;
+ }
+ }
+ }
+ }
+
+ public FocusOverlayManager(Listener listener, Looper looper) {
+ mHandler = new MainHandler(looper);
+ mMatrix = new Matrix();
+ mListener = listener;
+ }
+
+ public void setFocusRenderer(PieRenderer renderer) {
+ mPieRenderer = renderer;
+ mInitialized = (mMatrix != null);
+ }
+
+ public void setParameters(Parameters parameters) {
+ // parameters can only be null when onConfigurationChanged is called
+ // before camera is open. We will just return in this case, because
+ // parameters will be set again later with the right parameters after
+ // camera is open.
+ if (parameters == null) {
+ return;
+ }
+ mParameters = parameters;
+ mFocusAreaSupported = isFocusAreaSupported(parameters);
+ mMeteringAreaSupported = isMeteringAreaSupported(parameters);
+ mLockAeAwbNeeded = (isAutoExposureLockSupported(mParameters) ||
+ isAutoWhiteBalanceLockSupported(mParameters));
+ }
+
+ public void setPreviewSize(int previewWidth, int previewHeight) {
+ if (mPreviewWidth != previewWidth || mPreviewHeight != previewHeight) {
+ mPreviewWidth = previewWidth;
+ mPreviewHeight = previewHeight;
+ setMatrix();
+ }
+ }
+
+ public void setMirror(boolean mirror) {
+ mMirror = mirror;
+ setMatrix();
+ }
+
+ public void setDisplayOrientation(int displayOrientation) {
+ mDisplayOrientation = displayOrientation;
+ setMatrix();
+ }
+
+ private void setMatrix() {
+ if (mPreviewWidth != 0 && mPreviewHeight != 0) {
+ Matrix matrix = new Matrix();
+ prepareMatrix(matrix, mMirror, mDisplayOrientation,
+ mPreviewWidth, mPreviewHeight);
+ // In face detection, the matrix converts the driver coordinates to UI
+ // coordinates. In tap focus, the inverted matrix converts the UI
+ // coordinates to driver coordinates.
+ matrix.invert(mMatrix);
+ mInitialized = (mPieRenderer != null);
+ }
+ }
+
+ private void lockAeAwbIfNeeded() {
+ if (mLockAeAwbNeeded && !mAeAwbLock) {
+ mAeAwbLock = true;
+ mListener.setFocusParameters();
+ }
+ }
+
+ private void unlockAeAwbIfNeeded() {
+ if (mLockAeAwbNeeded && mAeAwbLock && (mState != STATE_FOCUSING_SNAP_ON_FINISH)) {
+ mAeAwbLock = false;
+ mListener.setFocusParameters();
+ }
+ }
+
+ public void onShutterDown() {
+ if (!mInitialized) {
+ return;
+ }
+
+ boolean autoFocusCalled = false;
+ if (needAutoFocusCall()) {
+ // Do not focus if touch focus has been triggered.
+ if (mState != STATE_SUCCESS && mState != STATE_FAIL) {
+ autoFocus();
+ autoFocusCalled = true;
+ }
+ }
+
+ if (!autoFocusCalled) {
+ lockAeAwbIfNeeded();
+ }
+ }
+
+ public void onShutterUp() {
+ if (!mInitialized) {
+ return;
+ }
+
+ if (needAutoFocusCall()) {
+ // User releases half-pressed focus key.
+ if (mState == STATE_FOCUSING || mState == STATE_SUCCESS
+ || mState == STATE_FAIL) {
+ cancelAutoFocus();
+ }
+ }
+
+ // Unlock AE and AWB after cancelAutoFocus. Camera API does not
+ // guarantee setParameters can be called during autofocus.
+ unlockAeAwbIfNeeded();
+ }
+
+ public void doSnap() {
+ if (!mInitialized) {
+ return;
+ }
+
+ // If the user has half-pressed the shutter and focus is completed, we
+ // can take the photo right away. If the focus mode is infinity, we can
+ // also take the photo.
+ if (!needAutoFocusCall() || (mState == STATE_SUCCESS || mState == STATE_FAIL)) {
+ capture();
+ } else if (mState == STATE_FOCUSING) {
+ // Half pressing the shutter (i.e. the focus button event) will
+ // already have requested AF for us, so just request capture on
+ // focus here.
+ mState = STATE_FOCUSING_SNAP_ON_FINISH;
+ } else if (mState == STATE_IDLE) {
+ // We didn't do focus. This can happen if the user press focus key
+ // while the snapshot is still in progress. The user probably wants
+ // the next snapshot as soon as possible, so we just do a snapshot
+ // without focusing again.
+ capture();
+ }
+ }
+
+ public void onAutoFocus(boolean focused, boolean shutterButtonPressed) {
+ if (mState == STATE_FOCUSING_SNAP_ON_FINISH) {
+ // Take the picture no matter focus succeeds or fails. No need
+ // to play the AF sound if we're about to play the shutter
+ // sound.
+ if (focused) {
+ mState = STATE_SUCCESS;
+ } else {
+ mState = STATE_FAIL;
+ }
+ updateFocusUI();
+ capture();
+ } else if (mState == STATE_FOCUSING) {
+ // This happens when (1) user is half-pressing the focus key or
+ // (2) touch focus is triggered. Play the focus tone. Do not
+ // take the picture now.
+ if (focused) {
+ mState = STATE_SUCCESS;
+ } else {
+ mState = STATE_FAIL;
+ }
+ updateFocusUI();
+ // If this is triggered by touch focus, cancel focus after a
+ // while.
+ if (mFocusArea != null) {
+ mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY);
+ }
+ if (shutterButtonPressed) {
+ // Lock AE & AWB so users can half-press shutter and recompose.
+ lockAeAwbIfNeeded();
+ }
+ } else if (mState == STATE_IDLE) {
+ // User has released the focus key before focus completes.
+ // Do nothing.
+ }
+ }
+
+ public void onAutoFocusMoving(boolean moving) {
+ if (!mInitialized) {
+ return;
+ }
+
+ // Ignore if we have requested autofocus. This method only handles
+ // continuous autofocus.
+ if (mState != STATE_IDLE) {
+ return;
+ }
+
+ if (moving) {
+ mPieRenderer.showStart();
+ } else {
+ mPieRenderer.showSuccess(true);
+ }
+ }
+
+ private void initializeFocusAreas(int focusWidth, int focusHeight,
+ int x, int y, int previewWidth, int previewHeight) {
+ if (mFocusArea == null) {
+ mFocusArea = new ArrayList<Object>();
+ mFocusArea.add(new Area(new Rect(), 1));
+ }
+
+ // Convert the coordinates to driver format.
+ calculateTapArea(focusWidth, focusHeight, 1f, x, y, previewWidth, previewHeight,
+ ((Area) mFocusArea.get(0)).rect);
+ }
+
+ private void initializeMeteringAreas(int focusWidth, int focusHeight,
+ int x, int y, int previewWidth, int previewHeight) {
+ if (mMeteringArea == null) {
+ mMeteringArea = new ArrayList<Object>();
+ mMeteringArea.add(new Area(new Rect(), 1));
+ }
+
+ // Convert the coordinates to driver format.
+ // AE area is bigger because exposure is sensitive and
+ // easy to over- or underexposure if area is too small.
+ calculateTapArea(focusWidth, focusHeight, 1.5f, x, y, previewWidth, previewHeight,
+ ((Area) mMeteringArea.get(0)).rect);
+ }
+
+ public void onSingleTapUp(int x, int y) {
+ if (!mInitialized || mState == STATE_FOCUSING_SNAP_ON_FINISH) {
+ return;
+ }
+
+ // Let users be able to cancel previous touch focus.
+ if ((mFocusArea != null) && (mState == STATE_FOCUSING ||
+ mState == STATE_SUCCESS || mState == STATE_FAIL)) {
+ cancelAutoFocus();
+ }
+ // Initialize variables.
+ int focusWidth = mPieRenderer.getSize();
+ int focusHeight = mPieRenderer.getSize();
+ if (focusWidth == 0 || mPieRenderer.getWidth() == 0 || mPieRenderer.getHeight() == 0) {
+ return;
+ }
+ int previewWidth = mPreviewWidth;
+ int previewHeight = mPreviewHeight;
+ // Initialize mFocusArea.
+ if (mFocusAreaSupported) {
+ initializeFocusAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight);
+ }
+ // Initialize mMeteringArea.
+ if (mMeteringAreaSupported) {
+ initializeMeteringAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight);
+ }
+
+ // Use margin to set the focus indicator to the touched area.
+ mPieRenderer.setFocus(x, y);
+
+ // Set the focus area and metering area.
+ mListener.setFocusParameters();
+ if (mFocusAreaSupported) {
+ autoFocus();
+ } else { // Just show the indicator in all other cases.
+ updateFocusUI();
+ // Reset the metering area in 3 seconds.
+ mHandler.removeMessages(RESET_TOUCH_FOCUS);
+ mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY);
+ }
+ }
+
+ public void onPreviewStarted() {
+ mState = STATE_IDLE;
+ }
+
+ public void onPreviewStopped() {
+ // If auto focus was in progress, it would have been stopped.
+ mState = STATE_IDLE;
+ resetTouchFocus();
+ updateFocusUI();
+ }
+
+ public void onCameraReleased() {
+ onPreviewStopped();
+ }
+
+ private void autoFocus() {
+ LogUtil.v(TAG, "Start autofocus.");
+ mListener.autoFocus();
+ mState = STATE_FOCUSING;
+ updateFocusUI();
+ mHandler.removeMessages(RESET_TOUCH_FOCUS);
+ }
+
+ private void cancelAutoFocus() {
+ LogUtil.v(TAG, "Cancel autofocus.");
+
+ // Reset the tap area before calling mListener.cancelAutofocus.
+ // Otherwise, focus mode stays at auto and the tap area passed to the
+ // driver is not reset.
+ resetTouchFocus();
+ mListener.cancelAutoFocus();
+ mState = STATE_IDLE;
+ updateFocusUI();
+ mHandler.removeMessages(RESET_TOUCH_FOCUS);
+ }
+
+ private void capture() {
+ if (mListener.capture()) {
+ mState = STATE_IDLE;
+ mHandler.removeMessages(RESET_TOUCH_FOCUS);
+ }
+ }
+
+ public String getFocusMode() {
+ if (mOverrideFocusMode != null) {
+ return mOverrideFocusMode;
+ }
+ List<String> supportedFocusModes = mParameters.getSupportedFocusModes();
+
+ if (mFocusAreaSupported && mFocusArea != null) {
+ // Always use autofocus in tap-to-focus.
+ mFocusMode = Parameters.FOCUS_MODE_AUTO;
+ } else {
+ mFocusMode = Parameters.FOCUS_MODE_CONTINUOUS_PICTURE;
+ }
+
+ if (!isSupported(mFocusMode, supportedFocusModes)) {
+ // For some reasons, the driver does not support the current
+ // focus mode. Fall back to auto.
+ if (isSupported(Parameters.FOCUS_MODE_AUTO,
+ mParameters.getSupportedFocusModes())) {
+ mFocusMode = Parameters.FOCUS_MODE_AUTO;
+ } else {
+ mFocusMode = mParameters.getFocusMode();
+ }
+ }
+ return mFocusMode;
+ }
+
+ public List getFocusAreas() {
+ return mFocusArea;
+ }
+
+ public List getMeteringAreas() {
+ return mMeteringArea;
+ }
+
+ public void updateFocusUI() {
+ if (!mInitialized) {
+ return;
+ }
+ FocusIndicator focusIndicator = mPieRenderer;
+
+ if (mState == STATE_IDLE) {
+ if (mFocusArea == null) {
+ focusIndicator.clear();
+ } else {
+ // Users touch on the preview and the indicator represents the
+ // metering area. Either focus area is not supported or
+ // autoFocus call is not required.
+ focusIndicator.showStart();
+ }
+ } else if (mState == STATE_FOCUSING || mState == STATE_FOCUSING_SNAP_ON_FINISH) {
+ focusIndicator.showStart();
+ } else {
+ if (Parameters.FOCUS_MODE_CONTINUOUS_PICTURE.equals(mFocusMode)) {
+ // TODO: check HAL behavior and decide if this can be removed.
+ focusIndicator.showSuccess(false);
+ } else if (mState == STATE_SUCCESS) {
+ focusIndicator.showSuccess(false);
+ } else if (mState == STATE_FAIL) {
+ focusIndicator.showFail(false);
+ }
+ }
+ }
+
+ public void resetTouchFocus() {
+ if (!mInitialized) {
+ return;
+ }
+
+ // Put focus indicator to the center. clear reset position
+ mPieRenderer.clear();
+
+ mFocusArea = null;
+ mMeteringArea = null;
+ }
+
+ private void calculateTapArea(int focusWidth, int focusHeight, float areaMultiple,
+ int x, int y, int previewWidth, int previewHeight, Rect rect) {
+ int areaWidth = (int) (focusWidth * areaMultiple);
+ int areaHeight = (int) (focusHeight * areaMultiple);
+ int left = clamp(x - areaWidth / 2, 0, previewWidth - areaWidth);
+ int top = clamp(y - areaHeight / 2, 0, previewHeight - areaHeight);
+
+ RectF rectF = new RectF(left, top, left + areaWidth, top + areaHeight);
+ mMatrix.mapRect(rectF);
+ rectFToRect(rectF, rect);
+ }
+
+ /* package */ int getFocusState() {
+ return mState;
+ }
+
+ public boolean isFocusCompleted() {
+ return mState == STATE_SUCCESS || mState == STATE_FAIL;
+ }
+
+ public boolean isFocusingSnapOnFinish() {
+ return mState == STATE_FOCUSING_SNAP_ON_FINISH;
+ }
+
+ public void removeMessages() {
+ mHandler.removeMessages(RESET_TOUCH_FOCUS);
+ }
+
+ public void overrideFocusMode(String focusMode) {
+ mOverrideFocusMode = focusMode;
+ }
+
+ public void setAeAwbLock(boolean lock) {
+ mAeAwbLock = lock;
+ }
+
+ public boolean getAeAwbLock() {
+ return mAeAwbLock;
+ }
+
+ private boolean needAutoFocusCall() {
+ String focusMode = getFocusMode();
+ return !(focusMode.equals(Parameters.FOCUS_MODE_INFINITY)
+ || focusMode.equals(Parameters.FOCUS_MODE_FIXED)
+ || focusMode.equals(Parameters.FOCUS_MODE_EDOF));
+ }
+
+ public static boolean isAutoExposureLockSupported(Parameters params) {
+ return TRUE.equals(params.get(AUTO_EXPOSURE_LOCK_SUPPORTED));
+ }
+
+ public static boolean isAutoWhiteBalanceLockSupported(Parameters params) {
+ return TRUE.equals(params.get(AUTO_WHITE_BALANCE_LOCK_SUPPORTED));
+ }
+
+ public static boolean isSupported(String value, List<String> supported) {
+ return supported != null && supported.indexOf(value) >= 0;
+ }
+
+ public static boolean isMeteringAreaSupported(Parameters params) {
+ return params.getMaxNumMeteringAreas() > 0;
+ }
+
+ public static boolean isFocusAreaSupported(Parameters params) {
+ return (params.getMaxNumFocusAreas() > 0
+ && isSupported(Parameters.FOCUS_MODE_AUTO,
+ params.getSupportedFocusModes()));
+ }
+
+ public static void prepareMatrix(Matrix matrix, boolean mirror, int displayOrientation,
+ int viewWidth, int viewHeight) {
+ // Need mirror for front camera.
+ matrix.setScale(mirror ? -1 : 1, 1);
+ // This is the value for android.hardware.Camera.setDisplayOrientation.
+ matrix.postRotate(displayOrientation);
+ // Camera driver coordinates range from (-1000, -1000) to (1000, 1000).
+ // UI coordinates range from (0, 0) to (width, height).
+ matrix.postScale(viewWidth / 2000f, viewHeight / 2000f);
+ matrix.postTranslate(viewWidth / 2f, viewHeight / 2f);
+ }
+
+ public static int clamp(int x, int min, int max) {
+ Assert.isTrue(max >= min);
+ if (x > max) {
+ return max;
+ }
+ if (x < min) {
+ return min;
+ }
+ return x;
+ }
+
+ public static void rectFToRect(RectF rectF, Rect rect) {
+ rect.left = Math.round(rectF.left);
+ rect.top = Math.round(rectF.top);
+ rect.right = Math.round(rectF.right);
+ rect.bottom = Math.round(rectF.bottom);
+ }
+}
diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java
new file mode 100644
index 0000000..df6734f
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java
@@ -0,0 +1,95 @@
+/*
+ * 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.messaging.ui.mediapicker.camerafocus;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.view.MotionEvent;
+
+public abstract class OverlayRenderer implements RenderOverlay.Renderer {
+
+ private static final String TAG = "CAM OverlayRenderer";
+ protected RenderOverlay mOverlay;
+
+ protected int mLeft, mTop, mRight, mBottom;
+
+ protected boolean mVisible;
+
+ public void setVisible(boolean vis) {
+ mVisible = vis;
+ update();
+ }
+
+ public boolean isVisible() {
+ return mVisible;
+ }
+
+ // default does not handle touch
+ @Override
+ public boolean handlesTouch() {
+ return false;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent evt) {
+ return false;
+ }
+
+ public abstract void onDraw(Canvas canvas);
+
+ public void draw(Canvas canvas) {
+ if (mVisible) {
+ onDraw(canvas);
+ }
+ }
+
+ @Override
+ public void setOverlay(RenderOverlay overlay) {
+ mOverlay = overlay;
+ }
+
+ @Override
+ public void layout(int left, int top, int right, int bottom) {
+ mLeft = left;
+ mRight = right;
+ mTop = top;
+ mBottom = bottom;
+ }
+
+ protected Context getContext() {
+ if (mOverlay != null) {
+ return mOverlay.getContext();
+ } else {
+ return null;
+ }
+ }
+
+ public int getWidth() {
+ return mRight - mLeft;
+ }
+
+ public int getHeight() {
+ return mBottom - mTop;
+ }
+
+ protected void update() {
+ if (mOverlay != null) {
+ mOverlay.update();
+ }
+ }
+
+} \ No newline at end of file
diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java
new file mode 100644
index 0000000..c602852
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java
@@ -0,0 +1,202 @@
+/*
+ * 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.messaging.ui.mediapicker.camerafocus;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Path;
+import android.graphics.drawable.Drawable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Pie menu item
+ */
+public class PieItem {
+
+ public static interface OnClickListener {
+ void onClick(PieItem item);
+ }
+
+ private Drawable mDrawable;
+ private int level;
+ private float mCenter;
+ private float start;
+ private float sweep;
+ private float animate;
+ private int inner;
+ private int outer;
+ private boolean mSelected;
+ private boolean mEnabled;
+ private List<PieItem> mItems;
+ private Path mPath;
+ private OnClickListener mOnClickListener;
+ private float mAlpha;
+
+ // Gray out the view when disabled
+ private static final float ENABLED_ALPHA = 1;
+ private static final float DISABLED_ALPHA = (float) 0.3;
+ private boolean mChangeAlphaWhenDisabled = true;
+
+ public PieItem(Drawable drawable, int level) {
+ mDrawable = drawable;
+ this.level = level;
+ setAlpha(1f);
+ mEnabled = true;
+ setAnimationAngle(getAnimationAngle());
+ start = -1;
+ mCenter = -1;
+ }
+
+ public boolean hasItems() {
+ return mItems != null;
+ }
+
+ public List<PieItem> getItems() {
+ return mItems;
+ }
+
+ public void addItem(PieItem item) {
+ if (mItems == null) {
+ mItems = new ArrayList<PieItem>();
+ }
+ mItems.add(item);
+ }
+
+ public void setPath(Path p) {
+ mPath = p;
+ }
+
+ public Path getPath() {
+ return mPath;
+ }
+
+ public void setChangeAlphaWhenDisabled (boolean enable) {
+ mChangeAlphaWhenDisabled = enable;
+ }
+
+ public void setAlpha(float alpha) {
+ mAlpha = alpha;
+ mDrawable.setAlpha((int) (255 * alpha));
+ }
+
+ public void setAnimationAngle(float a) {
+ animate = a;
+ }
+
+ public float getAnimationAngle() {
+ return animate;
+ }
+
+ public void setEnabled(boolean enabled) {
+ mEnabled = enabled;
+ if (mChangeAlphaWhenDisabled) {
+ if (mEnabled) {
+ setAlpha(ENABLED_ALPHA);
+ } else {
+ setAlpha(DISABLED_ALPHA);
+ }
+ }
+ }
+
+ public boolean isEnabled() {
+ return mEnabled;
+ }
+
+ public void setSelected(boolean s) {
+ mSelected = s;
+ }
+
+ public boolean isSelected() {
+ return mSelected;
+ }
+
+ public int getLevel() {
+ return level;
+ }
+
+ public void setGeometry(float st, float sw, int inside, int outside) {
+ start = st;
+ sweep = sw;
+ inner = inside;
+ outer = outside;
+ }
+
+ public void setFixedSlice(float center, float sweep) {
+ mCenter = center;
+ this.sweep = sweep;
+ }
+
+ public float getCenter() {
+ return mCenter;
+ }
+
+ public float getStart() {
+ return start;
+ }
+
+ public float getStartAngle() {
+ return start + animate;
+ }
+
+ public float getSweep() {
+ return sweep;
+ }
+
+ public int getInnerRadius() {
+ return inner;
+ }
+
+ public int getOuterRadius() {
+ return outer;
+ }
+
+ public void setOnClickListener(OnClickListener listener) {
+ mOnClickListener = listener;
+ }
+
+ public void performClick() {
+ if (mOnClickListener != null) {
+ mOnClickListener.onClick(this);
+ }
+ }
+
+ public int getIntrinsicWidth() {
+ return mDrawable.getIntrinsicWidth();
+ }
+
+ public int getIntrinsicHeight() {
+ return mDrawable.getIntrinsicHeight();
+ }
+
+ public void setBounds(int left, int top, int right, int bottom) {
+ mDrawable.setBounds(left, top, right, bottom);
+ }
+
+ public void draw(Canvas canvas) {
+ mDrawable.draw(canvas);
+ }
+
+ public void setImageResource(Context context, int resId) {
+ Drawable d = context.getResources().getDrawable(resId).mutate();
+ d.setBounds(mDrawable.getBounds());
+ mDrawable = d;
+ setAlpha(mAlpha);
+ }
+
+} \ No newline at end of file
diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java
new file mode 100644
index 0000000..ce8ca00
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java
@@ -0,0 +1,825 @@
+/*
+ * 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.messaging.ui.mediapicker.camerafocus;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Point;
+import android.graphics.PointF;
+import android.graphics.RectF;
+import android.os.Handler;
+import android.os.Message;
+import android.view.MotionEvent;
+import android.view.ViewConfiguration;
+import android.view.animation.Animation;
+import android.view.animation.Animation.AnimationListener;
+import android.view.animation.LinearInterpolator;
+import android.view.animation.Transformation;
+import com.android.messaging.R;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class PieRenderer extends OverlayRenderer
+ implements FocusIndicator {
+ // Sometimes continuous autofocus starts and stops several times quickly.
+ // These states are used to make sure the animation is run for at least some
+ // time.
+ private volatile int mState;
+ private ScaleAnimation mAnimation = new ScaleAnimation();
+ private static final int STATE_IDLE = 0;
+ private static final int STATE_FOCUSING = 1;
+ private static final int STATE_FINISHING = 2;
+ private static final int STATE_PIE = 8;
+
+ private Runnable mDisappear = new Disappear();
+ private Animation.AnimationListener mEndAction = new EndAction();
+ private static final int SCALING_UP_TIME = 600;
+ private static final int SCALING_DOWN_TIME = 100;
+ private static final int DISAPPEAR_TIMEOUT = 200;
+ private static final int DIAL_HORIZONTAL = 157;
+
+ private static final long PIE_FADE_IN_DURATION = 200;
+ private static final long PIE_XFADE_DURATION = 200;
+ private static final long PIE_SELECT_FADE_DURATION = 300;
+
+ private static final int MSG_OPEN = 0;
+ private static final int MSG_CLOSE = 1;
+ private static final float PIE_SWEEP = (float) (Math.PI * 2 / 3);
+ // geometry
+ private Point mCenter;
+ private int mRadius;
+ private int mRadiusInc;
+
+ // the detection if touch is inside a slice is offset
+ // inbounds by this amount to allow the selection to show before the
+ // finger covers it
+ private int mTouchOffset;
+
+ private List<PieItem> mItems;
+
+ private PieItem mOpenItem;
+
+ private Paint mSelectedPaint;
+ private Paint mSubPaint;
+
+ // touch handling
+ private PieItem mCurrentItem;
+
+ private Paint mFocusPaint;
+ private int mSuccessColor;
+ private int mFailColor;
+ private int mCircleSize;
+ private int mFocusX;
+ private int mFocusY;
+ private int mCenterX;
+ private int mCenterY;
+
+ private int mDialAngle;
+ private RectF mCircle;
+ private RectF mDial;
+ private Point mPoint1;
+ private Point mPoint2;
+ private int mStartAnimationAngle;
+ private boolean mFocused;
+ private int mInnerOffset;
+ private int mOuterStroke;
+ private int mInnerStroke;
+ private boolean mTapMode;
+ private boolean mBlockFocus;
+ private int mTouchSlopSquared;
+ private Point mDown;
+ private boolean mOpening;
+ private LinearAnimation mXFade;
+ private LinearAnimation mFadeIn;
+ private volatile boolean mFocusCancelled;
+
+ private Handler mHandler = new Handler() {
+ public void handleMessage(Message msg) {
+ switch(msg.what) {
+ case MSG_OPEN:
+ if (mListener != null) {
+ mListener.onPieOpened(mCenter.x, mCenter.y);
+ }
+ break;
+ case MSG_CLOSE:
+ if (mListener != null) {
+ mListener.onPieClosed();
+ }
+ break;
+ }
+ }
+ };
+
+ private PieListener mListener;
+
+ public static interface PieListener {
+ public void onPieOpened(int centerX, int centerY);
+ public void onPieClosed();
+ }
+
+ public void setPieListener(PieListener pl) {
+ mListener = pl;
+ }
+
+ public PieRenderer(Context context) {
+ init(context);
+ }
+
+ private void init(Context ctx) {
+ setVisible(false);
+ mItems = new ArrayList<PieItem>();
+ Resources res = ctx.getResources();
+ mRadius = (int) res.getDimensionPixelSize(R.dimen.pie_radius_start);
+ mCircleSize = mRadius - res.getDimensionPixelSize(R.dimen.focus_radius_offset);
+ mRadiusInc = (int) res.getDimensionPixelSize(R.dimen.pie_radius_increment);
+ mTouchOffset = (int) res.getDimensionPixelSize(R.dimen.pie_touch_offset);
+ mCenter = new Point(0, 0);
+ mSelectedPaint = new Paint();
+ mSelectedPaint.setColor(Color.argb(255, 51, 181, 229));
+ mSelectedPaint.setAntiAlias(true);
+ mSubPaint = new Paint();
+ mSubPaint.setAntiAlias(true);
+ mSubPaint.setColor(Color.argb(200, 250, 230, 128));
+ mFocusPaint = new Paint();
+ mFocusPaint.setAntiAlias(true);
+ mFocusPaint.setColor(Color.WHITE);
+ mFocusPaint.setStyle(Paint.Style.STROKE);
+ mSuccessColor = Color.GREEN;
+ mFailColor = Color.RED;
+ mCircle = new RectF();
+ mDial = new RectF();
+ mPoint1 = new Point();
+ mPoint2 = new Point();
+ mInnerOffset = res.getDimensionPixelSize(R.dimen.focus_inner_offset);
+ mOuterStroke = res.getDimensionPixelSize(R.dimen.focus_outer_stroke);
+ mInnerStroke = res.getDimensionPixelSize(R.dimen.focus_inner_stroke);
+ mState = STATE_IDLE;
+ mBlockFocus = false;
+ mTouchSlopSquared = ViewConfiguration.get(ctx).getScaledTouchSlop();
+ mTouchSlopSquared = mTouchSlopSquared * mTouchSlopSquared;
+ mDown = new Point();
+ }
+
+ public boolean showsItems() {
+ return mTapMode;
+ }
+
+ public void addItem(PieItem item) {
+ // add the item to the pie itself
+ mItems.add(item);
+ }
+
+ public void removeItem(PieItem item) {
+ mItems.remove(item);
+ }
+
+ public void clearItems() {
+ mItems.clear();
+ }
+
+ public void showInCenter() {
+ if ((mState == STATE_PIE) && isVisible()) {
+ mTapMode = false;
+ show(false);
+ } else {
+ if (mState != STATE_IDLE) {
+ cancelFocus();
+ }
+ mState = STATE_PIE;
+ setCenter(mCenterX, mCenterY);
+ mTapMode = true;
+ show(true);
+ }
+ }
+
+ public void hide() {
+ show(false);
+ }
+
+ /**
+ * guaranteed has center set
+ * @param show
+ */
+ private void show(boolean show) {
+ if (show) {
+ mState = STATE_PIE;
+ // ensure clean state
+ mCurrentItem = null;
+ mOpenItem = null;
+ for (PieItem item : mItems) {
+ item.setSelected(false);
+ }
+ layoutPie();
+ fadeIn();
+ } else {
+ mState = STATE_IDLE;
+ mTapMode = false;
+ if (mXFade != null) {
+ mXFade.cancel();
+ }
+ }
+ setVisible(show);
+ mHandler.sendEmptyMessage(show ? MSG_OPEN : MSG_CLOSE);
+ }
+
+ private void fadeIn() {
+ mFadeIn = new LinearAnimation(0, 1);
+ mFadeIn.setDuration(PIE_FADE_IN_DURATION);
+ mFadeIn.setAnimationListener(new AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) {
+ }
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ mFadeIn = null;
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {
+ }
+ });
+ mFadeIn.startNow();
+ mOverlay.startAnimation(mFadeIn);
+ }
+
+ public void setCenter(int x, int y) {
+ mCenter.x = x;
+ mCenter.y = y;
+ // when using the pie menu, align the focus ring
+ alignFocus(x, y);
+ }
+
+ private void layoutPie() {
+ int rgap = 2;
+ int inner = mRadius + rgap;
+ int outer = mRadius + mRadiusInc - rgap;
+ int gap = 1;
+ layoutItems(mItems, (float) (Math.PI / 2), inner, outer, gap);
+ }
+
+ private void layoutItems(List<PieItem> items, float centerAngle, int inner,
+ int outer, int gap) {
+ float emptyangle = PIE_SWEEP / 16;
+ float sweep = (float) (PIE_SWEEP - 2 * emptyangle) / items.size();
+ float angle = centerAngle - PIE_SWEEP / 2 + emptyangle + sweep / 2;
+ // check if we have custom geometry
+ // first item we find triggers custom sweep for all
+ // this allows us to re-use the path
+ for (PieItem item : items) {
+ if (item.getCenter() >= 0) {
+ sweep = item.getSweep();
+ break;
+ }
+ }
+ Path path = makeSlice(getDegrees(0) - gap, getDegrees(sweep) + gap,
+ outer, inner, mCenter);
+ for (PieItem item : items) {
+ // shared between items
+ item.setPath(path);
+ if (item.getCenter() >= 0) {
+ angle = item.getCenter();
+ }
+ int w = item.getIntrinsicWidth();
+ int h = item.getIntrinsicHeight();
+ // move views to outer border
+ int r = inner + (outer - inner) * 2 / 3;
+ int x = (int) (r * Math.cos(angle));
+ int y = mCenter.y - (int) (r * Math.sin(angle)) - h / 2;
+ x = mCenter.x + x - w / 2;
+ item.setBounds(x, y, x + w, y + h);
+ float itemstart = angle - sweep / 2;
+ item.setGeometry(itemstart, sweep, inner, outer);
+ if (item.hasItems()) {
+ layoutItems(item.getItems(), angle, inner,
+ outer + mRadiusInc / 2, gap);
+ }
+ angle += sweep;
+ }
+ }
+
+ private Path makeSlice(float start, float end, int outer, int inner, Point center) {
+ RectF bb =
+ new RectF(center.x - outer, center.y - outer, center.x + outer,
+ center.y + outer);
+ RectF bbi =
+ new RectF(center.x - inner, center.y - inner, center.x + inner,
+ center.y + inner);
+ Path path = new Path();
+ path.arcTo(bb, start, end - start, true);
+ path.arcTo(bbi, end, start - end);
+ path.close();
+ return path;
+ }
+
+ /**
+ * converts a
+ * @param angle from 0..PI to Android degrees (clockwise starting at 3 o'clock)
+ * @return skia angle
+ */
+ private float getDegrees(double angle) {
+ return (float) (360 - 180 * angle / Math.PI);
+ }
+
+ private void startFadeOut() {
+ mOverlay.animate().alpha(0).setListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ deselect();
+ show(false);
+ mOverlay.setAlpha(1);
+ super.onAnimationEnd(animation);
+ }
+ }).setDuration(PIE_SELECT_FADE_DURATION);
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ float alpha = 1;
+ if (mXFade != null) {
+ alpha = mXFade.getValue();
+ } else if (mFadeIn != null) {
+ alpha = mFadeIn.getValue();
+ }
+ int state = canvas.save();
+ if (mFadeIn != null) {
+ float sf = 0.9f + alpha * 0.1f;
+ canvas.scale(sf, sf, mCenter.x, mCenter.y);
+ }
+ drawFocus(canvas);
+ if (mState == STATE_FINISHING) {
+ canvas.restoreToCount(state);
+ return;
+ }
+ if ((mOpenItem == null) || (mXFade != null)) {
+ // draw base menu
+ for (PieItem item : mItems) {
+ drawItem(canvas, item, alpha);
+ }
+ }
+ if (mOpenItem != null) {
+ for (PieItem inner : mOpenItem.getItems()) {
+ drawItem(canvas, inner, (mXFade != null) ? (1 - 0.5f * alpha) : 1);
+ }
+ }
+ canvas.restoreToCount(state);
+ }
+
+ private void drawItem(Canvas canvas, PieItem item, float alpha) {
+ if (mState == STATE_PIE) {
+ if (item.getPath() != null) {
+ if (item.isSelected()) {
+ Paint p = mSelectedPaint;
+ int state = canvas.save();
+ float r = getDegrees(item.getStartAngle());
+ canvas.rotate(r, mCenter.x, mCenter.y);
+ canvas.drawPath(item.getPath(), p);
+ canvas.restoreToCount(state);
+ }
+ alpha = alpha * (item.isEnabled() ? 1 : 0.3f);
+ // draw the item view
+ item.setAlpha(alpha);
+ item.draw(canvas);
+ }
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent evt) {
+ float x = evt.getX();
+ float y = evt.getY();
+ int action = evt.getActionMasked();
+ PointF polar = getPolar(x, y, !(mTapMode));
+ if (MotionEvent.ACTION_DOWN == action) {
+ mDown.x = (int) evt.getX();
+ mDown.y = (int) evt.getY();
+ mOpening = false;
+ if (mTapMode) {
+ PieItem item = findItem(polar);
+ if ((item != null) && (mCurrentItem != item)) {
+ mState = STATE_PIE;
+ onEnter(item);
+ }
+ } else {
+ setCenter((int) x, (int) y);
+ show(true);
+ }
+ return true;
+ } else if (MotionEvent.ACTION_UP == action) {
+ if (isVisible()) {
+ PieItem item = mCurrentItem;
+ if (mTapMode) {
+ item = findItem(polar);
+ if (item != null && mOpening) {
+ mOpening = false;
+ return true;
+ }
+ }
+ if (item == null) {
+ mTapMode = false;
+ show(false);
+ } else if (!mOpening
+ && !item.hasItems()) {
+ item.performClick();
+ startFadeOut();
+ mTapMode = false;
+ }
+ return true;
+ }
+ } else if (MotionEvent.ACTION_CANCEL == action) {
+ if (isVisible() || mTapMode) {
+ show(false);
+ }
+ deselect();
+ return false;
+ } else if (MotionEvent.ACTION_MOVE == action) {
+ if (polar.y < mRadius) {
+ if (mOpenItem != null) {
+ mOpenItem = null;
+ } else {
+ deselect();
+ }
+ return false;
+ }
+ PieItem item = findItem(polar);
+ boolean moved = hasMoved(evt);
+ if ((item != null) && (mCurrentItem != item) && (!mOpening || moved)) {
+ // only select if we didn't just open or have moved past slop
+ mOpening = false;
+ if (moved) {
+ // switch back to swipe mode
+ mTapMode = false;
+ }
+ onEnter(item);
+ }
+ }
+ return false;
+ }
+
+ private boolean hasMoved(MotionEvent e) {
+ return mTouchSlopSquared < (e.getX() - mDown.x) * (e.getX() - mDown.x)
+ + (e.getY() - mDown.y) * (e.getY() - mDown.y);
+ }
+
+ /**
+ * enter a slice for a view
+ * updates model only
+ * @param item
+ */
+ private void onEnter(PieItem item) {
+ if (mCurrentItem != null) {
+ mCurrentItem.setSelected(false);
+ }
+ if (item != null && item.isEnabled()) {
+ item.setSelected(true);
+ mCurrentItem = item;
+ if ((mCurrentItem != mOpenItem) && mCurrentItem.hasItems()) {
+ openCurrentItem();
+ }
+ } else {
+ mCurrentItem = null;
+ }
+ }
+
+ private void deselect() {
+ if (mCurrentItem != null) {
+ mCurrentItem.setSelected(false);
+ }
+ if (mOpenItem != null) {
+ mOpenItem = null;
+ }
+ mCurrentItem = null;
+ }
+
+ private void openCurrentItem() {
+ if ((mCurrentItem != null) && mCurrentItem.hasItems()) {
+ mCurrentItem.setSelected(false);
+ mOpenItem = mCurrentItem;
+ mOpening = true;
+ mXFade = new LinearAnimation(1, 0);
+ mXFade.setDuration(PIE_XFADE_DURATION);
+ mXFade.setAnimationListener(new AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) {
+ }
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ mXFade = null;
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {
+ }
+ });
+ mXFade.startNow();
+ mOverlay.startAnimation(mXFade);
+ }
+ }
+
+ private PointF getPolar(float x, float y, boolean useOffset) {
+ PointF res = new PointF();
+ // get angle and radius from x/y
+ res.x = (float) Math.PI / 2;
+ x = x - mCenter.x;
+ y = mCenter.y - y;
+ res.y = (float) Math.sqrt(x * x + y * y);
+ if (x != 0) {
+ res.x = (float) Math.atan2(y, x);
+ if (res.x < 0) {
+ res.x = (float) (2 * Math.PI + res.x);
+ }
+ }
+ res.y = res.y + (useOffset ? mTouchOffset : 0);
+ return res;
+ }
+
+ /**
+ * @param polar x: angle, y: dist
+ * @return the item at angle/dist or null
+ */
+ private PieItem findItem(PointF polar) {
+ // find the matching item:
+ List<PieItem> items = (mOpenItem != null) ? mOpenItem.getItems() : mItems;
+ for (PieItem item : items) {
+ if (inside(polar, item)) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ private boolean inside(PointF polar, PieItem item) {
+ return (item.getInnerRadius() < polar.y)
+ && (item.getStartAngle() < polar.x)
+ && (item.getStartAngle() + item.getSweep() > polar.x)
+ && (!mTapMode || (item.getOuterRadius() > polar.y));
+ }
+
+ @Override
+ public boolean handlesTouch() {
+ return true;
+ }
+
+ // focus specific code
+
+ public void setBlockFocus(boolean blocked) {
+ mBlockFocus = blocked;
+ if (blocked) {
+ clear();
+ }
+ }
+
+ public void setFocus(int x, int y) {
+ mFocusX = x;
+ mFocusY = y;
+ setCircle(mFocusX, mFocusY);
+ }
+
+ public void alignFocus(int x, int y) {
+ mOverlay.removeCallbacks(mDisappear);
+ mAnimation.cancel();
+ mAnimation.reset();
+ mFocusX = x;
+ mFocusY = y;
+ mDialAngle = DIAL_HORIZONTAL;
+ setCircle(x, y);
+ mFocused = false;
+ }
+
+ public int getSize() {
+ return 2 * mCircleSize;
+ }
+
+ private int getRandomRange() {
+ return (int) (-60 + 120 * Math.random());
+ }
+
+ @Override
+ public void layout(int l, int t, int r, int b) {
+ super.layout(l, t, r, b);
+ mCenterX = (r - l) / 2;
+ mCenterY = (b - t) / 2;
+ mFocusX = mCenterX;
+ mFocusY = mCenterY;
+ setCircle(mFocusX, mFocusY);
+ if (isVisible() && mState == STATE_PIE) {
+ setCenter(mCenterX, mCenterY);
+ layoutPie();
+ }
+ }
+
+ private void setCircle(int cx, int cy) {
+ mCircle.set(cx - mCircleSize, cy - mCircleSize,
+ cx + mCircleSize, cy + mCircleSize);
+ mDial.set(cx - mCircleSize + mInnerOffset, cy - mCircleSize + mInnerOffset,
+ cx + mCircleSize - mInnerOffset, cy + mCircleSize - mInnerOffset);
+ }
+
+ public void drawFocus(Canvas canvas) {
+ if (mBlockFocus) {
+ return;
+ }
+ mFocusPaint.setStrokeWidth(mOuterStroke);
+ canvas.drawCircle((float) mFocusX, (float) mFocusY, (float) mCircleSize, mFocusPaint);
+ if (mState == STATE_PIE) {
+ return;
+ }
+ int color = mFocusPaint.getColor();
+ if (mState == STATE_FINISHING) {
+ mFocusPaint.setColor(mFocused ? mSuccessColor : mFailColor);
+ }
+ mFocusPaint.setStrokeWidth(mInnerStroke);
+ drawLine(canvas, mDialAngle, mFocusPaint);
+ drawLine(canvas, mDialAngle + 45, mFocusPaint);
+ drawLine(canvas, mDialAngle + 180, mFocusPaint);
+ drawLine(canvas, mDialAngle + 225, mFocusPaint);
+ canvas.save();
+ // rotate the arc instead of its offset to better use framework's shape caching
+ canvas.rotate(mDialAngle, mFocusX, mFocusY);
+ canvas.drawArc(mDial, 0, 45, false, mFocusPaint);
+ canvas.drawArc(mDial, 180, 45, false, mFocusPaint);
+ canvas.restore();
+ mFocusPaint.setColor(color);
+ }
+
+ private void drawLine(Canvas canvas, int angle, Paint p) {
+ convertCart(angle, mCircleSize - mInnerOffset, mPoint1);
+ convertCart(angle, mCircleSize - mInnerOffset + mInnerOffset / 3, mPoint2);
+ canvas.drawLine(mPoint1.x + mFocusX, mPoint1.y + mFocusY,
+ mPoint2.x + mFocusX, mPoint2.y + mFocusY, p);
+ }
+
+ private static void convertCart(int angle, int radius, Point out) {
+ double a = 2 * Math.PI * (angle % 360) / 360;
+ out.x = (int) (radius * Math.cos(a) + 0.5);
+ out.y = (int) (radius * Math.sin(a) + 0.5);
+ }
+
+ @Override
+ public void showStart() {
+ if (mState == STATE_PIE) {
+ return;
+ }
+ cancelFocus();
+ mStartAnimationAngle = 67;
+ int range = getRandomRange();
+ startAnimation(SCALING_UP_TIME,
+ false, mStartAnimationAngle, mStartAnimationAngle + range);
+ mState = STATE_FOCUSING;
+ }
+
+ @Override
+ public void showSuccess(boolean timeout) {
+ if (mState == STATE_FOCUSING) {
+ startAnimation(SCALING_DOWN_TIME,
+ timeout, mStartAnimationAngle);
+ mState = STATE_FINISHING;
+ mFocused = true;
+ }
+ }
+
+ @Override
+ public void showFail(boolean timeout) {
+ if (mState == STATE_FOCUSING) {
+ startAnimation(SCALING_DOWN_TIME,
+ timeout, mStartAnimationAngle);
+ mState = STATE_FINISHING;
+ mFocused = false;
+ }
+ }
+
+ private void cancelFocus() {
+ mFocusCancelled = true;
+ mOverlay.removeCallbacks(mDisappear);
+ if (mAnimation != null) {
+ mAnimation.cancel();
+ }
+ mFocusCancelled = false;
+ mFocused = false;
+ mState = STATE_IDLE;
+ }
+
+ @Override
+ public void clear() {
+ if (mState == STATE_PIE) {
+ return;
+ }
+ cancelFocus();
+ mOverlay.post(mDisappear);
+ }
+
+ private void startAnimation(long duration, boolean timeout,
+ float toScale) {
+ startAnimation(duration, timeout, mDialAngle,
+ toScale);
+ }
+
+ private void startAnimation(long duration, boolean timeout,
+ float fromScale, float toScale) {
+ setVisible(true);
+ mAnimation.reset();
+ mAnimation.setDuration(duration);
+ mAnimation.setScale(fromScale, toScale);
+ mAnimation.setAnimationListener(timeout ? mEndAction : null);
+ mOverlay.startAnimation(mAnimation);
+ update();
+ }
+
+ private class EndAction implements Animation.AnimationListener {
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ // Keep the focus indicator for some time.
+ if (!mFocusCancelled) {
+ mOverlay.postDelayed(mDisappear, DISAPPEAR_TIMEOUT);
+ }
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {
+ }
+
+ @Override
+ public void onAnimationStart(Animation animation) {
+ }
+ }
+
+ private class Disappear implements Runnable {
+ @Override
+ public void run() {
+ if (mState == STATE_PIE) {
+ return;
+ }
+ setVisible(false);
+ mFocusX = mCenterX;
+ mFocusY = mCenterY;
+ mState = STATE_IDLE;
+ setCircle(mFocusX, mFocusY);
+ mFocused = false;
+ }
+ }
+
+ private class ScaleAnimation extends Animation {
+ private float mFrom = 1f;
+ private float mTo = 1f;
+
+ public ScaleAnimation() {
+ setFillAfter(true);
+ }
+
+ public void setScale(float from, float to) {
+ mFrom = from;
+ mTo = to;
+ }
+
+ @Override
+ protected void applyTransformation(float interpolatedTime, Transformation t) {
+ mDialAngle = (int) (mFrom + (mTo - mFrom) * interpolatedTime);
+ }
+ }
+
+
+ private class LinearAnimation extends Animation {
+ private float mFrom;
+ private float mTo;
+ private float mValue;
+
+ public LinearAnimation(float from, float to) {
+ setFillAfter(true);
+ setInterpolator(new LinearInterpolator());
+ mFrom = from;
+ mTo = to;
+ }
+
+ public float getValue() {
+ return mValue;
+ }
+
+ @Override
+ protected void applyTransformation(float interpolatedTime, Transformation t) {
+ mValue = (mFrom + (mTo - mFrom) * interpolatedTime);
+ }
+ }
+
+} \ No newline at end of file
diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt b/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt
new file mode 100644
index 0000000..ed4e783
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt
@@ -0,0 +1,3 @@
+The files in this package were copied from the android-4.4.4_r1 branch of ASOP from the folders
+com/android/camera/ and com/android/camera/ui from files with the same name. Some modifications
+have been made to remove unneeded features and adjust to our needs. \ No newline at end of file
diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java b/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java
new file mode 100644
index 0000000..95cddc4
--- /dev/null
+++ b/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java
@@ -0,0 +1,178 @@
+/*
+ * 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.messaging.ui.mediapicker.camerafocus;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class RenderOverlay extends FrameLayout {
+
+ interface Renderer {
+
+ public boolean handlesTouch();
+ public boolean onTouchEvent(MotionEvent evt);
+ public void setOverlay(RenderOverlay overlay);
+ public void layout(int left, int top, int right, int bottom);
+ public void draw(Canvas canvas);
+
+ }
+
+ private RenderView mRenderView;
+ private List<Renderer> mClients;
+
+ // reverse list of touch clients
+ private List<Renderer> mTouchClients;
+ private int[] mPosition = new int[2];
+
+ public RenderOverlay(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mRenderView = new RenderView(context);
+ addView(mRenderView, new LayoutParams(LayoutParams.MATCH_PARENT,
+ LayoutParams.MATCH_PARENT));
+ mClients = new ArrayList<Renderer>(10);
+ mTouchClients = new ArrayList<Renderer>(10);
+ setWillNotDraw(false);
+
+ addRenderer(new PieRenderer(context));
+ }
+
+ public PieRenderer getPieRenderer() {
+ for (Renderer renderer : mClients) {
+ if (renderer instanceof PieRenderer) {
+ return (PieRenderer) renderer;
+ }
+ }
+ return null;
+ }
+
+ public void addRenderer(Renderer renderer) {
+ mClients.add(renderer);
+ renderer.setOverlay(this);
+ if (renderer.handlesTouch()) {
+ mTouchClients.add(0, renderer);
+ }
+ renderer.layout(getLeft(), getTop(), getRight(), getBottom());
+ }
+
+ public void addRenderer(int pos, Renderer renderer) {
+ mClients.add(pos, renderer);
+ renderer.setOverlay(this);
+ renderer.layout(getLeft(), getTop(), getRight(), getBottom());
+ }
+
+ public void remove(Renderer renderer) {
+ mClients.remove(renderer);
+ renderer.setOverlay(null);
+ }
+
+ public int getClientSize() {
+ return mClients.size();
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent m) {
+ return false;
+ }
+
+ public boolean directDispatchTouch(MotionEvent m, Renderer target) {
+ mRenderView.setTouchTarget(target);
+ boolean res = super.dispatchTouchEvent(m);
+ mRenderView.setTouchTarget(null);
+ return res;
+ }
+
+ private void adjustPosition() {
+ getLocationInWindow(mPosition);
+ }
+
+ public int getWindowPositionX() {
+ return mPosition[0];
+ }
+
+ public int getWindowPositionY() {
+ return mPosition[1];
+ }
+
+ public void update() {
+ mRenderView.invalidate();
+ }
+
+ private class RenderView extends View {
+
+ private Renderer mTouchTarget;
+
+ public RenderView(Context context) {
+ super(context);
+ setWillNotDraw(false);
+ }
+
+ public void setTouchTarget(Renderer target) {
+ mTouchTarget = target;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent evt) {
+ if (mTouchTarget != null) {
+ return mTouchTarget.onTouchEvent(evt);
+ }
+ if (mTouchClients != null) {
+ boolean res = false;
+ for (Renderer client : mTouchClients) {
+ res |= client.onTouchEvent(evt);
+ }
+ return res;
+ }
+ return false;
+ }
+
+ @Override
+ public void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ adjustPosition();
+ super.onLayout(changed, left, top, right, bottom);
+ if (mClients == null) {
+ return;
+ }
+ for (Renderer renderer : mClients) {
+ renderer.layout(left, top, right, bottom);
+ }
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+ if (mClients == null) {
+ return;
+ }
+ boolean redraw = false;
+ for (Renderer renderer : mClients) {
+ renderer.draw(canvas);
+ redraw = redraw || ((OverlayRenderer) renderer).isVisible();
+ }
+ if (redraw) {
+ invalidate();
+ }
+ }
+ }
+
+} \ No newline at end of file
diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java
new file mode 100644
index 0000000..d139a38
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.ui.photoviewer;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.support.rastermill.FrameSequenceDrawable;
+import android.support.v4.content.AsyncTaskLoader;
+
+import com.android.ex.photo.PhotoViewController;
+import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface;
+import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult;
+import com.android.messaging.datamodel.media.ImageRequestDescriptor;
+import com.android.messaging.datamodel.media.ImageResource;
+import com.android.messaging.datamodel.media.MediaRequest;
+import com.android.messaging.datamodel.media.MediaResourceManager;
+import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
+import com.android.messaging.util.ImageUtils;
+
+/**
+ * Loader for the bitmap of a photo.
+ */
+public class BuglePhotoBitmapLoader extends AsyncTaskLoader<BitmapResult>
+ implements PhotoBitmapLoaderInterface {
+ private String mPhotoUri;
+ private ImageResource mImageResource;
+ // The drawable that is currently "in use" and being presented to the user. This drawable
+ // should never exist without the image resource backing it.
+ private Drawable mDrawable;
+
+ public BuglePhotoBitmapLoader(Context context, String photoUri) {
+ super(context);
+ mPhotoUri = photoUri;
+ }
+
+ @Override
+ public void setPhotoUri(String photoUri) {
+ mPhotoUri = photoUri;
+ }
+
+ @Override
+ public BitmapResult loadInBackground() {
+ final BitmapResult result = new BitmapResult();
+ final Context context = getContext();
+ if (context != null && mPhotoUri != null) {
+ final ImageRequestDescriptor descriptor =
+ new UriImageRequestDescriptor(Uri.parse(mPhotoUri),
+ PhotoViewController.sMaxPhotoSize, PhotoViewController.sMaxPhotoSize,
+ true /* allowCompression */, false /* isStatic */,
+ false /* cropToCircle */,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ final MediaRequest<ImageResource> imageRequest =
+ descriptor.buildSyncMediaRequest(context);
+ final ImageResource imageResource =
+ MediaResourceManager.get().requestMediaResourceSync(imageRequest);
+ if (imageResource != null) {
+ setImageResource(imageResource);
+ result.status = BitmapResult.STATUS_SUCCESS;
+ result.drawable = mImageResource.getDrawable(context.getResources());
+ } else {
+ releaseImageResource();
+ result.status = BitmapResult.STATUS_EXCEPTION;
+ }
+ } else {
+ result.status = BitmapResult.STATUS_EXCEPTION;
+ }
+ return result;
+ }
+
+ /**
+ * Called when there is new data to deliver to the client. The super class will take care of
+ * delivering it; the implementation here just adds a little more logic.
+ */
+ @Override
+ public void deliverResult(BitmapResult result) {
+ final Drawable drawable = result != null ? result.drawable : null;
+ if (isReset()) {
+ // An async query came in while the loader is stopped. We don't need the result.
+ releaseDrawable(drawable);
+ return;
+ }
+
+ // We are now going to display this drawable so set to mDrawable
+ mDrawable = drawable;
+
+ if (isStarted()) {
+ // If the Loader is currently started, we can immediately deliver its results.
+ super.deliverResult(result);
+ }
+ }
+
+ /**
+ * Handles a request to start the Loader.
+ */
+ @Override
+ protected void onStartLoading() {
+ if (mDrawable != null) {
+ // If we currently have a result available, deliver it
+ // immediately.
+ final BitmapResult result = new BitmapResult();
+ result.status = BitmapResult.STATUS_SUCCESS;
+ result.drawable = mDrawable;
+ deliverResult(result);
+ }
+
+ if (takeContentChanged() || (mImageResource == null)) {
+ // If the data has changed since the last time it was loaded
+ // or is not currently available, start a load.
+ forceLoad();
+ }
+ }
+
+ /**
+ * Handles a request to stop the Loader.
+ */
+ @Override
+ protected void onStopLoading() {
+ // Attempt to cancel the current load task if possible.
+ cancelLoad();
+ }
+
+ /**
+ * Handles a request to cancel a load.
+ */
+ @Override
+ public void onCanceled(BitmapResult result) {
+ super.onCanceled(result);
+
+ // At this point we can release the resources associated with 'drawable' if needed.
+ if (result != null) {
+ releaseDrawable(result.drawable);
+ }
+ }
+
+ /**
+ * Handles a request to completely reset the Loader.
+ */
+ @Override
+ protected void onReset() {
+ super.onReset();
+
+ // Ensure the loader is stopped
+ onStopLoading();
+
+ releaseImageResource();
+ }
+
+ private void releaseDrawable(Drawable drawable) {
+ if (drawable != null && drawable instanceof FrameSequenceDrawable
+ && !((FrameSequenceDrawable) drawable).isDestroyed()) {
+ ((FrameSequenceDrawable) drawable).destroy();
+ }
+
+ }
+
+ private void setImageResource(final ImageResource resource) {
+ if (mImageResource != resource) {
+ // Clear out any information for what is currently used
+ releaseImageResource();
+ mImageResource = resource;
+ // No need to add ref since a ref is already reserved as a result of
+ // requestMediaResourceSync.
+ }
+ }
+
+ private void releaseImageResource() {
+ // If we are getting rid of the imageResource backing the drawable, we must also
+ // destroy the drawable before releasing it.
+ releaseDrawable(mDrawable);
+ mDrawable = null;
+
+ if (mImageResource != null) {
+ mImageResource.release();
+ }
+ mImageResource = null;
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java
new file mode 100644
index 0000000..52498c7
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.ui.photoviewer;
+
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.support.v4.app.FragmentManager;
+
+import com.android.ex.photo.adapters.PhotoPagerAdapter;
+import com.android.ex.photo.fragments.PhotoViewFragment;
+
+public class BuglePhotoPageAdapter extends PhotoPagerAdapter {
+
+ public BuglePhotoPageAdapter(Context context, FragmentManager fm, Cursor c, float maxScale,
+ boolean thumbsFullScreen) {
+ super(context, fm, c, maxScale, thumbsFullScreen);
+ }
+
+ @Override
+ protected PhotoViewFragment createPhotoViewFragment(Intent intent, int position,
+ boolean onlyShowSpinner) {
+ return BuglePhotoViewFragment.newInstance(intent, position, onlyShowSpinner);
+ }
+}
diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java
new file mode 100644
index 0000000..1924a96
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java
@@ -0,0 +1,31 @@
+/*
+ * 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.messaging.ui.photoviewer;
+
+import com.android.ex.photo.PhotoViewActivity;
+import com.android.ex.photo.PhotoViewController;
+
+/**
+ * Activity to display the conversation images in full-screen. Most of the customization is in
+ * {@link BuglePhotoViewController}.
+ */
+public class BuglePhotoViewActivity extends PhotoViewActivity {
+ @Override
+ public PhotoViewController createController() {
+ return new BuglePhotoViewController(this);
+ }
+}
diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java
new file mode 100644
index 0000000..eb39886
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.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.messaging.ui.photoviewer;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.content.Loader;
+import android.text.TextUtils;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.ShareActionProvider;
+import android.widget.Toast;
+
+import com.android.ex.photo.PhotoViewController;
+import com.android.ex.photo.adapters.PhotoPagerAdapter;
+import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.ConversationImagePartsView.PhotoViewQuery;
+import com.android.messaging.datamodel.MediaScratchFileProvider;
+import com.android.messaging.ui.conversation.ConversationFragment;
+import com.android.messaging.util.Dates;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+
+/**
+ * Customizations for the photoviewer to display conversation images in full screen.
+ */
+public class BuglePhotoViewController extends PhotoViewController {
+ private ShareActionProvider mShareActionProvider;
+ private MenuItem mShareItem;
+ private MenuItem mSaveItem;
+
+ public BuglePhotoViewController(final ActivityInterface activity) {
+ super(activity);
+ }
+
+ @Override
+ public Loader<BitmapResult> onCreateBitmapLoader(
+ final int id, final Bundle args, final String uri) {
+ switch (id) {
+ case BITMAP_LOADER_AVATAR:
+ case BITMAP_LOADER_THUMBNAIL:
+ case BITMAP_LOADER_PHOTO:
+ return new BuglePhotoBitmapLoader(getActivity().getContext(), uri);
+ default:
+ LogUtil.e(LogUtil.BUGLE_TAG,
+ "Photoviewer unable to open bitmap loader with unknown id: " + id);
+ return null;
+ }
+ }
+
+ @Override
+ public void updateActionBar() {
+ final Cursor cursor = getCursorAtProperPosition();
+
+ if (mSaveItem == null || cursor == null) {
+ // Load not finished, called from framework code before ready
+ return;
+ }
+ // Show the name as the title
+ mActionBarTitle = cursor.getString(PhotoViewQuery.INDEX_SENDER_FULL_NAME);
+ if (TextUtils.isEmpty(mActionBarTitle)) {
+ // If the name is not known, fall back to the phone number
+ mActionBarTitle = cursor.getString(PhotoViewQuery.INDEX_DISPLAY_DESTINATION);
+ }
+
+ // Show the timestamp as the subtitle
+ final long receivedTimestamp = cursor.getLong(PhotoViewQuery.INDEX_RECEIVED_TIMESTAMP);
+ mActionBarSubtitle = Dates.getMessageTimeString(receivedTimestamp).toString();
+
+ setActionBarTitles(getActivity().getActionBarInterface());
+ mSaveItem.setVisible(!isTempFile());
+
+ updateShareActionProvider();
+ }
+
+ private void updateShareActionProvider() {
+ final PhotoPagerAdapter adapter = getAdapter();
+ final Cursor cursor = getCursorAtProperPosition();
+ if (mShareActionProvider == null || mShareItem == null || adapter == null ||
+ cursor == null) {
+ // Not enough stuff loaded to update the share action
+ return;
+ }
+ final String photoUri = adapter.getPhotoUri(cursor);
+ if (isTempFile()) {
+ mShareItem.setVisible(false);
+ return;
+ }
+ final String contentType = adapter.getContentType(cursor);
+
+ final Intent shareIntent = new Intent();
+ shareIntent.setAction(Intent.ACTION_SEND);
+ shareIntent.setType(contentType);
+ shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(photoUri));
+ mShareActionProvider.setShareIntent(shareIntent);
+ mShareItem.setVisible(true);
+ }
+
+ /**
+ * Checks whether the current photo is a temp file. A temp file can be deleted at any time, so
+ * we need to disable share and save options because the file may no longer be there.
+ */
+ private boolean isTempFile() {
+ final Cursor cursor = getCursorAtProperPosition();
+ final Uri photoUri = Uri.parse(getAdapter().getPhotoUri(cursor));
+ return MediaScratchFileProvider.isMediaScratchSpaceUri(photoUri);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ ((Activity) getActivity()).getMenuInflater().inflate(R.menu.photo_view_menu, menu);
+
+ // Get the ShareActionProvider
+ mShareItem = menu.findItem(R.id.action_share);
+ mShareActionProvider = (ShareActionProvider) mShareItem.getActionProvider();
+ updateShareActionProvider();
+
+ mSaveItem = menu.findItem(R.id.action_save);
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(final Menu menu) {
+ return !mIsEmpty;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ if (item.getItemId() == R.id.action_save) {
+ if (OsUtil.hasStoragePermission()) {
+ final PhotoPagerAdapter adapter = getAdapter();
+ final Cursor cursor = getCursorAtProperPosition();
+ if (cursor == null) {
+ final Context context = getActivity().getContext();
+ final String error = context.getResources().getQuantityString(
+ R.plurals.attachment_save_error, 1, 1);
+ Toast.makeText(context, error, Toast.LENGTH_SHORT).show();
+ return true;
+ }
+ final String photoUri = adapter.getPhotoUri(cursor);
+ new ConversationFragment.SaveAttachmentTask(((Activity) getActivity()),
+ Uri.parse(photoUri), adapter.getContentType(cursor)).executeOnThreadPool();
+ } else {
+ ((Activity)getActivity()).requestPermissions(
+ new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 0);
+ }
+ return true;
+ } else {
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ public PhotoPagerAdapter createPhotoPagerAdapter(final Context context,
+ final FragmentManager fm, final Cursor c, final float maxScale) {
+ return new BuglePhotoPageAdapter(context, fm, c, maxScale, mDisplayThumbsFullScreen);
+ }
+}
diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java
new file mode 100644
index 0000000..698c510
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java
@@ -0,0 +1,89 @@
+/*
+ * 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.messaging.ui.photoviewer;
+
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.support.rastermill.FrameSequenceDrawable;
+import android.support.v4.content.Loader;
+
+import com.android.ex.photo.PhotoViewCallbacks;
+import com.android.ex.photo.fragments.PhotoViewFragment;
+import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult;
+
+public class BuglePhotoViewFragment extends PhotoViewFragment {
+
+ /** Public no-arg constructor for allowing the framework to handle orientation changes */
+ public BuglePhotoViewFragment() {
+ // Do nothing.
+ }
+
+ public static PhotoViewFragment newInstance(Intent intent, int position,
+ boolean onlyShowSpinner) {
+ final PhotoViewFragment f = new BuglePhotoViewFragment();
+ initializeArguments(intent, position, onlyShowSpinner, f);
+ return f;
+ }
+
+ @Override
+ public void onLoadFinished(Loader<BitmapResult> loader, BitmapResult result) {
+ super.onLoadFinished(loader, result);
+ // Need to check for the first time when we load the photos
+ if (PhotoViewCallbacks.BITMAP_LOADER_PHOTO == loader.getId()
+ && result.status == BitmapResult.STATUS_SUCCESS
+ && mCallback.isFragmentActive(this)) {
+ startGif();
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ startGif();
+ }
+
+ @Override
+ public void onPause() {
+ stopGif();
+ super.onPause();
+ }
+
+ @Override
+ public void onViewActivated() {
+ super.onViewActivated();
+ startGif();
+ }
+
+ @Override
+ public void resetViews() {
+ super.resetViews();
+ stopGif();
+ }
+
+ private void stopGif() {
+ final Drawable drawable = getDrawable();
+ if (drawable != null && drawable instanceof FrameSequenceDrawable) {
+ ((FrameSequenceDrawable) drawable).stop();
+ }
+ }
+
+ private void startGif() {
+ final Drawable drawable = getDrawable();
+ if (drawable != null && drawable instanceof FrameSequenceDrawable) {
+ ((FrameSequenceDrawable) drawable).start();
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/AccessibilityUtil.java b/src/com/android/messaging/util/AccessibilityUtil.java
new file mode 100644
index 0000000..f6c64a9
--- /dev/null
+++ b/src/com/android/messaging/util/AccessibilityUtil.java
@@ -0,0 +1,170 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.support.v4.view.accessibility.AccessibilityEventCompat;
+import android.support.v4.view.accessibility.AccessibilityRecordCompat;
+import android.text.TextUtils;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityManager;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+
+import javax.annotation.Nullable;
+
+public class AccessibilityUtil {
+ public static String sContentDescriptionDivider;
+
+ public static boolean isTouchExplorationEnabled(final Context context) {
+ final AccessibilityManager accessibilityManager = (AccessibilityManager)
+ context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+ return accessibilityManager.isTouchExplorationEnabled();
+ }
+
+ public static StringBuilder appendContentDescription(final Context context,
+ final StringBuilder contentDescription, final String val) {
+ if (sContentDescriptionDivider == null) {
+ sContentDescriptionDivider =
+ context.getResources().getString(R.string.enumeration_comma);
+ }
+ if (contentDescription.length() != 0) {
+ contentDescription.append(sContentDescriptionDivider);
+ }
+ contentDescription.append(val);
+ return contentDescription;
+ }
+
+ public static void announceForAccessibilityCompat(
+ final View view, @Nullable final AccessibilityManager accessibilityManager,
+ final int textResourceId) {
+ final String text = Factory.get().getApplicationContext().getResources().getString(
+ textResourceId);
+ announceForAccessibilityCompat(view, accessibilityManager, text);
+ }
+
+ public static void announceForAccessibilityCompat(
+ final View view, @Nullable AccessibilityManager accessibilityManager,
+ final CharSequence text) {
+ final Context context = view.getContext().getApplicationContext();
+ if (accessibilityManager == null) {
+ accessibilityManager = (AccessibilityManager) context.getSystemService(
+ Context.ACCESSIBILITY_SERVICE);
+ }
+
+ if (!accessibilityManager.isEnabled()) {
+ return;
+ }
+
+ // Jelly Bean added support for speaking text verbatim
+ final int eventType = OsUtil.isAtLeastJB() ? AccessibilityEvent.TYPE_ANNOUNCEMENT
+ : AccessibilityEvent.TYPE_VIEW_FOCUSED;
+
+ // Construct an accessibility event with the minimum recommended
+ // attributes. An event without a class name or package may be dropped.
+ final AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
+ event.getText().add(text);
+ event.setEnabled(view.isEnabled());
+ event.setClassName(view.getClass().getName());
+ event.setPackageName(context.getPackageName());
+
+ // JellyBean MR1 requires a source view to set the window ID.
+ final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event);
+ record.setSource(view);
+
+ // Sends the event directly through the accessibility manager. If we only supported SDK 14+
+ // we could have done:
+ // getParent().requestSendAccessibilityEvent(this, event);
+ accessibilityManager.sendAccessibilityEvent(event);
+ }
+
+ /**
+ * Check to see if the current layout is Right-to-Left. This check is only supported for
+ * API 17+.
+ * For earlier versions, this method will just return false.
+ * @return boolean Boolean indicating whether the currently locale is RTL.
+ */
+ public static boolean isLayoutRtl(final View view) {
+ if (OsUtil.isAtLeastJB_MR1()) {
+ return View.LAYOUT_DIRECTION_RTL == view.getLayoutDirection();
+ } else {
+ return false;
+ }
+ }
+
+ public static String getVocalizedPhoneNumber(final Resources res, final String phoneNumber) {
+ if (TextUtils.isEmpty(phoneNumber)) {
+ return "";
+ }
+ final StringBuilder vocalizedPhoneNumber = new StringBuilder();
+ for (final char c : phoneNumber.toCharArray()) {
+ getVocalizedNumber(res, c, vocalizedPhoneNumber);
+ }
+ return vocalizedPhoneNumber.toString();
+ }
+
+ public static void getVocalizedNumber(final Resources res, final char c,
+ final StringBuilder builder) {
+ switch (c) {
+ case '0':
+ builder.append(res.getString(R.string.content_description_for_number_zero));
+ builder.append(" ");
+ return;
+ case '1':
+ builder.append(res.getString(R.string.content_description_for_number_one));
+ builder.append(" ");
+ return;
+ case '2':
+ builder.append(res.getString(R.string.content_description_for_number_two));
+ builder.append(" ");
+ return;
+ case '3':
+ builder.append(res.getString(R.string.content_description_for_number_three));
+ builder.append(" ");
+ return;
+ case '4':
+ builder.append(res.getString(R.string.content_description_for_number_four));
+ builder.append(" ");
+ return;
+ case '5':
+ builder.append(res.getString(R.string.content_description_for_number_five));
+ builder.append(" ");
+ return;
+ case '6':
+ builder.append(res.getString(R.string.content_description_for_number_six));
+ builder.append(" ");
+ return;
+ case '7':
+ builder.append(res.getString(R.string.content_description_for_number_seven));
+ builder.append(" ");
+ return;
+ case '8':
+ builder.append(res.getString(R.string.content_description_for_number_eight));
+ builder.append(" ");
+ return;
+ case '9':
+ builder.append(res.getString(R.string.content_description_for_number_nine));
+ builder.append(" ");
+ return;
+ default:
+ builder.append(c);
+ return;
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/Assert.java b/src/com/android/messaging/util/Assert.java
new file mode 100644
index 0000000..437965c
--- /dev/null
+++ b/src/com/android/messaging/util/Assert.java
@@ -0,0 +1,214 @@
+/*
+ * 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.messaging.util;
+
+import android.os.Looper;
+
+import java.util.Arrays;
+
+public final class Assert {
+ public static @interface RunsOnMainThread {}
+ public static @interface DoesNotRunOnMainThread {}
+ public static @interface RunsOnAnyThread {}
+
+ private static final String TEST_THREAD_SUBSTRING = "test";
+
+ private static boolean sIsEngBuild;
+ private static boolean sShouldCrash;
+
+ // Private constructor so no one creates this class.
+ private Assert() {
+ }
+
+ // The proguard rules will strip this method out on user/userdebug builds.
+ // If you change the method signature you MUST edit proguard-release.flags.
+ private static void setIfEngBuild() {
+ sShouldCrash = sIsEngBuild = true;
+ }
+
+ private static void refreshGservices(final BugleGservices gservices) {
+ sShouldCrash = sIsEngBuild;
+ if (!sShouldCrash) {
+ sShouldCrash = gservices.getBoolean(
+ BugleGservicesKeys.ASSERTS_FATAL,
+ BugleGservicesKeys.ASSERTS_FATAL_DEFAULT);
+ }
+ }
+
+ // Static initializer block to find out if we're running an eng or
+ // release build.
+ static {
+ setIfEngBuild();
+ }
+
+ // This is called from FactoryImpl once the Gservices class is initialized.
+ public static void initializeGservices (final BugleGservices gservices) {
+ gservices.registerForChanges(new Runnable() {
+ @Override
+ public void run() {
+ refreshGservices(gservices);
+ }
+ });
+ refreshGservices(gservices);
+ }
+
+ /**
+ * Halt execution if this is not an eng build.
+ * <p>Intended for use in code paths that should be run only for tests and never on
+ * a real build.
+ * <p>Note that this will crash on a user build even though asserts don't normally
+ * crash on a user build.
+ */
+ public static void isEngBuild() {
+ isTrueReleaseCheck(sIsEngBuild);
+ }
+
+ /**
+ * Halt execution if this isn't the case.
+ */
+ public static void isTrue(final boolean condition) {
+ if (!condition) {
+ fail("Expected condition to be true", false);
+ }
+ }
+
+ /**
+ * Halt execution if this isn't the case.
+ */
+ public static void isFalse(final boolean condition) {
+ if (condition) {
+ fail("Expected condition to be false", false);
+ }
+ }
+
+ /**
+ * Halt execution even in release builds if this isn't the case.
+ */
+ public static void isTrueReleaseCheck(final boolean condition) {
+ if (!condition) {
+ fail("Expected condition to be true", true);
+ }
+ }
+
+ public static void equals(final int expected, final int actual) {
+ if (expected != actual) {
+ fail("Expected " + expected + " but got " + actual, false);
+ }
+ }
+
+ public static void equals(final long expected, final long actual) {
+ if (expected != actual) {
+ fail("Expected " + expected + " but got " + actual, false);
+ }
+ }
+
+ public static void equals(final Object expected, final Object actual) {
+ if (expected != actual
+ && (expected == null || actual == null || !expected.equals(actual))) {
+ fail("Expected " + expected + " but got " + actual, false);
+ }
+ }
+
+ public static void oneOf(final int actual, final int ...expected) {
+ for (int value : expected) {
+ if (actual == value) {
+ return;
+ }
+ }
+ fail("Expected value to be one of " + Arrays.toString(expected) + " but was " + actual);
+ }
+
+ public static void inRange(
+ final int val, final int rangeMinInclusive, final int rangeMaxInclusive) {
+ if (val < rangeMinInclusive || val > rangeMaxInclusive) {
+ fail("Expected value in range [" + rangeMinInclusive + ", " +
+ rangeMaxInclusive + "], but was " + val, false);
+ }
+ }
+
+ public static void inRange(
+ final long val, final long rangeMinInclusive, final long rangeMaxInclusive) {
+ if (val < rangeMinInclusive || val > rangeMaxInclusive) {
+ fail("Expected value in range [" + rangeMinInclusive + ", " +
+ rangeMaxInclusive + "], but was " + val, false);
+ }
+ }
+
+ public static void isMainThread() {
+ if (Looper.myLooper() != Looper.getMainLooper()
+ && !Thread.currentThread().getName().contains(TEST_THREAD_SUBSTRING)) {
+ fail("Expected to run on main thread", false);
+ }
+ }
+
+ public static void isNotMainThread() {
+ if (Looper.myLooper() == Looper.getMainLooper()
+ && !Thread.currentThread().getName().contains(TEST_THREAD_SUBSTRING)) {
+ fail("Not expected to run on main thread", false);
+ }
+ }
+
+ /**
+ * Halt execution if the value passed in is not null
+ * @param obj The object to check
+ */
+ public static void isNull(final Object obj) {
+ if (obj != null) {
+ fail("Expected object to be null", false);
+ }
+ }
+
+ /**
+ * Halt execution if the value passed in is not null
+ * @param obj The object to check
+ * @param failureMessage message to print when halting execution
+ */
+ public static void isNull(final Object obj, final String failureMessage) {
+ if (obj != null) {
+ fail(failureMessage, false);
+ }
+ }
+
+ /**
+ * Halt execution if the value passed in is null
+ * @param obj The object to check
+ */
+ public static void notNull(final Object obj) {
+ if (obj == null) {
+ fail("Expected value to be non-null", false);
+ }
+ }
+
+ public static void fail(final String message) {
+ fail("Assert.fail() called: " + message, false);
+ }
+
+ private static void fail(final String message, final boolean crashRelease) {
+ LogUtil.e(LogUtil.BUGLE_TAG, message);
+ if (crashRelease || sShouldCrash) {
+ throw new AssertionError(message);
+ } else {
+ // Find the method whose assertion failed. We're using a depth of 2, because all public
+ // Assert methods delegate to this one (see javadoc on getCaller() for details).
+ StackTraceElement caller = DebugUtils.getCaller(2);
+ if (caller != null) {
+ // This log message can be de-obfuscated by the Proguard retrace tool, just like a
+ // full stack trace from a crash.
+ LogUtil.e(LogUtil.BUGLE_TAG, "\tat " + caller.toString());
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/AvatarUriUtil.java b/src/com/android/messaging/util/AvatarUriUtil.java
new file mode 100644
index 0000000..df7d085
--- /dev/null
+++ b/src/com/android/messaging/util/AvatarUriUtil.java
@@ -0,0 +1,320 @@
+/*
+ * 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.messaging.util;
+
+import android.graphics.Color;
+import android.net.Uri;
+import android.net.Uri.Builder;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+
+import com.android.messaging.datamodel.data.ParticipantData;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A helper utility for creating {@link android.net.Uri}s to describe what avatar to fetch or
+ * generate and will help verify and extract information from avatar {@link android.net.Uri}s.
+ *
+ * There are three types of avatar {@link android.net.Uri}.
+ *
+ * 1) Group Avatars - These are avatars which are used to represent a group conversation. Group
+ * avatars uris are basically multiple avatar uri which can be any of the below types but not
+ * another group avatar. The group avatars can hold anywhere from two to four avatars uri and can
+ * be in any of the following format
+ * messaging://avatar/g?p=<avatarUri>&p=<avatarUri2>
+ * messaging://avatar/g?p=<avatarUri>&p=<avatarUri2>&p=<avatarUri3>
+ * messaging://avatar/g?p=<avatarUri>&p=<avatarUri2>&p=<avatarUri3>&p=<avatarUri4>
+ *
+ * 2) Local Resource - A local resource avatar is use when there is a profile photo for the
+ * participant. This can be any local resource.
+ *
+ * 3) Letter Tile - A letter tile is used when a participant has a name but no profile photo. A
+ * letter tile will contain the first code point of the participant's name and a background color
+ * based on the hash of the participant's full name. Letter tiles will be in the following format.
+ * messaging://avatar/l?n=<fullName>
+ *
+ * 4) Default Avatars - These are avatars are used when the participant has no profile photo or
+ * name. In these cases we use the default person icon with a color background. The color
+ * background is based on a hash of the normalized phone number.
+ *
+ * 5) Default Background Avatars - This is a special case for Default Avatars where we use the
+ * default background color for the default avatar.
+ *
+ * 6) SIM Selector Avatars - These are avatars used in the SIM selector. This may either be a
+ * regular local resource avatar (2) or an avatar with a SIM identifier (i.e. SIM background with
+ * a letter or a slot number).
+ */
+public class AvatarUriUtil {
+ private static final int MAX_GROUP_PARTICIPANTS = 4;
+
+ public static final String TYPE_GROUP_URI = "g";
+ public static final String TYPE_LOCAL_RESOURCE_URI = "r";
+ public static final String TYPE_LETTER_TILE_URI = "l";
+ public static final String TYPE_DEFAULT_URI = "d";
+ public static final String TYPE_DEFAULT_BACKGROUND_URI = "b";
+ public static final String TYPE_SIM_SELECTOR_URI = "s";
+
+ private static final String SCHEME = "messaging";
+ private static final String AUTHORITY = "avatar";
+ private static final String PARAM_NAME = "n";
+ private static final String PARAM_PRIMARY_URI = "m";
+ private static final String PARAM_FALLBACK_URI = "f";
+ private static final String PARAM_PARTICIPANT = "p";
+ private static final String PARAM_IDENTIFIER = "i";
+ private static final String PARAM_SIM_COLOR = "c";
+ private static final String PARAM_SIM_SELECTED = "s";
+ private static final String PARAM_SIM_INCOMING = "g";
+
+ public static final Uri DEFAULT_BACKGROUND_AVATAR = new Uri.Builder().scheme(SCHEME)
+ .authority(AUTHORITY).appendPath(TYPE_DEFAULT_BACKGROUND_URI).build();
+
+ private static final Uri BLANK_SIM_INDICATOR_INCOMING_URI = createSimIconUri("",
+ false /* selected */, Color.TRANSPARENT, true /* incoming */);
+ private static final Uri BLANK_SIM_INDICATOR_OUTGOING_URI = createSimIconUri("",
+ false /* selected */, Color.TRANSPARENT, false /* incoming */);
+
+ /**
+ * Creates an avatar uri based on a list of ParticipantData. The list of participants may not
+ * be null or empty. Depending on the size of the list either a group avatar uri will be create
+ * or an individual's avatar will be created. This will never return a null uri.
+ */
+ public static Uri createAvatarUri(@NonNull final List<ParticipantData> participants) {
+ Assert.notNull(participants);
+ Assert.isTrue(!participants.isEmpty());
+
+ if (participants.size() == 1) {
+ return createAvatarUri(participants.get(0));
+ }
+
+ final int numParticipants = Math.min(participants.size(), MAX_GROUP_PARTICIPANTS);
+ final ArrayList<Uri> avatarUris = new ArrayList<Uri>(numParticipants);
+ for (int i = 0; i < numParticipants; i++) {
+ avatarUris.add(createAvatarUri(participants.get(i)));
+ }
+ return AvatarUriUtil.joinAvatarUriToGroup(avatarUris);
+ }
+
+ /**
+ * Joins together a list of valid avatar uri into a group uri.The list of participants may not
+ * be null or empty. If a lit of one is given then the first element will be return back
+ * instead of a group avatar uri. All uris in the list must be a valid avatar uri. This will
+ * never return a null uri.
+ */
+ public static Uri joinAvatarUriToGroup(@NonNull final List<Uri> avatarUris) {
+ Assert.notNull(avatarUris);
+ Assert.isTrue(!avatarUris.isEmpty());
+
+ if (avatarUris.size() == 1) {
+ final Uri firstAvatar = avatarUris.get(0);
+ Assert.isTrue(AvatarUriUtil.isAvatarUri(firstAvatar));
+ return firstAvatar;
+ }
+
+ final Builder builder = new Builder();
+ builder.scheme(SCHEME);
+ builder.authority(AUTHORITY);
+ builder.appendPath(TYPE_GROUP_URI);
+ final int numParticipants = Math.min(avatarUris.size(), MAX_GROUP_PARTICIPANTS);
+ for (int i = 0; i < numParticipants; i++) {
+ final Uri uri = avatarUris.get(i);
+ Assert.notNull(uri);
+ Assert.isTrue(UriUtil.isLocalResourceUri(uri) || AvatarUriUtil.isAvatarUri(uri));
+ builder.appendQueryParameter(PARAM_PARTICIPANT, uri.toString());
+ }
+ return builder.build();
+ }
+
+ /**
+ * Creates an avatar uri based on ParticipantData which may not be null and expected to have
+ * profilePhotoUri, fullName and normalizedDestination populated. This will never return a null
+ * uri.
+ */
+ public static Uri createAvatarUri(@NonNull final ParticipantData participant) {
+ Assert.notNull(participant);
+ final String photoUriString = participant.getProfilePhotoUri();
+ final Uri profilePhotoUri = (photoUriString == null) ? null : Uri.parse(photoUriString);
+ final String name = participant.getFullName();
+ final String destination = participant.getNormalizedDestination();
+ final String contactLookupKey = participant.getLookupKey();
+ return createAvatarUri(profilePhotoUri, name, destination, contactLookupKey);
+ }
+
+ /**
+ * Creates an avatar uri based on a the input data.
+ */
+ public static Uri createAvatarUri(final Uri profilePhotoUri, final CharSequence name,
+ final String defaultIdentifier, final String contactLookupKey) {
+ Uri generatedUri;
+ if (!TextUtils.isEmpty(name) && isValidFirstCharacter(name)) {
+ generatedUri = AvatarUriUtil.fromName(name, contactLookupKey);
+ } else {
+ final String identifier = TextUtils.isEmpty(contactLookupKey)
+ ? defaultIdentifier : contactLookupKey;
+ generatedUri = AvatarUriUtil.fromIdentifier(identifier);
+ }
+
+ if (profilePhotoUri != null) {
+ if (UriUtil.isLocalResourceUri(profilePhotoUri)) {
+ return fromLocalResourceWithFallback(profilePhotoUri, generatedUri);
+ } else {
+ return profilePhotoUri;
+ }
+ } else {
+ return generatedUri;
+ }
+ }
+
+ public static boolean isValidFirstCharacter(final CharSequence name) {
+ final char c = name.charAt(0);
+ return c != '+';
+ }
+
+ /**
+ * Creates an avatar URI used for the SIM selector.
+ * @param participantData the self participant data for an <i>active</i> SIM
+ * @param slotIdentifier when null, this will simply use a regular avatar; otherwise, the
+ * first letter of slotIdentifier will be used for the icon.
+ * @param selected is this the currently selected SIM?
+ * @param incoming is this for an incoming message or outgoing message?
+ */
+ public static Uri createAvatarUri(@NonNull final ParticipantData participantData,
+ @Nullable final String slotIdentifier, final boolean selected, final boolean incoming) {
+ Assert.notNull(participantData);
+ Assert.isTrue(participantData.isActiveSubscription());
+ Assert.isTrue(!TextUtils.isEmpty(slotIdentifier) ||
+ !TextUtils.isEmpty(participantData.getProfilePhotoUri()));
+ if (TextUtils.isEmpty(slotIdentifier)) {
+ return createAvatarUri(participantData);
+ }
+
+ return createSimIconUri(slotIdentifier, selected, participantData.getSubscriptionColor(),
+ incoming);
+ }
+
+ private static Uri createSimIconUri(final String slotIdentifier, final boolean selected,
+ final int subColor, final boolean incoming) {
+ final Builder builder = new Builder();
+ builder.scheme(SCHEME);
+ builder.authority(AUTHORITY);
+ builder.appendPath(TYPE_SIM_SELECTOR_URI);
+ builder.appendQueryParameter(PARAM_IDENTIFIER, slotIdentifier);
+ builder.appendQueryParameter(PARAM_SIM_COLOR, String.valueOf(subColor));
+ builder.appendQueryParameter(PARAM_SIM_SELECTED, String.valueOf(selected));
+ builder.appendQueryParameter(PARAM_SIM_INCOMING, String.valueOf(incoming));
+ return builder.build();
+ }
+
+ public static Uri getBlankSimIndicatorUri(final boolean incoming) {
+ return incoming ? BLANK_SIM_INDICATOR_INCOMING_URI : BLANK_SIM_INDICATOR_OUTGOING_URI;
+ }
+
+ /**
+ * Creates an avatar uri from the given local resource Uri, followed by a fallback Uri in case
+ * the local resource one could not be loaded.
+ */
+ private static Uri fromLocalResourceWithFallback(@NonNull final Uri profilePhotoUri,
+ @NonNull Uri fallbackUri) {
+ Assert.notNull(profilePhotoUri);
+ Assert.notNull(fallbackUri);
+ final Builder builder = new Builder();
+ builder.scheme(SCHEME);
+ builder.authority(AUTHORITY);
+ builder.appendPath(TYPE_LOCAL_RESOURCE_URI);
+ builder.appendQueryParameter(PARAM_PRIMARY_URI, profilePhotoUri.toString());
+ builder.appendQueryParameter(PARAM_FALLBACK_URI, fallbackUri.toString());
+ return builder.build();
+ }
+
+ private static Uri fromName(@NonNull final CharSequence name, final String contactLookupKey) {
+ Assert.notNull(name);
+ final Builder builder = new Builder();
+ builder.scheme(SCHEME);
+ builder.authority(AUTHORITY);
+ builder.appendPath(TYPE_LETTER_TILE_URI);
+ final String nameString = String.valueOf(name);
+ builder.appendQueryParameter(PARAM_NAME, nameString);
+ final String identifier =
+ TextUtils.isEmpty(contactLookupKey) ? nameString : contactLookupKey;
+ builder.appendQueryParameter(PARAM_IDENTIFIER, identifier);
+ return builder.build();
+ }
+
+ private static Uri fromIdentifier(@NonNull final String identifier) {
+ final Builder builder = new Builder();
+ builder.scheme(SCHEME);
+ builder.authority(AUTHORITY);
+ builder.appendPath(TYPE_DEFAULT_URI);
+ builder.appendQueryParameter(PARAM_IDENTIFIER, identifier);
+ return builder.build();
+ }
+
+ public static boolean isAvatarUri(@NonNull final Uri uri) {
+ Assert.notNull(uri);
+ return uri != null && TextUtils.equals(SCHEME, uri.getScheme()) &&
+ TextUtils.equals(AUTHORITY, uri.getAuthority());
+ }
+
+ public static String getAvatarType(@NonNull final Uri uri) {
+ Assert.notNull(uri);
+ final List<String> path = uri.getPathSegments();
+ return path.isEmpty() ? null : path.get(0);
+ }
+
+ public static String getIdentifier(@NonNull final Uri uri) {
+ Assert.notNull(uri);
+ return uri.getQueryParameter(PARAM_IDENTIFIER);
+ }
+
+ public static String getName(@NonNull final Uri uri) {
+ Assert.notNull(uri);
+ return uri.getQueryParameter(PARAM_NAME);
+ }
+
+ public static List<String> getGroupParticipantUris(@NonNull final Uri uri) {
+ Assert.notNull(uri);
+ return uri.getQueryParameters(PARAM_PARTICIPANT);
+ }
+
+ public static int getSimColor(@NonNull final Uri uri) {
+ Assert.notNull(uri);
+ return Integer.valueOf(uri.getQueryParameter(PARAM_SIM_COLOR));
+ }
+
+ public static boolean getSimSelected(@NonNull final Uri uri) {
+ Assert.notNull(uri);
+ return Boolean.valueOf(uri.getQueryParameter(PARAM_SIM_SELECTED));
+ }
+
+ public static boolean getSimIncoming(@NonNull final Uri uri) {
+ Assert.notNull(uri);
+ return Boolean.valueOf(uri.getQueryParameter(PARAM_SIM_INCOMING));
+ }
+
+ public static Uri getPrimaryUri(@NonNull final Uri uri) {
+ Assert.notNull(uri);
+ final String primaryUriString = uri.getQueryParameter(PARAM_PRIMARY_URI);
+ return primaryUriString == null ? null : Uri.parse(primaryUriString);
+ }
+
+ public static Uri getFallbackUri(@NonNull final Uri uri) {
+ Assert.notNull(uri);
+ final String fallbackUriString = uri.getQueryParameter(PARAM_FALLBACK_URI);
+ return fallbackUriString == null ? null : Uri.parse(fallbackUriString);
+ }
+}
diff --git a/src/com/android/messaging/util/BugleActivityUtil.java b/src/com/android/messaging/util/BugleActivityUtil.java
new file mode 100644
index 0000000..7f722fd
--- /dev/null
+++ b/src/com/android/messaging/util/BugleActivityUtil.java
@@ -0,0 +1,88 @@
+/*
+ * 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.messaging.util;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.UserManager;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.ui.conversation.ConversationActivity;
+import com.android.messaging.ui.conversationlist.ConversationListActivity;
+
+/**
+ * Utility class including logic to verify requirements to run Bugle and other activity startup
+ * logic. Called from base Bugle activity classes.
+ */
+public class BugleActivityUtil {
+
+ private static final int REQUEST_GOOGLE_PLAY_SERVICES = 0;
+
+ /**
+ * Determine if the requirements for the app to run are met. Log any Activity startup
+ * analytics.
+ * @param context
+ * @param activity is used to launch an error Dialog if necessary
+ * @return true if resume should continue normally. Returns false if some requirements to run
+ * are not met.
+ */
+ public static boolean onActivityResume(Context context, Activity activity) {
+ DataModel.get().onActivityResume();
+ Factory.get().onActivityResume();
+
+ // Validate all requirements to run are met
+ return checkHasSmsPermissionsForUser(context, activity);
+ }
+
+ /**
+ * Determine if the user doesn't have SMS permissions. This can happen if you are not the phone
+ * owner and the owner has disabled your SMS permissions.
+ * @param context is the Context used to resolve the user permissions
+ * @param activity is the Activity used to launch an error Dialog if necessary
+ * @return true if the user has SMS permissions, otherwise false.
+ */
+ private static boolean checkHasSmsPermissionsForUser(Context context, Activity activity) {
+ if (!OsUtil.isAtLeastL()) {
+ // UserManager.DISALLOW_SMS added in L. No multiuser phones before this
+ return true;
+ }
+ UserManager userManager = (UserManager) context.getSystemService(Context.USER_SERVICE);
+ if (userManager.hasUserRestriction(UserManager.DISALLOW_SMS)) {
+ new AlertDialog.Builder(activity)
+ .setMessage(R.string.requires_sms_permissions_message)
+ .setCancelable(false)
+ .setNegativeButton(R.string.requires_sms_permissions_close_button,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int button) {
+ System.exit(0);
+ }
+ })
+ .show();
+ return false;
+ }
+ return true;
+ }
+}
+
diff --git a/src/com/android/messaging/util/BugleApplicationPrefs.java b/src/com/android/messaging/util/BugleApplicationPrefs.java
new file mode 100644
index 0000000..e9fceb4
--- /dev/null
+++ b/src/com/android/messaging/util/BugleApplicationPrefs.java
@@ -0,0 +1,45 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+
+/**
+ * Provides interface to access application-wide shared preferences. This includes both the user
+ * visible preferences (e.g. the general settings in the settings page), and internal preferences
+ * under {@link BuglePrefsKeys}.
+ */
+public class BugleApplicationPrefs extends BuglePrefsImpl {
+ public BugleApplicationPrefs(Context context) {
+ super(context);
+ }
+
+ @Override
+ public String getSharedPreferencesName() {
+ return SHARED_PREFERENCES_NAME;
+ }
+
+ @Override
+ protected void validateKey(String key) {
+ super.validateKey(key);
+ // Callers shouldn't try to access per-subscription preferences from this class
+ Assert.isFalse(key.startsWith(SHARED_PREFERENCES_PER_SUBSCRIPTION_PREFIX));
+ }
+
+ @Override
+ public void onUpgrade(int oldVersion, int newVersion) {
+ }
+}
diff --git a/src/com/android/messaging/util/BugleGservices.java b/src/com/android/messaging/util/BugleGservices.java
new file mode 100644
index 0000000..2e095de
--- /dev/null
+++ b/src/com/android/messaging/util/BugleGservices.java
@@ -0,0 +1,72 @@
+/*
+ * 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.messaging.util;
+
+import com.android.messaging.Factory;
+
+/**
+ * A thin wrapper for getting GServices value. During constructor time a one time background thread
+ * will cache all GServices key with the prefix of "bugle_". All get calls will wait for Gservices
+ * to finish caching the first time. In practice, the background thread will finish before any get
+ * request.
+ */
+public abstract class BugleGservices {
+ static final String BUGLE_GSERVICES_PREFIX = "bugle_";
+
+ public static BugleGservices get() {
+ return Factory.get().getBugleGservices();
+ }
+
+ public abstract void registerForChanges(final Runnable r);
+
+ /**
+ * @param key The key to look up in GServices
+ * @param defaultValue The default value if value in GServices is null or if
+ * NumberFormatException is caught.
+ * @return The corresponding value, or the default value.
+ */
+ public abstract long getLong(final String key, final long defaultValue);
+
+ /**
+ * @param key The key to look up in GServices
+ * @param defaultValue The default value if value in GServices is null or if
+ * NumberFormatException is caught.
+ * @return The corresponding value, or the default value.
+ */
+ public abstract int getInt(final String key, final int defaultValue);
+
+ /**
+ * @param key The key to look up in GServices
+ * @param defaultValue The default value if value in GServices is null.
+ * @return The corresponding value, or the default value.
+ */
+ public abstract boolean getBoolean(final String key, final boolean defaultValue);
+
+ /**
+ * @param key The key to look up in GServices
+ * @param defaultValue The default value if value in GServices is null.
+ * @return The corresponding value, or the default value.
+ */
+ public abstract String getString(final String key, final String defaultValue);
+
+ /**
+ * @param key The key to look up in GServices
+ * @param defaultValue The default value if value in GServices is null.
+ * @return The corresponding value, or the default value.
+ */
+ public abstract float getFloat(final String key, final float defaultValue);
+}
diff --git a/src/com/android/messaging/util/BugleGservicesImpl.java b/src/com/android/messaging/util/BugleGservicesImpl.java
new file mode 100644
index 0000000..5ef0898
--- /dev/null
+++ b/src/com/android/messaging/util/BugleGservicesImpl.java
@@ -0,0 +1,68 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+
+/**
+ * A thin wrapper for getting GServices value.
+ */
+public class BugleGservicesImpl extends BugleGservices {
+ public BugleGservicesImpl(final Context context) {
+ }
+
+ @Override
+ public void registerForChanges(final Runnable r) {
+ }
+
+ /**
+ * Asserts that the key has the expected prefix.
+ */
+ private void assertKeyAndWaitForGservices(final String key) {
+ Assert.isTrue(key.startsWith(BUGLE_GSERVICES_PREFIX));
+ }
+
+ @Override
+ public long getLong(final String key, final long defaultValue) {
+ assertKeyAndWaitForGservices(key);
+ return defaultValue;
+ }
+
+ @Override
+ public int getInt(final String key, final int defaultValue) {
+ assertKeyAndWaitForGservices(key);
+ return defaultValue;
+ }
+
+ @Override
+ public boolean getBoolean(final String key, final boolean defaultValue) {
+ assertKeyAndWaitForGservices(key);
+ return defaultValue;
+ }
+
+ @Override
+ public String getString(final String key, final String defaultValue) {
+ assertKeyAndWaitForGservices(key);
+ return defaultValue;
+ }
+
+ @Override
+ public float getFloat(final String key, final float defaultValue) {
+ assertKeyAndWaitForGservices(key);
+ return defaultValue;
+ }
+}
diff --git a/src/com/android/messaging/util/BugleGservicesKeys.java b/src/com/android/messaging/util/BugleGservicesKeys.java
new file mode 100644
index 0000000..f36dd7f
--- /dev/null
+++ b/src/com/android/messaging/util/BugleGservicesKeys.java
@@ -0,0 +1,298 @@
+/*
+ * 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.messaging.util;
+
+
+/**
+ * List of gservices keys and default values which are in use.
+ */
+public final class BugleGservicesKeys {
+ private BugleGservicesKeys() {} // do not instantiate
+
+ /**
+ * Whether to enable extra debugging features on the client. Default is
+ * {@value #ENABLE_DEBUGGING_FEATURES_DEFAULT}.
+ */
+ public static final String ENABLE_DEBUGGING_FEATURES
+ = "bugle_debugging";
+ public static final boolean ENABLE_DEBUGGING_FEATURES_DEFAULT
+ = false;
+
+ /**
+ * Whether to enable saving extra logs. Default is {@value #ENABLE_LOG_SAVER_DEFAULT}.
+ */
+ public static final String ENABLE_LOG_SAVER = "bugle_logsaver";
+ public static final boolean ENABLE_LOG_SAVER_DEFAULT = false;
+
+ /**
+ * Time in milliseconds of initial (attempt 1) resend backoff for failing messages
+ */
+ public static final String INITIAL_MESSAGE_RESEND_DELAY_MS = "bugle_resend_delay_in_millis";
+ public static final long INITIAL_MESSAGE_RESEND_DELAY_MS_DEFAULT = 5 * 1000L;
+
+ /**
+ * Time in milliseconds of max resend backoff for failing messages
+ */
+ public static final String MAX_MESSAGE_RESEND_DELAY_MS = "bugle_max_resend_delay_in_millis";
+ public static final long MAX_MESSAGE_RESEND_DELAY_MS_DEFAULT = 2 * 60 * 60 * 1000L;
+
+ /**
+ * Time in milliseconds of resend window for unsent messages
+ */
+ public static final String MESSAGE_RESEND_TIMEOUT_MS = "bugle_resend_timeout_in_millis";
+ public static final long MESSAGE_RESEND_TIMEOUT_MS_DEFAULT = 20 * 60 * 1000L;
+
+ /**
+ * Time in milliseconds of download window for new mms notifications
+ */
+ public static final String MESSAGE_DOWNLOAD_TIMEOUT_MS = "bugle_download_timeout_in_millis";
+ public static final long MESSAGE_DOWNLOAD_TIMEOUT_MS_DEFAULT = 20 * 60 * 1000L;
+
+ /**
+ * Time in milliseconds for SMS send timeout
+ */
+ public static final String SMS_SEND_TIMEOUT_IN_MILLIS = "bugle_sms_send_timeout";
+ public static final long SMS_SEND_TIMEOUT_IN_MILLIS_DEFAULT = 5 * 60 * 1000L;
+
+ /**
+ * Keys to control the SMS sync batch size. The batch size is defined by the number
+ * of messages that incur local database change, e.g. importing messages and
+ * deleting messages.
+ *
+ * 1. The minimum size for a batch and
+ * 2. The maximum size for a batch.
+ * The first batch uses the minimum size for probing. Set this to a small number for the
+ * first sync batch to make sure the user sees SMS showing up in conversations quickly
+ * Use these two settings to limit the number of messages to sync in each batch.
+ * The minimum is to make sure we always make progress during sync. The maximum is
+ * to limit the sync batch size within a reasonable range (needs to fit in an intent).
+ * 3. The time limit controls the limit of time duration of a sync batch. We can
+ * not control this directly due to the batching nature of sync. So this provides
+ * heuristics. We may sometime exceeds the limit if our calculation is off due to
+ * whatever reasons. Keeping this low ensures responsiveness of the application.
+ * 4. The limit on number of total messages to scan in one batch.
+ */
+ public static final String SMS_SYNC_BATCH_SIZE_MIN =
+ "bugle_sms_sync_batch_size_min";
+ public static final int SMS_SYNC_BATCH_SIZE_MIN_DEFAULT = 80;
+ public static final String SMS_SYNC_BATCH_SIZE_MAX =
+ "bugle_sms_sync_batch_size_max";
+ public static final int SMS_SYNC_BATCH_SIZE_MAX_DEFAULT = 1000;
+ public static final String SMS_SYNC_BATCH_TIME_LIMIT_MILLIS =
+ "bugle_sms_sync_batch_time_limit";
+ public static final long SMS_SYNC_BATCH_TIME_LIMIT_MILLIS_DEFAULT = 400;
+ public static final String SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN =
+ "bugle_sms_sync_batch_max_messages_to_scan";
+ public static final int SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN_DEFAULT =
+ SMS_SYNC_BATCH_SIZE_MAX_DEFAULT * 4;
+
+ /**
+ * Time in ms for sync to backoff from "now" to the latest message that will be sync'd.
+ *
+ * This controls the best case for how out of date the application will appear to be
+ * when bringing in changes made outside the application. It also represents a buffer
+ * to ensure that sync doesn't trigger based on changes made within the application.
+ */
+ public static final String SMS_SYNC_BACKOFF_TIME_MILLIS =
+ "bugle_sms_sync_backoff_time";
+ public static final long SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT = 5000L;
+
+ /**
+ * Just in case if we fall into a loop of full sync -> still not synchronized -> full sync ...
+ * This forces a backoff time so that we at most do full sync once a while (an hour by default)
+ */
+ public static final String SMS_FULL_SYNC_BACKOFF_TIME_MILLIS =
+ "bugle_sms_full_sync_backoff_time";
+ public static final long SMS_FULL_SYNC_BACKOFF_TIME_MILLIS_DEFAULT = 60 * 60 * 1000;
+
+ /**
+ * Time duration to retain the most recent SMS messages for SMS storage purging
+ *
+ * Format:
+ * <number>(w|m|y)
+ * Examples:
+ * "1y" -- a year
+ * "2w" -- two weeks
+ * "6m" -- six months
+ */
+ public static final String SMS_STORAGE_PURGING_MESSAGE_RETAINING_DURATION =
+ "bugle_sms_storage_purging_message_retaining_duration";
+ public static final String SMS_STORAGE_PURGING_MESSAGE_RETAINING_DURATION_DEFAULT = "1m";
+
+ /**
+ * MMS UA profile url.
+ *
+ * This is used on all Android devices running Hangout, so cannot just host the profile of the
+ * latest and greatest phones. However, if we're on KitKat or below we can't get the phone's
+ * UA profile and thus we need to send them the default url.
+ */
+ public static final String MMS_UA_PROFILE_URL =
+ "bugle_mms_uaprofurl";
+ public static final String MMS_UA_PROFILE_URL_DEFAULT =
+ "http://www.gstatic.com/android/sms/mms_ua_profile.xml";
+
+ /**
+ * MMS apn mmsc
+ */
+ public static final String MMS_MMSC =
+ "bugle_mms_mmsc";
+
+ /**
+ * MMS apn proxy ip address
+ */
+ public static final String MMS_PROXY_ADDRESS =
+ "bugle_mms_proxy_address";
+
+ /**
+ * MMS apn proxy port
+ */
+ public static final String MMS_PROXY_PORT =
+ "bugle_mms_proxy_port";
+
+ /**
+ * List of known SMS system messages that we will ignore (no deliver, no abort) so that the
+ * user doesn't see them and the appropriate app is able to handle them. We are delivering
+ * these as a \n delimited list of patterns, however we should eventually move to storing
+ * them with the per-carrier mms config xml file.
+ */
+ public static final String SMS_IGNORE_MESSAGE_REGEX =
+ "bugle_sms_ignore_message_regex";
+ public static final String SMS_IGNORE_MESSAGE_REGEX_DEFAULT = "";
+
+ /**
+ * When receiving or importing an mms, limit the length of text to this limit. Huge blocks
+ * of text can cause the app to hang/ANR/or crash in native text code..
+ */
+ public static final String MMS_TEXT_LIMIT = "bugle_mms_text_limit";
+ public static final int MMS_TEXT_LIMIT_DEFAULT = 2000;
+
+ /**
+ * Max number of attachments the user may add to a single message.
+ */
+ public static final String MMS_ATTACHMENT_LIMIT = "bugle_mms_attachment_limit";
+ public static final int MMS_ATTACHMENT_LIMIT_DEFAULT = 10;
+
+ /**
+ * The max number of messages to show in a single conversation notification. We always show
+ * the most recent message. If this value is >1, we may also include prior messages as well.
+ */
+ public static final String MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION =
+ "bugle_max_messages_in_conversation_notification";
+ public static final int MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_DEFAULT = 7;
+
+ /**
+ * Time (in seconds) between notification ringing for incoming messages of the same
+ * conversation. We won't ding more often than this value for messages coming in at a high rate.
+ */
+ public static final String NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS
+ = "bugle_notification_time_between_rings_seconds";
+ public static final int NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS_DEFAULT = 10;
+
+ /**
+ * The max number of messages to show in a single conversation notification, when a wearable
+ * device (i.e. smartwatch) is paired with the phone. Watches have a different UX model and
+ * less screen real estate, so we may want to optimize for that case. Note that if a wearable
+ * is paired, this value will apply to notifications as shown both on the watch and the phone.
+ */
+ public static final String MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE =
+ "bugle_max_messages_in_conversation_notification_with_wearable";
+ public static final int MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE_DEFAULT = 1;
+
+ /**
+ * Regular expression to match against query. If it matches then display
+ * the query plan for this query.
+ */
+ public static final String EXPLAIN_QUERY_PLAN_REGEXP = "bugle_query_plan_regexp";
+
+ /**
+ * Whether asserts are fatal on user/userdebug builds.
+ * Default is {@value #ASSERTS_FATAL_DEFAULT}.
+ */
+ public static final String ASSERTS_FATAL = "bugle_asserts_fatal";
+ public static final boolean ASSERTS_FATAL_DEFAULT = false;
+
+ /**
+ * Whether to use API for sending/downloading MMS (if present, true for L).
+ * Default is {@value #USE_MMS_API_IF_PRESENT_DEFAULT}.
+ */
+ public static final String USE_MMS_API_IF_PRESENT = "bugle_use_mms_api";
+ public static final boolean USE_MMS_API_IF_PRESENT_DEFAULT = true;
+
+ /**
+ * Whether to always auto-complete email addresses for sending MMS. By default, Bugle starts
+ * to auto-complete after the user has typed the "@" character.
+ * Default is (@value ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS_DEFAULT}.
+ */
+ public static final String ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS =
+ "bugle_always_autocomplete_email_address";
+ public static final boolean ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS_DEFAULT = false;
+
+ // We typically request an aspect ratio close the the screen size, but some cameras can be
+ // flaky and not work well in certain aspect ratios. This allows us to guide the CameraManager
+ // to pick a more reliable aspect ratio. The value is a float like 1.333f or 1.777f. There is
+ // no hard coded default because the default is the screen aspect ratio.
+ public static final String CAMERA_ASPECT_RATIO = "bugle_camera_aspect_ratio";
+
+ /**
+ * The recent time range within which we should check MMS WAP Push duplication
+ * If the value is 0, it signals that we should use old dedup algorithm for wap push
+ */
+ public static final String MMS_WAP_PUSH_DEDUP_TIME_LIMIT_SECS =
+ "bugle_mms_wap_push_dedup_time_limit_secs";
+ public static final long MMS_WAP_PUSH_DEDUP_TIME_LIMIT_SECS_DEFAULT = 7 * 24 * 3600; // 7 days
+
+ /**
+ * Whether to use persistent, on-disk LogSaver
+ */
+ public static final String PERSISTENT_LOGSAVER = "bugle_persistent_logsaver";
+ public static final boolean PERSISTENT_LOGSAVER_DEFAULT = false;
+
+ /**
+ * For in-memory LogSaver, what's the size of memory buffer in number of records
+ */
+ public static final String IN_MEMORY_LOGSAVER_RECORD_COUNT =
+ "bugle_in_memory_logsaver_record_count";
+ public static final int IN_MEMORY_LOGSAVER_RECORD_COUNT_DEFAULT = 500;
+
+ /**
+ * For on-disk LogSaver, what's the size of file rotation set
+ */
+ public static final String PERSISTENT_LOGSAVER_ROTATION_SET_SIZE =
+ "bugle_persistent_logsaver_rotation_set_size";
+ public static final int PERSISTENT_LOGSAVER_ROTATION_SET_SIZE_DEFAULT = 8;
+
+ /**
+ * For on-disk LogSaver, what's the byte limit of a single log file
+ */
+ public static final String PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES =
+ "bugle_persistent_logsaver_file_limit";
+ public static final int PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES_DEFAULT = 256 * 1024; // 256KB
+
+ /**
+ * We concatenate all text parts in an MMS to form the message text. This specifies
+ * the separator between the combinated text parts. Default is ' ' (space).
+ */
+ public static final String MMS_TEXT_CONCAT_SEPARATOR = "bugle_mms_text_concat_separator";
+ public static final String MMS_TEXT_CONCAT_SEPARATOR_DEFAULT = " ";
+
+ /**
+ * Whether to enable transcoding GIFs. We sometimes need to compress GIFs to make them small
+ * enough to send via MMS (which often limits messages to 1 MB in size).
+ */
+ public static final String ENABLE_GIF_TRANSCODING = "bugle_gif_transcoding";
+ public static final boolean ENABLE_GIF_TRANSCODING_DEFAULT = true;
+}
diff --git a/src/com/android/messaging/util/BuglePrefs.java b/src/com/android/messaging/util/BuglePrefs.java
new file mode 100644
index 0000000..74a0d46
--- /dev/null
+++ b/src/com/android/messaging/util/BuglePrefs.java
@@ -0,0 +1,140 @@
+/*
+ * 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.messaging.util;
+
+import com.android.messaging.Factory;
+
+/**
+ * Thin wrapper to get/set shared prefs values.
+ */
+public abstract class BuglePrefs {
+ /**
+ * Shared preferences name for preferences applicable to the entire app.
+ */
+ public static final String SHARED_PREFERENCES_NAME = "bugle";
+
+ /**
+ * Shared preferences name for subscription-specific preferences.
+ * Note: for all subscription-specific preferences, please prefix the shared preference keys
+ * with "buglesub_", so that Bugle may perform runtime validations on preferences to make sure
+ * you don't accidentally write per-subscription settings into the general pref file, and vice
+ * versa.
+ */
+ public static final String SHARED_PREFERENCES_PER_SUBSCRIPTION_PREFIX = "buglesub_";
+
+ /**
+ * A placeholder base version for Bugle builds where no shared pref version was defined.
+ */
+ public static final int NO_SHARED_PREFERENCES_VERSION = -1;
+
+ /**
+ * Returns the shared preferences file name to use.
+ * Subclasses should override and return the shared preferences file.
+ */
+ public abstract String getSharedPreferencesName();
+
+
+ /**
+ * Handles pref version upgrade.
+ */
+ public abstract void onUpgrade(final int oldVersion, final int newVersion);
+
+ /**
+ * Gets the SharedPreferences accessor to the application-wide preferences.
+ */
+ public static BuglePrefs getApplicationPrefs() {
+ return Factory.get().getApplicationPrefs();
+ }
+
+ /**
+ * Gets the SharedPreferences accessor to the subscription-specific preferences.
+ */
+ public static BuglePrefs getSubscriptionPrefs(final int subId) {
+ return Factory.get().getSubscriptionPrefs(subId);
+ }
+
+ /**
+ * @param key The key to look up in shared prefs
+ * @param defaultValue The default value if value in shared prefs is null or if
+ * NumberFormatException is caught.
+ * @return The corresponding value, or the default value.
+ */
+ public abstract int getInt(final String key, final int defaultValue);
+
+ /**
+ * @param key The key to look up in shared prefs
+ * @param defaultValue The default value if value in shared prefs is null or if
+ * NumberFormatException is caught.
+ * @return The corresponding value, or the default value.
+ */
+ public abstract long getLong(final String key, final long defaultValue);
+
+ /**
+ * @param key The key to look up in shared prefs
+ * @param defaultValue The default value if value in shared prefs is null.
+ * @return The corresponding value, or the default value.
+ */
+ public abstract boolean getBoolean(final String key, final boolean defaultValue);
+
+ /**
+ * @param key The key to look up in shared prefs
+ * @param defaultValue The default value if value in shared prefs is null.
+ * @return The corresponding value, or the default value.
+ */
+ public abstract String getString(final String key, final String defaultValue);
+
+ /**
+ * @param key The key to look up in shared prefs
+ * @return The corresponding value, or null if not found.
+ */
+ public abstract byte[] getBytes(final String key);
+
+ /**
+ * @param key The key to set in shared prefs
+ * @param value The value to assign to the key
+ */
+ public abstract void putInt(final String key, final int value);
+
+ /**
+ * @param key The key to set in shared prefs
+ * @param value The value to assign to the key
+ */
+ public abstract void putLong(final String key, final long value);
+
+ /**
+ * @param key The key to set in shared prefs
+ * @param value The value to assign to the key
+ */
+ public abstract void putBoolean(final String key, final boolean value);
+
+ /**
+ * @param key The key to set in shared prefs
+ * @param value The value to assign to the key
+ */
+ public abstract void putString(final String key, final String value);
+
+ /**
+ * @param key The key to set in shared prefs
+ * @param value The value to assign to the key
+ */
+ public abstract void putBytes(final String key, final byte[] value);
+
+ /**
+ * @param key The key to remove from shared prefs
+ */
+ public abstract void remove(String key);
+}
diff --git a/src/com/android/messaging/util/BuglePrefsImpl.java b/src/com/android/messaging/util/BuglePrefsImpl.java
new file mode 100644
index 0000000..7563040
--- /dev/null
+++ b/src/com/android/messaging/util/BuglePrefsImpl.java
@@ -0,0 +1,135 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Base64;
+
+/**
+ * Thin wrapper to get/set shared prefs values.
+ */
+public abstract class BuglePrefsImpl extends BuglePrefs {
+
+ private final Context mContext;
+
+ public BuglePrefsImpl(final Context context) {
+ mContext = context;
+ }
+
+ /**
+ * Validate the prefs key passed in. Subclasses should override this as needed to perform
+ * runtime checks (such as making sure per-subscription settings don't sneak into application-
+ * wide settings).
+ */
+ protected void validateKey(String key) {
+ }
+
+ @Override
+ public int getInt(final String key, final int defaultValue) {
+ validateKey(key);
+ final SharedPreferences prefs = mContext.getSharedPreferences(
+ getSharedPreferencesName(), Context.MODE_PRIVATE);
+ return prefs.getInt(key, defaultValue);
+ }
+
+ @Override
+ public long getLong(final String key, final long defaultValue) {
+ validateKey(key);
+ final SharedPreferences prefs = mContext.getSharedPreferences(
+ getSharedPreferencesName(), Context.MODE_PRIVATE);
+ return prefs.getLong(key, defaultValue);
+ }
+
+ @Override
+ public boolean getBoolean(final String key, final boolean defaultValue) {
+ validateKey(key);
+ final SharedPreferences prefs = mContext.getSharedPreferences(
+ getSharedPreferencesName(), Context.MODE_PRIVATE);
+ return prefs.getBoolean(key, defaultValue);
+ }
+
+ @Override
+ public String getString(final String key, final String defaultValue) {
+ validateKey(key);
+ final SharedPreferences prefs = mContext.getSharedPreferences(
+ getSharedPreferencesName(), Context.MODE_PRIVATE);
+ return prefs.getString(key, defaultValue);
+ }
+
+ @Override
+ public byte[] getBytes(String key) {
+ final String byteValue = getString(key, null);
+ return byteValue == null ? null : Base64.decode(byteValue, Base64.DEFAULT);
+ }
+
+ @Override
+ public void putInt(final String key, final int value) {
+ validateKey(key);
+ final SharedPreferences prefs = mContext.getSharedPreferences(
+ getSharedPreferencesName(), Context.MODE_PRIVATE);
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.putInt(key, value);
+ editor.apply();
+ }
+
+ @Override
+ public void putLong(final String key, final long value) {
+ validateKey(key);
+ final SharedPreferences prefs = mContext.getSharedPreferences(
+ getSharedPreferencesName(), Context.MODE_PRIVATE);
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.putLong(key, value);
+ editor.apply();
+ }
+
+ @Override
+ public void putBoolean(final String key, final boolean value) {
+ validateKey(key);
+ final SharedPreferences prefs = mContext.getSharedPreferences(
+ getSharedPreferencesName(), Context.MODE_PRIVATE);
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(key, value);
+ editor.apply();
+ }
+
+ @Override
+ public void putString(final String key, final String value) {
+ validateKey(key);
+ final SharedPreferences prefs = mContext.getSharedPreferences(
+ getSharedPreferencesName(), Context.MODE_PRIVATE);
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(key, value);
+ editor.apply();
+ }
+
+ @Override
+ public void putBytes(String key, byte[] value) {
+ final String encodedBytes = Base64.encodeToString(value, Base64.DEFAULT);
+ putString(key, encodedBytes);
+ }
+
+ @Override
+ public void remove(final String key) {
+ validateKey(key);
+ final SharedPreferences prefs = mContext.getSharedPreferences(
+ getSharedPreferencesName(), Context.MODE_PRIVATE);
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.remove(key);
+ editor.apply();
+ }
+}
diff --git a/src/com/android/messaging/util/BuglePrefsKeys.java b/src/com/android/messaging/util/BuglePrefsKeys.java
new file mode 100644
index 0000000..ae409bc
--- /dev/null
+++ b/src/com/android/messaging/util/BuglePrefsKeys.java
@@ -0,0 +1,71 @@
+/*
+ * 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.messaging.util;
+
+/**
+ * List of shared preferences keys and default values. These are all internal
+ * (not user-visible) preferences. Preferences that are exposed via the Settings
+ * activity should be defined in the constants.xml resource file instead.
+ */
+public final class BuglePrefsKeys {
+ private BuglePrefsKeys() {} // do not instantiate
+
+ /**
+ * Bugle's shared preferences version
+ */
+ public static final String SHARED_PREFERENCES_VERSION =
+ "shared_preferences_version";
+ public static final int SHARED_PREFERENCES_VERSION_DEFAULT =
+ BuglePrefs.NO_SHARED_PREFERENCES_VERSION;
+
+ /**
+ * Last time that we ran a a sync (in millis)
+ */
+ public static final String LAST_SYNC_TIME
+ = "last_sync_time_millis";
+ public static final long LAST_SYNC_TIME_DEFAULT
+ = -1;
+
+ /**
+ * Last time that we ran a full sync (in millis)
+ */
+ public static final String LAST_FULL_SYNC_TIME
+ = "last_full_sync_time_millis";
+ public static final long LAST_FULL_SYNC_TIME_DEFAULT
+ = -1;
+
+ /**
+ * Timestamp of the message for which we last did a message notification.
+ */
+ public static final String LATEST_NOTIFICATION_MESSAGE_TIMESTAMP
+ = "latest_notification_message_timestamp";
+
+ /**
+ * The last selected chooser index in the media picker.
+ */
+ public static final String SELECTED_MEDIA_PICKER_CHOOSER_INDEX
+ = "selected_media_picker_chooser_index";
+ public static final int SELECTED_MEDIA_PICKER_CHOOSER_INDEX_DEFAULT
+ = -1;
+
+ /**
+ * The attempt number when retrying ProcessPendingMessagesAction
+ */
+ public static final String PROCESS_PENDING_MESSAGES_RETRY_COUNT
+ = "process_pending_retry";
+
+}
diff --git a/src/com/android/messaging/util/BugleSubscriptionPrefs.java b/src/com/android/messaging/util/BugleSubscriptionPrefs.java
new file mode 100644
index 0000000..039712a
--- /dev/null
+++ b/src/com/android/messaging/util/BugleSubscriptionPrefs.java
@@ -0,0 +1,95 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+
+/**
+ * Provides interface to access per-subscription shared preferences. We have one instance of
+ * this per active subscription.
+ */
+public class BugleSubscriptionPrefs extends BuglePrefsImpl {
+ private final int mSubId;
+
+ public BugleSubscriptionPrefs(final Context context, final int subId) {
+ super(context);
+ mSubId = subId;
+ }
+
+ @Override
+ public String getSharedPreferencesName() {
+ return SHARED_PREFERENCES_PER_SUBSCRIPTION_PREFIX + String.valueOf(mSubId);
+ }
+
+ @Override
+ protected void validateKey(String key) {
+ super.validateKey(key);
+ // Callers should only access per-subscription preferences from this class
+ Assert.isTrue(key.startsWith(SHARED_PREFERENCES_PER_SUBSCRIPTION_PREFIX));
+ }
+
+ @Override
+ public void onUpgrade(final int oldVersion, final int newVersion) {
+ switch (oldVersion) {
+ case BuglePrefs.NO_SHARED_PREFERENCES_VERSION:
+ // Upgrade to version 1. Adding per-subscription shared prefs.
+ // Migrate values from the application-wide settings.
+ migratePrefBooleanInternal(BuglePrefs.getApplicationPrefs(), "delivery_reports",
+ R.string.delivery_reports_pref_key, R.bool.delivery_reports_pref_default);
+ migratePrefBooleanInternal(BuglePrefs.getApplicationPrefs(), "auto_retrieve_mms",
+ R.string.auto_retrieve_mms_pref_key, R.bool.auto_retrieve_mms_pref_default);
+ migratePrefBooleanInternal(BuglePrefs.getApplicationPrefs(),
+ "auto_retrieve_mms_when_roaming",
+ R.string.auto_retrieve_mms_when_roaming_pref_key,
+ R.bool.auto_retrieve_mms_when_roaming_pref_default);
+ migratePrefBooleanInternal(BuglePrefs.getApplicationPrefs(), "group_messaging",
+ R.string.group_mms_pref_key, R.bool.group_mms_pref_default);
+
+ if (PhoneUtils.getDefault().getActiveSubscriptionCount() == 1) {
+ migratePrefStringInternal(BuglePrefs.getApplicationPrefs(), "mms_phone_number",
+ R.string.mms_phone_number_pref_key, null);
+ }
+ }
+ }
+
+ private void migratePrefBooleanInternal(final BuglePrefs oldPrefs, final String oldKey,
+ final int newKeyResId, final int defaultValueResId) {
+ final Resources resources = Factory.get().getApplicationContext().getResources();
+ final boolean defaultValue = resources.getBoolean(defaultValueResId);
+ final boolean oldValue = oldPrefs.getBoolean(oldKey, defaultValue);
+
+ // Only migrate pref value if it's different than the default.
+ if (oldValue != defaultValue) {
+ putBoolean(resources.getString(newKeyResId), oldValue);
+ }
+ }
+
+ private void migratePrefStringInternal(final BuglePrefs oldPrefs, final String oldKey,
+ final int newKeyResId, final String defaultValue) {
+ final Resources resources = Factory.get().getApplicationContext().getResources();
+ final String oldValue = oldPrefs.getString(oldKey, defaultValue);
+
+ // Only migrate pref value if it's different than the default.
+ if (!TextUtils.equals(oldValue, defaultValue)) {
+ putString(resources.getString(newKeyResId), oldValue);
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/BugleWidgetPrefs.java b/src/com/android/messaging/util/BugleWidgetPrefs.java
new file mode 100644
index 0000000..63ba567
--- /dev/null
+++ b/src/com/android/messaging/util/BugleWidgetPrefs.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.util;
+
+import android.content.Context;
+
+/**
+ * Provides interface to access shared preferences used by bugle widgets.
+ */
+public class BugleWidgetPrefs extends BuglePrefsImpl {
+ /**
+ * Shared preferences name for preferences applicable to the entire app.
+ */
+ public static final String SHARED_PREFERENCES_WIDGET_NAME = "bugle_widgets";
+
+ public BugleWidgetPrefs(Context context) {
+ super(context);
+ }
+
+ @Override
+ public String getSharedPreferencesName() {
+ return SHARED_PREFERENCES_WIDGET_NAME;
+ }
+
+ @Override
+ public void onUpgrade(int oldVersion, int newVersion) {
+ }
+}
diff --git a/src/com/android/messaging/util/ChangeDefaultSmsAppHelper.java b/src/com/android/messaging/util/ChangeDefaultSmsAppHelper.java
new file mode 100644
index 0000000..6cf2f25
--- /dev/null
+++ b/src/com/android/messaging/util/ChangeDefaultSmsAppHelper.java
@@ -0,0 +1,157 @@
+/*
+ * 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.messaging.util;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.view.View;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.SnackBar;
+import com.android.messaging.ui.UIIntents;
+
+public class ChangeDefaultSmsAppHelper {
+ private Runnable mRunAfterMadeDefault;
+ private ChangeSmsAppSettingRunnable mChangeSmsAppSettingRunnable;
+
+ private static final int REQUEST_SET_DEFAULT_SMS_APP = 1;
+
+ /**
+ * When there's some condition that prevents an operation, such as sending a message,
+ * call warnOfMissingActionConditions to put up a toast and allow the user to repair
+ * that condition.
+ * @param sending - true if we're called during a sending operation
+ * @param runAfterMadeDefault - a runnable to run after the user responds
+ * positively to the condition prompt and resolves the condition. It is
+ * preferable to specify the value in {@link #handleChangeDefaultSmsResult}
+ * as that handles the case where the process gets restarted.
+ * If null, the user will be shown a generic toast message.
+ * @param composeView - compose view that may have the keyboard opened and focused
+ * @param rootView - if non-null, use this to attach a snackBar
+ * @param activity - calling activity
+ * @param fragment - calling fragment, may be null if called directly from an activity
+ */
+ public void warnOfMissingActionConditions(final boolean sending,
+ final Runnable runAfterMadeDefault,
+ final View composeView, final View rootView,
+ final Activity activity, final Fragment fragment) {
+ final PhoneUtils phoneUtils = PhoneUtils.getDefault();
+ final boolean isSmsCapable = phoneUtils.isSmsCapable();
+ final boolean hasPreferredSmsSim = phoneUtils.getHasPreferredSmsSim();
+ final boolean isDefaultSmsApp = phoneUtils.isDefaultSmsApp();
+
+ // Supports SMS?
+ if (!isSmsCapable) {
+ UiUtils.showToast(R.string.sms_disabled);
+
+ // Has a preferred sim?
+ } else if (!hasPreferredSmsSim) {
+ UiUtils.showToast(R.string.no_preferred_sim_selected);
+
+ // Is the default sms app?
+ } else if (!isDefaultSmsApp) {
+ mChangeSmsAppSettingRunnable = new ChangeSmsAppSettingRunnable(activity, fragment);
+ promptToChangeDefaultSmsApp(sending, runAfterMadeDefault,
+ composeView, rootView, activity);
+ }
+
+ LogUtil.w(LogUtil.BUGLE_TAG, "Unsatisfied action condition: "
+ + "isSmsCapable=" + isSmsCapable + ", "
+ + "hasPreferredSmsSim=" + hasPreferredSmsSim + ", "
+ + "isDefaultSmsApp=" + isDefaultSmsApp);
+ }
+
+ private void promptToChangeDefaultSmsApp(final boolean sending,
+ final Runnable runAfterMadeDefault,
+ final View composeView, final View rootView,
+ final Activity activity) {
+ if (composeView != null) {
+ // Avoid bug in system which puts soft keyboard over dialog after orientation change
+ ImeUtil.hideSoftInput(activity, composeView);
+ }
+ mRunAfterMadeDefault = runAfterMadeDefault;
+
+ if (rootView == null) {
+ // Immediately open the system "Change default SMS app?" dialog setting.
+ mChangeSmsAppSettingRunnable.run();
+ } else {
+ UiUtils.showSnackBarWithCustomAction(activity,
+ rootView,
+ activity.getString(sending ? R.string.requires_default_sms_app_to_send :
+ R.string.requires_default_sms_app),
+ SnackBar.Action.createCustomAction(mChangeSmsAppSettingRunnable,
+ activity.getString(R.string.requires_default_sms_change_button)),
+ null /* interactions */,
+ SnackBar.Placement.above(composeView));
+ }
+ }
+
+ private class ChangeSmsAppSettingRunnable implements Runnable {
+ private final Activity mActivity;
+ private final Fragment mFragment;
+
+ public ChangeSmsAppSettingRunnable(final Activity activity, final Fragment fragment) {
+ mActivity = activity;
+ mFragment = fragment;
+ }
+
+ @Override
+ public void run() {
+ try {
+ final Intent intent = UIIntents.get().getChangeDefaultSmsAppIntent(mActivity);
+ if (mFragment != null) {
+ mFragment.startActivityForResult(intent, REQUEST_SET_DEFAULT_SMS_APP);
+ } else {
+ mActivity.startActivityForResult(intent, REQUEST_SET_DEFAULT_SMS_APP);
+ }
+ } catch (final ActivityNotFoundException ex) {
+ // We shouldn't get here, but the monkey on JB MR0 can trigger it.
+ LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't find activity:", ex);
+ UiUtils.showToastAtBottom(R.string.activity_not_found_message);
+ }
+ }
+ }
+
+ public void handleChangeDefaultSmsResult(
+ final int requestCode,
+ final int resultCode,
+ Runnable runAfterMadeDefault) {
+ Assert.isTrue(mRunAfterMadeDefault == null || runAfterMadeDefault == null);
+ if (runAfterMadeDefault == null) {
+ runAfterMadeDefault = mRunAfterMadeDefault;
+ }
+
+ if (requestCode == REQUEST_SET_DEFAULT_SMS_APP) {
+ if (resultCode == Activity.RESULT_OK) {
+ // mRunAfterMadeDefault can be null if it was set only in
+ // promptToChangeDefaultSmsApp, and the process subsequently restarted when the
+ // user momentarily switched to another app. In that case, we'll simply show a
+ // generic toast since we do not know what the runnable was supposed to do.
+ if (runAfterMadeDefault != null) {
+ runAfterMadeDefault.run();
+ } else {
+ UiUtils.showToast(R.string.toast_after_setting_default_sms_app);
+ }
+ }
+ mRunAfterMadeDefault = null; // don't want to accidentally run it again
+ }
+ }
+}
+
+
diff --git a/src/com/android/messaging/util/CircularArray.java b/src/com/android/messaging/util/CircularArray.java
new file mode 100644
index 0000000..db6cf12
--- /dev/null
+++ b/src/com/android/messaging/util/CircularArray.java
@@ -0,0 +1,111 @@
+/*
+ * 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.messaging.util;
+
+/**
+ * Very simple circular array implementation.
+ *
+ * @param <E> The element type of this list.
+ * @LibraryInternal
+ */
+public class CircularArray<E> {
+ private int mNextWriter;
+ private boolean mHasWrapped;
+ private int mMaxCount;
+ Object mList[];
+
+ /**
+ * Constructor for CircularArray.
+ *
+ * @param count Max elements to hold in the list.
+ */
+ public CircularArray(int count) {
+ mMaxCount = count;
+ clear();
+ }
+
+ /**
+ * Reset the list.
+ */
+ public void clear() {
+ mNextWriter = 0;
+ mHasWrapped = false;
+ mList = new Object[mMaxCount];
+ }
+
+ /**
+ * Add an element to the end of the list.
+ *
+ * @param object The object to add.
+ */
+ public void add(E object) {
+ mList[mNextWriter] = object;
+ ++mNextWriter;
+ if (mNextWriter == mMaxCount) {
+ mNextWriter = 0;
+ mHasWrapped = true;
+ }
+ }
+
+ /**
+ * Get the number of elements in the list. This will be 0 <= returned count <= max count
+ *
+ * @return Elements in the circular list.
+ */
+ public int count() {
+ if (mHasWrapped) {
+ return mMaxCount;
+ } else {
+ return mNextWriter;
+ }
+ }
+
+ /**
+ * Return null if the list hasn't wrapped yet. Otherwise return the next object that would be
+ * overwritten. Can be useful to avoid extra allocations.
+ *
+ * @return
+ */
+ @SuppressWarnings("unchecked")
+ public E getFree() {
+ if (!mHasWrapped) {
+ return null;
+ } else {
+ return (E) mList[mNextWriter];
+ }
+ }
+
+ /**
+ * Get the object at index. Index 0 is the oldest item inserted into the list. Index (count() -
+ * 1) is the newest.
+ *
+ * @param index Index to retrieve.
+ * @return Object at index.
+ */
+ @SuppressWarnings("unchecked")
+ public E get(int index) {
+ if (mHasWrapped) {
+ int wrappedIndex = index + mNextWriter;
+ if (wrappedIndex >= mMaxCount) {
+ wrappedIndex -= mMaxCount;
+ }
+ return (E) mList[wrappedIndex];
+ } else {
+ return (E) mList[index];
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/ConnectivityUtil.java b/src/com/android/messaging/util/ConnectivityUtil.java
new file mode 100644
index 0000000..49f6e0a
--- /dev/null
+++ b/src/com/android/messaging/util/ConnectivityUtil.java
@@ -0,0 +1,247 @@
+/*
+ * 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.messaging.util;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.ConnectivityManager;
+import android.telephony.PhoneStateListener;
+import android.telephony.ServiceState;
+import android.telephony.SignalStrength;
+import android.telephony.TelephonyManager;
+
+public class ConnectivityUtil {
+ // Assume not connected until informed differently
+ private volatile int mCurrentServiceState = ServiceState.STATE_POWER_OFF;
+
+ private final TelephonyManager mTelephonyManager;
+ private final Context mContext;
+ private final ConnectivityBroadcastReceiver mReceiver;
+ private final ConnectivityManager mConnMgr;
+
+ private ConnectivityListener mListener;
+ private final IntentFilter mIntentFilter;
+
+ public interface ConnectivityListener {
+ public void onConnectivityStateChanged(final Context context, final Intent intent);
+ public void onPhoneStateChanged(final Context context, int serviceState);
+ }
+
+ public ConnectivityUtil(final Context context) {
+ mContext = context;
+ mTelephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
+ mConnMgr = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ mReceiver = new ConnectivityBroadcastReceiver();
+ mIntentFilter = new IntentFilter();
+ mIntentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
+ }
+
+ public int getCurrentServiceState() {
+ return mCurrentServiceState;
+ }
+
+ private final PhoneStateListener mPhoneStateListener = new PhoneStateListener() {
+ @Override
+ public void onServiceStateChanged(final ServiceState serviceState) {
+ if (mCurrentServiceState != serviceState.getState()) {
+ mCurrentServiceState = serviceState.getState();
+ onPhoneStateChanged(mCurrentServiceState);
+ }
+ }
+
+ @Override
+ public void onDataConnectionStateChanged(final int state) {
+ mCurrentServiceState = (state == TelephonyManager.DATA_DISCONNECTED) ?
+ ServiceState.STATE_OUT_OF_SERVICE : ServiceState.STATE_IN_SERVICE;
+ }
+ };
+
+ private void onPhoneStateChanged(final int serviceState) {
+ final ConnectivityListener listener = mListener;
+ if (listener != null) {
+ listener.onPhoneStateChanged(mContext, serviceState);
+ }
+ }
+
+ private void onConnectivityChanged(final Context context, final Intent intent) {
+ final ConnectivityListener listener = mListener;
+ if (listener != null) {
+ listener.onConnectivityStateChanged(context, intent);
+ }
+ }
+
+ public void register(final ConnectivityListener listener) {
+ Assert.isTrue(mListener == null || mListener == listener);
+ if (mListener == null) {
+ if (mTelephonyManager != null) {
+ mCurrentServiceState = (PhoneUtils.getDefault().isAirplaneModeOn() ?
+ ServiceState.STATE_POWER_OFF : ServiceState.STATE_IN_SERVICE);
+ mTelephonyManager.listen(mPhoneStateListener,
+ PhoneStateListener.LISTEN_SERVICE_STATE);
+ }
+ if (mConnMgr != null) {
+ mContext.registerReceiver(mReceiver, mIntentFilter);
+ }
+ }
+ mListener = listener;
+ }
+
+ public void unregister() {
+ if (mListener != null) {
+ if (mTelephonyManager != null) {
+ mTelephonyManager.listen(mPhoneStateListener, PhoneStateListener.LISTEN_NONE);
+ mCurrentServiceState = ServiceState.STATE_POWER_OFF;
+ }
+ if (mConnMgr != null) {
+ mContext.unregisterReceiver(mReceiver);
+ }
+ }
+ mListener = null;
+ }
+
+ /**
+ * Connectivity change broadcast receiver. This gets the network connectivity updates.
+ * In case we don't get the active connectivity when we first acquire the network,
+ * this receiver will notify us when it is connected, so to unblock the waiting thread
+ * which is sending the message.
+ */
+ public class ConnectivityBroadcastReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (!intent.getAction().equals(ConnectivityManager.CONNECTIVITY_ACTION)) {
+ return;
+ }
+
+ onConnectivityChanged(context, intent);
+ }
+ }
+
+ private int mSignalLevel = SIGNAL_STRENGTH_NONE_OR_UNKNOWN;
+
+ // We use a separate instance than mPhoneStateListener because the lifetimes are different.
+ private final PhoneStateListener mSignalStrengthListener = new PhoneStateListener() {
+ @Override
+ public void onSignalStrengthsChanged(final SignalStrength signalStrength) {
+ mSignalLevel = getLevel(signalStrength);
+ }
+ };
+
+ public void registerForSignalStrength() {
+ mTelephonyManager.listen(
+ mSignalStrengthListener, PhoneStateListener.LISTEN_SIGNAL_STRENGTHS);
+ }
+
+ public void unregisterForSignalStrength() {
+ mTelephonyManager.listen(mSignalStrengthListener, PhoneStateListener.LISTEN_NONE);
+ }
+
+ /**
+ * @param subId This is ignored because TelephonyManager does not support it.
+ * @return Signal strength as level 0..4
+ */
+ public int getSignalLevel(final int subId) {
+ return mSignalLevel;
+ }
+
+ private static final int SIGNAL_STRENGTH_NONE_OR_UNKNOWN = 0;
+ private static final int SIGNAL_STRENGTH_POOR = 1;
+ private static final int SIGNAL_STRENGTH_MODERATE = 2;
+ private static final int SIGNAL_STRENGTH_GOOD = 3;
+ private static final int SIGNAL_STRENGTH_GREAT = 4;
+
+ private static final int GSM_SIGNAL_STRENGTH_GREAT = 12;
+ private static final int GSM_SIGNAL_STRENGTH_GOOD = 8;
+ private static final int GSM_SIGNAL_STRENGTH_MODERATE = 8;
+
+ private static int getLevel(final SignalStrength signalStrength) {
+ if (signalStrength.isGsm()) {
+ // From frameworks/base/telephony/java/android/telephony/CellSignalStrengthGsm.java
+
+ // ASU ranges from 0 to 31 - TS 27.007 Sec 8.5
+ // asu = 0 (-113dB or less) is very weak
+ // signal, its better to show 0 bars to the user in such cases.
+ // asu = 99 is a special case, where the signal strength is unknown.
+ final int asu = signalStrength.getGsmSignalStrength();
+ if (asu <= 2 || asu == 99) return SIGNAL_STRENGTH_NONE_OR_UNKNOWN;
+ else if (asu >= GSM_SIGNAL_STRENGTH_GREAT) return SIGNAL_STRENGTH_GREAT;
+ else if (asu >= GSM_SIGNAL_STRENGTH_GOOD) return SIGNAL_STRENGTH_GOOD;
+ else if (asu >= GSM_SIGNAL_STRENGTH_MODERATE) return SIGNAL_STRENGTH_MODERATE;
+ else return SIGNAL_STRENGTH_POOR;
+ } else {
+ // From frameworks/base/telephony/java/android/telephony/CellSignalStrengthCdma.java
+
+ final int cdmaLevel = getCdmaLevel(signalStrength);
+ final int evdoLevel = getEvdoLevel(signalStrength);
+ if (evdoLevel == SIGNAL_STRENGTH_NONE_OR_UNKNOWN) {
+ /* We don't know evdo, use cdma */
+ return getCdmaLevel(signalStrength);
+ } else if (cdmaLevel == SIGNAL_STRENGTH_NONE_OR_UNKNOWN) {
+ /* We don't know cdma, use evdo */
+ return getEvdoLevel(signalStrength);
+ } else {
+ /* We know both, use the lowest level */
+ return cdmaLevel < evdoLevel ? cdmaLevel : evdoLevel;
+ }
+ }
+ }
+
+ /**
+ * Get cdma as level 0..4
+ */
+ private static int getCdmaLevel(final SignalStrength signalStrength) {
+ final int cdmaDbm = signalStrength.getCdmaDbm();
+ final int cdmaEcio = signalStrength.getCdmaEcio();
+ int levelDbm;
+ int levelEcio;
+ if (cdmaDbm >= -75) levelDbm = SIGNAL_STRENGTH_GREAT;
+ else if (cdmaDbm >= -85) levelDbm = SIGNAL_STRENGTH_GOOD;
+ else if (cdmaDbm >= -95) levelDbm = SIGNAL_STRENGTH_MODERATE;
+ else if (cdmaDbm >= -100) levelDbm = SIGNAL_STRENGTH_POOR;
+ else levelDbm = SIGNAL_STRENGTH_NONE_OR_UNKNOWN;
+ // Ec/Io are in dB*10
+ if (cdmaEcio >= -90) levelEcio = SIGNAL_STRENGTH_GREAT;
+ else if (cdmaEcio >= -110) levelEcio = SIGNAL_STRENGTH_GOOD;
+ else if (cdmaEcio >= -130) levelEcio = SIGNAL_STRENGTH_MODERATE;
+ else if (cdmaEcio >= -150) levelEcio = SIGNAL_STRENGTH_POOR;
+ else levelEcio = SIGNAL_STRENGTH_NONE_OR_UNKNOWN;
+ final int level = (levelDbm < levelEcio) ? levelDbm : levelEcio;
+ return level;
+ }
+ /**
+ * Get Evdo as level 0..4
+ */
+ private static int getEvdoLevel(final SignalStrength signalStrength) {
+ final int evdoDbm = signalStrength.getEvdoDbm();
+ final int evdoSnr = signalStrength.getEvdoSnr();
+ int levelEvdoDbm;
+ int levelEvdoSnr;
+ if (evdoDbm >= -65) levelEvdoDbm = SIGNAL_STRENGTH_GREAT;
+ else if (evdoDbm >= -75) levelEvdoDbm = SIGNAL_STRENGTH_GOOD;
+ else if (evdoDbm >= -90) levelEvdoDbm = SIGNAL_STRENGTH_MODERATE;
+ else if (evdoDbm >= -105) levelEvdoDbm = SIGNAL_STRENGTH_POOR;
+ else levelEvdoDbm = SIGNAL_STRENGTH_NONE_OR_UNKNOWN;
+ if (evdoSnr >= 7) levelEvdoSnr = SIGNAL_STRENGTH_GREAT;
+ else if (evdoSnr >= 5) levelEvdoSnr = SIGNAL_STRENGTH_GOOD;
+ else if (evdoSnr >= 3) levelEvdoSnr = SIGNAL_STRENGTH_MODERATE;
+ else if (evdoSnr >= 1) levelEvdoSnr = SIGNAL_STRENGTH_POOR;
+ else levelEvdoSnr = SIGNAL_STRENGTH_NONE_OR_UNKNOWN;
+ final int level = (levelEvdoDbm < levelEvdoSnr) ? levelEvdoDbm : levelEvdoSnr;
+ return level;
+ }
+}
diff --git a/src/com/android/messaging/util/ContactRecipientEntryUtils.java b/src/com/android/messaging/util/ContactRecipientEntryUtils.java
new file mode 100644
index 0000000..78c6ffd
--- /dev/null
+++ b/src/com/android/messaging/util/ContactRecipientEntryUtils.java
@@ -0,0 +1,115 @@
+/*
+ * 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.messaging.util;
+
+import android.net.Uri;
+import android.provider.ContactsContract.DisplayNameSources;
+import android.text.TextUtils;
+
+import com.android.ex.chips.RecipientEntry;
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.BugleRecipientEntry;
+import com.android.messaging.datamodel.data.ParticipantData;
+
+/**
+ * Provides utility methods around creating RecipientEntry instance specific to Bugle's needs.
+ */
+public class ContactRecipientEntryUtils {
+ /**
+ * A special contact id for generated contacts with no display name (number only) and avatar.
+ * By default, the chips UI doesn't load any avatar for chips with no display name, or where
+ * the display name is the same as phone number (which is true for unknown contacts).
+ * Since Bugle always generate a default avatar for all contacts, this is used to replace
+ * those default generated chips with a phone number and no avatars.
+ */
+ private static final long CONTACT_ID_NUMBER_WITH_AVATAR = -1000;
+
+ /**
+ * A generated special contact which says "Send to xxx" in the contact list, which allows
+ * a user to direct send an SMS to a number that was manually typed in.
+ */
+ private static final long CONTACT_ID_SENDTO_DESTINATION = -1001;
+
+ /**
+ * Construct a special "Send to xxx" entry for a given destination.
+ */
+ public static RecipientEntry constructSendToDestinationEntry(final String destination) {
+ return constructSpecialRecipientEntry(destination, CONTACT_ID_SENDTO_DESTINATION);
+ }
+
+ /**
+ * Construct a generated contact entry but with rendered avatar.
+ */
+ public static RecipientEntry constructNumberWithAvatarEntry(final String destination) {
+ return constructSpecialRecipientEntry(destination, CONTACT_ID_NUMBER_WITH_AVATAR);
+ }
+
+ private static RecipientEntry constructSpecialRecipientEntry(final String destination,
+ final long contactId) {
+ // For the send-to-destination (e.g. "Send to xxx" in the auto-complete drop-down)
+ // we want to show a default avatar with a static background so that it doesn't flicker
+ // as the user types.
+ final Uri avatarUri = contactId == CONTACT_ID_SENDTO_DESTINATION ?
+ AvatarUriUtil.DEFAULT_BACKGROUND_AVATAR : null;
+ return BugleRecipientEntry.constructTopLevelEntry(null, DisplayNameSources.STRUCTURED_NAME,
+ destination, RecipientEntry.INVALID_DESTINATION_TYPE, null, contactId,
+ null, contactId, avatarUri, true, null);
+ }
+
+ /**
+ * Gets the display name for contact list only. For most cases this is the same as the normal
+ * contact name, but there are cases where these two differ. For example, for the
+ * send to typed number item, we'd like to show "Send to xxx" in the contact list. However,
+ * when this item is actually added to the chips edit box, we would like to show just the
+ * phone number (i.e. no display name).
+ */
+ public static String getDisplayNameForContactList(final RecipientEntry entry) {
+ if (entry.getContactId() == CONTACT_ID_SENDTO_DESTINATION) {
+ return Factory.get().getApplicationContext().getResources().getString(
+ R.string.contact_list_send_to_text, formatDestination(entry));
+ } else if (!TextUtils.isEmpty(entry.getDisplayName())) {
+ return entry.getDisplayName();
+ } else {
+ return formatDestination(entry);
+ }
+ }
+
+ public static String formatDestination(final RecipientEntry entry) {
+ return PhoneUtils.getDefault().formatForDisplay(entry.getDestination());
+ }
+
+ /**
+ * Returns true if the given entry has only avatar and number
+ */
+ public static boolean isAvatarAndNumberOnlyContact(final RecipientEntry entry) {
+ return entry.getContactId() == CONTACT_ID_NUMBER_WITH_AVATAR;
+ }
+
+ /**
+ * Returns true if the given entry is a special send to number item.
+ */
+ public static boolean isSendToDestinationContact(final RecipientEntry entry) {
+ return entry.getContactId() == CONTACT_ID_SENDTO_DESTINATION;
+ }
+
+ /**
+ * Returns true if the given participant is a special send to number item.
+ */
+ public static boolean isSendToDestinationContact(final ParticipantData participant) {
+ return participant.getContactId() == CONTACT_ID_SENDTO_DESTINATION;
+ }
+}
diff --git a/src/com/android/messaging/util/ContactUtil.java b/src/com/android/messaging/util/ContactUtil.java
new file mode 100644
index 0000000..8555889
--- /dev/null
+++ b/src/com/android/messaging/util/ContactUtil.java
@@ -0,0 +1,525 @@
+/*
+ * 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.messaging.util;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Directory;
+import android.provider.ContactsContract.DisplayNameSources;
+import android.provider.ContactsContract.PhoneLookup;
+import android.provider.ContactsContract.Profile;
+import android.text.TextUtils;
+import android.view.View;
+
+import com.android.ex.chips.RecipientEntry;
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.CursorQueryData;
+import com.android.messaging.datamodel.FrequentContactsCursorQueryData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsSmsUtils;
+import com.android.messaging.ui.contact.AddContactsConfirmationDialog;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Utility class including logic to list, filter, and lookup phone and emails in CP2.
+ */
+@VisibleForTesting
+public class ContactUtil {
+
+ /**
+ * Index of different columns in phone or email queries. All queries below should confirm to
+ * this column content and ordering so that caller can use the uniformed way to process
+ * returned cursors.
+ */
+ public static final int INDEX_CONTACT_ID = 0;
+ public static final int INDEX_DISPLAY_NAME = 1;
+ public static final int INDEX_PHOTO_URI = 2;
+ public static final int INDEX_PHONE_EMAIL = 3;
+ public static final int INDEX_PHONE_EMAIL_TYPE = 4;
+ public static final int INDEX_PHONE_EMAIL_LABEL = 5;
+
+ // An optional lookup_id column used by PhoneLookupQuery that is needed when querying for
+ // contact information.
+ public static final int INDEX_LOOKUP_KEY = 6;
+
+ // An optional _id column to query results that need to be displayed in a list view.
+ public static final int INDEX_DATA_ID = 7;
+
+ // An optional sort_key column for displaying contact section labels.
+ public static final int INDEX_SORT_KEY = 8;
+
+ // Lookup key column index specific to frequent contacts query.
+ public static final int INDEX_LOOKUP_KEY_FREQUENT = 3;
+
+ /**
+ * Constants for listing and filtering phones.
+ */
+ public static class PhoneQuery {
+ public static final String SORT_KEY = Phone.SORT_KEY_PRIMARY;
+
+ public static final String[] PROJECTION = new String[] {
+ Phone.CONTACT_ID, // 0
+ Phone.DISPLAY_NAME_PRIMARY, // 1
+ Phone.PHOTO_THUMBNAIL_URI, // 2
+ Phone.NUMBER, // 3
+ Phone.TYPE, // 4
+ Phone.LABEL, // 5
+ Phone.LOOKUP_KEY, // 6
+ Phone._ID, // 7
+ PhoneQuery.SORT_KEY, // 8
+ };
+ }
+
+ /**
+ * Constants for looking up phone numbers.
+ */
+ public static class PhoneLookupQuery {
+ public static final String[] PROJECTION = new String[] {
+ // The _ID field points to the contact id of the content
+ PhoneLookup._ID, // 0
+ PhoneLookup.DISPLAY_NAME, // 1
+ PhoneLookup.PHOTO_THUMBNAIL_URI, // 2
+ PhoneLookup.NUMBER, // 3
+ PhoneLookup.TYPE, // 4
+ PhoneLookup.LABEL, // 5
+ PhoneLookup.LOOKUP_KEY, // 6
+ // The data id is not included as part of the projection since it's not part of
+ // PhoneLookup. This is okay because the _id field serves as both the data id and
+ // contact id. Also we never show the results directly in a list view so we are not
+ // concerned about duplicated _id's (namely, the same contact has two same phone
+ // numbers)
+ };
+ }
+
+ public static class FrequentContactQuery {
+ public static final String[] PROJECTION = new String[] {
+ Contacts._ID, // 0
+ Contacts.DISPLAY_NAME, // 1
+ Contacts.PHOTO_URI, // 2
+ Phone.LOOKUP_KEY, // 3
+ };
+ }
+
+ /**
+ * Constants for listing and filtering emails.
+ */
+ public static class EmailQuery {
+ public static final String SORT_KEY = Email.SORT_KEY_PRIMARY;
+
+ public static final String[] PROJECTION = new String[] {
+ Email.CONTACT_ID, // 0
+ Email.DISPLAY_NAME_PRIMARY, // 1
+ Email.PHOTO_THUMBNAIL_URI, // 2
+ Email.ADDRESS, // 3
+ Email.TYPE, // 4
+ Email.LABEL, // 5
+ Email.LOOKUP_KEY, // 6
+ Email._ID, // 7
+ EmailQuery.SORT_KEY, // 8
+ };
+ }
+
+ public static final int INDEX_SELF_QUERY_LOOKUP_KEY = 3;
+
+ /**
+ * Constants for querying self from CP2.
+ */
+ public static class SelfQuery {
+ public static final String[] PROJECTION = new String[] {
+ Profile._ID, // 0
+ Profile.DISPLAY_NAME_PRIMARY, // 1
+ Profile.PHOTO_THUMBNAIL_URI, // 2
+ Profile.LOOKUP_KEY // 3
+ // Phone number, type, label and data_id is not provided in this projection since
+ // Profile CONTENT_URI doesn't include this information. Also, we don't need it
+ // we just need the name and avatar url.
+ };
+ }
+
+ public static class StructuredNameQuery {
+ public static final String[] PROJECTION = new String[] {
+ StructuredName.DISPLAY_NAME,
+ StructuredName.GIVEN_NAME,
+ StructuredName.FAMILY_NAME,
+ StructuredName.PREFIX,
+ StructuredName.MIDDLE_NAME,
+ StructuredName.SUFFIX
+ };
+ }
+
+ public static final int INDEX_STRUCTURED_NAME_DISPLAY_NAME = 0;
+ public static final int INDEX_STRUCTURED_NAME_GIVEN_NAME = 1;
+ public static final int INDEX_STRUCTURED_NAME_FAMILY_NAME = 2;
+ public static final int INDEX_STRUCTURED_NAME_PREFIX = 3;
+ public static final int INDEX_STRUCTURED_NAME_MIDDLE_NAME = 4;
+ public static final int INDEX_STRUCTURED_NAME_SUFFIX = 5;
+
+ public static final long INVALID_CONTACT_ID = -1;
+
+ /**
+ * This class is static. No need to create an instance.
+ */
+ private ContactUtil() {
+ }
+
+ /**
+ * Shows a contact card or add to contacts dialog for the given contact info
+ * @param view The view whose click triggered this to show
+ * @param contactId The id of the contact in the android contacts DB
+ * @param contactLookupKey The lookup key from contacts DB
+ * @param avatarUri Uri to the avatar image if available
+ * @param normalizedDestination The normalized phone number or email
+ */
+ public static void showOrAddContact(final View view, final long contactId,
+ final String contactLookupKey, final Uri avatarUri,
+ final String normalizedDestination) {
+ if (contactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED
+ && !TextUtils.isEmpty(contactLookupKey)) {
+ final Uri lookupUri =
+ ContactsContract.Contacts.getLookupUri(contactId, contactLookupKey);
+ ContactsContract.QuickContact.showQuickContact(view.getContext(), view, lookupUri,
+ ContactsContract.QuickContact.MODE_LARGE, null);
+ } else if (!TextUtils.isEmpty(normalizedDestination) && !TextUtils.equals(
+ normalizedDestination, ParticipantData.getUnknownSenderDestination())) {
+ final AddContactsConfirmationDialog dialog = new AddContactsConfirmationDialog(
+ view.getContext(), avatarUri, normalizedDestination);
+ dialog.show();
+ }
+ }
+
+ @VisibleForTesting
+ public static CursorQueryData getSelf(final Context context) {
+ if (!ContactUtil.hasReadContactsPermission()) {
+ return CursorQueryData.getEmptyQueryData();
+ }
+ return new CursorQueryData(context, Profile.CONTENT_URI, SelfQuery.PROJECTION, null, null,
+ null);
+ }
+
+ /**
+ * Get a list of phones sorted by contact name. One contact may have multiple phones.
+ * In that case, each phone will be returned as a separate record in the result cursor.
+ */
+ @VisibleForTesting
+ public static CursorQueryData getPhones(final Context context) {
+ if (!ContactUtil.hasReadContactsPermission()) {
+ return CursorQueryData.getEmptyQueryData();
+ }
+
+ // The AOSP Contacts provider allows adding a ContactsContract.REMOVE_DUPLICATE_ENTRIES
+ // query parameter that removes duplicate (raw) numbers. Unfortunately, we can't use that
+ // because it causes the some phones' contacts provider to return incorrect sections.
+ final Uri uri = Phone.CONTENT_URI.buildUpon().appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT))
+ .appendQueryParameter(Contacts.EXTRA_ADDRESS_BOOK_INDEX, "true")
+ .build();
+
+ return new CursorQueryData(context, uri, PhoneQuery.PROJECTION, null, null,
+ PhoneQuery.SORT_KEY);
+ }
+
+ /**
+ * Lookup a destination (phone, email). Supplied destination should be a relatively complete
+ * one for this to succeed. PhoneLookup / EmailLookup URI will apply some smartness to do a
+ * loose match to see whether there is a contact that matches this destination.
+ */
+ public static CursorQueryData lookupDestination(final Context context,
+ final String destination) {
+ if (MmsSmsUtils.isEmailAddress(destination)) {
+ return ContactUtil.lookupEmail(context, destination);
+ } else {
+ return ContactUtil.lookupPhone(context, destination);
+ }
+ }
+
+ /**
+ * Returns whether the search text indicates an email based search or a phone number based one.
+ */
+ private static boolean shouldFilterForEmail(final String searchText) {
+ return searchText != null && searchText.contains("@");
+ }
+
+ /**
+ * Get a list of destinations (phone, email) matching the partial destination.
+ */
+ public static CursorQueryData filterDestination(final Context context,
+ final String destination) {
+ if (shouldFilterForEmail(destination)) {
+ return ContactUtil.filterEmails(context, destination);
+ } else {
+ return ContactUtil.filterPhones(context, destination);
+ }
+ }
+
+ /**
+ * Get a list of phones matching a search criteria. The search may be on contact name or
+ * phone number. In case search is on contact name, all matching contact's phone number
+ * will be returned.
+ * NOTE: This is visible for testing only, clients should only call filterDestination() since
+ * we support email addresses as well.
+ */
+ @VisibleForTesting
+ public static CursorQueryData filterPhones(final Context context, final String query) {
+ if (!ContactUtil.hasReadContactsPermission()) {
+ return CursorQueryData.getEmptyQueryData();
+ }
+
+ final Uri uri = Phone.CONTENT_FILTER_URI.buildUpon()
+ .appendPath(query).appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT))
+ .build();
+
+ return new CursorQueryData(context, uri, PhoneQuery.PROJECTION, null, null,
+ PhoneQuery.SORT_KEY);
+ }
+
+ /**
+ * Lookup a phone based on a phone number. Supplied phone should be a relatively complete
+ * phone number for this to succeed. PhoneLookup URI will apply some smartness to do a
+ * loose match to see whether there is a contact that matches this phone.
+ * NOTE: This is visible for testing only, clients should only call lookupDestination() since
+ * we support email addresses as well.
+ */
+ @VisibleForTesting
+ public static CursorQueryData lookupPhone(final Context context, final String phone) {
+ if (!ContactUtil.hasReadContactsPermission()) {
+ return CursorQueryData.getEmptyQueryData();
+ }
+
+ final Uri uri = getPhoneLookupUri().buildUpon()
+ .appendPath(phone).build();
+
+ return new CursorQueryData(context, uri, PhoneLookupQuery.PROJECTION, null, null, null);
+ }
+
+ /**
+ * Get frequently contacted people. This queries for Contacts.CONTENT_STREQUENT_URI, which
+ * includes both starred or frequently contacted people.
+ */
+ public static CursorQueryData getFrequentContacts(final Context context) {
+ if (!ContactUtil.hasReadContactsPermission()) {
+ return CursorQueryData.getEmptyQueryData();
+ }
+
+ return new FrequentContactsCursorQueryData(context, FrequentContactQuery.PROJECTION,
+ null, null, null);
+ }
+
+ /**
+ * Get a list of emails matching a search criteria. In Bugle, since email is not a common
+ * usage scenario, we should only do email search after user typed in a query indicating
+ * an intention to search by email (for example, "joe@").
+ * NOTE: This is visible for testing only, clients should only call filterDestination() since
+ * we support email addresses as well.
+ */
+ @VisibleForTesting
+ public static CursorQueryData filterEmails(final Context context, final String query) {
+ if (!ContactUtil.hasReadContactsPermission()) {
+ return CursorQueryData.getEmptyQueryData();
+ }
+
+ final Uri uri = Email.CONTENT_FILTER_URI.buildUpon()
+ .appendPath(query).appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT))
+ .build();
+
+ return new CursorQueryData(context, uri, EmailQuery.PROJECTION, null, null,
+ EmailQuery.SORT_KEY);
+ }
+
+ /**
+ * Lookup emails based a complete email address. Since there is no special logic needed for
+ * email lookup, this simply calls filterEmails.
+ * NOTE: This is visible for testing only, clients should only call lookupDestination() since
+ * we support email addresses as well.
+ */
+ @VisibleForTesting
+ public static CursorQueryData lookupEmail(final Context context, final String email) {
+ if (!ContactUtil.hasReadContactsPermission()) {
+ return CursorQueryData.getEmptyQueryData();
+ }
+
+ final Uri uri = getEmailContentLookupUri().buildUpon()
+ .appendPath(email).appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT))
+ .build();
+
+ return new CursorQueryData(context, uri, EmailQuery.PROJECTION, null, null,
+ EmailQuery.SORT_KEY);
+ }
+
+ /**
+ * Looks up the structured name for a contact.
+ *
+ * @param primaryOnly If there are multiple raw contacts, set this flag to return only the
+ * name used as the primary display name. Otherwise, this method returns all names.
+ */
+ private static CursorQueryData lookupStructuredName(final Context context, final long contactId,
+ final boolean primaryOnly) {
+ if (!ContactUtil.hasReadContactsPermission()) {
+ return CursorQueryData.getEmptyQueryData();
+ }
+
+ // TODO: Handle enterprise contacts
+ final Uri uri = ContactsContract.Contacts.CONTENT_URI.buildUpon()
+ .appendPath(String.valueOf(contactId))
+ .appendPath(ContactsContract.Contacts.Data.CONTENT_DIRECTORY).build();
+
+ String selection = ContactsContract.Data.MIMETYPE + "=?";
+ final String[] selectionArgs = {
+ StructuredName.CONTENT_ITEM_TYPE
+ };
+ if (primaryOnly) {
+ selection += " AND " + Contacts.DISPLAY_NAME_PRIMARY + "="
+ + StructuredName.DISPLAY_NAME;
+ }
+
+ return new CursorQueryData(context, uri,
+ StructuredNameQuery.PROJECTION, selection, selectionArgs, null);
+ }
+
+ /**
+ * Looks up the first name for a contact. If there are multiple raw
+ * contacts, this returns the name that is associated with the contact's
+ * primary display name. The name is null when contact id does not exist
+ * (possibly because it is a corp contact) or it does not have a first name.
+ */
+ public static String lookupFirstName(final Context context, final long contactId) {
+ if (isEnterpriseContactId(contactId)) {
+ return null;
+ }
+ String firstName = null;
+ Cursor nameCursor = null;
+ try {
+ nameCursor = ContactUtil.lookupStructuredName(context, contactId, true)
+ .performSynchronousQuery();
+ if (nameCursor != null && nameCursor.moveToFirst()) {
+ firstName = nameCursor.getString(ContactUtil.INDEX_STRUCTURED_NAME_GIVEN_NAME);
+ }
+ } finally {
+ if (nameCursor != null) {
+ nameCursor.close();
+ }
+ }
+ return firstName;
+ }
+
+ /**
+ * Creates a RecipientEntry from the provided data fields (from the contacts cursor).
+ * @param firstLevel whether this item is the first entry of this contact in the list.
+ */
+ public static RecipientEntry createRecipientEntry(final String displayName,
+ final int displayNameSource, final String destination, final int destinationType,
+ final String destinationLabel, final long contactId, final String lookupKey,
+ final long dataId, final String photoThumbnailUri, final boolean firstLevel) {
+ if (firstLevel) {
+ return RecipientEntry.constructTopLevelEntry(displayName, displayNameSource,
+ destination, destinationType, destinationLabel, contactId, null, dataId,
+ photoThumbnailUri, true, lookupKey);
+ } else {
+ return RecipientEntry.constructSecondLevelEntry(displayName, displayNameSource,
+ destination, destinationType, destinationLabel, contactId, null, dataId,
+ photoThumbnailUri, true, lookupKey);
+ }
+ }
+
+ /**
+ * Creates a RecipientEntry for PhoneQuery result. The result is then displayed in the
+ * contact search drop down or as replacement chips in the chips edit box.
+ */
+ public static RecipientEntry createRecipientEntryForPhoneQuery(final Cursor cursor,
+ final boolean isFirstLevel) {
+ final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID);
+ final String displayName = cursor.getString(
+ ContactUtil.INDEX_DISPLAY_NAME);
+ final String photoThumbnailUri = cursor.getString(
+ ContactUtil.INDEX_PHOTO_URI);
+ final String destination = cursor.getString(
+ ContactUtil.INDEX_PHONE_EMAIL);
+ final int destinationType = cursor.getInt(
+ ContactUtil.INDEX_PHONE_EMAIL_TYPE);
+ final String destinationLabel = cursor.getString(
+ ContactUtil.INDEX_PHONE_EMAIL_LABEL);
+ final String lookupKey = cursor.getString(
+ ContactUtil.INDEX_LOOKUP_KEY);
+
+ // PhoneQuery uses the contact id as the data id ("_id").
+ final long dataId = contactId;
+
+ return createRecipientEntry(displayName,
+ DisplayNameSources.STRUCTURED_NAME, destination, destinationType,
+ destinationLabel, contactId, lookupKey, dataId, photoThumbnailUri,
+ isFirstLevel);
+ }
+
+ /**
+ * Returns if a given contact id is valid.
+ */
+ public static boolean isValidContactId(final long contactId) {
+ return contactId >= 0;
+ }
+
+ /**
+ * Returns if a given contact id belongs to managed profile.
+ */
+ public static boolean isEnterpriseContactId(final long contactId) {
+ return isWorkProfileSupported()
+ && ContactsContract.Contacts.isEnterpriseContactId(contactId);
+ }
+
+ /**
+ * Returns if managed profile is supported.
+ */
+ public static boolean isWorkProfileSupported() {
+ final PackageManager pm = Factory.get().getApplicationContext().getPackageManager();
+ return pm.hasSystemFeature(PackageManager.FEATURE_MANAGED_USERS);
+ }
+
+ /**
+ * Returns Email lookup uri that will query both primary and corp profile
+ */
+ private static Uri getEmailContentLookupUri() {
+ if (isWorkProfileSupported() && OsUtil.isAtLeastM()) {
+ // TODO: use Email.ENTERPRISE_CONTENT_LOOKUP_URI, which will be available in M SDK API
+ return Uri.parse("content://com.android.contacts/data/emails/lookup_enterprise");
+ }
+ return Email.CONTENT_LOOKUP_URI;
+ }
+
+ /**
+ * Returns PhoneLookup URI.
+ */
+ public static Uri getPhoneLookupUri() {
+ // Apply it to M only
+ if (isWorkProfileSupported() && OsUtil.isAtLeastM()) {
+ return PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI;
+ }
+ return PhoneLookup.CONTENT_FILTER_URI;
+ }
+
+ public static boolean hasReadContactsPermission() {
+ return OsUtil.hasPermission(Manifest.permission.READ_CONTACTS);
+ }
+}
diff --git a/src/com/android/messaging/util/ContentType.java b/src/com/android/messaging/util/ContentType.java
new file mode 100644
index 0000000..bb4a7b2
--- /dev/null
+++ b/src/com/android/messaging/util/ContentType.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright (C) 2007-2008 Esmertec AG.
+ * Copyright (C) 2007-2008 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.messaging.util;
+
+import android.webkit.MimeTypeMap;
+
+public final class ContentType {
+ public static String THREE_GPP_EXTENSION = "3gp";
+ public static String VIDEO_MP4_EXTENSION = "mp4";
+ // Default extension used when we don't know one.
+ public static String DEFAULT_EXTENSION = "dat";
+
+ public static final int TYPE_IMAGE = 0;
+ public static final int TYPE_VIDEO = 1;
+ public static final int TYPE_AUDIO = 2;
+ public static final int TYPE_VCARD = 3;
+ public static final int TYPE_OTHER = 4;
+
+ public static final String ANY_TYPE = "*/*";
+ public static final String MMS_MESSAGE = "application/vnd.wap.mms-message";
+ // The phony content type for generic PDUs (e.g. ReadOrig.ind,
+ // Notification.ind, Delivery.ind).
+ public static final String MMS_GENERIC = "application/vnd.wap.mms-generic";
+ public static final String MMS_MULTIPART_MIXED = "application/vnd.wap.multipart.mixed";
+ public static final String MMS_MULTIPART_RELATED = "application/vnd.wap.multipart.related";
+ public static final String MMS_MULTIPART_ALTERNATIVE =
+ "application/vnd.wap.multipart.alternative";
+
+ public static final String TEXT_PLAIN = "text/plain";
+ public static final String TEXT_HTML = "text/html";
+ public static final String TEXT_VCALENDAR = "text/x-vCalendar";
+ public static final String TEXT_VCARD = "text/x-vCard";
+
+ public static final String IMAGE_PREFIX = "image/";
+ public static final String IMAGE_UNSPECIFIED = "image/*";
+ public static final String IMAGE_JPEG = "image/jpeg";
+ public static final String IMAGE_JPG = "image/jpg";
+ public static final String IMAGE_GIF = "image/gif";
+ public static final String IMAGE_WBMP = "image/vnd.wap.wbmp";
+ public static final String IMAGE_PNG = "image/png";
+ public static final String IMAGE_X_MS_BMP = "image/x-ms-bmp";
+
+ public static final String AUDIO_UNSPECIFIED = "audio/*";
+ public static final String AUDIO_AAC = "audio/aac";
+ public static final String AUDIO_AMR = "audio/amr";
+ public static final String AUDIO_IMELODY = "audio/imelody";
+ public static final String AUDIO_MID = "audio/mid";
+ public static final String AUDIO_MIDI = "audio/midi";
+ public static final String AUDIO_MP3 = "audio/mp3";
+ public static final String AUDIO_MPEG3 = "audio/mpeg3";
+ public static final String AUDIO_MPEG = "audio/mpeg";
+ public static final String AUDIO_MPG = "audio/mpg";
+ public static final String AUDIO_MP4 = "audio/mp4";
+ public static final String AUDIO_MP4_LATM = "audio/mp4-latm";
+ public static final String AUDIO_X_MID = "audio/x-mid";
+ public static final String AUDIO_X_MIDI = "audio/x-midi";
+ public static final String AUDIO_X_MP3 = "audio/x-mp3";
+ public static final String AUDIO_X_MPEG3 = "audio/x-mpeg3";
+ public static final String AUDIO_X_MPEG = "audio/x-mpeg";
+ public static final String AUDIO_X_MPG = "audio/x-mpg";
+ public static final String AUDIO_3GPP = "audio/3gpp";
+ public static final String AUDIO_X_WAV = "audio/x-wav";
+ public static final String AUDIO_OGG = "application/ogg";
+
+ public static final String MULTIPART_MIXED = "multipart/mixed";
+
+ public static final String VIDEO_UNSPECIFIED = "video/*";
+ public static final String VIDEO_3GP = "video/3gp";
+ public static final String VIDEO_3GPP = "video/3gpp";
+ public static final String VIDEO_3G2 = "video/3gpp2";
+ public static final String VIDEO_H263 = "video/h263";
+ public static final String VIDEO_M4V = "video/m4v";
+ public static final String VIDEO_MP4 = "video/mp4";
+ public static final String VIDEO_MPEG = "video/mpeg";
+ public static final String VIDEO_MPEG4 = "video/mpeg4";
+ public static final String VIDEO_WEBM = "video/webm";
+
+ public static final String APP_SMIL = "application/smil";
+ public static final String APP_WAP_XHTML = "application/vnd.wap.xhtml+xml";
+ public static final String APP_XHTML = "application/xhtml+xml";
+
+ public static final String APP_DRM_CONTENT = "application/vnd.oma.drm.content";
+ public static final String APP_DRM_MESSAGE = "application/vnd.oma.drm.message";
+
+ // This class should never be instantiated.
+ private ContentType() {
+ }
+
+ public static boolean isTextType(final String contentType) {
+ return TEXT_PLAIN.equals(contentType)
+ || TEXT_HTML.equals(contentType)
+ || APP_WAP_XHTML.equals(contentType);
+ }
+
+ public static boolean isMediaType(final String contentType) {
+ return isImageType(contentType)
+ || isVideoType(contentType)
+ || isAudioType(contentType)
+ || isVCardType(contentType);
+ }
+
+ public static boolean isImageType(final String contentType) {
+ return (null != contentType) && contentType.startsWith(IMAGE_PREFIX);
+ }
+
+ public static boolean isAudioType(final String contentType) {
+ return (null != contentType) &&
+ (contentType.startsWith("audio/") || contentType.equalsIgnoreCase(AUDIO_OGG));
+ }
+
+ public static boolean isVideoType(final String contentType) {
+ return (null != contentType) && contentType.startsWith("video/");
+ }
+
+ public static boolean isVCardType(final String contentType) {
+ return (null != contentType) && contentType.equalsIgnoreCase(TEXT_VCARD);
+ }
+
+ public static boolean isDrmType(final String contentType) {
+ return (null != contentType)
+ && (contentType.equals(APP_DRM_CONTENT)
+ || contentType.equals(APP_DRM_MESSAGE));
+ }
+
+ public static boolean isUnspecified(final String contentType) {
+ return (null != contentType) && contentType.endsWith("*");
+ }
+
+ /**
+ * If the content type is a type which can be displayed in the conversation list as a preview.
+ */
+ public static boolean isConversationListPreviewableType(final String contentType) {
+ return ContentType.isAudioType(contentType) || ContentType.isVideoType(contentType) ||
+ ContentType.isImageType(contentType) || ContentType.isVCardType(contentType);
+ }
+
+ /**
+ * Given a filename, look at the extension and try and determine the mime type.
+ *
+ * @param fileName a filename to determine the type from, such as img1231.jpg
+ * @param contentTypeDefault type to use when the content type can't be determined from the file
+ * extension. It can be null or a type such as ContentType.IMAGE_UNSPECIFIED
+ * @return Content type of the extension.
+ */
+ public static String getContentTypeFromExtension(final String fileName,
+ final String contentTypeDefault) {
+ final MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
+ final String extension = MimeTypeMap.getFileExtensionFromUrl(fileName);
+ String contentType = mimeTypeMap.getMimeTypeFromExtension(extension);
+ if (contentType == null) {
+ contentType = contentTypeDefault;
+ }
+ return contentType;
+ }
+
+ /**
+ * Get the common file extension for a given content type
+ * @param contentType The content type
+ * @return The extension without the .
+ */
+ public static String getExtension(final String contentType) {
+ if (VIDEO_MP4.equals(contentType)) {
+ return VIDEO_MP4_EXTENSION;
+ } else if (VIDEO_3GPP.equals(contentType)) {
+ return THREE_GPP_EXTENSION;
+ } else {
+ return DEFAULT_EXTENSION;
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/ConversationIdSet.java b/src/com/android/messaging/util/ConversationIdSet.java
new file mode 100644
index 0000000..75bf634
--- /dev/null
+++ b/src/com/android/messaging/util/ConversationIdSet.java
@@ -0,0 +1,69 @@
+/*
+ * 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.messaging.util;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+
+/**
+ * Utility class to make it easy to store multiple conversation id strings in a single string
+ * with delimeters.
+ */
+public class ConversationIdSet extends HashSet<String> {
+ private static final String JOIN_DELIMITER = "|";
+ private static final String SPLIT_DELIMITER = "\\|";
+
+ public ConversationIdSet() {
+ super();
+ }
+
+ public ConversationIdSet(final Collection<String> asList) {
+ super(asList);
+ }
+
+ public String first() {
+ if (size() > 0) {
+ return iterator().next();
+ } else {
+ return null;
+ }
+ }
+
+ public static ConversationIdSet createSet(final String conversationIdSetString) {
+ ConversationIdSet set = null;
+ if (conversationIdSetString != null) {
+ set = new ConversationIdSet(Arrays.asList(conversationIdSetString.split(
+ SPLIT_DELIMITER)));
+ }
+ return set;
+ }
+
+ public String getDelimitedString() {
+ return OsUtil.joinFromSetWithDelimiter(this, JOIN_DELIMITER);
+ }
+
+ public static String join(final String conversationIdSet1, final String conversationIdSet2) {
+ String joined = null;
+ if (conversationIdSet1 == null) {
+ joined = conversationIdSet2;
+ } else if (conversationIdSet2 != null) {
+ joined = conversationIdSet1 + JOIN_DELIMITER + conversationIdSet2;
+ }
+ return joined;
+ }
+
+}
diff --git a/src/com/android/messaging/util/CubicBezierInterpolator.java b/src/com/android/messaging/util/CubicBezierInterpolator.java
new file mode 100644
index 0000000..317b3e6
--- /dev/null
+++ b/src/com/android/messaging/util/CubicBezierInterpolator.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util;
+
+import android.view.animation.Interpolator;
+
+/**
+ * Class that acts as an interpolator to match the cubic-bezier css timing function where p0 is
+ * fixed at 0,0 and p3 is fixed at 1,1
+ */
+public class CubicBezierInterpolator implements Interpolator {
+ private final float mX1;
+ private final float mY1;
+ private final float mX2;
+ private final float mY2;
+
+ public CubicBezierInterpolator(final float x1, final float y1, final float x2, final float y2) {
+ mX1 = x1;
+ mY1 = y1;
+ mX2 = x2;
+ mY2 = y2;
+ }
+
+ @Override
+ public float getInterpolation(float v) {
+ return getY(getTForXValue(v));
+ }
+
+ private float getX(final float t) {
+ return getCoordinate(t, mX1, mX2);
+ }
+
+ private float getY(final float t) {
+ return getCoordinate(t, mY1, mY2);
+ }
+
+ private float getCoordinate(float t, float p1, float p2) {
+ // Special case start and end.
+ if (t == 0.0f || t == 1.0f) {
+ return t;
+ }
+
+ // Step one - from 4 points to 3.
+ float ip0 = linearInterpolate(0, p1, t);
+ float ip1 = linearInterpolate(p1, p2, t);
+ float ip2 = linearInterpolate(p2, 1, t);
+
+ // Step two - from 3 points to 2.
+ ip0 = linearInterpolate(ip0, ip1, t);
+ ip1 = linearInterpolate(ip1, ip2, t);
+
+ // Final step - last point.
+ return linearInterpolate(ip0, ip1, t);
+ }
+
+ private float linearInterpolate(float a, float b, float progress) {
+ return a + (b - a) * progress;
+ }
+
+ private float getTForXValue(final float x) {
+ final float epsilon = 1e-6f;
+ final int iterations = 8;
+
+ if (x <= 0.0f) {
+ return 0.0f;
+ } else if (x >= 1.0f) {
+ return 1.0f;
+ }
+
+ // Try gradient descent to solve for t. If it works, it is very fast.
+ float t = x;
+ float minT = 0.0f;
+ float maxT = 1.0f;
+ float value = 0.0f;
+ for (int i = 0; i < iterations; i++) {
+ value = getX(t);
+ double derivative = (getX(t + epsilon) - value) / epsilon;
+ if (Math.abs(value - x) < epsilon) {
+ return t;
+ } else if (Math.abs(derivative) < epsilon) {
+ break;
+ } else {
+ if (value < x) {
+ minT = t;
+ } else {
+ maxT = t;
+ }
+ t -= (value - x) / derivative;
+ }
+ }
+
+ // If the gradient descent got stuck in a local minimum, e.g. because the
+ // derivative was close to 0, use an interval bisection instead.
+ for (int i = 0; Math.abs(value - x) > epsilon && i < iterations; i++) {
+ if (value < x) {
+ minT = t;
+ t = (t + maxT) / 2.0f;
+ } else {
+ maxT = t;
+ t = (t + minT) / 2.0f;
+ }
+ value = getX(t);
+ }
+ return t;
+ }
+}
diff --git a/src/com/android/messaging/util/Dates.java b/src/com/android/messaging/util/Dates.java
new file mode 100644
index 0000000..d012dfd
--- /dev/null
+++ b/src/com/android/messaging/util/Dates.java
@@ -0,0 +1,280 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.text.format.DateUtils;
+import android.text.format.Time;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * Collection of date utilities.
+ */
+public class Dates {
+ public static final long SECOND_IN_MILLIS = 1000;
+ public static final long MINUTE_IN_MILLIS = SECOND_IN_MILLIS * 60;
+ public static final long HOUR_IN_MILLIS = MINUTE_IN_MILLIS * 60;
+ public static final long DAY_IN_MILLIS = HOUR_IN_MILLIS * 24;
+ public static final long WEEK_IN_MILLIS = DAY_IN_MILLIS * 7;
+
+ // Flags to specify whether or not to use 12 or 24 hour mode.
+ // Callers of methods in this class should never have to specify these; this is really
+ // intended only for unit tests.
+ @SuppressWarnings("deprecation")
+ @VisibleForTesting public static final int FORCE_12_HOUR = DateUtils.FORMAT_12HOUR;
+ @SuppressWarnings("deprecation")
+ @VisibleForTesting public static final int FORCE_24_HOUR = DateUtils.FORMAT_24HOUR;
+
+ /**
+ * Private default constructor
+ */
+ private Dates() {
+ }
+
+ private static Context getContext() {
+ return Factory.get().getApplicationContext();
+ }
+ /**
+ * Get the relative time as a string
+ *
+ * @param time The time
+ *
+ * @return The relative time
+ */
+ public static CharSequence getRelativeTimeSpanString(final long time) {
+ final long now = System.currentTimeMillis();
+ if (now - time < DateUtils.MINUTE_IN_MILLIS) {
+ // Also fixes bug where posts appear in the future
+ return getContext().getResources().getText(R.string.posted_just_now);
+ }
+
+ // Workaround for b/5657035. The platform method {@link DateUtils#getRelativeTimeSpan()}
+ // passes a null context to other platform methods. However, on some devices, this
+ // context is dereferenced when it shouldn't be and an NPE is thrown. We catch that
+ // here and use a slightly less precise time.
+ try {
+ return DateUtils.getRelativeTimeSpanString(time, now, DateUtils.MINUTE_IN_MILLIS,
+ DateUtils.FORMAT_ABBREV_RELATIVE).toString();
+ } catch (final NullPointerException npe) {
+ return getShortRelativeTimeSpanString(time);
+ }
+ }
+
+ public static CharSequence getConversationTimeString(final long time) {
+ return getTimeString(time, true /*abbreviated*/, false /*minPeriodToday*/);
+ }
+
+ public static CharSequence getMessageTimeString(final long time) {
+ return getTimeString(time, false /*abbreviated*/, false /*minPeriodToday*/);
+ }
+
+ public static CharSequence getWidgetTimeString(final long time, final boolean abbreviated) {
+ return getTimeString(time, abbreviated, true /*minPeriodToday*/);
+ }
+
+ public static CharSequence getFastScrollPreviewTimeString(final long time) {
+ return getTimeString(time, true /* abbreviated */, true /* minPeriodToday */);
+ }
+
+ public static CharSequence getMessageDetailsTimeString(final long time) {
+ final Context context = getContext();
+ int flags;
+ if (android.text.format.DateFormat.is24HourFormat(context)) {
+ flags = FORCE_24_HOUR;
+ } else {
+ flags = FORCE_12_HOUR;
+ }
+ return getOlderThanAYearTimestamp(time,
+ context.getResources().getConfiguration().locale, false /*abbreviated*/,
+ flags);
+ }
+
+ private static CharSequence getTimeString(final long time, final boolean abbreviated,
+ final boolean minPeriodToday) {
+ final Context context = getContext();
+ int flags;
+ if (android.text.format.DateFormat.is24HourFormat(context)) {
+ flags = FORCE_24_HOUR;
+ } else {
+ flags = FORCE_12_HOUR;
+ }
+ return getTimestamp(time, System.currentTimeMillis(), abbreviated,
+ context.getResources().getConfiguration().locale, flags, minPeriodToday);
+ }
+
+ @VisibleForTesting
+ public static CharSequence getTimestamp(final long time, final long now,
+ final boolean abbreviated, final Locale locale, final int flags,
+ final boolean minPeriodToday) {
+ final long timeDiff = now - time;
+
+ if (!minPeriodToday && timeDiff < DateUtils.MINUTE_IN_MILLIS) {
+ return getLessThanAMinuteOldTimeString(abbreviated);
+ } else if (!minPeriodToday && timeDiff < DateUtils.HOUR_IN_MILLIS) {
+ return getLessThanAnHourOldTimeString(timeDiff, flags);
+ } else if (getNumberOfDaysPassed(time, now) == 0) {
+ return getTodayTimeStamp(time, flags);
+ } else if (timeDiff < DateUtils.WEEK_IN_MILLIS) {
+ return getThisWeekTimestamp(time, locale, abbreviated, flags);
+ } else if (timeDiff < DateUtils.YEAR_IN_MILLIS) {
+ return getThisYearTimestamp(time, locale, abbreviated, flags);
+ } else {
+ return getOlderThanAYearTimestamp(time, locale, abbreviated, flags);
+ }
+ }
+
+ private static CharSequence getLessThanAMinuteOldTimeString(
+ final boolean abbreviated) {
+ return getContext().getResources().getText(
+ abbreviated ? R.string.posted_just_now : R.string.posted_now);
+ }
+
+ private static CharSequence getLessThanAnHourOldTimeString(final long timeDiff,
+ final int flags) {
+ final long count = (timeDiff / MINUTE_IN_MILLIS);
+ final String format = getContext().getResources().getQuantityString(
+ R.plurals.num_minutes_ago, (int) count);
+ return String.format(format, count);
+ }
+
+ private static CharSequence getTodayTimeStamp(final long time, final int flags) {
+ return DateUtils.formatDateTime(getContext(), time,
+ DateUtils.FORMAT_SHOW_TIME | flags);
+ }
+
+ private static CharSequence getExplicitFormattedTime(final long time, final int flags,
+ final String format24, final String format12) {
+ SimpleDateFormat formatter;
+ if ((flags & FORCE_24_HOUR) == FORCE_24_HOUR) {
+ formatter = new SimpleDateFormat(format24);
+ } else {
+ formatter = new SimpleDateFormat(format12);
+ }
+ return formatter.format(new Date(time));
+ }
+
+ private static CharSequence getThisWeekTimestamp(final long time,
+ final Locale locale, final boolean abbreviated, final int flags) {
+ final Context context = getContext();
+ if (abbreviated) {
+ return DateUtils.formatDateTime(context, time,
+ DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_ABBREV_WEEKDAY | flags);
+ } else {
+ if (locale.equals(Locale.US)) {
+ return getExplicitFormattedTime(time, flags, "EEE HH:mm", "EEE h:mmaa");
+ } else {
+ return DateUtils.formatDateTime(context, time,
+ DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_TIME
+ | DateUtils.FORMAT_ABBREV_WEEKDAY
+ | flags);
+ }
+ }
+ }
+
+ private static CharSequence getThisYearTimestamp(final long time, final Locale locale,
+ final boolean abbreviated, final int flags) {
+ final Context context = getContext();
+ if (abbreviated) {
+ return DateUtils.formatDateTime(context, time,
+ DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_ABBREV_MONTH
+ | DateUtils.FORMAT_NO_YEAR | flags);
+ } else {
+ if (locale.equals(Locale.US)) {
+ return getExplicitFormattedTime(time, flags, "MMM d, HH:mm", "MMM d, h:mmaa");
+ } else {
+ return DateUtils.formatDateTime(context, time,
+ DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME
+ | DateUtils.FORMAT_ABBREV_MONTH
+ | DateUtils.FORMAT_NO_YEAR
+ | flags);
+ }
+ }
+ }
+
+ private static CharSequence getOlderThanAYearTimestamp(final long time,
+ final Locale locale, final boolean abbreviated, final int flags) {
+ final Context context = getContext();
+ if (abbreviated) {
+ if (locale.equals(Locale.US)) {
+ return getExplicitFormattedTime(time, flags, "M/d/yy", "M/d/yy");
+ } else {
+ return DateUtils.formatDateTime(context, time,
+ DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR
+ | DateUtils.FORMAT_NUMERIC_DATE);
+ }
+ } else {
+ if (locale.equals(Locale.US)) {
+ return getExplicitFormattedTime(time, flags, "M/d/yy, HH:mm", "M/d/yy, h:mmaa");
+ } else {
+ return DateUtils.formatDateTime(context, time,
+ DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME
+ | DateUtils.FORMAT_NUMERIC_DATE | DateUtils.FORMAT_SHOW_YEAR
+ | flags);
+ }
+ }
+ }
+
+ public static CharSequence getShortRelativeTimeSpanString(final long time) {
+ final long now = System.currentTimeMillis();
+ final long duration = Math.abs(now - time);
+
+ int resId;
+ long count;
+
+ final Context context = getContext();
+
+ if (duration < HOUR_IN_MILLIS) {
+ count = duration / MINUTE_IN_MILLIS;
+ resId = R.plurals.num_minutes_ago;
+ } else if (duration < DAY_IN_MILLIS) {
+ count = duration / HOUR_IN_MILLIS;
+ resId = R.plurals.num_hours_ago;
+ } else if (duration < WEEK_IN_MILLIS) {
+ count = getNumberOfDaysPassed(time, now);
+ resId = R.plurals.num_days_ago;
+ } else {
+ // Although we won't be showing a time, there is a bug on some devices that use
+ // the passed in context. On these devices, passing in a {@code null} context
+ // here will generate an NPE. See b/5657035.
+ return DateUtils.formatDateRange(context, time, time,
+ DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_ABBREV_RELATIVE);
+ }
+
+ final String format = context.getResources().getQuantityString(resId, (int) count);
+ return String.format(format, count);
+ }
+
+ private static synchronized long getNumberOfDaysPassed(final long date1, final long date2) {
+ if (sThenTime == null) {
+ sThenTime = new Time();
+ }
+ sThenTime.set(date1);
+ final int day1 = Time.getJulianDay(date1, sThenTime.gmtoff);
+ sThenTime.set(date2);
+ final int day2 = Time.getJulianDay(date2, sThenTime.gmtoff);
+ return Math.abs(day2 - day1);
+ }
+
+ private static Time sThenTime;
+}
diff --git a/src/com/android/messaging/util/DebugUtils.java b/src/com/android/messaging/util/DebugUtils.java
new file mode 100644
index 0000000..f2c1d65
--- /dev/null
+++ b/src/com/android/messaging/util/DebugUtils.java
@@ -0,0 +1,425 @@
+/*
+ * 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.messaging.util;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.media.MediaPlayer;
+import android.os.Environment;
+import android.telephony.SmsMessage;
+import android.text.TextUtils;
+import android.widget.ArrayAdapter;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.SyncManager;
+import com.android.messaging.datamodel.action.DumpDatabaseAction;
+import com.android.messaging.datamodel.action.LogTelephonyDatabaseAction;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.debug.DebugSmsMmsFromDumpFileDialogFragment;
+import com.google.common.io.ByteStreams;
+
+import java.io.BufferedInputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.io.StreamCorruptedException;
+
+public class DebugUtils {
+ private static final String TAG = "bugle.util.DebugUtils";
+
+ private static boolean sDebugNoise;
+ private static boolean sDebugClassZeroSms;
+ private static MediaPlayer [] sMediaPlayer;
+ private static final Object sLock = new Object();
+
+ public static final int DEBUG_SOUND_SERVER_REQUEST = 0;
+ public static final int DEBUG_SOUND_DB_OP = 1;
+
+ public static void maybePlayDebugNoise(final Context context, final int sound) {
+ if (sDebugNoise) {
+ synchronized (sLock) {
+ try {
+ if (sMediaPlayer == null) {
+ sMediaPlayer = new MediaPlayer[2];
+ sMediaPlayer[DEBUG_SOUND_SERVER_REQUEST] =
+ MediaPlayer.create(context, R.raw.server_request_debug);
+ sMediaPlayer[DEBUG_SOUND_DB_OP] =
+ MediaPlayer.create(context, R.raw.db_op_debug);
+ sMediaPlayer[DEBUG_SOUND_DB_OP].setVolume(1.0F, 1.0F);
+ sMediaPlayer[DEBUG_SOUND_SERVER_REQUEST].setVolume(0.3F, 0.3F);
+ }
+ if (sMediaPlayer[sound] != null) {
+ sMediaPlayer[sound].start();
+ }
+ } catch (final IllegalArgumentException e) {
+ LogUtil.e(TAG, "MediaPlayer exception", e);
+ } catch (final SecurityException e) {
+ LogUtil.e(TAG, "MediaPlayer exception", e);
+ } catch (final IllegalStateException e) {
+ LogUtil.e(TAG, "MediaPlayer exception", e);
+ }
+ }
+ }
+ }
+
+ public static boolean isDebugEnabled() {
+ return BugleGservices.get().getBoolean(BugleGservicesKeys.ENABLE_DEBUGGING_FEATURES,
+ BugleGservicesKeys.ENABLE_DEBUGGING_FEATURES_DEFAULT);
+ }
+
+ public abstract static class DebugAction {
+ String mTitle;
+ public DebugAction(final String title) {
+ mTitle = title;
+ }
+
+ @Override
+ public String toString() {
+ return mTitle;
+ }
+
+ public abstract void run();
+ }
+
+ public static void showDebugOptions(final Activity host) {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(host);
+
+ final ArrayAdapter<DebugAction> arrayAdapter = new ArrayAdapter<DebugAction>(
+ host, android.R.layout.simple_list_item_1);
+
+ arrayAdapter.add(new DebugAction("Dump Database") {
+ @Override
+ public void run() {
+ DumpDatabaseAction.dumpDatabase();
+ }
+ });
+
+ arrayAdapter.add(new DebugAction("Log Telephony Data") {
+ @Override
+ public void run() {
+ LogTelephonyDatabaseAction.dumpDatabase();
+ }
+ });
+
+ arrayAdapter.add(new DebugAction("Toggle Noise") {
+ @Override
+ public void run() {
+ sDebugNoise = !sDebugNoise;
+ }
+ });
+
+ arrayAdapter.add(new DebugAction("Force sync SMS") {
+ @Override
+ public void run() {
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, -1);
+ SyncManager.forceSync();
+ }
+ });
+
+ arrayAdapter.add(new DebugAction("Sync SMS") {
+ @Override
+ public void run() {
+ SyncManager.sync();
+ }
+ });
+
+ arrayAdapter.add(new DebugAction("Load SMS/MMS from dump file") {
+ @Override
+ public void run() {
+ new DebugSmsMmsDumpTask(host,
+ DebugSmsMmsFromDumpFileDialogFragment.ACTION_LOAD).executeOnThreadPool();
+ }
+ });
+
+ arrayAdapter.add(new DebugAction("Email SMS/MMS dump file") {
+ @Override
+ public void run() {
+ new DebugSmsMmsDumpTask(host,
+ DebugSmsMmsFromDumpFileDialogFragment.ACTION_EMAIL).executeOnThreadPool();
+ }
+ });
+
+ arrayAdapter.add(new DebugAction("MMS Config...") {
+ @Override
+ public void run() {
+ UIIntents.get().launchDebugMmsConfigActivity(host);
+ }
+ });
+
+ arrayAdapter.add(new DebugAction(sDebugClassZeroSms ? "Turn off Class 0 sms test" :
+ "Turn on Class Zero test") {
+ @Override
+ public void run() {
+ sDebugClassZeroSms = !sDebugClassZeroSms;
+ }
+ });
+
+ builder.setAdapter(arrayAdapter,
+ new android.content.DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface arg0, final int pos) {
+ arrayAdapter.getItem(pos).run();
+ }
+ });
+
+ builder.create().show();
+ }
+
+ /**
+ * Task to list all the dump files and perform an action on it
+ */
+ private static class DebugSmsMmsDumpTask extends SafeAsyncTask<Void, Void, String[]> {
+ private final String mAction;
+ private final Activity mHost;
+
+ public DebugSmsMmsDumpTask(final Activity host, final String action) {
+ mHost = host;
+ mAction = action;
+ }
+
+ @Override
+ protected void onPostExecute(final String[] result) {
+ if (result == null || result.length < 1) {
+ return;
+ }
+ final FragmentManager fragmentManager = mHost.getFragmentManager();
+ final FragmentTransaction ft = fragmentManager.beginTransaction();
+ final DebugSmsMmsFromDumpFileDialogFragment dialog =
+ DebugSmsMmsFromDumpFileDialogFragment.newInstance(result, mAction);
+ dialog.show(fragmentManager, ""/*tag*/);
+ }
+
+ @Override
+ protected String[] doInBackgroundTimed(final Void... params) {
+ final File dir = DebugUtils.getDebugFilesDir();
+ return dir.list(new FilenameFilter() {
+ @Override
+ public boolean accept(final File dir, final String filename) {
+ return filename != null
+ && ((mAction == DebugSmsMmsFromDumpFileDialogFragment.ACTION_EMAIL
+ && filename.equals(DumpDatabaseAction.DUMP_NAME))
+ || filename.startsWith(MmsUtils.MMS_DUMP_PREFIX)
+ || filename.startsWith(MmsUtils.SMS_DUMP_PREFIX));
+ }
+ });
+ }
+ }
+
+ /**
+ * Dump the received raw SMS data into a file on external storage
+ *
+ * @param id The ID to use as part of the dump file name
+ * @param messages The raw SMS data
+ */
+ public static void dumpSms(final long id, final android.telephony.SmsMessage[] messages,
+ final String format) {
+ try {
+ final String dumpFileName = MmsUtils.SMS_DUMP_PREFIX + Long.toString(id);
+ final File dumpFile = DebugUtils.getDebugFile(dumpFileName, true);
+ if (dumpFile != null) {
+ final FileOutputStream fos = new FileOutputStream(dumpFile);
+ final DataOutputStream dos = new DataOutputStream(fos);
+ try {
+ final int chars = (TextUtils.isEmpty(format) ? 0 : format.length());
+ dos.writeInt(chars);
+ if (chars > 0) {
+ dos.writeUTF(format);
+ }
+ dos.writeInt(messages.length);
+ for (final android.telephony.SmsMessage message : messages) {
+ final byte[] pdu = message.getPdu();
+ dos.writeInt(pdu.length);
+ dos.write(pdu, 0, pdu.length);
+ }
+ dos.flush();
+ } finally {
+ dos.close();
+ ensureReadable(dumpFile);
+ }
+ }
+ } catch (final IOException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "dumpSms: " + e, e);
+ }
+ }
+
+ /**
+ * Load MMS/SMS from the dump file
+ */
+ public static SmsMessage[] retreiveSmsFromDumpFile(final String dumpFileName) {
+ SmsMessage[] messages = null;
+ final File inputFile = DebugUtils.getDebugFile(dumpFileName, false);
+ if (inputFile != null) {
+ FileInputStream fis = null;
+ DataInputStream dis = null;
+ try {
+ fis = new FileInputStream(inputFile);
+ dis = new DataInputStream(fis);
+
+ // SMS dump
+ final int chars = dis.readInt();
+ if (chars > 0) {
+ final String format = dis.readUTF();
+ }
+ final int count = dis.readInt();
+ final SmsMessage[] messagesTemp = new SmsMessage[count];
+ for (int i = 0; i < count; i++) {
+ final int length = dis.readInt();
+ final byte[] pdu = new byte[length];
+ dis.read(pdu, 0, length);
+ messagesTemp[i] = SmsMessage.createFromPdu(pdu);
+ }
+ messages = messagesTemp;
+ } catch (final FileNotFoundException e) {
+ // Nothing to do
+ } catch (final StreamCorruptedException e) {
+ // Nothing to do
+ } catch (final IOException e) {
+ // Nothing to do
+ } finally {
+ if (dis != null) {
+ try {
+ dis.close();
+ } catch (final IOException e) {
+ // Nothing to do
+ }
+ }
+ }
+ }
+ return messages;
+ }
+
+ public static File getDebugFile(final String fileName, final boolean create) {
+ final File dir = getDebugFilesDir();
+ final File file = new File(dir, fileName);
+ if (create && file.exists()) {
+ file.delete();
+ }
+ return file;
+ }
+
+ public static File getDebugFilesDir() {
+ final File dir = Environment.getExternalStorageDirectory();
+ return dir;
+ }
+
+ /**
+ * Load MMS/SMS from the dump file
+ */
+ public static byte[] receiveFromDumpFile(final String dumpFileName) {
+ byte[] data = null;
+ try {
+ final File inputFile = getDebugFile(dumpFileName, false);
+ if (inputFile != null) {
+ final FileInputStream fis = new FileInputStream(inputFile);
+ final BufferedInputStream bis = new BufferedInputStream(fis);
+ try {
+ // dump file
+ data = ByteStreams.toByteArray(bis);
+ if (data == null || data.length < 1) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "receiveFromDumpFile: empty data");
+ }
+ } finally {
+ bis.close();
+ }
+ }
+ } catch (final IOException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "receiveFromDumpFile: " + e, e);
+ }
+ return data;
+ }
+
+ public static void ensureReadable(final File file) {
+ if (file.exists()){
+ file.setReadable(true, false);
+ }
+ }
+
+ /**
+ * Logs the name of the method that is currently executing, e.g. "MyActivity.onCreate". This is
+ * useful for surgically adding logs for tracing execution while debugging.
+ * <p>
+ * NOTE: This method retrieves the current thread's stack trace, which adds runtime overhead.
+ * However, this method is only executed on eng builds if DEBUG logs are loggable.
+ */
+ public static void logCurrentMethod(String tag) {
+ if (!LogUtil.isLoggable(tag, LogUtil.DEBUG)) {
+ return;
+ }
+ StackTraceElement caller = getCaller(1);
+ if (caller == null) {
+ return;
+ }
+ String className = caller.getClassName();
+ // Strip off the package name
+ int lastDot = className.lastIndexOf('.');
+ if (lastDot > -1) {
+ className = className.substring(lastDot + 1);
+ }
+ LogUtil.d(tag, className + "." + caller.getMethodName());
+ }
+
+ /**
+ * Returns info about the calling method. The {@code depth} parameter controls how far back to
+ * go. For example, if foo() calls bar(), and bar() calls getCaller(0), it returns info about
+ * bar(). If bar() instead called getCaller(1), it would return info about foo(). And so on.
+ * <p>
+ * NOTE: This method retrieves the current thread's stack trace, which adds runtime overhead.
+ * It should only be used in production where necessary to gather context about an error or
+ * unexpected event (e.g. the {@link Assert} class uses it).
+ *
+ * @return stack frame information for the caller (if found); otherwise {@code null}.
+ */
+ public static StackTraceElement getCaller(int depth) {
+ // If the signature of this method is changed, proguard.flags must be updated!
+ if (depth < 0) {
+ throw new IllegalArgumentException("depth cannot be negative");
+ }
+ StackTraceElement[] trace = Thread.currentThread().getStackTrace();
+ if (trace == null || trace.length < (depth + 2)) {
+ return null;
+ }
+ // The stack trace includes some methods we don't care about (e.g. this method).
+ // Walk down until we find this method, and then back up to the caller we're looking for.
+ for (int i = 0; i < trace.length - 1; i++) {
+ String methodName = trace[i].getMethodName();
+ if ("getCaller".equals(methodName)) {
+ return trace[i + depth + 1];
+ }
+ }
+ // Never found ourself in the stack?!
+ return null;
+ }
+
+ /**
+ * Returns a boolean indicating whether ClassZero debugging is enabled. If enabled, any received
+ * sms is treated as if it were a class zero message and displayed by the ClassZeroActivity.
+ */
+ public static boolean debugClassZeroSmsEnabled() {
+ return sDebugClassZeroSms;
+ }
+}
diff --git a/src/com/android/messaging/util/EmailAddress.java b/src/com/android/messaging/util/EmailAddress.java
new file mode 100644
index 0000000..0c0dab9
--- /dev/null
+++ b/src/com/android/messaging/util/EmailAddress.java
@@ -0,0 +1,272 @@
+/*
+ * 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.messaging.util;
+
+import com.google.common.base.CharMatcher;
+
+/**
+ * Parsing the email address
+ */
+public final class EmailAddress {
+ private static final CharMatcher ANY_WHITESPACE = CharMatcher.anyOf(
+ " \t\n\r\f\u000B\u0085\u2028\u2029\u200D\uFFEF\uFFFD\uFFFE\uFFFF");
+ private static final CharMatcher EMAIL_ALLOWED_CHARS = CharMatcher.inRange((char) 0, (char) 31)
+ .or(CharMatcher.is((char) 127))
+ .or(CharMatcher.anyOf(" @,:<>"))
+ .negate();
+
+ /**
+ * Helper method that checks whether the input text is valid email address.
+ * TODO: This creates a new EmailAddress object each time
+ * Need to make it more lightweight by pulling out the validation code into a static method.
+ */
+ public static boolean isValidEmail(final String emailText) {
+ return new EmailAddress(emailText).isValid();
+ }
+
+ /**
+ * Parses the specified email address. Internationalized addresses are treated as invalid.
+ *
+ * @param emailString A string representing just an email address. It should
+ * not contain any other tokens. <code>"Name&lt;foo@example.org>"</code> won't be valid.
+ */
+ public EmailAddress(final String emailString) {
+ this(emailString, false);
+ }
+
+ /**
+ * Parses the specified email address.
+ *
+ * @param emailString A string representing just an email address. It should
+ * not contain any other tokens. <code>"Name&lt;foo@example.org>"</code> won't be valid.
+ * @param i18n Accept an internationalized address if it is true.
+ */
+ public EmailAddress(final String emailString, final boolean i18n) {
+ allowI18n = i18n;
+ valid = parseEmail(emailString);
+ }
+
+ /**
+ * Parses the specified email address. Internationalized addresses are treated as invalid.
+ *
+ * @param user A string representing the username in the email prior to the '@' symbol
+ * @param host A string representing the host following the '@' symbol
+ */
+ public EmailAddress(final String user, final String host) {
+ this(user, host, false);
+ }
+
+ /**
+ * Parses the specified email address.
+ *
+ * @param user A string representing the username in the email prior to the '@' symbol
+ * @param host A string representing the host following the '@' symbol
+ * @param i18n Accept an internationalized address if it is true.
+ */
+ public EmailAddress(final String user, final String host, final boolean i18n) {
+ allowI18n = i18n;
+ this.user = user;
+ setHost(host);
+ }
+
+ protected boolean parseEmail(final String emailString) {
+ // check for null
+ if (emailString == null) {
+ return false;
+ }
+
+ // Check for an '@' character. Get the last one, in case the local part is
+ // quoted. See http://b/1944742.
+ final int atIndex = emailString.lastIndexOf('@');
+ if ((atIndex <= 0) || // no '@' character in the email address
+ // or @ on the first position
+ (atIndex == (emailString.length() - 1))) { // last character, no host
+ return false;
+ }
+
+ user = emailString.substring(0, atIndex);
+ host = emailString.substring(atIndex + 1);
+
+ return isValidInternal();
+ }
+
+ @Override
+ public String toString() {
+ return user + "@" + host;
+ }
+
+ /**
+ * Ensure the email address is valid, conforming to current RFC2821 and
+ * RFC2822 guidelines (although some iffy characters, like ! and ;, are
+ * allowed because they are not technically prohibited in the RFC)
+ */
+ private boolean isValidInternal() {
+ if ((user == null) || (host == null)) {
+ return false;
+ }
+
+ if ((user.length() == 0) || (host.length() == 0)) {
+ return false;
+ }
+
+ // check for white space in the host
+ if (ANY_WHITESPACE.indexIn(host) >= 0) {
+ return false;
+ }
+
+ // ensure the host is above the minimum length
+ if (host.length() < 4) {
+ return false;
+ }
+
+ final int firstDot = host.indexOf('.');
+
+ // ensure host contains at least one dot
+ if (firstDot == -1) {
+ return false;
+ }
+
+ // check if the host contains two continuous dots.
+ if (host.indexOf("..") >= 0) {
+ return false;
+ }
+
+ // check if the first host char is a dot.
+ if (host.charAt(0) == '.') {
+ return false;
+ }
+
+ final int secondDot = host.indexOf(".", firstDot + 1);
+
+ // if there's a dot at the end, there needs to be a second dot
+ if (host.charAt(host.length() - 1) == '.' && secondDot == -1) {
+ return false;
+ }
+
+ // Host must not have any disallowed characters; allowI18n dictates whether
+ // host must be ASCII.
+ if (!EMAIL_ALLOWED_CHARS.matchesAllOf(host)
+ || (!allowI18n && !CharMatcher.ASCII.matchesAllOf(host))) {
+ return false;
+ }
+
+ if (user.startsWith("\"")) {
+ if (!isQuotedUserValid()) {
+ return false;
+ }
+ } else {
+ // check for white space in the user
+ if (ANY_WHITESPACE.indexIn(user) >= 0) {
+ return false;
+ }
+
+ // the user cannot contain two continuous dots
+ if (user.indexOf("..") >= 0) {
+ return false;
+ }
+
+ // User must not have any disallowed characters; allow I18n dictates whether
+ // user must be ASCII.
+ if (!EMAIL_ALLOWED_CHARS.matchesAllOf(user)
+ || (!allowI18n && !CharMatcher.ASCII.matchesAllOf(user))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean isQuotedUserValid() {
+ final int limit = user.length() - 1;
+ if (limit < 1 || !user.endsWith("\"")) {
+ return false;
+ }
+
+ // Unusual loop bounds (looking only at characters between the outer quotes,
+ // not at either quote character). Plus, i is manipulated within the loop.
+ for (int i = 1; i < limit; ++i) {
+ final char ch = user.charAt(i);
+ if (ch == '"' || ch == 127
+ // No non-whitespace control chars:
+ || (ch < 32 && !ANY_WHITESPACE.matches(ch))
+ // No non-ASCII chars, unless i18n is in effect:
+ || (ch >= 128 && !allowI18n)) {
+ return false;
+ } else if (ch == '\\') {
+ if (i + 1 < limit) {
+ ++i; // Skip the quoted character
+ } else {
+ // We have a trailing backslash -- so it can't be quoting anything.
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public boolean equals(final Object otherObject) {
+ // Do an instance check first as an optimization.
+ if (this == otherObject) {
+ return true;
+ }
+ if (otherObject instanceof EmailAddress) {
+ final EmailAddress otherAddress = (EmailAddress) otherObject;
+ return toString().equals(otherAddress.toString());
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ // Arbitrary hash code as a function of both host and user.
+ return toString().hashCode();
+ }
+
+ // accessors
+ public boolean isValid() {
+ return valid;
+ }
+
+ public String getUser() {
+ return user;
+ }
+
+ public String getHost() {
+ return host;
+ }
+
+ // used to change the host on an email address and rechecks validity
+
+ /**
+ * Changes the host name of the email address and rechecks the address'
+ * validity. Exercise caution when storing EmailAddress instances in
+ * hash-keyed collections. Calling setHost() with a different host name will
+ * change the return value of hashCode.
+ *
+ * @param hostName The new host name of the email address.
+ */
+ public void setHost(final String hostName) {
+ host = hostName;
+ valid = isValidInternal();
+ }
+
+ protected boolean valid = false;
+ protected String user = null;
+ protected String host = null;
+ protected boolean allowI18n = false;
+}
diff --git a/src/com/android/messaging/util/FallbackStrategies.java b/src/com/android/messaging/util/FallbackStrategies.java
new file mode 100644
index 0000000..57c208f
--- /dev/null
+++ b/src/com/android/messaging/util/FallbackStrategies.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.messaging.util;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Provides a generic and loose-coupled framework to execute one primary and multiple fallback
+ * strategies for solving a given task.<p>
+ * Basically, what would have been a nasty try-catch that's hard to separate and maintain:
+ * <pre><code>
+ * try {
+ * // doSomething() that may fail.
+ * } catch (Exception ex) {
+ * try {
+ * // fallback1() that may fail.
+ * } catch (Exception ex2) {
+ * try {
+ * // fallback2() that may fail.
+ * } catch (Exception ex3) {
+ * // ...
+ * }
+ * }
+ * }
+ * </code></pre>
+ * Now becomes:<br>
+ * <pre><code>
+ * FallbackStrategies
+ * .startWith(something)
+ * .thenTry(fallback1)
+ * .thenTry(fallback2)
+ * .execute();
+ * </code></pre>
+ */
+public class FallbackStrategies<Input, Output> {
+ public interface Strategy<Input, Output> {
+ Output execute(Input params) throws Exception;
+ }
+
+ private final List<Strategy<Input, Output>> mChainedStrategies;
+
+ private FallbackStrategies(final Strategy<Input, Output> primaryStrategy) {
+ mChainedStrategies = new ArrayList<Strategy<Input, Output>>();
+ mChainedStrategies.add(primaryStrategy);
+ }
+
+ public static <Input, Output> FallbackStrategies<Input, Output> startWith(
+ final Strategy<Input, Output> primaryStrategy) {
+ return new FallbackStrategies<Input, Output>(primaryStrategy);
+ }
+
+ public FallbackStrategies<Input, Output> thenTry(final Strategy<Input, Output> strategy) {
+ Assert.isFalse(mChainedStrategies.isEmpty());
+ mChainedStrategies.add(strategy);
+ return this;
+ }
+
+ public Output execute(final Input params) {
+ final int count = mChainedStrategies.size();
+ for (int i = 0; i < count; i++) {
+ final Strategy<Input, Output> strategy = mChainedStrategies.get(i);
+ try {
+ // If succeeds, this will directly return.
+ return strategy.execute(params);
+ } catch (Exception ex) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Exceptions occured when executing strategy " +
+ strategy + (i < count - 1 ?
+ ", attempting fallback " + mChainedStrategies.get(i + 1) :
+ ", and running out of fallbacks."), ex);
+ // This will fall through and continue with the next strategy (if any).
+ }
+ }
+ // Running out of strategies, return null.
+ // TODO: Should this accept user-defined fallback value other than null?
+ return null;
+ }
+}
diff --git a/src/com/android/messaging/util/FileUtil.java b/src/com/android/messaging/util/FileUtil.java
new file mode 100644
index 0000000..7c47ae9
--- /dev/null
+++ b/src/com/android/messaging/util/FileUtil.java
@@ -0,0 +1,140 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.webkit.MimeTypeMap;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.google.common.io.Files;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+public class FileUtil {
+ /** Returns a new file name, ensuring that such a file does not already exist. */
+ private static synchronized File getNewFile(File directory, String extension,
+ String fileNameFormat) throws IOException {
+ final Date date = new Date(System.currentTimeMillis());
+ final SimpleDateFormat dateFormat = new SimpleDateFormat(fileNameFormat);
+ final String numberedFileNameFormat = dateFormat.format(date) + "_%02d" + "." + extension;
+ for (int i = 1; i <= 99; i++) { // Only save 99 of the same file name.
+ final String newName = String.format(Locale.US, numberedFileNameFormat, i);
+ File testFile = new File(directory, newName);
+ if (!testFile.exists()) {
+ testFile.createNewFile();
+ return testFile;
+ }
+ }
+ LogUtil.e(LogUtil.BUGLE_TAG, "Too many duplicate file names: " + numberedFileNameFormat);
+ return null;
+ }
+
+ /**
+ * Creates an unused name to use for creating a new file. The format happens to be similar
+ * to that used by the Android camera application.
+ *
+ * @param directory directory that the file should be saved to
+ * @param contentType of the media being saved
+ * @return file name to be used for creating the new file. The caller is responsible for
+ * actually creating the file.
+ */
+ public static File getNewFile(File directory, String contentType) throws IOException {
+ MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
+ String fileExtension = mimeTypeMap.getExtensionFromMimeType(contentType);
+
+ final Context context = Factory.get().getApplicationContext();
+ String fileNameFormat = context.getString(ContentType.isImageType(contentType)
+ ? R.string.new_image_file_name_format : R.string.new_file_name_format);
+ return getNewFile(directory, fileExtension, fileNameFormat);
+ }
+
+ /** Delete everything below and including root */
+ public static void removeFileOrDirectory(File root) {
+ removeFileOrDirectoryExcept(root, null);
+ }
+
+ /** Delete everything below and including root except for the given file */
+ public static void removeFileOrDirectoryExcept(File root, File exclude) {
+ if (root.exists()) {
+ if (root.isDirectory()) {
+ for (File file : root.listFiles()) {
+ if (exclude == null || !file.equals(exclude)) {
+ removeFileOrDirectoryExcept(file, exclude);
+ }
+ }
+ root.delete();
+ } else if (root.isFile()) {
+ root.delete();
+ }
+ }
+ }
+
+ /**
+ * Move all files and folders under a directory into the target.
+ */
+ public static void moveAllContentUnderDirectory(File sourceDir, File targetDir) {
+ if (sourceDir.isDirectory() && targetDir.isDirectory()) {
+ if (isSameOrSubDirectory(sourceDir, targetDir)) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Can't move directory content since the source " +
+ "directory is a parent of the target");
+ return;
+ }
+ for (File file : sourceDir.listFiles()) {
+ if (file.isDirectory()) {
+ final File dirTarget = new File(targetDir, file.getName());
+ dirTarget.mkdirs();
+ moveAllContentUnderDirectory(file, dirTarget);
+ } else {
+ try {
+ final File fileTarget = new File(targetDir, file.getName());
+ Files.move(file, fileTarget);
+ } catch (IOException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Failed to move files", e);
+ // Try proceed with the next file.
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Checks, whether the child directory is the same as, or a sub-directory of the base
+ * directory.
+ */
+ private static boolean isSameOrSubDirectory(File base, File child) {
+ try {
+ base = base.getCanonicalFile();
+ child = child.getCanonicalFile();
+ File parentFile = child;
+ while (parentFile != null) {
+ if (base.equals(parentFile)) {
+ return true;
+ }
+ parentFile = parentFile.getParentFile();
+ }
+ return false;
+ } catch (IOException ex) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Error while accessing file", ex);
+ return false;
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/GifTranscoder.java b/src/com/android/messaging/util/GifTranscoder.java
new file mode 100644
index 0000000..65413a0
--- /dev/null
+++ b/src/com/android/messaging/util/GifTranscoder.java
@@ -0,0 +1,94 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.text.format.Formatter;
+
+import com.google.common.base.Stopwatch;
+
+import java.io.File;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Compresses a GIF so it can be sent via MMS.
+ * <p>
+ * The entry point lives in its own class, we can defer loading the native GIF transcoding library
+ * into memory until we actually need it.
+ */
+public class GifTranscoder {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private static int MIN_HEIGHT = 100;
+ private static int MIN_WIDTH = 100;
+
+ static {
+ System.loadLibrary("giftranscode");
+ }
+
+ public static boolean transcode(Context context, String filePath, String outFilePath) {
+ if (!isEnabled()) {
+ return false;
+ }
+ final long inputSize = new File(filePath).length();
+ Stopwatch stopwatch = Stopwatch.createStarted();
+ final boolean success = transcodeInternal(filePath, outFilePath);
+ stopwatch.stop();
+ final long elapsedMs = stopwatch.elapsed(TimeUnit.MILLISECONDS);
+ final long outputSize = new File(outFilePath).length();
+ final float compression = (inputSize > 0) ? ((float) outputSize / inputSize) : 0;
+
+ if (success) {
+ LogUtil.i(TAG, String.format("Resized GIF (%s) in %d ms, %s => %s (%.0f%%)",
+ LogUtil.sanitizePII(filePath),
+ elapsedMs,
+ Formatter.formatShortFileSize(context, inputSize),
+ Formatter.formatShortFileSize(context, outputSize),
+ compression * 100.0f));
+ }
+ return success;
+ }
+
+ private static native boolean transcodeInternal(String filePath, String outFilePath);
+
+ /**
+ * Estimates the size of a GIF transcoded from a GIF with the specified size.
+ */
+ public static long estimateFileSizeAfterTranscode(long fileSize) {
+ // I tested transcoding on ~70 GIFs and found that the transcoded files are in general
+ // about 25-35% the size of the original. This compression ratio is very consistent for the
+ // class of GIFs we care about most: those converted from video clips and 1-3 MB in size.
+ return (long) (fileSize * 0.35f);
+ }
+
+ public static boolean canBeTranscoded(int width, int height) {
+ if (!isEnabled()) {
+ return false;
+ }
+ return width >= MIN_WIDTH && height >= MIN_HEIGHT;
+ }
+
+ private static boolean isEnabled() {
+ final boolean enabled = BugleGservices.get().getBoolean(
+ BugleGservicesKeys.ENABLE_GIF_TRANSCODING,
+ BugleGservicesKeys.ENABLE_GIF_TRANSCODING_DEFAULT);
+ if (!enabled) {
+ LogUtil.w(TAG, "GIF transcoding is disabled");
+ }
+ return enabled;
+ }
+}
diff --git a/src/com/android/messaging/util/ImageUtils.java b/src/com/android/messaging/util/ImageUtils.java
new file mode 100644
index 0000000..05d3678
--- /dev/null
+++ b/src/com/android/messaging/util/ImageUtils.java
@@ -0,0 +1,908 @@
+/*
+ * 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.messaging.util;
+
+import android.app.ActivityManager;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapShader;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Shader.TileMode;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.provider.MediaStore;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.view.View;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.MediaScratchFileProvider;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.media.ImageRequest;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.exif.ExifInterface;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.io.Files;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+
+public class ImageUtils {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+ private static final int MAX_OOM_COUNT = 1;
+ private static final byte[] GIF87_HEADER = "GIF87a".getBytes(Charset.forName("US-ASCII"));
+ private static final byte[] GIF89_HEADER = "GIF89a".getBytes(Charset.forName("US-ASCII"));
+
+ // Used for drawBitmapWithCircleOnCanvas.
+ // Default color is transparent for both circle background and stroke.
+ public static final int DEFAULT_CIRCLE_BACKGROUND_COLOR = 0;
+ public static final int DEFAULT_CIRCLE_STROKE_COLOR = 0;
+
+ private static volatile ImageUtils sInstance;
+
+ public static ImageUtils get() {
+ if (sInstance == null) {
+ synchronized (ImageUtils.class) {
+ if (sInstance == null) {
+ sInstance = new ImageUtils();
+ }
+ }
+ }
+ return sInstance;
+ }
+
+ @VisibleForTesting
+ public static void set(final ImageUtils imageUtils) {
+ sInstance = imageUtils;
+ }
+
+ /**
+ * Transforms a bitmap into a byte array.
+ *
+ * @param quality Value between 0 and 100 that the compressor uses to discern what quality the
+ * resulting bytes should be
+ * @param bitmap Bitmap to convert into bytes
+ * @return byte array of bitmap
+ */
+ public static byte[] bitmapToBytes(final Bitmap bitmap, final int quality)
+ throws OutOfMemoryError {
+ boolean done = false;
+ int oomCount = 0;
+ byte[] imageBytes = null;
+ while (!done) {
+ try {
+ final ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bitmap.compress(Bitmap.CompressFormat.JPEG, quality, os);
+ imageBytes = os.toByteArray();
+ done = true;
+ } catch (final OutOfMemoryError e) {
+ LogUtil.w(TAG, "OutOfMemory converting bitmap to bytes.");
+ oomCount++;
+ if (oomCount <= MAX_OOM_COUNT) {
+ Factory.get().reclaimMemory();
+ } else {
+ done = true;
+ LogUtil.w(TAG, "Failed to convert bitmap to bytes. Out of Memory.");
+ }
+ throw e;
+ }
+ }
+ return imageBytes;
+ }
+
+ /**
+ * Given the source bitmap and a canvas, draws the bitmap through a circular
+ * mask. Only draws a circle with diameter equal to the destination width.
+ *
+ * @param bitmap The source bitmap to draw.
+ * @param canvas The canvas to draw it on.
+ * @param source The source bound of the bitmap.
+ * @param dest The destination bound on the canvas.
+ * @param bitmapPaint Optional Paint object for the bitmap
+ * @param fillBackground when set, fill the circle with backgroundColor
+ * @param strokeColor draw a border outside the circle with strokeColor
+ */
+ public static void drawBitmapWithCircleOnCanvas(final Bitmap bitmap, final Canvas canvas,
+ final RectF source, final RectF dest, @Nullable Paint bitmapPaint,
+ final boolean fillBackground, final int backgroundColor, int strokeColor) {
+ // Draw bitmap through shader first.
+ final BitmapShader shader = new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP);
+ final Matrix matrix = new Matrix();
+
+ // Fit bitmap to bounds.
+ matrix.setRectToRect(source, dest, Matrix.ScaleToFit.CENTER);
+
+ shader.setLocalMatrix(matrix);
+
+ if (bitmapPaint == null) {
+ bitmapPaint = new Paint();
+ }
+
+ bitmapPaint.setAntiAlias(true);
+ if (fillBackground) {
+ bitmapPaint.setColor(backgroundColor);
+ canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint);
+ }
+
+ bitmapPaint.setShader(shader);
+ canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint);
+ bitmapPaint.setShader(null);
+
+ if (strokeColor != 0) {
+ final Paint stroke = new Paint();
+ stroke.setAntiAlias(true);
+ stroke.setColor(strokeColor);
+ stroke.setStyle(Paint.Style.STROKE);
+ final float strokeWidth = 6f;
+ stroke.setStrokeWidth(strokeWidth);
+ canvas.drawCircle(dest.centerX(),
+ dest.centerX(),
+ dest.width() / 2f - stroke.getStrokeWidth() / 2f,
+ stroke);
+ }
+ }
+
+ /**
+ * Sets a drawable to the background of a view. setBackgroundDrawable() is deprecated since
+ * JB and replaced by setBackground().
+ */
+ @SuppressWarnings("deprecation")
+ public static void setBackgroundDrawableOnView(final View view, final Drawable drawable) {
+ if (OsUtil.isAtLeastJB()) {
+ view.setBackground(drawable);
+ } else {
+ view.setBackgroundDrawable(drawable);
+ }
+ }
+
+ /**
+ * Based on the input bitmap bounds given by BitmapFactory.Options, compute the required
+ * sub-sampling size for loading a scaled down version of the bitmap to the required size
+ * @param options a BitmapFactory.Options instance containing the bounds info of the bitmap
+ * @param reqWidth the desired width of the bitmap. Can be ImageRequest.UNSPECIFIED_SIZE.
+ * @param reqHeight the desired height of the bitmap. Can be ImageRequest.UNSPECIFIED_SIZE.
+ * @return
+ */
+ public int calculateInSampleSize(
+ final BitmapFactory.Options options, final int reqWidth, final int reqHeight) {
+ // Raw height and width of image
+ final int height = options.outHeight;
+ final int width = options.outWidth;
+ int inSampleSize = 1;
+
+ final boolean checkHeight = reqHeight != ImageRequest.UNSPECIFIED_SIZE;
+ final boolean checkWidth = reqWidth != ImageRequest.UNSPECIFIED_SIZE;
+ if ((checkHeight && height > reqHeight) ||
+ (checkWidth && width > reqWidth)) {
+
+ final int halfHeight = height / 2;
+ final int halfWidth = width / 2;
+
+ // Calculate the largest inSampleSize value that is a power of 2 and keeps both
+ // height and width larger than the requested height and width.
+ while ((!checkHeight || (halfHeight / inSampleSize) > reqHeight)
+ && (!checkWidth || (halfWidth / inSampleSize) > reqWidth)) {
+ inSampleSize *= 2;
+ }
+ }
+
+ return inSampleSize;
+ }
+
+ private static final String[] MEDIA_CONTENT_PROJECTION = new String[] {
+ MediaStore.MediaColumns.MIME_TYPE
+ };
+
+ private static final int INDEX_CONTENT_TYPE = 0;
+
+ @DoesNotRunOnMainThread
+ public static String getContentType(final ContentResolver cr, final Uri uri) {
+ // Figure out the content type of media.
+ String contentType = null;
+ Cursor cursor = null;
+ if (UriUtil.isMediaStoreUri(uri)) {
+ try {
+ cursor = cr.query(uri, MEDIA_CONTENT_PROJECTION, null, null, null);
+
+ if (cursor != null && cursor.moveToFirst()) {
+ contentType = cursor.getString(INDEX_CONTENT_TYPE);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+ if (contentType == null) {
+ // Last ditch effort to get the content type. Look at the file extension.
+ contentType = ContentType.getContentTypeFromExtension(uri.toString(),
+ ContentType.IMAGE_UNSPECIFIED);
+ }
+ return contentType;
+ }
+
+ /**
+ * @param context Android context
+ * @param uri Uri to the image data
+ * @return The exif orientation value for the image in the specified uri
+ */
+ public static int getOrientation(final Context context, final Uri uri) {
+ try {
+ return getOrientation(context.getContentResolver().openInputStream(uri));
+ } catch (FileNotFoundException e) {
+ LogUtil.e(TAG, "getOrientation couldn't open: " + uri, e);
+ }
+ return android.media.ExifInterface.ORIENTATION_UNDEFINED;
+ }
+
+ /**
+ * @param inputStream The stream to the image file. Closed on completion
+ * @return The exif orientation value for the image in the specified stream
+ */
+ public static int getOrientation(final InputStream inputStream) {
+ int orientation = android.media.ExifInterface.ORIENTATION_UNDEFINED;
+ if (inputStream != null) {
+ try {
+ final ExifInterface exifInterface = new ExifInterface();
+ exifInterface.readExif(inputStream);
+ final Integer orientationValue =
+ exifInterface.getTagIntValue(ExifInterface.TAG_ORIENTATION);
+ if (orientationValue != null) {
+ orientation = orientationValue.intValue();
+ }
+ } catch (IOException e) {
+ // If the image if GIF, PNG, or missing exif header, just use the defaults
+ } finally {
+ try {
+ if (inputStream != null) {
+ inputStream.close();
+ }
+ } catch (IOException e) {
+ LogUtil.e(TAG, "getOrientation error closing input stream", e);
+ }
+ }
+ }
+ return orientation;
+ }
+
+ /**
+ * Returns whether the resource is a GIF image.
+ */
+ public static boolean isGif(String contentType, Uri contentUri) {
+ if (TextUtils.equals(contentType, ContentType.IMAGE_GIF)) {
+ return true;
+ }
+ if (ContentType.isImageType(contentType)) {
+ try {
+ ContentResolver contentResolver = Factory.get().getApplicationContext()
+ .getContentResolver();
+ InputStream inputStream = contentResolver.openInputStream(contentUri);
+ return ImageUtils.isGif(inputStream);
+ } catch (Exception e) {
+ LogUtil.w(TAG, "Could not open GIF input stream", e);
+ }
+ }
+ // Assume anything with a non-image content type is not a GIF
+ return false;
+ }
+
+ /**
+ * @param inputStream The stream to the image file. Closed on completion
+ * @return Whether the image stream represents a GIF
+ */
+ public static boolean isGif(InputStream inputStream) {
+ if (inputStream != null) {
+ try {
+ byte[] gifHeaderBytes = new byte[6];
+ int value = inputStream.read(gifHeaderBytes, 0, 6);
+ if (value == 6) {
+ return Arrays.equals(gifHeaderBytes, GIF87_HEADER)
+ || Arrays.equals(gifHeaderBytes, GIF89_HEADER);
+ }
+ } catch (IOException e) {
+ return false;
+ } finally {
+ try {
+ inputStream.close();
+ } catch (IOException e) {
+ // Ignore
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Read an image and compress it to particular max dimensions and size.
+ * Used to ensure images can fit in an MMS.
+ * TODO: This uses memory very inefficiently as it processes the whole image as a unit
+ * (rather than slice by slice) but system JPEG functions do not support slicing and dicing.
+ */
+ public static class ImageResizer {
+
+ /**
+ * The quality parameter which is used to compress JPEG images.
+ */
+ private static final int IMAGE_COMPRESSION_QUALITY = 95;
+ /**
+ * The minimum quality parameter which is used to compress JPEG images.
+ */
+ private static final int MINIMUM_IMAGE_COMPRESSION_QUALITY = 50;
+
+ /**
+ * Minimum factor to reduce quality value
+ */
+ private static final double QUALITY_SCALE_DOWN_RATIO = 0.85f;
+
+ /**
+ * Maximum passes through the resize loop before failing permanently
+ */
+ private static final int NUMBER_OF_RESIZE_ATTEMPTS = 6;
+
+ /**
+ * Amount to scale down the picture when it doesn't fit
+ */
+ private static final float MIN_SCALE_DOWN_RATIO = 0.75f;
+
+ /**
+ * When computing sampleSize target scaling of no more than this ratio
+ */
+ private static final float MAX_TARGET_SCALE_FACTOR = 1.5f;
+
+
+ // Current sample size for subsampling image during initial decode
+ private int mSampleSize;
+ // Current bitmap holding initial decoded source image
+ private Bitmap mDecoded;
+ // If scaling is needed this holds the scaled bitmap (else should equal mDecoded)
+ private Bitmap mScaled;
+ // Current JPEG compression quality to use when compressing image
+ private int mQuality;
+ // Current factor to scale down decoded image before compressing
+ private float mScaleFactor;
+ // Flag keeping track of whether cache memory has been reclaimed
+ private boolean mHasReclaimedMemory;
+
+ // Initial size of the image (typically provided but can be UNSPECIFIED_SIZE)
+ private int mWidth;
+ private int mHeight;
+ // Orientation params of image as read from EXIF data
+ private final ExifInterface.OrientationParams mOrientationParams;
+ // Matrix to undo orientation and scale at the same time
+ private final Matrix mMatrix;
+ // Size limit as provided by MMS library
+ private final int mWidthLimit;
+ private final int mHeightLimit;
+ private final int mByteLimit;
+ // Uri from which to read source image
+ private final Uri mUri;
+ // Application context
+ private final Context mContext;
+ // Cached value of bitmap factory options
+ private final BitmapFactory.Options mOptions;
+ private final String mContentType;
+
+ private final int mMemoryClass;
+
+ /**
+ * Return resized (compressed) image (else null)
+ *
+ * @param width The width of the image (if known)
+ * @param height The height of the image (if known)
+ * @param orientation The orientation of the image as an ExifInterface constant
+ * @param widthLimit The width limit, in pixels
+ * @param heightLimit The height limit, in pixels
+ * @param byteLimit The binary size limit, in bytes
+ * @param uri Uri to the image data
+ * @param context Needed to open the image
+ * @param contentType of image
+ * @return encoded image meeting size requirements else null
+ */
+ public static byte[] getResizedImageData(final int width, final int height,
+ final int orientation, final int widthLimit, final int heightLimit,
+ final int byteLimit, final Uri uri, final Context context,
+ final String contentType) {
+ final ImageResizer resizer = new ImageResizer(width, height, orientation,
+ widthLimit, heightLimit, byteLimit, uri, context, contentType);
+ return resizer.resize();
+ }
+
+ /**
+ * Create and initialize an image resizer
+ */
+ private ImageResizer(final int width, final int height, final int orientation,
+ final int widthLimit, final int heightLimit, final int byteLimit, final Uri uri,
+ final Context context, final String contentType) {
+ mWidth = width;
+ mHeight = height;
+ mOrientationParams = ExifInterface.getOrientationParams(orientation);
+ mMatrix = new Matrix();
+ mWidthLimit = widthLimit;
+ mHeightLimit = heightLimit;
+ mByteLimit = byteLimit;
+ mUri = uri;
+ mWidth = width;
+ mContext = context;
+ mQuality = IMAGE_COMPRESSION_QUALITY;
+ mScaleFactor = 1.0f;
+ mHasReclaimedMemory = false;
+ mOptions = new BitmapFactory.Options();
+ mOptions.inScaled = false;
+ mOptions.inDensity = 0;
+ mOptions.inTargetDensity = 0;
+ mOptions.inSampleSize = 1;
+ mOptions.inJustDecodeBounds = false;
+ mOptions.inMutable = false;
+ final ActivityManager am =
+ (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+ mMemoryClass = Math.max(16, am.getMemoryClass());
+ mContentType = contentType;
+ }
+
+ /**
+ * Try to compress the image
+ *
+ * @return encoded image meeting size requirements else null
+ */
+ private byte[] resize() {
+ return ImageUtils.isGif(mContentType, mUri) ? resizeGifImage() : resizeStaticImage();
+ }
+
+ private byte[] resizeGifImage() {
+ byte[] bytesToReturn = null;
+ final String inputFilePath;
+ if (MediaScratchFileProvider.isMediaScratchSpaceUri(mUri)) {
+ inputFilePath = MediaScratchFileProvider.getFileFromUri(mUri).getAbsolutePath();
+ } else {
+ if (!TextUtils.equals(mUri.getScheme(), ContentResolver.SCHEME_FILE)) {
+ Assert.fail("Expected a GIF file uri, but actual uri = " + mUri.toString());
+ }
+ inputFilePath = mUri.getPath();
+ }
+
+ if (GifTranscoder.canBeTranscoded(mWidth, mHeight)) {
+ // Needed to perform the transcoding so that the gif can continue to play in the
+ // conversation while the sending is taking place
+ final Uri tmpUri = MediaScratchFileProvider.buildMediaScratchSpaceUri("gif");
+ final File outputFile = MediaScratchFileProvider.getFileFromUri(tmpUri);
+ final String outputFilePath = outputFile.getAbsolutePath();
+
+ final boolean success =
+ GifTranscoder.transcode(mContext, inputFilePath, outputFilePath);
+ if (success) {
+ try {
+ bytesToReturn = Files.toByteArray(outputFile);
+ } catch (IOException e) {
+ LogUtil.e(TAG, "Could not create FileInputStream with path of "
+ + outputFilePath, e);
+ }
+ }
+
+ // Need to clean up the new file created to compress the gif
+ mContext.getContentResolver().delete(tmpUri, null, null);
+ } else {
+ // We don't want to transcode the gif because its image dimensions would be too
+ // small so just return the bytes of the original gif
+ try {
+ bytesToReturn = Files.toByteArray(new File(inputFilePath));
+ } catch (IOException e) {
+ LogUtil.e(TAG,
+ "Could not create FileInputStream with path of " + inputFilePath, e);
+ }
+ }
+
+ return bytesToReturn;
+ }
+
+ private byte[] resizeStaticImage() {
+ if (!ensureImageSizeSet()) {
+ // Cannot read image size
+ return null;
+ }
+ // Find incoming image size
+ if (!canBeCompressed()) {
+ return null;
+ }
+
+ // Decode image - if out of memory - reclaim memory and retry
+ try {
+ for (int attempts = 0; attempts < NUMBER_OF_RESIZE_ATTEMPTS; attempts++) {
+ final byte[] encoded = recodeImage(attempts);
+
+ // Only return data within the limit
+ if (encoded != null && encoded.length <= mByteLimit) {
+ return encoded;
+ } else {
+ final int currentSize = (encoded == null ? 0 : encoded.length);
+ updateRecodeParameters(currentSize);
+ }
+ }
+ } catch (final FileNotFoundException e) {
+ LogUtil.e(TAG, "File disappeared during resizing");
+ } finally {
+ // Release all bitmaps
+ if (mScaled != null && mScaled != mDecoded) {
+ mScaled.recycle();
+ }
+ if (mDecoded != null) {
+ mDecoded.recycle();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Ensure that the width and height of the source image are known
+ * @return flag indicating whether size is known
+ */
+ private boolean ensureImageSizeSet() {
+ if (mWidth == MessagingContentProvider.UNSPECIFIED_SIZE ||
+ mHeight == MessagingContentProvider.UNSPECIFIED_SIZE) {
+ // First get the image data (compressed)
+ final ContentResolver cr = mContext.getContentResolver();
+ InputStream inputStream = null;
+ // Find incoming image size
+ try {
+ mOptions.inJustDecodeBounds = true;
+ inputStream = cr.openInputStream(mUri);
+ BitmapFactory.decodeStream(inputStream, null, mOptions);
+
+ mWidth = mOptions.outWidth;
+ mHeight = mOptions.outHeight;
+ mOptions.inJustDecodeBounds = false;
+
+ return true;
+ } catch (final FileNotFoundException e) {
+ LogUtil.e(TAG, "Could not open file corresponding to uri " + mUri, e);
+ } catch (final NullPointerException e) {
+ LogUtil.e(TAG, "NPE trying to open the uri " + mUri, e);
+ } finally {
+ if (inputStream != null) {
+ try {
+ inputStream.close();
+ } catch (final IOException e) {
+ // Nothing to do
+ }
+ }
+ }
+
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Choose an initial subsamplesize that ensures the decoded image is no more than
+ * MAX_TARGET_SCALE_FACTOR bigger than largest supported image and that it is likely to
+ * compress to smaller than the target size (assuming compression down to 1 bit per pixel).
+ * @return whether the image can be down subsampled
+ */
+ private boolean canBeCompressed() {
+ final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE);
+
+ int imageHeight = mHeight;
+ int imageWidth = mWidth;
+
+ // Assume can use half working memory to decode the initial image (4 bytes per pixel)
+ final int workingMemoryPixelLimit = (mMemoryClass * 1024 * 1024 / 8);
+ // Target 1 bits per pixel in final compressed image
+ final int finalSizePixelLimit = mByteLimit * 8;
+ // When choosing to halve the resolution - only do so the image will still be too big
+ // after scaling by MAX_TARGET_SCALE_FACTOR
+ final int heightLimitWithSlop = (int) (mHeightLimit * MAX_TARGET_SCALE_FACTOR);
+ final int widthLimitWithSlop = (int) (mWidthLimit * MAX_TARGET_SCALE_FACTOR);
+ final int pixelLimitWithSlop = (int) (finalSizePixelLimit *
+ MAX_TARGET_SCALE_FACTOR * MAX_TARGET_SCALE_FACTOR);
+ final int pixelLimit = Math.min(pixelLimitWithSlop, workingMemoryPixelLimit);
+
+ int sampleSize = 1;
+ boolean fits = (imageHeight < heightLimitWithSlop &&
+ imageWidth < widthLimitWithSlop &&
+ imageHeight * imageWidth < pixelLimit);
+
+ // Compare sizes to compute sub-sampling needed
+ while (!fits) {
+ sampleSize = sampleSize * 2;
+ // Note that recodeImage may try using mSampleSize * 2. Hence we use the factor of 4
+ if (sampleSize >= (Integer.MAX_VALUE / 4)) {
+ LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, String.format(
+ "Cannot resize image: widthLimit=%d heightLimit=%d byteLimit=%d " +
+ "imageWidth=%d imageHeight=%d", mWidthLimit, mHeightLimit, mByteLimit,
+ mWidth, mHeight));
+ Assert.fail("Image cannot be resized"); // http://b/18926934
+ return false;
+ }
+ if (logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
+ "computeInitialSampleSize: Increasing sampleSize to " + sampleSize
+ + " as h=" + imageHeight + " vs " + heightLimitWithSlop
+ + " w=" + imageWidth + " vs " + widthLimitWithSlop
+ + " p=" + imageHeight * imageWidth + " vs " + pixelLimit);
+ }
+ imageHeight = mHeight / sampleSize;
+ imageWidth = mWidth / sampleSize;
+ fits = (imageHeight < heightLimitWithSlop &&
+ imageWidth < widthLimitWithSlop &&
+ imageHeight * imageWidth < pixelLimit);
+ }
+
+ if (logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
+ "computeInitialSampleSize: Initial sampleSize " + sampleSize
+ + " for h=" + imageHeight + " vs " + heightLimitWithSlop
+ + " w=" + imageWidth + " vs " + widthLimitWithSlop
+ + " p=" + imageHeight * imageWidth + " vs " + pixelLimit);
+ }
+
+ mSampleSize = sampleSize;
+ return true;
+ }
+
+ /**
+ * Recode the image from initial Uri to encoded JPEG
+ * @param attempt Attempt number
+ * @return encoded image
+ */
+ private byte[] recodeImage(final int attempt) throws FileNotFoundException {
+ byte[] encoded = null;
+ try {
+ final ContentResolver cr = mContext.getContentResolver();
+ final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE);
+ if (logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: attempt=" + attempt
+ + " limit (w=" + mWidthLimit + " h=" + mHeightLimit + ") quality="
+ + mQuality + " scale=" + mScaleFactor + " sampleSize=" + mSampleSize);
+ }
+ if (mScaled == null) {
+ if (mDecoded == null) {
+ mOptions.inSampleSize = mSampleSize;
+ final InputStream inputStream = cr.openInputStream(mUri);
+ mDecoded = BitmapFactory.decodeStream(inputStream, null, mOptions);
+ if (mDecoded == null) {
+ if (logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
+ "getResizedImageData: got empty decoded bitmap");
+ }
+ return null;
+ }
+ }
+ if (logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: decoded w,h="
+ + mDecoded.getWidth() + "," + mDecoded.getHeight());
+ }
+ // Make sure to scale the decoded image if dimension is not within limit
+ final int decodedWidth = mDecoded.getWidth();
+ final int decodedHeight = mDecoded.getHeight();
+ if (decodedWidth > mWidthLimit || decodedHeight > mHeightLimit) {
+ final float minScaleFactor = Math.max(
+ mWidthLimit == 0 ? 1.0f :
+ (float) decodedWidth / (float) mWidthLimit,
+ mHeightLimit == 0 ? 1.0f :
+ (float) decodedHeight / (float) mHeightLimit);
+ if (mScaleFactor < minScaleFactor) {
+ mScaleFactor = minScaleFactor;
+ }
+ }
+ if (mScaleFactor > 1.0 || mOrientationParams.rotation != 0) {
+ mMatrix.reset();
+ mMatrix.postRotate(mOrientationParams.rotation);
+ mMatrix.postScale(mOrientationParams.scaleX / mScaleFactor,
+ mOrientationParams.scaleY / mScaleFactor);
+ mScaled = Bitmap.createBitmap(mDecoded, 0, 0, decodedWidth, decodedHeight,
+ mMatrix, false /* filter */);
+ if (mScaled == null) {
+ if (logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
+ "getResizedImageData: got empty scaled bitmap");
+ }
+ return null;
+ }
+ if (logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: scaled w,h="
+ + mScaled.getWidth() + "," + mScaled.getHeight());
+ }
+ } else {
+ mScaled = mDecoded;
+ }
+ }
+ // Now encode it at current quality
+ encoded = ImageUtils.bitmapToBytes(mScaled, mQuality);
+ if (encoded != null && logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
+ "getResizedImageData: Encoded down to " + encoded.length + "@"
+ + mScaled.getWidth() + "/" + mScaled.getHeight() + "~"
+ + mQuality);
+ }
+ } catch (final OutOfMemoryError e) {
+ LogUtil.w(LogUtil.BUGLE_IMAGE_TAG,
+ "getResizedImageData - image too big (OutOfMemoryError), will try "
+ + " with smaller scale factor");
+ // fall through and keep trying with more compression
+ }
+ return encoded;
+ }
+
+ /**
+ * When image recode fails this method updates compression parameters for the next attempt
+ * @param currentSize encoded image size (will be 0 if OOM)
+ */
+ private void updateRecodeParameters(final int currentSize) {
+ final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE);
+ // Only return data within the limit
+ if (currentSize > 0 &&
+ mQuality > MINIMUM_IMAGE_COMPRESSION_QUALITY) {
+ // First if everything succeeded but failed to hit target size
+ // Try quality proportioned to sqrt of size over size limit
+ mQuality = Math.max(MINIMUM_IMAGE_COMPRESSION_QUALITY,
+ Math.min((int) (mQuality * Math.sqrt((1.0 * mByteLimit) / currentSize)),
+ (int) (mQuality * QUALITY_SCALE_DOWN_RATIO)));
+ if (logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
+ "getResizedImageData: Retrying at quality " + mQuality);
+ }
+ } else if (currentSize > 0 &&
+ mScaleFactor < 2.0 * MIN_SCALE_DOWN_RATIO * MIN_SCALE_DOWN_RATIO) {
+ // JPEG compression failed to hit target size - need smaller image
+ // First try scaling by a little (< factor of 2) just so long resulting scale down
+ // ratio is still significantly bigger than next subsampling step
+ // i.e. mScaleFactor/MIN_SCALE_DOWN_RATIO (new scaling factor) <
+ // 2.0 / MIN_SCALE_DOWN_RATIO (arbitrary limit)
+ mQuality = IMAGE_COMPRESSION_QUALITY;
+ mScaleFactor = mScaleFactor / MIN_SCALE_DOWN_RATIO;
+ if (logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
+ "getResizedImageData: Retrying at scale " + mScaleFactor);
+ }
+ // Release scaled bitmap to trigger rescaling
+ if (mScaled != null && mScaled != mDecoded) {
+ mScaled.recycle();
+ }
+ mScaled = null;
+ } else if (currentSize <= 0 && !mHasReclaimedMemory) {
+ // Then before we subsample try cleaning up our cached memory
+ Factory.get().reclaimMemory();
+ mHasReclaimedMemory = true;
+ if (logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
+ "getResizedImageData: Retrying after reclaiming memory ");
+ }
+ } else {
+ // Last resort - subsample image by another factor of 2 and try again
+ mSampleSize = mSampleSize * 2;
+ mQuality = IMAGE_COMPRESSION_QUALITY;
+ mScaleFactor = 1.0f;
+ if (logv) {
+ LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
+ "getResizedImageData: Retrying at sampleSize " + mSampleSize);
+ }
+ // Release all bitmaps to trigger subsampling
+ if (mScaled != null && mScaled != mDecoded) {
+ mScaled.recycle();
+ }
+ mScaled = null;
+ if (mDecoded != null) {
+ mDecoded.recycle();
+ mDecoded = null;
+ }
+ }
+ }
+ }
+
+ /**
+ * Scales and center-crops a bitmap to the size passed in and returns the new bitmap.
+ *
+ * @param source Bitmap to scale and center-crop
+ * @param newWidth destination width
+ * @param newHeight destination height
+ * @return Bitmap scaled and center-cropped bitmap
+ */
+ public static Bitmap scaleCenterCrop(final Bitmap source, final int newWidth,
+ final int newHeight) {
+ final int sourceWidth = source.getWidth();
+ final int sourceHeight = source.getHeight();
+
+ // Compute the scaling factors to fit the new height and width, respectively.
+ // To cover the final image, the final scaling will be the bigger
+ // of these two.
+ final float xScale = (float) newWidth / sourceWidth;
+ final float yScale = (float) newHeight / sourceHeight;
+ final float scale = Math.max(xScale, yScale);
+
+ // Now get the size of the source bitmap when scaled
+ final float scaledWidth = scale * sourceWidth;
+ final float scaledHeight = scale * sourceHeight;
+
+ // Let's find out the upper left coordinates if the scaled bitmap
+ // should be centered in the new size give by the parameters
+ final float left = (newWidth - scaledWidth) / 2;
+ final float top = (newHeight - scaledHeight) / 2;
+
+ // The target rectangle for the new, scaled version of the source bitmap will now
+ // be
+ final RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight);
+
+ // Finally, we create a new bitmap of the specified size and draw our new,
+ // scaled bitmap onto it.
+ final Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, source.getConfig());
+ final Canvas canvas = new Canvas(dest);
+ canvas.drawBitmap(source, null, targetRect, null);
+
+ return dest;
+ }
+
+ /**
+ * The drawable can be a Nine-Patch. If we directly use the same drawable instance for each
+ * drawable of different sizes, then the drawable sizes would interfere with each other. The
+ * solution here is to create a new drawable instance for every time with the SAME
+ * ConstantState (i.e. sharing the same common state such as the bitmap, so that we don't have
+ * to recreate the bitmap resource), and apply the different properties on top (nine-patch
+ * size and color tint).
+ *
+ * TODO: we are creating new drawable instances here, but there are optimizations that
+ * can be made. For example, message bubbles shouldn't need the mutate() call and the
+ * play/pause buttons shouldn't need to create new drawable from the constant state.
+ */
+ public static Drawable getTintedDrawable(final Context context, final Drawable drawable,
+ final int color) {
+ // For some reason occassionally drawables on JB has a null constant state
+ final Drawable.ConstantState constantStateDrawable = drawable.getConstantState();
+ final Drawable retDrawable = (constantStateDrawable != null)
+ ? constantStateDrawable.newDrawable(context.getResources()).mutate()
+ : drawable;
+ retDrawable.setColorFilter(color, PorterDuff.Mode.SRC_ATOP);
+ return retDrawable;
+ }
+
+ /**
+ * Decodes image resource header and returns the image size.
+ */
+ public static Rect decodeImageBounds(final Context context, final Uri imageUri) {
+ final ContentResolver cr = context.getContentResolver();
+ try {
+ final InputStream inputStream = cr.openInputStream(imageUri);
+ if (inputStream != null) {
+ try {
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inJustDecodeBounds = true;
+ BitmapFactory.decodeStream(inputStream, null, options);
+ return new Rect(0, 0, options.outWidth, options.outHeight);
+ } finally {
+ try {
+ inputStream.close();
+ } catch (IOException e) {
+ // Do nothing.
+ }
+ }
+ }
+ } catch (FileNotFoundException e) {
+ LogUtil.e(TAG, "Couldn't open input stream for uri = " + imageUri);
+ }
+ return new Rect(0, 0, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE);
+ }
+}
diff --git a/src/com/android/messaging/util/ImeUtil.java b/src/com/android/messaging/util/ImeUtil.java
new file mode 100644
index 0000000..ab0df13
--- /dev/null
+++ b/src/com/android/messaging/util/ImeUtil.java
@@ -0,0 +1,88 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.view.View;
+import android.view.inputmethod.InputMethodManager;
+
+import com.google.common.annotations.VisibleForTesting;
+
+public class ImeUtil {
+ public interface ImeStateObserver {
+ void onImeStateChanged(boolean imeOpen);
+ }
+
+ public interface ImeStateHost {
+ void onDisplayHeightChanged(int heightMeasureSpec);
+ void registerImeStateObserver(ImeUtil.ImeStateObserver observer);
+ void unregisterImeStateObserver(ImeUtil.ImeStateObserver observer);
+ boolean isImeOpen();
+ }
+
+ private static volatile ImeUtil sInstance;
+
+ // Used to clear the static cached instance of ImeUtil during testing. This is necessary
+ // because a previous test may have installed a mocked instance (or vice versa).
+ public static void clearInstance() {
+ sInstance = null;
+ }
+ public static ImeUtil get() {
+ if (sInstance == null) {
+ synchronized (ImeUtil.class) {
+ if (sInstance == null) {
+ sInstance = new ImeUtil();
+ }
+ }
+ }
+ return sInstance;
+ }
+
+ @VisibleForTesting
+ public static void set(final ImeUtil imeUtil) {
+ sInstance = imeUtil;
+ }
+
+ public void hideImeKeyboard(@NonNull final Context context, @NonNull final View v) {
+ Assert.notNull(context);
+ Assert.notNull(v);
+
+ final InputMethodManager inputMethodManager =
+ (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ if (inputMethodManager != null) {
+ inputMethodManager.hideSoftInputFromWindow(v.getWindowToken(), 0 /* flags */);
+ }
+ }
+
+ public void showImeKeyboard(@NonNull final Context context, @NonNull final View v) {
+ Assert.notNull(context);
+ Assert.notNull(v);
+
+ final InputMethodManager inputMethodManager =
+ (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ if (inputMethodManager != null) {
+ v.requestFocus();
+ inputMethodManager.showSoftInput(v, 0 /* flags */);
+ }
+ }
+
+ public static void hideSoftInput(@NonNull final Context context, @NonNull final View v) {
+ final InputMethodManager inputMethodManager =
+ (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ inputMethodManager.hideSoftInputFromWindow(v.getWindowToken(), 0);
+ }
+}
diff --git a/src/com/android/messaging/util/LogSaver.java b/src/com/android/messaging/util/LogSaver.java
new file mode 100644
index 0000000..7d1f2fd
--- /dev/null
+++ b/src/com/android/messaging/util/LogSaver.java
@@ -0,0 +1,293 @@
+/*
+ * 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.messaging.util;
+
+import android.os.Process;
+import android.util.Log;
+
+import com.android.messaging.Factory;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.text.SimpleDateFormat;
+import java.util.logging.FileHandler;
+import java.util.logging.Formatter;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Save the app's own log to dump along with adb bugreport
+ */
+public abstract class LogSaver {
+ /**
+ * Writes the accumulated log entries, from oldest to newest, to the specified PrintWriter.
+ * Log lines are emitted in much the same form as logcat -v threadtime -- specifically,
+ * lines will include a timestamp, pid, tid, level, and tag.
+ *
+ * @param writer The PrintWriter to output
+ */
+ public abstract void dump(PrintWriter writer);
+
+ /**
+ * Log a line
+ *
+ * @param level The log level to use
+ * @param tag The log tag
+ * @param msg The message of the log line
+ */
+ public abstract void log(int level, String tag, String msg);
+
+ /**
+ * Check if the LogSaver still matches the current Gservices settings
+ *
+ * @return true if matches, false otherwise
+ */
+ public abstract boolean isCurrent();
+
+ private LogSaver() {
+ }
+
+ public static LogSaver newInstance() {
+ final boolean persistent = BugleGservices.get().getBoolean(
+ BugleGservicesKeys.PERSISTENT_LOGSAVER,
+ BugleGservicesKeys.PERSISTENT_LOGSAVER_DEFAULT);
+ if (persistent) {
+ final int setSize = BugleGservices.get().getInt(
+ BugleGservicesKeys.PERSISTENT_LOGSAVER_ROTATION_SET_SIZE,
+ BugleGservicesKeys.PERSISTENT_LOGSAVER_ROTATION_SET_SIZE_DEFAULT);
+ final int fileLimitBytes = BugleGservices.get().getInt(
+ BugleGservicesKeys.PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES,
+ BugleGservicesKeys.PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES_DEFAULT);
+ return new DiskLogSaver(setSize, fileLimitBytes);
+ } else {
+ final int size = BugleGservices.get().getInt(
+ BugleGservicesKeys.IN_MEMORY_LOGSAVER_RECORD_COUNT,
+ BugleGservicesKeys.IN_MEMORY_LOGSAVER_RECORD_COUNT_DEFAULT);
+ return new MemoryLogSaver(size);
+ }
+ }
+
+ /**
+ * A circular in-memory log to be used to log potentially verbose logs. The logs will be
+ * persisted in memory in the application and can be dumped by various dump() methods.
+ * For example, adb shell dumpsys activity provider com.android.messaging.
+ * The dump will also show up in bugreports.
+ */
+ private static final class MemoryLogSaver extends LogSaver {
+ /**
+ * Record to store a single log entry. Stores timestamp, tid, level, tag, and message.
+ * It can be reused when the circular log rolls over. This avoids creating new objects.
+ */
+ private static class LogRecord {
+ int mTid;
+ String mLevelString;
+ long mTimeMillis; // from System.currentTimeMillis
+ String mTag;
+ String mMessage;
+
+ LogRecord() {
+ }
+
+ void set(int tid, int level, long time, String tag, String message) {
+ this.mTid = tid;
+ this.mTimeMillis = time;
+ this.mTag = tag;
+ this.mMessage = message;
+ this.mLevelString = getLevelString(level);
+ }
+ }
+
+ private final int mSize;
+ private final CircularArray<LogRecord> mLogList;
+ private final Object mLock;
+
+ private final SimpleDateFormat mSdf = new SimpleDateFormat("MM-dd HH:mm:ss.SSS");
+
+ public MemoryLogSaver(final int size) {
+ mSize = size;
+ mLogList = new CircularArray<LogRecord>(size);
+ mLock = new Object();
+ }
+
+ @Override
+ public void dump(PrintWriter writer) {
+ int pid = Process.myPid();
+ synchronized (mLock) {
+ for (int i = 0; i < mLogList.count(); i++) {
+ LogRecord rec = mLogList.get(i);
+ writer.println(String.format("%s %5d %5d %s %s: %s",
+ mSdf.format(rec.mTimeMillis),
+ pid, rec.mTid, rec.mLevelString, rec.mTag, rec.mMessage));
+ }
+ }
+ }
+
+ @Override
+ public void log(int level, String tag, String msg) {
+ synchronized (mLock) {
+ LogRecord rec = mLogList.getFree();
+ if (rec == null) {
+ rec = new LogRecord();
+ }
+ rec.set(Process.myTid(), level, System.currentTimeMillis(), tag, msg);
+ mLogList.add(rec);
+ }
+ }
+
+ @Override
+ public boolean isCurrent() {
+ final boolean persistent = BugleGservices.get().getBoolean(
+ BugleGservicesKeys.PERSISTENT_LOGSAVER,
+ BugleGservicesKeys.PERSISTENT_LOGSAVER_DEFAULT);
+ if (persistent) {
+ return false;
+ }
+ final int size = BugleGservices.get().getInt(
+ BugleGservicesKeys.IN_MEMORY_LOGSAVER_RECORD_COUNT,
+ BugleGservicesKeys.IN_MEMORY_LOGSAVER_RECORD_COUNT_DEFAULT);
+ return size == mSize;
+ }
+ }
+
+ /**
+ * A persistent, on-disk log saver. It uses the standard Java util logger along with
+ * a rotation log file set to store the logs in app's local file directory "app_logs".
+ */
+ private static final class DiskLogSaver extends LogSaver {
+ private static final String DISK_LOG_DIR_NAME = "logs";
+
+ private final int mSetSize;
+ private final int mFileLimitBytes;
+ private Logger mDiskLogger;
+
+ public DiskLogSaver(final int setSize, final int fileLimitBytes) {
+ Assert.isTrue(setSize > 0);
+ Assert.isTrue(fileLimitBytes > 0);
+ mSetSize = setSize;
+ mFileLimitBytes = fileLimitBytes;
+ initDiskLog();
+ }
+
+ private static void clearDefaultHandlers(Logger logger) {
+ Assert.notNull(logger);
+ for (Handler handler : logger.getHandlers()) {
+ logger.removeHandler(handler);
+ }
+ }
+
+ private void initDiskLog() {
+ mDiskLogger = Logger.getLogger(LogUtil.BUGLE_TAG);
+ // We don't want the default console handler
+ clearDefaultHandlers(mDiskLogger);
+ // Don't want duplicate print in system log
+ mDiskLogger.setUseParentHandlers(false);
+ // FileHandler manages the log files in a fixed rotation set
+ final File logDir = Factory.get().getApplicationContext().getDir(
+ DISK_LOG_DIR_NAME, 0/*mode*/);
+ FileHandler handler = null;
+ try {
+ handler = new FileHandler(
+ logDir + "/%g.log", mFileLimitBytes, mSetSize, true/*append*/);
+ } catch (Exception e) {
+ Log.e(LogUtil.BUGLE_TAG, "LogSaver: fail to init disk logger", e);
+ return;
+ }
+ final Formatter formatter = new Formatter() {
+ @Override
+ public String format(java.util.logging.LogRecord r) {
+ return r.getMessage();
+ }
+ };
+ handler.setFormatter(formatter);
+ handler.setLevel(Level.ALL);
+ mDiskLogger.addHandler(handler);
+ }
+
+ @Override
+ public void dump(PrintWriter writer) {
+ for (int i = mSetSize - 1; i >= 0; i--) {
+ final File logDir = Factory.get().getApplicationContext().getDir(
+ DISK_LOG_DIR_NAME, 0/*mode*/);
+ final String logFilePath = logDir + "/" + i + ".log";
+ try {
+ final File logFile = new File(logFilePath);
+ if (!logFile.exists()) {
+ continue;
+ }
+ final BufferedReader reader = new BufferedReader(new FileReader(logFile));
+ for (String line; (line = reader.readLine()) != null;) {
+ line = line.trim();
+ writer.println(line);
+ }
+ } catch (FileNotFoundException e) {
+ Log.w(LogUtil.BUGLE_TAG, "LogSaver: can not find log file " + logFilePath);
+ } catch (IOException e) {
+ Log.w(LogUtil.BUGLE_TAG, "LogSaver: can not read log file", e);
+ }
+ }
+ }
+
+ @Override
+ public void log(int level, String tag, String msg) {
+ final SimpleDateFormat sdf = new SimpleDateFormat("MM-dd HH:mm:ss.SSS");
+ mDiskLogger.info(String.format("%s %5d %5d %s %s: %s\n",
+ sdf.format(System.currentTimeMillis()),
+ Process.myPid(), Process.myTid(), getLevelString(level), tag, msg));
+ }
+
+ @Override
+ public boolean isCurrent() {
+ final boolean persistent = BugleGservices.get().getBoolean(
+ BugleGservicesKeys.PERSISTENT_LOGSAVER,
+ BugleGservicesKeys.PERSISTENT_LOGSAVER_DEFAULT);
+ if (!persistent) {
+ return false;
+ }
+ final int setSize = BugleGservices.get().getInt(
+ BugleGservicesKeys.PERSISTENT_LOGSAVER_ROTATION_SET_SIZE,
+ BugleGservicesKeys.PERSISTENT_LOGSAVER_ROTATION_SET_SIZE_DEFAULT);
+ final int fileLimitBytes = BugleGservices.get().getInt(
+ BugleGservicesKeys.PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES,
+ BugleGservicesKeys.PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES_DEFAULT);
+ return setSize == mSetSize && fileLimitBytes == mFileLimitBytes;
+ }
+ }
+
+ private static String getLevelString(final int level) {
+ switch (level) {
+ case android.util.Log.DEBUG:
+ return "D";
+ case android.util.Log.WARN:
+ return "W";
+ case android.util.Log.INFO:
+ return "I";
+ case android.util.Log.VERBOSE:
+ return "V";
+ case android.util.Log.ERROR:
+ return "E";
+ case android.util.Log.ASSERT:
+ return "A";
+ default:
+ return "?";
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/LogUtil.java b/src/com/android/messaging/util/LogUtil.java
new file mode 100644
index 0000000..021f39b
--- /dev/null
+++ b/src/com/android/messaging/util/LogUtil.java
@@ -0,0 +1,274 @@
+/*
+ * 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.messaging.util;
+
+/**
+ * Log utility class.
+ */
+public class LogUtil {
+ public static final String BUGLE_TAG = "MessagingApp";
+ public static final String PROFILE_TAG = "MessagingAppProf";
+ public static final String BUGLE_DATABASE_TAG = "MessagingAppDb";
+ public static final String BUGLE_DATABASE_PERF_TAG = "MessagingAppDbPerf";
+ public static final String BUGLE_DATAMODEL_TAG = "MessagingAppDataModel";
+ public static final String BUGLE_IMAGE_TAG = "MessagingAppImage";
+ public static final String BUGLE_NOTIFICATIONS_TAG = "MessagingAppNotif";
+ public static final String BUGLE_WIDGET_TAG = "MessagingAppWidget";
+
+ public static final int DEBUG = android.util.Log.DEBUG;
+ public static final int WARN = android.util.Log.WARN;
+ public static final int VERBOSE = android.util.Log.VERBOSE;
+ public static final int INFO = android.util.Log.INFO;
+ public static final int ERROR = android.util.Log.ERROR;
+
+ // If this is non-null, DEBUG and higher logs will be tracked in-memory. It will not include
+ // VERBOSE logs.
+ private static LogSaver sDebugLogSaver;
+ private static volatile boolean sCaptureDebugLogs;
+
+ /**
+ * Read Gservices to see if logging should be enabled.
+ */
+ public static void refreshGservices(final BugleGservices gservices) {
+ sCaptureDebugLogs = gservices.getBoolean(
+ BugleGservicesKeys.ENABLE_LOG_SAVER,
+ BugleGservicesKeys.ENABLE_LOG_SAVER_DEFAULT);
+ if (sCaptureDebugLogs && (sDebugLogSaver == null || !sDebugLogSaver.isCurrent())) {
+ // We were not capturing logs before. We are now.
+ sDebugLogSaver = LogSaver.newInstance();
+ } else if (!sCaptureDebugLogs && sDebugLogSaver != null) {
+ // We were capturing logs. We aren't anymore.
+ sDebugLogSaver = null;
+ }
+ }
+
+ // This is called from FactoryImpl once the Gservices class is initialized.
+ public static void initializeGservices (final BugleGservices gservices) {
+ gservices.registerForChanges(new Runnable() {
+ @Override
+ public void run() {
+ refreshGservices(gservices);
+ }
+ });
+ refreshGservices(gservices);
+ }
+
+ /**
+ * 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 msg The message you would like logged.
+ */
+ public static void v(final String tag, final String msg) {
+ println(android.util.Log.VERBOSE, tag, msg);
+ }
+
+ /**
+ * 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 msg The message you would like logged.
+ * @param tr An exception to log
+ */
+ public static void v(final String tag, final String msg, final Throwable tr) {
+ println(android.util.Log.VERBOSE, tag, msg + '\n'
+ + android.util.Log.getStackTraceString(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 msg The message you would like logged.
+ */
+ public static void d(final String tag, final String msg) {
+ println(android.util.Log.DEBUG, tag, msg);
+ }
+
+ /**
+ * Send a {@link #DEBUG} log message and log the exception.
+ * @param tag Used to identify the source of a log message. It usually identifies
+ * the class or activity where the log call occurs.
+ * @param msg The message you would like logged.
+ * @param tr An exception to log
+ */
+ public static void d(final String tag, final String msg, final Throwable tr) {
+ println(android.util.Log.DEBUG, tag, msg + '\n'
+ + android.util.Log.getStackTraceString(tr));
+ }
+
+ /**
+ * Send an {@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 msg The message you would like logged.
+ */
+ public static void i(final String tag, final String msg) {
+ println(android.util.Log.INFO, tag, msg);
+ }
+
+ /**
+ * Send a {@link #INFO} log message and log the exception.
+ * @param tag Used to identify the source of a log message. It usually identifies
+ * the class or activity where the log call occurs.
+ * @param msg The message you would like logged.
+ * @param tr An exception to log
+ */
+ public static void i(final String tag, final String msg, final Throwable tr) {
+ println(android.util.Log.INFO, tag, msg + '\n'
+ + android.util.Log.getStackTraceString(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 msg The message you would like logged.
+ */
+ public static void w(final String tag, final String msg) {
+ println(android.util.Log.WARN, tag, msg);
+ }
+
+ /**
+ * Send a {@link #WARN} log message and log the exception.
+ * @param tag Used to identify the source of a log message. It usually identifies
+ * the class or activity where the log call occurs.
+ * @param msg The message you would like logged.
+ * @param tr An exception to log
+ */
+ public static void w(final String tag, final String msg, final Throwable tr) {
+ println(android.util.Log.WARN, tag, msg);
+ println(android.util.Log.WARN, tag, android.util.Log.getStackTraceString(tr));
+ }
+
+ /**
+ * Send an {@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 msg The message you would like logged.
+ */
+ public static void e(final String tag, final String msg) {
+ println(android.util.Log.ERROR, tag, msg);
+ }
+
+ /**
+ * Send a {@link #ERROR} log message and log the exception.
+ * @param tag Used to identify the source of a log message. It usually identifies
+ * the class or activity where the log call occurs.
+ * @param msg The message you would like logged.
+ * @param tr An exception to log
+ */
+ public static void e(final String tag, final String msg, final Throwable tr) {
+ println(android.util.Log.ERROR, tag, msg);
+ println(android.util.Log.ERROR, tag, android.util.Log.getStackTraceString(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.
+ * @param msg The message you would like logged.
+ */
+ public static void wtf(final String tag, final String msg) {
+ // Make sure this goes into our log buffer
+ println(android.util.Log.ASSERT, tag, "wtf\n" + msg);
+ android.util.Log.wtf(tag, msg, new Exception());
+ }
+
+ /**
+ * 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.
+ * @param msg The message you would like logged.
+ * @param tr An exception to log
+ */
+ public static void wtf(final String tag, final String msg, final Throwable tr) {
+ // Make sure this goes into our log buffer
+ println(android.util.Log.ASSERT, tag, "wtf\n" + msg + '\n' +
+ android.util.Log.getStackTraceString(tr));
+ android.util.Log.wtf(tag, msg, tr);
+ }
+
+ /**
+ * Low-level logging call.
+ * @param level The priority/type of this log message
+ * @param tag Used to identify the source of a log message. It usually identifies
+ * the class or activity where the log call occurs.
+ * @param msg The message you would like logged.
+ */
+ private static void println(final int level, final String tag, final String msg) {
+ android.util.Log.println(level, tag, msg);
+
+ LogSaver serviceLog = sDebugLogSaver;
+ if (serviceLog != null && level >= android.util.Log.DEBUG) {
+ serviceLog.log(level, tag, msg);
+ }
+ }
+
+ /**
+ * Save logging into LogSaver only, for dumping to bug report
+ *
+ * @param level The priority/type of this log message
+ * @param tag Used to identify the source of a log message. It usually identifies
+ * the class or activity where the log call occurs.
+ * @param msg The message you would like logged.
+ */
+ public static void save(final int level, final String tag, final String msg) {
+ LogSaver serviceLog = sDebugLogSaver;
+ if (serviceLog != null) {
+ serviceLog.log(level, tag, msg);
+ }
+ }
+
+ /**
+ * Checks to see whether or not a log for the specified tag is loggable at the specified level.
+ * See {@link android.util.Log#isLoggable(String, int)} for more discussion.
+ */
+ public static boolean isLoggable(final String tag, final int level) {
+ return android.util.Log.isLoggable(tag, level);
+ }
+
+ /**
+ * Returns text as is if {@value #BUGLE_TAG}'s log level is set to DEBUG or VERBOSE;
+ * returns "--" otherwise. Useful for log statements where we don't want to log
+ * various strings (e.g., usernames) with default logging to avoid leaking PII in logcat.
+ */
+ public static String sanitizePII(final String text) {
+ if (text == null) {
+ return null;
+ }
+
+ if (android.util.Log.isLoggable(BUGLE_TAG, android.util.Log.DEBUG)) {
+ return text;
+ } else {
+ return "Redacted-" + text.length();
+ }
+ }
+
+ public static void dump(java.io.PrintWriter out) {
+ final LogSaver logsaver = sDebugLogSaver;
+ if (logsaver != null) {
+ logsaver.dump(out);
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/LoggingTimer.java b/src/com/android/messaging/util/LoggingTimer.java
new file mode 100644
index 0000000..d0d41ac
--- /dev/null
+++ b/src/com/android/messaging/util/LoggingTimer.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util;
+
+import android.os.SystemClock;
+
+/**
+ * A utility timer that logs the execution time of operations
+ */
+public class LoggingTimer {
+ private static final int NO_WARN_LIMIT = -1;
+
+ private final String mTag;
+ private final String mName;
+ private final long mWarnLimitMillis;
+ private long mStartMillis;
+
+ public LoggingTimer(final String tag, final String name) {
+ this(tag, name, NO_WARN_LIMIT);
+ }
+
+ public LoggingTimer(final String tag, final String name, final long warnLimitMillis) {
+ mTag = tag;
+ mName = name;
+ mWarnLimitMillis = warnLimitMillis;
+ }
+
+ /**
+ * This method should be called at the start of the operation to be timed.
+ */
+ public void start() {
+ mStartMillis = SystemClock.elapsedRealtime();
+
+ if (LogUtil.isLoggable(mTag, LogUtil.VERBOSE)) {
+ LogUtil.v(mTag, "Timer start for " + mName);
+ }
+ }
+
+ /**
+ * This method should be called at the end of the operation to be timed. It logs the time since
+ * the last call to {@link #start}
+ */
+ public void stopAndLog() {
+ final long elapsedMs = SystemClock.elapsedRealtime() - mStartMillis;
+
+ final String logMessage = String.format("Used %dms for %s", elapsedMs, mName);
+
+ LogUtil.save(LogUtil.DEBUG, mTag, logMessage);
+
+ if (mWarnLimitMillis != NO_WARN_LIMIT && elapsedMs > mWarnLimitMillis) {
+ LogUtil.w(mTag, logMessage);
+ } else if (LogUtil.isLoggable(mTag, LogUtil.VERBOSE)) {
+ LogUtil.v(mTag, logMessage);
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/LongSparseSet.java b/src/com/android/messaging/util/LongSparseSet.java
new file mode 100644
index 0000000..8e2cfca
--- /dev/null
+++ b/src/com/android/messaging/util/LongSparseSet.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.messaging.util;
+
+import android.support.v4.util.LongSparseArray;
+
+/**
+ * A space saving set for long values using v4 compat LongSparseArray
+ */
+public class LongSparseSet {
+ private static final Object THE_ONLY_VALID_VALUE = new Object();
+ private final LongSparseArray<Object> mSet = new LongSparseArray<Object>();
+
+ public LongSparseSet() {
+ }
+
+ /**
+ * @param key The element to check
+ * @return True if the element is in the set, false otherwise
+ */
+ public boolean contains(long key) {
+ if (mSet.get(key, null/*default*/) == THE_ONLY_VALID_VALUE) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Add an element to the set
+ *
+ * @param key The element to add
+ */
+ public void add(long key) {
+ mSet.put(key, THE_ONLY_VALID_VALUE);
+ }
+
+ /**
+ * Remove an element from the set
+ *
+ * @param key The element to remove
+ */
+ public void remove(long key) {
+ mSet.delete(key);
+ }
+}
diff --git a/src/com/android/messaging/util/MaterialPalette.java b/src/com/android/messaging/util/MaterialPalette.java
new file mode 100644
index 0000000..8dbacbf
--- /dev/null
+++ b/src/com/android/messaging/util/MaterialPalette.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.messaging.util;
+
+
+public class MaterialPalette{
+ public final int mPrimaryColor;
+ public final int mSecondaryColor;
+
+ public MaterialPalette(final int primaryColor, final int secondaryColor) {
+ mPrimaryColor = primaryColor;
+ mSecondaryColor = secondaryColor;
+ }
+}
diff --git a/src/com/android/messaging/util/MediaMetadataRetrieverWrapper.java b/src/com/android/messaging/util/MediaMetadataRetrieverWrapper.java
new file mode 100644
index 0000000..b1078d1
--- /dev/null
+++ b/src/com/android/messaging/util/MediaMetadataRetrieverWrapper.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.messaging.util;
+
+import android.content.ContentResolver;
+import android.content.res.AssetFileDescriptor;
+import android.graphics.Bitmap;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+
+import java.io.IOException;
+
+/**
+ * Convenience wrapper for {@link MediaMetadataRetriever} to help with its eccentric error handling.
+ */
+public class MediaMetadataRetrieverWrapper {
+ private final MediaMetadataRetriever mRetriever = new MediaMetadataRetriever();
+
+ public MediaMetadataRetrieverWrapper() {
+ }
+
+ public void setDataSource(Uri uri) throws IOException {
+ ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver();
+ AssetFileDescriptor fd = resolver.openAssetFileDescriptor(uri, "r");
+ if (fd == null) {
+ throw new IOException("openAssetFileDescriptor returned null for " + uri);
+ }
+ try {
+ mRetriever.setDataSource(fd.getFileDescriptor());
+ } catch (RuntimeException e) {
+ release();
+ throw new IOException(e);
+ } finally {
+ fd.close();
+ }
+ }
+
+ public int extractInteger(final int key, final int defaultValue) {
+ final String s = mRetriever.extractMetadata(key);
+ if (TextUtils.isEmpty(s)) {
+ return defaultValue;
+ }
+ return Integer.parseInt(s);
+ }
+
+ public String extractMetadata(final int key) {
+ return mRetriever.extractMetadata(key);
+ }
+
+ public Bitmap getFrameAtTime() {
+ return mRetriever.getFrameAtTime();
+ }
+
+ public Bitmap getFrameAtTime(final long timeUs) {
+ return mRetriever.getFrameAtTime(timeUs);
+ }
+
+ public void release() {
+ try {
+ mRetriever.release();
+ } catch (RuntimeException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "MediaMetadataRetriever.release failed", e);
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/MediaUtil.java b/src/com/android/messaging/util/MediaUtil.java
new file mode 100644
index 0000000..f25354c
--- /dev/null
+++ b/src/com/android/messaging/util/MediaUtil.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.messaging.util;
+
+import android.content.Context;
+
+import com.android.messaging.Factory;
+
+public abstract class MediaUtil {
+ public static interface OnCompletionListener {
+ public void onCompletion();
+ }
+
+ public static MediaUtil get() {
+ return Factory.get().getMediaUtil();
+ }
+
+ /**
+ * Play sound from local resources given a resource id.
+ */
+ public abstract void playSound(final Context context, final int resId,
+ final OnCompletionListener completionListener);
+}
diff --git a/src/com/android/messaging/util/MediaUtilImpl.java b/src/com/android/messaging/util/MediaUtilImpl.java
new file mode 100644
index 0000000..272a057
--- /dev/null
+++ b/src/com/android/messaging/util/MediaUtilImpl.java
@@ -0,0 +1,66 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.content.res.AssetFileDescriptor;
+import android.media.AudioManager;
+import android.media.MediaPlayer;
+
+/**
+ * Default implementation of MediaUtil
+ */
+public class MediaUtilImpl extends MediaUtil {
+
+ @Override
+ public void playSound(final Context context, final int resId,
+ final OnCompletionListener completionListener) {
+ // We want to play at the media volume and not the ringer volume, but we do want to
+ // avoid playing sound when the ringer/notifications are silenced. This is used for
+ // in app sounds that are not critical and should not impact running silent but also
+ // shouldn't play at full ring volume if you want to hear your ringer but don't want
+ // to be annoyed with in-app volume.
+ AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ try {
+ final MediaPlayer mediaPlayer = new MediaPlayer();
+ mediaPlayer.setAudioStreamType(AudioManager.STREAM_NOTIFICATION);
+ final AssetFileDescriptor afd = context.getResources().openRawResourceFd(resId);
+ mediaPlayer.setDataSource(
+ afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength());
+ afd.close();
+ mediaPlayer.prepare();
+ mediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() {
+ @Override
+ public void onCompletion(final MediaPlayer mp) {
+ if (completionListener != null) {
+ completionListener.onCompletion();
+ }
+ mp.stop();
+ mp.release();
+ }
+ });
+ mediaPlayer.seekTo(0);
+ mediaPlayer.start();
+ return;
+ } catch (final Exception e) {
+ LogUtil.w("MediaUtilImpl", "Error playing sound id: " + resId, e);
+ }
+ if (completionListener != null) {
+ // Call the completion handler to not block functionality if audio play fails
+ completionListener.onCompletion();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/messaging/util/NotificationPlayer.java b/src/com/android/messaging/util/NotificationPlayer.java
new file mode 100644
index 0000000..a4ed44e
--- /dev/null
+++ b/src/com/android/messaging/util/NotificationPlayer.java
@@ -0,0 +1,363 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.media.AudioManager;
+import android.media.MediaPlayer;
+import android.media.MediaPlayer.OnCompletionListener;
+import android.net.Uri;
+import android.os.Looper;
+import android.os.PowerManager;
+import android.os.SystemClock;
+
+import com.android.messaging.Factory;
+
+import java.util.LinkedList;
+
+/**
+ * This class is provides the same interface and functionality as android.media.AsyncPlayer
+ * with the following differences:
+ * - whenever audio is played, audio focus is requested,
+ * - whenever audio playback is stopped or the playback completed, audio focus is abandoned.
+ *
+ * This file has been copied from com.android.server.NotificationPlayer. The only modification is
+ * the addition of a volume parameter. Hopefully the framework will adapt AsyncPlayer to support
+ * all the functionality in this class, at which point this one can be deleted.
+ */
+public class NotificationPlayer implements OnCompletionListener {
+ private static final int PLAY = 1;
+ private static final int STOP = 2;
+ private static final boolean mDebug = false;
+
+ private static final class Command {
+ int code;
+ Uri uri;
+ boolean looping;
+ int stream;
+ float volume;
+ long requestTime;
+ boolean releaseFocus;
+
+ @Override
+ public String toString() {
+ return "{ code=" + code + " looping=" + looping + " stream=" + stream
+ + " uri=" + uri + " }";
+ }
+ }
+
+ private final LinkedList<Command> mCmdQueue = new LinkedList<Command>();
+
+ private Looper mLooper;
+
+ /*
+ * Besides the use of audio focus, the only implementation difference between AsyncPlayer and
+ * NotificationPlayer resides in the creation of the MediaPlayer. For the completion callback,
+ * OnCompletionListener, to be called at the end of the playback, the MediaPlayer needs to
+ * be created with a looper running so its event handler is not null.
+ */
+ private final class CreationAndCompletionThread extends Thread {
+ public Command mCmd;
+ public CreationAndCompletionThread(final Command cmd) {
+ super();
+ mCmd = cmd;
+ }
+
+ @Override
+ public void run() {
+ Looper.prepare();
+ mLooper = Looper.myLooper();
+ synchronized (this) {
+ final AudioManager audioManager =
+ (AudioManager) Factory.get().getApplicationContext()
+ .getSystemService(Context.AUDIO_SERVICE);
+ try {
+ final MediaPlayer player = new MediaPlayer();
+ player.setAudioStreamType(mCmd.stream);
+ player.setDataSource(Factory.get().getApplicationContext(), mCmd.uri);
+ player.setLooping(mCmd.looping);
+ player.setVolume(mCmd.volume, mCmd.volume);
+ player.prepare();
+ if ((mCmd.uri != null) && (mCmd.uri.getEncodedPath() != null)
+ && (mCmd.uri.getEncodedPath().length() > 0)) {
+ audioManager.requestAudioFocus(null, mCmd.stream,
+ mCmd.looping ? AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
+ : AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK);
+ }
+ player.setOnCompletionListener(NotificationPlayer.this);
+ player.start();
+ if (mPlayer != null) {
+ mPlayer.release();
+ }
+ mPlayer = player;
+ } catch (final Exception e) {
+ LogUtil.w(mTag, "error loading sound for " + mCmd.uri, e);
+ }
+ mAudioManager = audioManager;
+ this.notify();
+ }
+ Looper.loop();
+ }
+ }
+
+ private void startSound(final Command cmd) {
+ // Preparing can be slow, so if there is something else
+ // is playing, let it continue until we're done, so there
+ // is less of a glitch.
+ try {
+ if (mDebug) {
+ LogUtil.d(mTag, "Starting playback");
+ }
+ //-----------------------------------
+ // This is were we deviate from the AsyncPlayer implementation and create the
+ // MediaPlayer in a new thread with which we're synchronized
+ synchronized (mCompletionHandlingLock) {
+ // if another sound was already playing, it doesn't matter we won't get notified
+ // of the completion, since only the completion notification of the last sound
+ // matters
+ if ((mLooper != null)
+ && (mLooper.getThread().getState() != Thread.State.TERMINATED)) {
+ mLooper.quit();
+ }
+ mCompletionThread = new CreationAndCompletionThread(cmd);
+ synchronized (mCompletionThread) {
+ mCompletionThread.start();
+ mCompletionThread.wait();
+ }
+ }
+ //-----------------------------------
+
+ final long delay = SystemClock.elapsedRealtime() - cmd.requestTime;
+ if (delay > 1000) {
+ LogUtil.w(mTag, "Notification sound delayed by " + delay + "msecs");
+ }
+ } catch (final Exception e) {
+ LogUtil.w(mTag, "error loading sound for " + cmd.uri, e);
+ }
+ }
+
+ private void stopSound(final Command cmd) {
+ if (mPlayer == null) {
+ return;
+ }
+ final long delay = SystemClock.elapsedRealtime() - cmd.requestTime;
+ if (delay > 1000) {
+ LogUtil.w(mTag, "Notification stop delayed by " + delay + "msecs");
+ }
+ mPlayer.stop();
+ mPlayer.release();
+ mPlayer = null;
+ if (cmd.releaseFocus && mAudioManager != null) {
+ mAudioManager.abandonAudioFocus(null);
+ }
+ mAudioManager = null;
+ if ((mLooper != null) && (mLooper.getThread().getState() != Thread.State.TERMINATED)) {
+ mLooper.quit();
+ }
+ }
+
+ private final class CmdThread extends java.lang.Thread {
+ CmdThread() {
+ super("NotificationPlayer-" + mTag);
+ }
+
+ @Override
+ public void run() {
+ while (true) {
+ Command cmd = null;
+
+ synchronized (mCmdQueue) {
+ if (mDebug) {
+ LogUtil.d(mTag, "RemoveFirst");
+ }
+ cmd = mCmdQueue.removeFirst();
+ }
+
+ switch (cmd.code) {
+ case PLAY:
+ if (mDebug) {
+ LogUtil.d(mTag, "PLAY");
+ }
+ startSound(cmd);
+ break;
+ case STOP:
+ if (mDebug) {
+ LogUtil.d(mTag, "STOP");
+ }
+ stopSound(cmd);
+ break;
+ }
+
+ synchronized (mCmdQueue) {
+ if (mCmdQueue.size() == 0) {
+ // nothing left to do, quit
+ // doing this check after we're done prevents the case where they
+ // added it during the operation from spawning two threads and
+ // trying to do them in parallel.
+ mThread = null;
+ releaseWakeLock();
+ return;
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onCompletion(final MediaPlayer mp) {
+ if (mAudioManager != null) {
+ mAudioManager.abandonAudioFocus(null);
+ }
+ // if there are no more sounds to play, end the Looper to listen for media completion
+ synchronized (mCmdQueue) {
+ if (mCmdQueue.size() == 0) {
+ synchronized (mCompletionHandlingLock) {
+ if (mLooper != null) {
+ mLooper.quit();
+ }
+ mCompletionThread = null;
+ }
+ }
+ }
+ }
+
+ private String mTag;
+ private CmdThread mThread;
+ private CreationAndCompletionThread mCompletionThread;
+ private final Object mCompletionHandlingLock = new Object();
+ private MediaPlayer mPlayer;
+ private PowerManager.WakeLock mWakeLock;
+ private AudioManager mAudioManager;
+
+ // The current state according to the caller. Reality lags behind
+ // because of the asynchronous nature of this class.
+ private int mState = STOP;
+
+ /**
+ * Construct a NotificationPlayer object.
+ *
+ * @param tag a string to use for debugging
+ */
+ public NotificationPlayer(final String tag) {
+ if (tag != null) {
+ mTag = tag;
+ } else {
+ mTag = "NotificationPlayer";
+ }
+ }
+
+ /**
+ * Start playing the sound. It will actually start playing at some
+ * point in the future. There are no guarantees about latency here.
+ * Calling this before another audio file is done playing will stop
+ * that one and start the new one.
+ *
+ * @param uri The URI to play. (see {@link MediaPlayer#setDataSource(Context, Uri)})
+ * @param looping Whether the audio should loop forever.
+ * (see {@link MediaPlayer#setLooping(boolean)})
+ * @param stream the AudioStream to use.
+ * (see {@link MediaPlayer#setAudioStreamType(int)})
+ * @param volume The volume at which to play this sound, as a fraction of the system volume for
+ * the relevant stream type. A value of 1 is the maximum and means play at the system
+ * volume with no attenuation.
+ */
+ public void play(final Uri uri, final boolean looping, final int stream, final float volume) {
+ final Command cmd = new Command();
+ cmd.requestTime = SystemClock.elapsedRealtime();
+ cmd.code = PLAY;
+ cmd.uri = uri;
+ cmd.looping = looping;
+ cmd.stream = stream;
+ cmd.volume = volume;
+ synchronized (mCmdQueue) {
+ enqueueLocked(cmd);
+ mState = PLAY;
+ }
+ }
+
+ /** Same as calling stop(true) */
+ public void stop() {
+ stop(true);
+ }
+
+ /**
+ * Stop a previously played sound. It can't be played again or unpaused
+ * at this point. Calling this multiple times has no ill effects.
+ * @param releaseAudioFocus whether to release audio focus
+ */
+ public void stop(final boolean releaseAudioFocus) {
+ synchronized (mCmdQueue) {
+ // This check allows stop to be called multiple times without starting
+ // a thread that ends up doing nothing.
+ if (mState != STOP) {
+ final Command cmd = new Command();
+ cmd.requestTime = SystemClock.elapsedRealtime();
+ cmd.code = STOP;
+ cmd.releaseFocus = releaseAudioFocus;
+ enqueueLocked(cmd);
+ mState = STOP;
+ }
+ }
+ }
+
+ private void enqueueLocked(final Command cmd) {
+ mCmdQueue.add(cmd);
+ if (mThread == null) {
+ acquireWakeLock();
+ mThread = new CmdThread();
+ mThread.start();
+ }
+ }
+
+ /**
+ * We want to hold a wake lock while we do the prepare and play. The stop probably is
+ * optional, but it won't hurt to have it too. The problem is that if you start a sound
+ * while you're holding a wake lock (e.g. an alarm starting a notification), you want the
+ * sound to play, but if the CPU turns off before mThread gets to work, it won't. The
+ * simplest way to deal with this is to make it so there is a wake lock held while the
+ * thread is starting or running. You're going to need the WAKE_LOCK permission if you're
+ * going to call this.
+ *
+ * This must be called before the first time play is called.
+ *
+ * @hide
+ */
+ public void setUsesWakeLock() {
+ if (mWakeLock != null || mThread != null) {
+ // if either of these has happened, we've already played something.
+ // and our releases will be out of sync.
+ throw new RuntimeException("assertion failed mWakeLock=" + mWakeLock
+ + " mThread=" + mThread);
+ }
+ final PowerManager pm = (PowerManager) Factory.get().getApplicationContext()
+ .getSystemService(Context.POWER_SERVICE);
+ mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, mTag);
+ }
+
+ private void acquireWakeLock() {
+ if (mWakeLock != null) {
+ mWakeLock.acquire();
+ }
+ }
+
+ private void releaseWakeLock() {
+ if (mWakeLock != null) {
+ mWakeLock.release();
+ }
+ }
+}
+
diff --git a/src/com/android/messaging/util/OsUtil.java b/src/com/android/messaging/util/OsUtil.java
new file mode 100644
index 0000000..e45a63c
--- /dev/null
+++ b/src/com/android/messaging/util/OsUtil.java
@@ -0,0 +1,269 @@
+/*
+ * 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.messaging.util;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.UserHandle;
+import android.os.UserManager;
+
+import com.android.messaging.Factory;
+
+import java.util.ArrayList;
+import java.util.Hashtable;
+import java.util.Set;
+
+/**
+ * Android OS version utilities
+ */
+public class OsUtil {
+ private static boolean sIsAtLeastICS_MR1;
+ private static boolean sIsAtLeastJB;
+ private static boolean sIsAtLeastJB_MR1;
+ private static boolean sIsAtLeastJB_MR2;
+ private static boolean sIsAtLeastKLP;
+ private static boolean sIsAtLeastL;
+ private static boolean sIsAtLeastL_MR1;
+ private static boolean sIsAtLeastM;
+
+ private static Boolean sIsSecondaryUser = null;
+
+ static {
+ final int v = getApiVersion();
+ sIsAtLeastICS_MR1 = v >= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1;
+ sIsAtLeastJB = v >= android.os.Build.VERSION_CODES.JELLY_BEAN;
+ sIsAtLeastJB_MR1 = v >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+ sIsAtLeastJB_MR2 = v >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+ sIsAtLeastKLP = v >= android.os.Build.VERSION_CODES.KITKAT;
+ sIsAtLeastL = v >= android.os.Build.VERSION_CODES.LOLLIPOP;
+ sIsAtLeastL_MR1 = v >= android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+ sIsAtLeastM = v >= android.os.Build.VERSION_CODES.M;
+ }
+
+ /**
+ * @return True if the version of Android that we're running on is at least Ice Cream Sandwich
+ * MR1 (API level 15).
+ */
+ public static boolean isAtLeastICS_MR1() {
+ return sIsAtLeastICS_MR1;
+ }
+
+ /**
+ * @return True if the version of Android that we're running on is at least Jelly Bean
+ * (API level 16).
+ */
+ public static boolean isAtLeastJB() {
+ return sIsAtLeastJB;
+ }
+
+ /**
+ * @return True if the version of Android that we're running on is at least Jelly Bean MR1
+ * (API level 17).
+ */
+ public static boolean isAtLeastJB_MR1() {
+ return sIsAtLeastJB_MR1;
+ }
+
+ /**
+ * @return True if the version of Android that we're running on is at least Jelly Bean MR2
+ * (API level 18).
+ */
+ public static boolean isAtLeastJB_MR2() {
+ return sIsAtLeastJB_MR2;
+ }
+
+ /**
+ * @return True if the version of Android that we're running on is at least KLP
+ * (API level 19).
+ */
+ public static boolean isAtLeastKLP() {
+ return sIsAtLeastKLP;
+ }
+
+ /**
+ * @return True if the version of Android that we're running on is at least L
+ * (API level 21).
+ */
+ public static boolean isAtLeastL() {
+ return sIsAtLeastL;
+ }
+
+ /**
+ * @return True if the version of Android that we're running on is at least L MR1
+ * (API level 22).
+ */
+ public static boolean isAtLeastL_MR1() {
+ return sIsAtLeastL_MR1;
+ }
+
+ /**
+ * @return True if the version of Android that we're running on is at least M
+ * (API level 23).
+ */
+ public static boolean isAtLeastM() {
+ return sIsAtLeastM;
+ }
+
+ /**
+ * @return The Android API version of the OS that we're currently running on.
+ */
+ public static int getApiVersion() {
+ return android.os.Build.VERSION.SDK_INT;
+ }
+
+ public static boolean isSecondaryUser() {
+ if (sIsSecondaryUser == null) {
+ final Context context = Factory.get().getApplicationContext();
+ boolean isSecondaryUser = false;
+
+ // Only check for newer devices (but not the nexus 10)
+ if (OsUtil.sIsAtLeastJB_MR1 && !"Nexus 10".equals(Build.MODEL)) {
+ final UserHandle uh = android.os.Process.myUserHandle();
+ final UserManager userManager =
+ (UserManager) context.getSystemService(Context.USER_SERVICE);
+ if (userManager != null) {
+ final long userSerialNumber = userManager.getSerialNumberForUser(uh);
+ isSecondaryUser = (0 != userSerialNumber);
+ }
+ }
+ sIsSecondaryUser = isSecondaryUser;
+ }
+ return sIsSecondaryUser;
+ }
+
+ /**
+ * Creates a joined string from a Set<String> using the given delimiter.
+ * @param values
+ * @param delimiter
+ * @return
+ */
+ public static String joinFromSetWithDelimiter(
+ final Set<String> values, final String delimiter) {
+ if (values != null) {
+ final StringBuilder joinedStringBuilder = new StringBuilder();
+ boolean firstValue = true;
+ for (final String value : values) {
+ if (firstValue) {
+ firstValue = false;
+ } else {
+ joinedStringBuilder.append(delimiter);
+ }
+ joinedStringBuilder.append(value);
+ }
+ return joinedStringBuilder.toString();
+ }
+ return null;
+ }
+
+ private static Hashtable<String, Integer> sPermissions = new Hashtable<String, Integer>();
+
+ /**
+ * Check if the app has the specified permission. If it does not, the app needs to use
+ * {@link android.app.Activity#requestPermission}. Note that if it
+ * returns true, it cannot return false in the same process as the OS kills the process when
+ * any permission is revoked.
+ * @param permission A permission from {@link android.Manifest.permission}
+ */
+ public static boolean hasPermission(final String permission) {
+ if (OsUtil.isAtLeastM()) {
+ // It is safe to cache the PERMISSION_GRANTED result as the process gets killed if the
+ // user revokes the permission setting. However, PERMISSION_DENIED should not be
+ // cached as the process does not get killed if the user enables the permission setting.
+ if (!sPermissions.containsKey(permission)
+ || sPermissions.get(permission) == PackageManager.PERMISSION_DENIED) {
+ final Context context = Factory.get().getApplicationContext();
+ final int permissionState = context.checkSelfPermission(permission);
+ sPermissions.put(permission, permissionState);
+ }
+ return sPermissions.get(permission) == PackageManager.PERMISSION_GRANTED;
+ } else {
+ return true;
+ }
+ }
+
+ /** Does the app have all the specified permissions */
+ public static boolean hasPermissions(final String[] permissions) {
+ for (final String permission : permissions) {
+ if (!hasPermission(permission)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public static boolean hasPhonePermission() {
+ return hasPermission(Manifest.permission.READ_PHONE_STATE);
+ }
+
+ public static boolean hasSmsPermission() {
+ return hasPermission(Manifest.permission.READ_SMS);
+ }
+
+ public static boolean hasLocationPermission() {
+ return OsUtil.hasPermission(Manifest.permission.ACCESS_FINE_LOCATION);
+ }
+
+
+ public static boolean hasStoragePermission() {
+ // Note that READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE are granted or denied
+ // together.
+ return OsUtil.hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE);
+ }
+
+ public static boolean hasRecordAudioPermission() {
+ return OsUtil.hasPermission(Manifest.permission.RECORD_AUDIO);
+ }
+
+ /**
+ * Returns array with the set of permissions that have not been granted from the given set.
+ * The array will be empty if the app has all of the specified permissions. Note that calling
+ * {@link Activity#requestPermissions} for an already granted permission can prompt the user
+ * again, and its up to the app to only request permissions that are missing.
+ */
+ public static String[] getMissingPermissions(final String[] permissions) {
+ final ArrayList<String> missingList = new ArrayList<String>();
+ for (final String permission : permissions) {
+ if (!hasPermission(permission)) {
+ missingList.add(permission);
+ }
+ }
+
+ final String[] missingArray = new String[missingList.size()];
+ missingList.toArray(missingArray);
+ return missingArray;
+ }
+
+ private static String[] sRequiredPermissions = new String[] {
+ // Required to read existing SMS threads
+ Manifest.permission.READ_SMS,
+ // Required for knowing the phone number, number of SIMs, etc.
+ Manifest.permission.READ_PHONE_STATE,
+ // This is not strictly required, but simplifies the contact picker scenarios
+ Manifest.permission.READ_CONTACTS,
+ };
+
+ /** Does the app have the minimum set of permissions required to operate. */
+ public static boolean hasRequiredPermissions() {
+ return hasPermissions(sRequiredPermissions);
+ }
+
+ public static String[] getMissingRequiredPermissions() {
+ return getMissingPermissions(sRequiredPermissions);
+ }
+}
diff --git a/src/com/android/messaging/util/PendingIntentConstants.java b/src/com/android/messaging/util/PendingIntentConstants.java
new file mode 100644
index 0000000..1a594c5
--- /dev/null
+++ b/src/com/android/messaging/util/PendingIntentConstants.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.messaging.util;
+
+
+public class PendingIntentConstants {
+ // Notifications
+ public static final int SMS_NOTIFICATION_ID = 0;
+ public static final int SMS_SECONDARY_USER_NOTIFICATION_ID = 1;
+ public static final int MSG_SEND_ERROR = 2;
+ public static final int SMS_STORAGE_LOW_NOTIFICATION_ID = 3;
+
+ // Request codes
+ public static final int UPDATE_NOTIFICATIONS_ALARM_ACTION_ID = 100;
+
+ public static final int MIN_ASSIGNED_REQUEST_CODE = 1001;
+
+ // Logging
+ private static final String TAG = LogUtil.BUGLE_TAG;
+ private static final boolean VERBOSE = false;
+
+ // Internal Constants
+ private static final String NOTIFICATION_REQUEST_CODE_PREFS = "notificationRequestCodes.v1";
+ private static final String REQUEST_CODE_DELIMITER = "|";
+ private static final String MAX_REQUEST_CODE_KEY = "maxRequestCode";
+}
diff --git a/src/com/android/messaging/util/PhoneUtils.java b/src/com/android/messaging/util/PhoneUtils.java
new file mode 100644
index 0000000..726f083
--- /dev/null
+++ b/src/com/android/messaging/util/PhoneUtils.java
@@ -0,0 +1,1011 @@
+/*
+ * 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.messaging.util;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.database.Cursor;
+import android.net.ConnectivityManager;
+import android.provider.Settings;
+import android.provider.Telephony;
+import android.support.v4.util.ArrayMap;
+import android.telephony.PhoneNumberUtils;
+import android.telephony.SmsManager;
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.sms.MmsSmsUtils;
+import com.google.i18n.phonenumbers.NumberParseException;
+import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;
+import com.google.i18n.phonenumbers.PhoneNumberUtil;
+import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * This class abstracts away platform dependency of calling telephony related
+ * platform APIs, mostly involving TelephonyManager, SubscriptionManager and
+ * a bit of SmsManager.
+ *
+ * The class instance can only be obtained via the get(int subId) method parameterized
+ * by a SIM subscription ID. On pre-L_MR1, the subId is not used and it has to be
+ * the default subId (-1).
+ *
+ * A convenient getDefault() method is provided for default subId (-1) on any platform
+ */
+public abstract class PhoneUtils {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private static final int MINIMUM_PHONE_NUMBER_LENGTH_TO_FORMAT = 6;
+
+ private static final List<SubscriptionInfo> EMPTY_SUBSCRIPTION_LIST = new ArrayList<>();
+
+ // The canonical phone number cache
+ // Each country gets its own cache. The following maps from ISO country code to
+ // the country's cache. Each cache maps from original phone number to canonicalized phone
+ private static final ArrayMap<String, ArrayMap<String, String>> sCanonicalPhoneNumberCache =
+ new ArrayMap<>();
+
+ protected final Context mContext;
+ protected final TelephonyManager mTelephonyManager;
+ protected final int mSubId;
+
+ public PhoneUtils(int subId) {
+ mSubId = subId;
+ mContext = Factory.get().getApplicationContext();
+ mTelephonyManager =
+ (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE);
+ }
+
+ /**
+ * Get the SIM's country code
+ *
+ * @return the country code on the SIM
+ */
+ public abstract String getSimCountry();
+
+ /**
+ * Get number of SIM slots
+ *
+ * @return the SIM slot count
+ */
+ public abstract int getSimSlotCount();
+
+ /**
+ * Get SIM's carrier name
+ *
+ * @return the carrier name of the SIM
+ */
+ public abstract String getCarrierName();
+
+ /**
+ * Check if there is SIM inserted on the device
+ *
+ * @return true if there is SIM inserted, false otherwise
+ */
+ public abstract boolean hasSim();
+
+ /**
+ * Check if the SIM is roaming
+ *
+ * @return true if the SIM is in romaing state, false otherwise
+ */
+ public abstract boolean isRoaming();
+
+ /**
+ * Get the MCC and MNC in integer of the SIM's provider
+ *
+ * @return an array of two ints, [0] is the MCC code and [1] is the MNC code
+ */
+ public abstract int[] getMccMnc();
+
+ /**
+ * Get the mcc/mnc string
+ *
+ * @return the text of mccmnc string
+ */
+ public abstract String getSimOperatorNumeric();
+
+ /**
+ * Get the SIM's self raw number, i.e. not canonicalized
+ *
+ * @param allowOverride Whether to use the app's setting to override the self number
+ * @return the original self number
+ * @throws IllegalStateException if no active subscription on L-MR1+
+ */
+ public abstract String getSelfRawNumber(final boolean allowOverride);
+
+ /**
+ * Returns the "effective" subId, or the subId used in the context of actual messages,
+ * conversations and subscription-specific settings, for the given "nominal" sub id.
+ *
+ * For pre-L-MR1 platform, this should always be
+ * {@value com.android.messaging.datamodel.data.ParticipantData#DEFAULT_SELF_SUB_ID};
+ *
+ * On the other hand, for L-MR1 and above, DEFAULT_SELF_SUB_ID will be mapped to the system
+ * default subscription id for SMS.
+ *
+ * @param subId The input subId
+ * @return the real subId if we can convert
+ */
+ public abstract int getEffectiveSubId(int subId);
+
+ /**
+ * Returns the number of active subscriptions in the device.
+ */
+ public abstract int getActiveSubscriptionCount();
+
+ /**
+ * Get {@link SmsManager} instance
+ *
+ * @return the relevant SmsManager instance based on OS version and subId
+ */
+ public abstract SmsManager getSmsManager();
+
+ /**
+ * Get the default SMS subscription id
+ *
+ * @return the default sub ID
+ */
+ public abstract int getDefaultSmsSubscriptionId();
+
+ /**
+ * Returns if there's currently a system default SIM selected for sending SMS.
+ */
+ public abstract boolean getHasPreferredSmsSim();
+
+ /**
+ * For L_MR1, system may return a negative subId. Convert this into our own
+ * subId, so that we consistently use -1 for invalid or default.
+ *
+ * see b/18629526 and b/18670346
+ *
+ * @param intent The push intent from system
+ * @param extraName The name of the sub id extra
+ * @return the subId that is valid and meaningful for the app
+ */
+ public abstract int getEffectiveIncomingSubIdFromSystem(Intent intent, String extraName);
+
+ /**
+ * Get the subscription_id column value from a telephony provider cursor
+ *
+ * @param cursor The database query cursor
+ * @param subIdIndex The index of the subId column in the cursor
+ * @return the subscription_id column value from the cursor
+ */
+ public abstract int getSubIdFromTelephony(Cursor cursor, int subIdIndex);
+
+ /**
+ * Check if data roaming is enabled
+ *
+ * @return true if data roaming is enabled, false otherwise
+ */
+ public abstract boolean isDataRoamingEnabled();
+
+ /**
+ * Check if mobile data is enabled
+ *
+ * @return true if mobile data is enabled, false otherwise
+ */
+ public abstract boolean isMobileDataEnabled();
+
+ /**
+ * Get the set of self phone numbers, all normalized
+ *
+ * @return the set of normalized self phone numbers
+ */
+ public abstract HashSet<String> getNormalizedSelfNumbers();
+
+ /**
+ * This interface packages methods should only compile on L_MR1.
+ * This is needed to make unit tests happy when mockito tries to
+ * mock these methods. Calling on these methods on L_MR1 requires
+ * an extra invocation of toMr1().
+ */
+ public interface LMr1 {
+ /**
+ * Get this SIM's information. Only applies to L_MR1 above
+ *
+ * @return the subscription info of the SIM
+ */
+ public abstract SubscriptionInfo getActiveSubscriptionInfo();
+
+ /**
+ * Get the list of active SIMs in system. Only applies to L_MR1 above
+ *
+ * @return the list of subscription info for all inserted SIMs
+ */
+ public abstract List<SubscriptionInfo> getActiveSubscriptionInfoList();
+
+ /**
+ * Register subscription change listener. Only applies to L_MR1 above
+ *
+ * @param listener The listener to register
+ */
+ public abstract void registerOnSubscriptionsChangedListener(
+ SubscriptionManager.OnSubscriptionsChangedListener listener);
+ }
+
+ /**
+ * The PhoneUtils class for pre L_MR1
+ */
+ public static class PhoneUtilsPreLMR1 extends PhoneUtils {
+ private final ConnectivityManager mConnectivityManager;
+
+ public PhoneUtilsPreLMR1() {
+ super(ParticipantData.DEFAULT_SELF_SUB_ID);
+ mConnectivityManager =
+ (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE);
+ }
+
+ @Override
+ public String getSimCountry() {
+ final String country = mTelephonyManager.getSimCountryIso();
+ if (TextUtils.isEmpty(country)) {
+ return null;
+ }
+ return country.toUpperCase();
+ }
+
+ @Override
+ public int getSimSlotCount() {
+ // Don't support MSIM pre-L_MR1
+ return 1;
+ }
+
+ @Override
+ public String getCarrierName() {
+ return mTelephonyManager.getNetworkOperatorName();
+ }
+
+ @Override
+ public boolean hasSim() {
+ return mTelephonyManager.getSimState() != TelephonyManager.SIM_STATE_ABSENT;
+ }
+
+ @Override
+ public boolean isRoaming() {
+ return mTelephonyManager.isNetworkRoaming();
+ }
+
+ @Override
+ public int[] getMccMnc() {
+ final String mccmnc = mTelephonyManager.getSimOperator();
+ int mcc = 0;
+ int mnc = 0;
+ try {
+ mcc = Integer.parseInt(mccmnc.substring(0, 3));
+ mnc = Integer.parseInt(mccmnc.substring(3));
+ } catch (Exception e) {
+ LogUtil.w(TAG, "PhoneUtils.getMccMnc: invalid string " + mccmnc, e);
+ }
+ return new int[]{mcc, mnc};
+ }
+
+ @Override
+ public String getSimOperatorNumeric() {
+ return mTelephonyManager.getSimOperator();
+ }
+
+ @Override
+ public String getSelfRawNumber(final boolean allowOverride) {
+ if (allowOverride) {
+ final String userDefinedNumber = getNumberFromPrefs(mContext,
+ ParticipantData.DEFAULT_SELF_SUB_ID);
+ if (!TextUtils.isEmpty(userDefinedNumber)) {
+ return userDefinedNumber;
+ }
+ }
+ return mTelephonyManager.getLine1Number();
+ }
+
+ @Override
+ public int getEffectiveSubId(int subId) {
+ Assert.equals(ParticipantData.DEFAULT_SELF_SUB_ID, subId);
+ return ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+
+ @Override
+ public SmsManager getSmsManager() {
+ return SmsManager.getDefault();
+ }
+
+ @Override
+ public int getDefaultSmsSubscriptionId() {
+ Assert.fail("PhoneUtils.getDefaultSmsSubscriptionId(): not supported before L MR1");
+ return ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+
+ @Override
+ public boolean getHasPreferredSmsSim() {
+ // SIM selection is not supported pre-L_MR1.
+ return true;
+ }
+
+ @Override
+ public int getActiveSubscriptionCount() {
+ return hasSim() ? 1 : 0;
+ }
+
+ @Override
+ public int getEffectiveIncomingSubIdFromSystem(Intent intent, String extraName) {
+ // Pre-L_MR1 always returns the default id
+ return ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+
+ @Override
+ public int getSubIdFromTelephony(Cursor cursor, int subIdIndex) {
+ // No subscription_id column before L_MR1
+ return ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+
+ @Override
+ @SuppressWarnings("deprecation")
+ public boolean isDataRoamingEnabled() {
+ boolean dataRoamingEnabled = false;
+ final ContentResolver cr = mContext.getContentResolver();
+ if (OsUtil.isAtLeastJB_MR1()) {
+ dataRoamingEnabled =
+ (Settings.Global.getInt(cr, Settings.Global.DATA_ROAMING, 0) != 0);
+ } else {
+ dataRoamingEnabled =
+ (Settings.System.getInt(cr, Settings.System.DATA_ROAMING, 0) != 0);
+ }
+ return dataRoamingEnabled;
+ }
+
+ @Override
+ public boolean isMobileDataEnabled() {
+ boolean mobileDataEnabled = false;
+ try {
+ final Class cmClass = mConnectivityManager.getClass();
+ final Method method = cmClass.getDeclaredMethod("getMobileDataEnabled");
+ method.setAccessible(true); // Make the method callable
+ // get the setting for "mobile data"
+ mobileDataEnabled = (Boolean) method.invoke(mConnectivityManager);
+ } catch (final Exception e) {
+ LogUtil.e(TAG, "PhoneUtil.isMobileDataEnabled: system api not found", e);
+ }
+ return mobileDataEnabled;
+ }
+
+ @Override
+ public HashSet<String> getNormalizedSelfNumbers() {
+ final HashSet<String> numbers = new HashSet<>();
+ numbers.add(getCanonicalForSelf(true/*allowOverride*/));
+ return numbers;
+ }
+ }
+
+ /**
+ * The PhoneUtils class for L_MR1
+ */
+ public static class PhoneUtilsLMR1 extends PhoneUtils implements LMr1 {
+ private final SubscriptionManager mSubscriptionManager;
+
+ public PhoneUtilsLMR1(final int subId) {
+ super(subId);
+ mSubscriptionManager = SubscriptionManager.from(Factory.get().getApplicationContext());
+ }
+
+ @Override
+ public String getSimCountry() {
+ final SubscriptionInfo subInfo = getActiveSubscriptionInfo();
+ if (subInfo != null) {
+ final String country = subInfo.getCountryIso();
+ if (TextUtils.isEmpty(country)) {
+ return null;
+ }
+ return country.toUpperCase();
+ }
+ return null;
+ }
+
+ @Override
+ public int getSimSlotCount() {
+ return mSubscriptionManager.getActiveSubscriptionInfoCountMax();
+ }
+
+ @Override
+ public String getCarrierName() {
+ final SubscriptionInfo subInfo = getActiveSubscriptionInfo();
+ if (subInfo != null) {
+ final CharSequence displayName = subInfo.getDisplayName();
+ if (!TextUtils.isEmpty(displayName)) {
+ return displayName.toString();
+ }
+ final CharSequence carrierName = subInfo.getCarrierName();
+ if (carrierName != null) {
+ return carrierName.toString();
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public boolean hasSim() {
+ return mSubscriptionManager.getActiveSubscriptionInfoCount() > 0;
+ }
+
+ @Override
+ public boolean isRoaming() {
+ return mSubscriptionManager.isNetworkRoaming(mSubId);
+ }
+
+ @Override
+ public int[] getMccMnc() {
+ int mcc = 0;
+ int mnc = 0;
+ final SubscriptionInfo subInfo = getActiveSubscriptionInfo();
+ if (subInfo != null) {
+ mcc = subInfo.getMcc();
+ mnc = subInfo.getMnc();
+ }
+ return new int[]{mcc, mnc};
+ }
+
+ @Override
+ public String getSimOperatorNumeric() {
+ // For L_MR1 we return the canonicalized (xxxxxx) string
+ return getMccMncString(getMccMnc());
+ }
+
+ @Override
+ public String getSelfRawNumber(final boolean allowOverride) {
+ if (allowOverride) {
+ final String userDefinedNumber = getNumberFromPrefs(mContext, mSubId);
+ if (!TextUtils.isEmpty(userDefinedNumber)) {
+ return userDefinedNumber;
+ }
+ }
+
+ final SubscriptionInfo subInfo = getActiveSubscriptionInfo();
+ if (subInfo != null) {
+ String phoneNumber = subInfo.getNumber();
+ if (TextUtils.isEmpty(phoneNumber) && LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "SubscriptionInfo phone number for self is empty!");
+ }
+ return phoneNumber;
+ }
+ LogUtil.w(TAG, "PhoneUtils.getSelfRawNumber: subInfo is null for " + mSubId);
+ throw new IllegalStateException("No active subscription");
+ }
+
+ @Override
+ public SubscriptionInfo getActiveSubscriptionInfo() {
+ try {
+ final SubscriptionInfo subInfo =
+ mSubscriptionManager.getActiveSubscriptionInfo(mSubId);
+ if (subInfo == null) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ // This is possible if the sub id is no longer available.
+ LogUtil.d(TAG, "PhoneUtils.getActiveSubscriptionInfo(): empty sub info for "
+ + mSubId);
+ }
+ }
+ return subInfo;
+ } catch (Exception e) {
+ LogUtil.e(TAG, "PhoneUtils.getActiveSubscriptionInfo: system exception for "
+ + mSubId, e);
+ }
+ return null;
+ }
+
+ @Override
+ public List<SubscriptionInfo> getActiveSubscriptionInfoList() {
+ final List<SubscriptionInfo> subscriptionInfos =
+ mSubscriptionManager.getActiveSubscriptionInfoList();
+ if (subscriptionInfos != null) {
+ return subscriptionInfos;
+ }
+ return EMPTY_SUBSCRIPTION_LIST;
+ }
+
+ @Override
+ public int getEffectiveSubId(int subId) {
+ if (subId == ParticipantData.DEFAULT_SELF_SUB_ID) {
+ return getDefaultSmsSubscriptionId();
+ }
+ return subId;
+ }
+
+ @Override
+ public void registerOnSubscriptionsChangedListener(
+ SubscriptionManager.OnSubscriptionsChangedListener listener) {
+ mSubscriptionManager.addOnSubscriptionsChangedListener(listener);
+ }
+
+ @Override
+ public SmsManager getSmsManager() {
+ return SmsManager.getSmsManagerForSubscriptionId(mSubId);
+ }
+
+ @Override
+ public int getDefaultSmsSubscriptionId() {
+ final int systemDefaultSubId = SmsManager.getDefaultSmsSubscriptionId();
+ if (systemDefaultSubId < 0) {
+ // Always use -1 for any negative subId from system
+ return ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+ return systemDefaultSubId;
+ }
+
+ @Override
+ public boolean getHasPreferredSmsSim() {
+ return getDefaultSmsSubscriptionId() != ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+
+ @Override
+ public int getActiveSubscriptionCount() {
+ return mSubscriptionManager.getActiveSubscriptionInfoCount();
+ }
+
+ @Override
+ public int getEffectiveIncomingSubIdFromSystem(Intent intent, String extraName) {
+ return getEffectiveIncomingSubIdFromSystem(intent.getIntExtra(extraName,
+ ParticipantData.DEFAULT_SELF_SUB_ID));
+ }
+
+ private int getEffectiveIncomingSubIdFromSystem(int subId) {
+ if (subId < 0) {
+ if (mSubscriptionManager.getActiveSubscriptionInfoCount() > 1) {
+ // For multi-SIM device, we can not decide which SIM to use if system
+ // does not know either. So just make it the invalid sub id.
+ return ParticipantData.DEFAULT_SELF_SUB_ID;
+ }
+ // For single-SIM device, it must come from the only SIM we have
+ return getDefaultSmsSubscriptionId();
+ }
+ return subId;
+ }
+
+ @Override
+ public int getSubIdFromTelephony(Cursor cursor, int subIdIndex) {
+ return getEffectiveIncomingSubIdFromSystem(cursor.getInt(subIdIndex));
+ }
+
+ @Override
+ public boolean isDataRoamingEnabled() {
+ final SubscriptionInfo subInfo = getActiveSubscriptionInfo();
+ if (subInfo == null) {
+ // There is nothing we can do if system give us empty sub info
+ LogUtil.e(TAG, "PhoneUtils.isDataRoamingEnabled: system return empty sub info for "
+ + mSubId);
+ return false;
+ }
+ return subInfo.getDataRoaming() != SubscriptionManager.DATA_ROAMING_DISABLE;
+ }
+
+ @Override
+ public boolean isMobileDataEnabled() {
+ boolean mobileDataEnabled = false;
+ try {
+ final Class cmClass = mTelephonyManager.getClass();
+ final Method method = cmClass.getDeclaredMethod("getDataEnabled", Integer.TYPE);
+ method.setAccessible(true); // Make the method callable
+ // get the setting for "mobile data"
+ mobileDataEnabled = (Boolean) method.invoke(
+ mTelephonyManager, Integer.valueOf(mSubId));
+ } catch (final Exception e) {
+ LogUtil.e(TAG, "PhoneUtil.isMobileDataEnabled: system api not found", e);
+ }
+ return mobileDataEnabled;
+
+ }
+
+ @Override
+ public HashSet<String> getNormalizedSelfNumbers() {
+ final HashSet<String> numbers = new HashSet<>();
+ for (SubscriptionInfo info : getActiveSubscriptionInfoList()) {
+ numbers.add(PhoneUtils.get(info.getSubscriptionId()).getCanonicalForSelf(
+ true/*allowOverride*/));
+ }
+ return numbers;
+ }
+ }
+
+ /**
+ * A convenient get() method that uses the default SIM. Use this when SIM is
+ * not relevant, e.g. isDefaultSmsApp
+ *
+ * @return an instance of PhoneUtils for default SIM
+ */
+ public static PhoneUtils getDefault() {
+ return Factory.get().getPhoneUtils(ParticipantData.DEFAULT_SELF_SUB_ID);
+ }
+
+ /**
+ * Get an instance of PhoneUtils associated with a specific SIM, which is also platform
+ * specific.
+ *
+ * @param subId The SIM's subscription ID
+ * @return the instance
+ */
+ public static PhoneUtils get(int subId) {
+ return Factory.get().getPhoneUtils(subId);
+ }
+
+ public LMr1 toLMr1() {
+ if (OsUtil.isAtLeastL_MR1()) {
+ return (LMr1) this;
+ } else {
+ Assert.fail("PhoneUtils.toLMr1(): invalid OS version");
+ return null;
+ }
+ }
+
+ /**
+ * Check if this device supports SMS
+ *
+ * @return true if SMS is supported, false otherwise
+ */
+ public boolean isSmsCapable() {
+ return mTelephonyManager.isSmsCapable();
+ }
+
+ /**
+ * Check if this device supports voice calling
+ *
+ * @return true if voice calling is supported, false otherwise
+ */
+ public boolean isVoiceCapable() {
+ return mTelephonyManager.isVoiceCapable();
+ }
+
+ /**
+ * Get the ISO country code from system locale setting
+ *
+ * @return the ISO country code from system locale
+ */
+ private static String getLocaleCountry() {
+ final String country = Locale.getDefault().getCountry();
+ if (TextUtils.isEmpty(country)) {
+ return null;
+ }
+ return country.toUpperCase();
+ }
+
+ /**
+ * Get ISO country code from the SIM, if not available, fall back to locale
+ *
+ * @return SIM or locale ISO country code
+ */
+ public String getSimOrDefaultLocaleCountry() {
+ String country = getSimCountry();
+ if (country == null) {
+ country = getLocaleCountry();
+ }
+ return country;
+ }
+
+ // Get or set the cache of canonicalized phone numbers for a specific country
+ private static ArrayMap<String, String> getOrAddCountryMapInCacheLocked(String country) {
+ if (country == null) {
+ country = "";
+ }
+ ArrayMap<String, String> countryMap = sCanonicalPhoneNumberCache.get(country);
+ if (countryMap == null) {
+ countryMap = new ArrayMap<>();
+ sCanonicalPhoneNumberCache.put(country, countryMap);
+ }
+ return countryMap;
+ }
+
+ // Get canonicalized phone number from cache
+ private static String getCanonicalFromCache(final String phoneText, String country) {
+ synchronized (sCanonicalPhoneNumberCache) {
+ final ArrayMap<String, String> countryMap = getOrAddCountryMapInCacheLocked(country);
+ return countryMap.get(phoneText);
+ }
+ }
+
+ // Put canonicalized phone number into cache
+ private static void putCanonicalToCache(final String phoneText, String country,
+ final String canonical) {
+ synchronized (sCanonicalPhoneNumberCache) {
+ final ArrayMap<String, String> countryMap = getOrAddCountryMapInCacheLocked(country);
+ countryMap.put(phoneText, canonical);
+ }
+ }
+
+ /**
+ * Utility method to parse user input number into standard E164 number.
+ *
+ * @param phoneText Phone number text as input by user.
+ * @param country ISO country code based on which to parse the number.
+ * @return E164 phone number. Returns null in case parsing failed.
+ */
+ private static String getValidE164Number(final String phoneText, final String country) {
+ final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance();
+ try {
+ final PhoneNumber phoneNumber = phoneNumberUtil.parse(phoneText, country);
+ if (phoneNumber != null && phoneNumberUtil.isValidNumber(phoneNumber)) {
+ return phoneNumberUtil.format(phoneNumber, PhoneNumberFormat.E164);
+ }
+ } catch (final NumberParseException e) {
+ LogUtil.e(TAG, "PhoneUtils.getValidE164Number(): Not able to parse phone number "
+ + LogUtil.sanitizePII(phoneText) + " for country " + country);
+ }
+ return null;
+ }
+
+ /**
+ * Canonicalize phone number using system locale country
+ *
+ * @param phoneText The phone number to canonicalize
+ * @return the canonicalized number
+ */
+ public String getCanonicalBySystemLocale(final String phoneText) {
+ return getCanonicalByCountry(phoneText, getLocaleCountry());
+ }
+
+ /**
+ * Canonicalize phone number using SIM's country, may fall back to system locale country
+ * if SIM country can not be obtained
+ *
+ * @param phoneText The phone number to canonicalize
+ * @return the canonicalized number
+ */
+ public String getCanonicalBySimLocale(final String phoneText) {
+ return getCanonicalByCountry(phoneText, getSimOrDefaultLocaleCountry());
+ }
+
+ /**
+ * Canonicalize phone number using a country code.
+ * This uses an internal cache per country to speed up.
+ *
+ * @param phoneText The phone number to canonicalize
+ * @param country The ISO country code to use
+ * @return the canonicalized number, or the original number if can't be parsed
+ */
+ private String getCanonicalByCountry(final String phoneText, final String country) {
+ Assert.notNull(phoneText);
+
+ String canonicalNumber = getCanonicalFromCache(phoneText, country);
+ if (canonicalNumber != null) {
+ return canonicalNumber;
+ }
+ canonicalNumber = getValidE164Number(phoneText, country);
+ if (canonicalNumber == null) {
+ // If we can't normalize this number, we just use the display string number.
+ // This is possible for short codes and other non-localizable numbers.
+ canonicalNumber = phoneText;
+ }
+ putCanonicalToCache(phoneText, country, canonicalNumber);
+ return canonicalNumber;
+ }
+
+ /**
+ * Canonicalize the self (per SIM) phone number
+ *
+ * @param allowOverride whether to use the override number in app settings
+ * @return the canonicalized self phone number
+ */
+ public String getCanonicalForSelf(final boolean allowOverride) {
+ String selfNumber = null;
+ try {
+ selfNumber = getSelfRawNumber(allowOverride);
+ } catch (IllegalStateException e) {
+ // continue;
+ }
+ if (selfNumber == null) {
+ return "";
+ }
+ return getCanonicalBySimLocale(selfNumber);
+ }
+
+ /**
+ * Get the SIM's phone number in NATIONAL format with only digits, used in sending
+ * as LINE1NOCOUNTRYCODE macro in mms_config
+ *
+ * @return all digits national format number of the SIM
+ */
+ public String getSimNumberNoCountryCode() {
+ String selfNumber = null;
+ try {
+ selfNumber = getSelfRawNumber(false/*allowOverride*/);
+ } catch (IllegalStateException e) {
+ // continue
+ }
+ if (selfNumber == null) {
+ selfNumber = "";
+ }
+ final String country = getSimCountry();
+ final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance();
+ try {
+ final PhoneNumber phoneNumber = phoneNumberUtil.parse(selfNumber, country);
+ if (phoneNumber != null && phoneNumberUtil.isValidNumber(phoneNumber)) {
+ return phoneNumberUtil
+ .format(phoneNumber, PhoneNumberFormat.NATIONAL)
+ .replaceAll("\\D", "");
+ }
+ } catch (final NumberParseException e) {
+ LogUtil.e(TAG, "PhoneUtils.getSimNumberNoCountryCode(): Not able to parse phone number "
+ + LogUtil.sanitizePII(selfNumber) + " for country " + country);
+ }
+ return selfNumber;
+
+ }
+
+ /**
+ * Format a phone number for displaying, using system locale country.
+ * If the country code matches between the system locale and the input phone number,
+ * it will be formatted into NATIONAL format, otherwise, the INTERNATIONAL format
+ *
+ * @param phoneText The original phone text
+ * @return formatted number
+ */
+ public String formatForDisplay(final String phoneText) {
+ // Only format a valid number which length >=6
+ if (TextUtils.isEmpty(phoneText) ||
+ phoneText.replaceAll("\\D", "").length() < MINIMUM_PHONE_NUMBER_LENGTH_TO_FORMAT) {
+ return phoneText;
+ }
+ final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance();
+ final String systemCountry = getLocaleCountry();
+ final int systemCountryCode = phoneNumberUtil.getCountryCodeForRegion(systemCountry);
+ try {
+ final PhoneNumber parsedNumber = phoneNumberUtil.parse(phoneText, systemCountry);
+ final PhoneNumberFormat phoneNumberFormat =
+ (systemCountryCode > 0 && parsedNumber.getCountryCode() == systemCountryCode) ?
+ PhoneNumberFormat.NATIONAL : PhoneNumberFormat.INTERNATIONAL;
+ return phoneNumberUtil.format(parsedNumber, phoneNumberFormat);
+ } catch (NumberParseException e) {
+ LogUtil.e(TAG, "PhoneUtils.formatForDisplay: invalid phone number "
+ + LogUtil.sanitizePII(phoneText) + " with country " + systemCountry);
+ return phoneText;
+ }
+ }
+
+ /**
+ * Is Messaging the default SMS app?
+ * - On KLP+ this checks the system setting.
+ * - On JB (and below) this always returns true, since the setting was added in KLP.
+ */
+ public boolean isDefaultSmsApp() {
+ if (OsUtil.isAtLeastKLP()) {
+ final String configuredApplication = Telephony.Sms.getDefaultSmsPackage(mContext);
+ return mContext.getPackageName().equals(configuredApplication);
+ }
+ return true;
+ }
+
+ /**
+ * Get default SMS app package name
+ *
+ * @return the package name of default SMS app
+ */
+ public String getDefaultSmsApp() {
+ if (OsUtil.isAtLeastKLP()) {
+ return Telephony.Sms.getDefaultSmsPackage(mContext);
+ }
+ return null;
+ }
+
+ /**
+ * Determines if SMS is currently enabled on this device.
+ * - Device must support SMS
+ * - On KLP+ we must be set as the default SMS app
+ */
+ public boolean isSmsEnabled() {
+ return isSmsCapable() && isDefaultSmsApp();
+ }
+
+ /**
+ * Returns the name of the default SMS app, or the empty string if there is
+ * an error or there is no default app (e.g. JB and below).
+ */
+ public String getDefaultSmsAppLabel() {
+ if (OsUtil.isAtLeastKLP()) {
+ final String packageName = Telephony.Sms.getDefaultSmsPackage(mContext);
+ final PackageManager pm = mContext.getPackageManager();
+ try {
+ final ApplicationInfo appInfo = pm.getApplicationInfo(packageName, 0);
+ return pm.getApplicationLabel(appInfo).toString();
+ } catch (NameNotFoundException e) {
+ // Fall through and return empty string
+ }
+ }
+ return "";
+ }
+
+ /**
+ * Gets the state of Airplane Mode.
+ *
+ * @return true if enabled.
+ */
+ @SuppressWarnings("deprecation")
+ public boolean isAirplaneModeOn() {
+ if (OsUtil.isAtLeastJB_MR1()) {
+ return Settings.Global.getInt(mContext.getContentResolver(),
+ Settings.Global.AIRPLANE_MODE_ON, 0) != 0;
+ } else {
+ return Settings.System.getInt(mContext.getContentResolver(),
+ Settings.System.AIRPLANE_MODE_ON, 0) != 0;
+ }
+ }
+
+ public static String getMccMncString(int[] mccmnc) {
+ if (mccmnc == null || mccmnc.length != 2) {
+ return "000000";
+ }
+ return String.format("%03d%03d", mccmnc[0], mccmnc[1]);
+ }
+
+ public static String canonicalizeMccMnc(final String mcc, final String mnc) {
+ try {
+ return String.format("%03d%03d", Integer.parseInt(mcc), Integer.parseInt(mnc));
+ } catch (final NumberFormatException e) {
+ // Return invalid as is
+ LogUtil.w(TAG, "canonicalizeMccMnc: invalid mccmnc:" + mcc + " ," + mnc);
+ }
+ return mcc + mnc;
+ }
+
+ /**
+ * Returns whether the given destination is valid for sending SMS/MMS message.
+ */
+ public static boolean isValidSmsMmsDestination(final String destination) {
+ return PhoneNumberUtils.isWellFormedSmsAddress(destination) ||
+ MmsSmsUtils.isEmailAddress(destination);
+ }
+
+ public interface SubscriptionRunnable {
+ void runForSubscription(int subId);
+ }
+
+ /**
+ * A convenience method for iterating through all active subscriptions
+ *
+ * @param runnable a {@link SubscriptionRunnable} for performing work on each subscription.
+ */
+ public static void forEachActiveSubscription(final SubscriptionRunnable runnable) {
+ if (OsUtil.isAtLeastL_MR1()) {
+ final List<SubscriptionInfo> subscriptionList =
+ getDefault().toLMr1().getActiveSubscriptionInfoList();
+ for (final SubscriptionInfo subscriptionInfo : subscriptionList) {
+ runnable.runForSubscription(subscriptionInfo.getSubscriptionId());
+ }
+ } else {
+ runnable.runForSubscription(ParticipantData.DEFAULT_SELF_SUB_ID);
+ }
+ }
+
+ private static String getNumberFromPrefs(final Context context, final int subId) {
+ final BuglePrefs prefs = BuglePrefs.getSubscriptionPrefs(subId);
+ final String mmsPhoneNumberPrefKey =
+ context.getString(R.string.mms_phone_number_pref_key);
+ final String userDefinedNumber = prefs.getString(mmsPhoneNumberPrefKey, null);
+ if (!TextUtils.isEmpty(userDefinedNumber)) {
+ return userDefinedNumber;
+ }
+ return null;
+ }
+}
diff --git a/src/com/android/messaging/util/RingtoneUtil.java b/src/com/android/messaging/util/RingtoneUtil.java
new file mode 100644
index 0000000..a7facfb
--- /dev/null
+++ b/src/com/android/messaging/util/RingtoneUtil.java
@@ -0,0 +1,53 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.net.Uri;
+import android.provider.Settings;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+
+public class RingtoneUtil {
+ /**
+ * Return a ringtone Uri for the string representation passed in. Use the app
+ * and system defaults as fallbacks
+ * @param ringtoneString is the ringtone to resolve
+ * @return the Uri of the ringtone or the fallback ringtone
+ */
+ public static Uri getNotificationRingtoneUri(String ringtoneString) {
+ if (ringtoneString == null) {
+ // No override specified, fall back to system-wide setting.
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ final Context context = Factory.get().getApplicationContext();
+ final String prefKey = context.getString(R.string.notification_sound_pref_key);
+ ringtoneString = prefs.getString(prefKey, null);
+ }
+
+ if (!TextUtils.isEmpty(ringtoneString)) {
+ // We have set a value, even if it is the default Uri at some point
+ return Uri.parse(ringtoneString);
+ } else if (ringtoneString == null) {
+ // We have no setting specified (== null), so we default to the system default
+ return Settings.System.DEFAULT_NOTIFICATION_URI;
+ } else {
+ // An empty string (== "") here is the result of selecting "None" as the ringtone
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/SafeAsyncTask.java b/src/com/android/messaging/util/SafeAsyncTask.java
new file mode 100644
index 0000000..1cce6e9
--- /dev/null
+++ b/src/com/android/messaging/util/SafeAsyncTask.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util;
+
+import android.content.Intent;
+import android.os.AsyncTask;
+import android.os.Debug;
+import android.os.SystemClock;
+
+import com.android.messaging.Factory;
+import com.android.messaging.util.Assert.RunsOnAnyThread;
+
+/**
+ * Wrapper class which provides explicit API for:
+ * <ol>
+ * <li>Threading policy choice - Users of this class should use the explicit API instead of
+ * {@link #execute} which uses different threading policy on different OS versions.
+ * <li>Enforce creation on main thread as required by AsyncTask
+ * <li>Enforce that the background task does not take longer than expected.
+ * </ol>
+ */
+public abstract class SafeAsyncTask<Params, Progress, Result>
+ extends AsyncTask<Params, Progress, Result> {
+ private static final long DEFAULT_MAX_EXECUTION_TIME_MILLIS = 10 * 1000; // 10 seconds
+
+ /** This is strongly discouraged as it can block other AsyncTasks indefinitely. */
+ public static final long UNBOUNDED_TIME = Long.MAX_VALUE;
+
+ private static final String WAKELOCK_ID = "bugle_safe_async_task_wakelock";
+ protected static final int WAKELOCK_OP = 1000;
+ private static WakeLockHelper sWakeLock = new WakeLockHelper(WAKELOCK_ID);
+
+ private final long mMaxExecutionTimeMillis;
+ private final boolean mCancelExecutionOnTimeout;
+ private boolean mThreadPoolRequested;
+
+ public SafeAsyncTask() {
+ this(DEFAULT_MAX_EXECUTION_TIME_MILLIS, false);
+ }
+
+ public SafeAsyncTask(final long maxTimeMillis) {
+ this(maxTimeMillis, false);
+ }
+
+ /**
+ * @param maxTimeMillis maximum expected time for the background operation. This is just
+ * a diagnostic tool to catch unexpectedly long operations. If an operation does take
+ * longer than expected, it is fine to increase this argument. If the value is larger
+ * than a minute, you should consider using a dedicated thread so as not to interfere
+ * with other AsyncTasks.
+ *
+ * <p>Use {@link #UNBOUNDED_TIME} if you do not know the maximum expected time. This
+ * is strongly discouraged as it can block other AsyncTasks indefinitely.
+ *
+ * @param cancelExecutionOnTimeout whether to attempt to cancel the task execution on timeout.
+ * If this is set, at execution timeout we will call cancel(), so doInBackgroundTimed()
+ * should periodically check if the task is to be cancelled and finish promptly if
+ * possible, and handle the cancel event in onCancelled(). Also, at the end of execution
+ * we will not crash the execution if it went over limit since we explicitly canceled it.
+ */
+ public SafeAsyncTask(final long maxTimeMillis, final boolean cancelExecutionOnTimeout) {
+ Assert.isMainThread(); // AsyncTask has to be created on the main thread
+ mMaxExecutionTimeMillis = maxTimeMillis;
+ mCancelExecutionOnTimeout = cancelExecutionOnTimeout;
+ }
+
+ public final SafeAsyncTask<Params, Progress, Result> executeOnThreadPool(
+ final Params... params) {
+ Assert.isMainThread(); // AsyncTask requires this
+ mThreadPoolRequested = true;
+ executeOnExecutor(THREAD_POOL_EXECUTOR, params);
+ return this;
+ }
+
+ protected abstract Result doInBackgroundTimed(final Params... params);
+
+ @Override
+ protected final Result doInBackground(final Params... params) {
+ // This enforces that executeOnThreadPool was called, not execute. Ideally, we would
+ // make execute throw an exception, but since it is final, we cannot override it.
+ Assert.isTrue(mThreadPoolRequested);
+
+ if (mCancelExecutionOnTimeout) {
+ ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ if (getStatus() == Status.RUNNING) {
+ // Cancel the task if it's still running.
+ LogUtil.w(LogUtil.BUGLE_TAG, String.format("%s timed out and is canceled",
+ this));
+ cancel(true /* mayInterruptIfRunning */);
+ }
+ }
+ }, mMaxExecutionTimeMillis);
+ }
+
+ final long startTime = SystemClock.elapsedRealtime();
+ try {
+ return doInBackgroundTimed(params);
+ } finally {
+ final long executionTime = SystemClock.elapsedRealtime() - startTime;
+ if (executionTime > mMaxExecutionTimeMillis) {
+ LogUtil.w(LogUtil.BUGLE_TAG, String.format("%s took %dms", this, executionTime));
+ // Don't crash if debugger is attached or if we are asked to cancel on timeout.
+ if (!Debug.isDebuggerConnected() && !mCancelExecutionOnTimeout) {
+ Assert.fail(this + " took too long");
+ }
+ }
+ }
+
+ }
+
+ @Override
+ protected void onPostExecute(final Result result) {
+ // No need to use AsyncTask at all if there is no onPostExecute
+ Assert.fail("Use SafeAsyncTask.executeOnThreadPool");
+ }
+
+ /**
+ * This provides a way for people to run async tasks but without onPostExecute.
+ * This can be called on any thread.
+ *
+ * Run code in a thread using AsyncTask's thread pool.
+ *
+ * To enable wakelock during the execution, see {@link #executeOnThreadPool(Runnable, boolean)}
+ *
+ * @param runnable The Runnable to execute asynchronously
+ */
+ @RunsOnAnyThread
+ public static void executeOnThreadPool(final Runnable runnable) {
+ executeOnThreadPool(runnable, false);
+ }
+
+ /**
+ * This provides a way for people to run async tasks but without onPostExecute.
+ * This can be called on any thread.
+ *
+ * Run code in a thread using AsyncTask's thread pool.
+ *
+ * @param runnable The Runnable to execute asynchronously
+ * @param withWakeLock when set, a wake lock will be held for the duration of the runnable
+ * execution
+ */
+ public static void executeOnThreadPool(final Runnable runnable, final boolean withWakeLock) {
+ if (withWakeLock) {
+ final Intent intent = new Intent();
+ sWakeLock.acquire(Factory.get().getApplicationContext(), intent, WAKELOCK_OP);
+ THREAD_POOL_EXECUTOR.execute(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ runnable.run();
+ } finally {
+ sWakeLock.release(intent, WAKELOCK_OP);
+ }
+ }
+ });
+ } else {
+ THREAD_POOL_EXECUTOR.execute(runnable);
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/SwitchCompatUtils.java b/src/com/android/messaging/util/SwitchCompatUtils.java
new file mode 100644
index 0000000..b5d1ed5
--- /dev/null
+++ b/src/com/android/messaging/util/SwitchCompatUtils.java
@@ -0,0 +1,129 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.support.v7.graphics.drawable.DrawableWrapper;
+import android.support.v7.widget.SwitchCompat;
+import android.util.TypedValue;
+
+/* Most methods in this file are copied from
+ * v7/appcompat/src/android/support/v7/internal/widget/TintManager.java. It would be better if
+ * we could have just extended the TintManager but this is a final class that we do not have
+ * access to. */
+
+/**
+ * Util methods for the SwitchCompat widget
+ */
+public class SwitchCompatUtils {
+ /**
+ * Given a color and a SwitchCompat view, updates the SwitchCompat to appear with the appropiate
+ * color when enabled and checked
+ */
+ public static void updateSwitchCompatColor(SwitchCompat switchCompat, final int color) {
+ final Context context = switchCompat.getContext();
+ final TypedValue typedValue = new TypedValue();
+
+ switchCompat.setThumbDrawable(getColorTintedDrawable(switchCompat.getThumbDrawable(),
+ getSwitchThumbColorStateList(context, color, typedValue),
+ PorterDuff.Mode.MULTIPLY));
+
+ switchCompat.setTrackDrawable(getColorTintedDrawable(switchCompat.getTrackDrawable(),
+ getSwitchTrackColorStateList(context, color, typedValue), PorterDuff.Mode.SRC_IN));
+ }
+
+ private static Drawable getColorTintedDrawable(Drawable oldDrawable,
+ final ColorStateList colorStateList, final PorterDuff.Mode mode) {
+ final int[] thumbState = oldDrawable.isStateful() ? oldDrawable.getState() : null;
+ if (oldDrawable instanceof DrawableWrapper) {
+ oldDrawable = ((DrawableWrapper) oldDrawable).getWrappedDrawable();
+ }
+ final Drawable newDrawable = new TintDrawableWrapper(oldDrawable, colorStateList, mode);
+ if (thumbState != null) {
+ newDrawable.setState(thumbState);
+ }
+ return newDrawable;
+ }
+
+ private static ColorStateList getSwitchThumbColorStateList(final Context context,
+ final int color, final TypedValue typedValue) {
+ final int[][] states = new int[3][];
+ final int[] colors = new int[3];
+ int i = 0;
+ // Disabled state
+ states[i] = new int[] { -android.R.attr.state_enabled };
+ colors[i] = getColor(Color.parseColor("#ffbdbdbd"), 1f);
+ i++;
+ states[i] = new int[] { android.R.attr.state_checked };
+ colors[i] = color;
+ i++;
+ // Default enabled state
+ states[i] = new int[0];
+ colors[i] = getThemeAttrColor(context, typedValue,
+ android.support.v7.appcompat.R.attr.colorSwitchThumbNormal);
+ i++;
+ return new ColorStateList(states, colors);
+ }
+
+ private static ColorStateList getSwitchTrackColorStateList(final Context context,
+ final int color, final TypedValue typedValue) {
+ final int[][] states = new int[3][];
+ final int[] colors = new int[3];
+ int i = 0;
+ // Disabled state
+ states[i] = new int[] { -android.R.attr.state_enabled };
+ colors[i] = getThemeAttrColor(context, typedValue, android.R.attr.colorForeground, 0.1f);
+ i++;
+ states[i] = new int[] { android.R.attr.state_checked };
+ colors[i] = getColor(color, 0.3f);
+ i++;
+ // Default enabled state
+ states[i] = new int[0];
+ colors[i] = getThemeAttrColor(context, typedValue, android.R.attr.colorForeground, 0.3f);
+ i++;
+ return new ColorStateList(states, colors);
+ }
+
+ private static int getThemeAttrColor(final Context context, final TypedValue typedValue,
+ final int attr) {
+ if (context.getTheme().resolveAttribute(attr, typedValue, true)) {
+ if (typedValue.type >= TypedValue.TYPE_FIRST_INT
+ && typedValue.type <= TypedValue.TYPE_LAST_INT) {
+ return typedValue.data;
+ } else if (typedValue.type == TypedValue.TYPE_STRING) {
+ return context.getResources().getColor(typedValue.resourceId);
+ }
+ }
+ return 0;
+ }
+
+ private static int getThemeAttrColor(final Context context, final TypedValue typedValue,
+ final int attr, final float alpha) {
+ final int color = getThemeAttrColor(context, typedValue, attr);
+ return getColor(color, alpha);
+ }
+
+ private static int getColor(int color, float alpha) {
+ final int originalAlpha = Color.alpha(color);
+ // Return the color, multiplying the original alpha by the disabled value
+ return (color & 0x00ffffff) | (Math.round(originalAlpha * alpha) << 24);
+ }
+}
diff --git a/src/com/android/messaging/util/TextUtil.java b/src/com/android/messaging/util/TextUtil.java
new file mode 100644
index 0000000..b240396
--- /dev/null
+++ b/src/com/android/messaging/util/TextUtil.java
@@ -0,0 +1,73 @@
+/*
+ * 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.messaging.util;
+
+import android.support.annotation.Nullable;
+
+public class TextUtil {
+ /**
+ * Returns true if the string is empty, null or only whitespace.
+ */
+ public static boolean isAllWhitespace(@Nullable String string) {
+ if (string == null || string.isEmpty()) {
+ return true;
+ }
+
+ for (int i = 0; i < string.length(); ++i) {
+ if (!Character.isWhitespace(string.charAt(i))) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Taken from PhoneNumberUtils, where it is only available in API 21+ Replaces all unicode
+ * (e.g. Arabic, Persian) digits with their decimal digit equivalents.
+ *
+ * @param number the number to perform the replacement on.
+ * @return the replaced number.
+ */
+ public static String replaceUnicodeDigits(String number) {
+ StringBuilder normalizedDigits = new StringBuilder(number.length());
+ for (char c : number.toCharArray()) {
+ int digit = Character.digit(c, 10);
+ if (digit != -1) {
+ normalizedDigits.append(digit);
+ } else {
+ normalizedDigits.append(c);
+ }
+ }
+ return normalizedDigits.toString();
+ }
+
+ /**
+ * Appends text to the stringBuilder.
+ * If stringBuilder already has content, separator is prepended to create a separator between
+ * entries.
+ * @param stringBuilder The stringBuilder to add to
+ * @param text The text to append
+ * @param separator The separator to add if there is already text, typically "," or "\n"
+ */
+ public static void appendWithSeparator(final StringBuilder stringBuilder, final String text,
+ final String separator) {
+ if (stringBuilder.length() > 0) {
+ stringBuilder.append(separator);
+ }
+ stringBuilder.append(text);
+ }
+}
diff --git a/src/com/android/messaging/util/ThreadUtil.java b/src/com/android/messaging/util/ThreadUtil.java
new file mode 100644
index 0000000..3b935d8
--- /dev/null
+++ b/src/com/android/messaging/util/ThreadUtil.java
@@ -0,0 +1,28 @@
+/*
+ * 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.messaging.util;
+
+import android.os.Handler;
+import android.os.Looper;
+
+public class ThreadUtil {
+ private static final Handler sHandler = new Handler(Looper.getMainLooper());
+
+ public static Handler getMainThreadHandler() {
+ return sHandler;
+ }
+}
diff --git a/src/com/android/messaging/util/TintDrawableWrapper.java b/src/com/android/messaging/util/TintDrawableWrapper.java
new file mode 100644
index 0000000..e6ea4bd
--- /dev/null
+++ b/src/com/android/messaging/util/TintDrawableWrapper.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util;
+
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.support.v7.graphics.drawable.DrawableWrapper;
+
+/*
+ * This is directly copied from v7/appcompat/src/android/support/v7/internal/widget/TintManager.java
+ */
+
+/**
+ * A {@link DrawableWrapper} which updates it's color filter using a {@link ColorStateList}.
+ */
+class TintDrawableWrapper extends DrawableWrapper {
+ private final ColorStateList mTintStateList;
+ private final PorterDuff.Mode mTintMode;
+ private int mCurrentColor;
+ public TintDrawableWrapper(Drawable drawable, ColorStateList tintStateList) {
+ this(drawable, tintStateList, PorterDuff.Mode.SRC_IN);
+ }
+ public TintDrawableWrapper(Drawable drawable, ColorStateList tintStateList,
+ PorterDuff.Mode tintMode) {
+ super(drawable);
+ mTintStateList = tintStateList;
+ mTintMode = tintMode;
+ }
+ @Override
+ public boolean isStateful() {
+ return (mTintStateList != null && mTintStateList.isStateful()) || super.isStateful();
+ }
+ @Override
+ public boolean setState(int[] stateSet) {
+ boolean handled = super.setState(stateSet);
+ handled = updateTint(stateSet) || handled;
+ return handled;
+ }
+ private boolean updateTint(int[] state) {
+ if (mTintStateList != null) {
+ final int color = mTintStateList.getColorForState(state, mCurrentColor);
+ if (color != mCurrentColor) {
+ if (color != Color.TRANSPARENT) {
+ setColorFilter(color, mTintMode);
+ } else {
+ clearColorFilter();
+ }
+ mCurrentColor = color;
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/com/android/messaging/util/Trace.java b/src/com/android/messaging/util/Trace.java
new file mode 100644
index 0000000..da1e87c
--- /dev/null
+++ b/src/com/android/messaging/util/Trace.java
@@ -0,0 +1,115 @@
+/*
+ * 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.messaging.util;
+
+
+import android.annotation.TargetApi;
+import android.os.Build;
+
+/**
+ * Helper class for systrace (see http://developer.android.com/tools/help/systrace.html).<p>
+ * To enable, set log.tag.Bugle_Trace (defined by {@link #TAG} to VERBOSE before
+ * the process starts.<p>
+ * Note that this will run only on JBMR2 or later; on earlier platforms or if the log
+ * tag isn't set, calls to {@link #beginSection(String)} or {@link #endSection()} are no-ops. <p>
+ * Internally, calls dispatch to either a class that actually does work or a class that doesn't.
+ * This avoids Dalvik complaining when it loads the class on earlier platforms that the
+ * opcodes aren't available, and, according to the Dalvik team, using vtable dispatching for
+ * something like this should be faster than if (OsUtil.isAtLeast...()) on each call.
+ */
+public final class Trace {
+ private static final String TAG = "Bugle_Trace";
+ private abstract static class AbstractTrace {
+ abstract void beginSection(String sectionName);
+ abstract void endSection();
+ }
+
+ private static final AbstractTrace sTrace;
+
+ // Static initializer to pick the correct trace class to handle tracing.
+ static {
+ // Use android.util.Log instead of LogUtil here to avoid pulling in Gservices
+ // too early in app startup.
+ if (OsUtil.isAtLeastJB_MR2() &&
+ android.util.Log.isLoggable(TAG, android.util.Log.VERBOSE)) {
+ sTrace = new TraceJBMR2();
+ } else {
+ sTrace = new TraceShim();
+ }
+ }
+
+ /**
+ * Writes a trace message to indicate that a given section of code has begun. This call must
+ * be followed by a corresponding call to {@link #endSection()} on the same thread.
+ *
+ * <p class="note"> At this time the vertical bar character '|', newline character '\n', and
+ * null character '\0' are used internally by the tracing mechanism. If sectionName contains
+ * these characters they will be replaced with a space character in the trace.
+ *
+ * @param sectionName The name of the code section to appear in the trace. This may be at
+ * most 127 Unicode code units long.
+ */
+ public static void beginSection(String sectionName) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "beginSection() " + sectionName);
+ }
+ sTrace.beginSection(sectionName);
+ }
+
+ /**
+ * Writes a trace message to indicate that a given section of code has ended. This call must
+ * be preceeded by a corresponding call to {@link #beginSection(String)}. Calling this method
+ * will mark the end of the most recently begun section of code, so care must be taken to
+ * ensure that beginSection / endSection pairs are properly nested and called from the same
+ * thread.
+ */
+ public static void endSection() {
+ sTrace.endSection();
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "endSection()");
+ }
+ }
+
+ /**
+ * Internal class that we use if we really did enable tracing.
+ */
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
+ private static final class TraceJBMR2 extends AbstractTrace {
+ @Override
+ void beginSection(String sectionName) {
+ android.os.Trace.beginSection(sectionName);
+ }
+
+ @Override
+ void endSection() {
+ android.os.Trace.endSection();
+ }
+ }
+
+ /**
+ * Dummy class that we use if we aren't really tracing.
+ */
+ private static final class TraceShim extends AbstractTrace {
+ @Override
+ void beginSection(String sectionName) {
+ }
+
+ @Override
+ void endSection() {
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/Typefaces.java b/src/com/android/messaging/util/Typefaces.java
new file mode 100644
index 0000000..eb8562c
--- /dev/null
+++ b/src/com/android/messaging/util/Typefaces.java
@@ -0,0 +1,45 @@
+/*
+ * 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.messaging.util;
+
+import android.graphics.Typeface;
+
+/**
+ * Provides access to typefaces used by code. Specially important for typefaces coming from assets,
+ * which appear (from platform code inspection) to not be cached.
+ * Note: Considered making this a singleton provided by factory/appcontext, but seemed too simple,
+ * not worth stubbing.
+ */
+public class Typefaces {
+ private static Typeface sRobotoBold;
+ private static Typeface sRobotoNormal;
+
+ public static Typeface getRobotoBold() {
+ Assert.isMainThread();
+ if (sRobotoBold == null) {
+ sRobotoBold = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD);
+ }
+ return sRobotoBold;
+ }
+
+ public static Typeface getRobotoNormal() {
+ Assert.isMainThread();
+ if (sRobotoNormal == null) {
+ sRobotoNormal = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL);
+ }
+ return sRobotoNormal;
+ }
+}
diff --git a/src/com/android/messaging/util/UiUtils.java b/src/com/android/messaging/util/UiUtils.java
new file mode 100644
index 0000000..84fe353
--- /dev/null
+++ b/src/com/android/messaging/util/UiUtils.java
@@ -0,0 +1,438 @@
+/*
+ * 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.messaging.util;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.pm.ActivityInfo;
+import android.content.res.Configuration;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v7.app.ActionBar;
+import android.support.v7.app.ActionBarActivity;
+import android.text.Html;
+import android.text.Spanned;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.text.style.URLSpan;
+import android.view.Gravity;
+import android.view.Surface;
+import android.view.View;
+import android.view.View.OnLayoutChangeListener;
+import android.view.animation.Animation;
+import android.view.animation.Animation.AnimationListener;
+import android.view.animation.Interpolator;
+import android.view.animation.ScaleAnimation;
+import android.widget.RemoteViews;
+import android.widget.Toast;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.ui.SnackBar;
+import com.android.messaging.ui.SnackBar.Placement;
+import com.android.messaging.ui.conversationlist.ConversationListActivity;
+import com.android.messaging.ui.SnackBarInteraction;
+import com.android.messaging.ui.SnackBarManager;
+import com.android.messaging.ui.UIIntents;
+
+import java.lang.reflect.Field;
+import java.util.List;
+
+public class UiUtils {
+ /** MediaPicker transition duration in ms */
+ public static final int MEDIAPICKER_TRANSITION_DURATION =
+ getApplicationContext().getResources().getInteger(
+ R.integer.mediapicker_transition_duration);
+ /** Short transition duration in ms */
+ public static final int ASYNCIMAGE_TRANSITION_DURATION =
+ getApplicationContext().getResources().getInteger(
+ R.integer.asyncimage_transition_duration);
+ /** Compose transition duration in ms */
+ public static final int COMPOSE_TRANSITION_DURATION =
+ getApplicationContext().getResources().getInteger(
+ R.integer.compose_transition_duration);
+ /** Generic duration for revealing/hiding a view */
+ public static final int REVEAL_ANIMATION_DURATION =
+ getApplicationContext().getResources().getInteger(
+ R.integer.reveal_view_animation_duration);
+
+ public static final Interpolator DEFAULT_INTERPOLATOR = new CubicBezierInterpolator(
+ 0.4f, 0.0f, 0.2f, 1.0f);
+
+ public static final Interpolator EASE_IN_INTERPOLATOR = new CubicBezierInterpolator(
+ 0.4f, 0.0f, 0.8f, 0.5f);
+
+ public static final Interpolator EASE_OUT_INTERPOLATOR = new CubicBezierInterpolator(
+ 0.0f, 0.0f, 0.2f, 1f);
+
+ /** Show a simple toast at the bottom */
+ public static void showToastAtBottom(final int messageId) {
+ UiUtils.showToastAtBottom(getApplicationContext().getString(messageId));
+ }
+
+ /** Show a simple toast at the bottom */
+ public static void showToastAtBottom(final String message) {
+ final Toast toast = Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG);
+ toast.setGravity(Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL, 0, 0);
+ toast.show();
+ }
+
+ /** Show a simple toast at the default position */
+ public static void showToast(final int messageId) {
+ final Toast toast = Toast.makeText(getApplicationContext(),
+ getApplicationContext().getString(messageId), Toast.LENGTH_LONG);
+ toast.setGravity(Gravity.CENTER_HORIZONTAL, 0, 0);
+ toast.show();
+ }
+
+ /** Show a simple toast at the default position */
+ public static void showToast(final int pluralsMessageId, final int count) {
+ final Toast toast = Toast.makeText(getApplicationContext(),
+ getApplicationContext().getResources().getQuantityString(pluralsMessageId, count),
+ Toast.LENGTH_LONG);
+ toast.setGravity(Gravity.CENTER_HORIZONTAL, 0, 0);
+ toast.show();
+ }
+
+ public static void showSnackBar(final Context context, @NonNull final View parentView,
+ final String message, @Nullable final Runnable runnable, final int runnableLabel,
+ @Nullable final List<SnackBarInteraction> interactions) {
+ Assert.notNull(context);
+ SnackBar.Action action = null;
+ switch (runnableLabel) {
+ case SnackBar.Action.SNACK_BAR_UNDO:
+ action = SnackBar.Action.createUndoAction(runnable);
+ break;
+ case SnackBar.Action.SNACK_BAR_RETRY:
+ action = SnackBar.Action.createRetryAction(runnable);
+ break;
+ default :
+ break;
+ }
+
+ showSnackBarWithCustomAction(context, parentView, message, action, interactions,
+ null /* placement */);
+ }
+
+ public static void showSnackBarWithCustomAction(final Context context,
+ @NonNull final View parentView,
+ @NonNull final String message,
+ @NonNull final SnackBar.Action action,
+ @Nullable final List<SnackBarInteraction> interactions,
+ @Nullable final Placement placement) {
+ Assert.notNull(context);
+ Assert.isTrue(!TextUtils.isEmpty(message));
+ Assert.notNull(action);
+ SnackBarManager.get()
+ .newBuilder(parentView)
+ .setText(message)
+ .setAction(action)
+ .withInteractions(interactions)
+ .withPlacement(placement)
+ .show();
+ }
+
+ /**
+ * Run the given runnable once after the next layout pass of the view.
+ */
+ public static void doOnceAfterLayoutChange(final View view, final Runnable runnable) {
+ final OnLayoutChangeListener listener = new OnLayoutChangeListener() {
+ @Override
+ public void onLayoutChange(final View v, final int left, final int top, final int right,
+ final int bottom, final int oldLeft, final int oldTop, final int oldRight,
+ final int oldBottom) {
+ // Call the runnable outside the layout pass because very few actions are allowed in
+ // the layout pass
+ ThreadUtil.getMainThreadHandler().post(runnable);
+ view.removeOnLayoutChangeListener(this);
+ }
+ };
+ view.addOnLayoutChangeListener(listener);
+ }
+
+ public static boolean isLandscapeMode() {
+ return Factory.get().getApplicationContext().getResources().getConfiguration().orientation
+ == Configuration.ORIENTATION_LANDSCAPE;
+ }
+
+ private static Context getApplicationContext() {
+ return Factory.get().getApplicationContext();
+ }
+
+ public static CharSequence commaEllipsize(
+ final String text,
+ final TextPaint paint,
+ final int width,
+ final String oneMore,
+ final String more) {
+ CharSequence ellipsized = TextUtils.commaEllipsize(
+ text,
+ paint,
+ width,
+ oneMore,
+ more);
+ if (TextUtils.isEmpty(ellipsized)) {
+ ellipsized = text;
+ }
+ return ellipsized;
+ }
+
+ /**
+ * Reveals/Hides a view with a scale animation from view center.
+ * @param view the view to animate
+ * @param desiredVisibility desired visibility (e.g. View.GONE) for the animated view.
+ * @param onFinishRunnable an optional runnable called at the end of the animation
+ */
+ public static void revealOrHideViewWithAnimation(final View view, final int desiredVisibility,
+ @Nullable final Runnable onFinishRunnable) {
+ final boolean needAnimation = view.getVisibility() != desiredVisibility;
+ if (needAnimation) {
+ final float fromScale = desiredVisibility == View.VISIBLE ? 0F : 1F;
+ final float toScale = desiredVisibility == View.VISIBLE ? 1F : 0F;
+ final ScaleAnimation showHideAnimation =
+ new ScaleAnimation(fromScale, toScale, fromScale, toScale,
+ ScaleAnimation.RELATIVE_TO_SELF, 0.5f,
+ ScaleAnimation.RELATIVE_TO_SELF, 0.5f);
+ showHideAnimation.setDuration(REVEAL_ANIMATION_DURATION);
+ showHideAnimation.setInterpolator(DEFAULT_INTERPOLATOR);
+ showHideAnimation.setAnimationListener(new AnimationListener() {
+ @Override
+ public void onAnimationStart(final Animation animation) {
+ }
+
+ @Override
+ public void onAnimationRepeat(final Animation animation) {
+ }
+
+ @Override
+ public void onAnimationEnd(final Animation animation) {
+ if (onFinishRunnable != null) {
+ // Rather than running this immediately, we post it to happen next so that
+ // the animation will be completed so that the view can be detached from
+ // it's window. Otherwise, we may leak memory.
+ ThreadUtil.getMainThreadHandler().post(onFinishRunnable);
+ }
+ }
+ });
+ view.clearAnimation();
+ view.startAnimation(showHideAnimation);
+ // We are playing a view Animation; unlike view property animations, we can commit the
+ // visibility immediately instead of waiting for animation end.
+ view.setVisibility(desiredVisibility);
+ } else if (onFinishRunnable != null) {
+ // Make sure onFinishRunnable is always executed.
+ ThreadUtil.getMainThreadHandler().post(onFinishRunnable);
+ }
+ }
+
+ public static Rect getMeasuredBoundsOnScreen(final View view) {
+ final int[] location = new int[2];
+ view.getLocationOnScreen(location);
+ return new Rect(location[0], location[1],
+ location[0] + view.getMeasuredWidth(), location[1] + view.getMeasuredHeight());
+ }
+
+ public static void setStatusBarColor(final Activity activity, final int color) {
+ if (OsUtil.isAtLeastL()) {
+ // To achieve the appearance of an 80% opacity blend against a black background,
+ // each color channel is reduced in value by 20%.
+ final int blendedRed = (int) Math.floor(0.8 * Color.red(color));
+ final int blendedGreen = (int) Math.floor(0.8 * Color.green(color));
+ final int blendedBlue = (int) Math.floor(0.8 * Color.blue(color));
+
+ activity.getWindow().setStatusBarColor(
+ Color.rgb(blendedRed, blendedGreen, blendedBlue));
+ }
+ }
+
+ public static void lockOrientation(final Activity activity) {
+ final int orientation = activity.getResources().getConfiguration().orientation;
+ final int rotation = activity.getWindowManager().getDefaultDisplay().getRotation();
+
+ // rotation tracks the rotation of the device from its natural orientation
+ // orientation tracks whether the screen is landscape or portrait.
+ // It is possible to have a rotation of 0 (device in its natural orientation) in portrait
+ // (phone), or in landscape (tablet), so we have to check both values to determine what to
+ // pass to setRequestedOrientation.
+ if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) {
+ if (orientation == Configuration.ORIENTATION_PORTRAIT) {
+ activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
+ } else if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
+ }
+ } else if (rotation == Surface.ROTATION_180 || rotation == Surface.ROTATION_270) {
+ if (orientation == Configuration.ORIENTATION_PORTRAIT) {
+ activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT);
+ } else if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE);
+ }
+ }
+ }
+
+ public static void unlockOrientation(final Activity activity) {
+ activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR);
+ }
+
+ public static int getPaddingStart(final View view) {
+ return OsUtil.isAtLeastJB_MR1() ? view.getPaddingStart() : view.getPaddingLeft();
+ }
+
+ public static int getPaddingEnd(final View view) {
+ return OsUtil.isAtLeastJB_MR1() ? view.getPaddingEnd() : view.getPaddingRight();
+ }
+
+ public static boolean isRtlMode() {
+ return OsUtil.isAtLeastJB_MR2() && Factory.get().getApplicationContext().getResources()
+ .getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
+ }
+
+ /**
+ * Check if the activity needs to be redirected to permission check
+ * @return true if {@link Activity#finish()} was called because redirection was performed
+ */
+ public static boolean redirectToPermissionCheckIfNeeded(final Activity activity) {
+ if (!OsUtil.hasRequiredPermissions()) {
+ UIIntents.get().launchPermissionCheckActivity(activity);
+ } else {
+ // No redirect performed
+ return false;
+ }
+
+ // Redirect performed
+ activity.finish();
+ return true;
+ }
+
+ /**
+ * Called to check if all conditions are nominal and a "go" for some action, such as deleting
+ * a message, that requires this app to be the default app. This is also a precondition
+ * required for sending a draft.
+ * @return true if all conditions are nominal and we're ready to send a message
+ */
+ public static boolean isReadyForAction() {
+ final PhoneUtils phoneUtils = PhoneUtils.getDefault();
+
+ // Have all the conditions been met:
+ // Supports SMS?
+ // Has a preferred sim?
+ // Is the default sms app?
+ return phoneUtils.isSmsCapable() &&
+ phoneUtils.getHasPreferredSmsSim() &&
+ phoneUtils.isDefaultSmsApp();
+ }
+
+ /*
+ * Removes all html markup from the text and replaces links with the the text and a text version
+ * of the href.
+ * @param htmlText HTML markup text
+ * @return Sanitized string with link hrefs inlined
+ */
+ public static String stripHtml(final String htmlText) {
+ final StringBuilder result = new StringBuilder();
+ final Spanned markup = Html.fromHtml(htmlText);
+ final String strippedText = markup.toString();
+
+ final URLSpan[] links = markup.getSpans(0, markup.length() - 1, URLSpan.class);
+ int currentIndex = 0;
+ for (final URLSpan link : links) {
+ final int spanStart = markup.getSpanStart(link);
+ final int spanEnd = markup.getSpanEnd(link);
+ if (spanStart > currentIndex) {
+ result.append(strippedText, currentIndex, spanStart);
+ }
+ final String displayText = strippedText.substring(spanStart, spanEnd);
+ final String linkText = link.getURL();
+ result.append(getApplicationContext().getString(R.string.link_display_format,
+ displayText, linkText));
+ currentIndex = spanEnd;
+ }
+ if (strippedText.length() > currentIndex) {
+ result.append(strippedText, currentIndex, strippedText.length());
+ }
+ return result.toString();
+ }
+
+ public static void setActionBarShadowVisibility(final ActionBarActivity activity, final boolean visible) {
+ final ActionBar actionBar = activity.getSupportActionBar();
+ actionBar.setElevation(visible ?
+ activity.getResources().getDimensionPixelSize(R.dimen.action_bar_elevation) :
+ 0);
+ final View actionBarView = activity.getWindow().getDecorView().findViewById(
+ android.support.v7.appcompat.R.id.decor_content_parent);
+ if (actionBarView != null) {
+ // AppCompatActionBar has one drawable Field, which is the shadow for the action bar
+ // set the alpha on that drawable manually
+ final Field[] fields = actionBarView.getClass().getDeclaredFields();
+ try {
+ for (final Field field : fields) {
+ if (field.getType().equals(Drawable.class)) {
+ field.setAccessible(true);
+ final Drawable shadowDrawable = (Drawable) field.get(actionBarView);
+ if (shadowDrawable != null) {
+ shadowDrawable.setAlpha(visible ? 255 : 0);
+ actionBarView.invalidate();
+ return;
+ }
+ }
+ }
+ } catch (final IllegalAccessException ex) {
+ // Not expected, we should avoid this via field.setAccessible(true) above
+ LogUtil.e(LogUtil.BUGLE_TAG, "Error setting shadow visibility", ex);
+ }
+ }
+ }
+
+ /**
+ * Get the activity that's hosting the view, typically casting view.getContext() as an Activity
+ * is sufficient, but sometimes the context is a context wrapper, in which case we need to case
+ * the base context
+ */
+ public static Activity getActivity(final View view) {
+ if (view == null) {
+ return null;
+ }
+ return getActivity(view.getContext());
+ }
+
+ /**
+ * Get the activity for the supplied context, typically casting context as an Activity
+ * is sufficient, but sometimes the context is a context wrapper, in which case we need to case
+ * the base context
+ */
+ public static Activity getActivity(final Context context) {
+ if (context == null) {
+ return null;
+ }
+ if (context instanceof Activity) {
+ return (Activity) context;
+ }
+ if (context instanceof ContextWrapper) {
+ return getActivity(((ContextWrapper) context).getBaseContext());
+ }
+
+ // We've hit a non-activity context such as an app-context
+ return null;
+ }
+
+ public static RemoteViews getWidgetMissingPermissionView(final Context context) {
+ return new RemoteViews(context.getPackageName(), R.layout.widget_missing_permission);
+ }
+}
diff --git a/src/com/android/messaging/util/UriUtil.java b/src/com/android/messaging/util/UriUtil.java
new file mode 100644
index 0000000..4bbc80d
--- /dev/null
+++ b/src/com/android/messaging/util/UriUtil.java
@@ -0,0 +1,393 @@
+/*
+ * 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.messaging.util;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.res.AssetFileDescriptor;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.provider.MediaStore;
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.datamodel.MediaScratchFileProvider;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.google.common.io.ByteStreams;
+import com.google.common.io.Files;
+import com.google.common.io.Resources;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.Arrays;
+import java.util.HashSet;
+
+public class UriUtil {
+ private static final String SCHEME_SMS = "sms";
+ private static final String SCHEME_SMSTO = "smsto";
+ private static final String SCHEME_MMS = "mms";
+ private static final String SCHEME_MMSTO = "smsto";
+ public static final HashSet<String> SMS_MMS_SCHEMES = new HashSet<String>(
+ Arrays.asList(SCHEME_SMS, SCHEME_MMS, SCHEME_SMSTO, SCHEME_MMSTO));
+
+ public static final String SCHEME_BUGLE = "bugle";
+ public static final HashSet<String> SUPPORTED_SCHEME = new HashSet<String>(
+ Arrays.asList(ContentResolver.SCHEME_ANDROID_RESOURCE,
+ ContentResolver.SCHEME_CONTENT,
+ ContentResolver.SCHEME_FILE,
+ SCHEME_BUGLE));
+
+ public static final String SCHEME_TEL = "tel:";
+
+ /**
+ * Get a Uri representation of the file path of a resource file.
+ */
+ public static Uri getUriForResourceFile(final String path) {
+ return TextUtils.isEmpty(path) ? null : Uri.fromFile(new File(path));
+ }
+
+ /**
+ * Extract the path from a file:// Uri, or null if the uri is of other scheme.
+ */
+ public static String getFilePathFromUri(final Uri uri) {
+ if (!isFileUri(uri)) {
+ return null;
+ }
+ return uri.getPath();
+ }
+
+ /**
+ * Returns whether the given Uri is local or remote.
+ */
+ public static boolean isLocalResourceUri(final Uri uri) {
+ final String scheme = uri.getScheme();
+ return TextUtils.equals(scheme, ContentResolver.SCHEME_ANDROID_RESOURCE) ||
+ TextUtils.equals(scheme, ContentResolver.SCHEME_CONTENT) ||
+ TextUtils.equals(scheme, ContentResolver.SCHEME_FILE);
+ }
+
+ /**
+ * Returns whether the given Uri is part of Bugle's app package
+ */
+ public static boolean isBugleAppResource(final Uri uri) {
+ final String scheme = uri.getScheme();
+ return TextUtils.equals(scheme, ContentResolver.SCHEME_ANDROID_RESOURCE);
+ }
+
+ public static boolean isFileUri(final Uri uri) {
+ return uri != null && TextUtils.equals(uri.getScheme(), ContentResolver.SCHEME_FILE);
+ }
+
+ /**
+ * Constructs an android.resource:// uri for the given resource id.
+ */
+ public static Uri getUriForResourceId(final Context context, final int resId) {
+ return new Uri.Builder()
+ .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
+ .authority(context.getPackageName())
+ .appendPath(String.valueOf(resId))
+ .build();
+ }
+
+ /**
+ * Returns whether the given Uri string is local.
+ */
+ public static boolean isLocalUri(@NonNull final Uri uri) {
+ Assert.notNull(uri);
+ return SUPPORTED_SCHEME.contains(uri.getScheme());
+ }
+
+ private static final String MEDIA_STORE_URI_KLP = "com.android.providers.media.documents";
+
+ /**
+ * Check if a URI is from the MediaStore
+ */
+ public static boolean isMediaStoreUri(final Uri uri) {
+ final String uriAuthority = uri.getAuthority();
+ return TextUtils.equals(ContentResolver.SCHEME_CONTENT, uri.getScheme())
+ && (TextUtils.equals(MediaStore.AUTHORITY, uriAuthority) ||
+ // KK changed the media store authority name
+ TextUtils.equals(MEDIA_STORE_URI_KLP, uriAuthority));
+ }
+
+ /**
+ * Gets the size in bytes for the content uri. Currently we only support content in the
+ * scratch space.
+ */
+ @DoesNotRunOnMainThread
+ public static long getContentSize(final Uri uri) {
+ Assert.isNotMainThread();
+ if (isLocalResourceUri(uri)) {
+ ParcelFileDescriptor pfd = null;
+ try {
+ pfd = Factory.get().getApplicationContext()
+ .getContentResolver().openFileDescriptor(uri, "r");
+ return Math.max(pfd.getStatSize(), 0);
+ } catch (final FileNotFoundException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Error getting content size", e);
+ } finally {
+ if (pfd != null) {
+ try {
+ pfd.close();
+ } catch (final IOException e) {
+ // Do nothing.
+ }
+ }
+ }
+ } else {
+ Assert.fail("Unsupported uri type!");
+ }
+ return 0;
+ }
+
+ /** @return duration in milliseconds or 0 if not able to determine */
+ public static int getMediaDurationMs(final Uri uri) {
+ final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper();
+ try {
+ retriever.setDataSource(uri);
+ return retriever.extractInteger(MediaMetadataRetriever.METADATA_KEY_DURATION, 0);
+ } catch (final IOException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Unable extract duration from media file: " + uri, e);
+ return 0;
+ } finally {
+ retriever.release();
+ }
+ }
+
+ /**
+ * Persist a piece of content from the given input stream, byte by byte to the scratch
+ * directory.
+ * @return the output Uri if the operation succeeded, or null if failed.
+ */
+ @DoesNotRunOnMainThread
+ public static Uri persistContentToScratchSpace(final InputStream inputStream) {
+ final Context context = Factory.get().getApplicationContext();
+ final Uri scratchSpaceUri = MediaScratchFileProvider.buildMediaScratchSpaceUri(null);
+ return copyContent(context, inputStream, scratchSpaceUri);
+ }
+
+ /**
+ * Persist a piece of content from the given sourceUri, byte by byte to the scratch
+ * directory.
+ * @return the output Uri if the operation succeeded, or null if failed.
+ */
+ @DoesNotRunOnMainThread
+ public static Uri persistContentToScratchSpace(final Uri sourceUri) {
+ InputStream inputStream = null;
+ final Context context = Factory.get().getApplicationContext();
+ try {
+ if (UriUtil.isLocalResourceUri(sourceUri)) {
+ inputStream = context.getContentResolver().openInputStream(sourceUri);
+ } else {
+ // The content is remote. Download it.
+ final URL url = new URL(sourceUri.toString());
+ final URLConnection ucon = url.openConnection();
+ inputStream = new BufferedInputStream(ucon.getInputStream());
+ }
+ return persistContentToScratchSpace(inputStream);
+ } catch (final Exception ex) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Error while retrieving media ", ex);
+ return null;
+ } finally {
+ if (inputStream != null) {
+ try {
+ inputStream.close();
+ } catch (final IOException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "error trying to close the inputStream", e);
+ }
+ }
+ }
+ }
+
+ /**
+ * Persist a piece of content from the given input stream, byte by byte to the specified
+ * directory.
+ * @return the output Uri if the operation succeeded, or null if failed.
+ */
+ @DoesNotRunOnMainThread
+ public static Uri persistContent(
+ final InputStream inputStream, final File outputDir, final String contentType) {
+ if (!outputDir.exists() && !outputDir.mkdirs()) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Error creating " + outputDir.getAbsolutePath());
+ return null;
+ }
+
+ final Context context = Factory.get().getApplicationContext();
+ try {
+ final Uri targetUri = Uri.fromFile(FileUtil.getNewFile(outputDir, contentType));
+ return copyContent(context, inputStream, targetUri);
+ } catch (final IOException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Error creating file in " + outputDir.getAbsolutePath());
+ return null;
+ }
+ }
+
+ /**
+ * Persist a piece of content from the given sourceUri, byte by byte to the
+ * specified output directory.
+ * @return the output Uri if the operation succeeded, or null if failed.
+ */
+ @DoesNotRunOnMainThread
+ public static Uri persistContent(
+ final Uri sourceUri, final File outputDir, final String contentType) {
+ InputStream inputStream = null;
+ final Context context = Factory.get().getApplicationContext();
+ try {
+ if (UriUtil.isLocalResourceUri(sourceUri)) {
+ inputStream = context.getContentResolver().openInputStream(sourceUri);
+ } else {
+ // The content is remote. Download it.
+ final URL url = new URL(sourceUri.toString());
+ final URLConnection ucon = url.openConnection();
+ inputStream = new BufferedInputStream(ucon.getInputStream());
+ }
+ return persistContent(inputStream, outputDir, contentType);
+ } catch (final Exception ex) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Error while retrieving media ", ex);
+ return null;
+ } finally {
+ if (inputStream != null) {
+ try {
+ inputStream.close();
+ } catch (final IOException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "error trying to close the inputStream", e);
+ }
+ }
+ }
+ }
+
+ /** @return uri of target file, or null on error */
+ @DoesNotRunOnMainThread
+ private static Uri copyContent(
+ final Context context, final InputStream inputStream, final Uri targetUri) {
+ Assert.isNotMainThread();
+ OutputStream outputStream = null;
+ try {
+ outputStream = context.getContentResolver().openOutputStream(targetUri);
+ ByteStreams.copy(inputStream, outputStream);
+ } catch (final Exception ex) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Error while copying content ", ex);
+ return null;
+ } finally {
+ if (outputStream != null) {
+ try {
+ outputStream.flush();
+ } catch (final IOException e) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "error trying to flush the outputStream", e);
+ return null;
+ } finally {
+ try {
+ outputStream.close();
+ } catch (final IOException e) {
+ // Do nothing.
+ }
+ }
+ }
+ }
+ return targetUri;
+ }
+
+ public static boolean isSmsMmsUri(final Uri uri) {
+ return uri != null && SMS_MMS_SCHEMES.contains(uri.getScheme());
+ }
+
+ /**
+ * Extract recipient destinations from Uri of form
+ * SCHEME:destionation[,destination]?otherstuff
+ * where SCHEME is one of the supported sms/mms schemes.
+ *
+ * @param uri sms/mms uri
+ * @return recipient destinations or null
+ */
+ public static String[] parseRecipientsFromSmsMmsUri(final Uri uri) {
+ if (!isSmsMmsUri(uri)) {
+ return null;
+ }
+ final String[] parts = uri.getSchemeSpecificPart().split("\\?");
+ if (TextUtils.isEmpty(parts[0])) {
+ return null;
+ }
+ // replaceUnicodeDigits will replace digits typed in other languages (i.e. Egyptian) with
+ // the usual ascii equivalents.
+ return TextUtil.replaceUnicodeDigits(parts[0]).replace(';', ',').split(",");
+ }
+
+ /**
+ * Return the length of the file to which contentUri refers
+ *
+ * @param contentUri URI for the file of which we want the length
+ * @return Length of the file or AssetFileDescriptor.UNKNOWN_LENGTH
+ */
+ public static long getUriContentLength(final Uri contentUri) {
+ final Context context = Factory.get().getApplicationContext();
+ AssetFileDescriptor afd = null;
+ try {
+ afd = context.getContentResolver().openAssetFileDescriptor(contentUri, "r");
+ return afd.getLength();
+ } catch (final FileNotFoundException e) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Failed to query length of " + contentUri);
+ } finally {
+ if (afd != null) {
+ try {
+ afd.close();
+ } catch (final IOException e) {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Failed to close afd for " + contentUri);
+ }
+ }
+ }
+ return AssetFileDescriptor.UNKNOWN_LENGTH;
+ }
+
+ /**
+ * Download data from the given url to the given local file.
+ * @return true if the download was successful.
+ *
+ * TODO: Add retry/exponential backoff logic.
+ */
+ @DoesNotRunOnMainThread
+ public static boolean downloadDataFromUrl(final String urlString, final File localFile) {
+ Assert.isNotMainThread();
+ LogUtil.i(LogUtil.BUGLE_TAG, "Downloading from " + urlString + " to " + localFile);
+ try {
+ Files.createParentDirs(localFile);
+ final URL inUrl = new URL(urlString);
+ ByteStreams.copy(Resources.newInputStreamSupplier(inUrl),
+ Files.newOutputStreamSupplier(localFile));
+ return true;
+ } catch (final IOException ex) {
+ LogUtil.e(LogUtil.BUGLE_TAG, "Error downloading from " + urlString, ex);
+ return false;
+ }
+ }
+
+ /** @return string representation of URI or null if URI was null */
+ public static String stringFromUri(final Uri uri) {
+ return uri == null ? null : uri.toString();
+ }
+
+ /** @return URI created from string or null if string was null or empty */
+ public static Uri uriFromString(final String uriString) {
+ return TextUtils.isEmpty(uriString) ? null : Uri.parse(uriString);
+ }
+}
diff --git a/src/com/android/messaging/util/VersionUtil.java b/src/com/android/messaging/util/VersionUtil.java
new file mode 100644
index 0000000..b87aa55
--- /dev/null
+++ b/src/com/android/messaging/util/VersionUtil.java
@@ -0,0 +1,67 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager.NameNotFoundException;
+
+import java.util.Locale;
+
+public final class VersionUtil {
+ private static final Object sLock = new Object();
+ private static VersionUtil sInstance;
+ private final String mSimpleVersionName;
+ private final int mVersionCode;
+
+ public static VersionUtil getInstance(final Context context) {
+ synchronized (sLock) {
+ if (sInstance == null) {
+ sInstance = new VersionUtil(context);
+ }
+ }
+ return sInstance;
+ }
+
+ private VersionUtil(final Context context) {
+ int versionCode;
+ try {
+ PackageInfo pi = context.getPackageManager().getPackageInfo(
+ context.getPackageName(), 0);
+ versionCode = pi.versionCode;
+ } catch (final NameNotFoundException exception) {
+ Assert.fail("couldn't get package info " + exception);
+ versionCode = -1;
+ }
+ mVersionCode = versionCode;
+ final int majorBuildNumber = versionCode / 1000;
+ // Use US locale to format version number so that other language characters don't
+ // show up in version string.
+ mSimpleVersionName = String.format(Locale.US, "%d.%d.%03d",
+ majorBuildNumber / 10000,
+ (majorBuildNumber / 1000) % 10,
+ majorBuildNumber % 1000);
+ }
+
+ public int getVersionCode() {
+ return mVersionCode;
+ }
+
+ public String getSimpleName() {
+ return mSimpleVersionName;
+ }
+}
diff --git a/src/com/android/messaging/util/WakeLockHelper.java b/src/com/android/messaging/util/WakeLockHelper.java
new file mode 100644
index 0000000..c9a9152
--- /dev/null
+++ b/src/com/android/messaging/util/WakeLockHelper.java
@@ -0,0 +1,121 @@
+/*
+ * 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.messaging.util;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Debug;
+import android.os.PowerManager;
+import android.os.Process;
+
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Helper class used to manage wakelock state
+ */
+public class WakeLockHelper {
+ private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
+ private static final boolean VERBOSE = false;
+
+ @VisibleForTesting
+ public static final String EXTRA_CALLING_PID = "pid";
+
+ private final Object mLock = new Object();
+ private final String mWakeLockId;
+ private final int mMyPid;
+
+ private PowerManager.WakeLock mWakeLock;
+
+ public WakeLockHelper(final String wakeLockId) {
+ mWakeLockId = wakeLockId;
+ mMyPid = Process.myPid();
+ }
+
+ /**
+ * Acquire the wakelock
+ */
+ public void acquire(final Context context, final Intent intent, final int opcode) {
+ synchronized (mLock) {
+ if (mWakeLock == null) {
+ if (VERBOSE) {
+ LogUtil.v(TAG, "initializing wakelock");
+ }
+ final PowerManager pm = (PowerManager)
+ context.getSystemService(Context.POWER_SERVICE);
+ mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, mWakeLockId);
+ }
+ }
+ if (VERBOSE) {
+ LogUtil.v(TAG, "acquiring " + mWakeLockId + " for opcode " + opcode);
+ }
+ mWakeLock.acquire();
+ intent.putExtra(EXTRA_CALLING_PID, mMyPid);
+ }
+
+ /**
+ * Check if wakelock held by this process
+ */
+ public boolean isHeld(final Intent intent) {
+ final boolean respectWakeLock = (mMyPid == intent.getIntExtra(EXTRA_CALLING_PID, -1));
+ return (respectWakeLock && mWakeLock.isHeld());
+ }
+
+ /**
+ * Ensure that wakelock is held by this process
+ */
+ public boolean ensure(final Intent intent, final int opcode) {
+ final boolean respectWakeLock = (mMyPid == intent.getIntExtra(EXTRA_CALLING_PID, -1));
+ if (VERBOSE) {
+ LogUtil.v(TAG, "WakeLockHelper.ensure Intent " + intent + " "
+ + intent.getAction() + " opcode: " + opcode
+ + " respectWakeLock " + respectWakeLock);
+ }
+
+ if (respectWakeLock) {
+ final boolean isHeld = (respectWakeLock && isHeld(intent));
+ if (!isHeld) {
+ LogUtil.e(TAG, "WakeLockHelper.ensure called " + intent + " " + intent.getAction()
+ + " opcode: " + opcode + " sWakeLock: " + mWakeLock + " isHeld: "
+ + ((mWakeLock == null) ? "(null)" : mWakeLock.isHeld()));
+ if (!Debug.isDebuggerConnected()) {
+ Assert.fail("WakeLock dropped prior to service starting");
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Release wakelock (if it is held by this process)
+ */
+ public void release(final Intent intent, final int opcode) {
+ final boolean respectWakeLock = (mMyPid == intent.getIntExtra(EXTRA_CALLING_PID, -1));
+ if (respectWakeLock) {
+ try {
+ mWakeLock.release();
+ } catch (final RuntimeException ex) {
+ LogUtil.e(TAG, "KeepAliveService.onHandleIntent exit crash " + intent + " "
+ + intent.getAction() + " opcode: " + opcode + " sWakeLock: " + mWakeLock
+ + " isHeld: " + ((mWakeLock == null) ? "(null)" : mWakeLock.isHeld()));
+ if (!Debug.isDebuggerConnected()) {
+ Assert.fail("WakeLock no longer held at end of handler");
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/YouTubeUtil.java b/src/com/android/messaging/util/YouTubeUtil.java
new file mode 100644
index 0000000..203a666
--- /dev/null
+++ b/src/com/android/messaging/util/YouTubeUtil.java
@@ -0,0 +1,98 @@
+/*
+ * 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.messaging.util;
+
+import android.net.Uri;
+import android.text.TextUtils;
+
+public class YouTubeUtil {
+ private static final String YOUTUBE_HOST_1 = "www.youtube.com";
+ private static final String YOUTUBE_HOST_2 = "youtube.com";
+ private static final String YOUTUBE_HOST_3 = "m.youtube.com";
+ private static final String YOUTUBE_HOST_4 = "youtube.googleapis.com";
+ private static final String YOUTUBE_HOST_5 = "youtu.be";
+
+ private static final String YOUTUBE_PATH_1 = "/watch";
+ private static final String YOUTUBE_PATH_2 = "/embed/";
+ private static final String YOUTUBE_PATH_3 = "/v/";
+ private static final String YOUTUBE_PATH_4 = "/apiplayer";
+
+ public static final String YOUTUBE_STATIC_THUMBNAIL_PREFIX = "https://img.youtube.com/vi/";
+ public static final String YOUTUBE_STATIC_THUMBNAIL_END = "/hqdefault.jpg";
+
+ public static String getYoutubePreviewImageLink(String urlString) {
+ // Types of youtube urls:
+ // 1.) http://www.youtube.com/watch?v=VIDEOID
+ // 2.) http://www.youtube.com/embed/VIDEOID
+ // 3.) http://www.youtube.com/v/VIDEOID
+ // 3a.) https://youtube.googleapis.com/v/VIDEOID
+ // 4.) http://www.youtube.com/apiplayer?video_id=VIDEO_ID
+ // 5.) http://youtu.be/VIDEOID
+ if (!urlString.startsWith("http")) {
+ // Apparently the url is not an RFC 2396 compliant uri without the port
+ urlString = "http://" + urlString;
+ }
+ final Uri uri = Uri.parse(urlString);
+ final String host = uri.getHost();
+ if (YOUTUBE_HOST_1.equalsIgnoreCase(host)
+ || YOUTUBE_HOST_2.equalsIgnoreCase(host)
+ || YOUTUBE_HOST_3.equalsIgnoreCase(host)
+ || YOUTUBE_HOST_4.equalsIgnoreCase(host)
+ || YOUTUBE_HOST_5.equalsIgnoreCase(host)) {
+ final String videoId = getYouTubeVideoId(uri);
+ if (!TextUtils.isEmpty(videoId)) {
+ return YOUTUBE_STATIC_THUMBNAIL_PREFIX + videoId + YOUTUBE_STATIC_THUMBNAIL_END;
+ }
+ return null;
+ }
+ return null;
+ }
+
+ private static String getYouTubeVideoId(Uri uri) {
+ final String urlPath = uri.getPath();
+
+ if (TextUtils.isEmpty(urlPath)) {
+ // There is no path so no need to continue.
+ return null;
+ }
+ // Case 1
+ if (urlPath.startsWith(YOUTUBE_PATH_1)) {
+ return uri.getQueryParameter("v");
+ }
+ // Case 2
+ if (urlPath.startsWith(YOUTUBE_PATH_2)) {
+ return getVideoIdFromPath(YOUTUBE_PATH_2, urlPath);
+ }
+ // Case 3
+ if (urlPath.startsWith(YOUTUBE_PATH_3)) {
+ return getVideoIdFromPath(YOUTUBE_PATH_3, urlPath);
+ }
+ // Case 4
+ if (urlPath.startsWith(YOUTUBE_PATH_4)) {
+ return uri.getQueryParameter("video_id");
+ }
+ // Case 5
+ if (YOUTUBE_HOST_5.equalsIgnoreCase(uri.getHost())) {
+ return getVideoIdFromPath("/", urlPath);
+ }
+ return null;
+ }
+
+ private static String getVideoIdFromPath(String prefixSubstring, String urlPath) {
+ return urlPath.substring(prefixSubstring.length());
+ }
+
+}
diff --git a/src/com/android/messaging/util/exif/ByteBufferInputStream.java b/src/com/android/messaging/util/exif/ByteBufferInputStream.java
new file mode 100644
index 0000000..9db92ef
--- /dev/null
+++ b/src/com/android/messaging/util/exif/ByteBufferInputStream.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+class ByteBufferInputStream extends InputStream {
+
+ private final ByteBuffer mBuf;
+
+ public ByteBufferInputStream(ByteBuffer buf) {
+ mBuf = buf;
+ }
+
+ @Override
+ public int read() {
+ if (!mBuf.hasRemaining()) {
+ return -1;
+ }
+ return mBuf.get() & 0xFF;
+ }
+
+ @Override
+ public int read(byte[] bytes, int off, int len) {
+ if (!mBuf.hasRemaining()) {
+ return -1;
+ }
+
+ len = Math.min(len, mBuf.remaining());
+ mBuf.get(bytes, off, len);
+ return len;
+ }
+}
diff --git a/src/com/android/messaging/util/exif/CountedDataInputStream.java b/src/com/android/messaging/util/exif/CountedDataInputStream.java
new file mode 100644
index 0000000..ce766d9
--- /dev/null
+++ b/src/com/android/messaging/util/exif/CountedDataInputStream.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+import java.io.EOFException;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.Charset;
+
+class CountedDataInputStream extends FilterInputStream {
+
+ private int mCount = 0;
+
+ // allocate a byte buffer for a long value;
+ private final byte mByteArray[] = new byte[8];
+ private final ByteBuffer mByteBuffer = ByteBuffer.wrap(mByteArray);
+
+ protected CountedDataInputStream(InputStream in) {
+ super(in);
+ }
+
+ public int getReadByteCount() {
+ return mCount;
+ }
+
+ @Override
+ public int read(byte[] b) throws IOException {
+ int r = in.read(b);
+ mCount += (r >= 0) ? r : 0;
+ return r;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int r = in.read(b, off, len);
+ mCount += (r >= 0) ? r : 0;
+ return r;
+ }
+
+ @Override
+ public int read() throws IOException {
+ int r = in.read();
+ mCount += (r >= 0) ? 1 : 0;
+ return r;
+ }
+
+ @Override
+ public long skip(long length) throws IOException {
+ long skip = in.skip(length);
+ mCount += skip;
+ return skip;
+ }
+
+ public void skipOrThrow(long length) throws IOException {
+ if (skip(length) != length) {
+ throw new EOFException();
+ }
+ }
+
+ public void skipTo(long target) throws IOException {
+ long cur = mCount;
+ long diff = target - cur;
+ assert(diff >= 0);
+ skipOrThrow(diff);
+ }
+
+ public void readOrThrow(byte[] b, int off, int len) throws IOException {
+ int r = read(b, off, len);
+ if (r != len) {
+ throw new EOFException();
+ }
+ }
+
+ public void readOrThrow(byte[] b) throws IOException {
+ readOrThrow(b, 0, b.length);
+ }
+
+ public void setByteOrder(ByteOrder order) {
+ mByteBuffer.order(order);
+ }
+
+ public ByteOrder getByteOrder() {
+ return mByteBuffer.order();
+ }
+
+ public short readShort() throws IOException {
+ readOrThrow(mByteArray, 0 , 2);
+ mByteBuffer.rewind();
+ return mByteBuffer.getShort();
+ }
+
+ public int readUnsignedShort() throws IOException {
+ return readShort() & 0xffff;
+ }
+
+ public int readInt() throws IOException {
+ readOrThrow(mByteArray, 0 , 4);
+ mByteBuffer.rewind();
+ return mByteBuffer.getInt();
+ }
+
+ public long readUnsignedInt() throws IOException {
+ return readInt() & 0xffffffffL;
+ }
+
+ public long readLong() throws IOException {
+ readOrThrow(mByteArray, 0 , 8);
+ mByteBuffer.rewind();
+ return mByteBuffer.getLong();
+ }
+
+ public String readString(int n) throws IOException {
+ byte buf[] = new byte[n];
+ readOrThrow(buf);
+ return new String(buf, "UTF8");
+ }
+
+ public String readString(int n, Charset charset) throws IOException {
+ byte buf[] = new byte[n];
+ readOrThrow(buf);
+ return new String(buf, charset);
+ }
+}
diff --git a/src/com/android/messaging/util/exif/ExifData.java b/src/com/android/messaging/util/exif/ExifData.java
new file mode 100644
index 0000000..77ba4e9
--- /dev/null
+++ b/src/com/android/messaging/util/exif/ExifData.java
@@ -0,0 +1,349 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+import android.util.Log;
+import com.android.messaging.util.LogUtil;
+
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * This class stores the EXIF header in IFDs according to the JPEG
+ * specification. It is the result produced by {@link ExifReader}.
+ *
+ * @see ExifReader
+ * @see IfdData
+ */
+class ExifData {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+ private static final byte[] USER_COMMENT_ASCII = {
+ 0x41, 0x53, 0x43, 0x49, 0x49, 0x00, 0x00, 0x00
+ };
+ private static final byte[] USER_COMMENT_JIS = {
+ 0x4A, 0x49, 0x53, 0x00, 0x00, 0x00, 0x00, 0x00
+ };
+ private static final byte[] USER_COMMENT_UNICODE = {
+ 0x55, 0x4E, 0x49, 0x43, 0x4F, 0x44, 0x45, 0x00
+ };
+
+ private final IfdData[] mIfdDatas = new IfdData[IfdId.TYPE_IFD_COUNT];
+ private byte[] mThumbnail;
+ private final ArrayList<byte[]> mStripBytes = new ArrayList<byte[]>();
+ private final ByteOrder mByteOrder;
+
+ ExifData(ByteOrder order) {
+ mByteOrder = order;
+ }
+
+ /**
+ * Gets the compressed thumbnail. Returns null if there is no compressed
+ * thumbnail.
+ *
+ * @see #hasCompressedThumbnail()
+ */
+ protected byte[] getCompressedThumbnail() {
+ return mThumbnail;
+ }
+
+ /**
+ * Sets the compressed thumbnail.
+ */
+ protected void setCompressedThumbnail(byte[] thumbnail) {
+ mThumbnail = thumbnail;
+ }
+
+ /**
+ * Returns true it this header contains a compressed thumbnail.
+ */
+ protected boolean hasCompressedThumbnail() {
+ return mThumbnail != null;
+ }
+
+ /**
+ * Adds an uncompressed strip.
+ */
+ protected void setStripBytes(int index, byte[] strip) {
+ if (index < mStripBytes.size()) {
+ mStripBytes.set(index, strip);
+ } else {
+ for (int i = mStripBytes.size(); i < index; i++) {
+ mStripBytes.add(null);
+ }
+ mStripBytes.add(strip);
+ }
+ }
+
+ /**
+ * Gets the strip count.
+ */
+ protected int getStripCount() {
+ return mStripBytes.size();
+ }
+
+ /**
+ * Gets the strip at the specified index.
+ *
+ * @exceptions #IndexOutOfBoundException
+ */
+ protected byte[] getStrip(int index) {
+ return mStripBytes.get(index);
+ }
+
+ /**
+ * Returns true if this header contains uncompressed strip.
+ */
+ protected boolean hasUncompressedStrip() {
+ return mStripBytes.size() != 0;
+ }
+
+ /**
+ * Gets the byte order.
+ */
+ protected ByteOrder getByteOrder() {
+ return mByteOrder;
+ }
+
+ /**
+ * Returns the {@link IfdData} object corresponding to a given IFD if it
+ * exists or null.
+ */
+ protected IfdData getIfdData(int ifdId) {
+ if (ExifTag.isValidIfd(ifdId)) {
+ return mIfdDatas[ifdId];
+ }
+ return null;
+ }
+
+ /**
+ * Adds IFD data. If IFD data of the same type already exists, it will be
+ * replaced by the new data.
+ */
+ protected void addIfdData(IfdData data) {
+ mIfdDatas[data.getId()] = data;
+ }
+
+ /**
+ * Returns the {@link IfdData} object corresponding to a given IFD or
+ * generates one if none exist.
+ */
+ protected IfdData getOrCreateIfdData(int ifdId) {
+ IfdData ifdData = mIfdDatas[ifdId];
+ if (ifdData == null) {
+ ifdData = new IfdData(ifdId);
+ mIfdDatas[ifdId] = ifdData;
+ }
+ return ifdData;
+ }
+
+ /**
+ * Returns the tag with a given TID in the given IFD if the tag exists.
+ * Otherwise returns null.
+ */
+ protected ExifTag getTag(short tag, int ifd) {
+ IfdData ifdData = mIfdDatas[ifd];
+ return (ifdData == null) ? null : ifdData.getTag(tag);
+ }
+
+ /**
+ * Adds the given ExifTag to its default IFD and returns an existing ExifTag
+ * with the same TID or null if none exist.
+ */
+ protected ExifTag addTag(ExifTag tag) {
+ if (tag != null) {
+ int ifd = tag.getIfd();
+ return addTag(tag, ifd);
+ }
+ return null;
+ }
+
+ /**
+ * Adds the given ExifTag to the given IFD and returns an existing ExifTag
+ * with the same TID or null if none exist.
+ */
+ protected ExifTag addTag(ExifTag tag, int ifdId) {
+ if (tag != null && ExifTag.isValidIfd(ifdId)) {
+ IfdData ifdData = getOrCreateIfdData(ifdId);
+ return ifdData.setTag(tag);
+ }
+ return null;
+ }
+
+ protected void clearThumbnailAndStrips() {
+ mThumbnail = null;
+ mStripBytes.clear();
+ }
+
+ /**
+ * Removes the thumbnail and its related tags. IFD1 will be removed.
+ */
+ protected void removeThumbnailData() {
+ clearThumbnailAndStrips();
+ mIfdDatas[IfdId.TYPE_IFD_1] = null;
+ }
+
+ /**
+ * Removes the tag with a given TID and IFD.
+ */
+ protected void removeTag(short tagId, int ifdId) {
+ IfdData ifdData = mIfdDatas[ifdId];
+ if (ifdData == null) {
+ return;
+ }
+ ifdData.removeTag(tagId);
+ }
+
+ /**
+ * Decodes the user comment tag into string as specified in the EXIF
+ * standard. Returns null if decoding failed.
+ */
+ protected String getUserComment() {
+ IfdData ifdData = mIfdDatas[IfdId.TYPE_IFD_0];
+ if (ifdData == null) {
+ return null;
+ }
+ ExifTag tag = ifdData.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_USER_COMMENT));
+ if (tag == null) {
+ return null;
+ }
+ if (tag.getComponentCount() < 8) {
+ return null;
+ }
+
+ byte[] buf = new byte[tag.getComponentCount()];
+ tag.getBytes(buf);
+
+ byte[] code = new byte[8];
+ System.arraycopy(buf, 0, code, 0, 8);
+
+ try {
+ if (Arrays.equals(code, USER_COMMENT_ASCII)) {
+ return new String(buf, 8, buf.length - 8, "US-ASCII");
+ } else if (Arrays.equals(code, USER_COMMENT_JIS)) {
+ return new String(buf, 8, buf.length - 8, "EUC-JP");
+ } else if (Arrays.equals(code, USER_COMMENT_UNICODE)) {
+ return new String(buf, 8, buf.length - 8, "UTF-16");
+ } else {
+ return null;
+ }
+ } catch (UnsupportedEncodingException e) {
+ Log.w(TAG, "Failed to decode the user comment");
+ return null;
+ }
+ }
+
+ /**
+ * Returns a list of all {@link ExifTag}s in the ExifData or null if there
+ * are none.
+ */
+ protected List<ExifTag> getAllTags() {
+ ArrayList<ExifTag> ret = new ArrayList<ExifTag>();
+ for (IfdData d : mIfdDatas) {
+ if (d != null) {
+ ExifTag[] tags = d.getAllTags();
+ if (tags != null) {
+ for (ExifTag t : tags) {
+ ret.add(t);
+ }
+ }
+ }
+ }
+ if (ret.size() == 0) {
+ return null;
+ }
+ return ret;
+ }
+
+ /**
+ * Returns a list of all {@link ExifTag}s in a given IFD or null if there
+ * are none.
+ */
+ protected List<ExifTag> getAllTagsForIfd(int ifd) {
+ IfdData d = mIfdDatas[ifd];
+ if (d == null) {
+ return null;
+ }
+ ExifTag[] tags = d.getAllTags();
+ if (tags == null) {
+ return null;
+ }
+ ArrayList<ExifTag> ret = new ArrayList<ExifTag>(tags.length);
+ for (ExifTag t : tags) {
+ ret.add(t);
+ }
+ if (ret.size() == 0) {
+ return null;
+ }
+ return ret;
+ }
+
+ /**
+ * Returns a list of all {@link ExifTag}s with a given TID or null if there
+ * are none.
+ */
+ protected List<ExifTag> getAllTagsForTagId(short tag) {
+ ArrayList<ExifTag> ret = new ArrayList<ExifTag>();
+ for (IfdData d : mIfdDatas) {
+ if (d != null) {
+ ExifTag t = d.getTag(tag);
+ if (t != null) {
+ ret.add(t);
+ }
+ }
+ }
+ if (ret.size() == 0) {
+ return null;
+ }
+ return ret;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (obj instanceof ExifData) {
+ ExifData data = (ExifData) obj;
+ if (data.mByteOrder != mByteOrder ||
+ data.mStripBytes.size() != mStripBytes.size() ||
+ !Arrays.equals(data.mThumbnail, mThumbnail)) {
+ return false;
+ }
+ for (int i = 0; i < mStripBytes.size(); i++) {
+ if (!Arrays.equals(data.mStripBytes.get(i), mStripBytes.get(i))) {
+ return false;
+ }
+ }
+ for (int i = 0; i < IfdId.TYPE_IFD_COUNT; i++) {
+ IfdData ifd1 = data.getIfdData(i);
+ IfdData ifd2 = getIfdData(i);
+ if (ifd1 != ifd2 && ifd1 != null && !ifd1.equals(ifd2)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+}
diff --git a/src/com/android/messaging/util/exif/ExifInterface.java b/src/com/android/messaging/util/exif/ExifInterface.java
new file mode 100644
index 0000000..b556748
--- /dev/null
+++ b/src/com/android/messaging/util/exif/ExifInterface.java
@@ -0,0 +1,2448 @@
+/*
+ * Copyright (C) 2013 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.messaging.util.exif;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.util.SparseIntArray;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.RandomAccessFile;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.FileChannel.MapMode;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.TimeZone;
+
+/**
+ * This class provides methods and constants for reading and writing jpeg file
+ * metadata. It contains a collection of ExifTags, and a collection of
+ * definitions for creating valid ExifTags. The collection of ExifTags can be
+ * updated by: reading new ones from a file, deleting or adding existing ones,
+ * or building new ExifTags from a tag definition. These ExifTags can be written
+ * to a valid jpeg image as exif metadata.
+ * <p>
+ * Each ExifTag has a tag ID (TID) and is stored in a specific image file
+ * directory (IFD) as specified by the exif standard. A tag definition can be
+ * looked up with a constant that is a combination of TID and IFD. This
+ * definition has information about the type, number of components, and valid
+ * IFDs for a tag.
+ *
+ * @see ExifTag
+ */
+public class ExifInterface {
+ public static final int TAG_NULL = -1;
+ public static final int IFD_NULL = -1;
+ public static final int DEFINITION_NULL = 0;
+
+ /**
+ * Tag constants for Jeita EXIF 2.2
+ */
+
+ // IFD 0
+ public static final int TAG_IMAGE_WIDTH =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0100);
+ public static final int TAG_IMAGE_LENGTH =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0101); // Image height
+ public static final int TAG_BITS_PER_SAMPLE =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0102);
+ public static final int TAG_COMPRESSION =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0103);
+ public static final int TAG_PHOTOMETRIC_INTERPRETATION =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0106);
+ public static final int TAG_IMAGE_DESCRIPTION =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x010E);
+ public static final int TAG_MAKE =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x010F);
+ public static final int TAG_MODEL =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0110);
+ public static final int TAG_STRIP_OFFSETS =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0111);
+ public static final int TAG_ORIENTATION =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0112);
+ public static final int TAG_SAMPLES_PER_PIXEL =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0115);
+ public static final int TAG_ROWS_PER_STRIP =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0116);
+ public static final int TAG_STRIP_BYTE_COUNTS =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0117);
+ public static final int TAG_X_RESOLUTION =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x011A);
+ public static final int TAG_Y_RESOLUTION =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x011B);
+ public static final int TAG_PLANAR_CONFIGURATION =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x011C);
+ public static final int TAG_RESOLUTION_UNIT =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0128);
+ public static final int TAG_TRANSFER_FUNCTION =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x012D);
+ public static final int TAG_SOFTWARE =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0131);
+ public static final int TAG_DATE_TIME =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0132);
+ public static final int TAG_ARTIST =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x013B);
+ public static final int TAG_WHITE_POINT =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x013E);
+ public static final int TAG_PRIMARY_CHROMATICITIES =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x013F);
+ public static final int TAG_Y_CB_CR_COEFFICIENTS =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0211);
+ public static final int TAG_Y_CB_CR_SUB_SAMPLING =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0212);
+ public static final int TAG_Y_CB_CR_POSITIONING =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0213);
+ public static final int TAG_REFERENCE_BLACK_WHITE =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x0214);
+ public static final int TAG_COPYRIGHT =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x8298);
+ public static final int TAG_EXIF_IFD =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x8769);
+ public static final int TAG_GPS_IFD =
+ defineTag(IfdId.TYPE_IFD_0, (short) 0x8825);
+ // IFD 1
+ public static final int TAG_JPEG_INTERCHANGE_FORMAT =
+ defineTag(IfdId.TYPE_IFD_1, (short) 0x0201);
+ public static final int TAG_JPEG_INTERCHANGE_FORMAT_LENGTH =
+ defineTag(IfdId.TYPE_IFD_1, (short) 0x0202);
+ // IFD Exif Tags
+ public static final int TAG_EXPOSURE_TIME =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x829A);
+ public static final int TAG_F_NUMBER =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x829D);
+ public static final int TAG_EXPOSURE_PROGRAM =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x8822);
+ public static final int TAG_SPECTRAL_SENSITIVITY =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x8824);
+ public static final int TAG_ISO_SPEED_RATINGS =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x8827);
+ public static final int TAG_OECF =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x8828);
+ public static final int TAG_EXIF_VERSION =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9000);
+ public static final int TAG_DATE_TIME_ORIGINAL =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9003);
+ public static final int TAG_DATE_TIME_DIGITIZED =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9004);
+ public static final int TAG_COMPONENTS_CONFIGURATION =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9101);
+ public static final int TAG_COMPRESSED_BITS_PER_PIXEL =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9102);
+ public static final int TAG_SHUTTER_SPEED_VALUE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9201);
+ public static final int TAG_APERTURE_VALUE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9202);
+ public static final int TAG_BRIGHTNESS_VALUE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9203);
+ public static final int TAG_EXPOSURE_BIAS_VALUE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9204);
+ public static final int TAG_MAX_APERTURE_VALUE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9205);
+ public static final int TAG_SUBJECT_DISTANCE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9206);
+ public static final int TAG_METERING_MODE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9207);
+ public static final int TAG_LIGHT_SOURCE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9208);
+ public static final int TAG_FLASH =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9209);
+ public static final int TAG_FOCAL_LENGTH =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x920A);
+ public static final int TAG_SUBJECT_AREA =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9214);
+ public static final int TAG_MAKER_NOTE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x927C);
+ public static final int TAG_USER_COMMENT =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9286);
+ public static final int TAG_SUB_SEC_TIME =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9290);
+ public static final int TAG_SUB_SEC_TIME_ORIGINAL =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9291);
+ public static final int TAG_SUB_SEC_TIME_DIGITIZED =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9292);
+ public static final int TAG_FLASHPIX_VERSION =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA000);
+ public static final int TAG_COLOR_SPACE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA001);
+ public static final int TAG_PIXEL_X_DIMENSION =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA002);
+ public static final int TAG_PIXEL_Y_DIMENSION =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA003);
+ public static final int TAG_RELATED_SOUND_FILE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA004);
+ public static final int TAG_INTEROPERABILITY_IFD =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA005);
+ public static final int TAG_FLASH_ENERGY =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA20B);
+ public static final int TAG_SPATIAL_FREQUENCY_RESPONSE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA20C);
+ public static final int TAG_FOCAL_PLANE_X_RESOLUTION =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA20E);
+ public static final int TAG_FOCAL_PLANE_Y_RESOLUTION =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA20F);
+ public static final int TAG_FOCAL_PLANE_RESOLUTION_UNIT =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA210);
+ public static final int TAG_SUBJECT_LOCATION =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA214);
+ public static final int TAG_EXPOSURE_INDEX =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA215);
+ public static final int TAG_SENSING_METHOD =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA217);
+ public static final int TAG_FILE_SOURCE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA300);
+ public static final int TAG_SCENE_TYPE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA301);
+ public static final int TAG_CFA_PATTERN =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA302);
+ public static final int TAG_CUSTOM_RENDERED =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA401);
+ public static final int TAG_EXPOSURE_MODE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA402);
+ public static final int TAG_WHITE_BALANCE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA403);
+ public static final int TAG_DIGITAL_ZOOM_RATIO =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA404);
+ public static final int TAG_FOCAL_LENGTH_IN_35_MM_FILE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA405);
+ public static final int TAG_SCENE_CAPTURE_TYPE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA406);
+ public static final int TAG_GAIN_CONTROL =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA407);
+ public static final int TAG_CONTRAST =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA408);
+ public static final int TAG_SATURATION =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA409);
+ public static final int TAG_SHARPNESS =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA40A);
+ public static final int TAG_DEVICE_SETTING_DESCRIPTION =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA40B);
+ public static final int TAG_SUBJECT_DISTANCE_RANGE =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA40C);
+ public static final int TAG_IMAGE_UNIQUE_ID =
+ defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA420);
+ // IFD GPS tags
+ public static final int TAG_GPS_VERSION_ID =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 0);
+ public static final int TAG_GPS_LATITUDE_REF =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 1);
+ public static final int TAG_GPS_LATITUDE =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 2);
+ public static final int TAG_GPS_LONGITUDE_REF =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 3);
+ public static final int TAG_GPS_LONGITUDE =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 4);
+ public static final int TAG_GPS_ALTITUDE_REF =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 5);
+ public static final int TAG_GPS_ALTITUDE =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 6);
+ public static final int TAG_GPS_TIME_STAMP =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 7);
+ public static final int TAG_GPS_SATTELLITES =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 8);
+ public static final int TAG_GPS_STATUS =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 9);
+ public static final int TAG_GPS_MEASURE_MODE =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 10);
+ public static final int TAG_GPS_DOP =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 11);
+ public static final int TAG_GPS_SPEED_REF =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 12);
+ public static final int TAG_GPS_SPEED =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 13);
+ public static final int TAG_GPS_TRACK_REF =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 14);
+ public static final int TAG_GPS_TRACK =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 15);
+ public static final int TAG_GPS_IMG_DIRECTION_REF =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 16);
+ public static final int TAG_GPS_IMG_DIRECTION =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 17);
+ public static final int TAG_GPS_MAP_DATUM =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 18);
+ public static final int TAG_GPS_DEST_LATITUDE_REF =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 19);
+ public static final int TAG_GPS_DEST_LATITUDE =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 20);
+ public static final int TAG_GPS_DEST_LONGITUDE_REF =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 21);
+ public static final int TAG_GPS_DEST_LONGITUDE =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 22);
+ public static final int TAG_GPS_DEST_BEARING_REF =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 23);
+ public static final int TAG_GPS_DEST_BEARING =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 24);
+ public static final int TAG_GPS_DEST_DISTANCE_REF =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 25);
+ public static final int TAG_GPS_DEST_DISTANCE =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 26);
+ public static final int TAG_GPS_PROCESSING_METHOD =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 27);
+ public static final int TAG_GPS_AREA_INFORMATION =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 28);
+ public static final int TAG_GPS_DATE_STAMP =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 29);
+ public static final int TAG_GPS_DIFFERENTIAL =
+ defineTag(IfdId.TYPE_IFD_GPS, (short) 30);
+ // IFD Interoperability tags
+ public static final int TAG_INTEROPERABILITY_INDEX =
+ defineTag(IfdId.TYPE_IFD_INTEROPERABILITY, (short) 1);
+
+ /**
+ * Tags that contain offset markers. These are included in the banned
+ * defines.
+ */
+ private static HashSet<Short> sOffsetTags = new HashSet<Short>();
+ static {
+ sOffsetTags.add(getTrueTagKey(TAG_GPS_IFD));
+ sOffsetTags.add(getTrueTagKey(TAG_EXIF_IFD));
+ sOffsetTags.add(getTrueTagKey(TAG_JPEG_INTERCHANGE_FORMAT));
+ sOffsetTags.add(getTrueTagKey(TAG_INTEROPERABILITY_IFD));
+ sOffsetTags.add(getTrueTagKey(TAG_STRIP_OFFSETS));
+ }
+
+ /**
+ * Tags with definitions that cannot be overridden (banned defines).
+ */
+ protected static HashSet<Short> sBannedDefines = new HashSet<Short>(sOffsetTags);
+ static {
+ sBannedDefines.add(getTrueTagKey(TAG_NULL));
+ sBannedDefines.add(getTrueTagKey(TAG_JPEG_INTERCHANGE_FORMAT_LENGTH));
+ sBannedDefines.add(getTrueTagKey(TAG_STRIP_BYTE_COUNTS));
+ }
+
+ /**
+ * Returns the constant representing a tag with a given TID and default IFD.
+ */
+ public static int defineTag(int ifdId, short tagId) {
+ return (tagId & 0x0000ffff) | (ifdId << 16);
+ }
+
+ /**
+ * Returns the TID for a tag constant.
+ */
+ public static short getTrueTagKey(int tag) {
+ // Truncate
+ return (short) tag;
+ }
+
+ /**
+ * Returns the default IFD for a tag constant.
+ */
+ public static int getTrueIfd(int tag) {
+ return tag >>> 16;
+ }
+
+ /**
+ * Constants for {@link TAG_ORIENTATION}. They can be interpreted as
+ * follows:
+ * <ul>
+ * <li>TOP_LEFT is the normal orientation.</li>
+ * <li>TOP_RIGHT is a left-right mirror.</li>
+ * <li>BOTTOM_LEFT is a 180 degree rotation.</li>
+ * <li>BOTTOM_RIGHT is a top-bottom mirror.</li>
+ * <li>LEFT_TOP is mirrored about the top-left<->bottom-right axis.</li>
+ * <li>RIGHT_TOP is a 90 degree clockwise rotation.</li>
+ * <li>LEFT_BOTTOM is mirrored about the top-right<->bottom-left axis.</li>
+ * <li>RIGHT_BOTTOM is a 270 degree clockwise rotation.</li>
+ * </ul>
+ */
+ public static interface Orientation {
+ public static final short TOP_LEFT = 1;
+ public static final short TOP_RIGHT = 2;
+ public static final short BOTTOM_LEFT = 3;
+ public static final short BOTTOM_RIGHT = 4;
+ public static final short LEFT_TOP = 5;
+ public static final short RIGHT_TOP = 6;
+ public static final short LEFT_BOTTOM = 7;
+ public static final short RIGHT_BOTTOM = 8;
+ }
+
+ /**
+ * Constants for {@link TAG_Y_CB_CR_POSITIONING}
+ */
+ public static interface YCbCrPositioning {
+ public static final short CENTERED = 1;
+ public static final short CO_SITED = 2;
+ }
+
+ /**
+ * Constants for {@link TAG_COMPRESSION}
+ */
+ public static interface Compression {
+ public static final short UNCOMPRESSION = 1;
+ public static final short JPEG = 6;
+ }
+
+ /**
+ * Constants for {@link TAG_RESOLUTION_UNIT}
+ */
+ public static interface ResolutionUnit {
+ public static final short INCHES = 2;
+ public static final short CENTIMETERS = 3;
+ }
+
+ /**
+ * Constants for {@link TAG_PHOTOMETRIC_INTERPRETATION}
+ */
+ public static interface PhotometricInterpretation {
+ public static final short RGB = 2;
+ public static final short YCBCR = 6;
+ }
+
+ /**
+ * Constants for {@link TAG_PLANAR_CONFIGURATION}
+ */
+ public static interface PlanarConfiguration {
+ public static final short CHUNKY = 1;
+ public static final short PLANAR = 2;
+ }
+
+ /**
+ * Constants for {@link TAG_EXPOSURE_PROGRAM}
+ */
+ public static interface ExposureProgram {
+ public static final short NOT_DEFINED = 0;
+ public static final short MANUAL = 1;
+ public static final short NORMAL_PROGRAM = 2;
+ public static final short APERTURE_PRIORITY = 3;
+ public static final short SHUTTER_PRIORITY = 4;
+ public static final short CREATIVE_PROGRAM = 5;
+ public static final short ACTION_PROGRAM = 6;
+ public static final short PROTRAIT_MODE = 7;
+ public static final short LANDSCAPE_MODE = 8;
+ }
+
+ /**
+ * Constants for {@link TAG_METERING_MODE}
+ */
+ public static interface MeteringMode {
+ public static final short UNKNOWN = 0;
+ public static final short AVERAGE = 1;
+ public static final short CENTER_WEIGHTED_AVERAGE = 2;
+ public static final short SPOT = 3;
+ public static final short MULTISPOT = 4;
+ public static final short PATTERN = 5;
+ public static final short PARTAIL = 6;
+ public static final short OTHER = 255;
+ }
+
+ /**
+ * Constants for {@link TAG_FLASH} As the definition in Jeita EXIF 2.2
+ * standard, we can treat this constant as bitwise flag.
+ * <p>
+ * e.g.
+ * <p>
+ * short flash = FIRED | RETURN_STROBE_RETURN_LIGHT_DETECTED |
+ * MODE_AUTO_MODE
+ */
+ public static interface Flash {
+ // LSB
+ public static final short DID_NOT_FIRED = 0;
+ public static final short FIRED = 1;
+ // 1st~2nd bits
+ public static final short RETURN_NO_STROBE_RETURN_DETECTION_FUNCTION = 0 << 1;
+ public static final short RETURN_STROBE_RETURN_LIGHT_NOT_DETECTED = 2 << 1;
+ public static final short RETURN_STROBE_RETURN_LIGHT_DETECTED = 3 << 1;
+ // 3rd~4th bits
+ public static final short MODE_UNKNOWN = 0 << 3;
+ public static final short MODE_COMPULSORY_FLASH_FIRING = 1 << 3;
+ public static final short MODE_COMPULSORY_FLASH_SUPPRESSION = 2 << 3;
+ public static final short MODE_AUTO_MODE = 3 << 3;
+ // 5th bit
+ public static final short FUNCTION_PRESENT = 0 << 5;
+ public static final short FUNCTION_NO_FUNCTION = 1 << 5;
+ // 6th bit
+ public static final short RED_EYE_REDUCTION_NO_OR_UNKNOWN = 0 << 6;
+ public static final short RED_EYE_REDUCTION_SUPPORT = 1 << 6;
+ }
+
+ /**
+ * Constants for {@link TAG_COLOR_SPACE}
+ */
+ public static interface ColorSpace {
+ public static final short SRGB = 1;
+ public static final short UNCALIBRATED = (short) 0xFFFF;
+ }
+
+ /**
+ * Constants for {@link TAG_EXPOSURE_MODE}
+ */
+ public static interface ExposureMode {
+ public static final short AUTO_EXPOSURE = 0;
+ public static final short MANUAL_EXPOSURE = 1;
+ public static final short AUTO_BRACKET = 2;
+ }
+
+ /**
+ * Constants for {@link TAG_WHITE_BALANCE}
+ */
+ public static interface WhiteBalance {
+ public static final short AUTO = 0;
+ public static final short MANUAL = 1;
+ }
+
+ /**
+ * Constants for {@link TAG_SCENE_CAPTURE_TYPE}
+ */
+ public static interface SceneCapture {
+ public static final short STANDARD = 0;
+ public static final short LANDSCAPE = 1;
+ public static final short PROTRAIT = 2;
+ public static final short NIGHT_SCENE = 3;
+ }
+
+ /**
+ * Constants for {@link TAG_COMPONENTS_CONFIGURATION}
+ */
+ public static interface ComponentsConfiguration {
+ public static final short NOT_EXIST = 0;
+ public static final short Y = 1;
+ public static final short CB = 2;
+ public static final short CR = 3;
+ public static final short R = 4;
+ public static final short G = 5;
+ public static final short B = 6;
+ }
+
+ /**
+ * Constants for {@link TAG_LIGHT_SOURCE}
+ */
+ public static interface LightSource {
+ public static final short UNKNOWN = 0;
+ public static final short DAYLIGHT = 1;
+ public static final short FLUORESCENT = 2;
+ public static final short TUNGSTEN = 3;
+ public static final short FLASH = 4;
+ public static final short FINE_WEATHER = 9;
+ public static final short CLOUDY_WEATHER = 10;
+ public static final short SHADE = 11;
+ public static final short DAYLIGHT_FLUORESCENT = 12;
+ public static final short DAY_WHITE_FLUORESCENT = 13;
+ public static final short COOL_WHITE_FLUORESCENT = 14;
+ public static final short WHITE_FLUORESCENT = 15;
+ public static final short STANDARD_LIGHT_A = 17;
+ public static final short STANDARD_LIGHT_B = 18;
+ public static final short STANDARD_LIGHT_C = 19;
+ public static final short D55 = 20;
+ public static final short D65 = 21;
+ public static final short D75 = 22;
+ public static final short D50 = 23;
+ public static final short ISO_STUDIO_TUNGSTEN = 24;
+ public static final short OTHER = 255;
+ }
+
+ /**
+ * Constants for {@link TAG_SENSING_METHOD}
+ */
+ public static interface SensingMethod {
+ public static final short NOT_DEFINED = 1;
+ public static final short ONE_CHIP_COLOR = 2;
+ public static final short TWO_CHIP_COLOR = 3;
+ public static final short THREE_CHIP_COLOR = 4;
+ public static final short COLOR_SEQUENTIAL_AREA = 5;
+ public static final short TRILINEAR = 7;
+ public static final short COLOR_SEQUENTIAL_LINEAR = 8;
+ }
+
+ /**
+ * Constants for {@link TAG_FILE_SOURCE}
+ */
+ public static interface FileSource {
+ public static final short DSC = 3;
+ }
+
+ /**
+ * Constants for {@link TAG_SCENE_TYPE}
+ */
+ public static interface SceneType {
+ public static final short DIRECT_PHOTOGRAPHED = 1;
+ }
+
+ /**
+ * Constants for {@link TAG_GAIN_CONTROL}
+ */
+ public static interface GainControl {
+ public static final short NONE = 0;
+ public static final short LOW_UP = 1;
+ public static final short HIGH_UP = 2;
+ public static final short LOW_DOWN = 3;
+ public static final short HIGH_DOWN = 4;
+ }
+
+ /**
+ * Constants for {@link TAG_CONTRAST}
+ */
+ public static interface Contrast {
+ public static final short NORMAL = 0;
+ public static final short SOFT = 1;
+ public static final short HARD = 2;
+ }
+
+ /**
+ * Constants for {@link TAG_SATURATION}
+ */
+ public static interface Saturation {
+ public static final short NORMAL = 0;
+ public static final short LOW = 1;
+ public static final short HIGH = 2;
+ }
+
+ /**
+ * Constants for {@link TAG_SHARPNESS}
+ */
+ public static interface Sharpness {
+ public static final short NORMAL = 0;
+ public static final short SOFT = 1;
+ public static final short HARD = 2;
+ }
+
+ /**
+ * Constants for {@link TAG_SUBJECT_DISTANCE}
+ */
+ public static interface SubjectDistance {
+ public static final short UNKNOWN = 0;
+ public static final short MACRO = 1;
+ public static final short CLOSE_VIEW = 2;
+ public static final short DISTANT_VIEW = 3;
+ }
+
+ /**
+ * Constants for {@link TAG_GPS_LATITUDE_REF},
+ * {@link TAG_GPS_DEST_LATITUDE_REF}
+ */
+ public static interface GpsLatitudeRef {
+ public static final String NORTH = "N";
+ public static final String SOUTH = "S";
+ }
+
+ /**
+ * Constants for {@link TAG_GPS_LONGITUDE_REF},
+ * {@link TAG_GPS_DEST_LONGITUDE_REF}
+ */
+ public static interface GpsLongitudeRef {
+ public static final String EAST = "E";
+ public static final String WEST = "W";
+ }
+
+ /**
+ * Constants for {@link TAG_GPS_ALTITUDE_REF}
+ */
+ public static interface GpsAltitudeRef {
+ public static final short SEA_LEVEL = 0;
+ public static final short SEA_LEVEL_NEGATIVE = 1;
+ }
+
+ /**
+ * Constants for {@link TAG_GPS_STATUS}
+ */
+ public static interface GpsStatus {
+ public static final String IN_PROGRESS = "A";
+ public static final String INTEROPERABILITY = "V";
+ }
+
+ /**
+ * Constants for {@link TAG_GPS_MEASURE_MODE}
+ */
+ public static interface GpsMeasureMode {
+ public static final String MODE_2_DIMENSIONAL = "2";
+ public static final String MODE_3_DIMENSIONAL = "3";
+ }
+
+ /**
+ * Constants for {@link TAG_GPS_SPEED_REF},
+ * {@link TAG_GPS_DEST_DISTANCE_REF}
+ */
+ public static interface GpsSpeedRef {
+ public static final String KILOMETERS = "K";
+ public static final String MILES = "M";
+ public static final String KNOTS = "N";
+ }
+
+ /**
+ * Constants for {@link TAG_GPS_TRACK_REF},
+ * {@link TAG_GPS_IMG_DIRECTION_REF}, {@link TAG_GPS_DEST_BEARING_REF}
+ */
+ public static interface GpsTrackRef {
+ public static final String TRUE_DIRECTION = "T";
+ public static final String MAGNETIC_DIRECTION = "M";
+ }
+
+ /**
+ * Constants for {@link TAG_GPS_DIFFERENTIAL}
+ */
+ public static interface GpsDifferential {
+ public static final short WITHOUT_DIFFERENTIAL_CORRECTION = 0;
+ public static final short DIFFERENTIAL_CORRECTION_APPLIED = 1;
+ }
+
+ private static final String NULL_ARGUMENT_STRING = "Argument is null";
+ private ExifData mData = new ExifData(DEFAULT_BYTE_ORDER);
+ public static final ByteOrder DEFAULT_BYTE_ORDER = ByteOrder.BIG_ENDIAN;
+
+ public ExifInterface() {
+ mGPSDateStampFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+ }
+
+ /**
+ * Reads the exif tags from a byte array, clearing this ExifInterface
+ * object's existing exif tags.
+ *
+ * @param jpeg a byte array containing a jpeg compressed image.
+ * @throws java.io.IOException
+ */
+ public void readExif(byte[] jpeg) throws IOException {
+ readExif(new ByteArrayInputStream(jpeg));
+ }
+
+ /**
+ * Reads the exif tags from an InputStream, clearing this ExifInterface
+ * object's existing exif tags.
+ *
+ * @param inStream an InputStream containing a jpeg compressed image.
+ * @throws java.io.IOException
+ */
+ public void readExif(InputStream inStream) throws IOException {
+ if (inStream == null) {
+ throw new IllegalArgumentException(NULL_ARGUMENT_STRING);
+ }
+ ExifData d = null;
+ try {
+ d = new ExifReader(this).read(inStream);
+ } catch (ExifInvalidFormatException e) {
+ throw new IOException("Invalid exif format : " + e);
+ }
+ mData = d;
+ }
+
+ /**
+ * Reads the exif tags from a file, clearing this ExifInterface object's
+ * existing exif tags.
+ *
+ * @param inFileName a string representing the filepath to jpeg file.
+ * @throws java.io.FileNotFoundException
+ * @throws java.io.IOException
+ */
+ public void readExif(String inFileName) throws FileNotFoundException, IOException {
+ if (inFileName == null) {
+ throw new IllegalArgumentException(NULL_ARGUMENT_STRING);
+ }
+ InputStream is = null;
+ try {
+ is = new BufferedInputStream(new FileInputStream(inFileName));
+ readExif(is);
+ } catch (IOException e) {
+ closeSilently(is);
+ throw e;
+ }
+ is.close();
+ }
+
+ /**
+ * Sets the exif tags, clearing this ExifInterface object's existing exif
+ * tags.
+ *
+ * @param tags a collection of exif tags to set.
+ */
+ public void setExif(Collection<ExifTag> tags) {
+ clearExif();
+ setTags(tags);
+ }
+
+ /**
+ * Clears this ExifInterface object's existing exif tags.
+ */
+ public void clearExif() {
+ mData = new ExifData(DEFAULT_BYTE_ORDER);
+ }
+
+ /**
+ * Writes the tags from this ExifInterface object into a jpeg image,
+ * removing prior exif tags.
+ *
+ * @param jpeg a byte array containing a jpeg compressed image.
+ * @param exifOutStream an OutputStream to which the jpeg image with added
+ * exif tags will be written.
+ * @throws java.io.IOException
+ */
+ public void writeExif(byte[] jpeg, OutputStream exifOutStream) throws IOException {
+ if (jpeg == null || exifOutStream == null) {
+ throw new IllegalArgumentException(NULL_ARGUMENT_STRING);
+ }
+ OutputStream s = getExifWriterStream(exifOutStream);
+ s.write(jpeg, 0, jpeg.length);
+ s.flush();
+ }
+
+ /**
+ * Writes the tags from this ExifInterface object into a jpeg compressed
+ * bitmap, removing prior exif tags.
+ *
+ * @param bmap a bitmap to compress and write exif into.
+ * @param exifOutStream the OutputStream to which the jpeg image with added
+ * exif tags will be written.
+ * @throws java.io.IOException
+ */
+ public void writeExif(Bitmap bmap, OutputStream exifOutStream) throws IOException {
+ if (bmap == null || exifOutStream == null) {
+ throw new IllegalArgumentException(NULL_ARGUMENT_STRING);
+ }
+ OutputStream s = getExifWriterStream(exifOutStream);
+ bmap.compress(Bitmap.CompressFormat.JPEG, 90, s);
+ s.flush();
+ }
+
+ /**
+ * Writes the tags from this ExifInterface object into a jpeg stream,
+ * removing prior exif tags.
+ *
+ * @param jpegStream an InputStream containing a jpeg compressed image.
+ * @param exifOutStream an OutputStream to which the jpeg image with added
+ * exif tags will be written.
+ * @throws java.io.IOException
+ */
+ public void writeExif(InputStream jpegStream, OutputStream exifOutStream) throws IOException {
+ if (jpegStream == null || exifOutStream == null) {
+ throw new IllegalArgumentException(NULL_ARGUMENT_STRING);
+ }
+ OutputStream s = getExifWriterStream(exifOutStream);
+ doExifStreamIO(jpegStream, s);
+ s.flush();
+ }
+
+ /**
+ * Writes the tags from this ExifInterface object into a jpeg image,
+ * removing prior exif tags.
+ *
+ * @param jpeg a byte array containing a jpeg compressed image.
+ * @param exifOutFileName a String containing the filepath to which the jpeg
+ * image with added exif tags will be written.
+ * @throws java.io.FileNotFoundException
+ * @throws java.io.IOException
+ */
+ public void writeExif(byte[] jpeg, String exifOutFileName) throws FileNotFoundException,
+ IOException {
+ if (jpeg == null || exifOutFileName == null) {
+ throw new IllegalArgumentException(NULL_ARGUMENT_STRING);
+ }
+ OutputStream s = null;
+ try {
+ s = getExifWriterStream(exifOutFileName);
+ s.write(jpeg, 0, jpeg.length);
+ s.flush();
+ } catch (IOException e) {
+ closeSilently(s);
+ throw e;
+ }
+ s.close();
+ }
+
+ /**
+ * Writes the tags from this ExifInterface object into a jpeg compressed
+ * bitmap, removing prior exif tags.
+ *
+ * @param bmap a bitmap to compress and write exif into.
+ * @param exifOutFileName a String containing the filepath to which the jpeg
+ * image with added exif tags will be written.
+ * @throws java.io.FileNotFoundException
+ * @throws java.io.IOException
+ */
+ public void writeExif(Bitmap bmap, String exifOutFileName) throws FileNotFoundException,
+ IOException {
+ if (bmap == null || exifOutFileName == null) {
+ throw new IllegalArgumentException(NULL_ARGUMENT_STRING);
+ }
+ OutputStream s = null;
+ try {
+ s = getExifWriterStream(exifOutFileName);
+ bmap.compress(Bitmap.CompressFormat.JPEG, 90, s);
+ s.flush();
+ } catch (IOException e) {
+ closeSilently(s);
+ throw e;
+ }
+ s.close();
+ }
+
+ /**
+ * Writes the tags from this ExifInterface object into a jpeg stream,
+ * removing prior exif tags.
+ *
+ * @param jpegStream an InputStream containing a jpeg compressed image.
+ * @param exifOutFileName a String containing the filepath to which the jpeg
+ * image with added exif tags will be written.
+ * @throws java.io.FileNotFoundException
+ * @throws java.io.IOException
+ */
+ public void writeExif(InputStream jpegStream, String exifOutFileName)
+ throws FileNotFoundException, IOException {
+ if (jpegStream == null || exifOutFileName == null) {
+ throw new IllegalArgumentException(NULL_ARGUMENT_STRING);
+ }
+ OutputStream s = null;
+ try {
+ s = getExifWriterStream(exifOutFileName);
+ doExifStreamIO(jpegStream, s);
+ s.flush();
+ } catch (IOException e) {
+ closeSilently(s);
+ throw e;
+ }
+ s.close();
+ }
+
+ /**
+ * Writes the tags from this ExifInterface object into a jpeg file, removing
+ * prior exif tags.
+ *
+ * @param jpegFileName a String containing the filepath for a jpeg file.
+ * @param exifOutFileName a String containing the filepath to which the jpeg
+ * image with added exif tags will be written.
+ * @throws java.io.FileNotFoundException
+ * @throws java.io.IOException
+ */
+ public void writeExif(String jpegFileName, String exifOutFileName)
+ throws FileNotFoundException, IOException {
+ if (jpegFileName == null || exifOutFileName == null) {
+ throw new IllegalArgumentException(NULL_ARGUMENT_STRING);
+ }
+ InputStream is = null;
+ try {
+ is = new FileInputStream(jpegFileName);
+ writeExif(is, exifOutFileName);
+ } catch (IOException e) {
+ closeSilently(is);
+ throw e;
+ }
+ is.close();
+ }
+
+ /**
+ * Wraps an OutputStream object with an ExifOutputStream. Exif tags in this
+ * ExifInterface object will be added to a jpeg image written to this
+ * stream, removing prior exif tags. Other methods of this ExifInterface
+ * object should not be called until the returned OutputStream has been
+ * closed.
+ *
+ * @param outStream an OutputStream to wrap.
+ * @return an OutputStream that wraps the outStream parameter, and adds exif
+ * metadata. A jpeg image should be written to this stream.
+ */
+ public OutputStream getExifWriterStream(OutputStream outStream) {
+ if (outStream == null) {
+ throw new IllegalArgumentException(NULL_ARGUMENT_STRING);
+ }
+ ExifOutputStream eos = new ExifOutputStream(outStream, this);
+ eos.setExifData(mData);
+ return eos;
+ }
+
+ /**
+ * Returns an OutputStream object that writes to a file. Exif tags in this
+ * ExifInterface object will be added to a jpeg image written to this
+ * stream, removing prior exif tags. Other methods of this ExifInterface
+ * object should not be called until the returned OutputStream has been
+ * closed.
+ *
+ * @param exifOutFileName an String containing a filepath for a jpeg file.
+ * @return an OutputStream that writes to the exifOutFileName file, and adds
+ * exif metadata. A jpeg image should be written to this stream.
+ * @throws java.io.FileNotFoundException
+ */
+ public OutputStream getExifWriterStream(String exifOutFileName) throws FileNotFoundException {
+ if (exifOutFileName == null) {
+ throw new IllegalArgumentException(NULL_ARGUMENT_STRING);
+ }
+ OutputStream out = null;
+ try {
+ out = new FileOutputStream(exifOutFileName);
+ } catch (FileNotFoundException e) {
+ closeSilently(out);
+ throw e;
+ }
+ return getExifWriterStream(out);
+ }
+
+ /**
+ * Attempts to do an in-place rewrite the exif metadata in a file for the
+ * given tags. If tags do not exist or do not have the same size as the
+ * existing exif tags, this method will fail.
+ *
+ * @param filename a String containing a filepath for a jpeg file with exif
+ * tags to rewrite.
+ * @param tags tags that will be written into the jpeg file over existing
+ * tags if possible.
+ * @return true if success, false if could not overwrite. If false, no
+ * changes are made to the file.
+ * @throws java.io.FileNotFoundException
+ * @throws java.io.IOException
+ */
+ public boolean rewriteExif(String filename, Collection<ExifTag> tags)
+ throws FileNotFoundException, IOException {
+ RandomAccessFile file = null;
+ InputStream is = null;
+ boolean ret;
+ try {
+ File temp = new File(filename);
+ is = new BufferedInputStream(new FileInputStream(temp));
+
+ // Parse beginning of APP1 in exif to find size of exif header.
+ ExifParser parser = null;
+ try {
+ parser = ExifParser.parse(is, this);
+ } catch (ExifInvalidFormatException e) {
+ throw new IOException("Invalid exif format : ", e);
+ }
+ long exifSize = parser.getOffsetToExifEndFromSOF();
+
+ // Free up resources
+ is.close();
+ is = null;
+
+ // Open file for memory mapping.
+ file = new RandomAccessFile(temp, "rw");
+ long fileLength = file.length();
+ if (fileLength < exifSize) {
+ throw new IOException("Filesize changed during operation");
+ }
+
+ // Map only exif header into memory.
+ ByteBuffer buf = file.getChannel().map(MapMode.READ_WRITE, 0, exifSize);
+
+ // Attempt to overwrite tag values without changing lengths (avoids
+ // file copy).
+ ret = rewriteExif(buf, tags);
+ } catch (IOException e) {
+ closeSilently(file);
+ throw e;
+ } finally {
+ closeSilently(is);
+ }
+ file.close();
+ return ret;
+ }
+
+ /**
+ * Attempts to do an in-place rewrite the exif metadata in a ByteBuffer for
+ * the given tags. If tags do not exist or do not have the same size as the
+ * existing exif tags, this method will fail.
+ *
+ * @param buf a ByteBuffer containing a jpeg file with existing exif tags to
+ * rewrite.
+ * @param tags tags that will be written into the jpeg ByteBuffer over
+ * existing tags if possible.
+ * @return true if success, false if could not overwrite. If false, no
+ * changes are made to the ByteBuffer.
+ * @throws java.io.IOException
+ */
+ public boolean rewriteExif(ByteBuffer buf, Collection<ExifTag> tags) throws IOException {
+ ExifModifier mod = null;
+ try {
+ mod = new ExifModifier(buf, this);
+ for (ExifTag t : tags) {
+ mod.modifyTag(t);
+ }
+ return mod.commit();
+ } catch (ExifInvalidFormatException e) {
+ throw new IOException("Invalid exif format : " + e);
+ }
+ }
+
+ /**
+ * Attempts to do an in-place rewrite of the exif metadata. If this fails,
+ * fall back to overwriting file. This preserves tags that are not being
+ * rewritten.
+ *
+ * @param filename a String containing a filepath for a jpeg file.
+ * @param tags tags that will be written into the jpeg file over existing
+ * tags if possible.
+ * @throws java.io.FileNotFoundException
+ * @throws java.io.IOException
+ * @see #rewriteExif
+ */
+ public void forceRewriteExif(String filename, Collection<ExifTag> tags)
+ throws FileNotFoundException,
+ IOException {
+ // Attempt in-place write
+ if (!rewriteExif(filename, tags)) {
+ // Fall back to doing a copy
+ ExifData tempData = mData;
+ mData = new ExifData(DEFAULT_BYTE_ORDER);
+ FileInputStream is = null;
+ ByteArrayOutputStream bytes = null;
+ try {
+ is = new FileInputStream(filename);
+ bytes = new ByteArrayOutputStream();
+ doExifStreamIO(is, bytes);
+ byte[] imageBytes = bytes.toByteArray();
+ readExif(imageBytes);
+ setTags(tags);
+ writeExif(imageBytes, filename);
+ } catch (IOException e) {
+ closeSilently(is);
+ throw e;
+ } finally {
+ is.close();
+ // Prevent clobbering of mData
+ mData = tempData;
+ }
+ }
+ }
+
+ /**
+ * Attempts to do an in-place rewrite of the exif metadata using the tags in
+ * this ExifInterface object. If this fails, fall back to overwriting file.
+ * This preserves tags that are not being rewritten.
+ *
+ * @param filename a String containing a filepath for a jpeg file.
+ * @throws java.io.FileNotFoundException
+ * @throws java.io.IOException
+ * @see #rewriteExif
+ */
+ public void forceRewriteExif(String filename) throws FileNotFoundException, IOException {
+ forceRewriteExif(filename, getAllTags());
+ }
+
+ /**
+ * Get the exif tags in this ExifInterface object or null if none exist.
+ *
+ * @return a List of {@link ExifTag}s.
+ */
+ public List<ExifTag> getAllTags() {
+ return mData.getAllTags();
+ }
+
+ /**
+ * Returns a list of ExifTags that share a TID (which can be obtained by
+ * calling {@link #getTrueTagKey} on a defined tag constant) or null if none
+ * exist.
+ *
+ * @param tagId a TID as defined in the exif standard (or with
+ * {@link #defineTag}).
+ * @return a List of {@link ExifTag}s.
+ */
+ public List<ExifTag> getTagsForTagId(short tagId) {
+ return mData.getAllTagsForTagId(tagId);
+ }
+
+ /**
+ * Returns a list of ExifTags that share an IFD (which can be obtained by
+ * calling {@link #getTrueIFD} on a defined tag constant) or null if none
+ * exist.
+ *
+ * @param ifdId an IFD as defined in the exif standard (or with
+ * {@link #defineTag}).
+ * @return a List of {@link ExifTag}s.
+ */
+ public List<ExifTag> getTagsForIfdId(int ifdId) {
+ return mData.getAllTagsForIfd(ifdId);
+ }
+
+ /**
+ * Gets an ExifTag for an IFD other than the tag's default.
+ *
+ * @see #getTag
+ */
+ public ExifTag getTag(int tagId, int ifdId) {
+ if (!ExifTag.isValidIfd(ifdId)) {
+ return null;
+ }
+ return mData.getTag(getTrueTagKey(tagId), ifdId);
+ }
+
+ /**
+ * Returns the ExifTag in that tag's default IFD for a defined tag constant
+ * or null if none exists.
+ *
+ * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @return an {@link ExifTag} or null if none exists.
+ */
+ public ExifTag getTag(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return getTag(tagId, ifdId);
+ }
+
+ /**
+ * Gets a tag value for an IFD other than the tag's default.
+ *
+ * @see #getTagValue
+ */
+ public Object getTagValue(int tagId, int ifdId) {
+ ExifTag t = getTag(tagId, ifdId);
+ return (t == null) ? null : t.getValue();
+ }
+
+ /**
+ * Returns the value of the ExifTag in that tag's default IFD for a defined
+ * tag constant or null if none exists or the value could not be cast into
+ * the return type.
+ *
+ * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @return the value of the ExifTag or null if none exists.
+ */
+ public Object getTagValue(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return getTagValue(tagId, ifdId);
+ }
+
+ /*
+ * Getter methods that are similar to getTagValue. Null is returned if the
+ * tag value cannot be cast into the return type.
+ */
+
+ /**
+ * @see #getTagValue
+ */
+ public String getTagStringValue(int tagId, int ifdId) {
+ ExifTag t = getTag(tagId, ifdId);
+ if (t == null) {
+ return null;
+ }
+ return t.getValueAsString();
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public String getTagStringValue(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return getTagStringValue(tagId, ifdId);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public Long getTagLongValue(int tagId, int ifdId) {
+ long[] l = getTagLongValues(tagId, ifdId);
+ if (l == null || l.length <= 0) {
+ return null;
+ }
+ return new Long(l[0]);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public Long getTagLongValue(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return getTagLongValue(tagId, ifdId);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public Integer getTagIntValue(int tagId, int ifdId) {
+ int[] l = getTagIntValues(tagId, ifdId);
+ if (l == null || l.length <= 0) {
+ return null;
+ }
+ return new Integer(l[0]);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public Integer getTagIntValue(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return getTagIntValue(tagId, ifdId);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public Byte getTagByteValue(int tagId, int ifdId) {
+ byte[] l = getTagByteValues(tagId, ifdId);
+ if (l == null || l.length <= 0) {
+ return null;
+ }
+ return new Byte(l[0]);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public Byte getTagByteValue(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return getTagByteValue(tagId, ifdId);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public Rational getTagRationalValue(int tagId, int ifdId) {
+ Rational[] l = getTagRationalValues(tagId, ifdId);
+ if (l == null || l.length == 0) {
+ return null;
+ }
+ return new Rational(l[0]);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public Rational getTagRationalValue(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return getTagRationalValue(tagId, ifdId);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public long[] getTagLongValues(int tagId, int ifdId) {
+ ExifTag t = getTag(tagId, ifdId);
+ if (t == null) {
+ return null;
+ }
+ return t.getValueAsLongs();
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public long[] getTagLongValues(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return getTagLongValues(tagId, ifdId);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public int[] getTagIntValues(int tagId, int ifdId) {
+ ExifTag t = getTag(tagId, ifdId);
+ if (t == null) {
+ return null;
+ }
+ return t.getValueAsInts();
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public int[] getTagIntValues(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return getTagIntValues(tagId, ifdId);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public byte[] getTagByteValues(int tagId, int ifdId) {
+ ExifTag t = getTag(tagId, ifdId);
+ if (t == null) {
+ return null;
+ }
+ return t.getValueAsBytes();
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public byte[] getTagByteValues(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return getTagByteValues(tagId, ifdId);
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public Rational[] getTagRationalValues(int tagId, int ifdId) {
+ ExifTag t = getTag(tagId, ifdId);
+ if (t == null) {
+ return null;
+ }
+ return t.getValueAsRationals();
+ }
+
+ /**
+ * @see #getTagValue
+ */
+ public Rational[] getTagRationalValues(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return getTagRationalValues(tagId, ifdId);
+ }
+
+ /**
+ * Checks whether a tag has a defined number of elements.
+ *
+ * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @return true if the tag has a defined number of elements.
+ */
+ public boolean isTagCountDefined(int tagId) {
+ int info = getTagInfo().get(tagId);
+ // No value in info can be zero, as all tags have a non-zero type
+ if (info == 0) {
+ return false;
+ }
+ return getComponentCountFromInfo(info) != ExifTag.SIZE_UNDEFINED;
+ }
+
+ /**
+ * Gets the defined number of elements for a tag.
+ *
+ * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @return the number of elements or {@link ExifTag#SIZE_UNDEFINED} if the
+ * tag or the number of elements is not defined.
+ */
+ public int getDefinedTagCount(int tagId) {
+ int info = getTagInfo().get(tagId);
+ if (info == 0) {
+ return ExifTag.SIZE_UNDEFINED;
+ }
+ return getComponentCountFromInfo(info);
+ }
+
+ /**
+ * Gets the number of elements for an ExifTag in a given IFD.
+ *
+ * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @param ifdId the IFD containing the ExifTag to check.
+ * @return the number of elements in the ExifTag, if the tag's size is
+ * undefined this will return the actual number of elements that is
+ * in the ExifTag's value.
+ */
+ public int getActualTagCount(int tagId, int ifdId) {
+ ExifTag t = getTag(tagId, ifdId);
+ if (t == null) {
+ return 0;
+ }
+ return t.getComponentCount();
+ }
+
+ /**
+ * Gets the default IFD for a tag.
+ *
+ * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @return the default IFD for a tag definition or {@link #IFD_NULL} if no
+ * definition exists.
+ */
+ public int getDefinedTagDefaultIfd(int tagId) {
+ int info = getTagInfo().get(tagId);
+ if (info == DEFINITION_NULL) {
+ return IFD_NULL;
+ }
+ return getTrueIfd(tagId);
+ }
+
+ /**
+ * Gets the defined type for a tag.
+ *
+ * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @return the type.
+ * @see ExifTag#getDataType()
+ */
+ public short getDefinedTagType(int tagId) {
+ int info = getTagInfo().get(tagId);
+ if (info == 0) {
+ return -1;
+ }
+ return getTypeFromInfo(info);
+ }
+
+ /**
+ * Returns true if tag TID is one of the following: {@link TAG_EXIF_IFD},
+ * {@link TAG_GPS_IFD}, {@link TAG_JPEG_INTERCHANGE_FORMAT},
+ * {@link TAG_STRIP_OFFSETS}, {@link TAG_INTEROPERABILITY_IFD}
+ * <p>
+ * Note: defining tags with these TID's is disallowed.
+ *
+ * @param tag a tag's TID (can be obtained from a defined tag constant with
+ * {@link #getTrueTagKey}).
+ * @return true if the TID is that of an offset tag.
+ */
+ protected static boolean isOffsetTag(short tag) {
+ return sOffsetTags.contains(tag);
+ }
+
+ /**
+ * Creates a tag for a defined tag constant in a given IFD if that IFD is
+ * allowed for the tag. This method will fail anytime the appropriate
+ * {@link ExifTag#setValue} for this tag's datatype would fail.
+ *
+ * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @param ifdId the IFD that the tag should be in.
+ * @param val the value of the tag to set.
+ * @return an ExifTag object or null if one could not be constructed.
+ * @see #buildTag
+ */
+ public ExifTag buildTag(int tagId, int ifdId, Object val) {
+ int info = getTagInfo().get(tagId);
+ if (info == 0 || val == null) {
+ return null;
+ }
+ short type = getTypeFromInfo(info);
+ int definedCount = getComponentCountFromInfo(info);
+ boolean hasDefinedCount = (definedCount != ExifTag.SIZE_UNDEFINED);
+ if (!ExifInterface.isIfdAllowed(info, ifdId)) {
+ return null;
+ }
+ ExifTag t = new ExifTag(getTrueTagKey(tagId), type, definedCount, ifdId, hasDefinedCount);
+ if (!t.setValue(val)) {
+ return null;
+ }
+ return t;
+ }
+
+ /**
+ * Creates a tag for a defined tag constant in the tag's default IFD.
+ *
+ * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @param val the tag's value.
+ * @return an ExifTag object.
+ */
+ public ExifTag buildTag(int tagId, Object val) {
+ int ifdId = getTrueIfd(tagId);
+ return buildTag(tagId, ifdId, val);
+ }
+
+ protected ExifTag buildUninitializedTag(int tagId) {
+ int info = getTagInfo().get(tagId);
+ if (info == 0) {
+ return null;
+ }
+ short type = getTypeFromInfo(info);
+ int definedCount = getComponentCountFromInfo(info);
+ boolean hasDefinedCount = (definedCount != ExifTag.SIZE_UNDEFINED);
+ int ifdId = getTrueIfd(tagId);
+ ExifTag t = new ExifTag(getTrueTagKey(tagId), type, definedCount, ifdId, hasDefinedCount);
+ return t;
+ }
+
+ /**
+ * Sets the value of an ExifTag if it exists in the given IFD. The value
+ * must be the correct type and length for that ExifTag.
+ *
+ * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @param ifdId the IFD that the ExifTag is in.
+ * @param val the value to set.
+ * @return true if success, false if the ExifTag doesn't exist or the value
+ * is the wrong type/length.
+ * @see #setTagValue
+ */
+ public boolean setTagValue(int tagId, int ifdId, Object val) {
+ ExifTag t = getTag(tagId, ifdId);
+ if (t == null) {
+ return false;
+ }
+ return t.setValue(val);
+ }
+
+ /**
+ * Sets the value of an ExifTag if it exists it's default IFD. The value
+ * must be the correct type and length for that ExifTag.
+ *
+ * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @param val the value to set.
+ * @return true if success, false if the ExifTag doesn't exist or the value
+ * is the wrong type/length.
+ */
+ public boolean setTagValue(int tagId, Object val) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ return setTagValue(tagId, ifdId, val);
+ }
+
+ /**
+ * Puts an ExifTag into this ExifInterface object's tags, removing a
+ * previous ExifTag with the same TID and IFD. The IFD it is put into will
+ * be the one the tag was created with in {@link #buildTag}.
+ *
+ * @param tag an ExifTag to put into this ExifInterface's tags.
+ * @return the previous ExifTag with the same TID and IFD or null if none
+ * exists.
+ */
+ public ExifTag setTag(ExifTag tag) {
+ return mData.addTag(tag);
+ }
+
+ /**
+ * Puts a collection of ExifTags into this ExifInterface objects's tags. Any
+ * previous ExifTags with the same TID and IFDs will be removed.
+ *
+ * @param tags a Collection of ExifTags.
+ * @see #setTag
+ */
+ public void setTags(Collection<ExifTag> tags) {
+ for (ExifTag t : tags) {
+ setTag(t);
+ }
+ }
+
+ /**
+ * Removes the ExifTag for a tag constant from the given IFD.
+ *
+ * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ * @param ifdId the IFD of the ExifTag to remove.
+ */
+ public void deleteTag(int tagId, int ifdId) {
+ mData.removeTag(getTrueTagKey(tagId), ifdId);
+ }
+
+ /**
+ * Removes the ExifTag for a tag constant from that tag's default IFD.
+ *
+ * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ */
+ public void deleteTag(int tagId) {
+ int ifdId = getDefinedTagDefaultIfd(tagId);
+ deleteTag(tagId, ifdId);
+ }
+
+ /**
+ * Creates a new tag definition in this ExifInterface object for a given TID
+ * and default IFD. Creating a definition with the same TID and default IFD
+ * as a previous definition will override it.
+ *
+ * @param tagId the TID for the tag.
+ * @param defaultIfd the default IFD for the tag.
+ * @param tagType the type of the tag (see {@link ExifTag#getDataType()}).
+ * @param defaultComponentCount the number of elements of this tag's type in
+ * the tags value.
+ * @param allowedIfds the IFD's this tag is allowed to be put in.
+ * @return the defined tag constant (e.g. {@link #TAG_IMAGE_WIDTH}) or
+ * {@link #TAG_NULL} if the definition could not be made.
+ */
+ public int setTagDefinition(short tagId, int defaultIfd, short tagType,
+ short defaultComponentCount, int[] allowedIfds) {
+ if (sBannedDefines.contains(tagId)) {
+ return TAG_NULL;
+ }
+ if (ExifTag.isValidType(tagType) && ExifTag.isValidIfd(defaultIfd)) {
+ int tagDef = defineTag(defaultIfd, tagId);
+ if (tagDef == TAG_NULL) {
+ return TAG_NULL;
+ }
+ int[] otherDefs = getTagDefinitionsForTagId(tagId);
+ SparseIntArray infos = getTagInfo();
+ // Make sure defaultIfd is in allowedIfds
+ boolean defaultCheck = false;
+ for (int i : allowedIfds) {
+ if (defaultIfd == i) {
+ defaultCheck = true;
+ }
+ if (!ExifTag.isValidIfd(i)) {
+ return TAG_NULL;
+ }
+ }
+ if (!defaultCheck) {
+ return TAG_NULL;
+ }
+
+ int ifdFlags = getFlagsFromAllowedIfds(allowedIfds);
+ // Make sure no identical tags can exist in allowedIfds
+ if (otherDefs != null) {
+ for (int def : otherDefs) {
+ int tagInfo = infos.get(def);
+ int allowedFlags = getAllowedIfdFlagsFromInfo(tagInfo);
+ if ((ifdFlags & allowedFlags) != 0) {
+ return TAG_NULL;
+ }
+ }
+ }
+ getTagInfo().put(tagDef, ifdFlags << 24 | (tagType << 16) | defaultComponentCount);
+ return tagDef;
+ }
+ return TAG_NULL;
+ }
+
+ protected int getTagDefinition(short tagId, int defaultIfd) {
+ return getTagInfo().get(defineTag(defaultIfd, tagId));
+ }
+
+ protected int[] getTagDefinitionsForTagId(short tagId) {
+ int[] ifds = IfdData.getIfds();
+ int[] defs = new int[ifds.length];
+ int counter = 0;
+ SparseIntArray infos = getTagInfo();
+ for (int i : ifds) {
+ int def = defineTag(i, tagId);
+ if (infos.get(def) != DEFINITION_NULL) {
+ defs[counter++] = def;
+ }
+ }
+ if (counter == 0) {
+ return null;
+ }
+
+ return Arrays.copyOfRange(defs, 0, counter);
+ }
+
+ protected int getTagDefinitionForTag(ExifTag tag) {
+ short type = tag.getDataType();
+ int count = tag.getComponentCount();
+ int ifd = tag.getIfd();
+ return getTagDefinitionForTag(tag.getTagId(), type, count, ifd);
+ }
+
+ protected int getTagDefinitionForTag(short tagId, short type, int count, int ifd) {
+ int[] defs = getTagDefinitionsForTagId(tagId);
+ if (defs == null) {
+ return TAG_NULL;
+ }
+ SparseIntArray infos = getTagInfo();
+ int ret = TAG_NULL;
+ for (int i : defs) {
+ int info = infos.get(i);
+ short defType = getTypeFromInfo(info);
+ int defCount = getComponentCountFromInfo(info);
+ int[] defIfds = getAllowedIfdsFromInfo(info);
+ boolean validIfd = false;
+ for (int j : defIfds) {
+ if (j == ifd) {
+ validIfd = true;
+ break;
+ }
+ }
+ if (validIfd && type == defType
+ && (count == defCount || defCount == ExifTag.SIZE_UNDEFINED)) {
+ ret = i;
+ break;
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Removes a tag definition for given defined tag constant.
+ *
+ * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}.
+ */
+ public void removeTagDefinition(int tagId) {
+ getTagInfo().delete(tagId);
+ }
+
+ /**
+ * Resets tag definitions to the default ones.
+ */
+ public void resetTagDefinitions() {
+ mTagInfo = null;
+ }
+
+ /**
+ * Returns the thumbnail from IFD1 as a bitmap, or null if none exists.
+ *
+ * @return the thumbnail as a bitmap.
+ */
+ public Bitmap getThumbnailBitmap() {
+ if (mData.hasCompressedThumbnail()) {
+ byte[] thumb = mData.getCompressedThumbnail();
+ return BitmapFactory.decodeByteArray(thumb, 0, thumb.length);
+ } else if (mData.hasUncompressedStrip()) {
+ // TODO: implement uncompressed
+ }
+ return null;
+ }
+
+ /**
+ * Returns the thumbnail from IFD1 as a byte array, or null if none exists.
+ * The bytes may either be an uncompressed strip as specified in the exif
+ * standard or a jpeg compressed image.
+ *
+ * @return the thumbnail as a byte array.
+ */
+ public byte[] getThumbnailBytes() {
+ if (mData.hasCompressedThumbnail()) {
+ return mData.getCompressedThumbnail();
+ } else if (mData.hasUncompressedStrip()) {
+ // TODO: implement this
+ }
+ return null;
+ }
+
+ /**
+ * Returns the thumbnail if it is jpeg compressed, or null if none exists.
+ *
+ * @return the thumbnail as a byte array.
+ */
+ public byte[] getThumbnail() {
+ return mData.getCompressedThumbnail();
+ }
+
+ /**
+ * Check if thumbnail is compressed.
+ *
+ * @return true if the thumbnail is compressed.
+ */
+ public boolean isThumbnailCompressed() {
+ return mData.hasCompressedThumbnail();
+ }
+
+ /**
+ * Check if thumbnail exists.
+ *
+ * @return true if a compressed thumbnail exists.
+ */
+ public boolean hasThumbnail() {
+ // TODO: add back in uncompressed strip
+ return mData.hasCompressedThumbnail();
+ }
+
+ // TODO: uncompressed thumbnail setters
+
+ /**
+ * Sets the thumbnail to be a jpeg compressed image. Clears any prior
+ * thumbnail.
+ *
+ * @param thumb a byte array containing a jpeg compressed image.
+ * @return true if the thumbnail was set.
+ */
+ public boolean setCompressedThumbnail(byte[] thumb) {
+ mData.clearThumbnailAndStrips();
+ mData.setCompressedThumbnail(thumb);
+ return true;
+ }
+
+ /**
+ * Sets the thumbnail to be a jpeg compressed bitmap. Clears any prior
+ * thumbnail.
+ *
+ * @param thumb a bitmap to compress to a jpeg thumbnail.
+ * @return true if the thumbnail was set.
+ */
+ public boolean setCompressedThumbnail(Bitmap thumb) {
+ ByteArrayOutputStream thumbnail = new ByteArrayOutputStream();
+ if (!thumb.compress(Bitmap.CompressFormat.JPEG, 90, thumbnail)) {
+ return false;
+ }
+ return setCompressedThumbnail(thumbnail.toByteArray());
+ }
+
+ /**
+ * Clears the compressed thumbnail if it exists.
+ */
+ public void removeCompressedThumbnail() {
+ mData.setCompressedThumbnail(null);
+ }
+
+ // Convenience methods:
+
+ /**
+ * Decodes the user comment tag into string as specified in the EXIF
+ * standard. Returns null if decoding failed.
+ */
+ public String getUserComment() {
+ return mData.getUserComment();
+ }
+
+ /**
+ * Returns the Orientation ExifTag value for a given number of degrees.
+ *
+ * @param degrees the amount an image is rotated in degrees.
+ */
+ public static short getOrientationValueForRotation(int degrees) {
+ degrees %= 360;
+ if (degrees < 0) {
+ degrees += 360;
+ }
+ if (degrees < 90) {
+ return Orientation.TOP_LEFT; // 0 degrees
+ } else if (degrees < 180) {
+ return Orientation.RIGHT_TOP; // 90 degrees cw
+ } else if (degrees < 270) {
+ return Orientation.BOTTOM_LEFT; // 180 degrees
+ } else {
+ return Orientation.RIGHT_BOTTOM; // 270 degrees cw
+ }
+ }
+
+ /**
+ * Returns the rotation degrees corresponding to an ExifTag Orientation
+ * value.
+ *
+ * @param orientation the ExifTag Orientation value.
+ */
+ public static int getRotationForOrientationValue(short orientation) {
+ switch (orientation) {
+ case Orientation.TOP_LEFT:
+ return 0;
+ case Orientation.RIGHT_TOP:
+ return 90;
+ case Orientation.BOTTOM_LEFT:
+ return 180;
+ case Orientation.RIGHT_BOTTOM:
+ return 270;
+ default:
+ return 0;
+ }
+ }
+
+ public static OrientationParams getOrientationParams(int orientation) {
+ OrientationParams params = new OrientationParams();
+ switch (orientation) {
+ case Orientation.TOP_RIGHT: // Flip horizontal
+ params.scaleX = -1;
+ break;
+ case Orientation.BOTTOM_RIGHT: // Flip vertical
+ params.scaleY = -1;
+ break;
+ case Orientation.BOTTOM_LEFT: // Rotate 180
+ params.rotation = 180;
+ break;
+ case Orientation.RIGHT_BOTTOM: // Rotate 270
+ params.rotation = 270;
+ params.invertDimensions = true;
+ break;
+ case Orientation.RIGHT_TOP: // Rotate 90
+ params.rotation = 90;
+ params.invertDimensions = true;
+ break;
+ case Orientation.LEFT_TOP: // Transpose
+ params.rotation = 90;
+ params.scaleX = -1;
+ params.invertDimensions = true;
+ break;
+ case Orientation.LEFT_BOTTOM: // Transverse
+ params.rotation = 270;
+ params.scaleX = -1;
+ params.invertDimensions = true;
+ break;
+ }
+ return params;
+ }
+
+ public static class OrientationParams {
+ public int rotation = 0;
+ public int scaleX = 1;
+ public int scaleY = 1;
+ public boolean invertDimensions = false;
+ }
+
+ /**
+ * Gets the double representation of the GPS latitude or longitude
+ * coordinate.
+ *
+ * @param coordinate an array of 3 Rationals representing the degrees,
+ * minutes, and seconds of the GPS location as defined in the
+ * exif specification.
+ * @param reference a GPS reference reperesented by a String containing "N",
+ * "S", "E", or "W".
+ * @return the GPS coordinate represented as degrees + minutes/60 +
+ * seconds/3600
+ */
+ public static double convertLatOrLongToDouble(Rational[] coordinate, String reference) {
+ try {
+ double degrees = coordinate[0].toDouble();
+ double minutes = coordinate[1].toDouble();
+ double seconds = coordinate[2].toDouble();
+ double result = degrees + minutes / 60.0 + seconds / 3600.0;
+ if ((reference.equals("S") || reference.equals("W"))) {
+ return -result;
+ }
+ return result;
+ } catch (ArrayIndexOutOfBoundsException e) {
+ throw new IllegalArgumentException();
+ }
+ }
+
+ /**
+ * Gets the GPS latitude and longitude as a pair of doubles from this
+ * ExifInterface object's tags, or null if the necessary tags do not exist.
+ *
+ * @return an array of 2 doubles containing the latitude, and longitude
+ * respectively.
+ * @see #convertLatOrLongToDouble
+ */
+ public double[] getLatLongAsDoubles() {
+ Rational[] latitude = getTagRationalValues(TAG_GPS_LATITUDE);
+ String latitudeRef = getTagStringValue(TAG_GPS_LATITUDE_REF);
+ Rational[] longitude = getTagRationalValues(TAG_GPS_LONGITUDE);
+ String longitudeRef = getTagStringValue(TAG_GPS_LONGITUDE_REF);
+ if (latitude == null || longitude == null || latitudeRef == null || longitudeRef == null
+ || latitude.length < 3 || longitude.length < 3) {
+ return null;
+ }
+ double[] latLon = new double[2];
+ latLon[0] = convertLatOrLongToDouble(latitude, latitudeRef);
+ latLon[1] = convertLatOrLongToDouble(longitude, longitudeRef);
+ return latLon;
+ }
+
+ private static final String GPS_DATE_FORMAT_STR = "yyyy:MM:dd";
+ private static final String DATETIME_FORMAT_STR = "yyyy:MM:dd kk:mm:ss";
+ private final DateFormat mDateTimeStampFormat = new SimpleDateFormat(DATETIME_FORMAT_STR);
+ private final DateFormat mGPSDateStampFormat = new SimpleDateFormat(GPS_DATE_FORMAT_STR);
+ private final Calendar mGPSTimeStampCalendar = Calendar
+ .getInstance(TimeZone.getTimeZone("UTC"));
+
+ /**
+ * Creates, formats, and sets the DateTimeStamp tag for one of:
+ * {@link #TAG_DATE_TIME}, {@link #TAG_DATE_TIME_DIGITIZED},
+ * {@link #TAG_DATE_TIME_ORIGINAL}.
+ *
+ * @param tagId one of the DateTimeStamp tags.
+ * @param timestamp a timestamp to format.
+ * @param timezone a TimeZone object.
+ * @return true if success, false if the tag could not be set.
+ */
+ public boolean addDateTimeStampTag(int tagId, long timestamp, TimeZone timezone) {
+ if (tagId == TAG_DATE_TIME || tagId == TAG_DATE_TIME_DIGITIZED
+ || tagId == TAG_DATE_TIME_ORIGINAL) {
+ mDateTimeStampFormat.setTimeZone(timezone);
+ ExifTag t = buildTag(tagId, mDateTimeStampFormat.format(timestamp));
+ if (t == null) {
+ return false;
+ }
+ setTag(t);
+ } else {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Creates and sets all to the GPS tags for a give latitude and longitude.
+ *
+ * @param latitude a GPS latitude coordinate.
+ * @param longitude a GPS longitude coordinate.
+ * @return true if success, false if they could not be created or set.
+ */
+ public boolean addGpsTags(double latitude, double longitude) {
+ ExifTag latTag = buildTag(TAG_GPS_LATITUDE, toExifLatLong(latitude));
+ ExifTag longTag = buildTag(TAG_GPS_LONGITUDE, toExifLatLong(longitude));
+ ExifTag latRefTag = buildTag(TAG_GPS_LATITUDE_REF,
+ latitude >= 0 ? GpsLatitudeRef.NORTH
+ : GpsLatitudeRef.SOUTH);
+ ExifTag longRefTag = buildTag(TAG_GPS_LONGITUDE_REF,
+ longitude >= 0 ? GpsLongitudeRef.EAST
+ : GpsLongitudeRef.WEST);
+ if (latTag == null || longTag == null || latRefTag == null || longRefTag == null) {
+ return false;
+ }
+ setTag(latTag);
+ setTag(longTag);
+ setTag(latRefTag);
+ setTag(longRefTag);
+ return true;
+ }
+
+ /**
+ * Creates and sets the GPS timestamp tag.
+ *
+ * @param timestamp a GPS timestamp.
+ * @return true if success, false if could not be created or set.
+ */
+ public boolean addGpsDateTimeStampTag(long timestamp) {
+ ExifTag t = buildTag(TAG_GPS_DATE_STAMP, mGPSDateStampFormat.format(timestamp));
+ if (t == null) {
+ return false;
+ }
+ setTag(t);
+ mGPSTimeStampCalendar.setTimeInMillis(timestamp);
+ t = buildTag(TAG_GPS_TIME_STAMP, new Rational[] {
+ new Rational(mGPSTimeStampCalendar.get(Calendar.HOUR_OF_DAY), 1),
+ new Rational(mGPSTimeStampCalendar.get(Calendar.MINUTE), 1),
+ new Rational(mGPSTimeStampCalendar.get(Calendar.SECOND), 1)
+ });
+ if (t == null) {
+ return false;
+ }
+ setTag(t);
+ return true;
+ }
+
+ private static Rational[] toExifLatLong(double value) {
+ // convert to the format dd/1 mm/1 ssss/100
+ value = Math.abs(value);
+ int degrees = (int) value;
+ value = (value - degrees) * 60;
+ int minutes = (int) value;
+ value = (value - minutes) * 6000;
+ int seconds = (int) value;
+ return new Rational[] {
+ new Rational(degrees, 1), new Rational(minutes, 1), new Rational(seconds, 100)
+ };
+ }
+
+ private void doExifStreamIO(InputStream is, OutputStream os) throws IOException {
+ byte[] buf = new byte[1024];
+ int ret = is.read(buf, 0, 1024);
+ while (ret != -1) {
+ os.write(buf, 0, ret);
+ ret = is.read(buf, 0, 1024);
+ }
+ }
+
+ protected static void closeSilently(Closeable c) {
+ if (c != null) {
+ try {
+ c.close();
+ } catch (Throwable e) {
+ // ignored
+ }
+ }
+ }
+
+ private SparseIntArray mTagInfo = null;
+
+ protected SparseIntArray getTagInfo() {
+ if (mTagInfo == null) {
+ mTagInfo = new SparseIntArray();
+ initTagInfo();
+ }
+ return mTagInfo;
+ }
+
+ private void initTagInfo() {
+ /**
+ * We put tag information in a 4-bytes integer. The first byte a bitmask
+ * representing the allowed IFDs of the tag, the second byte is the data
+ * type, and the last two byte are a short value indicating the default
+ * component count of this tag.
+ */
+ // IFD0 tags
+ int[] ifdAllowedIfds = {
+ IfdId.TYPE_IFD_0, IfdId.TYPE_IFD_1
+ };
+ int ifdFlags = getFlagsFromAllowedIfds(ifdAllowedIfds) << 24;
+ mTagInfo.put(ExifInterface.TAG_MAKE,
+ ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_IMAGE_WIDTH,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_IMAGE_LENGTH,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_BITS_PER_SAMPLE,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 3);
+ mTagInfo.put(ExifInterface.TAG_COMPRESSION,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_ORIENTATION, ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16
+ | 1);
+ mTagInfo.put(ExifInterface.TAG_SAMPLES_PER_PIXEL,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_PLANAR_CONFIGURATION,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_Y_CB_CR_POSITIONING,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_X_RESOLUTION,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_Y_RESOLUTION,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_RESOLUTION_UNIT,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_STRIP_OFFSETS,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_ROWS_PER_STRIP,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_STRIP_BYTE_COUNTS,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_TRANSFER_FUNCTION,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 3 * 256);
+ mTagInfo.put(ExifInterface.TAG_WHITE_POINT,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_PRIMARY_CHROMATICITIES,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 6);
+ mTagInfo.put(ExifInterface.TAG_Y_CB_CR_COEFFICIENTS,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 3);
+ mTagInfo.put(ExifInterface.TAG_REFERENCE_BLACK_WHITE,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 6);
+ mTagInfo.put(ExifInterface.TAG_DATE_TIME,
+ ifdFlags | ExifTag.TYPE_ASCII << 16 | 20);
+ mTagInfo.put(ExifInterface.TAG_IMAGE_DESCRIPTION,
+ ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_MAKE,
+ ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_MODEL,
+ ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_SOFTWARE,
+ ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_ARTIST,
+ ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_COPYRIGHT,
+ ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_EXIF_IFD,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_GPS_IFD,
+ ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1);
+ // IFD1 tags
+ int[] ifd1AllowedIfds = {
+ IfdId.TYPE_IFD_1
+ };
+ int ifdFlags1 = getFlagsFromAllowedIfds(ifd1AllowedIfds) << 24;
+ mTagInfo.put(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT,
+ ifdFlags1 | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH,
+ ifdFlags1 | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1);
+ // Exif tags
+ int[] exifAllowedIfds = {
+ IfdId.TYPE_IFD_EXIF
+ };
+ int exifFlags = getFlagsFromAllowedIfds(exifAllowedIfds) << 24;
+ mTagInfo.put(ExifInterface.TAG_EXIF_VERSION,
+ exifFlags | ExifTag.TYPE_UNDEFINED << 16 | 4);
+ mTagInfo.put(ExifInterface.TAG_FLASHPIX_VERSION,
+ exifFlags | ExifTag.TYPE_UNDEFINED << 16 | 4);
+ mTagInfo.put(ExifInterface.TAG_COLOR_SPACE,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_COMPONENTS_CONFIGURATION,
+ exifFlags | ExifTag.TYPE_UNDEFINED << 16 | 4);
+ mTagInfo.put(ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_PIXEL_X_DIMENSION,
+ exifFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_PIXEL_Y_DIMENSION,
+ exifFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_MAKER_NOTE,
+ exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_USER_COMMENT,
+ exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_RELATED_SOUND_FILE,
+ exifFlags | ExifTag.TYPE_ASCII << 16 | 13);
+ mTagInfo.put(ExifInterface.TAG_DATE_TIME_ORIGINAL,
+ exifFlags | ExifTag.TYPE_ASCII << 16 | 20);
+ mTagInfo.put(ExifInterface.TAG_DATE_TIME_DIGITIZED,
+ exifFlags | ExifTag.TYPE_ASCII << 16 | 20);
+ mTagInfo.put(ExifInterface.TAG_SUB_SEC_TIME,
+ exifFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_SUB_SEC_TIME_ORIGINAL,
+ exifFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_SUB_SEC_TIME_DIGITIZED,
+ exifFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_IMAGE_UNIQUE_ID,
+ exifFlags | ExifTag.TYPE_ASCII << 16 | 33);
+ mTagInfo.put(ExifInterface.TAG_EXPOSURE_TIME,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_F_NUMBER,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_EXPOSURE_PROGRAM,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_SPECTRAL_SENSITIVITY,
+ exifFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_ISO_SPEED_RATINGS,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_OECF,
+ exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_SHUTTER_SPEED_VALUE,
+ exifFlags | ExifTag.TYPE_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_APERTURE_VALUE,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_BRIGHTNESS_VALUE,
+ exifFlags | ExifTag.TYPE_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_EXPOSURE_BIAS_VALUE,
+ exifFlags | ExifTag.TYPE_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_MAX_APERTURE_VALUE,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_SUBJECT_DISTANCE,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_METERING_MODE,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_LIGHT_SOURCE,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_FLASH,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_FOCAL_LENGTH,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_SUBJECT_AREA,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_FLASH_ENERGY,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE,
+ exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_SUBJECT_LOCATION,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_EXPOSURE_INDEX,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_SENSING_METHOD,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_FILE_SOURCE,
+ exifFlags | ExifTag.TYPE_UNDEFINED << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_SCENE_TYPE,
+ exifFlags | ExifTag.TYPE_UNDEFINED << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_CFA_PATTERN,
+ exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_CUSTOM_RENDERED,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_EXPOSURE_MODE,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_WHITE_BALANCE,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_DIGITAL_ZOOM_RATIO,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_FOCAL_LENGTH_IN_35_MM_FILE,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_SCENE_CAPTURE_TYPE,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_GAIN_CONTROL,
+ exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_CONTRAST,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_SATURATION,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_SHARPNESS,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION,
+ exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_SUBJECT_DISTANCE_RANGE,
+ exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_INTEROPERABILITY_IFD, exifFlags
+ | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1);
+ // GPS tag
+ int[] gpsAllowedIfds = {
+ IfdId.TYPE_IFD_GPS
+ };
+ int gpsFlags = getFlagsFromAllowedIfds(gpsAllowedIfds) << 24;
+ mTagInfo.put(ExifInterface.TAG_GPS_VERSION_ID,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_BYTE << 16 | 4);
+ mTagInfo.put(ExifInterface.TAG_GPS_LATITUDE_REF,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_GPS_LONGITUDE_REF,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_GPS_LATITUDE,
+ gpsFlags | ExifTag.TYPE_RATIONAL << 16 | 3);
+ mTagInfo.put(ExifInterface.TAG_GPS_LONGITUDE,
+ gpsFlags | ExifTag.TYPE_RATIONAL << 16 | 3);
+ mTagInfo.put(ExifInterface.TAG_GPS_ALTITUDE_REF,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_BYTE << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_GPS_ALTITUDE,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_GPS_TIME_STAMP,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 3);
+ mTagInfo.put(ExifInterface.TAG_GPS_SATTELLITES,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_GPS_STATUS,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_GPS_MEASURE_MODE,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_GPS_DOP,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_GPS_SPEED_REF,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_GPS_SPEED,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_GPS_TRACK_REF,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_GPS_TRACK,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_GPS_IMG_DIRECTION_REF,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_GPS_IMG_DIRECTION,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_GPS_MAP_DATUM,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_GPS_DEST_LATITUDE_REF,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_GPS_DEST_LATITUDE,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_GPS_DEST_BEARING_REF,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_GPS_DEST_BEARING,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_GPS_DEST_DISTANCE_REF,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | 2);
+ mTagInfo.put(ExifInterface.TAG_GPS_DEST_DISTANCE,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1);
+ mTagInfo.put(ExifInterface.TAG_GPS_PROCESSING_METHOD,
+ gpsFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_GPS_AREA_INFORMATION,
+ gpsFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED);
+ mTagInfo.put(ExifInterface.TAG_GPS_DATE_STAMP,
+ gpsFlags | ExifTag.TYPE_ASCII << 16 | 11);
+ mTagInfo.put(ExifInterface.TAG_GPS_DIFFERENTIAL,
+ gpsFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 11);
+ // Interoperability tag
+ int[] interopAllowedIfds = {
+ IfdId.TYPE_IFD_INTEROPERABILITY
+ };
+ int interopFlags = getFlagsFromAllowedIfds(interopAllowedIfds) << 24;
+ mTagInfo.put(TAG_INTEROPERABILITY_INDEX, interopFlags | ExifTag.TYPE_ASCII << 16
+ | ExifTag.SIZE_UNDEFINED);
+ }
+
+ protected static int getAllowedIfdFlagsFromInfo(int info) {
+ return info >>> 24;
+ }
+
+ protected static int[] getAllowedIfdsFromInfo(int info) {
+ int ifdFlags = getAllowedIfdFlagsFromInfo(info);
+ int[] ifds = IfdData.getIfds();
+ ArrayList<Integer> l = new ArrayList<Integer>();
+ for (int i = 0; i < IfdId.TYPE_IFD_COUNT; i++) {
+ int flag = (ifdFlags >> i) & 1;
+ if (flag == 1) {
+ l.add(ifds[i]);
+ }
+ }
+ if (l.size() <= 0) {
+ return null;
+ }
+ int[] ret = new int[l.size()];
+ int j = 0;
+ for (int i : l) {
+ ret[j++] = i;
+ }
+ return ret;
+ }
+
+ protected static boolean isIfdAllowed(int info, int ifd) {
+ int[] ifds = IfdData.getIfds();
+ int ifdFlags = getAllowedIfdFlagsFromInfo(info);
+ for (int i = 0; i < ifds.length; i++) {
+ if (ifd == ifds[i] && ((ifdFlags >> i) & 1) == 1) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ protected static int getFlagsFromAllowedIfds(int[] allowedIfds) {
+ if (allowedIfds == null || allowedIfds.length == 0) {
+ return 0;
+ }
+ int flags = 0;
+ int[] ifds = IfdData.getIfds();
+ for (int i = 0; i < IfdId.TYPE_IFD_COUNT; i++) {
+ for (int j : allowedIfds) {
+ if (ifds[i] == j) {
+ flags |= 1 << i;
+ break;
+ }
+ }
+ }
+ return flags;
+ }
+
+ protected static short getTypeFromInfo(int info) {
+ return (short) ((info >> 16) & 0x0ff);
+ }
+
+ protected static int getComponentCountFromInfo(int info) {
+ return info & 0x0ffff;
+ }
+
+}
diff --git a/src/com/android/messaging/util/exif/ExifInvalidFormatException.java b/src/com/android/messaging/util/exif/ExifInvalidFormatException.java
new file mode 100644
index 0000000..a38f8a3
--- /dev/null
+++ b/src/com/android/messaging/util/exif/ExifInvalidFormatException.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+public class ExifInvalidFormatException extends Exception {
+ public ExifInvalidFormatException(String meg) {
+ super(meg);
+ }
+}
diff --git a/src/com/android/messaging/util/exif/ExifModifier.java b/src/com/android/messaging/util/exif/ExifModifier.java
new file mode 100644
index 0000000..274022c
--- /dev/null
+++ b/src/com/android/messaging/util/exif/ExifModifier.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+import android.util.Log;
+import com.android.messaging.util.LogUtil;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.List;
+
+class ExifModifier {
+ public static final String TAG = LogUtil.BUGLE_TAG;
+ public static final boolean DEBUG = false;
+ private final ByteBuffer mByteBuffer;
+ private final ExifData mTagToModified;
+ private final List<TagOffset> mTagOffsets = new ArrayList<TagOffset>();
+ private final ExifInterface mInterface;
+ private int mOffsetBase;
+
+ private static class TagOffset {
+ final int mOffset;
+ final ExifTag mTag;
+
+ TagOffset(ExifTag tag, int offset) {
+ mTag = tag;
+ mOffset = offset;
+ }
+ }
+
+ protected ExifModifier(ByteBuffer byteBuffer, ExifInterface iRef) throws IOException,
+ ExifInvalidFormatException {
+ mByteBuffer = byteBuffer;
+ mOffsetBase = byteBuffer.position();
+ mInterface = iRef;
+ InputStream is = null;
+ try {
+ is = new ByteBufferInputStream(byteBuffer);
+ // Do not require any IFD;
+ ExifParser parser = ExifParser.parse(is, mInterface);
+ mTagToModified = new ExifData(parser.getByteOrder());
+ mOffsetBase += parser.getTiffStartPosition();
+ mByteBuffer.position(0);
+ } finally {
+ ExifInterface.closeSilently(is);
+ }
+ }
+
+ protected ByteOrder getByteOrder() {
+ return mTagToModified.getByteOrder();
+ }
+
+ protected boolean commit() throws IOException, ExifInvalidFormatException {
+ InputStream is = null;
+ try {
+ is = new ByteBufferInputStream(mByteBuffer);
+ int flag = 0;
+ IfdData[] ifdDatas = new IfdData[] {
+ mTagToModified.getIfdData(IfdId.TYPE_IFD_0),
+ mTagToModified.getIfdData(IfdId.TYPE_IFD_1),
+ mTagToModified.getIfdData(IfdId.TYPE_IFD_EXIF),
+ mTagToModified.getIfdData(IfdId.TYPE_IFD_INTEROPERABILITY),
+ mTagToModified.getIfdData(IfdId.TYPE_IFD_GPS)
+ };
+
+ if (ifdDatas[IfdId.TYPE_IFD_0] != null) {
+ flag |= ExifParser.OPTION_IFD_0;
+ }
+ if (ifdDatas[IfdId.TYPE_IFD_1] != null) {
+ flag |= ExifParser.OPTION_IFD_1;
+ }
+ if (ifdDatas[IfdId.TYPE_IFD_EXIF] != null) {
+ flag |= ExifParser.OPTION_IFD_EXIF;
+ }
+ if (ifdDatas[IfdId.TYPE_IFD_GPS] != null) {
+ flag |= ExifParser.OPTION_IFD_GPS;
+ }
+ if (ifdDatas[IfdId.TYPE_IFD_INTEROPERABILITY] != null) {
+ flag |= ExifParser.OPTION_IFD_INTEROPERABILITY;
+ }
+
+ ExifParser parser = ExifParser.parse(is, flag, mInterface);
+ int event = parser.next();
+ IfdData currIfd = null;
+ while (event != ExifParser.EVENT_END) {
+ switch (event) {
+ case ExifParser.EVENT_START_OF_IFD:
+ currIfd = ifdDatas[parser.getCurrentIfd()];
+ if (currIfd == null) {
+ parser.skipRemainingTagsInCurrentIfd();
+ }
+ break;
+ case ExifParser.EVENT_NEW_TAG:
+ ExifTag oldTag = parser.getTag();
+ ExifTag newTag = currIfd.getTag(oldTag.getTagId());
+ if (newTag != null) {
+ if (newTag.getComponentCount() != oldTag.getComponentCount()
+ || newTag.getDataType() != oldTag.getDataType()) {
+ return false;
+ } else {
+ mTagOffsets.add(new TagOffset(newTag, oldTag.getOffset()));
+ currIfd.removeTag(oldTag.getTagId());
+ if (currIfd.getTagCount() == 0) {
+ parser.skipRemainingTagsInCurrentIfd();
+ }
+ }
+ }
+ break;
+ }
+ event = parser.next();
+ }
+ for (IfdData ifd : ifdDatas) {
+ if (ifd != null && ifd.getTagCount() > 0) {
+ return false;
+ }
+ }
+ modify();
+ } finally {
+ ExifInterface.closeSilently(is);
+ }
+ return true;
+ }
+
+ private void modify() {
+ mByteBuffer.order(getByteOrder());
+ for (TagOffset tagOffset : mTagOffsets) {
+ writeTagValue(tagOffset.mTag, tagOffset.mOffset);
+ }
+ }
+
+ private void writeTagValue(ExifTag tag, int offset) {
+ if (DEBUG) {
+ Log.v(TAG, "modifying tag to: \n" + tag.toString());
+ Log.v(TAG, "at offset: " + offset);
+ }
+ mByteBuffer.position(offset + mOffsetBase);
+ switch (tag.getDataType()) {
+ case ExifTag.TYPE_ASCII:
+ byte buf[] = tag.getStringByte();
+ if (buf.length == tag.getComponentCount()) {
+ buf[buf.length - 1] = 0;
+ mByteBuffer.put(buf);
+ } else {
+ mByteBuffer.put(buf);
+ mByteBuffer.put((byte) 0);
+ }
+ break;
+ case ExifTag.TYPE_LONG:
+ case ExifTag.TYPE_UNSIGNED_LONG:
+ for (int i = 0, n = tag.getComponentCount(); i < n; i++) {
+ mByteBuffer.putInt((int) tag.getValueAt(i));
+ }
+ break;
+ case ExifTag.TYPE_RATIONAL:
+ case ExifTag.TYPE_UNSIGNED_RATIONAL:
+ for (int i = 0, n = tag.getComponentCount(); i < n; i++) {
+ Rational v = tag.getRational(i);
+ mByteBuffer.putInt((int) v.getNumerator());
+ mByteBuffer.putInt((int) v.getDenominator());
+ }
+ break;
+ case ExifTag.TYPE_UNDEFINED:
+ case ExifTag.TYPE_UNSIGNED_BYTE:
+ buf = new byte[tag.getComponentCount()];
+ tag.getBytes(buf);
+ mByteBuffer.put(buf);
+ break;
+ case ExifTag.TYPE_UNSIGNED_SHORT:
+ for (int i = 0, n = tag.getComponentCount(); i < n; i++) {
+ mByteBuffer.putShort((short) tag.getValueAt(i));
+ }
+ break;
+ }
+ }
+
+ public void modifyTag(ExifTag tag) {
+ mTagToModified.addTag(tag);
+ }
+}
diff --git a/src/com/android/messaging/util/exif/ExifOutputStream.java b/src/com/android/messaging/util/exif/ExifOutputStream.java
new file mode 100644
index 0000000..2016da4
--- /dev/null
+++ b/src/com/android/messaging/util/exif/ExifOutputStream.java
@@ -0,0 +1,522 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+import android.util.Log;
+import com.android.messaging.util.LogUtil;
+
+import java.io.BufferedOutputStream;
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+
+/**
+ * This class provides a way to replace the Exif header of a JPEG image.
+ * <p>
+ * Below is an example of writing EXIF data into a file
+ *
+ * <pre>
+ * public static void writeExif(byte[] jpeg, ExifData exif, String path) {
+ * OutputStream os = null;
+ * try {
+ * os = new FileOutputStream(path);
+ * ExifOutputStream eos = new ExifOutputStream(os);
+ * // Set the exif header
+ * eos.setExifData(exif);
+ * // Write the original jpeg out, the header will be add into the file.
+ * eos.write(jpeg);
+ * } catch (FileNotFoundException e) {
+ * e.printStackTrace();
+ * } catch (IOException e) {
+ * e.printStackTrace();
+ * } finally {
+ * if (os != null) {
+ * try {
+ * os.close();
+ * } catch (IOException e) {
+ * e.printStackTrace();
+ * }
+ * }
+ * }
+ * }
+ * </pre>
+ */
+class ExifOutputStream extends FilterOutputStream {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+ private static final boolean DEBUG = false;
+ private static final int STREAMBUFFER_SIZE = 0x00010000; // 64Kb
+
+ private static final int STATE_SOI = 0;
+ private static final int STATE_FRAME_HEADER = 1;
+ private static final int STATE_JPEG_DATA = 2;
+
+ private static final int EXIF_HEADER = 0x45786966;
+ private static final short TIFF_HEADER = 0x002A;
+ private static final short TIFF_BIG_ENDIAN = 0x4d4d;
+ private static final short TIFF_LITTLE_ENDIAN = 0x4949;
+ private static final short TAG_SIZE = 12;
+ private static final short TIFF_HEADER_SIZE = 8;
+ private static final int MAX_EXIF_SIZE = 65535;
+
+ private ExifData mExifData;
+ private int mState = STATE_SOI;
+ private int mByteToSkip;
+ private int mByteToCopy;
+ private final byte[] mSingleByteArray = new byte[1];
+ private final ByteBuffer mBuffer = ByteBuffer.allocate(4);
+ private final ExifInterface mInterface;
+
+ protected ExifOutputStream(OutputStream ou, ExifInterface iRef) {
+ super(new BufferedOutputStream(ou, STREAMBUFFER_SIZE));
+ mInterface = iRef;
+ }
+
+ /**
+ * Sets the ExifData to be written into the JPEG file. Should be called
+ * before writing image data.
+ */
+ protected void setExifData(ExifData exifData) {
+ mExifData = exifData;
+ }
+
+ /**
+ * Gets the Exif header to be written into the JPEF file.
+ */
+ protected ExifData getExifData() {
+ return mExifData;
+ }
+
+ private int requestByteToBuffer(int requestByteCount, byte[] buffer
+ , int offset, int length) {
+ int byteNeeded = requestByteCount - mBuffer.position();
+ int byteToRead = length > byteNeeded ? byteNeeded : length;
+ mBuffer.put(buffer, offset, byteToRead);
+ return byteToRead;
+ }
+
+ /**
+ * Writes the image out. The input data should be a valid JPEG format. After
+ * writing, it's Exif header will be replaced by the given header.
+ */
+ @Override
+ public void write(byte[] buffer, int offset, int length) throws IOException {
+ while ((mByteToSkip > 0 || mByteToCopy > 0 || mState != STATE_JPEG_DATA)
+ && length > 0) {
+ if (mByteToSkip > 0) {
+ int byteToProcess = length > mByteToSkip ? mByteToSkip : length;
+ length -= byteToProcess;
+ mByteToSkip -= byteToProcess;
+ offset += byteToProcess;
+ }
+ if (mByteToCopy > 0) {
+ int byteToProcess = length > mByteToCopy ? mByteToCopy : length;
+ out.write(buffer, offset, byteToProcess);
+ length -= byteToProcess;
+ mByteToCopy -= byteToProcess;
+ offset += byteToProcess;
+ }
+ if (length == 0) {
+ return;
+ }
+ switch (mState) {
+ case STATE_SOI:
+ int byteRead = requestByteToBuffer(2, buffer, offset, length);
+ offset += byteRead;
+ length -= byteRead;
+ if (mBuffer.position() < 2) {
+ return;
+ }
+ mBuffer.rewind();
+ if (mBuffer.getShort() != JpegHeader.SOI) {
+ throw new IOException("Not a valid jpeg image, cannot write exif");
+ }
+ out.write(mBuffer.array(), 0, 2);
+ mState = STATE_FRAME_HEADER;
+ mBuffer.rewind();
+ writeExifData();
+ break;
+ case STATE_FRAME_HEADER:
+ // We ignore the APP1 segment and copy all other segments
+ // until SOF tag.
+ byteRead = requestByteToBuffer(4, buffer, offset, length);
+ offset += byteRead;
+ length -= byteRead;
+ // Check if this image data doesn't contain SOF.
+ if (mBuffer.position() == 2) {
+ short tag = mBuffer.getShort();
+ if (tag == JpegHeader.EOI) {
+ out.write(mBuffer.array(), 0, 2);
+ mBuffer.rewind();
+ }
+ }
+ if (mBuffer.position() < 4) {
+ return;
+ }
+ mBuffer.rewind();
+ short marker = mBuffer.getShort();
+ if (marker == JpegHeader.APP1) {
+ mByteToSkip = (mBuffer.getShort() & 0x0000ffff) - 2;
+ mState = STATE_JPEG_DATA;
+ } else if (!JpegHeader.isSofMarker(marker)) {
+ out.write(mBuffer.array(), 0, 4);
+ mByteToCopy = (mBuffer.getShort() & 0x0000ffff) - 2;
+ } else {
+ out.write(mBuffer.array(), 0, 4);
+ mState = STATE_JPEG_DATA;
+ }
+ mBuffer.rewind();
+ }
+ }
+ if (length > 0) {
+ out.write(buffer, offset, length);
+ }
+ }
+
+ /**
+ * Writes the one bytes out. The input data should be a valid JPEG format.
+ * After writing, it's Exif header will be replaced by the given header.
+ */
+ @Override
+ public void write(int oneByte) throws IOException {
+ mSingleByteArray[0] = (byte) (0xff & oneByte);
+ write(mSingleByteArray);
+ }
+
+ /**
+ * Equivalent to calling write(buffer, 0, buffer.length).
+ */
+ @Override
+ public void write(byte[] buffer) throws IOException {
+ write(buffer, 0, buffer.length);
+ }
+
+ private void writeExifData() throws IOException {
+ if (mExifData == null) {
+ return;
+ }
+ if (DEBUG) {
+ Log.v(TAG, "Writing exif data...");
+ }
+ ArrayList<ExifTag> nullTags = stripNullValueTags(mExifData);
+ createRequiredIfdAndTag();
+ int exifSize = calculateAllOffset();
+ if (exifSize + 8 > MAX_EXIF_SIZE) {
+ throw new IOException("Exif header is too large (>64Kb)");
+ }
+ OrderedDataOutputStream dataOutputStream = new OrderedDataOutputStream(out);
+ dataOutputStream.setByteOrder(ByteOrder.BIG_ENDIAN);
+ dataOutputStream.writeShort(JpegHeader.APP1);
+ dataOutputStream.writeShort((short) (exifSize + 8));
+ dataOutputStream.writeInt(EXIF_HEADER);
+ dataOutputStream.writeShort((short) 0x0000);
+ if (mExifData.getByteOrder() == ByteOrder.BIG_ENDIAN) {
+ dataOutputStream.writeShort(TIFF_BIG_ENDIAN);
+ } else {
+ dataOutputStream.writeShort(TIFF_LITTLE_ENDIAN);
+ }
+ dataOutputStream.setByteOrder(mExifData.getByteOrder());
+ dataOutputStream.writeShort(TIFF_HEADER);
+ dataOutputStream.writeInt(8);
+ writeAllTags(dataOutputStream);
+ writeThumbnail(dataOutputStream);
+ for (ExifTag t : nullTags) {
+ mExifData.addTag(t);
+ }
+ }
+
+ private ArrayList<ExifTag> stripNullValueTags(ExifData data) {
+ ArrayList<ExifTag> nullTags = new ArrayList<ExifTag>();
+ if (data.getAllTags() == null) {
+ return nullTags;
+ }
+ for (ExifTag t : data.getAllTags()) {
+ if (t.getValue() == null && !ExifInterface.isOffsetTag(t.getTagId())) {
+ data.removeTag(t.getTagId(), t.getIfd());
+ nullTags.add(t);
+ }
+ }
+ return nullTags;
+ }
+
+ private void writeThumbnail(OrderedDataOutputStream dataOutputStream) throws IOException {
+ if (mExifData.hasCompressedThumbnail()) {
+ dataOutputStream.write(mExifData.getCompressedThumbnail());
+ } else if (mExifData.hasUncompressedStrip()) {
+ for (int i = 0; i < mExifData.getStripCount(); i++) {
+ dataOutputStream.write(mExifData.getStrip(i));
+ }
+ }
+ }
+
+ private void writeAllTags(OrderedDataOutputStream dataOutputStream) throws IOException {
+ writeIfd(mExifData.getIfdData(IfdId.TYPE_IFD_0), dataOutputStream);
+ writeIfd(mExifData.getIfdData(IfdId.TYPE_IFD_EXIF), dataOutputStream);
+ IfdData interoperabilityIfd = mExifData.getIfdData(IfdId.TYPE_IFD_INTEROPERABILITY);
+ if (interoperabilityIfd != null) {
+ writeIfd(interoperabilityIfd, dataOutputStream);
+ }
+ IfdData gpsIfd = mExifData.getIfdData(IfdId.TYPE_IFD_GPS);
+ if (gpsIfd != null) {
+ writeIfd(gpsIfd, dataOutputStream);
+ }
+ IfdData ifd1 = mExifData.getIfdData(IfdId.TYPE_IFD_1);
+ if (ifd1 != null) {
+ writeIfd(mExifData.getIfdData(IfdId.TYPE_IFD_1), dataOutputStream);
+ }
+ }
+
+ private void writeIfd(IfdData ifd, OrderedDataOutputStream dataOutputStream)
+ throws IOException {
+ ExifTag[] tags = ifd.getAllTags();
+ dataOutputStream.writeShort((short) tags.length);
+ for (ExifTag tag : tags) {
+ dataOutputStream.writeShort(tag.getTagId());
+ dataOutputStream.writeShort(tag.getDataType());
+ dataOutputStream.writeInt(tag.getComponentCount());
+ if (DEBUG) {
+ Log.v(TAG, "\n" + tag.toString());
+ }
+ if (tag.getDataSize() > 4) {
+ dataOutputStream.writeInt(tag.getOffset());
+ } else {
+ ExifOutputStream.writeTagValue(tag, dataOutputStream);
+ for (int i = 0, n = 4 - tag.getDataSize(); i < n; i++) {
+ dataOutputStream.write(0);
+ }
+ }
+ }
+ dataOutputStream.writeInt(ifd.getOffsetToNextIfd());
+ for (ExifTag tag : tags) {
+ if (tag.getDataSize() > 4) {
+ ExifOutputStream.writeTagValue(tag, dataOutputStream);
+ }
+ }
+ }
+
+ private int calculateOffsetOfIfd(IfdData ifd, int offset) {
+ offset += 2 + ifd.getTagCount() * TAG_SIZE + 4;
+ ExifTag[] tags = ifd.getAllTags();
+ for (ExifTag tag : tags) {
+ if (tag.getDataSize() > 4) {
+ tag.setOffset(offset);
+ offset += tag.getDataSize();
+ }
+ }
+ return offset;
+ }
+
+ private void createRequiredIfdAndTag() throws IOException {
+ // IFD0 is required for all file
+ IfdData ifd0 = mExifData.getIfdData(IfdId.TYPE_IFD_0);
+ if (ifd0 == null) {
+ ifd0 = new IfdData(IfdId.TYPE_IFD_0);
+ mExifData.addIfdData(ifd0);
+ }
+ ExifTag exifOffsetTag = mInterface.buildUninitializedTag(ExifInterface.TAG_EXIF_IFD);
+ if (exifOffsetTag == null) {
+ throw new IOException("No definition for crucial exif tag: "
+ + ExifInterface.TAG_EXIF_IFD);
+ }
+ ifd0.setTag(exifOffsetTag);
+
+ // Exif IFD is required for all files.
+ IfdData exifIfd = mExifData.getIfdData(IfdId.TYPE_IFD_EXIF);
+ if (exifIfd == null) {
+ exifIfd = new IfdData(IfdId.TYPE_IFD_EXIF);
+ mExifData.addIfdData(exifIfd);
+ }
+
+ // GPS IFD
+ IfdData gpsIfd = mExifData.getIfdData(IfdId.TYPE_IFD_GPS);
+ if (gpsIfd != null) {
+ ExifTag gpsOffsetTag = mInterface.buildUninitializedTag(ExifInterface.TAG_GPS_IFD);
+ if (gpsOffsetTag == null) {
+ throw new IOException("No definition for crucial exif tag: "
+ + ExifInterface.TAG_GPS_IFD);
+ }
+ ifd0.setTag(gpsOffsetTag);
+ }
+
+ // Interoperability IFD
+ IfdData interIfd = mExifData.getIfdData(IfdId.TYPE_IFD_INTEROPERABILITY);
+ if (interIfd != null) {
+ ExifTag interOffsetTag = mInterface
+ .buildUninitializedTag(ExifInterface.TAG_INTEROPERABILITY_IFD);
+ if (interOffsetTag == null) {
+ throw new IOException("No definition for crucial exif tag: "
+ + ExifInterface.TAG_INTEROPERABILITY_IFD);
+ }
+ exifIfd.setTag(interOffsetTag);
+ }
+
+ IfdData ifd1 = mExifData.getIfdData(IfdId.TYPE_IFD_1);
+
+ // thumbnail
+ if (mExifData.hasCompressedThumbnail()) {
+
+ if (ifd1 == null) {
+ ifd1 = new IfdData(IfdId.TYPE_IFD_1);
+ mExifData.addIfdData(ifd1);
+ }
+
+ ExifTag offsetTag = mInterface
+ .buildUninitializedTag(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT);
+ if (offsetTag == null) {
+ throw new IOException("No definition for crucial exif tag: "
+ + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT);
+ }
+
+ ifd1.setTag(offsetTag);
+ ExifTag lengthTag = mInterface
+ .buildUninitializedTag(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH);
+ if (lengthTag == null) {
+ throw new IOException("No definition for crucial exif tag: "
+ + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH);
+ }
+
+ lengthTag.setValue(mExifData.getCompressedThumbnail().length);
+ ifd1.setTag(lengthTag);
+
+ // Get rid of tags for uncompressed if they exist.
+ ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_OFFSETS));
+ ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_BYTE_COUNTS));
+ } else if (mExifData.hasUncompressedStrip()) {
+ if (ifd1 == null) {
+ ifd1 = new IfdData(IfdId.TYPE_IFD_1);
+ mExifData.addIfdData(ifd1);
+ }
+ int stripCount = mExifData.getStripCount();
+ ExifTag offsetTag = mInterface.buildUninitializedTag(ExifInterface.TAG_STRIP_OFFSETS);
+ if (offsetTag == null) {
+ throw new IOException("No definition for crucial exif tag: "
+ + ExifInterface.TAG_STRIP_OFFSETS);
+ }
+ ExifTag lengthTag = mInterface
+ .buildUninitializedTag(ExifInterface.TAG_STRIP_BYTE_COUNTS);
+ if (lengthTag == null) {
+ throw new IOException("No definition for crucial exif tag: "
+ + ExifInterface.TAG_STRIP_BYTE_COUNTS);
+ }
+ long[] lengths = new long[stripCount];
+ for (int i = 0; i < mExifData.getStripCount(); i++) {
+ lengths[i] = mExifData.getStrip(i).length;
+ }
+ lengthTag.setValue(lengths);
+ ifd1.setTag(offsetTag);
+ ifd1.setTag(lengthTag);
+ // Get rid of tags for compressed if they exist.
+ ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT));
+ ifd1.removeTag(ExifInterface
+ .getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH));
+ } else if (ifd1 != null) {
+ // Get rid of offset and length tags if there is no thumbnail.
+ ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_OFFSETS));
+ ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_BYTE_COUNTS));
+ ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT));
+ ifd1.removeTag(ExifInterface
+ .getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH));
+ }
+ }
+
+ private int calculateAllOffset() {
+ int offset = TIFF_HEADER_SIZE;
+ IfdData ifd0 = mExifData.getIfdData(IfdId.TYPE_IFD_0);
+ offset = calculateOffsetOfIfd(ifd0, offset);
+ ifd0.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_EXIF_IFD)).setValue(offset);
+
+ IfdData exifIfd = mExifData.getIfdData(IfdId.TYPE_IFD_EXIF);
+ offset = calculateOffsetOfIfd(exifIfd, offset);
+
+ IfdData interIfd = mExifData.getIfdData(IfdId.TYPE_IFD_INTEROPERABILITY);
+ if (interIfd != null) {
+ exifIfd.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_INTEROPERABILITY_IFD))
+ .setValue(offset);
+ offset = calculateOffsetOfIfd(interIfd, offset);
+ }
+
+ IfdData gpsIfd = mExifData.getIfdData(IfdId.TYPE_IFD_GPS);
+ if (gpsIfd != null) {
+ ifd0.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_GPS_IFD)).setValue(offset);
+ offset = calculateOffsetOfIfd(gpsIfd, offset);
+ }
+
+ IfdData ifd1 = mExifData.getIfdData(IfdId.TYPE_IFD_1);
+ if (ifd1 != null) {
+ ifd0.setOffsetToNextIfd(offset);
+ offset = calculateOffsetOfIfd(ifd1, offset);
+ }
+
+ // thumbnail
+ if (mExifData.hasCompressedThumbnail()) {
+ ifd1.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT))
+ .setValue(offset);
+ offset += mExifData.getCompressedThumbnail().length;
+ } else if (mExifData.hasUncompressedStrip()) {
+ int stripCount = mExifData.getStripCount();
+ long[] offsets = new long[stripCount];
+ for (int i = 0; i < mExifData.getStripCount(); i++) {
+ offsets[i] = offset;
+ offset += mExifData.getStrip(i).length;
+ }
+ ifd1.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_OFFSETS)).setValue(
+ offsets);
+ }
+ return offset;
+ }
+
+ static void writeTagValue(ExifTag tag, OrderedDataOutputStream dataOutputStream)
+ throws IOException {
+ switch (tag.getDataType()) {
+ case ExifTag.TYPE_ASCII:
+ byte buf[] = tag.getStringByte();
+ if (buf.length == tag.getComponentCount()) {
+ buf[buf.length - 1] = 0;
+ dataOutputStream.write(buf);
+ } else {
+ dataOutputStream.write(buf);
+ dataOutputStream.write(0);
+ }
+ break;
+ case ExifTag.TYPE_LONG:
+ case ExifTag.TYPE_UNSIGNED_LONG:
+ for (int i = 0, n = tag.getComponentCount(); i < n; i++) {
+ dataOutputStream.writeInt((int) tag.getValueAt(i));
+ }
+ break;
+ case ExifTag.TYPE_RATIONAL:
+ case ExifTag.TYPE_UNSIGNED_RATIONAL:
+ for (int i = 0, n = tag.getComponentCount(); i < n; i++) {
+ dataOutputStream.writeRational(tag.getRational(i));
+ }
+ break;
+ case ExifTag.TYPE_UNDEFINED:
+ case ExifTag.TYPE_UNSIGNED_BYTE:
+ buf = new byte[tag.getComponentCount()];
+ tag.getBytes(buf);
+ dataOutputStream.write(buf);
+ break;
+ case ExifTag.TYPE_UNSIGNED_SHORT:
+ for (int i = 0, n = tag.getComponentCount(); i < n; i++) {
+ dataOutputStream.writeShort((short) tag.getValueAt(i));
+ }
+ break;
+ }
+ }
+}
diff --git a/src/com/android/messaging/util/exif/ExifParser.java b/src/com/android/messaging/util/exif/ExifParser.java
new file mode 100644
index 0000000..4b6cf68
--- /dev/null
+++ b/src/com/android/messaging/util/exif/ExifParser.java
@@ -0,0 +1,918 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+import android.util.Log;
+import com.android.messaging.util.LogUtil;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteOrder;
+import java.nio.charset.Charset;
+import java.util.Map.Entry;
+import java.util.TreeMap;
+
+/**
+ * This class provides a low-level EXIF parsing API. Given a JPEG format
+ * InputStream, the caller can request which IFD's to read via
+ * {@link #parse(java.io.InputStream, int)} with given options.
+ * <p>
+ * Below is an example of getting EXIF data from IFD 0 and EXIF IFD using the
+ * parser.
+ *
+ * <pre>
+ * void parse() {
+ * ExifParser parser = ExifParser.parse(mImageInputStream,
+ * ExifParser.OPTION_IFD_0 | ExifParser.OPTIONS_IFD_EXIF);
+ * int event = parser.next();
+ * while (event != ExifParser.EVENT_END) {
+ * switch (event) {
+ * case ExifParser.EVENT_START_OF_IFD:
+ * break;
+ * case ExifParser.EVENT_NEW_TAG:
+ * ExifTag tag = parser.getTag();
+ * if (!tag.hasValue()) {
+ * parser.registerForTagValue(tag);
+ * } else {
+ * processTag(tag);
+ * }
+ * break;
+ * case ExifParser.EVENT_VALUE_OF_REGISTERED_TAG:
+ * tag = parser.getTag();
+ * if (tag.getDataType() != ExifTag.TYPE_UNDEFINED) {
+ * processTag(tag);
+ * }
+ * break;
+ * }
+ * event = parser.next();
+ * }
+ * }
+ *
+ * void processTag(ExifTag tag) {
+ * // process the tag as you like.
+ * }
+ * </pre>
+ */
+public class ExifParser {
+ private static final boolean LOGV = false;
+ private static final String TAG = LogUtil.BUGLE_TAG;
+ /**
+ * When the parser reaches a new IFD area. Call {@link #getCurrentIfd()} to
+ * know which IFD we are in.
+ */
+ public static final int EVENT_START_OF_IFD = 0;
+ /**
+ * When the parser reaches a new tag. Call {@link #getTag()}to get the
+ * corresponding tag.
+ */
+ public static final int EVENT_NEW_TAG = 1;
+ /**
+ * When the parser reaches the value area of tag that is registered by
+ * {@link #registerForTagValue(ExifTag)} previously. Call {@link #getTag()}
+ * to get the corresponding tag.
+ */
+ public static final int EVENT_VALUE_OF_REGISTERED_TAG = 2;
+
+ /**
+ * When the parser reaches the compressed image area.
+ */
+ public static final int EVENT_COMPRESSED_IMAGE = 3;
+ /**
+ * When the parser reaches the uncompressed image strip. Call
+ * {@link #getStripIndex()} to get the index of the strip.
+ *
+ * @see #getStripIndex()
+ * @see #getStripCount()
+ */
+ public static final int EVENT_UNCOMPRESSED_STRIP = 4;
+ /**
+ * When there is nothing more to parse.
+ */
+ public static final int EVENT_END = 5;
+
+ /**
+ * Option bit to request to parse IFD0.
+ */
+ public static final int OPTION_IFD_0 = 1 << 0;
+ /**
+ * Option bit to request to parse IFD1.
+ */
+ public static final int OPTION_IFD_1 = 1 << 1;
+ /**
+ * Option bit to request to parse Exif-IFD.
+ */
+ public static final int OPTION_IFD_EXIF = 1 << 2;
+ /**
+ * Option bit to request to parse GPS-IFD.
+ */
+ public static final int OPTION_IFD_GPS = 1 << 3;
+ /**
+ * Option bit to request to parse Interoperability-IFD.
+ */
+ public static final int OPTION_IFD_INTEROPERABILITY = 1 << 4;
+ /**
+ * Option bit to request to parse thumbnail.
+ */
+ public static final int OPTION_THUMBNAIL = 1 << 5;
+
+ protected static final int EXIF_HEADER = 0x45786966; // EXIF header "Exif"
+ protected static final short EXIF_HEADER_TAIL = (short) 0x0000; // EXIF header in APP1
+
+ // TIFF header
+ protected static final short LITTLE_ENDIAN_TAG = (short) 0x4949; // "II"
+ protected static final short BIG_ENDIAN_TAG = (short) 0x4d4d; // "MM"
+ protected static final short TIFF_HEADER_TAIL = 0x002A;
+
+ protected static final int TAG_SIZE = 12;
+ protected static final int OFFSET_SIZE = 2;
+
+ private static final Charset US_ASCII = Charset.forName("US-ASCII");
+
+ protected static final int DEFAULT_IFD0_OFFSET = 8;
+
+ private final CountedDataInputStream mTiffStream;
+ private final int mOptions;
+ private int mIfdStartOffset = 0;
+ private int mNumOfTagInIfd = 0;
+ private int mIfdType;
+ private ExifTag mTag;
+ private ImageEvent mImageEvent;
+ private int mStripCount;
+ private ExifTag mStripSizeTag;
+ private ExifTag mJpegSizeTag;
+ private boolean mNeedToParseOffsetsInCurrentIfd;
+ private boolean mContainExifData = false;
+ private int mApp1End;
+ private int mOffsetToApp1EndFromSOF = 0;
+ private byte[] mDataAboveIfd0;
+ private int mIfd0Position;
+ private int mTiffStartPosition;
+ private final ExifInterface mInterface;
+
+ private static final short TAG_EXIF_IFD = ExifInterface
+ .getTrueTagKey(ExifInterface.TAG_EXIF_IFD);
+ private static final short TAG_GPS_IFD = ExifInterface.getTrueTagKey(ExifInterface.TAG_GPS_IFD);
+ private static final short TAG_INTEROPERABILITY_IFD = ExifInterface
+ .getTrueTagKey(ExifInterface.TAG_INTEROPERABILITY_IFD);
+ private static final short TAG_JPEG_INTERCHANGE_FORMAT = ExifInterface
+ .getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT);
+ private static final short TAG_JPEG_INTERCHANGE_FORMAT_LENGTH = ExifInterface
+ .getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH);
+ private static final short TAG_STRIP_OFFSETS = ExifInterface
+ .getTrueTagKey(ExifInterface.TAG_STRIP_OFFSETS);
+ private static final short TAG_STRIP_BYTE_COUNTS = ExifInterface
+ .getTrueTagKey(ExifInterface.TAG_STRIP_BYTE_COUNTS);
+
+ private final TreeMap<Integer, Object> mCorrespondingEvent = new TreeMap<Integer, Object>();
+
+ private boolean isIfdRequested(int ifdType) {
+ switch (ifdType) {
+ case IfdId.TYPE_IFD_0:
+ return (mOptions & OPTION_IFD_0) != 0;
+ case IfdId.TYPE_IFD_1:
+ return (mOptions & OPTION_IFD_1) != 0;
+ case IfdId.TYPE_IFD_EXIF:
+ return (mOptions & OPTION_IFD_EXIF) != 0;
+ case IfdId.TYPE_IFD_GPS:
+ return (mOptions & OPTION_IFD_GPS) != 0;
+ case IfdId.TYPE_IFD_INTEROPERABILITY:
+ return (mOptions & OPTION_IFD_INTEROPERABILITY) != 0;
+ }
+ return false;
+ }
+
+ private boolean isThumbnailRequested() {
+ return (mOptions & OPTION_THUMBNAIL) != 0;
+ }
+
+ private ExifParser(InputStream inputStream, int options, ExifInterface iRef)
+ throws IOException, ExifInvalidFormatException {
+ if (inputStream == null) {
+ throw new IOException("Null argument inputStream to ExifParser");
+ }
+ if (LOGV) {
+ Log.v(TAG, "Reading exif...");
+ }
+ mInterface = iRef;
+ mContainExifData = seekTiffData(inputStream);
+ mTiffStream = new CountedDataInputStream(inputStream);
+ mOptions = options;
+ if (!mContainExifData) {
+ return;
+ }
+
+ parseTiffHeader();
+ long offset = mTiffStream.readUnsignedInt();
+ if (offset > Integer.MAX_VALUE) {
+ throw new ExifInvalidFormatException("Invalid offset " + offset);
+ }
+ mIfd0Position = (int) offset;
+ mIfdType = IfdId.TYPE_IFD_0;
+ if (isIfdRequested(IfdId.TYPE_IFD_0) || needToParseOffsetsInCurrentIfd()) {
+ registerIfd(IfdId.TYPE_IFD_0, offset);
+ if (offset != DEFAULT_IFD0_OFFSET) {
+ mDataAboveIfd0 = new byte[(int) offset - DEFAULT_IFD0_OFFSET];
+ read(mDataAboveIfd0);
+ }
+ }
+ }
+
+ /**
+ * Parses the the given InputStream with the given options
+ *
+ * @exception java.io.IOException
+ * @exception ExifInvalidFormatException
+ */
+ protected static ExifParser parse(InputStream inputStream, int options, ExifInterface iRef)
+ throws IOException, ExifInvalidFormatException {
+ return new ExifParser(inputStream, options, iRef);
+ }
+
+ /**
+ * Parses the the given InputStream with default options; that is, every IFD
+ * and thumbnaill will be parsed.
+ *
+ * @exception java.io.IOException
+ * @exception ExifInvalidFormatException
+ * @see #parse(java.io.InputStream, int)
+ */
+ protected static ExifParser parse(InputStream inputStream, ExifInterface iRef)
+ throws IOException, ExifInvalidFormatException {
+ return new ExifParser(inputStream, OPTION_IFD_0 | OPTION_IFD_1
+ | OPTION_IFD_EXIF | OPTION_IFD_GPS | OPTION_IFD_INTEROPERABILITY
+ | OPTION_THUMBNAIL, iRef);
+ }
+
+ /**
+ * Moves the parser forward and returns the next parsing event
+ *
+ * @exception java.io.IOException
+ * @exception ExifInvalidFormatException
+ * @see #EVENT_START_OF_IFD
+ * @see #EVENT_NEW_TAG
+ * @see #EVENT_VALUE_OF_REGISTERED_TAG
+ * @see #EVENT_COMPRESSED_IMAGE
+ * @see #EVENT_UNCOMPRESSED_STRIP
+ * @see #EVENT_END
+ */
+ protected int next() throws IOException, ExifInvalidFormatException {
+ if (!mContainExifData) {
+ return EVENT_END;
+ }
+ int offset = mTiffStream.getReadByteCount();
+ int endOfTags = mIfdStartOffset + OFFSET_SIZE + TAG_SIZE * mNumOfTagInIfd;
+ if (offset < endOfTags) {
+ mTag = readTag();
+ if (mTag == null) {
+ return next();
+ }
+ if (mNeedToParseOffsetsInCurrentIfd) {
+ checkOffsetOrImageTag(mTag);
+ }
+ return EVENT_NEW_TAG;
+ } else if (offset == endOfTags) {
+ // There is a link to ifd1 at the end of ifd0
+ if (mIfdType == IfdId.TYPE_IFD_0) {
+ long ifdOffset = readUnsignedLong();
+ if (isIfdRequested(IfdId.TYPE_IFD_1) || isThumbnailRequested()) {
+ if (ifdOffset != 0) {
+ registerIfd(IfdId.TYPE_IFD_1, ifdOffset);
+ }
+ }
+ } else {
+ int offsetSize = 4;
+ // Some camera models use invalid length of the offset
+ if (mCorrespondingEvent.size() > 0) {
+ offsetSize = mCorrespondingEvent.firstEntry().getKey() -
+ mTiffStream.getReadByteCount();
+ }
+ if (offsetSize < 4) {
+ Log.w(TAG, "Invalid size of link to next IFD: " + offsetSize);
+ } else {
+ long ifdOffset = readUnsignedLong();
+ if (ifdOffset != 0) {
+ Log.w(TAG, "Invalid link to next IFD: " + ifdOffset);
+ }
+ }
+ }
+ }
+ while (mCorrespondingEvent.size() != 0) {
+ Entry<Integer, Object> entry = mCorrespondingEvent.pollFirstEntry();
+ Object event = entry.getValue();
+ try {
+ skipTo(entry.getKey());
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to skip to data at: " + entry.getKey() +
+ " for " + event.getClass().getName() + ", the file may be broken.");
+ continue;
+ }
+ if (event instanceof IfdEvent) {
+ mIfdType = ((IfdEvent) event).ifd;
+ mNumOfTagInIfd = mTiffStream.readUnsignedShort();
+ mIfdStartOffset = entry.getKey();
+
+ if (mNumOfTagInIfd * TAG_SIZE + mIfdStartOffset + OFFSET_SIZE > mApp1End) {
+ Log.w(TAG, "Invalid size of IFD " + mIfdType);
+ return EVENT_END;
+ }
+
+ mNeedToParseOffsetsInCurrentIfd = needToParseOffsetsInCurrentIfd();
+ if (((IfdEvent) event).isRequested) {
+ return EVENT_START_OF_IFD;
+ } else {
+ skipRemainingTagsInCurrentIfd();
+ }
+ } else if (event instanceof ImageEvent) {
+ mImageEvent = (ImageEvent) event;
+ return mImageEvent.type;
+ } else {
+ ExifTagEvent tagEvent = (ExifTagEvent) event;
+ mTag = tagEvent.tag;
+ if (mTag.getDataType() != ExifTag.TYPE_UNDEFINED) {
+ readFullTagValue(mTag);
+ checkOffsetOrImageTag(mTag);
+ }
+ if (tagEvent.isRequested) {
+ return EVENT_VALUE_OF_REGISTERED_TAG;
+ }
+ }
+ }
+ return EVENT_END;
+ }
+
+ /**
+ * Skips the tags area of current IFD, if the parser is not in the tag area,
+ * nothing will happen.
+ *
+ * @throws java.io.IOException
+ * @throws ExifInvalidFormatException
+ */
+ protected void skipRemainingTagsInCurrentIfd() throws IOException, ExifInvalidFormatException {
+ int endOfTags = mIfdStartOffset + OFFSET_SIZE + TAG_SIZE * mNumOfTagInIfd;
+ int offset = mTiffStream.getReadByteCount();
+ if (offset > endOfTags) {
+ return;
+ }
+ if (mNeedToParseOffsetsInCurrentIfd) {
+ while (offset < endOfTags) {
+ mTag = readTag();
+ offset += TAG_SIZE;
+ if (mTag == null) {
+ continue;
+ }
+ checkOffsetOrImageTag(mTag);
+ }
+ } else {
+ skipTo(endOfTags);
+ }
+ long ifdOffset = readUnsignedLong();
+ // For ifd0, there is a link to ifd1 in the end of all tags
+ if (mIfdType == IfdId.TYPE_IFD_0
+ && (isIfdRequested(IfdId.TYPE_IFD_1) || isThumbnailRequested())) {
+ if (ifdOffset > 0) {
+ registerIfd(IfdId.TYPE_IFD_1, ifdOffset);
+ }
+ }
+ }
+
+ private boolean needToParseOffsetsInCurrentIfd() {
+ switch (mIfdType) {
+ case IfdId.TYPE_IFD_0:
+ return isIfdRequested(IfdId.TYPE_IFD_EXIF) || isIfdRequested(IfdId.TYPE_IFD_GPS)
+ || isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY)
+ || isIfdRequested(IfdId.TYPE_IFD_1);
+ case IfdId.TYPE_IFD_1:
+ return isThumbnailRequested();
+ case IfdId.TYPE_IFD_EXIF:
+ // The offset to interoperability IFD is located in Exif IFD
+ return isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY);
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * If {@link #next()} return {@link #EVENT_NEW_TAG} or
+ * {@link #EVENT_VALUE_OF_REGISTERED_TAG}, call this function to get the
+ * corresponding tag.
+ * <p>
+ * For {@link #EVENT_NEW_TAG}, the tag may not contain the value if the size
+ * of the value is greater than 4 bytes. One should call
+ * {@link ExifTag#hasValue()} to check if the tag contains value. If there
+ * is no value,call {@link #registerForTagValue(ExifTag)} to have the parser
+ * emit {@link #EVENT_VALUE_OF_REGISTERED_TAG} when it reaches the area
+ * pointed by the offset.
+ * <p>
+ * When {@link #EVENT_VALUE_OF_REGISTERED_TAG} is emitted, the value of the
+ * tag will have already been read except for tags of undefined type. For
+ * tags of undefined type, call one of the read methods to get the value.
+ *
+ * @see #registerForTagValue(ExifTag)
+ * @see #read(byte[])
+ * @see #read(byte[], int, int)
+ * @see #readLong()
+ * @see #readRational()
+ * @see #readString(int)
+ * @see #readString(int, java.nio.charset.Charset)
+ */
+ protected ExifTag getTag() {
+ return mTag;
+ }
+
+ /**
+ * Gets number of tags in the current IFD area.
+ */
+ protected int getTagCountInCurrentIfd() {
+ return mNumOfTagInIfd;
+ }
+
+ /**
+ * Gets the ID of current IFD.
+ *
+ * @see IfdId#TYPE_IFD_0
+ * @see IfdId#TYPE_IFD_1
+ * @see IfdId#TYPE_IFD_GPS
+ * @see IfdId#TYPE_IFD_INTEROPERABILITY
+ * @see IfdId#TYPE_IFD_EXIF
+ */
+ protected int getCurrentIfd() {
+ return mIfdType;
+ }
+
+ /**
+ * When receiving {@link #EVENT_UNCOMPRESSED_STRIP}, call this function to
+ * get the index of this strip.
+ *
+ * @see #getStripCount()
+ */
+ protected int getStripIndex() {
+ return mImageEvent.stripIndex;
+ }
+
+ /**
+ * When receiving {@link #EVENT_UNCOMPRESSED_STRIP}, call this function to
+ * get the number of strip data.
+ *
+ * @see #getStripIndex()
+ */
+ protected int getStripCount() {
+ return mStripCount;
+ }
+
+ /**
+ * When receiving {@link #EVENT_UNCOMPRESSED_STRIP}, call this function to
+ * get the strip size.
+ */
+ protected int getStripSize() {
+ if (mStripSizeTag == null) {
+ return 0;
+ }
+ return (int) mStripSizeTag.getValueAt(0);
+ }
+
+ /**
+ * When receiving {@link #EVENT_COMPRESSED_IMAGE}, call this function to get
+ * the image data size.
+ */
+ protected int getCompressedImageSize() {
+ if (mJpegSizeTag == null) {
+ return 0;
+ }
+ return (int) mJpegSizeTag.getValueAt(0);
+ }
+
+ private void skipTo(int offset) throws IOException {
+ mTiffStream.skipTo(offset);
+ while (!mCorrespondingEvent.isEmpty() && mCorrespondingEvent.firstKey() < offset) {
+ mCorrespondingEvent.pollFirstEntry();
+ }
+ }
+
+ /**
+ * When getting {@link #EVENT_NEW_TAG} in the tag area of IFD, the tag may
+ * not contain the value if the size of the value is greater than 4 bytes.
+ * When the value is not available here, call this method so that the parser
+ * will emit {@link #EVENT_VALUE_OF_REGISTERED_TAG} when it reaches the area
+ * where the value is located.
+ *
+ * @see #EVENT_VALUE_OF_REGISTERED_TAG
+ */
+ protected void registerForTagValue(ExifTag tag) {
+ if (tag.getOffset() >= mTiffStream.getReadByteCount()) {
+ mCorrespondingEvent.put(tag.getOffset(), new ExifTagEvent(tag, true));
+ }
+ }
+
+ private void registerIfd(int ifdType, long offset) {
+ // Cast unsigned int to int since the offset is always smaller
+ // than the size of APP1 (65536)
+ mCorrespondingEvent.put((int) offset, new IfdEvent(ifdType, isIfdRequested(ifdType)));
+ }
+
+ private void registerCompressedImage(long offset) {
+ mCorrespondingEvent.put((int) offset, new ImageEvent(EVENT_COMPRESSED_IMAGE));
+ }
+
+ private void registerUncompressedStrip(int stripIndex, long offset) {
+ mCorrespondingEvent.put((int) offset, new ImageEvent(EVENT_UNCOMPRESSED_STRIP
+ , stripIndex));
+ }
+
+ private ExifTag readTag() throws IOException, ExifInvalidFormatException {
+ short tagId = mTiffStream.readShort();
+ short dataFormat = mTiffStream.readShort();
+ long numOfComp = mTiffStream.readUnsignedInt();
+ if (numOfComp > Integer.MAX_VALUE) {
+ throw new ExifInvalidFormatException(
+ "Number of component is larger then Integer.MAX_VALUE");
+ }
+ // Some invalid image file contains invalid data type. Ignore those tags
+ if (!ExifTag.isValidType(dataFormat)) {
+ Log.w(TAG, String.format("Tag %04x: Invalid data type %d", tagId, dataFormat));
+ mTiffStream.skip(4);
+ return null;
+ }
+ // TODO: handle numOfComp overflow
+ ExifTag tag = new ExifTag(tagId, dataFormat, (int) numOfComp, mIfdType,
+ ((int) numOfComp) != ExifTag.SIZE_UNDEFINED);
+ int dataSize = tag.getDataSize();
+ if (dataSize > 4) {
+ long offset = mTiffStream.readUnsignedInt();
+ if (offset > Integer.MAX_VALUE) {
+ throw new ExifInvalidFormatException(
+ "offset is larger then Integer.MAX_VALUE");
+ }
+ // Some invalid images put some undefined data before IFD0.
+ // Read the data here.
+ if ((offset < mIfd0Position) && (dataFormat == ExifTag.TYPE_UNDEFINED)) {
+ byte[] buf = new byte[(int) numOfComp];
+ System.arraycopy(mDataAboveIfd0, (int) offset - DEFAULT_IFD0_OFFSET,
+ buf, 0, (int) numOfComp);
+ tag.setValue(buf);
+ } else {
+ tag.setOffset((int) offset);
+ }
+ } else {
+ boolean defCount = tag.hasDefinedCount();
+ // Set defined count to 0 so we can add \0 to non-terminated strings
+ tag.setHasDefinedCount(false);
+ // Read value
+ readFullTagValue(tag);
+ tag.setHasDefinedCount(defCount);
+ mTiffStream.skip(4 - dataSize);
+ // Set the offset to the position of value.
+ tag.setOffset(mTiffStream.getReadByteCount() - 4);
+ }
+ return tag;
+ }
+
+ /**
+ * Check the tag, if the tag is one of the offset tag that points to the IFD
+ * or image the caller is interested in, register the IFD or image.
+ */
+ private void checkOffsetOrImageTag(ExifTag tag) {
+ // Some invalid formattd image contains tag with 0 size.
+ if (tag.getComponentCount() == 0) {
+ return;
+ }
+ short tid = tag.getTagId();
+ int ifd = tag.getIfd();
+ if (tid == TAG_EXIF_IFD && checkAllowed(ifd, ExifInterface.TAG_EXIF_IFD)) {
+ if (isIfdRequested(IfdId.TYPE_IFD_EXIF)
+ || isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY)) {
+ registerIfd(IfdId.TYPE_IFD_EXIF, tag.getValueAt(0));
+ }
+ } else if (tid == TAG_GPS_IFD && checkAllowed(ifd, ExifInterface.TAG_GPS_IFD)) {
+ if (isIfdRequested(IfdId.TYPE_IFD_GPS)) {
+ registerIfd(IfdId.TYPE_IFD_GPS, tag.getValueAt(0));
+ }
+ } else if (tid == TAG_INTEROPERABILITY_IFD
+ && checkAllowed(ifd, ExifInterface.TAG_INTEROPERABILITY_IFD)) {
+ if (isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY)) {
+ registerIfd(IfdId.TYPE_IFD_INTEROPERABILITY, tag.getValueAt(0));
+ }
+ } else if (tid == TAG_JPEG_INTERCHANGE_FORMAT
+ && checkAllowed(ifd, ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT)) {
+ if (isThumbnailRequested()) {
+ registerCompressedImage(tag.getValueAt(0));
+ }
+ } else if (tid == TAG_JPEG_INTERCHANGE_FORMAT_LENGTH
+ && checkAllowed(ifd, ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH)) {
+ if (isThumbnailRequested()) {
+ mJpegSizeTag = tag;
+ }
+ } else if (tid == TAG_STRIP_OFFSETS && checkAllowed(ifd, ExifInterface.TAG_STRIP_OFFSETS)) {
+ if (isThumbnailRequested()) {
+ if (tag.hasValue()) {
+ for (int i = 0; i < tag.getComponentCount(); i++) {
+ if (tag.getDataType() == ExifTag.TYPE_UNSIGNED_SHORT) {
+ registerUncompressedStrip(i, tag.getValueAt(i));
+ } else {
+ registerUncompressedStrip(i, tag.getValueAt(i));
+ }
+ }
+ } else {
+ mCorrespondingEvent.put(tag.getOffset(), new ExifTagEvent(tag, false));
+ }
+ }
+ } else if (tid == TAG_STRIP_BYTE_COUNTS
+ && checkAllowed(ifd, ExifInterface.TAG_STRIP_BYTE_COUNTS)
+ && isThumbnailRequested() && tag.hasValue()) {
+ mStripSizeTag = tag;
+ }
+ }
+
+ private boolean checkAllowed(int ifd, int tagId) {
+ int info = mInterface.getTagInfo().get(tagId);
+ if (info == ExifInterface.DEFINITION_NULL) {
+ return false;
+ }
+ return ExifInterface.isIfdAllowed(info, ifd);
+ }
+
+ protected void readFullTagValue(ExifTag tag) throws IOException {
+ // Some invalid images contains tags with wrong size, check it here
+ short type = tag.getDataType();
+ if (type == ExifTag.TYPE_ASCII || type == ExifTag.TYPE_UNDEFINED ||
+ type == ExifTag.TYPE_UNSIGNED_BYTE) {
+ int size = tag.getComponentCount();
+ if (mCorrespondingEvent.size() > 0) {
+ if (mCorrespondingEvent.firstEntry().getKey() < mTiffStream.getReadByteCount()
+ + size) {
+ Object event = mCorrespondingEvent.firstEntry().getValue();
+ if (event instanceof ImageEvent) {
+ // Tag value overlaps thumbnail, ignore thumbnail.
+ Log.w(TAG, "Thumbnail overlaps value for tag: \n" + tag.toString());
+ Entry<Integer, Object> entry = mCorrespondingEvent.pollFirstEntry();
+ Log.w(TAG, "Invalid thumbnail offset: " + entry.getKey());
+ } else {
+ // Tag value overlaps another tag, shorten count
+ if (event instanceof IfdEvent) {
+ Log.w(TAG, "Ifd " + ((IfdEvent) event).ifd
+ + " overlaps value for tag: \n" + tag.toString());
+ } else if (event instanceof ExifTagEvent) {
+ Log.w(TAG, "Tag value for tag: \n"
+ + ((ExifTagEvent) event).tag.toString()
+ + " overlaps value for tag: \n" + tag.toString());
+ }
+ size = mCorrespondingEvent.firstEntry().getKey()
+ - mTiffStream.getReadByteCount();
+ Log.w(TAG, "Invalid size of tag: \n" + tag.toString()
+ + " setting count to: " + size);
+ tag.forceSetComponentCount(size);
+ }
+ }
+ }
+ }
+ switch (tag.getDataType()) {
+ case ExifTag.TYPE_UNSIGNED_BYTE:
+ case ExifTag.TYPE_UNDEFINED: {
+ byte buf[] = new byte[tag.getComponentCount()];
+ read(buf);
+ tag.setValue(buf);
+ }
+ break;
+ case ExifTag.TYPE_ASCII:
+ tag.setValue(readString(tag.getComponentCount()));
+ break;
+ case ExifTag.TYPE_UNSIGNED_LONG: {
+ long value[] = new long[tag.getComponentCount()];
+ for (int i = 0, n = value.length; i < n; i++) {
+ value[i] = readUnsignedLong();
+ }
+ tag.setValue(value);
+ }
+ break;
+ case ExifTag.TYPE_UNSIGNED_RATIONAL: {
+ Rational value[] = new Rational[tag.getComponentCount()];
+ for (int i = 0, n = value.length; i < n; i++) {
+ value[i] = readUnsignedRational();
+ }
+ tag.setValue(value);
+ }
+ break;
+ case ExifTag.TYPE_UNSIGNED_SHORT: {
+ int value[] = new int[tag.getComponentCount()];
+ for (int i = 0, n = value.length; i < n; i++) {
+ value[i] = readUnsignedShort();
+ }
+ tag.setValue(value);
+ }
+ break;
+ case ExifTag.TYPE_LONG: {
+ int value[] = new int[tag.getComponentCount()];
+ for (int i = 0, n = value.length; i < n; i++) {
+ value[i] = readLong();
+ }
+ tag.setValue(value);
+ }
+ break;
+ case ExifTag.TYPE_RATIONAL: {
+ Rational value[] = new Rational[tag.getComponentCount()];
+ for (int i = 0, n = value.length; i < n; i++) {
+ value[i] = readRational();
+ }
+ tag.setValue(value);
+ }
+ break;
+ }
+ if (LOGV) {
+ Log.v(TAG, "\n" + tag.toString());
+ }
+ }
+
+ private void parseTiffHeader() throws IOException,
+ ExifInvalidFormatException {
+ short byteOrder = mTiffStream.readShort();
+ if (LITTLE_ENDIAN_TAG == byteOrder) {
+ mTiffStream.setByteOrder(ByteOrder.LITTLE_ENDIAN);
+ } else if (BIG_ENDIAN_TAG == byteOrder) {
+ mTiffStream.setByteOrder(ByteOrder.BIG_ENDIAN);
+ } else {
+ throw new ExifInvalidFormatException("Invalid TIFF header");
+ }
+
+ if (mTiffStream.readShort() != TIFF_HEADER_TAIL) {
+ throw new ExifInvalidFormatException("Invalid TIFF header");
+ }
+ }
+
+ private boolean seekTiffData(InputStream inputStream) throws IOException,
+ ExifInvalidFormatException {
+ CountedDataInputStream dataStream = new CountedDataInputStream(inputStream);
+ if (dataStream.readShort() != JpegHeader.SOI) {
+ throw new ExifInvalidFormatException("Invalid JPEG format");
+ }
+
+ short marker = dataStream.readShort();
+ while (marker != JpegHeader.EOI
+ && !JpegHeader.isSofMarker(marker)) {
+ int length = dataStream.readUnsignedShort();
+ // Some invalid formatted image contains multiple APP1,
+ // try to find the one with Exif data.
+ if (marker == JpegHeader.APP1) {
+ int header = 0;
+ short headerTail = 0;
+ if (length >= 8) {
+ header = dataStream.readInt();
+ headerTail = dataStream.readShort();
+ length -= 6;
+ if (header == EXIF_HEADER && headerTail == EXIF_HEADER_TAIL) {
+ mTiffStartPosition = dataStream.getReadByteCount();
+ mApp1End = length;
+ mOffsetToApp1EndFromSOF = mTiffStartPosition + mApp1End;
+ return true;
+ }
+ }
+ }
+ if (length < 2 || (length - 2) != dataStream.skip(length - 2)) {
+ Log.w(TAG, "Invalid JPEG format.");
+ return false;
+ }
+ marker = dataStream.readShort();
+ }
+ return false;
+ }
+
+ protected int getOffsetToExifEndFromSOF() {
+ return mOffsetToApp1EndFromSOF;
+ }
+
+ protected int getTiffStartPosition() {
+ return mTiffStartPosition;
+ }
+
+ /**
+ * Reads bytes from the InputStream.
+ */
+ protected int read(byte[] buffer, int offset, int length) throws IOException {
+ return mTiffStream.read(buffer, offset, length);
+ }
+
+ /**
+ * Equivalent to read(buffer, 0, buffer.length).
+ */
+ protected int read(byte[] buffer) throws IOException {
+ return mTiffStream.read(buffer);
+ }
+
+ /**
+ * Reads a String from the InputStream with US-ASCII charset. The parser
+ * will read n bytes and convert it to ascii string. This is used for
+ * reading values of type {@link ExifTag#TYPE_ASCII}.
+ */
+ protected String readString(int n) throws IOException {
+ return readString(n, US_ASCII);
+ }
+
+ /**
+ * Reads a String from the InputStream with the given charset. The parser
+ * will read n bytes and convert it to string. This is used for reading
+ * values of type {@link ExifTag#TYPE_ASCII}.
+ */
+ protected String readString(int n, Charset charset) throws IOException {
+ if (n > 0) {
+ return mTiffStream.readString(n, charset);
+ } else {
+ return "";
+ }
+ }
+
+ /**
+ * Reads value of type {@link ExifTag#TYPE_UNSIGNED_SHORT} from the
+ * InputStream.
+ */
+ protected int readUnsignedShort() throws IOException {
+ return mTiffStream.readShort() & 0xffff;
+ }
+
+ /**
+ * Reads value of type {@link ExifTag#TYPE_UNSIGNED_LONG} from the
+ * InputStream.
+ */
+ protected long readUnsignedLong() throws IOException {
+ return readLong() & 0xffffffffL;
+ }
+
+ /**
+ * Reads value of type {@link ExifTag#TYPE_UNSIGNED_RATIONAL} from the
+ * InputStream.
+ */
+ protected Rational readUnsignedRational() throws IOException {
+ long nomi = readUnsignedLong();
+ long denomi = readUnsignedLong();
+ return new Rational(nomi, denomi);
+ }
+
+ /**
+ * Reads value of type {@link ExifTag#TYPE_LONG} from the InputStream.
+ */
+ protected int readLong() throws IOException {
+ return mTiffStream.readInt();
+ }
+
+ /**
+ * Reads value of type {@link ExifTag#TYPE_RATIONAL} from the InputStream.
+ */
+ protected Rational readRational() throws IOException {
+ int nomi = readLong();
+ int denomi = readLong();
+ return new Rational(nomi, denomi);
+ }
+
+ private static class ImageEvent {
+ int stripIndex;
+ int type;
+
+ ImageEvent(int type) {
+ this.stripIndex = 0;
+ this.type = type;
+ }
+
+ ImageEvent(int type, int stripIndex) {
+ this.type = type;
+ this.stripIndex = stripIndex;
+ }
+ }
+
+ private static class IfdEvent {
+ int ifd;
+ boolean isRequested;
+
+ IfdEvent(int ifd, boolean isInterestedIfd) {
+ this.ifd = ifd;
+ this.isRequested = isInterestedIfd;
+ }
+ }
+
+ private static class ExifTagEvent {
+ ExifTag tag;
+ boolean isRequested;
+
+ ExifTagEvent(ExifTag tag, boolean isRequireByUser) {
+ this.tag = tag;
+ this.isRequested = isRequireByUser;
+ }
+ }
+
+ /**
+ * Gets the byte order of the current InputStream.
+ */
+ protected ByteOrder getByteOrder() {
+ return mTiffStream.getByteOrder();
+ }
+}
diff --git a/src/com/android/messaging/util/exif/ExifReader.java b/src/com/android/messaging/util/exif/ExifReader.java
new file mode 100644
index 0000000..eece48a
--- /dev/null
+++ b/src/com/android/messaging/util/exif/ExifReader.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+import android.util.Log;
+import com.android.messaging.util.LogUtil;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * This class reads the EXIF header of a JPEG file and stores it in
+ * {@link ExifData}.
+ */
+class ExifReader {
+ private static final String TAG = LogUtil.BUGLE_TAG;
+
+ private final ExifInterface mInterface;
+
+ ExifReader(ExifInterface iRef) {
+ mInterface = iRef;
+ }
+
+ /**
+ * Parses the inputStream and and returns the EXIF data in an
+ * {@link ExifData}.
+ *
+ * @throws ExifInvalidFormatException
+ * @throws java.io.IOException
+ */
+ protected ExifData read(InputStream inputStream) throws ExifInvalidFormatException,
+ IOException {
+ ExifParser parser = ExifParser.parse(inputStream, mInterface);
+ ExifData exifData = new ExifData(parser.getByteOrder());
+ ExifTag tag = null;
+
+ int event = parser.next();
+ while (event != ExifParser.EVENT_END) {
+ switch (event) {
+ case ExifParser.EVENT_START_OF_IFD:
+ exifData.addIfdData(new IfdData(parser.getCurrentIfd()));
+ break;
+ case ExifParser.EVENT_NEW_TAG:
+ tag = parser.getTag();
+ if (!tag.hasValue()) {
+ parser.registerForTagValue(tag);
+ } else {
+ exifData.getIfdData(tag.getIfd()).setTag(tag);
+ }
+ break;
+ case ExifParser.EVENT_VALUE_OF_REGISTERED_TAG:
+ tag = parser.getTag();
+ if (tag.getDataType() == ExifTag.TYPE_UNDEFINED) {
+ parser.readFullTagValue(tag);
+ }
+ exifData.getIfdData(tag.getIfd()).setTag(tag);
+ break;
+ case ExifParser.EVENT_COMPRESSED_IMAGE:
+ byte buf[] = new byte[parser.getCompressedImageSize()];
+ if (buf.length == parser.read(buf)) {
+ exifData.setCompressedThumbnail(buf);
+ } else {
+ Log.w(TAG, "Failed to read the compressed thumbnail");
+ }
+ break;
+ case ExifParser.EVENT_UNCOMPRESSED_STRIP:
+ buf = new byte[parser.getStripSize()];
+ if (buf.length == parser.read(buf)) {
+ exifData.setStripBytes(parser.getStripIndex(), buf);
+ } else {
+ Log.w(TAG, "Failed to read the strip bytes");
+ }
+ break;
+ }
+ event = parser.next();
+ }
+ return exifData;
+ }
+}
diff --git a/src/com/android/messaging/util/exif/ExifTag.java b/src/com/android/messaging/util/exif/ExifTag.java
new file mode 100644
index 0000000..da6f4ed
--- /dev/null
+++ b/src/com/android/messaging/util/exif/ExifTag.java
@@ -0,0 +1,1008 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+import java.nio.charset.Charset;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Date;
+
+/**
+ * This class stores information of an EXIF tag. For more information about
+ * defined EXIF tags, please read the Jeita EXIF 2.2 standard. Tags should be
+ * instantiated using {@link ExifInterface#buildTag}.
+ *
+ * @see ExifInterface
+ */
+public class ExifTag {
+ /**
+ * The BYTE type in the EXIF standard. An 8-bit unsigned integer.
+ */
+ public static final short TYPE_UNSIGNED_BYTE = 1;
+ /**
+ * The ASCII type in the EXIF standard. An 8-bit byte containing one 7-bit
+ * ASCII code. The final byte is terminated with NULL.
+ */
+ public static final short TYPE_ASCII = 2;
+ /**
+ * The SHORT type in the EXIF standard. A 16-bit (2-byte) unsigned integer
+ */
+ public static final short TYPE_UNSIGNED_SHORT = 3;
+ /**
+ * The LONG type in the EXIF standard. A 32-bit (4-byte) unsigned integer
+ */
+ public static final short TYPE_UNSIGNED_LONG = 4;
+ /**
+ * The RATIONAL type of EXIF standard. It consists of two LONGs. The first
+ * one is the numerator and the second one expresses the denominator.
+ */
+ public static final short TYPE_UNSIGNED_RATIONAL = 5;
+ /**
+ * The UNDEFINED type in the EXIF standard. An 8-bit byte that can take any
+ * value depending on the field definition.
+ */
+ public static final short TYPE_UNDEFINED = 7;
+ /**
+ * The SLONG type in the EXIF standard. A 32-bit (4-byte) signed integer
+ * (2's complement notation).
+ */
+ public static final short TYPE_LONG = 9;
+ /**
+ * The SRATIONAL type of EXIF standard. It consists of two SLONGs. The first
+ * one is the numerator and the second one is the denominator.
+ */
+ public static final short TYPE_RATIONAL = 10;
+
+ private static Charset US_ASCII = Charset.forName("US-ASCII");
+ private static final int TYPE_TO_SIZE_MAP[] = new int[11];
+ private static final int UNSIGNED_SHORT_MAX = 65535;
+ private static final long UNSIGNED_LONG_MAX = 4294967295L;
+ private static final long LONG_MAX = Integer.MAX_VALUE;
+ private static final long LONG_MIN = Integer.MIN_VALUE;
+
+ static {
+ TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_BYTE] = 1;
+ TYPE_TO_SIZE_MAP[TYPE_ASCII] = 1;
+ TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_SHORT] = 2;
+ TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_LONG] = 4;
+ TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_RATIONAL] = 8;
+ TYPE_TO_SIZE_MAP[TYPE_UNDEFINED] = 1;
+ TYPE_TO_SIZE_MAP[TYPE_LONG] = 4;
+ TYPE_TO_SIZE_MAP[TYPE_RATIONAL] = 8;
+ }
+
+ static final int SIZE_UNDEFINED = 0;
+
+ // Exif TagId
+ private final short mTagId;
+ // Exif Tag Type
+ private final short mDataType;
+ // If tag has defined count
+ private boolean mHasDefinedDefaultComponentCount;
+ // Actual data count in tag (should be number of elements in value array)
+ private int mComponentCountActual;
+ // The ifd that this tag should be put in
+ private int mIfd;
+ // The value (array of elements of type Tag Type)
+ private Object mValue;
+ // Value offset in exif header.
+ private int mOffset;
+
+ private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("yyyy:MM:dd kk:mm:ss");
+
+ /**
+ * Returns true if the given IFD is a valid IFD.
+ */
+ public static boolean isValidIfd(int ifdId) {
+ return ifdId == IfdId.TYPE_IFD_0 || ifdId == IfdId.TYPE_IFD_1
+ || ifdId == IfdId.TYPE_IFD_EXIF || ifdId == IfdId.TYPE_IFD_INTEROPERABILITY
+ || ifdId == IfdId.TYPE_IFD_GPS;
+ }
+
+ /**
+ * Returns true if a given type is a valid tag type.
+ */
+ public static boolean isValidType(short type) {
+ return type == TYPE_UNSIGNED_BYTE || type == TYPE_ASCII ||
+ type == TYPE_UNSIGNED_SHORT || type == TYPE_UNSIGNED_LONG ||
+ type == TYPE_UNSIGNED_RATIONAL || type == TYPE_UNDEFINED ||
+ type == TYPE_LONG || type == TYPE_RATIONAL;
+ }
+
+ // Use builtTag in ExifInterface instead of constructor.
+ ExifTag(short tagId, short type, int componentCount, int ifd,
+ boolean hasDefinedComponentCount) {
+ mTagId = tagId;
+ mDataType = type;
+ mComponentCountActual = componentCount;
+ mHasDefinedDefaultComponentCount = hasDefinedComponentCount;
+ mIfd = ifd;
+ mValue = null;
+ }
+
+ /**
+ * Gets the element size of the given data type in bytes.
+ *
+ * @see #TYPE_ASCII
+ * @see #TYPE_LONG
+ * @see #TYPE_RATIONAL
+ * @see #TYPE_UNDEFINED
+ * @see #TYPE_UNSIGNED_BYTE
+ * @see #TYPE_UNSIGNED_LONG
+ * @see #TYPE_UNSIGNED_RATIONAL
+ * @see #TYPE_UNSIGNED_SHORT
+ */
+ public static int getElementSize(short type) {
+ return TYPE_TO_SIZE_MAP[type];
+ }
+
+ /**
+ * Returns the ID of the IFD this tag belongs to.
+ *
+ * @see IfdId#TYPE_IFD_0
+ * @see IfdId#TYPE_IFD_1
+ * @see IfdId#TYPE_IFD_EXIF
+ * @see IfdId#TYPE_IFD_GPS
+ * @see IfdId#TYPE_IFD_INTEROPERABILITY
+ */
+ public int getIfd() {
+ return mIfd;
+ }
+
+ protected void setIfd(int ifdId) {
+ mIfd = ifdId;
+ }
+
+ /**
+ * Gets the TID of this tag.
+ */
+ public short getTagId() {
+ return mTagId;
+ }
+
+ /**
+ * Gets the data type of this tag
+ *
+ * @see #TYPE_ASCII
+ * @see #TYPE_LONG
+ * @see #TYPE_RATIONAL
+ * @see #TYPE_UNDEFINED
+ * @see #TYPE_UNSIGNED_BYTE
+ * @see #TYPE_UNSIGNED_LONG
+ * @see #TYPE_UNSIGNED_RATIONAL
+ * @see #TYPE_UNSIGNED_SHORT
+ */
+ public short getDataType() {
+ return mDataType;
+ }
+
+ /**
+ * Gets the total data size in bytes of the value of this tag.
+ */
+ public int getDataSize() {
+ return getComponentCount() * getElementSize(getDataType());
+ }
+
+ /**
+ * Gets the component count of this tag.
+ */
+
+ // TODO: fix integer overflows with this
+ public int getComponentCount() {
+ return mComponentCountActual;
+ }
+
+ /**
+ * Sets the component count of this tag. Call this function before
+ * setValue() if the length of value does not match the component count.
+ */
+ protected void forceSetComponentCount(int count) {
+ mComponentCountActual = count;
+ }
+
+ /**
+ * Returns true if this ExifTag contains value; otherwise, this tag will
+ * contain an offset value that is determined when the tag is written.
+ */
+ public boolean hasValue() {
+ return mValue != null;
+ }
+
+ /**
+ * Sets integer values into this tag. This method should be used for tags of
+ * type {@link #TYPE_UNSIGNED_SHORT}. This method will fail if:
+ * <ul>
+ * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_SHORT},
+ * {@link #TYPE_UNSIGNED_LONG}, or {@link #TYPE_LONG}.</li>
+ * <li>The value overflows.</li>
+ * <li>The value.length does NOT match the component count in the definition
+ * for this tag.</li>
+ * </ul>
+ */
+ public boolean setValue(int[] value) {
+ if (checkBadComponentCount(value.length)) {
+ return false;
+ }
+ if (mDataType != TYPE_UNSIGNED_SHORT && mDataType != TYPE_LONG &&
+ mDataType != TYPE_UNSIGNED_LONG) {
+ return false;
+ }
+ if (mDataType == TYPE_UNSIGNED_SHORT && checkOverflowForUnsignedShort(value)) {
+ return false;
+ } else if (mDataType == TYPE_UNSIGNED_LONG && checkOverflowForUnsignedLong(value)) {
+ return false;
+ }
+
+ long[] data = new long[value.length];
+ for (int i = 0; i < value.length; i++) {
+ data[i] = value[i];
+ }
+ mValue = data;
+ mComponentCountActual = value.length;
+ return true;
+ }
+
+ /**
+ * Sets integer value into this tag. This method should be used for tags of
+ * type {@link #TYPE_UNSIGNED_SHORT}, or {@link #TYPE_LONG}. This method
+ * will fail if:
+ * <ul>
+ * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_SHORT},
+ * {@link #TYPE_UNSIGNED_LONG}, or {@link #TYPE_LONG}.</li>
+ * <li>The value overflows.</li>
+ * <li>The component count in the definition of this tag is not 1.</li>
+ * </ul>
+ */
+ public boolean setValue(int value) {
+ return setValue(new int[] {
+ value
+ });
+ }
+
+ /**
+ * Sets long values into this tag. This method should be used for tags of
+ * type {@link #TYPE_UNSIGNED_LONG}. This method will fail if:
+ * <ul>
+ * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_LONG}.</li>
+ * <li>The value overflows.</li>
+ * <li>The value.length does NOT match the component count in the definition
+ * for this tag.</li>
+ * </ul>
+ */
+ public boolean setValue(long[] value) {
+ if (checkBadComponentCount(value.length) || mDataType != TYPE_UNSIGNED_LONG) {
+ return false;
+ }
+ if (checkOverflowForUnsignedLong(value)) {
+ return false;
+ }
+ mValue = value;
+ mComponentCountActual = value.length;
+ return true;
+ }
+
+ /**
+ * Sets long values into this tag. This method should be used for tags of
+ * type {@link #TYPE_UNSIGNED_LONG}. This method will fail if:
+ * <ul>
+ * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_LONG}.</li>
+ * <li>The value overflows.</li>
+ * <li>The component count in the definition for this tag is not 1.</li>
+ * </ul>
+ */
+ public boolean setValue(long value) {
+ return setValue(new long[] {
+ value
+ });
+ }
+
+ /**
+ * Sets a string value into this tag. This method should be used for tags of
+ * type {@link #TYPE_ASCII}. The string is converted to an ASCII string.
+ * Characters that cannot be converted are replaced with '?'. The length of
+ * the string must be equal to either (component count -1) or (component
+ * count). The final byte will be set to the string null terminator '\0',
+ * overwriting the last character in the string if the value.length is equal
+ * to the component count. This method will fail if:
+ * <ul>
+ * <li>The data type is not {@link #TYPE_ASCII} or {@link #TYPE_UNDEFINED}.</li>
+ * <li>The length of the string is not equal to (component count -1) or
+ * (component count) in the definition for this tag.</li>
+ * </ul>
+ */
+ public boolean setValue(String value) {
+ if (mDataType != TYPE_ASCII && mDataType != TYPE_UNDEFINED) {
+ return false;
+ }
+
+ byte[] buf = value.getBytes(US_ASCII);
+ byte[] finalBuf = buf;
+ if (buf.length > 0) {
+ finalBuf = (buf[buf.length - 1] == 0 || mDataType == TYPE_UNDEFINED) ? buf : Arrays
+ .copyOf(buf, buf.length + 1);
+ } else if (mDataType == TYPE_ASCII && mComponentCountActual == 1) {
+ finalBuf = new byte[] { 0 };
+ }
+ int count = finalBuf.length;
+ if (checkBadComponentCount(count)) {
+ return false;
+ }
+ mComponentCountActual = count;
+ mValue = finalBuf;
+ return true;
+ }
+
+ /**
+ * Sets Rational values into this tag. This method should be used for tags
+ * of type {@link #TYPE_UNSIGNED_RATIONAL}, or {@link #TYPE_RATIONAL}. This
+ * method will fail if:
+ * <ul>
+ * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_RATIONAL}
+ * or {@link #TYPE_RATIONAL}.</li>
+ * <li>The value overflows.</li>
+ * <li>The value.length does NOT match the component count in the definition
+ * for this tag.</li>
+ * </ul>
+ *
+ * @see Rational
+ */
+ public boolean setValue(Rational[] value) {
+ if (checkBadComponentCount(value.length)) {
+ return false;
+ }
+ if (mDataType != TYPE_UNSIGNED_RATIONAL && mDataType != TYPE_RATIONAL) {
+ return false;
+ }
+ if (mDataType == TYPE_UNSIGNED_RATIONAL && checkOverflowForUnsignedRational(value)) {
+ return false;
+ } else if (mDataType == TYPE_RATIONAL && checkOverflowForRational(value)) {
+ return false;
+ }
+
+ mValue = value;
+ mComponentCountActual = value.length;
+ return true;
+ }
+
+ /**
+ * Sets a Rational value into this tag. This method should be used for tags
+ * of type {@link #TYPE_UNSIGNED_RATIONAL}, or {@link #TYPE_RATIONAL}. This
+ * method will fail if:
+ * <ul>
+ * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_RATIONAL}
+ * or {@link #TYPE_RATIONAL}.</li>
+ * <li>The value overflows.</li>
+ * <li>The component count in the definition for this tag is not 1.</li>
+ * </ul>
+ *
+ * @see Rational
+ */
+ public boolean setValue(Rational value) {
+ return setValue(new Rational[] {
+ value
+ });
+ }
+
+ /**
+ * Sets byte values into this tag. This method should be used for tags of
+ * type {@link #TYPE_UNSIGNED_BYTE} or {@link #TYPE_UNDEFINED}. This method
+ * will fail if:
+ * <ul>
+ * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_BYTE} or
+ * {@link #TYPE_UNDEFINED} .</li>
+ * <li>The length does NOT match the component count in the definition for
+ * this tag.</li>
+ * </ul>
+ */
+ public boolean setValue(byte[] value, int offset, int length) {
+ if (checkBadComponentCount(length)) {
+ return false;
+ }
+ if (mDataType != TYPE_UNSIGNED_BYTE && mDataType != TYPE_UNDEFINED) {
+ return false;
+ }
+ mValue = new byte[length];
+ System.arraycopy(value, offset, mValue, 0, length);
+ mComponentCountActual = length;
+ return true;
+ }
+
+ /**
+ * Equivalent to setValue(value, 0, value.length).
+ */
+ public boolean setValue(byte[] value) {
+ return setValue(value, 0, value.length);
+ }
+
+ /**
+ * Sets byte value into this tag. This method should be used for tags of
+ * type {@link #TYPE_UNSIGNED_BYTE} or {@link #TYPE_UNDEFINED}. This method
+ * will fail if:
+ * <ul>
+ * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_BYTE} or
+ * {@link #TYPE_UNDEFINED} .</li>
+ * <li>The component count in the definition for this tag is not 1.</li>
+ * </ul>
+ */
+ public boolean setValue(byte value) {
+ return setValue(new byte[] {
+ value
+ });
+ }
+
+ /**
+ * Sets the value for this tag using an appropriate setValue method for the
+ * given object. This method will fail if:
+ * <ul>
+ * <li>The corresponding setValue method for the class of the object passed
+ * in would fail.</li>
+ * <li>There is no obvious way to cast the object passed in into an EXIF tag
+ * type.</li>
+ * </ul>
+ */
+ public boolean setValue(Object obj) {
+ if (obj == null) {
+ return false;
+ } else if (obj instanceof Short) {
+ return setValue(((Short) obj).shortValue() & 0x0ffff);
+ } else if (obj instanceof String) {
+ return setValue((String) obj);
+ } else if (obj instanceof int[]) {
+ return setValue((int[]) obj);
+ } else if (obj instanceof long[]) {
+ return setValue((long[]) obj);
+ } else if (obj instanceof Rational) {
+ return setValue((Rational) obj);
+ } else if (obj instanceof Rational[]) {
+ return setValue((Rational[]) obj);
+ } else if (obj instanceof byte[]) {
+ return setValue((byte[]) obj);
+ } else if (obj instanceof Integer) {
+ return setValue(((Integer) obj).intValue());
+ } else if (obj instanceof Long) {
+ return setValue(((Long) obj).longValue());
+ } else if (obj instanceof Byte) {
+ return setValue(((Byte) obj).byteValue());
+ } else if (obj instanceof Short[]) {
+ // Nulls in this array are treated as zeroes.
+ Short[] arr = (Short[]) obj;
+ int[] fin = new int[arr.length];
+ for (int i = 0; i < arr.length; i++) {
+ fin[i] = (arr[i] == null) ? 0 : arr[i].shortValue() & 0x0ffff;
+ }
+ return setValue(fin);
+ } else if (obj instanceof Integer[]) {
+ // Nulls in this array are treated as zeroes.
+ Integer[] arr = (Integer[]) obj;
+ int[] fin = new int[arr.length];
+ for (int i = 0; i < arr.length; i++) {
+ fin[i] = (arr[i] == null) ? 0 : arr[i].intValue();
+ }
+ return setValue(fin);
+ } else if (obj instanceof Long[]) {
+ // Nulls in this array are treated as zeroes.
+ Long[] arr = (Long[]) obj;
+ long[] fin = new long[arr.length];
+ for (int i = 0; i < arr.length; i++) {
+ fin[i] = (arr[i] == null) ? 0 : arr[i].longValue();
+ }
+ return setValue(fin);
+ } else if (obj instanceof Byte[]) {
+ // Nulls in this array are treated as zeroes.
+ Byte[] arr = (Byte[]) obj;
+ byte[] fin = new byte[arr.length];
+ for (int i = 0; i < arr.length; i++) {
+ fin[i] = (arr[i] == null) ? 0 : arr[i].byteValue();
+ }
+ return setValue(fin);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Sets a timestamp to this tag. The method converts the timestamp with the
+ * format of "yyyy:MM:dd kk:mm:ss" and calls {@link #setValue(String)}. This
+ * method will fail if the data type is not {@link #TYPE_ASCII} or the
+ * component count of this tag is not 20 or undefined.
+ *
+ * @param time the number of milliseconds since Jan. 1, 1970 GMT
+ * @return true on success
+ */
+ public boolean setTimeValue(long time) {
+ // synchronized on TIME_FORMAT as SimpleDateFormat is not thread safe
+ synchronized (TIME_FORMAT) {
+ return setValue(TIME_FORMAT.format(new Date(time)));
+ }
+ }
+
+ /**
+ * Gets the value as a String. This method should be used for tags of type
+ * {@link #TYPE_ASCII}.
+ *
+ * @return the value as a String, or null if the tag's value does not exist
+ * or cannot be converted to a String.
+ */
+ public String getValueAsString() {
+ if (mValue == null) {
+ return null;
+ } else if (mValue instanceof String) {
+ return (String) mValue;
+ } else if (mValue instanceof byte[]) {
+ return new String((byte[]) mValue, US_ASCII);
+ }
+ return null;
+ }
+
+ /**
+ * Gets the value as a String. This method should be used for tags of type
+ * {@link #TYPE_ASCII}.
+ *
+ * @param defaultValue the String to return if the tag's value does not
+ * exist or cannot be converted to a String.
+ * @return the tag's value as a String, or the defaultValue.
+ */
+ public String getValueAsString(String defaultValue) {
+ String s = getValueAsString();
+ if (s == null) {
+ return defaultValue;
+ }
+ return s;
+ }
+
+ /**
+ * Gets the value as a byte array. This method should be used for tags of
+ * type {@link #TYPE_UNDEFINED} or {@link #TYPE_UNSIGNED_BYTE}.
+ *
+ * @return the value as a byte array, or null if the tag's value does not
+ * exist or cannot be converted to a byte array.
+ */
+ public byte[] getValueAsBytes() {
+ if (mValue instanceof byte[]) {
+ return (byte[]) mValue;
+ }
+ return null;
+ }
+
+ /**
+ * Gets the value as a byte. If there are more than 1 bytes in this value,
+ * gets the first byte. This method should be used for tags of type
+ * {@link #TYPE_UNDEFINED} or {@link #TYPE_UNSIGNED_BYTE}.
+ *
+ * @param defaultValue the byte to return if tag's value does not exist or
+ * cannot be converted to a byte.
+ * @return the tag's value as a byte, or the defaultValue.
+ */
+ public byte getValueAsByte(byte defaultValue) {
+ byte[] b = getValueAsBytes();
+ if (b == null || b.length < 1) {
+ return defaultValue;
+ }
+ return b[0];
+ }
+
+ /**
+ * Gets the value as an array of Rationals. This method should be used for
+ * tags of type {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}.
+ *
+ * @return the value as as an array of Rationals, or null if the tag's value
+ * does not exist or cannot be converted to an array of Rationals.
+ */
+ public Rational[] getValueAsRationals() {
+ if (mValue instanceof Rational[]) {
+ return (Rational[]) mValue;
+ }
+ return null;
+ }
+
+ /**
+ * Gets the value as a Rational. If there are more than 1 Rationals in this
+ * value, gets the first one. This method should be used for tags of type
+ * {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}.
+ *
+ * @param defaultValue the Rational to return if tag's value does not exist
+ * or cannot be converted to a Rational.
+ * @return the tag's value as a Rational, or the defaultValue.
+ */
+ public Rational getValueAsRational(Rational defaultValue) {
+ Rational[] r = getValueAsRationals();
+ if (r == null || r.length < 1) {
+ return defaultValue;
+ }
+ return r[0];
+ }
+
+ /**
+ * Gets the value as a Rational. If there are more than 1 Rationals in this
+ * value, gets the first one. This method should be used for tags of type
+ * {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}.
+ *
+ * @param defaultValue the numerator of the Rational to return if tag's
+ * value does not exist or cannot be converted to a Rational (the
+ * denominator will be 1).
+ * @return the tag's value as a Rational, or the defaultValue.
+ */
+ public Rational getValueAsRational(long defaultValue) {
+ Rational defaultVal = new Rational(defaultValue, 1);
+ return getValueAsRational(defaultVal);
+ }
+
+ /**
+ * Gets the value as an array of ints. This method should be used for tags
+ * of type {@link #TYPE_UNSIGNED_SHORT}, {@link #TYPE_UNSIGNED_LONG}.
+ *
+ * @return the value as as an array of ints, or null if the tag's value does
+ * not exist or cannot be converted to an array of ints.
+ */
+ public int[] getValueAsInts() {
+ if (mValue == null) {
+ return null;
+ } else if (mValue instanceof long[]) {
+ long[] val = (long[]) mValue;
+ int[] arr = new int[val.length];
+ for (int i = 0; i < val.length; i++) {
+ arr[i] = (int) val[i]; // Truncates
+ }
+ return arr;
+ }
+ return null;
+ }
+
+ /**
+ * Gets the value as an int. If there are more than 1 ints in this value,
+ * gets the first one. This method should be used for tags of type
+ * {@link #TYPE_UNSIGNED_SHORT}, {@link #TYPE_UNSIGNED_LONG}.
+ *
+ * @param defaultValue the int to return if tag's value does not exist or
+ * cannot be converted to an int.
+ * @return the tag's value as a int, or the defaultValue.
+ */
+ public int getValueAsInt(int defaultValue) {
+ int[] i = getValueAsInts();
+ if (i == null || i.length < 1) {
+ return defaultValue;
+ }
+ return i[0];
+ }
+
+ /**
+ * Gets the value as an array of longs. This method should be used for tags
+ * of type {@link #TYPE_UNSIGNED_LONG}.
+ *
+ * @return the value as as an array of longs, or null if the tag's value
+ * does not exist or cannot be converted to an array of longs.
+ */
+ public long[] getValueAsLongs() {
+ if (mValue instanceof long[]) {
+ return (long[]) mValue;
+ }
+ return null;
+ }
+
+ /**
+ * Gets the value or null if none exists. If there are more than 1 longs in
+ * this value, gets the first one. This method should be used for tags of
+ * type {@link #TYPE_UNSIGNED_LONG}.
+ *
+ * @param defaultValue the long to return if tag's value does not exist or
+ * cannot be converted to a long.
+ * @return the tag's value as a long, or the defaultValue.
+ */
+ public long getValueAsLong(long defaultValue) {
+ long[] l = getValueAsLongs();
+ if (l == null || l.length < 1) {
+ return defaultValue;
+ }
+ return l[0];
+ }
+
+ /**
+ * Gets the tag's value or null if none exists.
+ */
+ public Object getValue() {
+ return mValue;
+ }
+
+ /**
+ * Gets a long representation of the value.
+ *
+ * @param defaultValue value to return if there is no value or value is a
+ * rational with a denominator of 0.
+ * @return the tag's value as a long, or defaultValue if no representation
+ * exists.
+ */
+ public long forceGetValueAsLong(long defaultValue) {
+ long[] l = getValueAsLongs();
+ if (l != null && l.length >= 1) {
+ return l[0];
+ }
+ byte[] b = getValueAsBytes();
+ if (b != null && b.length >= 1) {
+ return b[0];
+ }
+ Rational[] r = getValueAsRationals();
+ if (r != null && r.length >= 1 && r[0].getDenominator() != 0) {
+ return (long) r[0].toDouble();
+ }
+ return defaultValue;
+ }
+
+ /**
+ * Gets a string representation of the value.
+ */
+ public String forceGetValueAsString() {
+ if (mValue == null) {
+ return "";
+ } else if (mValue instanceof byte[]) {
+ if (mDataType == TYPE_ASCII) {
+ return new String((byte[]) mValue, US_ASCII);
+ } else {
+ return Arrays.toString((byte[]) mValue);
+ }
+ } else if (mValue instanceof long[]) {
+ if (((long[]) mValue).length == 1) {
+ return String.valueOf(((long[]) mValue)[0]);
+ } else {
+ return Arrays.toString((long[]) mValue);
+ }
+ } else if (mValue instanceof Object[]) {
+ if (((Object[]) mValue).length == 1) {
+ Object val = ((Object[]) mValue)[0];
+ if (val == null) {
+ return "";
+ } else {
+ return val.toString();
+ }
+ } else {
+ return Arrays.toString((Object[]) mValue);
+ }
+ } else {
+ return mValue.toString();
+ }
+ }
+
+ /**
+ * Gets the value for type {@link #TYPE_ASCII}, {@link #TYPE_LONG},
+ * {@link #TYPE_UNDEFINED}, {@link #TYPE_UNSIGNED_BYTE},
+ * {@link #TYPE_UNSIGNED_LONG}, or {@link #TYPE_UNSIGNED_SHORT}. For
+ * {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}, call
+ * {@link #getRational(int)} instead.
+ *
+ * @exception IllegalArgumentException if the data type is
+ * {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}.
+ */
+ protected long getValueAt(int index) {
+ if (mValue instanceof long[]) {
+ return ((long[]) mValue)[index];
+ } else if (mValue instanceof byte[]) {
+ return ((byte[]) mValue)[index];
+ }
+ throw new IllegalArgumentException("Cannot get integer value from "
+ + convertTypeToString(mDataType));
+ }
+
+ /**
+ * Gets the {@link #TYPE_ASCII} data.
+ *
+ * @exception IllegalArgumentException If the type is NOT
+ * {@link #TYPE_ASCII}.
+ */
+ protected String getString() {
+ if (mDataType != TYPE_ASCII) {
+ throw new IllegalArgumentException("Cannot get ASCII value from "
+ + convertTypeToString(mDataType));
+ }
+ return new String((byte[]) mValue, US_ASCII);
+ }
+
+ /*
+ * Get the converted ascii byte. Used by ExifOutputStream.
+ */
+ protected byte[] getStringByte() {
+ return (byte[]) mValue;
+ }
+
+ /**
+ * Gets the {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL} data.
+ *
+ * @exception IllegalArgumentException If the type is NOT
+ * {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}.
+ */
+ protected Rational getRational(int index) {
+ if ((mDataType != TYPE_RATIONAL) && (mDataType != TYPE_UNSIGNED_RATIONAL)) {
+ throw new IllegalArgumentException("Cannot get RATIONAL value from "
+ + convertTypeToString(mDataType));
+ }
+ return ((Rational[]) mValue)[index];
+ }
+
+ /**
+ * Equivalent to getBytes(buffer, 0, buffer.length).
+ */
+ protected void getBytes(byte[] buf) {
+ getBytes(buf, 0, buf.length);
+ }
+
+ /**
+ * Gets the {@link #TYPE_UNDEFINED} or {@link #TYPE_UNSIGNED_BYTE} data.
+ *
+ * @param buf the byte array in which to store the bytes read.
+ * @param offset the initial position in buffer to store the bytes.
+ * @param length the maximum number of bytes to store in buffer. If length >
+ * component count, only the valid bytes will be stored.
+ * @exception IllegalArgumentException If the type is NOT
+ * {@link #TYPE_UNDEFINED} or {@link #TYPE_UNSIGNED_BYTE}.
+ */
+ protected void getBytes(byte[] buf, int offset, int length) {
+ if ((mDataType != TYPE_UNDEFINED) && (mDataType != TYPE_UNSIGNED_BYTE)) {
+ throw new IllegalArgumentException("Cannot get BYTE value from "
+ + convertTypeToString(mDataType));
+ }
+ System.arraycopy(mValue, 0, buf, offset,
+ (length > mComponentCountActual) ? mComponentCountActual : length);
+ }
+
+ /**
+ * Gets the offset of this tag. This is only valid if this data size > 4 and
+ * contains an offset to the location of the actual value.
+ */
+ protected int getOffset() {
+ return mOffset;
+ }
+
+ /**
+ * Sets the offset of this tag.
+ */
+ protected void setOffset(int offset) {
+ mOffset = offset;
+ }
+
+ protected void setHasDefinedCount(boolean d) {
+ mHasDefinedDefaultComponentCount = d;
+ }
+
+ protected boolean hasDefinedCount() {
+ return mHasDefinedDefaultComponentCount;
+ }
+
+ private boolean checkBadComponentCount(int count) {
+ if (mHasDefinedDefaultComponentCount && (mComponentCountActual != count)) {
+ return true;
+ }
+ return false;
+ }
+
+ private static String convertTypeToString(short type) {
+ switch (type) {
+ case TYPE_UNSIGNED_BYTE:
+ return "UNSIGNED_BYTE";
+ case TYPE_ASCII:
+ return "ASCII";
+ case TYPE_UNSIGNED_SHORT:
+ return "UNSIGNED_SHORT";
+ case TYPE_UNSIGNED_LONG:
+ return "UNSIGNED_LONG";
+ case TYPE_UNSIGNED_RATIONAL:
+ return "UNSIGNED_RATIONAL";
+ case TYPE_UNDEFINED:
+ return "UNDEFINED";
+ case TYPE_LONG:
+ return "LONG";
+ case TYPE_RATIONAL:
+ return "RATIONAL";
+ default:
+ return "";
+ }
+ }
+
+ private boolean checkOverflowForUnsignedShort(int[] value) {
+ for (int v : value) {
+ if (v > UNSIGNED_SHORT_MAX || v < 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean checkOverflowForUnsignedLong(long[] value) {
+ for (long v : value) {
+ if (v < 0 || v > UNSIGNED_LONG_MAX) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean checkOverflowForUnsignedLong(int[] value) {
+ for (int v : value) {
+ if (v < 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean checkOverflowForUnsignedRational(Rational[] value) {
+ for (Rational v : value) {
+ if (v.getNumerator() < 0 || v.getDenominator() < 0
+ || v.getNumerator() > UNSIGNED_LONG_MAX
+ || v.getDenominator() > UNSIGNED_LONG_MAX) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean checkOverflowForRational(Rational[] value) {
+ for (Rational v : value) {
+ if (v.getNumerator() < LONG_MIN || v.getDenominator() < LONG_MIN
+ || v.getNumerator() > LONG_MAX
+ || v.getDenominator() > LONG_MAX) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) {
+ return false;
+ }
+ if (obj instanceof ExifTag) {
+ ExifTag tag = (ExifTag) obj;
+ if (tag.mTagId != this.mTagId
+ || tag.mComponentCountActual != this.mComponentCountActual
+ || tag.mDataType != this.mDataType) {
+ return false;
+ }
+ if (mValue != null) {
+ if (tag.mValue == null) {
+ return false;
+ } else if (mValue instanceof long[]) {
+ if (!(tag.mValue instanceof long[])) {
+ return false;
+ }
+ return Arrays.equals((long[]) mValue, (long[]) tag.mValue);
+ } else if (mValue instanceof Rational[]) {
+ if (!(tag.mValue instanceof Rational[])) {
+ return false;
+ }
+ return Arrays.equals((Rational[]) mValue, (Rational[]) tag.mValue);
+ } else if (mValue instanceof byte[]) {
+ if (!(tag.mValue instanceof byte[])) {
+ return false;
+ }
+ return Arrays.equals((byte[]) mValue, (byte[]) tag.mValue);
+ } else {
+ return mValue.equals(tag.mValue);
+ }
+ } else {
+ return tag.mValue == null;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("tag id: %04X\n", mTagId) + "ifd id: " + mIfd + "\ntype: "
+ + convertTypeToString(mDataType) + "\ncount: " + mComponentCountActual
+ + "\noffset: " + mOffset + "\nvalue: " + forceGetValueAsString() + "\n";
+ }
+
+}
diff --git a/src/com/android/messaging/util/exif/IfdData.java b/src/com/android/messaging/util/exif/IfdData.java
new file mode 100644
index 0000000..6b8c293
--- /dev/null
+++ b/src/com/android/messaging/util/exif/IfdData.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * This class stores all the tags in an IFD.
+ *
+ * @see ExifData
+ * @see ExifTag
+ */
+class IfdData {
+
+ private final int mIfdId;
+ private final Map<Short, ExifTag> mExifTags = new HashMap<Short, ExifTag>();
+ private int mOffsetToNextIfd = 0;
+ private static final int[] sIfds = {
+ IfdId.TYPE_IFD_0, IfdId.TYPE_IFD_1, IfdId.TYPE_IFD_EXIF,
+ IfdId.TYPE_IFD_INTEROPERABILITY, IfdId.TYPE_IFD_GPS
+ };
+ /**
+ * Creates an IfdData with given IFD ID.
+ *
+ * @see IfdId#TYPE_IFD_0
+ * @see IfdId#TYPE_IFD_1
+ * @see IfdId#TYPE_IFD_EXIF
+ * @see IfdId#TYPE_IFD_GPS
+ * @see IfdId#TYPE_IFD_INTEROPERABILITY
+ */
+ IfdData(int ifdId) {
+ mIfdId = ifdId;
+ }
+
+ protected static int[] getIfds() {
+ return sIfds;
+ }
+
+ /**
+ * Get a array the contains all {@link ExifTag} in this IFD.
+ */
+ protected ExifTag[] getAllTags() {
+ return mExifTags.values().toArray(new ExifTag[mExifTags.size()]);
+ }
+
+ /**
+ * Gets the ID of this IFD.
+ *
+ * @see IfdId#TYPE_IFD_0
+ * @see IfdId#TYPE_IFD_1
+ * @see IfdId#TYPE_IFD_EXIF
+ * @see IfdId#TYPE_IFD_GPS
+ * @see IfdId#TYPE_IFD_INTEROPERABILITY
+ */
+ protected int getId() {
+ return mIfdId;
+ }
+
+ /**
+ * Gets the {@link ExifTag} with given tag id. Return null if there is no
+ * such tag.
+ */
+ protected ExifTag getTag(short tagId) {
+ return mExifTags.get(tagId);
+ }
+
+ /**
+ * Adds or replaces a {@link ExifTag}.
+ */
+ protected ExifTag setTag(ExifTag tag) {
+ tag.setIfd(mIfdId);
+ return mExifTags.put(tag.getTagId(), tag);
+ }
+
+ protected boolean checkCollision(short tagId) {
+ return mExifTags.get(tagId) != null;
+ }
+
+ /**
+ * Removes the tag of the given ID
+ */
+ protected void removeTag(short tagId) {
+ mExifTags.remove(tagId);
+ }
+
+ /**
+ * Gets the tags count in the IFD.
+ */
+ protected int getTagCount() {
+ return mExifTags.size();
+ }
+
+ /**
+ * Sets the offset of next IFD.
+ */
+ protected void setOffsetToNextIfd(int offset) {
+ mOffsetToNextIfd = offset;
+ }
+
+ /**
+ * Gets the offset of next IFD.
+ */
+ protected int getOffsetToNextIfd() {
+ return mOffsetToNextIfd;
+ }
+
+ /**
+ * Returns true if all tags in this two IFDs are equal. Note that tags of
+ * IFDs offset or thumbnail offset will be ignored.
+ */
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (obj instanceof IfdData) {
+ IfdData data = (IfdData) obj;
+ if (data.getId() == mIfdId && data.getTagCount() == getTagCount()) {
+ ExifTag[] tags = data.getAllTags();
+ for (ExifTag tag : tags) {
+ if (ExifInterface.isOffsetTag(tag.getTagId())) {
+ continue;
+ }
+ ExifTag tag2 = mExifTags.get(tag.getTagId());
+ if (!tag.equals(tag2)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/com/android/messaging/util/exif/IfdId.java b/src/com/android/messaging/util/exif/IfdId.java
new file mode 100644
index 0000000..06a820d
--- /dev/null
+++ b/src/com/android/messaging/util/exif/IfdId.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+/**
+ * The constants of the IFD ID defined in EXIF spec.
+ */
+public interface IfdId {
+ public static final int TYPE_IFD_0 = 0;
+ public static final int TYPE_IFD_1 = 1;
+ public static final int TYPE_IFD_EXIF = 2;
+ public static final int TYPE_IFD_INTEROPERABILITY = 3;
+ public static final int TYPE_IFD_GPS = 4;
+ /* This is used in ExifData to allocate enough IfdData */
+ static final int TYPE_IFD_COUNT = 5;
+
+}
diff --git a/src/com/android/messaging/util/exif/JpegHeader.java b/src/com/android/messaging/util/exif/JpegHeader.java
new file mode 100644
index 0000000..1dd12a5
--- /dev/null
+++ b/src/com/android/messaging/util/exif/JpegHeader.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+class JpegHeader {
+ public static final short SOI = (short) 0xFFD8;
+ public static final short APP1 = (short) 0xFFE1;
+ public static final short APP0 = (short) 0xFFE0;
+ public static final short EOI = (short) 0xFFD9;
+
+ /**
+ * SOF (start of frame). All value between SOF0 and SOF15 is SOF marker except for DHT, JPG,
+ * and DAC marker.
+ */
+ public static final short SOF0 = (short) 0xFFC0;
+ public static final short SOF15 = (short) 0xFFCF;
+ public static final short DHT = (short) 0xFFC4;
+ public static final short JPG = (short) 0xFFC8;
+ public static final short DAC = (short) 0xFFCC;
+
+ public static final boolean isSofMarker(short marker) {
+ return marker >= SOF0 && marker <= SOF15 && marker != DHT && marker != JPG
+ && marker != DAC;
+ }
+}
diff --git a/src/com/android/messaging/util/exif/OrderedDataOutputStream.java b/src/com/android/messaging/util/exif/OrderedDataOutputStream.java
new file mode 100644
index 0000000..cf64805
--- /dev/null
+++ b/src/com/android/messaging/util/exif/OrderedDataOutputStream.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+class OrderedDataOutputStream extends FilterOutputStream {
+ private final ByteBuffer mByteBuffer = ByteBuffer.allocate(4);
+
+ public OrderedDataOutputStream(OutputStream out) {
+ super(out);
+ }
+
+ public OrderedDataOutputStream setByteOrder(ByteOrder order) {
+ mByteBuffer.order(order);
+ return this;
+ }
+
+ public OrderedDataOutputStream writeShort(short value) throws IOException {
+ mByteBuffer.rewind();
+ mByteBuffer.putShort(value);
+ out.write(mByteBuffer.array(), 0, 2);
+ return this;
+ }
+
+ public OrderedDataOutputStream writeInt(int value) throws IOException {
+ mByteBuffer.rewind();
+ mByteBuffer.putInt(value);
+ out.write(mByteBuffer.array());
+ return this;
+ }
+
+ public OrderedDataOutputStream writeRational(Rational rational) throws IOException {
+ writeInt((int) rational.getNumerator());
+ writeInt((int) rational.getDenominator());
+ return this;
+ }
+}
diff --git a/src/com/android/messaging/util/exif/Rational.java b/src/com/android/messaging/util/exif/Rational.java
new file mode 100644
index 0000000..b42ceb7
--- /dev/null
+++ b/src/com/android/messaging/util/exif/Rational.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.util.exif;
+
+/**
+ * The rational data type of EXIF tag. Contains a pair of longs representing the
+ * numerator and denominator of a Rational number.
+ */
+public class Rational {
+
+ private final long mNumerator;
+ private final long mDenominator;
+
+ /**
+ * Create a Rational with a given numerator and denominator.
+ *
+ * @param nominator
+ * @param denominator
+ */
+ public Rational(long nominator, long denominator) {
+ mNumerator = nominator;
+ mDenominator = denominator;
+ }
+
+ /**
+ * Create a copy of a Rational.
+ */
+ public Rational(Rational r) {
+ mNumerator = r.mNumerator;
+ mDenominator = r.mDenominator;
+ }
+
+ /**
+ * Gets the numerator of the rational.
+ */
+ public long getNumerator() {
+ return mNumerator;
+ }
+
+ /**
+ * Gets the denominator of the rational
+ */
+ public long getDenominator() {
+ return mDenominator;
+ }
+
+ /**
+ * Gets the rational value as type double. Will cause a divide-by-zero error
+ * if the denominator is 0.
+ */
+ public double toDouble() {
+ return mNumerator / (double) mDenominator;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) {
+ return false;
+ }
+ if (this == obj) {
+ return true;
+ }
+ if (obj instanceof Rational) {
+ Rational data = (Rational) obj;
+ return mNumerator == data.mNumerator && mDenominator == data.mDenominator;
+ }
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return mNumerator + "/" + mDenominator;
+ }
+}
diff --git a/src/com/android/messaging/widget/BaseWidgetFactory.java b/src/com/android/messaging/widget/BaseWidgetFactory.java
new file mode 100644
index 0000000..30b80ae
--- /dev/null
+++ b/src/com/android/messaging/widget/BaseWidgetFactory.java
@@ -0,0 +1,232 @@
+/*
+ * 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.messaging.widget;
+
+import android.appwidget.AppWidgetManager;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.Typeface;
+import android.net.Uri;
+import android.os.Binder;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.style.StyleSpan;
+import android.widget.RemoteViews;
+import android.widget.RemoteViewsService;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.media.AvatarGroupRequestDescriptor;
+import com.android.messaging.datamodel.media.AvatarRequestDescriptor;
+import com.android.messaging.datamodel.media.ImageRequestDescriptor;
+import com.android.messaging.datamodel.media.ImageResource;
+import com.android.messaging.datamodel.media.MediaRequest;
+import com.android.messaging.datamodel.media.MediaResourceManager;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.LogUtil;
+
+/**
+ * Remote Views Factory for Bugle Widget.
+ */
+abstract class BaseWidgetFactory implements RemoteViewsService.RemoteViewsFactory {
+ protected static final String TAG = LogUtil.BUGLE_WIDGET_TAG;
+
+ protected static final int MAX_ITEMS_TO_SHOW = 25;
+
+ /**
+ * Lock to avoid race condition between widgets.
+ */
+ protected static final Object sWidgetLock = new Object();
+
+ protected final Context mContext;
+ protected final int mAppWidgetId;
+ protected boolean mShouldShowViewMore;
+ protected Cursor mCursor;
+ protected final AppWidgetManager mAppWidgetManager;
+ protected int mIconSize;
+ protected ImageResource mAvatarResource;
+
+ public BaseWidgetFactory(Context context, Intent intent) {
+ mContext = context;
+ mAppWidgetId = intent.getIntExtra(
+ AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
+ mAppWidgetManager = AppWidgetManager.getInstance(context);
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "BaseWidgetFactory intent: " + intent + "widget id: " + mAppWidgetId);
+ }
+ mIconSize = (int) context.getResources()
+ .getDimension(R.dimen.contact_icon_view_normal_size);
+
+ }
+
+ @Override
+ public void onCreate() {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "onCreate");
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "onDestroy");
+ }
+ synchronized (sWidgetLock) {
+ if (mCursor != null && !mCursor.isClosed()) {
+ mCursor.close();
+ mCursor = null;
+ }
+ }
+ }
+
+ @Override
+ public void onDataSetChanged() {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "onDataSetChanged");
+ }
+ synchronized (sWidgetLock) {
+ if (mCursor != null) {
+ mCursor.close();
+ mCursor = null;
+ }
+ final long token = Binder.clearCallingIdentity();
+ try {
+ mCursor = doQuery();
+ onLoadComplete();
+ } finally {
+ Binder.restoreCallingIdentity(token);
+ }
+ }
+ }
+
+ protected abstract Cursor doQuery();
+
+ /**
+ * Returns the number of items that should be shown in the widget list. This method also
+ * updates the boolean that indicates whether the "show more" item should be shown.
+ * @return the number of items to be displayed in the list.
+ */
+ @Override
+ public int getCount() {
+ synchronized (sWidgetLock) {
+ if (mCursor == null) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "getCount: 0");
+ }
+ return 0;
+ }
+ final int count = getItemCount();
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "getCount: " + count);
+ }
+ mShouldShowViewMore = count < mCursor.getCount();
+ return count + (mShouldShowViewMore ? 1 : 0);
+ }
+ }
+
+ /**
+ * Returns the number of messages that should be shown in the widget. This method
+ * doesn't update the boolean that indicates whether the "show more" item should be included
+ * in the list.
+ * @return
+ */
+ protected int getItemCount() {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "getItemCount: " + mCursor.getCount());
+ }
+ return Math.min(mCursor.getCount(), MAX_ITEMS_TO_SHOW);
+ }
+
+ /*
+ * Make the given text bold if the item is unread
+ */
+ protected CharSequence boldifyIfUnread(CharSequence text, final boolean unread) {
+ if (!unread) {
+ return text;
+ }
+ final SpannableStringBuilder builder = new SpannableStringBuilder(text);
+ builder.setSpan(new StyleSpan(Typeface.BOLD), 0, text.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ return builder;
+ }
+
+ protected Bitmap getAvatarBitmap(final Uri avatarUri) {
+ final String avatarType = avatarUri == null ?
+ null : AvatarUriUtil.getAvatarType(avatarUri);
+ ImageRequestDescriptor descriptor;
+ if (AvatarUriUtil.TYPE_GROUP_URI.equals(avatarType)) {
+ descriptor = new AvatarGroupRequestDescriptor(avatarUri, mIconSize, mIconSize);
+ } else {
+ descriptor = new AvatarRequestDescriptor(avatarUri, mIconSize, mIconSize);
+ }
+
+ final MediaRequest<ImageResource> imageRequest =
+ descriptor.buildSyncMediaRequest(mContext);
+ final ImageResource imageResource =
+ MediaResourceManager.get().requestMediaResourceSync(imageRequest);
+ if (imageResource != null) {
+ setAvatarResource(imageResource);
+ return mAvatarResource.getBitmap();
+ } else {
+ releaseAvatarResource();
+ return null;
+ }
+ }
+
+ /**
+ * @return the "View more messages" view. When the user taps this item, they're
+ * taken to the conversation in Bugle.
+ */
+ abstract protected RemoteViews getViewMoreItemsView();
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ private void onLoadComplete() {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "onLoadComplete");
+ }
+ final RemoteViews remoteViews = new RemoteViews(mContext.getPackageName(),
+ getMainLayoutId());
+ mAppWidgetManager.partiallyUpdateAppWidget(mAppWidgetId, remoteViews);
+ }
+
+ protected abstract int getMainLayoutId();
+
+ private void setAvatarResource(final ImageResource resource) {
+ if (mAvatarResource != resource) {
+ // Clear out any information for what is currently used
+ releaseAvatarResource();
+ mAvatarResource = resource;
+ }
+ }
+
+ private void releaseAvatarResource() {
+ if (mAvatarResource != null) {
+ mAvatarResource.release();
+ }
+ mAvatarResource = null;
+ }
+}
diff --git a/src/com/android/messaging/widget/BaseWidgetProvider.java b/src/com/android/messaging/widget/BaseWidgetProvider.java
new file mode 100644
index 0000000..431a6c7
--- /dev/null
+++ b/src/com/android/messaging/widget/BaseWidgetProvider.java
@@ -0,0 +1,184 @@
+/*
+ * 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.messaging.widget;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+
+import com.android.messaging.util.LogUtil;
+
+public abstract class BaseWidgetProvider extends AppWidgetProvider {
+ protected static final String TAG = LogUtil.BUGLE_WIDGET_TAG;
+
+ public static final int WIDGET_CONVERSATION_REQUEST_CODE = 987;
+
+ static final String WIDGET_SIZE_KEY = "widgetSizeKey";
+
+ public static final int SIZE_LARGE = 0; // undefined == 0, which is the default, large
+ public static final int SIZE_SMALL = 1;
+ public static final int SIZE_MEDIUM = 2;
+ public static final int SIZE_PRE_JB = 3;
+
+ /**
+ * Update all widgets in the list
+ */
+ @Override
+ public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds);
+
+ for (int i = 0; i < appWidgetIds.length; ++i) {
+ updateWidget(context, appWidgetIds[i]);
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "onReceive intent: " + intent + " for " + this.getClass());
+ }
+ final String action = intent.getAction();
+
+ // The base class AppWidgetProvider's onReceive handles the normal widget intents. Here
+ // we're looking for an intent sent by our app when it knows a message has
+ // been sent or received (or a conversation has been read) and is telling the widget it
+ // needs to update.
+ if (getAction().equals(action)) {
+ final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
+ final int[] appWidgetIds = appWidgetManager.getAppWidgetIds(new ComponentName(context,
+ this.getClass()));
+
+ if (appWidgetIds.length > 0) {
+ // We need to update all Bugle app widgets on the home screen.
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "onReceive notifyAppWidgetViewDataChanged listId: " +
+ getListId() + " first widgetId: " + appWidgetIds[0]);
+ }
+ appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, getListId());
+ }
+ } else {
+ super.onReceive(context, intent);
+ }
+ }
+
+ protected abstract String getAction();
+
+ protected abstract int getListId();
+
+ /**
+ * Update the widget appWidgetId
+ */
+ protected abstract void updateWidget(Context context, int appWidgetId);
+
+ private int getWidgetSize(AppWidgetManager appWidgetManager,
+ int appWidgetId) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "BaseWidgetProvider.getWidgetSize");
+ }
+
+ // Get the dimensions
+ final Bundle options = appWidgetManager.getAppWidgetOptions(appWidgetId);
+
+ // Get min width and height.
+ final int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH);
+ final int minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT);
+
+ // First find out rows and columns based on width provided.
+ final int rows = getCellsForSize(minHeight);
+ final int columns = getCellsForSize(minWidth);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "BaseWidgetProvider.getWidgetSize row: " + rows +
+ " columns: " + columns);
+ }
+
+ int size = SIZE_MEDIUM;
+ if (rows == 1) {
+ size = SIZE_SMALL; // Our widget doesn't let itself get this small. Perhaps in the
+ // future will add a super-mini widget.
+ } else if (columns > 3) {
+ size = SIZE_LARGE;
+ }
+
+ // put the size in the bundle so our service know what size it's dealing with.
+ final int savedSize = options.getInt(WIDGET_SIZE_KEY);
+ if (savedSize != size) {
+ options.putInt(WIDGET_SIZE_KEY, size);
+ appWidgetManager.updateAppWidgetOptions(appWidgetId, options);
+
+ // The size changed. We have to force the widget to rebuild the list.
+ appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, getListId());
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "BaseWidgetProvider.getWidgetSize old size: " + savedSize +
+ " new size saved: " + size);
+ }
+ }
+
+ return size;
+ }
+
+ /**
+ * Returns number of cells needed for given size of the widget.
+ *
+ * @param size Widget size in dp.
+ * @return Size in number of cells.
+ */
+ private static int getCellsForSize(int size) {
+ // The hardwired sizes in this function come from the hardwired formula found in
+ // Android's UI guidelines for widget design:
+ // http://developer.android.com/guide/practices/ui_guidelines/widget_design.html
+ return (size + 30) / 70;
+ }
+
+ @Override
+ public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager,
+ int appWidgetId, Bundle newOptions) {
+
+ final int widgetSize = getWidgetSize(appWidgetManager, appWidgetId);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "BaseWidgetProvider.onAppWidgetOptionsChanged new size: " +
+ widgetSize);
+ }
+
+ super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
+ }
+
+ protected void deletePreferences(final int widgetId) {
+ }
+
+ /**
+ * Remove preferences when deleting widget
+ */
+ @Override
+ public void onDeleted(Context context, int[] appWidgetIds) {
+ super.onDeleted(context, appWidgetIds);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "BaseWidgetProvider.onDeleted");
+ }
+
+ for (final int widgetId : appWidgetIds) {
+ deletePreferences(widgetId);
+ }
+ }
+
+}
diff --git a/src/com/android/messaging/widget/BugleWidgetProvider.java b/src/com/android/messaging/widget/BugleWidgetProvider.java
new file mode 100644
index 0000000..50c97b6
--- /dev/null
+++ b/src/com/android/messaging/widget/BugleWidgetProvider.java
@@ -0,0 +1,114 @@
+/*
+ * 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.messaging.widget;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.widget.RemoteViews;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.SafeAsyncTask;
+import com.android.messaging.util.UiUtils;
+
+public class BugleWidgetProvider extends BaseWidgetProvider {
+ public static final String ACTION_NOTIFY_CONVERSATIONS_CHANGED =
+ "com.android.Bugle.intent.action.ACTION_NOTIFY_CONVERSATIONS_CHANGED";
+
+ public static final int WIDGET_NEW_CONVERSATION_REQUEST_CODE = 986;
+
+ /**
+ * Update the widget appWidgetId
+ */
+ @Override
+ protected void updateWidget(final Context context, final int appWidgetId) {
+ if (OsUtil.hasRequiredPermissions()) {
+ SafeAsyncTask.executeOnThreadPool(new Runnable() {
+ @Override
+ public void run() {
+ rebuildWidget(context, appWidgetId);
+ }
+ });
+ } else {
+ AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId,
+ UiUtils.getWidgetMissingPermissionView(context));
+ }
+ }
+
+ @Override
+ protected String getAction() {
+ return ACTION_NOTIFY_CONVERSATIONS_CHANGED;
+ }
+
+ @Override
+ protected int getListId() {
+ return R.id.conversation_list;
+ }
+
+ public static void rebuildWidget(final Context context, final int appWidgetId) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "BugleWidgetProvider.rebuildWidget appWidgetId: " + appWidgetId);
+ }
+ final RemoteViews remoteViews = new RemoteViews(context.getPackageName(),
+ R.layout.widget_conversation_list);
+ PendingIntent clickIntent;
+
+ // Launch an intent to avoid ANRs
+ final Intent intent = new Intent(context, WidgetConversationListService.class);
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
+ remoteViews.setRemoteAdapter(appWidgetId, R.id.conversation_list, intent);
+
+ remoteViews.setTextViewText(R.id.widget_label, context.getString(R.string.app_name));
+
+ // Open Bugle's app conversation list when click on header
+ clickIntent = UIIntents.get().getWidgetPendingIntentForConversationListActivity(context);
+ remoteViews.setOnClickPendingIntent(R.id.widget_header, clickIntent);
+
+ // On click intent for Compose
+ clickIntent = UIIntents.get().getWidgetPendingIntentForConversationActivity(context,
+ null /*conversationId*/, WIDGET_NEW_CONVERSATION_REQUEST_CODE);
+ remoteViews.setOnClickPendingIntent(R.id.widget_compose, clickIntent);
+
+ // On click intent for Conversation
+ // Note: the template intent has to be a "naked" intent without any extras. It turns out
+ // that if the template intent does have extras, those particular extras won't get
+ // replaced by the fill-in intent on each list item.
+ clickIntent = UIIntents.get().getWidgetPendingIntentForConversationActivity(context,
+ null /*conversationId*/, WIDGET_CONVERSATION_REQUEST_CODE);
+ remoteViews.setPendingIntentTemplate(R.id.conversation_list, clickIntent);
+
+ AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, remoteViews);
+ }
+
+ /*
+ * notifyDatasetChanged call when the conversation list changes so the Bugle widget will
+ * update and reflect the changes
+ */
+ public static void notifyConversationListChanged(final Context context) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "notifyConversationListChanged");
+ }
+ final Intent intent = new Intent(ACTION_NOTIFY_CONVERSATIONS_CHANGED);
+ context.sendBroadcast(intent);
+ }
+}
diff --git a/src/com/android/messaging/widget/WidgetConversationListService.java b/src/com/android/messaging/widget/WidgetConversationListService.java
new file mode 100644
index 0000000..264b98c
--- /dev/null
+++ b/src/com/android/messaging/widget/WidgetConversationListService.java
@@ -0,0 +1,281 @@
+/*
+ * 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.messaging.widget;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Typeface;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StyleSpan;
+import android.view.View;
+import android.widget.RemoteViews;
+import android.widget.RemoteViewsService;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.ConversationListData;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.conversationlist.ConversationListItemView;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.Dates;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+public class WidgetConversationListService extends RemoteViewsService {
+ private static final String TAG = LogUtil.BUGLE_WIDGET_TAG;
+
+ @Override
+ public RemoteViewsFactory onGetViewFactory(Intent intent) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "onGetViewFactory intent: " + intent);
+ }
+ return new WidgetConversationListFactory(getApplicationContext(), intent);
+ }
+
+ /**
+ * Remote Views Factory for Bugle Widget.
+ */
+ private static class WidgetConversationListFactory extends BaseWidgetFactory {
+
+ public WidgetConversationListFactory(Context context, Intent intent) {
+ super(context, intent);
+ }
+
+ @Override
+ protected Cursor doQuery() {
+ return mContext.getContentResolver().query(MessagingContentProvider.CONVERSATIONS_URI,
+ ConversationListItemData.PROJECTION,
+ ConversationListData.WHERE_NOT_ARCHIVED,
+ null, // selection args
+ ConversationListData.SORT_ORDER);
+ }
+
+ /**
+ * @return the {@link RemoteViews} for a specific position in the list.
+ */
+ @Override
+ public RemoteViews getViewAt(int position) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "getViewAt position: " + position);
+ }
+ synchronized (sWidgetLock) {
+ // "View more conversations" view.
+ if (mCursor == null
+ || (mShouldShowViewMore && position >= getItemCount())) {
+ return getViewMoreItemsView();
+ }
+
+ if (!mCursor.moveToPosition(position)) {
+ // If we ever fail to move to a position, return the "View More conversations"
+ // view.
+ LogUtil.w(TAG, "Failed to move to position: " + position);
+ return getViewMoreItemsView();
+ }
+
+ final ConversationListItemData conv = new ConversationListItemData();
+ conv.bind(mCursor);
+
+ // Inflate and fill out the remote view
+ final RemoteViews remoteViews = new RemoteViews(
+ mContext.getPackageName(), R.layout.widget_conversation_list_item);
+
+ final boolean hasUnreadMessages = !conv.getIsRead();
+ final Resources resources = mContext.getResources();
+ final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp();
+
+ final String timeStamp = conv.getIsSendRequested() ?
+ resources.getString(R.string.message_status_sending) :
+ Dates.getWidgetTimeString(conv.getTimestamp(), true /*abbreviated*/)
+ .toString();
+ // Date/Timestamp or Sending or Error state -- all shown in the date item
+ remoteViews.setTextViewText(R.id.date,
+ boldifyIfUnread(timeStamp, hasUnreadMessages));
+
+ // From
+ remoteViews.setTextViewText(R.id.from,
+ boldifyIfUnread(conv.getName(), hasUnreadMessages));
+
+ // Notifications turned off mini-bell icon
+ remoteViews.setViewVisibility(R.id.conversation_notification_bell,
+ conv.getNotificationEnabled() ? View.GONE : View.VISIBLE);
+
+ // On click intent.
+ final Intent intent = UIIntents.get().getIntentForConversationActivity(mContext,
+ conv.getConversationId(), null /* draft */);
+
+ remoteViews.setOnClickFillInIntent(R.id.widget_conversation_list_item, intent);
+
+ // Avatar
+ boolean includeAvatar;
+ if (OsUtil.isAtLeastJB()) {
+ final Bundle options = mAppWidgetManager.getAppWidgetOptions(mAppWidgetId);
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "getViewAt BugleWidgetProvider.WIDGET_SIZE_KEY: " +
+ options.getInt(BugleWidgetProvider.WIDGET_SIZE_KEY));
+ }
+
+ includeAvatar = options.getInt(BugleWidgetProvider.WIDGET_SIZE_KEY) ==
+ BugleWidgetProvider.SIZE_LARGE;
+ } else {
+ includeAvatar = true;;
+ }
+
+ // Show the avatar when grande size, otherwise hide it.
+ remoteViews.setViewVisibility(R.id.avatarView, includeAvatar ?
+ View.VISIBLE : View.GONE);
+
+ Uri iconUri = null;
+ if (conv.getIcon() != null) {
+ iconUri = Uri.parse(conv.getIcon());
+ }
+ remoteViews.setImageViewBitmap(R.id.avatarView, includeAvatar ?
+ getAvatarBitmap(iconUri) : null);
+
+ // Error
+ // Only show the fail icon if it is not a group conversation.
+ // And also require that we be the default sms app.
+ final boolean showError = conv.getIsFailedStatus() &&
+ isDefaultSmsApp;
+ final boolean showDraft = conv.getShowDraft() &&
+ isDefaultSmsApp;
+ remoteViews.setViewVisibility(R.id.conversation_failed_status_icon,
+ showError && includeAvatar ?
+ View.VISIBLE : View.GONE);
+
+ if (showError || showDraft) {
+ remoteViews.setViewVisibility(R.id.snippet, View.GONE);
+ remoteViews.setViewVisibility(R.id.errorBlock, View.VISIBLE);
+ remoteViews.setTextViewText(R.id.errorSnippet, getSnippetText(conv));
+
+ if (showDraft) {
+ // Show italicized "Draft" on third line
+ final String text = resources.getString(
+ R.string.conversation_list_item_view_draft_message);
+ SpannableStringBuilder builder = new SpannableStringBuilder(text);
+ builder.setSpan(new StyleSpan(Typeface.ITALIC), 0, text.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ builder.setSpan(new ForegroundColorSpan(
+ resources.getColor(R.color.widget_text_color)),
+ 0, text.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ remoteViews.setTextViewText(R.id.errorText, builder);
+ } else {
+ // Show error message on third line
+ int failureMessageId = R.string.message_status_download_failed;
+ if (conv.getIsMessageTypeOutgoing()) {
+ failureMessageId = MmsUtils.mapRawStatusToErrorResourceId(
+ conv.getMessageStatus(),
+ conv.getMessageRawTelephonyStatus());
+ }
+ remoteViews.setTextViewText(R.id.errorText,
+ resources.getString(failureMessageId));
+ }
+ } else {
+ remoteViews.setViewVisibility(R.id.errorBlock, View.GONE);
+ remoteViews.setViewVisibility(R.id.snippet, View.VISIBLE);
+ remoteViews.setTextViewText(R.id.snippet,
+ boldifyIfUnread(getSnippetText(conv), hasUnreadMessages));
+ }
+
+ // Set the accessibility TalkBack text
+ remoteViews.setContentDescription(R.id.widget_conversation_list_item,
+ ConversationListItemView.buildContentDescription(mContext.getResources(),
+ conv, new TextPaint()));
+
+ return remoteViews;
+ }
+ }
+
+ private String getSnippetText(final ConversationListItemData conv) {
+ String snippetText = conv.getShowDraft() ?
+ conv.getDraftSnippetText() : conv.getSnippetText();
+ final String previewContentType = conv.getShowDraft() ?
+ conv.getDraftPreviewContentType() : conv.getPreviewContentType();
+ if (TextUtils.isEmpty(snippetText)) {
+ Resources resources = mContext.getResources();
+ // Use the attachment type as a snippet so the preview doesn't look odd
+ if (ContentType.isAudioType(previewContentType)) {
+ snippetText = resources.getString(
+ R.string.conversation_list_snippet_audio_clip);
+ } else if (ContentType.isImageType(previewContentType)) {
+ snippetText = resources.getString(R.string.conversation_list_snippet_picture);
+ } else if (ContentType.isVideoType(previewContentType)) {
+ snippetText = resources.getString(R.string.conversation_list_snippet_video);
+ } else if (ContentType.isVCardType(previewContentType)) {
+ snippetText = resources.getString(R.string.conversation_list_snippet_vcard);
+ }
+ }
+ return snippetText;
+ }
+
+ /**
+ * @return the "View more conversations" view. When the user taps this item, they're
+ * taken to the Bugle's conversation list.
+ */
+ @Override
+ protected RemoteViews getViewMoreItemsView() {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "getViewMoreItemsView");
+ }
+ final RemoteViews view = new RemoteViews(mContext.getPackageName(),
+ R.layout.widget_loading);
+ view.setTextViewText(
+ R.id.loading_text, mContext.getText(R.string.view_more_conversations));
+
+ // Tapping this "More conversations" item should take us to the ConversationList.
+ // However, the list view is primed with an intent to go to the Conversation activity.
+ // Each normal conversation list item sets the fill-in intent with the
+ // ConversationId for that particular conversation. In other words, the only place
+ // we can go is the ConversationActivity. We add an extra here to tell the
+ // ConversationActivity to really take us to the ConversationListActivity.
+ final Intent intent = new Intent();
+ intent.putExtra(UIIntents.UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST, true);
+ view.setOnClickFillInIntent(R.id.widget_loading, intent);
+ return view;
+ }
+
+ @Override
+ public RemoteViews getLoadingView() {
+ RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading);
+ view.setTextViewText(
+ R.id.loading_text, mContext.getText(R.string.loading_conversations));
+ return view;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 2;
+ }
+
+ @Override
+ protected int getMainLayoutId() {
+ return R.layout.widget_conversation_list;
+ }
+ }
+
+}
diff --git a/src/com/android/messaging/widget/WidgetConversationProvider.java b/src/com/android/messaging/widget/WidgetConversationProvider.java
new file mode 100644
index 0000000..6ae5614
--- /dev/null
+++ b/src/com/android/messaging/widget/WidgetConversationProvider.java
@@ -0,0 +1,316 @@
+/*
+ * 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.messaging.widget;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Looper;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.RemoteViews;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.WidgetPickConversationActivity;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.SafeAsyncTask;
+import com.android.messaging.util.UiUtils;
+
+public class WidgetConversationProvider extends BaseWidgetProvider {
+ public static final String ACTION_NOTIFY_MESSAGES_CHANGED =
+ "com.android.Bugle.intent.action.ACTION_NOTIFY_MESSAGES_CHANGED";
+
+ public static final int WIDGET_CONVERSATION_TEMPLATE_REQUEST_CODE = 1985;
+ public static final int WIDGET_CONVERSATION_REPLY_CODE = 1987;
+
+ // Intent extras
+ public static final String UI_INTENT_EXTRA_RECIPIENT = "recipient";
+ public static final String UI_INTENT_EXTRA_ICON = "icon";
+
+ /**
+ * Update the widget appWidgetId
+ */
+ @Override
+ protected void updateWidget(final Context context, final int appWidgetId) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "updateWidget appWidgetId: " + appWidgetId);
+ }
+ if (OsUtil.hasRequiredPermissions()) {
+ rebuildWidget(context, appWidgetId);
+ } else {
+ AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId,
+ UiUtils.getWidgetMissingPermissionView(context));
+ }
+ }
+
+ @Override
+ protected String getAction() {
+ return ACTION_NOTIFY_MESSAGES_CHANGED;
+ }
+
+ @Override
+ protected int getListId() {
+ return R.id.message_list;
+ }
+
+ public static void rebuildWidget(final Context context, final int appWidgetId) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "WidgetConversationProvider.rebuildWidget appWidgetId: " + appWidgetId);
+ }
+ final RemoteViews remoteViews = new RemoteViews(context.getPackageName(),
+ R.layout.widget_conversation);
+ PendingIntent clickIntent;
+ final UIIntents uiIntents = UIIntents.get();
+ if (!isWidgetConfigured(appWidgetId)) {
+ // Widget has not been configured yet. Hide the normal UI elements and show the
+ // configuration view instead.
+ remoteViews.setViewVisibility(R.id.widget_label, View.GONE);
+ remoteViews.setViewVisibility(R.id.message_list, View.GONE);
+ remoteViews.setViewVisibility(R.id.launcher_icon, View.VISIBLE);
+ remoteViews.setViewVisibility(R.id.widget_configuration, View.VISIBLE);
+
+ remoteViews.setOnClickPendingIntent(R.id.widget_configuration,
+ uiIntents.getWidgetPendingIntentForConfigurationActivity(context, appWidgetId));
+
+ // On click intent for Goto Conversation List
+ clickIntent = uiIntents.getWidgetPendingIntentForConversationListActivity(context);
+ remoteViews.setOnClickPendingIntent(R.id.widget_header, clickIntent);
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "WidgetConversationProvider.rebuildWidget appWidgetId: " +
+ appWidgetId + " going into configure state");
+ }
+ } else {
+ remoteViews.setViewVisibility(R.id.widget_label, View.VISIBLE);
+ remoteViews.setViewVisibility(R.id.message_list, View.VISIBLE);
+ remoteViews.setViewVisibility(R.id.launcher_icon, View.GONE);
+ remoteViews.setViewVisibility(R.id.widget_configuration, View.GONE);
+
+ final String conversationId =
+ WidgetPickConversationActivity.getConversationIdPref(appWidgetId);
+ final boolean isMainThread = Looper.myLooper() == Looper.getMainLooper();
+ // If we're running on the UI thread, we can't do the DB access needed to get the
+ // conversation data. We'll do excute this again off of the UI thread.
+ final ConversationListItemData convData = isMainThread ?
+ null : getConversationData(context, conversationId);
+
+ // Launch an intent to avoid ANRs
+ final Intent intent = new Intent(context, WidgetConversationService.class);
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ intent.putExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
+ intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
+ remoteViews.setRemoteAdapter(appWidgetId, R.id.message_list, intent);
+
+ remoteViews.setTextViewText(R.id.widget_label, convData != null ?
+ convData.getName() : context.getString(R.string.app_name));
+
+ // On click intent for Goto Conversation List
+ clickIntent = uiIntents.getWidgetPendingIntentForConversationListActivity(context);
+ remoteViews.setOnClickPendingIntent(R.id.widget_goto_conversation_list, clickIntent);
+
+ // Open the conversation when click on header
+ clickIntent = uiIntents.getWidgetPendingIntentForConversationActivity(context,
+ conversationId, WIDGET_CONVERSATION_REQUEST_CODE);
+ remoteViews.setOnClickPendingIntent(R.id.widget_header, clickIntent);
+
+ // On click intent for Conversation
+ // Note: the template intent has to be a "naked" intent without any extras. It turns out
+ // that if the template intent does have extras, those particular extras won't get
+ // replaced by the fill-in intent on each list item.
+ clickIntent = uiIntents.getWidgetPendingIntentForConversationActivity(context,
+ conversationId, WIDGET_CONVERSATION_TEMPLATE_REQUEST_CODE);
+ remoteViews.setPendingIntentTemplate(R.id.message_list, clickIntent);
+
+ if (isMainThread) {
+ // We're running on the UI thread and we couldn't update all the parts of the
+ // widget dependent on ConversationListItemData. However, we have to update
+ // the widget regardless, even with those missing pieces. Here we update the
+ // widget again in the background.
+ SafeAsyncTask.executeOnThreadPool(new Runnable() {
+ @Override
+ public void run() {
+ rebuildWidget(context, appWidgetId);
+ }
+ });
+ }
+ }
+
+ AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, remoteViews);
+
+ }
+
+ /*
+ * notifyMessagesChanged called when the conversation changes so the widget will
+ * update and reflect the changes
+ */
+ public static void notifyMessagesChanged(final Context context, final String conversationId) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "notifyMessagesChanged");
+ }
+ final Intent intent = new Intent(ACTION_NOTIFY_MESSAGES_CHANGED);
+ intent.putExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
+ context.sendBroadcast(intent);
+ }
+
+ /*
+ * notifyConversationDeleted is called when a conversation is deleted. Look through all the
+ * widgets and if they're displaying that conversation, force the widget into its
+ * configuration state.
+ */
+ public static void notifyConversationDeleted(final Context context,
+ final String conversationId) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "notifyConversationDeleted convId: " + conversationId);
+ }
+
+ final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
+ for (final int appWidgetId : appWidgetManager.getAppWidgetIds(new ComponentName(context,
+ WidgetConversationProvider.class))) {
+ // Retrieve the persisted information for this widget from preferences.
+ final String widgetConvId =
+ WidgetPickConversationActivity.getConversationIdPref(appWidgetId);
+
+ if (widgetConvId == null || widgetConvId.equals(conversationId)) {
+ if (widgetConvId != null) {
+ WidgetPickConversationActivity.deleteConversationIdPref(appWidgetId);
+ }
+ rebuildWidget(context, appWidgetId);
+ }
+ }
+ }
+
+ /*
+ * notifyConversationRenamed is called when a conversation is renamed. Look through all the
+ * widgets and if they're displaying that conversation, force the widget to rebuild itself
+ */
+ public static void notifyConversationRenamed(final Context context,
+ final String conversationId) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "notifyConversationRenamed convId: " + conversationId);
+ }
+
+ final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
+ for (final int appWidgetId : appWidgetManager.getAppWidgetIds(new ComponentName(context,
+ WidgetConversationProvider.class))) {
+ // Retrieve the persisted information for this widget from preferences.
+ final String widgetConvId =
+ WidgetPickConversationActivity.getConversationIdPref(appWidgetId);
+
+ if (widgetConvId != null && widgetConvId.equals(conversationId)) {
+ rebuildWidget(context, appWidgetId);
+ }
+ }
+ }
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "WidgetConversationProvider onReceive intent: " + intent);
+ }
+ final String action = intent.getAction();
+
+ // The base class AppWidgetProvider's onReceive handles the normal widget intents. Here
+ // we're looking for an intent sent by our app when it knows a message has
+ // been sent or received (or a conversation has been read) and is telling the widget it
+ // needs to update.
+ if (getAction().equals(action)) {
+ final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
+ final int[] appWidgetIds = appWidgetManager.getAppWidgetIds(new ComponentName(context,
+ this.getClass()));
+
+ if (appWidgetIds.length == 0) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "WidgetConversationProvider onReceive no widget ids");
+ }
+ return;
+ }
+ // Normally the conversation id points to a specific conversation and we only update
+ // widgets looking at that conversation. When the conversation id is null, that means
+ // there's been a massive change (such as the initial import) and we need to update
+ // every conversation widget.
+ final String conversationId = intent.getExtras()
+ .getString(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
+
+ // Only update the widgets that match the conversation id that changed.
+ for (final int widgetId : appWidgetIds) {
+ // Retrieve the persisted information for this widget from preferences.
+ final String widgetConvId =
+ WidgetPickConversationActivity.getConversationIdPref(widgetId);
+ if (conversationId == null || TextUtils.equals(conversationId, widgetConvId)) {
+ // Update the list portion (i.e. the message list) of the widget
+ appWidgetManager.notifyAppWidgetViewDataChanged(widgetId, getListId());
+ }
+ }
+ } else {
+ super.onReceive(context, intent);
+ }
+ }
+
+ private static ConversationListItemData getConversationData(final Context context,
+ final String conversationId) {
+ if (TextUtils.isEmpty(conversationId)) {
+ return null;
+ }
+ final Uri uri = MessagingContentProvider.buildConversationMetadataUri(conversationId);
+ Cursor cursor = null;
+ try {
+ cursor = context.getContentResolver().query(uri,
+ ConversationListItemData.PROJECTION,
+ null, // selection
+ null, // selection args
+ null); // sort order
+ if (cursor != null && cursor.getCount() > 0) {
+ final ConversationListItemData conv = new ConversationListItemData();
+ cursor.moveToFirst();
+ conv.bind(cursor);
+ return conv;
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+
+ @Override
+ protected void deletePreferences(final int widgetId) {
+ WidgetPickConversationActivity.deleteConversationIdPref(widgetId);
+ }
+
+ /**
+ * When this widget is created, it's created for a particular conversation and that
+ * ConversationId is stored in shared prefs. If the associated conversation is deleted,
+ * the widget doesn't get deleted. Instead, it goes into a "tap to configure" state. This
+ * function determines whether the widget has been configured and has an associated
+ * ConversationId.
+ */
+ public static boolean isWidgetConfigured(final int appWidgetId) {
+ final String conversationId =
+ WidgetPickConversationActivity.getConversationIdPref(appWidgetId);
+ return !TextUtils.isEmpty(conversationId);
+ }
+
+}
diff --git a/src/com/android/messaging/widget/WidgetConversationService.java b/src/com/android/messaging/widget/WidgetConversationService.java
new file mode 100644
index 0000000..4fd3934
--- /dev/null
+++ b/src/com/android/messaging/widget/WidgetConversationService.java
@@ -0,0 +1,521 @@
+/*
+ * 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.messaging.widget;
+
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.text.format.Formatter;
+import android.text.style.ForegroundColorSpan;
+import android.view.View;
+import android.widget.RemoteViews;
+import android.widget.RemoteViewsService;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.media.ImageResource;
+import com.android.messaging.datamodel.media.MediaRequest;
+import com.android.messaging.datamodel.media.MediaResourceManager;
+import com.android.messaging.datamodel.media.MessagePartImageRequestDescriptor;
+import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor;
+import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
+import com.android.messaging.datamodel.media.VideoThumbnailRequest;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.Dates;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+
+import java.util.List;
+
+public class WidgetConversationService extends RemoteViewsService {
+ private static final String TAG = LogUtil.BUGLE_WIDGET_TAG;
+
+ private static final int IMAGE_ATTACHMENT_SIZE = 400;
+
+ @Override
+ public RemoteViewsFactory onGetViewFactory(Intent intent) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "onGetViewFactory intent: " + intent);
+ }
+ return new WidgetConversationFactory(getApplicationContext(), intent);
+ }
+
+ /**
+ * Remote Views Factory for the conversation widget.
+ */
+ private static class WidgetConversationFactory extends BaseWidgetFactory {
+ private ImageResource mImageResource;
+ private String mConversationId;
+
+ public WidgetConversationFactory(Context context, Intent intent) {
+ super(context, intent);
+
+ mConversationId = intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "BugleFactory intent: " + intent + "widget id: " + mAppWidgetId);
+ }
+ mIconSize = (int) context.getResources()
+ .getDimension(R.dimen.contact_icon_view_normal_size);
+ }
+
+ @Override
+ public void onCreate() {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "onCreate");
+ }
+ super.onCreate();
+
+ // If the conversation for this widget has been removed, we want to update the widget to
+ // "Tap to configure" mode.
+ if (!WidgetConversationProvider.isWidgetConfigured(mAppWidgetId)) {
+ WidgetConversationProvider.rebuildWidget(mContext, mAppWidgetId);
+ }
+ }
+
+ @Override
+ protected Cursor doQuery() {
+ if (TextUtils.isEmpty(mConversationId)) {
+ LogUtil.w(TAG, "doQuery no conversation id");
+ return null;
+ }
+ final Uri uri = MessagingContentProvider.buildConversationMessagesUri(mConversationId);
+ if (uri != null) {
+ LogUtil.w(TAG, "doQuery uri: " + uri.toString());
+ }
+ return mContext.getContentResolver().query(uri,
+ ConversationMessageData.getProjection(),
+ null, // where
+ null, // selection args
+ null // sort order
+ );
+ }
+
+ /**
+ * @return the {@link RemoteViews} for a specific position in the list.
+ */
+ @Override
+ public RemoteViews getViewAt(final int originalPosition) {
+ synchronized (sWidgetLock) {
+ // "View more messages" view.
+ if (mCursor == null
+ || (mShouldShowViewMore && originalPosition == 0)) {
+ return getViewMoreItemsView();
+ }
+ // The message cursor is in reverse order for performance reasons.
+ final int position = getCount() - originalPosition - 1;
+ if (!mCursor.moveToPosition(position)) {
+ // If we ever fail to move to a position, return the "View More messages"
+ // view.
+ LogUtil.w(TAG, "Failed to move to position: " + position);
+ return getViewMoreItemsView();
+ }
+
+ final ConversationMessageData message = new ConversationMessageData();
+ message.bind(mCursor);
+
+ // Inflate and fill out the remote view
+ final RemoteViews remoteViews = new RemoteViews(
+ mContext.getPackageName(), message.getIsIncoming() ?
+ R.layout.widget_message_item_incoming :
+ R.layout.widget_message_item_outgoing);
+
+ final boolean hasUnreadMessages = false; //!message.getIsRead();
+
+ // Date
+ remoteViews.setTextViewText(R.id.date, boldifyIfUnread(
+ Dates.getWidgetTimeString(message.getReceivedTimeStamp(),
+ false /*abbreviated*/),
+ hasUnreadMessages));
+
+ // On click intent.
+ final Intent intent = UIIntents.get().getIntentForConversationActivity(mContext,
+ mConversationId, null /* draft */);
+
+ // Attachments
+ int attachmentStringId = 0;
+ remoteViews.setViewVisibility(R.id.attachmentFrame, View.GONE);
+
+ int scrollToPosition = originalPosition;
+ final int cursorCount = mCursor.getCount();
+ if (cursorCount > MAX_ITEMS_TO_SHOW) {
+ scrollToPosition += cursorCount - MAX_ITEMS_TO_SHOW;
+ }
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "getViewAt position: " + originalPosition +
+ " computed position: " + position +
+ " scrollToPosition: " + scrollToPosition +
+ " cursorCount: " + cursorCount +
+ " MAX_ITEMS_TO_SHOW: " + MAX_ITEMS_TO_SHOW);
+ }
+
+ intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, scrollToPosition);
+ if (message.hasAttachments()) {
+ final List<MessagePartData> attachments = message.getAttachments();
+ for (MessagePartData part : attachments) {
+ final boolean videoWithThumbnail = part.isVideo()
+ && (VideoThumbnailRequest.shouldShowIncomingVideoThumbnails()
+ || !message.getIsIncoming());
+ if (part.isImage() || videoWithThumbnail) {
+ final Uri uri = part.getContentUri();
+ remoteViews.setViewVisibility(R.id.attachmentFrame, View.VISIBLE);
+ remoteViews.setViewVisibility(R.id.playButton, part.isVideo() ?
+ View.VISIBLE : View.GONE);
+ remoteViews.setImageViewBitmap(R.id.attachment,
+ getAttachmentBitmap(part));
+ intent.putExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_URI ,
+ uri.toString());
+ intent.putExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE ,
+ part.getContentType());
+ break;
+ } else if (part.isVideo()) {
+ attachmentStringId = R.string.conversation_list_snippet_video;
+ break;
+ }
+ if (part.isAudio()) {
+ attachmentStringId = R.string.conversation_list_snippet_audio_clip;
+ break;
+ }
+ if (part.isVCard()) {
+ attachmentStringId = R.string.conversation_list_snippet_vcard;
+ break;
+ }
+ }
+ }
+
+ remoteViews.setOnClickFillInIntent(message.getIsIncoming() ?
+ R.id.widget_message_item_incoming :
+ R.id.widget_message_item_outgoing,
+ intent);
+
+ // Avatar
+ boolean includeAvatar;
+ if (OsUtil.isAtLeastJB()) {
+ final Bundle options = mAppWidgetManager.getAppWidgetOptions(mAppWidgetId);
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "getViewAt BugleWidgetProvider.WIDGET_SIZE_KEY: " +
+ options.getInt(BugleWidgetProvider.WIDGET_SIZE_KEY));
+ }
+
+ includeAvatar = options.getInt(BugleWidgetProvider.WIDGET_SIZE_KEY)
+ == BugleWidgetProvider.SIZE_LARGE;
+ } else {
+ includeAvatar = true;
+ }
+
+ // Show the avatar (and shadow) when grande size, otherwise hide it.
+ remoteViews.setViewVisibility(R.id.avatarView, includeAvatar ?
+ View.VISIBLE : View.GONE);
+ remoteViews.setViewVisibility(R.id.avatarShadow, includeAvatar ?
+ View.VISIBLE : View.GONE);
+
+ final Uri avatarUri = AvatarUriUtil.createAvatarUri(
+ message.getSenderProfilePhotoUri(),
+ message.getSenderFullName(),
+ message.getSenderNormalizedDestination(),
+ message.getSenderContactLookupKey());
+
+ remoteViews.setImageViewBitmap(R.id.avatarView, includeAvatar ?
+ getAvatarBitmap(avatarUri) : null);
+
+ String text = message.getText();
+ if (attachmentStringId != 0) {
+ final String attachment = mContext.getString(attachmentStringId);
+ if (!TextUtils.isEmpty(text)) {
+ text += '\n' + attachment;
+ } else {
+ text = attachment;
+ }
+ }
+
+ remoteViews.setViewVisibility(R.id.message, View.VISIBLE);
+ updateViewContent(text, message, remoteViews);
+
+ return remoteViews;
+ }
+ }
+
+ // updateViewContent figures out what to show in the message and date fields based on
+ // the message status. This code came from ConversationMessageView.updateViewContent, but
+ // had to be simplified to work with our simple widget list item.
+ // updateViewContent also builds the accessibility content description for the list item.
+ private void updateViewContent(final String messageText,
+ final ConversationMessageData message,
+ final RemoteViews remoteViews) {
+ int titleResId = -1;
+ int statusResId = -1;
+ boolean showInRed = false;
+ String statusText = null;
+ switch(message.getStatus()) {
+ case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING:
+ case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING:
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
+ titleResId = R.string.message_title_downloading;
+ statusResId = R.string.message_status_downloading;
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD:
+ if (!OsUtil.isSecondaryUser()) {
+ titleResId = R.string.message_title_manual_download;
+ statusResId = R.string.message_status_download;
+ }
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE:
+ if (!OsUtil.isSecondaryUser()) {
+ titleResId = R.string.message_title_download_failed;
+ statusResId = R.string.message_status_download_error;
+ showInRed = true;
+ }
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED:
+ if (!OsUtil.isSecondaryUser()) {
+ titleResId = R.string.message_title_download_failed;
+ statusResId = R.string.message_status_download;
+ showInRed = true;
+ }
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND:
+ case MessageData.BUGLE_STATUS_OUTGOING_SENDING:
+ statusResId = R.string.message_status_sending;
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
+ case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
+ statusResId = R.string.message_status_send_retrying;
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
+ statusResId = R.string.message_status_send_failed_emergency_number;
+ showInRed = true;
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED:
+ // don't show the error state unless we're the default sms app
+ if (PhoneUtils.getDefault().isDefaultSmsApp()) {
+ statusResId = MmsUtils.mapRawStatusToErrorResourceId(
+ message.getStatus(), message.getRawTelephonyStatus());
+ showInRed = true;
+ break;
+ }
+ // FALL THROUGH HERE
+
+ case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE:
+ case MessageData.BUGLE_STATUS_INCOMING_COMPLETE:
+ default:
+ if (!message.getCanClusterWithNextMessage()) {
+ statusText = Dates.getWidgetTimeString(message.getReceivedTimeStamp(),
+ false /*abbreviated*/).toString();
+ }
+ break;
+ }
+
+ // Build the content description while we're populating the various fields.
+ final StringBuilder description = new StringBuilder();
+ final String separator = mContext.getString(R.string.enumeration_comma);
+ // Sender information
+ final boolean hasPlainTextMessage = !(TextUtils.isEmpty(message.getText()));
+ if (message.getIsIncoming()) {
+ int senderResId = hasPlainTextMessage
+ ? R.string.incoming_text_sender_content_description
+ : R.string.incoming_sender_content_description;
+ description.append(mContext.getString(senderResId, message.getSenderDisplayName()));
+ } else {
+ int senderResId = hasPlainTextMessage
+ ? R.string.outgoing_text_sender_content_description
+ : R.string.outgoing_sender_content_description;
+ description.append(mContext.getString(senderResId));
+ }
+
+ final boolean titleVisible = (titleResId >= 0);
+ if (titleVisible) {
+ final String titleText = mContext.getString(titleResId);
+ remoteViews.setTextViewText(R.id.message, titleText);
+
+ final String mmsInfoText = mContext.getString(
+ R.string.mms_info,
+ Formatter.formatFileSize(mContext, message.getSmsMessageSize()),
+ DateUtils.formatDateTime(
+ mContext,
+ message.getMmsExpiry(),
+ DateUtils.FORMAT_SHOW_DATE |
+ DateUtils.FORMAT_SHOW_TIME |
+ DateUtils.FORMAT_NUMERIC_DATE |
+ DateUtils.FORMAT_NO_YEAR));
+ remoteViews.setTextViewText(R.id.date, mmsInfoText);
+ description.append(separator);
+ description.append(mmsInfoText);
+ } else if (!TextUtils.isEmpty(messageText)) {
+ remoteViews.setTextViewText(R.id.message, messageText);
+ description.append(separator);
+ description.append(messageText);
+ } else {
+ remoteViews.setViewVisibility(R.id.message, View.GONE);
+ }
+
+ final String subjectText = MmsUtils.cleanseMmsSubject(mContext.getResources(),
+ message.getMmsSubject());
+ if (!TextUtils.isEmpty(subjectText)) {
+ description.append(separator);
+ description.append(subjectText);
+ }
+
+ if (statusResId >= 0) {
+ statusText = mContext.getString(statusResId);
+ final Spannable colorStr = new SpannableString(statusText);
+ if (showInRed) {
+ colorStr.setSpan(new ForegroundColorSpan(
+ mContext.getResources().getColor(R.color.timestamp_text_failed)),
+ 0, statusText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ remoteViews.setTextViewText(R.id.date, colorStr);
+ description.append(separator);
+ description.append(colorStr);
+ } else {
+ description.append(separator);
+ description.append(Dates.getWidgetTimeString(message.getReceivedTimeStamp(),
+ false /*abbreviated*/));
+ }
+
+ if (message.hasAttachments()) {
+ final List<MessagePartData> attachments = message.getAttachments();
+ int stringId;
+ for (MessagePartData part : attachments) {
+ if (part.isImage()) {
+ stringId = R.string.conversation_list_snippet_picture;
+ } else if (part.isVideo()) {
+ stringId = R.string.conversation_list_snippet_video;
+ } else if (part.isAudio()) {
+ stringId = R.string.conversation_list_snippet_audio_clip;
+ } else if (part.isVCard()) {
+ stringId = R.string.conversation_list_snippet_vcard;
+ } else {
+ stringId = 0;
+ }
+ if (stringId > 0) {
+ description.append(separator);
+ description.append(mContext.getString(stringId));
+ }
+ }
+ }
+ remoteViews.setContentDescription(message.getIsIncoming() ?
+ R.id.widget_message_item_incoming :
+ R.id.widget_message_item_outgoing, description);
+ }
+
+ private Bitmap getAttachmentBitmap(final MessagePartData part) {
+ UriImageRequestDescriptor descriptor;
+ if (part.isImage()) {
+ descriptor = new MessagePartImageRequestDescriptor(part,
+ IMAGE_ATTACHMENT_SIZE, // desiredWidth
+ IMAGE_ATTACHMENT_SIZE, // desiredHeight
+ true // isStatic
+ );
+ } else if (part.isVideo()) {
+ descriptor = new MessagePartVideoThumbnailRequestDescriptor(part);
+ } else {
+ return null;
+ }
+
+ final MediaRequest<ImageResource> imageRequest =
+ descriptor.buildSyncMediaRequest(mContext);
+ final ImageResource imageResource =
+ MediaResourceManager.get().requestMediaResourceSync(imageRequest);
+ if (imageResource != null && imageResource.getBitmap() != null) {
+ setImageResource(imageResource);
+ return Bitmap.createBitmap(imageResource.getBitmap());
+ } else {
+ releaseImageResource();
+ return null;
+ }
+ }
+
+ /**
+ * @return the "View more messages" view. When the user taps this item, they're
+ * taken to the conversation in Bugle.
+ */
+ @Override
+ protected RemoteViews getViewMoreItemsView() {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "getViewMoreConversationsView");
+ }
+ final RemoteViews view = new RemoteViews(mContext.getPackageName(),
+ R.layout.widget_loading);
+ view.setTextViewText(
+ R.id.loading_text, mContext.getText(R.string.view_more_messages));
+
+ // Tapping this "More messages" item should take us to the conversation.
+ final Intent intent = UIIntents.get().getIntentForConversationActivity(mContext,
+ mConversationId, null /* draft */);
+ view.setOnClickFillInIntent(R.id.widget_loading, intent);
+ return view;
+ }
+
+ @Override
+ public RemoteViews getLoadingView() {
+ final RemoteViews view = new RemoteViews(mContext.getPackageName(),
+ R.layout.widget_loading);
+ view.setTextViewText(
+ R.id.loading_text, mContext.getText(R.string.loading_messages));
+ return view;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 3; // Number of different list items that can be returned -
+ // 1- incoming list item
+ // 2- outgoing list item
+ // 3- more items list item
+ }
+
+ @Override
+ protected int getMainLayoutId() {
+ return R.layout.widget_conversation;
+ }
+
+ private void setImageResource(final ImageResource resource) {
+ if (mImageResource != resource) {
+ // Clear out any information for what is currently used
+ releaseImageResource();
+ mImageResource = resource;
+ }
+ }
+
+ private void releaseImageResource() {
+ if (mImageResource != null) {
+ mImageResource.release();
+ }
+ mImageResource = null;
+ }
+ }
+
+}